fix(apps): preserve legacy CLI app skills

This commit is contained in:
Xubin Ren
2026-08-08 08:38:06 +08:00
parent 93fd48d327
commit da8ffcd883
6 changed files with 75 additions and 10 deletions
+20 -5
View File
@@ -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({
+4 -2
View File
@@ -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()
+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=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)
+1 -2
View File
@@ -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]"
),
}]
+28
View File
@@ -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)
+21
View File
@@ -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)