mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 14:23:58 +00:00
feat(apps): add extension registry source
This commit is contained in:
parent
232df45126
commit
c1357e86de
@ -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:
|
||||
|
||||
@ -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"}}
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user