feat(apps): package CLI apps as Agent Plugins

This commit is contained in:
Xubin Ren
2026-08-10 15:33:06 +09:00
parent 2c083856e0
commit 6f361cd476
9 changed files with 118 additions and 22 deletions
+5
View File
@@ -2331,6 +2331,11 @@ This initial compatibility layer loads the portable `skills/` component only. Ag
need an explicit trust and approval flow. Configure a reviewed MCP server through **Apps** or need an explicit trust and approval flow. Configure a reviewed MCP server through **Apps** or
`tools.mcpServers` for now. `tools.mcpServers` for now.
CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through
its catalog adapter, then writes a skills-only Agent Plugin under `<workspace>/plugins/`; updates
refresh that package and uninstall removes it. The external executable remains managed by the
CLI Apps installer rather than by the Agent Plugins manifest.
## Tool Hint Max Length ## Tool Hint Max Length
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read. Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
+1
View File
@@ -63,6 +63,7 @@ class SkillsLoader:
Returns: Returns:
List of skill info dicts with 'name', 'path', 'source'. List of skill info dicts with 'name', 'path', 'source'.
""" """
self.plugin_skills = discover_agent_plugin_skills(self.workspace)
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace") skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
seen_names = {entry["name"] for entry in skills} seen_names = {entry["name"] for entry in skills}
for plugin_skill in self.plugin_skills: for plugin_skill in self.plugin_skills:
+59 -11
View File
@@ -18,6 +18,7 @@ from typing import Any, cast
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
import yaml
from loguru import logger from loguru import logger
from nanobot.apps.protocol import app_manifest, compact_dict from nanobot.apps.protocol import app_manifest, compact_dict
@@ -27,6 +28,7 @@ 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"
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json" 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" NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
_CATALOG_SOURCES = ( _CATALOG_SOURCES = (
@@ -41,6 +43,8 @@ _MAX_ARTIFACT_REPORT = 12
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+") _SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE) _SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE) _MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
_SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
_SKILL_NAME_LINE_RE = re.compile(r"^name\s*:.*$", re.MULTILINE)
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<") _SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE) _ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
_ARTIFACT_EXTENSIONS = frozenset({ _ARTIFACT_EXTENSIONS = frozenset({
@@ -211,10 +215,15 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
def _safe_skill_name(name: str) -> str: def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-") clean = _SAFE_NAME_RE.sub("-", name.lower()).replace("_", "-").strip("-")
return f"cli-app-{clean or 'app'}" return f"cli-app-{clean or 'app'}"
def _skill_relative_path(name: str) -> str:
skill_name = _safe_skill_name(name)
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
def _has_shell_meta(command: str) -> bool: def _has_shell_meta(command: str) -> bool:
return any(char in command for char in _SHELL_META_CHARS) return any(char in command for char in _SHELL_META_CHARS)
@@ -613,7 +622,7 @@ class CliAppManager:
"name": installed_name, "name": installed_name,
"entry_point": entry_point, "entry_point": entry_point,
"source": str(data.get("source") or ""), "source": str(data.get("source") or ""),
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md", "skill": _skill_relative_path(installed_name),
"tool": "run_cli_app", "tool": "run_cli_app",
} }
) )
@@ -640,6 +649,10 @@ class CliAppManager:
return not _has_shell_meta(install_cmd) return not _has_shell_meta(install_cmd)
def _skill_path(self, name: str) -> Path: def _skill_path(self, name: str) -> Path:
skill_name = _safe_skill_name(name)
return self.workspace / "plugins" / skill_name / "skills" / skill_name / "SKILL.md"
def _legacy_skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md" return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
def _app_payload( def _app_payload(
@@ -713,7 +726,8 @@ class CliAppManager:
name = str(app["name"]) name = str(app["name"])
entry_point = str(app.get("entry_point") or "") entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app) strategy = self._strategy(app)
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md" skill_path = _skill_relative_path(name)
plugin_path = f"plugins/{_safe_skill_name(name)}"
capabilities = [ capabilities = [
compact_dict({ compact_dict({
"type": "cli", "type": "cli",
@@ -726,13 +740,13 @@ class CliAppManager:
install = compact_dict({ install = compact_dict({
"supported": install_supported, "supported": install_supported,
"strategy": strategy, "strategy": strategy,
"managed_paths": [skill_path], "managed_paths": [plugin_path],
"verification": ["entry_point_available"] if entry_point else [], "verification": ["entry_point_available"] if entry_point else [],
}) })
remove = compact_dict({ remove = compact_dict({
"supported": strategy != "unsupported", "supported": strategy != "unsupported",
"strategy": strategy, "strategy": strategy,
"managed_paths": [skill_path], "managed_paths": [plugin_path],
"verification": ( "verification": (
["package_manager_ok", "entry_point_absent", "managed_paths_absent"] ["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
if strategy not in {"bundled", "unsupported"} if strategy not in {"bundled", "unsupported"}
@@ -1032,11 +1046,10 @@ class CliAppManager:
name = str(app.get("name") or "unknown") name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or name) display = str(app.get("display_name") or name)
entry = str(app.get("entry_point") or f"cli-anything-{name}") entry = str(app.get("entry_point") or f"cli-anything-{name}")
description = _catalog_description(app) or f"Use {display} from nanobot." description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
return f"""--- return f"""---
name: {_safe_skill_name(name)} name: {_safe_skill_name(name)}
description: >- description: {json.dumps(description, ensure_ascii=False)}
{description}
--- ---
# {display} # {display}
@@ -1072,18 +1085,53 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :]) return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
return note + "\n" + content return note + "\n" + content
def _normalise_skill(self, content: str, app: dict[str, Any]) -> str:
"""Give a catalog skill the identity required by its plugin directory."""
match = _SKILL_FRONTMATTER_RE.match(content)
if match is None:
return self._fallback_skill(app)
try:
metadata = _as_object_dict(cast(object, yaml.safe_load(match.group(1))))
except yaml.YAMLError:
return self._fallback_skill(app)
description = metadata.get("description") if metadata is not None else None
if not isinstance(description, str) or not 1 <= len(description.strip()) <= 1024:
return self._fallback_skill(app)
name = _safe_skill_name(str(app["name"]))
frontmatter, replaced = _SKILL_NAME_LINE_RE.subn(f"name: {name}", match.group(1), count=1)
if not replaced:
frontmatter = f"name: {name}\n{frontmatter}"
body = content[match.end():].lstrip()
return f"---\n{frontmatter.strip()}\n---\n\n{body}"
def install_skill(self, app: dict[str, Any]) -> Path: def install_skill(self, app: dict[str, Any]) -> Path:
path = self._skill_path(str(app["name"])) path = self._skill_path(str(app["name"]))
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
content = self._fetch_skill_content(app) or self._fallback_skill(app) content = self._fetch_skill_content(app) or self._fallback_skill(app)
content = self._normalise_skill(content, app)
content = self._with_nanobot_skill_note(content, app) content = self._with_nanobot_skill_note(content, app)
path.write_text(content, encoding="utf-8") path.write_text(content, encoding="utf-8")
plugin_root = path.parents[2]
manifest = compact_dict({
"$schema": AGENT_PLUGIN_SCHEMA,
"name": _safe_skill_name(str(app["name"])),
"version": str(app.get("version") or ""),
"description": _catalog_description(app),
})
_write_json(plugin_root / "plugin.json", manifest)
legacy_dir = self._legacy_skill_path(str(app["name"])).parent
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
return path return path
def remove_skill(self, name: str) -> None: def remove_skill(self, name: str) -> None:
skill_dir = self._skill_path(name).parent plugin_root = self._skill_path(name).parents[2]
if skill_dir.is_dir(): if plugin_root.is_dir():
shutil.rmtree(skill_dir) shutil.rmtree(plugin_root)
legacy_dir = self._legacy_skill_path(name).parent
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]: def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
installed = self._load_installed() installed = self._load_installed()
+2 -1
View File
@@ -32,7 +32,8 @@ def runtime_lines_for_request(
f"@{str(item['name']).strip().lower()} " f"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; " f"(installed; tool=run_cli_app; "
f"entry_point={str(item.get('entry_point') or 'unknown')}; " f"entry_point={str(item.get('entry_point') or 'unknown')}; "
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). " f"skill=plugins/cli-app-{str(item['name']).strip().lower()}/skills/"
f"cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell." "Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions for item in mentions
if str(item.get("name") or "").strip() if str(item.get("name") or "").strip()
+1 -1
View File
@@ -295,7 +295,7 @@ class Session:
) )
cli_lines.append( cli_lines.append(
f"[CLI App Attachment: @{name}; tool=run_cli_app; entry_point={entry_point}; " f"[CLI App Attachment: @{name}; tool=run_cli_app; entry_point={entry_point}; "
f"skill=skills/cli-app-{name}/SKILL.md]" f"skill=plugins/cli-app-{name}/skills/cli-app-{name}/SKILL.md]"
) )
if cli_lines: if cli_lines:
breadcrumbs = "\n".join(cli_lines) breadcrumbs = "\n".join(cli_lines)
+16
View File
@@ -1,4 +1,5 @@
import json import json
import shutil
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -54,6 +55,21 @@ def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
assert "`acme-tools/skills/release-notes/SKILL.md`" in loader.build_skills_summary() assert "`acme-tools/skills/release-notes/SKILL.md`" in loader.build_skills_summary()
def test_skills_loader_sees_plugin_installed_after_startup(tmp_path: Path) -> None:
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert loader.list_skills() == []
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "release-notes")
assert [entry["name"] for entry in loader.list_skills()] == ["release-notes"]
shutil.rmtree(plugin)
assert loader.list_skills() == []
assert loader.build_skills_summary() == ""
def test_agent_plugin_skills_are_direct_children_only(tmp_path: Path) -> None: def test_agent_plugin_skills_are_direct_children_only(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "acme-tools") plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "direct") _write_skill(plugin, "direct")
+2 -1
View File
@@ -491,7 +491,8 @@ def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
"content": ( "content": (
"please use @drawio\n" "please use @drawio\n"
"[CLI App Attachment: @drawio; tool=run_cli_app; " "[CLI App Attachment: @drawio; tool=run_cli_app; "
"entry_point=cli-anything-drawio; skill=skills/cli-app-drawio/SKILL.md]" "entry_point=cli-anything-drawio; "
"skill=plugins/cli-app-drawio/skills/cli-app-drawio/SKILL.md]"
), ),
}] }]
+30 -6
View File
@@ -9,6 +9,7 @@ from types import SimpleNamespace
import pytest import pytest
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
@@ -391,6 +392,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
"_fetch_skill_content", "_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n", lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
) )
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("legacy", encoding="utf-8")
payload = manager.install("gimp") payload = manager.install("gimp")
@@ -400,9 +404,21 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert "state_recorded" in payload["last_action"]["verification"] assert "state_recorded" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp" assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md" plugin = manager.workspace / "plugins" / "cli-app-gimp"
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
assert skill.is_file() assert skill.is_file()
assert json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) == {
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "cli-app-gimp",
"version": "1.0.0",
"description": "Public duplicate entry",
}
assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8")
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8") assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
assert [item.name for item in discover_agent_plugin_skills(manager.workspace)] == [
"cli-app-gimp"
]
assert not legacy.exists()
def test_run_argv_logs_command_exit_and_output( def test_run_argv_logs_command_exit_and_output(
@@ -487,7 +503,14 @@ def test_install_records_available_cli_without_reinstalling(
assert "entry_point_available" in payload["last_action"]["verification"] assert "entry_point_available" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["feishu"]["entry_point_path"] == str(resolved) assert installed["feishu"]["entry_point_path"] == str(resolved)
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md" skill = (
manager.workspace
/ "plugins"
/ "cli-app-feishu"
/ "skills"
/ "cli-app-feishu"
/ "SKILL.md"
)
assert skill.is_file() assert skill.is_file()
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8") assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
@@ -704,7 +727,8 @@ def test_uninstall_removes_installed_state_and_generated_skill(
manager = _manager(tmp_path) manager = _manager(tmp_path)
_seed_catalog(manager) _seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}}) manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
skill_dir = manager.workspace / "skills" / "cli-app-gimp" plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
skill_dir.mkdir(parents=True) skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8") (skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr( monkeypatch.setattr(
@@ -717,7 +741,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
assert payload["last_action"]["ok"] is True assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert not skill_dir.exists() assert not plugin_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command( def test_uninstall_uses_safe_python_m_pip_uninstall_command(
@@ -845,14 +869,14 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
"name": "zoom", "name": "zoom",
"entry_point": "cli-anything-zoom", "entry_point": "cli-anything-zoom",
"source": "public", "source": "public",
"skill": "skills/cli-app-zoom/SKILL.md", "skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app", "tool": "run_cli_app",
}, },
{ {
"name": "gimp", "name": "gimp",
"entry_point": "cli-anything-gimp", "entry_point": "cli-anything-gimp",
"source": "harness", "source": "harness",
"skill": "skills/cli-app-gimp/SKILL.md", "skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app", "tool": "run_cli_app",
}, },
] ]
+2 -2
View File
@@ -38,7 +38,7 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
assert "CLI App Mention: @zoom" in joined assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path): def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
@@ -58,4 +58,4 @@ def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
assert "CLI App Attachment: @zoom" in joined assert "CLI App Attachment: @zoom" in joined
assert "tool=run_cli_app" in joined assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined