diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index dedd83628..eef3f5ad4 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -219,7 +219,13 @@ def _safe_skill_name(name: str) -> str: return f"cli-app-{clean or 'app'}" -def _skill_relative_path(name: str) -> str: +def _legacy_skill_name(name: str) -> str: + """Return the workspace skill name emitted before Agent Plugins support.""" + clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-") + return f"cli-app-{clean or 'app'}" + + +def _plugin_skill_relative_path(name: str) -> str: skill_name = _safe_skill_name(name) return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md" @@ -622,7 +628,7 @@ class CliAppManager: "name": installed_name, "entry_point": entry_point, "source": str(data.get("source") or ""), - "skill": _skill_relative_path(installed_name), + "skill": self.skill_relative_path(installed_name), "tool": "run_cli_app", } ) @@ -653,7 +659,16 @@ class CliAppManager: 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" / _legacy_skill_name(name) / "SKILL.md" + + def _installed_skill_path(self, name: str) -> Path: + path = self._skill_path(name) + legacy_path = self._legacy_skill_path(name) + return legacy_path if not path.is_file() and legacy_path.is_file() else path + + def skill_relative_path(self, name: str) -> str: + """Return the existing skill path, falling back to the canonical plugin path.""" + return self._installed_skill_path(name).relative_to(self.workspace).as_posix() def _app_payload( self, @@ -690,7 +705,7 @@ class CliAppManager: "status": status, "logo_url": logo_url, "brand_color": brand_color, - "skill_installed": self._skill_path(name).is_file(), + "skill_installed": self._installed_skill_path(name).is_file(), "manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color), } @@ -726,7 +741,7 @@ class CliAppManager: name = str(app["name"]) entry_point = str(app.get("entry_point") or "") strategy = self._strategy(app) - skill_path = _skill_relative_path(name) + skill_path = _plugin_skill_relative_path(name) plugin_path = f"plugins/{_safe_skill_name(name)}" capabilities = [ compact_dict({ diff --git a/nanobot/apps/cli/utils.py b/nanobot/apps/cli/utils.py index f0d4a53ec..49e074476 100644 --- a/nanobot/apps/cli/utils.py +++ b/nanobot/apps/cli/utils.py @@ -29,6 +29,9 @@ def runtime_lines_for_request( """Return CLI App annotations from an immutable request snapshot.""" structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None if isinstance(structured, list): + from nanobot.apps.cli import CliAppManager + + manager = CliAppManager(workspace=workspace) structured_items = cast(list[Any], structured) mentions = [ cast(Mapping[str, Any], item) for item in structured_items @@ -41,8 +44,7 @@ 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=plugins/cli-app-{str(item['name']).strip().lower()}/skills/" - f"cli-app-{str(item['name']).strip().lower()}/SKILL.md). " + f"skill={manager.skill_relative_path(str(item['name']))}). " "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 8658f5f67..723067ba9 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -285,7 +285,7 @@ class Session: ) cli_lines.append( f"[CLI App Attachment: @{name}; tool=run_cli_app; entry_point={entry_point}; " - f"skill=plugins/cli-app-{name}/skills/cli-app-{name}/SKILL.md]" + f"skill=skills/cli-app-{name}/SKILL.md]" ) if cli_lines: breadcrumbs = "\n".join(cli_lines) diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 54c7554c0..357bdc20b 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -491,8 +491,7 @@ 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=plugins/cli-app-drawio/skills/cli-app-drawio/SKILL.md]" + "entry_point=cli-anything-drawio; skill=skills/cli-app-drawio/SKILL.md]" ), }] diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index 87a5a7a43..dc9c9afbf 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -882,6 +882,34 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path ] +def test_legacy_underscored_skill_remains_visible_and_removable(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( + "---\nname: cli-app-unimol_tools\ndescription: Legacy Uni-Mol app.\n---\n", + encoding="utf-8", + ) + manager._save_installed( + {"unimol_tools": {"entry_point": "cli-anything-unimol-tools", "source": "harness"}} + ) + + app = { + "name": "unimol_tools", + "entry_point": "cli-anything-unimol-tools", + "install_cmd": "pip install cli-anything-unimol-tools", + } + + assert manager._app_payload(app, manager._load_installed())["skill_installed"] is True + assert manager.mentioned_installed_apps("use @unimol_tools")[0]["skill"] == ( + "skills/cli-app-unimol_tools/SKILL.md" + ) + + manager.remove_skill("unimol_tools") + + assert not legacy.exists() + + def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None: manager = _manager(tmp_path) _seed_catalog(manager) diff --git a/tests/cli_apps/test_utils.py b/tests/cli_apps/test_utils.py index 183169cc9..f162b16db 100644 --- a/tests/cli_apps/test_utils.py +++ b/tests/cli_apps/test_utils.py @@ -62,3 +62,24 @@ def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path): assert "tool=run_cli_app" in joined assert "entry_point=cli-anything-zoom" in joined assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined + + +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( + SimpleNamespace( + content="please use @unimol_tools", + metadata={ + "cli_apps": [{ + "name": "unimol_tools", + "entry_point": "cli-anything-unimol-tools", + }], + }, + ), + tmp_path, + ) + + assert "skill=skills/cli-app-unimol_tools/SKILL.md" in "\n".join(lines)