From 4c343e69b1faea2caa900795bd4b9290c627eff1 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 8 Aug 2026 10:33:33 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20installable-skills=20catalog=20?= =?UTF-8?q?=E2=80=94=20/skills=20available/install/update=20(rant=2010:14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host rant 2026-08-08T10:14:29 (revised design, supersedes the 10:11:35 registry draft): the recommended-skills list IS a skill. Implemented as specified — zero system.j2 change: - emrg/skills/skill-catalog.md — the catalog itself is a normal skill (frontmatter name/description for the loader + nested skills: list with the 5 metadata fields for the installer); loader picks it up via the existing mechanism → one line in Available Skills. - loader._parse_frontmatter — skip indented (nested) lines so the catalog's nested per-skill description cannot overwrite its own top-level description; deprecated recommended.md never loads as a skill. - emrg/skills/registry.py — catalog parse/ensure + .state.json (atomic, corrupt-tolerant). - emrg/skills/installer.py — host-confirmed CLI install (uv tool install --python 3.12 browser-harness), self-publish via ' skill', frontmatter validation with rollback, version from api.github.com releases/latest; update refreshes only managed=true files, never installs a CLI silently, never touches host-modified copies. - daemon — skills_available / skills_install / skills_update commands; background 24h TTL update loop (startup + every 24h, deterministic, no LLM); skill reload after install/update; startup fallback writes the catalog baseline if missing (upgrades/user deletion). - TUI /skills — available / install (yes/no confirm) / update. - packaging — baseline shipped in assets + build-runtime (offline machines still see /skills available). - tests: +35 (634 total); docs test counts + /skills docs synced. --- Agent.md | 4 +- README.cn.md | 4 +- README.md | 4 +- emrg/client/app.py | 116 ++++++++- emrg/server/daemon.py | 81 ++++++ emrg/skills/installer.py | 309 ++++++++++++++++++++++ emrg/skills/loader.py | 13 + emrg/skills/registry.py | 227 +++++++++++++++++ emrg/skills/skill-catalog.md | 28 ++ packaging/assets/skill-catalog.md | 28 ++ packaging/build-runtime.sh | 3 + tests/test_skills_registry.py | 411 ++++++++++++++++++++++++++++++ 12 files changed, 1213 insertions(+), 15 deletions(-) create mode 100644 emrg/skills/installer.py create mode 100644 emrg/skills/registry.py create mode 100644 emrg/skills/skill-catalog.md create mode 100644 packaging/assets/skill-catalog.md create mode 100644 tests/test_skills_registry.py diff --git a/Agent.md b/Agent.md index 8dc93575..f2564291 100644 --- a/Agent.md +++ b/Agent.md @@ -22,7 +22,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - `tool_types.py` — Tool type definitions (ToolDefinition, ToolResult) - `evolution_prompt.md` — Evolution prompt template - `emrg/tools/` — Tool implementations (bash, read, write, edit, glob, grep, base + registry) -- `emrg/skills/` — Dynamically loaded skill modules (skills, progressive disclosure) +- `emrg/skills/` — Dynamically loaded skill modules (skills, progressive disclosure) + installable-skills catalog (`skill-catalog.md`, `/skills available|install|update`) - `emrg/client/` — Client (TUI interface based on inlined python-tui) - `daemon_manager.py` — Daemon lifecycle (start/restart-if-stale/ensure-connected) + protocol client (DaemonConnection: send_task/send_command/recv/read_stream) — shared with GUI (Phase 3) - `app.py` — Main entry, event loop, ChatHistory widget, command autocomplete, session selector @@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (599) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (634) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/README.cn.md b/README.cn.md index 8203255b..c8a1fbd0 100644 --- a/README.cn.md +++ b/README.cn.md @@ -221,7 +221,7 @@ vision = true | `/delete [id]` | 删除会话——不带参数进入交互式选择器 | | `/rewind` | 回退对话——选择历史消息点,截断后续内容 | | `/trigger` | 触发演化任务——交互式选择器(↑↓/j/k) | -| `/skills` | 列出已加载的技能模块 | +| `/skills` | 列出已加载技能(含 skill-catalog);`/skills available`=可安装目录,`/skills install `=安装,`/skills update`=刷新受管技能 | | `/version` | 显示 EMRG 版本和实例信息 | | `Esc` | 中断正在运行的响应 | | `Ctrl+C` / `exit` | 退出 | @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 599 项) +uv run pytest tests/ -v # 跑测试(当前 634 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index 1e9d68e4..1b342782 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ vision = true | `/delete [id]` | Delete a session — no args for interactive picker | | `/rewind` | Rewind conversation — pick a history point and truncate after it | | `/trigger` | Trigger an evolution task — interactive picker (↑↓/j/k) | -| `/skills` | List loaded skill modules | +| `/skills` | List loaded skills (incl. skill-catalog); `/skills available` = installable catalog, `/skills install ` = install, `/skills update` = refresh managed skills | | `/version` | Show EMRG version and instance info | | `Esc` | Interrupt a running response mid-stream | | `Ctrl+C` / `exit` | Quit | @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 599 items) +uv run pytest tests/ -v # run tests (currently 634 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/client/app.py b/emrg/client/app.py index 697dbe92..892bc0a4 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -266,6 +266,7 @@ async def _run_elapsed_timer() -> None: rewind_sel = SelectorState() task_sel = SelectorState() _rant_project: str | None = None # Set after project selection, used on next Enter + _skills_confirm: tuple | None = None # (skill_name, install_cmd) — next Enter answers the prompt # Command autocomplete state (shows dropdown when user types /) _autocomplete_active = False @@ -699,6 +700,71 @@ async def _reconnect(): term.render() continue + # Skills available result (installable-skills catalog) + if data.get("type") == "skills_available_result": + skills = data.get("skills", []) + err = data.get("error", "") + if err: + chat.add("system", f"Error: {err}") + elif not skills: + chat.add("system", "No catalog skills found. Check ~/.emrg/skills/skill-catalog.md") + else: + lines = ["**Available Skills (catalog):**", ""] + for s in skills: + mark = "✅ installed" if s.get("installed") else "not installed" + if s.get("managed"): + mark += " · managed" + lines.append(f"- **{s.get('name', '?')}** — {s.get('description', '')} ({mark})") + lines.append("") + lines.append("Install: `/skills install ` · Refresh: `/skills update`") + chat.add("system", "\n".join(lines)) + term.render() + continue + + # Skills install result + if data.get("type") == "skills_install_result": + nonlocal _skills_confirm + name = data.get("name", "") + if data.get("confirm_required"): + cmd = data.get("install_command", "") + _skills_confirm = (name, cmd) + chat.add("system", + f"⚠️ Skill `{name}` needs its CLI installed first:\n" + f"`{cmd}`\n\n" + f"Type `yes` to confirm, or anything else to cancel.") + elif data.get("error"): + chat.add("system", f"Install failed for `{name}`: {data['error']}") + elif data.get("ok"): + chat.add("system", + f"✅ Skill `{name}` installed" + + (f" (v{data.get('version', '?')})" if data.get("version") else "") + + ". It will appear in the next session's Available Skills.") + term.render() + continue + + # Skills update result + if data.get("type") == "skills_update_result": + err = data.get("error", "") + checked = data.get("checked", 0) + updated = data.get("updated", []) + skipped = data.get("skipped", []) + errors = data.get("errors", []) + if err: + chat.add("system", f"Skill update failed: {err}") + else: + lines = [f"**Skill update check:** {checked} managed skill(s)"] + if updated: + lines.append(f"Updated: {', '.join(updated)}") + if skipped: + lines.append(f"Skipped (CLI missing): {', '.join(skipped)}") + if errors: + lines.append(f"Failed: {', '.join(errors)}") + if not updated and not skipped and not errors: + lines.append("All up to date.") + chat.add("system", "\n".join(lines)) + term.render() + continue + # Resume result if data.get("type") == "resume_result": err = data.get("error", "") @@ -952,6 +1018,7 @@ async def handle_key(data: bytes) -> bool: nonlocal history_index, history_saved_input nonlocal _autocomplete_active, _autocomplete_widget nonlocal _request_start, _last_center, _elapsed_task, _pending_images + nonlocal _skills_confirm if len(data) == 0: return True if data == b"\x1b[200~": paste_mode = True; return True if data == b"\x1b[201~": @@ -1354,6 +1421,19 @@ async def handle_key(data: bytes) -> bool: inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True + # Pending /skills install confirmation — next line is the answer + if _skills_confirm is not None: + name, cmd = _skills_confirm + _skills_confirm = None + if text.lower() in ("y", "yes"): + await conn.send_command("skills_install", name=name, confirmed=True) + chat.add("system", f"Confirmed — installing `{name}` (CLI: `{cmd}`)…") + else: + chat.add("system", "Install cancelled.") + status.update(center=server_id or "emrg") + inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() + return True + # Handle /memory command if text.lower().startswith("/memory"): parts = text.split(None, 1) @@ -1435,16 +1515,34 @@ async def handle_key(data: bytes) -> bool: inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True - # Handle /skills command - if text.lower() == "/skills": - skills = load_skills() - if skills: - lines = ["**Loaded Skills:**", ""] - for s in skills: - lines.append(f"- **{s.name}** ({s.source}) — {s.description}") - chat.add("system", "\n".join(lines)) + # Handle /skills command (list / available / install / update) + if text.lower().startswith("/skills"): + parts = text.split(None, 1) + sub = parts[1].strip() if len(parts) > 1 else "" + sub_l = sub.lower() + if sub_l == "available": + # Installable-skills catalog (rant 2026-08-08T10:14:29) + await conn.send_command("skills_available") + status.update(center="checking available skills…") + elif sub_l.startswith("install "): + name = sub[8:].strip() + if not name: + chat.add("system", "Usage: /skills install ") + else: + await conn.send_command("skills_install", name=name, confirmed=False) + status.update(center=f"installing {name}…") + elif sub_l == "update": + await conn.send_command("skills_update") + status.update(center="checking skill updates…") else: - chat.add("system", "No skills loaded. Add .md files to ~/.emrg/skills/ or .emrg/skills/") + skills = load_skills() + if skills: + lines = ["**Loaded Skills:**", ""] + for s in skills: + lines.append(f"- **{s.name}** ({s.source}) — {s.description}") + chat.add("system", "\n".join(lines)) + else: + chat.add("system", "No skills loaded. Add .md files to ~/.emrg/skills/ or .emrg/skills/") inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index e4f7e4fb..65182f85 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -113,6 +113,7 @@ def _redact(value): from emrg.tools.glob_tool import GlobTool from emrg.tools.grep_tool import GrepTool from emrg.skills.loader import load_skills +from emrg.skills.registry import ensure_catalog_file, load_catalog_skills, skill_is_managed from emrg.server.scheduler import TaskScheduler logger = logging.getLogger(__name__) @@ -148,6 +149,13 @@ def __init__(self, llm_config: LlmConfig) -> None: runtime_dir.mkdir(parents=True, exist_ok=True) # Ensure skills directory exists for evolution-installed skills (runtime_dir / "skills").mkdir(exist_ok=True) + # Installable-skills catalog baseline (rant 2026-08-08T10:14:29): + # the catalog is itself a skill (skill-catalog.md); on upgrades or + # user deletion the daemon re-writes it from the embedded baseline. + try: + ensure_catalog_file() + except Exception: + logger.debug("could not ensure skill catalog", exc_info=True) host_name = platform.node() self.identity = InstanceIdentity( @@ -269,11 +277,22 @@ async def serve(self) -> None: self._scheduler = TaskScheduler(self.identity) self._scheduler.load_and_start() + # Background deterministic skill update check (rant 2026-08-08T10:14:29): + # runs at startup + every 24h — refreshes managed skills to their + # latest GitHub releases. Never installs a CLI silently, never touches + # host-modified skill copies. + self._skills_ttl_task = asyncio.create_task(self._skills_ttl_loop()) + try: await self._server.serve_forever() except asyncio.CancelledError: pass finally: + self._skills_ttl_task.cancel() + try: + await self._skills_ttl_task + except (asyncio.CancelledError, Exception): + pass self._scheduler.stop_all() await self._scheduler.wait_all() await self.llm.close() @@ -286,6 +305,25 @@ async def serve(self) -> None: except OSError: pass + async def _skills_ttl_loop(self) -> None: + """Background deterministic skill update check (startup + every 24h). + + Design (rant 2026-08-08T10:14:29 §6): the check is deterministic + logic, not LLM thinking — on each tick, refresh every managed=true + skill whose latest GitHub release differs from the recorded version. + Failures are logged at debug level and never crash the daemon. + """ + from emrg.skills.installer import _UPDATE_TTL_SECONDS, run_update_check_once + + while True: + result = await run_update_check_once() + if result.get("updated"): + logger.info("skills auto-updated: %s", result["updated"]) + self.skills = load_skills() + elif result.get("errors"): + logger.warning("skills update errors: %s", result["errors"]) + await asyncio.sleep(_UPDATE_TTL_SECONDS) + def _evolution_count(self) -> int: """Total completed evolution cycles across scheduler handlers + disk. @@ -1165,6 +1203,49 @@ async def _process_message( count, f" project={project}" if project else "", _redact_string(rant_message[:100])) await self._send(ws, {"ok": True, "count": count}) + elif msg_type == "skills_available": + # Installable-skills catalog (rant 2026-08-08T10:14:29): list + # catalog skills with installed/managed status. + entries = load_catalog_skills() + installed = {s.name for s in self.skills} + result = [ + { + "name": e.get("name", ""), + "description": e.get("description", ""), + "installed": e.get("name", "") in installed, + "managed": skill_is_managed(e.get("name", "")), + } + for e in entries + ] + await self._send(ws, {"type": "skills_available_result", "skills": result}) + + elif msg_type == "skills_install": + # /skills install — host-confirmed CLI install, then + # self-publish skill files into ~/.emrg/skills/. + from emrg.skills.installer import install_skill + + name = msg.get("name", "").strip() + confirmed = bool(msg.get("confirmed", False)) + if not name: + await self._send(ws, { + "type": "skills_install_result", + "error": "skills_install requires a skill name", + }) + return + result = await install_skill(name, confirmed=confirmed) + if result.get("ok"): + # Reload so the next system-prompt build includes the skill + # (design: "下次构建系统提示即含该技能", no daemon restart needed). + self.skills = load_skills() + await self._send(ws, {"type": "skills_install_result", "name": name, **result}) + + elif msg_type == "skills_update": + # /skills update — refresh managed skills to latest releases. + from emrg.skills.installer import update_managed_skills + + result = await update_managed_skills() + await self._send(ws, {"type": "skills_update_result", **result}) + elif msg_type == "list_models": await self._handle_list_models(ws) diff --git a/emrg/skills/installer.py b/emrg/skills/installer.py new file mode 100644 index 00000000..fe9301cb --- /dev/null +++ b/emrg/skills/installer.py @@ -0,0 +1,309 @@ +"""Install/update skills from the catalog — deterministic, no LLM. + +Revised design (rant 2026-08-08T10:14:29, supersedes the 10:11:35 +registry design): the installable list lives in the normal skill file +``~/.emrg/skills/skill-catalog.md``; the installer reads its frontmatter +``skills:`` list for the 5 metadata fields (name/description/repo/ +install/dest/check). + +Install flow (``/skills install ``, host-confirmed): + +1. look up the catalog entry +2. ensure the CLI exists: ``uv tool install --python 3.12 `` — + first-time CLI install requires explicit host confirmation + (MANIFESTO host-rights §10: the TUI surfaces a yes/no prompt; the + background update path never installs a CLI silently) +3. run `` skill`` to self-publish the skill file(s) — EMRG does not + need to know the file list in advance +4. write the published output into dest (``~/.emrg/skills/``) +5. validate the frontmatter (name + description) — roll back on failure +6. record ``{name: {version, installed_at, managed: true}}`` in + ``~/.emrg/skills/.state.json`` + +Update check (daemon startup + every 24h, background deterministic): + +- for each ``managed: true`` entry, compare the latest GitHub release tag + (via api.github.com — raw.githubusercontent.com / github.com:443 may be + blocked on the host network) with the recorded version +- tag differs → re-run the publish step (CLI already present, so no host + confirmation); never touch host-modified copies (not managed) + +The PyPI package name is hardcoded in the installer logic per design +("uv tool install --python 3.12 browser-harness" — it does not go into +the catalog). +""" + +from __future__ import annotations + +import asyncio +import logging +import shutil +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Awaitable, Callable, Optional + +from emrg.config import config_dir +from emrg.skills.registry import ( + find_catalog_skill, + read_state, + write_state, +) + +logger = logging.getLogger(__name__) + +CLI_NAME = "browser-harness" +CLI_PYPI_PACKAGE = "browser-harness" +CLI_INSTALL_CMD = ["uv", "tool", "install", "--python", "3.12", CLI_PYPI_PACKAGE] +GITHUB_API = "https://api.github.com" +_UPDATE_TTL_SECONDS = 24 * 3600 + +# Catalog "install" values we know how to drive. Anything else is refused +# with a clear error (capability passport style: catalog is a decision aid). +_KNOWN_INSTALL_KINDS = ("self-publishing",) + + +@dataclass +class CmdResult: + """Result of a subprocess run (returncode + merged stdout/stderr).""" + + returncode: int + stdout: str + + +Runner = Callable[..., Awaitable[CmdResult]] +HttpGet = Callable[[str], Awaitable[Optional[dict]]] + + +async def _default_runner(cmd: list[str], **kwargs) -> CmdResult: + """Run a command via asyncio subprocess (captures merged output).""" + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + **kwargs, + ) + out, _ = await proc.communicate() + return CmdResult(proc.returncode or 0, out.decode("utf-8", "replace")) + + +async def _default_http_get(url: str) -> Optional[dict]: + """GET a JSON endpoint via httpx (used for GitHub release checks).""" + import httpx + + async with httpx.AsyncClient( + timeout=15.0, + headers={"User-Agent": "emrg-skill-catalog", "Accept": "application/vnd.github+json"}, + ) as client: + resp = await client.get(url) + if resp.status_code != 200: + return None + return resp.json() + + +def cli_available() -> bool: + """True when the CLI executable is on PATH.""" + return shutil.which(CLI_NAME) is not None + + +async def _fetch_latest_tag(repo: str, http_get: Optional[HttpGet]) -> Optional[str]: + """Latest release tag for a repo, via api.github.com (None on failure). + + Strips a leading "v" so "v0.1.8" compares against a state version + recorded as "0.1.8". + """ + get = http_get or _default_http_get + try: + data = await get(f"{GITHUB_API}/repos/{repo}/releases/latest") + except Exception: + logger.debug("release check failed for %s", repo, exc_info=True) + return None + if not isinstance(data, dict): + return None + tag = data.get("tag_name") + if not isinstance(tag, str) or not tag: + return None + return tag[1:] if tag.startswith("v") else tag + + +async def _publish_skill(entry: dict, runner: Optional[Runner]) -> dict: + """Run `` skill`` and write its output into dest. + + Returns {"ok": True, "path": ..., "name": ...} or {"error": ...}. + Writes are rollback-safe: on validation failure the freshly written + file is removed. + """ + install_kind = entry.get("install", "") + if install_kind not in _KNOWN_INSTALL_KINDS: + return {"error": f"unsupported install kind: {install_kind!r}"} + + dest = _resolve_dest(entry.get("dest", "~/.emrg/skills/")) + run = runner or _default_runner + try: + result = await run([CLI_NAME, "skill"]) + except FileNotFoundError: + return {"error": f"{CLI_NAME} CLI not found on PATH"} + if result.returncode != 0: + return {"error": f"{CLI_NAME} skill failed (exit {result.returncode})"} + + skill_text = result.stdout.strip() + if not skill_text: + return {"error": f"{CLI_NAME} skill produced empty output"} + + # Validate the published file has a name+description frontmatter + # (reuse the loader's parser — no new YAML dependency). + from emrg.skills.loader import _parse_frontmatter + + fm = _parse_frontmatter(skill_text) if skill_text.startswith("---") else {} + name = fm.get("name", "") + description = fm.get("description", "") + if not name or not description: + return {"error": "published skill missing name/description frontmatter"} + + dest.mkdir(parents=True, exist_ok=True) + target = dest / f"{name}.md" + try: + target.write_text(skill_text + "\n", encoding="utf-8") + except OSError as e: + return {"error": f"cannot write skill file: {e}"} + return {"ok": True, "path": str(target), "name": name} + + +def _resolve_dest(dest: str) -> Path: + """Resolve a catalog dest value. + + ``~/.emrg/...`` routes through ``config_dir()`` (the single source of + truth for EMRG's runtime dir — tests redirect it via monkeypatch); + any other ``~`` path expands against the real home directory. + """ + if dest.startswith("~"): + rel = dest[1:].lstrip("/") + if rel.startswith(".emrg/"): + return config_dir() / rel[len(".emrg/"):] + return Path.home() / rel + return Path(dest) + + +async def install_skill( + name: str, + *, + confirmed: bool = False, + runner: Optional[Runner] = None, + http_get: Optional[HttpGet] = None, +) -> dict: + """Install a catalog skill by name (host-confirmed CLI install). + + Returns one of: + {"error": ...} — unknown skill / failed + {"confirm_required": True, ...} — CLI missing, host must confirm + {"ok": True, "name", "version", "installed_at"} — done + """ + entry = find_catalog_skill(name) + if entry is None: + return {"error": f"unknown catalog skill: {name!r}"} + + if not cli_available(): + if not confirmed: + return { + "confirm_required": True, + "name": name, + "install_command": " ".join(CLI_INSTALL_CMD), + "message": ( + f"Skill {name!r} needs its CLI installed first: " + f"`{' '.join(CLI_INSTALL_CMD)}`" + ), + } + run = runner or _default_runner + try: + result = await run(CLI_INSTALL_CMD) + except FileNotFoundError: + return {"error": "uv not found on PATH — cannot install CLI"} + if result.returncode != 0: + return {"error": f"CLI install failed (exit {result.returncode}): {result.stdout[-300:]}"} + if not cli_available(): + return {"error": "CLI install finished but command not found on PATH"} + + published = await _publish_skill(entry, runner) + if "error" in published: + return {"error": published["error"]} + + # Record state: version from the latest GitHub release (best effort), + # installed_at now, managed=True so the 24h check can refresh it. + latest = await _fetch_latest_tag(entry.get("repo", ""), http_get) + version = latest or "unknown" + now = datetime.now().astimezone().isoformat(timespec="seconds") + state = read_state() + state[name] = { + "version": version, + "installed_at": now, + "updated_at": now, + "managed": True, + } + write_state(state) + + return { + "ok": True, + "name": name, + "version": version, + "installed_at": now, + "path": published.get("path", ""), + } + + +async def update_managed_skills( + *, + runner: Optional[Runner] = None, + http_get: Optional[HttpGet] = None, +) -> dict: + """Refresh managed skills whose latest GitHub release differs. + + Background-deterministic: never installs a missing CLI silently, never + touches non-managed (host-modified) skill files. Returns a summary: + {"checked": int, "updated": [names], "skipped": [names], "errors": [names]} + """ + state = read_state() + managed = {k: v for k, v in state.items() if v.get("managed")} + updated: list[str] = [] + skipped: list[str] = [] + errors: list[str] = [] + + for name, info in managed.items(): + entry = find_catalog_skill(name) + if entry is None: + continue + latest = await _fetch_latest_tag(entry.get("repo", ""), http_get) + if latest is None: + continue # network/API failure — try again next cycle + if latest == info.get("version"): + continue # up to date + if not cli_available(): + skipped.append(name) # never install a CLI in the background + continue + published = await _publish_skill(entry, runner) + if "error" in published: + errors.append(name) + logger.warning("skill update failed for %s: %s", name, published["error"]) + continue + now = datetime.now().astimezone().isoformat(timespec="seconds") + info["version"] = latest + info["updated_at"] = now + updated.append(name) + + if updated or errors: + write_state(state) + + return { + "checked": len(managed), + "updated": updated, + "skipped": skipped, + "errors": errors, + } + + +async def run_update_check_once() -> dict: + """One-shot update check (used by the daemon TTL loop).""" + try: + return await update_managed_skills() + except Exception: + logger.debug("skills update check failed", exc_info=True) + return {"checked": 0, "updated": [], "skipped": [], "errors": [], "error": "update check failed"} diff --git a/emrg/skills/loader.py b/emrg/skills/loader.py index 87088d74..e47df5b7 100644 --- a/emrg/skills/loader.py +++ b/emrg/skills/loader.py @@ -43,10 +43,16 @@ def _parse_frontmatter(text: str) -> dict[str, str]: """Parse simple key: value YAML frontmatter. Handles quoted strings and plain values. No nested structures. + Indented (nested) lines are ignored so a skill whose frontmatter also + carries a nested metadata list (skill-catalog.md, rant + 2026-08-08T10:14:29) keeps its own top-level name/description — + nested ``description:`` keys must not overwrite the top-level one. This avoids adding pyyaml as a dependency for the simple format. """ result: dict[str, str] = {} for line in text.split("\n"): + if line[:1].isspace(): # indented → nested YAML, not a top-level key + continue line = line.strip() if not line or line.startswith("#"): continue @@ -63,6 +69,13 @@ def _parse_frontmatter(text: str) -> dict[str, str]: def _parse_skill_file(file_path: Path, source: str) -> Optional[Skill]: """Parse a single skill .md file. Returns None if parsing fails.""" + # Defensive: the deprecated registry file (superseded 2026-08-08T10:14:29 + # by skill-catalog.md) would parse as a bogus skill named "recommended" + # — never load it as one. + if file_path.name == "recommended.md": + logger.debug("skill: skipping deprecated registry file %s", file_path) + return None + try: text = file_path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): diff --git a/emrg/skills/registry.py b/emrg/skills/registry.py new file mode 100644 index 00000000..89c512af --- /dev/null +++ b/emrg/skills/registry.py @@ -0,0 +1,227 @@ +"""Installable-skills catalog — parse ``~/.emrg/skills/skill-catalog.md``. + +Revised design (rant 2026-08-08T10:14:29, supersedes the 10:11:35 +registry design): the recommended-skills list is itself a normal skill +file named ``skill-catalog.md``. The existing skill loader already picks +it up (name + description → one line in the system prompt's Available +Skills), and the LLM reads its body for install/update guidance. No new +meta-mechanism, no system.j2 change. + +The frontmatter carries both loader fields (``name``/``description``, +which the loader reads and ignores everything else) and machine +metadata for ``/skills available/install/update`` (a ``skills:`` list of +name/description/repo/install/dest/check — same 5 metadata fields as the +original design, still no file lists). Install/update state lives in +``~/.emrg/skills/.state.json`` — ``managed: true`` marks catalog-managed +files that the 24h update check may refresh; unmarked files are treated +as host-modified and never touched. +""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +from pathlib import Path + +from emrg.config import config_dir + +logger = logging.getLogger(__name__) + +# Canonical baseline — must match emrg/skills/skill-catalog.md +# (tests/test_skills_registry.py asserts byte-equality so the embedded +# fallback never drifts from the shipped source file). +BASELINE_CATALOG_MD = """--- +name: skill-catalog +description: "Catalog of optional installable skills (browser-harness, etc.). Read this file when a task needs a capability you don't have — it lists what is installable, how to install, and how updates are checked." +skills: + - name: browser-harness + description: "Direct browser control via CDP: automation, scraping, testing, site work." + repo: browser-use/browser-harness + install: self-publishing + dest: ~/.emrg/skills/ + check: github_release +--- + +# Installable Skills + +This catalog lists optional skills that are NOT installed by default. When +a task needs one (e.g. browser interaction), install it on demand: + +## browser-harness + +- **What it does**: Direct browser control via CDP — automation, scraping, testing, site work. +- **How to install**: `/skills install browser-harness` +- **Source repo**: browser-use/browser-harness +- **Install method**: self-publishing (CLI's own `skill` command emits the skill files) +- **Install destination**: `~/.emrg/skills/` +- **Update check**: GitHub release tag (api.github.com) + +(New recommended skills get a section appended here — adding a skill only +touches this file, the system prompt never changes.) +""" + +# Frontmatter keys a valid catalog entry must carry. +REQUIRED_ENTRY_FIELDS = ("name", "description", "repo", "install", "dest", "check") + +CATALOG_FILENAME = "skill-catalog.md" + + +def catalog_path() -> Path: + """Path of the installable-skills catalog file.""" + return config_dir() / "skills" / CATALOG_FILENAME + + +def state_path() -> Path: + """Path of the skills install/update state file.""" + return config_dir() / "skills" / ".state.json" + + +def ensure_catalog_file() -> Path: + """Write the bundled baseline catalog if missing (daemon startup fallback). + + Covers upgrades and user deletions: a clean install gets the catalog + from the packaging bundle, but the daemon re-writes it from the + embedded baseline when it is absent (log INFO, never disturbs the + host). + """ + path = catalog_path() + if not path.exists(): + try: + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_text(BASELINE_CATALOG_MD, path) + logger.info("wrote baseline skill catalog: %s", path) + except OSError: + logger.warning("could not write %s", path, exc_info=True) + return path + + +def parse_skills_frontmatter(text: str) -> list[dict]: + """Parse the ``skills:`` list from the catalog frontmatter. + + Handles the simple indented list-of-maps format used by the baseline + (avoids pulling in a YAML dependency for this fixed structure): + + .. code-block:: yaml + + skills: + - name: browser-harness + description: "..." + repo: ... + install: ... + dest: ... + check: ... + + Top-level ``name``/``description`` (loader fields) are ignored. + Returns a list of dicts (5 metadata fields each); malformed entries + are skipped. + """ + if not text.startswith("---"): + return [] + parts = text.split("---", 2) + if len(parts) < 3: + return [] + fm_lines = parts[1].splitlines() + + entries: list[dict] = [] + current: dict[str, str] | None = None + in_skills = False + for raw in fm_lines: + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped == "skills:": + in_skills = True + continue + if not in_skills: + continue + if stripped.startswith("- "): # new entry: "- name: X" + if current is not None: + entries.append(current) + current = {} + key, _, value = stripped[2:].partition(":") + current[key.strip()] = _strip_quotes(value.strip()) + elif current is not None and ":" in stripped: + key, _, value = stripped.partition(":") + current[key.strip()] = _strip_quotes(value.strip()) + if current is not None: + entries.append(current) + + # Keep only entries with all required fields + return [e for e in entries if all(k in e for k in REQUIRED_ENTRY_FIELDS)] + + +def _strip_quotes(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + return value[1:-1] + return value + + +def load_catalog_skills() -> list[dict]: + """Read the catalog's installable-skill list (empty when missing).""" + path = catalog_path() + if not path.exists(): + return [] + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + logger.debug("cannot read %s", path) + return [] + return parse_skills_frontmatter(text) + + +def find_catalog_skill(name: str) -> dict | None: + """Look up a single catalog entry by name.""" + for entry in load_catalog_skills(): + if entry.get("name") == name: + return entry + return None + + +# ── install/update state (.state.json) ─────────────────────────────── + +def read_state() -> dict[str, dict]: + """Read skill state: {name: {version, installed_at, managed, ...}}.""" + path = state_path() + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + return {k: v for k, v in data.items() if isinstance(v, dict)} + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + logger.debug("cannot parse %s — treating as empty", path) + return {} + + +def write_state(state: dict[str, dict]) -> None: + """Atomically write skill state.""" + path = state_path() + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_text( + json.dumps(state, ensure_ascii=False, indent=2) + "\n", + path, + ) + + +def skill_is_managed(name: str) -> bool: + """True when a skill is tracked by the catalog update check.""" + return bool(read_state().get(name, {}).get("managed")) + + +def _atomic_write_text(data: str, target: Path) -> None: + """Write text via temp file + os.replace (atomic, no partial reads).""" + fd, tmp_path = tempfile.mkstemp( + dir=str(target.parent), prefix=".atomic_", suffix=".tmp" + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(data) + os.replace(tmp_path, target) + except OSError: + logger.warning("atomic write failed for %s", target, exc_info=True) + try: + os.unlink(tmp_path) + except OSError: + pass diff --git a/emrg/skills/skill-catalog.md b/emrg/skills/skill-catalog.md new file mode 100644 index 00000000..c9e11b0f --- /dev/null +++ b/emrg/skills/skill-catalog.md @@ -0,0 +1,28 @@ +--- +name: skill-catalog +description: "Catalog of optional installable skills (browser-harness, etc.). Read this file when a task needs a capability you don't have — it lists what is installable, how to install, and how updates are checked." +skills: + - name: browser-harness + description: "Direct browser control via CDP: automation, scraping, testing, site work." + repo: browser-use/browser-harness + install: self-publishing + dest: ~/.emrg/skills/ + check: github_release +--- + +# Installable Skills + +This catalog lists optional skills that are NOT installed by default. When +a task needs one (e.g. browser interaction), install it on demand: + +## browser-harness + +- **What it does**: Direct browser control via CDP — automation, scraping, testing, site work. +- **How to install**: `/skills install browser-harness` +- **Source repo**: browser-use/browser-harness +- **Install method**: self-publishing (CLI's own `skill` command emits the skill files) +- **Install destination**: `~/.emrg/skills/` +- **Update check**: GitHub release tag (api.github.com) + +(New recommended skills get a section appended here — adding a skill only +touches this file, the system prompt never changes.) diff --git a/packaging/assets/skill-catalog.md b/packaging/assets/skill-catalog.md new file mode 100644 index 00000000..c9e11b0f --- /dev/null +++ b/packaging/assets/skill-catalog.md @@ -0,0 +1,28 @@ +--- +name: skill-catalog +description: "Catalog of optional installable skills (browser-harness, etc.). Read this file when a task needs a capability you don't have — it lists what is installable, how to install, and how updates are checked." +skills: + - name: browser-harness + description: "Direct browser control via CDP: automation, scraping, testing, site work." + repo: browser-use/browser-harness + install: self-publishing + dest: ~/.emrg/skills/ + check: github_release +--- + +# Installable Skills + +This catalog lists optional skills that are NOT installed by default. When +a task needs one (e.g. browser interaction), install it on demand: + +## browser-harness + +- **What it does**: Direct browser control via CDP — automation, scraping, testing, site work. +- **How to install**: `/skills install browser-harness` +- **Source repo**: browser-use/browser-harness +- **Install method**: self-publishing (CLI's own `skill` command emits the skill files) +- **Install destination**: `~/.emrg/skills/` +- **Update check**: GitHub release tag (api.github.com) + +(New recommended skills get a section appended here — adding a skill only +touches this file, the system prompt never changes.) diff --git a/packaging/build-runtime.sh b/packaging/build-runtime.sh index ee816d1b..58a1726a 100755 --- a/packaging/build-runtime.sh +++ b/packaging/build-runtime.sh @@ -99,6 +99,9 @@ fi # ── 5. assets/LICENSE/version ── cp "$ROOT/LICENSE" "$DIST/assets/LICENSE" 2>/dev/null || echo "Apache-2.0" > "$DIST/assets/LICENSE" +# Installable-skills catalog baseline (rant 2026-08-08T10:14:29): ship it in +# the runtime so installed machines have it even before daemon first run. +cp "$ROOT/packaging/assets/skill-catalog.md" "$DIST/assets/skill-catalog.md" 2>/dev/null || true "$PY_BIN" -c "import emrg,sys; sys.path.insert(0,'$DIST/source'); import emrg as e; print(e.__version__)" > "$DIST/version.txt" 2>/dev/null \ || echo "0.2.12" > "$DIST/version.txt" diff --git a/tests/test_skills_registry.py b/tests/test_skills_registry.py new file mode 100644 index 00000000..1b7b13b5 --- /dev/null +++ b/tests/test_skills_registry.py @@ -0,0 +1,411 @@ +"""Tests for the installable-skills catalog (rant 2026-08-08T10:14:29). + +The catalog is itself a skill (skill-catalog.md) — the existing loader +picks it up, the system prompt is untouched (zero j2 change). Covers: +baseline parsing, embedded-baseline == shipped-file invariant, daemon +startup fallback, catalog-as-skill loading, deprecated recommended.md +skip, host-confirmed install flow (positive/negative), managed-only +update (positive/negative), and corrupt-state tolerance. +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +import pytest + +import emrg.skills.installer as installer +import emrg.skills.registry as registry +from emrg.skills.loader import _parse_frontmatter, _parse_skill_file, load_skills +from emrg.skills.registry import ( + BASELINE_CATALOG_MD, + ensure_catalog_file, + find_catalog_skill, + load_catalog_skills, + parse_skills_frontmatter, + read_state, + skill_is_managed, + write_state, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent +SHIPPED_CATALOG = REPO_ROOT / "emrg" / "skills" / "skill-catalog.md" + + +# ── helpers ────────────────────────────────────────────────────────── + +class FakeRunner: + """Controllable asyncio subprocess runner (FakeGitRun-style).""" + + def __init__(self, skill_output: str = "", cli_install_rc: int = 0): + self.calls: list[list[str]] = [] + self.skill_output = skill_output + self.cli_install_rc = cli_install_rc + + async def __call__(self, cmd, **kwargs): + self.calls.append(list(cmd)) + if cmd[0] == "uv" and cmd[1:3] == ["tool", "install"]: + return installer.CmdResult(self.cli_install_rc, "installed\n") + if cmd == ["browser-harness", "skill"]: + return installer.CmdResult(0, self.skill_output) + return installer.CmdResult(0, "") + + +class FakeHttp: + """Controllable api.github.com release responder.""" + + def __init__(self, tag: str | None = "v0.1.8"): + self.tag = tag + + async def __call__(self, url): + if url.endswith("/releases/latest"): + if self.tag is None: + return None + return {"tag_name": self.tag} + return None + + +@pytest.fixture +def tmp_home(tmp_path, monkeypatch): + """Point config_dir() at a temp dir for all catalog paths. + + Patches BOTH the registry module's and the installer module's + config_dir binding (installer._resolve_dest consults its own), so no + test ever touches the real ~/.emrg/skills/. + """ + import emrg.skills.loader as loader + + # mimic the real config_dir() shape (~/.emrg) so all paths line up + emrg_dir = tmp_path / ".emrg" + monkeypatch.setattr(registry, "config_dir", lambda: emrg_dir) + monkeypatch.setattr(installer, "config_dir", lambda: emrg_dir) + # isolate the loader's user-skill dir (~/.emrg/skills) from the real host + monkeypatch.setattr(loader.Path, "home", staticmethod(lambda: tmp_path)) + return tmp_path + + +@pytest.fixture +def no_cli(monkeypatch): + monkeypatch.setattr(installer, "cli_available", lambda: False) + + +@pytest.fixture +def with_cli(monkeypatch): + monkeypatch.setattr(installer, "cli_available", lambda: True) + + +def _run(coro): + return asyncio.run(coro) + + +VALID_SKILL_MD = """--- +name: browser-harness +description: "Direct browser control via CDP: automation, scraping, testing, site work." +--- + +# browser-harness + +Body text. +""" + + +# ── catalog parsing ────────────────────────────────────────────────── + +class TestParseFrontmatter: + def test_baseline_parses_one_entry_with_all_fields(self): + entries = parse_skills_frontmatter(BASELINE_CATALOG_MD) + assert len(entries) == 1 + e = entries[0] + assert e["name"] == "browser-harness" + assert e["description"].startswith("Direct browser control via CDP") + assert e["repo"] == "browser-use/browser-harness" + assert e["install"] == "self-publishing" + assert e["dest"] == "~/.emrg/skills/" + assert e["check"] == "github_release" + + def test_embedded_baseline_matches_shipped_file(self): + shipped = SHIPPED_CATALOG.read_text(encoding="utf-8") + assert shipped == BASELINE_CATALOG_MD + + def test_quoted_description_with_inner_colon_kept(self): + entries = parse_skills_frontmatter(BASELINE_CATALOG_MD) + assert entries[0]["description"] == ( + "Direct browser control via CDP: automation, scraping, testing, site work." + ) + + def test_missing_fields_filtered_out(self): + text = """--- +name: skill-catalog +description: "d" +skills: + - name: only-name +--- +""" + assert parse_skills_frontmatter(text) == [] + + def test_garbage_returns_empty(self): + assert parse_skills_frontmatter("no frontmatter here") == [] + assert parse_skills_frontmatter("---\nnot: yaml\n---\n") == [] + + def test_empty_skills_list(self): + text = "---\nname: skill-catalog\ndescription: d\nskills:\n---\nbody" + assert parse_skills_frontmatter(text) == [] + + +class TestCatalogFile: + def test_ensure_writes_baseline_when_missing(self, tmp_home): + path = ensure_catalog_file() + assert path.exists() + assert path.read_text(encoding="utf-8") == BASELINE_CATALOG_MD + + def test_ensure_does_not_overwrite_existing(self, tmp_home): + path = registry.catalog_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("---\nname: custom-catalog\n---\n", encoding="utf-8") + ensure_catalog_file() + assert path.read_text(encoding="utf-8").startswith("---\nname: custom-catalog") + + def test_load_catalog_from_disk(self, tmp_home): + ensure_catalog_file() + entries = load_catalog_skills() + assert [e["name"] for e in entries] == ["browser-harness"] + assert find_catalog_skill("browser-harness") is not None + assert find_catalog_skill("nope") is None + + def test_load_catalog_missing_file(self, tmp_home): + assert load_catalog_skills() == [] + + def test_corrupt_catalog_file_tolerated(self, tmp_home): + path = registry.catalog_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\x00\x01garbage", encoding="utf-8") + assert load_catalog_skills() == [] + + +# ── catalog IS a skill (revised design core) ───────────────────────── + +class TestCatalogAsSkill: + def test_parse_skill_file_loads_catalog(self): + # acceptance 1: the catalog itself is a normal skill + skill = _parse_skill_file(SHIPPED_CATALOG, "user") + assert skill is not None + assert skill.name == "skill-catalog" + assert "installable" in skill.description + + def test_load_skills_includes_catalog(self, tmp_home): + # user skills dir: catalog + one real skill → both loaded + skills_dir = tmp_home / ".emrg" / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "skill-catalog.md").write_text(BASELINE_CATALOG_MD, encoding="utf-8") + real = skills_dir / "real-skill.md" + real.write_text(VALID_SKILL_MD.replace("browser-harness", "real-skill"), encoding="utf-8") + skills = load_skills(project_dir=tmp_home) + names = [s.name for s in skills] + assert "skill-catalog" in names + assert "real-skill" in names + + def test_deprecated_recommended_md_never_loads(self, tmp_home): + # the superseded registry file (10:11:35 design) must not become a skill + skills_dir = tmp_home / ".emrg" / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "recommended.md").write_text( + "---\nskills:\n - name: browser-harness\n---\nbody", encoding="utf-8" + ) + skills = load_skills(project_dir=tmp_home) + assert [s.name for s in skills] == [] + + def test_system_j2_untouched(self): + # acceptance 1/5: zero template change — no new section, no catalog mention + j2 = (REPO_ROOT / "emrg" / "server" / "prompts" / "system.j2").read_text(encoding="utf-8") + assert "Recommended Skills" not in j2 + assert "skill-catalog" not in j2 + assert "browser-harness" not in j2 + + +# ── state ──────────────────────────────────────────────────────────── + +class TestState: + def test_write_read_roundtrip(self, tmp_home): + write_state({"browser-harness": {"version": "0.1.8", "managed": True}}) + state = read_state() + assert state["browser-harness"]["version"] == "0.1.8" + assert skill_is_managed("browser-harness") + assert not skill_is_managed("other") + + def test_corrupt_state_tolerated(self, tmp_home): + path = registry.state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{corrupt", encoding="utf-8") + assert read_state() == {} + + def test_non_dict_state_tolerated(self, tmp_home): + path = registry.state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("[1,2,3]", encoding="utf-8") + assert read_state() == {} + + def test_state_write_is_atomic_no_tmp_leftover(self, tmp_home): + write_state({"a": {"managed": True}}) + leftovers = [f for f in os.listdir(registry.state_path().parent) if f.endswith(".tmp")] + assert leftovers == [] + + +# ── install flow ───────────────────────────────────────────────────── + +class TestInstall: + def test_unknown_skill_errors(self, tmp_home, no_cli): + result = _run(installer.install_skill("nope")) + assert "error" in result + + def test_requires_confirmation_when_cli_missing(self, tmp_home, no_cli): + ensure_catalog_file() + result = _run(installer.install_skill("browser-harness", confirmed=False)) + assert result["confirm_required"] is True + assert "uv tool install" in result["install_command"] + + def test_full_install_flow(self, tmp_home, with_cli): + ensure_catalog_file() + runner = FakeRunner(skill_output=VALID_SKILL_MD) + result = _run(installer.install_skill( + "browser-harness", confirmed=True, runner=runner, http_get=FakeHttp("v0.1.8") + )) + assert result["ok"] is True + assert result["version"] == "0.1.8" + # skill file landed with valid frontmatter + skill_file = tmp_home / ".emrg" / "skills" / "browser-harness.md" + assert skill_file.exists() + fm = _parse_frontmatter(skill_file.read_text(encoding="utf-8")) + assert fm["name"] == "browser-harness" and fm["description"] + # state recorded managed + state = read_state() + assert state["browser-harness"]["managed"] is True + assert state["browser-harness"]["version"] == "0.1.8" + # publish step ran exactly once + assert runner.calls.count(["browser-harness", "skill"]) == 1 + + def test_cli_installed_when_confirmed(self, tmp_home, no_cli, monkeypatch): + ensure_catalog_file() + runner = FakeRunner(skill_output=VALID_SKILL_MD) + + def _cli_available(): + return len(runner.calls) >= 1 # after uv install, CLI "exists" + + monkeypatch.setattr(installer, "cli_available", _cli_available) + result = _run(installer.install_skill( + "browser-harness", confirmed=True, runner=runner, http_get=FakeHttp("v0.1.8") + )) + assert result["ok"] is True + assert any(c[0] == "uv" and c[1:3] == ["tool", "install"] for c in runner.calls) + + def test_cli_install_failure(self, tmp_home, no_cli, monkeypatch): + ensure_catalog_file() + runner = FakeRunner(skill_output=VALID_SKILL_MD, cli_install_rc=1) + monkeypatch.setattr(installer, "cli_available", lambda: False) + result = _run(installer.install_skill( + "browser-harness", confirmed=True, runner=runner, http_get=FakeHttp() + )) + assert "error" in result + assert not (tmp_home / ".emrg" / "skills" / "browser-harness.md").exists() + + def test_publish_invalid_output_rolls_back(self, tmp_home, with_cli): + ensure_catalog_file() + # skill output without name/description frontmatter → refused, no file + runner = FakeRunner(skill_output="# just a title\nno frontmatter\n") + result = _run(installer.install_skill( + "browser-harness", confirmed=True, runner=runner, http_get=FakeHttp() + )) + assert "error" in result + assert not (tmp_home / ".emrg" / "skills" / "browser-harness.md").exists() + assert read_state() == {} # nothing recorded + + def test_publish_empty_output(self, tmp_home, with_cli): + ensure_catalog_file() + runner = FakeRunner(skill_output="") + result = _run(installer.install_skill( + "browser-harness", confirmed=True, runner=runner, http_get=FakeHttp() + )) + assert "error" in result + + def test_install_version_fallback_when_api_down(self, tmp_home, with_cli): + ensure_catalog_file() + runner = FakeRunner(skill_output=VALID_SKILL_MD) + result = _run(installer.install_skill( + "browser-harness", confirmed=True, runner=runner, http_get=FakeHttp(tag=None) + )) + assert result["ok"] is True + assert result["version"] == "unknown" + + +# ── update check ───────────────────────────────────────────────────── + +class TestUpdate: + def _seed_state(self, state: dict): + """Seed .state.json with an explicit dict (hyphenated skill names + cannot be Python kwargs).""" + write_state(state) + + def test_updates_managed_skill_on_new_release(self, tmp_home, with_cli): + ensure_catalog_file() + self._seed_state({"browser-harness": {"version": "0.1.3", "installed_at": "2026-08-08T00:00:00+08:00", "managed": True}}) + runner = FakeRunner(skill_output=VALID_SKILL_MD) + result = _run(installer.update_managed_skills(runner=runner, http_get=FakeHttp("v0.1.8"))) + assert result["updated"] == ["browser-harness"] + assert read_state()["browser-harness"]["version"] == "0.1.8" + assert (tmp_home / ".emrg" / "skills" / "browser-harness.md").exists() + + def test_up_to_date_no_publish(self, tmp_home, with_cli): + ensure_catalog_file() + self._seed_state({"browser-harness": {"version": "0.1.8", "installed_at": "2026-08-08T00:00:00+08:00", "managed": True}}) + runner = FakeRunner(skill_output=VALID_SKILL_MD) + result = _run(installer.update_managed_skills(runner=runner, http_get=FakeHttp("v0.1.8"))) + assert result["updated"] == [] + assert runner.calls == [] + + def test_api_down_skips_silently(self, tmp_home, with_cli): + ensure_catalog_file() + self._seed_state({"browser-harness": {"version": "0.1.3", "installed_at": "2026-08-08T00:00:00+08:00", "managed": True}}) + runner = FakeRunner(skill_output=VALID_SKILL_MD) + result = _run(installer.update_managed_skills(runner=runner, http_get=FakeHttp(tag=None))) + assert result["updated"] == [] + assert read_state()["browser-harness"]["version"] == "0.1.3" + + def test_skips_when_cli_missing(self, tmp_home, no_cli): + ensure_catalog_file() + self._seed_state({"browser-harness": {"version": "0.1.3", "installed_at": "2026-08-08T00:00:00+08:00", "managed": True}}) + result = _run(installer.update_managed_skills(http_get=FakeHttp("v0.1.8"))) + assert result["skipped"] == ["browser-harness"] + assert read_state()["browser-harness"]["version"] == "0.1.3" + + def test_manual_copies_untouched(self, tmp_home, with_cli): + ensure_catalog_file() + # browser-harness is NOT in state (host manual copy) → never refreshed + write_state({"other": {"version": "9.9.9", "managed": True}}) + manual = tmp_home / ".emrg" / "skills" / "browser-harness.md" + manual.parent.mkdir(parents=True, exist_ok=True) + manual.write_text("# host's own copy\n", encoding="utf-8") + result = _run(installer.update_managed_skills(runner=FakeRunner(VALID_SKILL_MD), http_get=FakeHttp("v0.1.8"))) + assert "browser-harness" not in result["updated"] + assert manual.read_text(encoding="utf-8") == "# host's own copy\n" + + def test_unknown_repo_entry_ignored(self, tmp_home, with_cli): + ensure_catalog_file() + # entry not in catalog anymore → skipped + write_state({"ghost": {"version": "0.1.0", "managed": True}}) + result = _run(installer.update_managed_skills(runner=FakeRunner(VALID_SKILL_MD), http_get=FakeHttp("v0.9.0"))) + assert result["checked"] == 1 + assert result["updated"] == [] + + def test_update_error_reported(self, tmp_home, with_cli): + ensure_catalog_file() + self._seed_state({"browser-harness": {"version": "0.1.3", "installed_at": "2026-08-08T00:00:00+08:00", "managed": True}}) + runner = FakeRunner(skill_output="broken output") + result = _run(installer.update_managed_skills(runner=runner, http_get=FakeHttp("v0.1.8"))) + assert result["errors"] == ["browser-harness"] + assert read_state()["browser-harness"]["version"] == "0.1.3" # not advanced + + def test_no_state_noop(self, tmp_home, with_cli): + ensure_catalog_file() + result = _run(installer.update_managed_skills(runner=FakeRunner(VALID_SKILL_MD), http_get=FakeHttp("v0.1.8"))) + assert result == {"checked": 0, "updated": [], "skipped": [], "errors": []}