diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index 5dedfa78c..7273bbdbf 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -25,7 +25,13 @@ from nanobot.security.workspace_policy import is_path_within CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json" CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json" CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main" -CLI_ANYTHING_RAW_SKILLS_BASE = f"{CLI_ANYTHING_RAW_BASE}/skills/" +NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json" +NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main" +_CATALOG_SOURCES = ( + ("harness", CLI_ANYTHING_REGISTRY_URL, CLI_ANYTHING_RAW_BASE, True), + ("public", CLI_ANYTHING_PUBLIC_REGISTRY_URL, CLI_ANYTHING_RAW_BASE, True), + ("extensions", NANOBOT_EXTENSION_REGISTRY_URL, NANOBOT_EXTENSION_RAW_BASE, False), +) _MAX_TOOL_OUTPUT_CHARS = 12_000 _MAX_ARTIFACT_SCAN_PATHS = 4_000 @@ -344,16 +350,17 @@ def _safe_skill_path(value: str) -> str | None: return value if parts[-1] == "SKILL.md" else None -def _skill_content_url(skill_md: str) -> str | None: +def _skill_content_url(skill_md: str, *, raw_base: str = CLI_ANYTHING_RAW_BASE) -> str | None: safe_path = _safe_skill_path(skill_md) if safe_path: - return f"{CLI_ANYTHING_RAW_BASE}/{safe_path}" + return f"{raw_base.rstrip('/')}/{safe_path}" parsed = urlparse(skill_md) if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com": return None - if not skill_md.startswith(CLI_ANYTHING_RAW_SKILLS_BASE): + raw_prefix = raw_base.rstrip("/") + "/" + if not skill_md.startswith(raw_prefix): return None - suffix = skill_md.removeprefix(f"{CLI_ANYTHING_RAW_BASE}/") + suffix = skill_md.removeprefix(raw_prefix) return skill_md if _safe_skill_path(suffix) else None @@ -435,27 +442,22 @@ class CliAppManager: return data def catalog(self, *, force_refresh: bool = False) -> tuple[list[dict[str, Any]], str | None]: - registries = [ - ( - "harness", - self._fetch_registry( - CLI_ANYTHING_REGISTRY_URL, - self._cache_path("harness"), + registries: list[tuple[str, str, dict[str, Any]]] = [] + for source, url, raw_base, required in _CATALOG_SOURCES: + try: + registry = self._fetch_registry( + url, + self._cache_path(source), force_refresh=force_refresh, - ), - ), - ( - "public", - self._fetch_registry( - CLI_ANYTHING_PUBLIC_REGISTRY_URL, - self._cache_path("public"), - force_refresh=force_refresh, - ), - ), - ] + ) + except Exception: + if required: + raise + continue + registries.append((source, raw_base, registry)) apps_by_name: dict[str, dict[str, Any]] = {} updated_values: list[str] = [] - for source, registry in registries: + for source, raw_base, registry in registries: meta = registry.get("meta") if isinstance(meta, dict) and isinstance(meta.get("updated"), str): updated_values.append(meta["updated"]) @@ -464,6 +466,7 @@ class CliAppManager: continue entry = dict(row) entry["_source"] = source + entry["_raw_base"] = raw_base key = str(entry["name"]).lower() previous = apps_by_name.get(key) if previous: @@ -476,6 +479,15 @@ class CliAppManager: apps_by_name[key] = entry return list(apps_by_name.values()), max(updated_values) if updated_values else None + def _manifest_source(self, app: dict[str, Any]) -> str: + source = str(app.get("_source") or "harness") + if source == "extensions": + return "nanobot-extension" + return f"cli-anything:{source}" + + def _trust_registry(self, app: dict[str, Any]) -> str: + return "nanobot-extension" if str(app.get("_source") or "") == "extensions" else "cli-anything" + def get_app(self, name: str, *, force_refresh: bool = False) -> dict[str, Any]: wanted = name.lower() for app in self.catalog(force_refresh=force_refresh)[0]: @@ -640,14 +652,14 @@ class CliAppManager: version=str(app.get("version") or ""), description=_catalog_description(app), category=str(app.get("category") or "uncategorized"), - source=f"cli-anything:{app.get('_source') or 'harness'}", + source=self._manifest_source(app), logo_url=logo_url, brand_color=brand_color, capabilities=capabilities, install=install, remove=remove, trust={ - "registry": "cli-anything", + "registry": self._trust_registry(app), "level": "catalog", "review_status": "catalog_entry", }, @@ -793,7 +805,7 @@ class CliAppManager: skill_md = str(app.get("skill_md") or "").strip() if not skill_md: return None - url = _skill_content_url(skill_md) + url = _skill_content_url(skill_md, raw_base=str(app.get("_raw_base") or CLI_ANYTHING_RAW_BASE)) if not url: return None try: diff --git a/tests/agent/test_workspace_scope.py b/tests/agent/test_workspace_scope.py index 504392436..9b2cff25e 100644 --- a/tests/agent/test_workspace_scope.py +++ b/tests/agent/test_workspace_scope.py @@ -261,6 +261,10 @@ async def test_cli_app_scope_controls_working_dir( json.dumps({"_cached_at": time.time(), "data": {"meta": {}, "clis": []}}), encoding="utf-8", ) + (data_dir / "extensions_registry_cache.json").write_text( + json.dumps({"_cached_at": time.time(), "data": {"meta": {}, "clis": []}}), + encoding="utf-8", + ) CliAppManager(workspace=project, data_dir=data_dir)._save_installed( {"demo": {"entry_point": "demo-cli"}} ) diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index 4b32f3fd3..a25547186 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -121,6 +121,7 @@ def _seed_catalog(manager: CliAppManager) -> None: } _write_cache(manager._cache_path("harness"), harness) _write_cache(manager._cache_path("public"), public) + _write_cache(manager._cache_path("extensions"), {"meta": {}, "clis": []}) def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -> None: @@ -187,6 +188,7 @@ def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None: ], }, ) + _write_cache(manager._cache_path("extensions"), {"meta": {}, "clis": []}) payload = manager.payload() @@ -195,6 +197,75 @@ def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None: assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64" +def test_payload_includes_nanobot_extension_registry(tmp_path: Path) -> None: + manager = _manager(tmp_path) + _write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []}) + _write_cache(manager._cache_path("public"), {"meta": {"updated": "2026-04-18"}, "clis": []}) + _write_cache( + manager._cache_path("extensions"), + { + "meta": {"updated": "2026-05-29"}, + "clis": [ + { + "name": "hyperframes", + "display_name": "HyperFrames", + "version": "latest", + "description": "HTML-to-MP4 motion graphics CLI", + "category": "video", + "package_manager": "npm", + "npm_package": "hyperframes", + "install_cmd": "npm install -g hyperframes", + "entry_point": "hyperframes", + "skill_md": "skills/hyperframes/SKILL.md", + } + ], + }, + ) + + payload = manager.payload() + + assert payload["catalog_updated_at"] == "2026-05-29" + app = payload["apps"][0] + assert app["name"] == "hyperframes" + assert app["source"] == "extensions" + assert app["install_supported"] is True + assert app["manifest"]["source"] == "nanobot-extension" + assert app["manifest"]["trust"]["registry"] == "nanobot-extension" + + +def test_optional_extension_registry_failure_does_not_break_payload( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = _manager(tmp_path) + _write_cache( + manager._cache_path("harness"), + { + "meta": {"updated": "2026-04-16"}, + "clis": [ + { + "name": "gimp", + "display_name": "GIMP", + "description": "Image editing", + "install_cmd": "pip install cli-anything-gimp", + "entry_point": "cli-anything-gimp", + } + ], + }, + ) + _write_cache(manager._cache_path("public"), {"meta": {"updated": "2026-04-18"}, "clis": []}) + + def fail_get(*args, **kwargs): + raise RuntimeError("network unavailable") + + monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get) + + payload = manager.payload() + + assert payload["catalog_updated_at"] == "2026-04-18" + assert [app["name"] for app in payload["apps"]] == ["gimp"] + + def test_install_dispatches_safe_pip_and_installs_skill( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -333,6 +404,38 @@ def test_fetch_skill_content_allows_cli_anything_raw_skill_url( ] +def test_fetch_skill_content_uses_extension_raw_base_for_relative_skills( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = _manager(tmp_path) + seen: list[str] = [] + + class Response: + text = "---\nname: hyperframes\ndescription: HyperFrames\n---\n# HyperFrames\n" + + @staticmethod + def raise_for_status() -> None: + return None + + def fake_get(url: str, **kwargs): + seen.append(url) + return Response() + + monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get) + + content = manager._fetch_skill_content({ + "name": "hyperframes", + "skill_md": "skills/hyperframes/SKILL.md", + "_raw_base": "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main", + }) + + assert content and "# HyperFrames" in content + assert seen == [ + "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/skills/hyperframes/SKILL.md" + ] + + def test_uninstall_removes_installed_state_and_generated_skill( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/cli_apps/test_tool.py b/tests/cli_apps/test_tool.py index e0b3b47c6..6ea261320 100644 --- a/tests/cli_apps/test_tool.py +++ b/tests/cli_apps/test_tool.py @@ -41,6 +41,7 @@ def test_run_cli_app_uses_installed_registry_app( } _write_cache(data_dir / "harness_registry_cache.json", registry) _write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []}) + _write_cache(data_dir / "extensions_registry_cache.json", {"meta": {}, "clis": []}) CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed( {"gimp": {"entry_point": "cli-anything-gimp"}} ) @@ -102,6 +103,7 @@ def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> Non } _write_cache(data_dir / "harness_registry_cache.json", registry) _write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []}) + _write_cache(data_dir / "extensions_registry_cache.json", {"meta": {}, "clis": []}) monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir) tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)