feat(apps): package CLI apps as Agent Plugins

This commit is contained in:
Xubin Ren
2026-08-08 01:14:24 +08:00
parent 2663bae186
commit 93fd48d327
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
`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 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:
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")
seen_names = {entry["name"] for entry in 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
import httpx
import yaml
from loguru import logger
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_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
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_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
_CATALOG_SOURCES = (
@@ -41,6 +43,8 @@ _MAX_ARTIFACT_REPORT = 12
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", 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 = ("|", "&&", "||", ";", "$(", "`", ">", "<")
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
_ARTIFACT_EXTENSIONS = frozenset({
@@ -211,10 +215,15 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
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'}"
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:
return any(char in command for char in _SHELL_META_CHARS)
@@ -613,7 +622,7 @@ class CliAppManager:
"name": installed_name,
"entry_point": entry_point,
"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",
}
)
@@ -640,6 +649,10 @@ class CliAppManager:
return not _has_shell_meta(install_cmd)
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"
def _app_payload(
@@ -713,7 +726,8 @@ class CliAppManager:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
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 = [
compact_dict({
"type": "cli",
@@ -726,13 +740,13 @@ class CliAppManager:
install = compact_dict({
"supported": install_supported,
"strategy": strategy,
"managed_paths": [skill_path],
"managed_paths": [plugin_path],
"verification": ["entry_point_available"] if entry_point else [],
})
remove = compact_dict({
"supported": strategy != "unsupported",
"strategy": strategy,
"managed_paths": [skill_path],
"managed_paths": [plugin_path],
"verification": (
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
if strategy not in {"bundled", "unsupported"}
@@ -1032,11 +1046,10 @@ class CliAppManager:
name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or 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"""---
name: {_safe_skill_name(name)}
description: >-
{description}
description: {json.dumps(description, ensure_ascii=False)}
---
# {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 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:
path = self._skill_path(str(app["name"]))
path.parent.mkdir(parents=True, exist_ok=True)
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)
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
def remove_skill(self, name: str) -> None:
skill_dir = self._skill_path(name).parent
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
plugin_root = self._skill_path(name).parents[2]
if plugin_root.is_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]:
installed = self._load_installed()
+2 -1
View File
@@ -41,7 +41,8 @@ def runtime_lines_for_request(
f"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; "
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."
for item in mentions
if str(item.get("name") or "").strip()
+1 -1
View File
@@ -285,7 +285,7 @@ class Session:
)
cli_lines.append(
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:
breadcrumbs = "\n".join(cli_lines)
+16
View File
@@ -1,4 +1,5 @@
import json
import shutil
from pathlib import Path
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()
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:
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "direct")
+2 -1
View File
@@ -491,7 +491,8 @@ def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
"content": (
"please use @drawio\n"
"[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
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
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",
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")
@@ -400,9 +404,21 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert "state_recorded" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
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 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 [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(
@@ -487,7 +503,14 @@ def test_install_records_available_cli_without_reinstalling(
assert "entry_point_available" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
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 '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)
_seed_catalog(manager)
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 / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr(
@@ -717,7 +741,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
assert payload["last_action"]["ok"] is True
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(
@@ -845,14 +869,14 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"skill": "skills/cli-app-zoom/SKILL.md",
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app",
},
{
"name": "gimp",
"entry_point": "cli-anything-gimp",
"source": "harness",
"skill": "skills/cli-app-gimp/SKILL.md",
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app",
},
]
+2 -2
View File
@@ -39,7 +39,7 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" 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):
@@ -61,4 +61,4 @@ def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
assert "CLI App Attachment: @zoom" in joined
assert "tool=run_cli_app" 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