mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +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_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_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_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_TOOL_OUTPUT_CHARS = 12_000
|
||||||
_MAX_ARTIFACT_SCAN_PATHS = 4_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
|
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)
|
safe_path = _safe_skill_path(skill_md)
|
||||||
if safe_path:
|
if safe_path:
|
||||||
return f"{CLI_ANYTHING_RAW_BASE}/{safe_path}"
|
return f"{raw_base.rstrip('/')}/{safe_path}"
|
||||||
parsed = urlparse(skill_md)
|
parsed = urlparse(skill_md)
|
||||||
if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
|
if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
|
||||||
return None
|
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
|
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
|
return skill_md if _safe_skill_path(suffix) else None
|
||||||
|
|
||||||
|
|
||||||
@ -435,27 +442,22 @@ class CliAppManager:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def catalog(self, *, force_refresh: bool = False) -> tuple[list[dict[str, Any]], str | None]:
|
def catalog(self, *, force_refresh: bool = False) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
registries = [
|
registries: list[tuple[str, str, dict[str, Any]]] = []
|
||||||
(
|
for source, url, raw_base, required in _CATALOG_SOURCES:
|
||||||
"harness",
|
try:
|
||||||
self._fetch_registry(
|
registry = self._fetch_registry(
|
||||||
CLI_ANYTHING_REGISTRY_URL,
|
url,
|
||||||
self._cache_path("harness"),
|
self._cache_path(source),
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
),
|
)
|
||||||
),
|
except Exception:
|
||||||
(
|
if required:
|
||||||
"public",
|
raise
|
||||||
self._fetch_registry(
|
continue
|
||||||
CLI_ANYTHING_PUBLIC_REGISTRY_URL,
|
registries.append((source, raw_base, registry))
|
||||||
self._cache_path("public"),
|
|
||||||
force_refresh=force_refresh,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
apps_by_name: dict[str, dict[str, Any]] = {}
|
apps_by_name: dict[str, dict[str, Any]] = {}
|
||||||
updated_values: list[str] = []
|
updated_values: list[str] = []
|
||||||
for source, registry in registries:
|
for source, raw_base, registry in registries:
|
||||||
meta = registry.get("meta")
|
meta = registry.get("meta")
|
||||||
if isinstance(meta, dict) and isinstance(meta.get("updated"), str):
|
if isinstance(meta, dict) and isinstance(meta.get("updated"), str):
|
||||||
updated_values.append(meta["updated"])
|
updated_values.append(meta["updated"])
|
||||||
@ -464,6 +466,7 @@ class CliAppManager:
|
|||||||
continue
|
continue
|
||||||
entry = dict(row)
|
entry = dict(row)
|
||||||
entry["_source"] = source
|
entry["_source"] = source
|
||||||
|
entry["_raw_base"] = raw_base
|
||||||
key = str(entry["name"]).lower()
|
key = str(entry["name"]).lower()
|
||||||
previous = apps_by_name.get(key)
|
previous = apps_by_name.get(key)
|
||||||
if previous:
|
if previous:
|
||||||
@ -476,6 +479,15 @@ class CliAppManager:
|
|||||||
apps_by_name[key] = entry
|
apps_by_name[key] = entry
|
||||||
return list(apps_by_name.values()), max(updated_values) if updated_values else None
|
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]:
|
def get_app(self, name: str, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||||
wanted = name.lower()
|
wanted = name.lower()
|
||||||
for app in self.catalog(force_refresh=force_refresh)[0]:
|
for app in self.catalog(force_refresh=force_refresh)[0]:
|
||||||
@ -640,14 +652,14 @@ class CliAppManager:
|
|||||||
version=str(app.get("version") or ""),
|
version=str(app.get("version") or ""),
|
||||||
description=_catalog_description(app),
|
description=_catalog_description(app),
|
||||||
category=str(app.get("category") or "uncategorized"),
|
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,
|
logo_url=logo_url,
|
||||||
brand_color=brand_color,
|
brand_color=brand_color,
|
||||||
capabilities=capabilities,
|
capabilities=capabilities,
|
||||||
install=install,
|
install=install,
|
||||||
remove=remove,
|
remove=remove,
|
||||||
trust={
|
trust={
|
||||||
"registry": "cli-anything",
|
"registry": self._trust_registry(app),
|
||||||
"level": "catalog",
|
"level": "catalog",
|
||||||
"review_status": "catalog_entry",
|
"review_status": "catalog_entry",
|
||||||
},
|
},
|
||||||
@ -793,7 +805,7 @@ class CliAppManager:
|
|||||||
skill_md = str(app.get("skill_md") or "").strip()
|
skill_md = str(app.get("skill_md") or "").strip()
|
||||||
if not skill_md:
|
if not skill_md:
|
||||||
return None
|
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:
|
if not url:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -261,6 +261,10 @@ async def test_cli_app_scope_controls_working_dir(
|
|||||||
json.dumps({"_cached_at": time.time(), "data": {"meta": {}, "clis": []}}),
|
json.dumps({"_cached_at": time.time(), "data": {"meta": {}, "clis": []}}),
|
||||||
encoding="utf-8",
|
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(
|
CliAppManager(workspace=project, data_dir=data_dir)._save_installed(
|
||||||
{"demo": {"entry_point": "demo-cli"}}
|
{"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("harness"), harness)
|
||||||
_write_cache(manager._cache_path("public"), public)
|
_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:
|
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()
|
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"
|
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(
|
def test_install_dispatches_safe_pip_and_installs_skill(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
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(
|
def test_uninstall_removes_installed_state_and_generated_skill(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
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 / "harness_registry_cache.json", registry)
|
||||||
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
_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(
|
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
|
||||||
{"gimp": {"entry_point": "cli-anything-gimp"}}
|
{"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 / "harness_registry_cache.json", registry)
|
||||||
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
_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)
|
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
||||||
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
|
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user