From 6f361cd47620cc50181dd4eb6615b80d99001332 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:14:24 +0800 Subject: [PATCH] feat(apps): package CLI apps as Agent Plugins --- docs/configuration.md | 5 ++ nanobot/agent/skills.py | 1 + nanobot/apps/cli/service.py | 70 +++++++++++++++++---- nanobot/apps/cli/utils.py | 3 +- nanobot/session/manager.py | 2 +- tests/agent/test_agent_plugins.py | 16 +++++ tests/agent/test_session_manager_history.py | 3 +- tests/cli_apps/test_service.py | 36 +++++++++-- tests/cli_apps/test_utils.py | 4 +- 9 files changed, 118 insertions(+), 22 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ed9cc0aeb..74136d957 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 `/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. diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index 0a4f5c863..4693c9a46 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -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: diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index 413b449bd..dedd83628 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -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() diff --git a/nanobot/apps/cli/utils.py b/nanobot/apps/cli/utils.py index c48bb56b5..6bbe6cf73 100644 --- a/nanobot/apps/cli/utils.py +++ b/nanobot/apps/cli/utils.py @@ -32,7 +32,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() diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index ac5547103..5870076c6 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -295,7 +295,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) diff --git a/tests/agent/test_agent_plugins.py b/tests/agent/test_agent_plugins.py index 8205f4fe0..9d18cfdb4 100644 --- a/tests/agent/test_agent_plugins.py +++ b/tests/agent/test_agent_plugins.py @@ -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") diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 357bdc20b..54c7554c0 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -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]" ), }] diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index 7b0a312a0..87a5a7a43 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -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", }, ] diff --git a/tests/cli_apps/test_utils.py b/tests/cli_apps/test_utils.py index 4c5d7d64e..cb8c6f276 100644 --- a/tests/cli_apps/test_utils.py +++ b/tests/cli_apps/test_utils.py @@ -38,7 +38,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): @@ -58,4 +58,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