feat(plugins): integrate portable Agent Plugins

This commit is contained in:
Xubin Ren
2026-08-11 20:16:24 +09:00
parent 57d81bc1cd
commit d5e0df6963
23 changed files with 1111 additions and 110 deletions
+66 -6
View File
@@ -9,9 +9,16 @@ from types import SimpleNamespace
import pytest
from nanobot.agent import plugins as agent_plugins
from nanobot.agent.skills import SkillsLoader
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
@pytest.fixture(autouse=True)
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(agent_plugins, "get_config_path", lambda: tmp_path / "config/config.json")
def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
@@ -391,6 +398,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 +410,15 @@ 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()
manifest = json.loads((plugin / "plugin.json").read_text(encoding="utf-8"))
assert (manifest["name"], manifest["version"]) == ("cli-app-gimp", "1.0.0")
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 SkillsLoader(manager.workspace).load_skill("cli-app-gimp") is not None
assert not legacy.exists()
def test_run_argv_logs_command_exit_and_output(
@@ -487,7 +503,7 @@ 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 +720,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 +734,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,19 +862,62 @@ 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",
},
]
def test_remove_skill_cleans_legacy_underscored_name(tmp_path: Path) -> None:
manager = _manager(tmp_path)
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
manager.remove_skill("unimol_tools")
assert not legacy.exists()
def test_migrated_cli_app_skill_keeps_legacy_identity_alias(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
data_dir = tmp_path / "data"
monkeypatch.setattr(
"nanobot.apps.cli.service.get_runtime_subdir",
lambda _name: data_dir,
)
workspace = tmp_path / "workspace"
workspace.mkdir()
manager = CliAppManager(workspace=workspace)
manager._save_installed({"unimol_tools": {"entry_point": "unimol-tools"}})
manager.install_skill({
"name": "unimol_tools",
"display_name": "Uni-Mol Tools",
"entry_point": "unimol-tools",
})
agent_plugins.set_agent_plugin_enabled(workspace, "cli-app-unimol-tools", True)
loader = SkillsLoader(workspace)
assert loader.get_explicitly_invoked_skills("Use $cli-app-unimol_tools") == [
"cli-app-unimol-tools"
]
assert loader.load_skill("cli-app-unimol_tools") is not None
disabled = SkillsLoader(workspace, disabled_skills={"cli-app-unimol_tools"})
assert "cli-app-unimol-tools" not in {
skill["name"] for skill in disabled.list_skills(filter_unavailable=False)
}
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
+11 -9
View File
@@ -38,24 +38,26 @@ 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):
def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
lines = runtime_lines_for_request(
"please use @zoom tonight",
"please use @unimol_tools",
{
"cli_apps": [{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"display_name": "Zoom",
"name": "unimol_tools",
"entry_point": "cli-anything-unimol-tools",
}],
},
tmp_path,
)
joined = "\n".join(lines)
assert "CLI App Attachment: @zoom" in joined
assert "CLI App Attachment: @unimol_tools" 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 "entry_point=cli-anything-unimol-tools" in joined
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in joined