Files
nanobot/tests/cli_apps/test_service.py
T
chengyongru be3a42ebac fix(gateway): keep event loop responsive (NAN-33)
Move blocking filesystem, persistence, subprocess, media, and DNS work off the gateway event loop while preserving existing contracts. Add bounded cancellation and responsiveness regression coverage.
2026-08-25 10:18:13 +08:00

1259 lines
43 KiB
Python

from __future__ import annotations
import asyncio
import json
import subprocess
import sys
import time
from contextlib import suppress
from pathlib import Path
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(
json.dumps({"_cached_at": time.time(), "data": registry}),
encoding="utf-8",
)
def _manager(tmp_path: Path) -> CliAppManager:
workspace = tmp_path / "workspace"
workspace.mkdir()
return CliAppManager(
workspace=workspace,
data_dir=tmp_path / "data",
runtime=CliAppsRuntimeConfig(catalog_ttl_seconds=3600, install_timeout=5, run_timeout=5),
)
def _seed_catalog(manager: CliAppManager) -> None:
harness = {
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"version": "1.0.0",
"description": "Image editing",
"category": "image",
"requires": "Python 3.10+",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
"skill_md": "skills/cli-anything-gimp/SKILL.md",
}
],
}
public = {
"meta": {"updated": "2026-04-18"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"description": "Public duplicate entry",
},
{
"name": "jimeng",
"display_name": "Jimeng",
"version": "latest",
"description": "Script install",
"category": "ai",
"install_strategy": "script",
"install_cmd": "curl -fsSL https://example.invalid/install.sh | bash",
"entry_point": "dreamina",
},
{
"name": "feishu",
"display_name": "Feishu/Lark CLI",
"version": "latest",
"description": "Official Lark CLI",
"category": "communication",
"package_manager": "npm",
"npm_package": "@larksuite/cli",
"install_cmd": "npm install -g @larksuite/cli",
"entry_point": "lark-cli",
},
{
"name": "dify-workflow",
"display_name": "Dify Workflow",
"version": "latest",
"description": "Run Dify workflows",
"category": "ai",
"install_cmd": "pip install cli-anything-dify-workflow",
"entry_point": "cli-anything-dify-workflow",
},
{
"name": "shopify",
"display_name": "Shopify CLI",
"version": "latest",
"description": "Shopify",
"category": "web",
"package_manager": "npm",
"npm_package": "@shopify/cli",
"install_cmd": "npm install -g @shopify/cli",
"entry_point": "shopify",
},
{
"name": "clibrowser",
"display_name": "clibrowser",
"version": "latest",
"description": "Cargo install",
"category": "web",
"install_cmd": "cargo install --git https://example.invalid/clibrowser.git",
"entry_point": "clibrowser",
},
{
"name": "suno",
"display_name": "Suno CLI",
"version": "latest",
"description": "python3 pip install",
"category": "music",
"package_manager": "pip",
"install_strategy": "command",
"install_cmd": "python3 -m pip install git+https://example.invalid/suno-cli.git",
"uninstall_cmd": "python3 -m pip uninstall -y suno-cli",
"entry_point": "suno",
},
],
}
_write_cache(manager._cache_path("harness"), harness)
_write_cache(manager._cache_path("public"), public)
_write_cache(manager._cache_path("extensions"), {"meta": {}, "clis": []})
def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
payload = manager.payload()
assert payload["catalog_updated_at"] == "2026-04-18"
apps = {app["name"]: app for app in payload["apps"]}
assert set(apps) == {
"clibrowser",
"dify-workflow",
"feishu",
"gimp",
"jimeng",
"shopify",
"suno",
}
assert apps["gimp"]["install_supported"] is True
assert apps["gimp"]["source"] == "harness+public"
assert apps["gimp"]["description"] == "Public duplicate entry"
assert apps["feishu"]["description"] == "Lark CLI"
assert apps["feishu"]["manifest"]["description"] == "Lark CLI"
assert apps["clibrowser"]["install_supported"] is False
assert apps["jimeng"]["install_supported"] is False
assert apps["suno"]["install_supported"] is True
assert apps["gimp"]["logo_url"]
gimp_manifest = apps["gimp"]["manifest"]
assert gimp_manifest["schema"] == "agent-app.v1"
assert gimp_manifest["id"] == "gimp"
assert gimp_manifest["source"] == "cli-anything:harness+public"
assert gimp_manifest["capabilities"][0]["type"] == "cli"
assert gimp_manifest["capabilities"][0]["entry_point"] == "cli-anything-gimp"
assert gimp_manifest["install"]["verification"] == ["entry_point_available"]
assert "entry_point_absent" in gimp_manifest["remove"]["verification"]
assert gimp_manifest["trust"]["review_status"] == "catalog_entry"
assert apps["dify-workflow"]["logo_url"] == "https://cdn.simpleicons.org/dify/155EEF"
assert apps["feishu"]["logo_url"] == (
"https://www.google.com/s2/favicons?domain=larksuite.com&sz=64"
)
assert apps["jimeng"]["logo_url"] == "https://cdn.simpleicons.org/bytedance/3C8CFF"
assert apps["clibrowser"]["logo_url"] == (
"https://www.google.com/s2/favicons?domain=github.com/allthingssecurity/clibrowser&sz=64"
)
def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []})
_write_cache(
manager._cache_path("public"),
{
"meta": {"updated": "2026-04-18"},
"clis": [
{
"name": "anygen",
"display_name": "AnyGen",
"description": "Generate docs, slides, websites and more via AnyGen cloud API",
"category": "generation",
"install_cmd": "pip install cli-anything-anygen",
"entry_point": "cli-anything-anygen",
}
],
},
)
_write_cache(manager._cache_path("extensions"), {"meta": {}, "clis": []})
payload = manager.payload()
app = payload["apps"][0]
assert app["name"] == "anygen"
assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64"
def test_payload_resolves_obsidian_agent_cli_brand(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_write_cache(
manager._cache_path("harness"),
{
"meta": {"updated": "2026-07-14"},
"clis": [
{
"name": "obsidian-agent-cli",
"display_name": "Obsidian CLI",
"description": "Obsidian automation",
"category": "productivity",
"entry_point": "obsidian-agent",
}
],
},
)
_write_cache(manager._cache_path("public"), {"meta": {}, "clis": []})
_write_cache(manager._cache_path("extensions"), {"meta": {}, "clis": []})
app = manager.payload()["apps"][0]
assert app["brand_color"] == "#7C3AED"
assert app["logo_url"] == "https://cdn.simpleicons.org/obsidian/7C3AED"
def test_installed_payload_enriches_apps_from_cached_catalog(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({
"gimp": {
"entry_point": "installed-gimp",
"source": "harness",
"strategy": "pip",
}
})
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda entry_point: "/bin/installed-gimp" if entry_point == "installed-gimp" else None,
)
app = manager.installed_payload()["apps"][0]
assert app["name"] == "gimp"
assert app["entry_point"] == "installed-gimp"
assert app["source"] == "harness"
assert app["status"] == "installed"
assert app["display_name"] == "GIMP"
assert app["category"] == "image"
assert app["description"] == "Public duplicate entry"
assert app["brand_color"] == "#5C5543"
assert app["logo_url"] == "https://cdn.simpleicons.org/gimp/5C5543"
def test_payload_includes_nanobot_extension_registry(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []})
_write_cache(manager._cache_path("public"), {"meta": {"updated": "2026-04-18"}, "clis": []})
_write_cache(
manager._cache_path("extensions"),
{
"meta": {"updated": "2026-05-29"},
"clis": [
{
"name": "hyperframes",
"display_name": "HyperFrames",
"version": "latest",
"description": "HTML-to-MP4 motion graphics CLI",
"category": "video",
"package_manager": "npm",
"npm_package": "hyperframes",
"install_cmd": "npm install -g hyperframes",
"entry_point": "hyperframes",
"logo_url": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/assets/logo.png",
"brand_color": "#111827",
"skill_md": "skills/hyperframes/SKILL.md",
}
],
},
)
payload = manager.payload()
assert payload["catalog_updated_at"] == "2026-05-29"
app = payload["apps"][0]
assert app["name"] == "hyperframes"
assert app["source"] == "extensions"
assert app["logo_url"] == "https://raw.githubusercontent.com/heygen-com/hyperframes/main/assets/logo.png"
assert app["brand_color"] == "#111827"
assert app["install_supported"] is True
assert app["manifest"]["source"] == "nanobot-extension"
assert app["manifest"]["trust"]["registry"] == "nanobot-extension"
def test_optional_extension_registry_failure_does_not_break_payload(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_write_cache(
manager._cache_path("harness"),
{
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"description": "Image editing",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
}
],
},
)
_write_cache(manager._cache_path("public"), {"meta": {"updated": "2026-04-18"}, "clis": []})
def fail_get(*args, **kwargs):
raise RuntimeError("network unavailable")
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
payload = manager.payload()
assert payload["catalog_updated_at"] == "2026-04-18"
assert [app["name"] for app in payload["apps"]] == ["gimp"]
def test_payload_cache_only_does_not_fetch_catalog(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
def fail_get(*args, **kwargs):
raise AssertionError("network should not be used")
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
payload = manager.payload(cache_only=True)
assert payload["catalog_updated_at"] == "2026-04-18"
assert {app["name"] for app in payload["apps"]} >= {"gimp", "feishu"}
assert manager.catalog_cache_fresh() is True
def test_catalog_cache_fresh_can_include_optional_sources(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_write_cache(
manager._cache_path("harness"),
{"meta": {"updated": "2026-04-16"}, "clis": []},
)
_write_cache(
manager._cache_path("public"),
{"meta": {"updated": "2026-04-18"}, "clis": []},
)
assert manager.catalog_cache_fresh() is True
assert manager.catalog_cache_fresh(include_optional=True) is False
def test_payload_cache_only_without_cache_returns_empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
manager = _manager(tmp_path)
def fail_get(*args, **kwargs):
raise AssertionError("network should not be used")
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
payload = manager.payload(cache_only=True)
assert payload == {"apps": [], "installed_count": 0, "catalog_updated_at": None}
assert manager.catalog_cache_fresh() is False
def test_install_dispatches_safe_pip_and_installs_skill(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(manager, "_run_argv", fake_run)
monkeypatch.setattr(manager, "_pip_available", staticmethod(lambda: True))
monkeypatch.setattr(
manager,
"_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")
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
assert payload["last_action"]["ok"] is True
assert payload["last_action"]["installed"] is True
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"
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(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.apps.cli import service as cli_service
manager = _manager(tmp_path)
records: list[str] = []
class _Logger:
def info(self, message: str, *args: object) -> None:
records.append(message.format(*args))
def fake_run(
argv: list[str],
*,
capture_output: bool,
text: bool,
encoding: str,
errors: str,
timeout: int,
env: dict[str, str],
) -> subprocess.CompletedProcess[str]:
assert capture_output is True
assert text is True
assert encoding == "utf-8"
assert errors == "replace"
assert timeout == 5
assert "OPENAI_API_KEY" not in env
assert env["PYTHONUNBUFFERED"] == "1"
return subprocess.CompletedProcess(argv, 0, stdout="installed ok", stderr="")
monkeypatch.setattr(cli_service, "logger", _Logger())
monkeypatch.setattr(cli_service.subprocess, "run", fake_run)
result = manager._run_argv(["python", "-m", "pip", "install", "sample"], timeout=5)
assert result.returncode == 0
assert any(record.startswith("CLI Apps: running ") for record in records)
assert any("command exited with code 0" in record for record in records)
assert any("installed ok" in record for record in records)
def test_run_argv_decodes_utf8_output(tmp_path: Path) -> None:
manager = _manager(tmp_path)
result = manager._run_argv(
[
sys.executable,
"-c",
"import sys; sys.stdout.buffer.write(chr(0x2713).encode('utf-8'))",
],
timeout=5,
)
assert result.returncode == 0
assert result.stdout == "\u2713"
def test_install_records_available_cli_without_reinstalling(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = tmp_path / "bin" / "lark-cli"
resolved.parent.mkdir()
resolved.write_text("#!/bin/sh\n", encoding="utf-8")
def fail_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
raise AssertionError(f"unexpected install command: {argv}")
monkeypatch.setattr(manager, "_run_argv", fail_run)
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda command: str(resolved) if command == "lark-cli" else None,
)
payload = manager.install("feishu")
assert payload["last_action"]["ok"] is True
assert payload["last_action"]["installed"] is True
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 / "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")
def test_install_recovers_stale_npm_global_directory(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_write_cache(manager._cache_path("harness"), {"meta": {"updated": "2026-04-16"}, "clis": []})
_write_cache(manager._cache_path("public"), {"meta": {"updated": "2026-04-18"}, "clis": []})
_write_cache(
manager._cache_path("extensions"),
{
"meta": {"updated": "2026-05-29"},
"clis": [
{
"name": "hyperframes",
"display_name": "HyperFrames",
"package_manager": "npm",
"npm_package": "hyperframes",
"install_cmd": "npm install -g hyperframes",
"entry_point": "hyperframes",
"skill_md": "skills/hyperframes/SKILL.md",
}
],
},
)
npm = str(tmp_path / "bin" / "npm")
global_root = tmp_path / "global"
stale_package = global_root / "hyperframes"
stale_temp = global_root / ".hyperframes-broken"
stale_package.mkdir(parents=True)
stale_temp.mkdir()
install_attempts = 0
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
nonlocal install_attempts
if argv == [npm, "root", "-g"]:
return subprocess.CompletedProcess(argv, 0, stdout=str(global_root), stderr="")
if argv == [npm, "install", "-g", "hyperframes"]:
install_attempts += 1
if install_attempts == 1:
return subprocess.CompletedProcess(
argv,
1,
stdout="",
stderr="npm error ENOTEMPTY\nnpm error syscall rename",
)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
raise AssertionError(f"unexpected command: {argv}")
monkeypatch.setattr(manager, "_run_argv", fake_run)
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda command: npm if command == "npm" else None,
)
payload = manager.install("hyperframes")
assert install_attempts == 2
assert not stale_package.exists()
assert not stale_temp.exists()
assert payload["last_action"]["ok"] is True
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["hyperframes"]["strategy"] == "npm"
def test_install_records_entry_point_path_and_pip_distribution(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = tmp_path / "bin" / "cli-anything-gimp"
resolved.parent.mkdir()
resolved.write_text("#!/bin/sh\n", encoding="utf-8")
monkeypatch.setattr(
manager,
"_run_argv",
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
)
monkeypatch.setattr(
manager,
"_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
)
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda command: str(resolved) if command == "cli-anything-gimp" else None,
)
monkeypatch.setattr(
"nanobot.apps.cli.service.importlib_metadata.distributions",
lambda: [
SimpleNamespace(
entry_points=[
SimpleNamespace(group="console_scripts", name="cli-anything-gimp"),
],
metadata={"Name": "cli-anything-gimp"},
)
],
)
manager.install("gimp")
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point_path"] == str(resolved)
assert installed["gimp"]["pip_distribution"] == "cli-anything-gimp"
assert installed["gimp"]["display_name"] == "GIMP"
assert installed["gimp"]["category"] == "image"
assert installed["gimp"]["description"] == "Public duplicate entry"
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
manager = _manager(tmp_path)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
manager._save_installed({"zoom": {"entry_point": "cli-anything-zoom"}})
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert set(installed) == {"zoom"}
assert not list(manager.installed_path.parent.glob(".installed.json.*.tmp"))
def test_fetch_skill_content_rejects_untrusted_urls(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
def fail_get(*args, **kwargs):
raise AssertionError("untrusted skill URL should not be fetched")
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
assert manager._fetch_skill_content({
"name": "evil",
"skill_md": "https://example.com/SKILL.md",
}) is None
assert manager._fetch_skill_content({
"name": "evil",
"skill_md": "skills/../evil/SKILL.md",
}) is None
def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
seen: list[str] = []
class Response:
text = "---\nname: cli-app-test\ndescription: Test\n---\n# Test\n"
@staticmethod
def raise_for_status() -> None:
return None
def fake_get(url: str, **kwargs):
seen.append(url)
return Response()
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get)
content = manager._fetch_skill_content({
"name": "gimp",
"skill_md": "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md",
})
assert content and "# Test" in content
assert seen == [
"https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md"
]
def test_fetch_skill_content_uses_extension_raw_base_for_relative_skills(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
seen: list[str] = []
class Response:
text = "---\nname: hyperframes\ndescription: HyperFrames\n---\n# HyperFrames\n"
@staticmethod
def raise_for_status() -> None:
return None
def fake_get(url: str, **kwargs):
seen.append(url)
return Response()
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get)
content = manager._fetch_skill_content({
"name": "hyperframes",
"skill_md": "skills/hyperframes/SKILL.md",
"_raw_base": "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main",
})
assert content and "# HyperFrames" in content
assert seen == [
"https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/skills/hyperframes/SKILL.md"
]
def test_uninstall_removes_installed_state_and_generated_skill(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-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(
manager,
"_run_argv",
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
)
payload = manager.uninstall("gimp")
assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert not plugin_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"suno": {"entry_point": "suno"}})
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(manager, "_run_argv", fake_run)
monkeypatch.setattr(manager, "_pip_available", staticmethod(lambda: True))
payload = manager.uninstall("suno")
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "suno-cli"]]
assert payload["last_action"]["ok"] is True
def test_uninstall_uses_recorded_pip_distribution(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({
"gimp": {
"entry_point": "cli-anything-gimp",
"pip_distribution": "actual-dist-name",
"entry_point_path": str(tmp_path / "bin" / "cli-anything-gimp"),
}
})
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(manager, "_run_argv", fake_run)
monkeypatch.setattr(manager, "_pip_available", staticmethod(lambda: True))
payload = manager.uninstall("gimp")
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "actual-dist-name"]]
assert payload["last_action"]["ok"] is True
assert payload["last_action"]["removed"] is True
assert "entry_point_absent" in payload["last_action"]["verification"]
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
def test_uninstall_keeps_state_when_entry_point_still_available(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
monkeypatch.setattr(
manager,
"_run_argv",
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
)
monkeypatch.setattr(manager, "_pip_available", staticmethod(lambda: True))
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda command: "/usr/local/bin/cli-anything-gimp" if command == "cli-anything-gimp" else None,
)
payload = manager.uninstall("gimp")
assert payload["last_action"]["ok"] is False
assert payload["last_action"]["removed"] is False
assert payload["last_action"]["still_available"] is True
assert payload["last_action"]["verification_failed"] == ["entry_point_absent"]
assert "kept it installed" in payload["last_action"]["message"]
assert "gimp" in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
def test_uninstall_keeps_state_when_recorded_entry_point_still_exists(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = tmp_path / "bin" / "cli-anything-gimp"
resolved.parent.mkdir()
resolved.write_text("#!/bin/sh\n", encoding="utf-8")
manager._save_installed({
"gimp": {
"entry_point": "cli-anything-gimp",
"entry_point_path": str(resolved),
}
})
monkeypatch.setattr(
manager,
"_run_argv",
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
)
payload = manager.uninstall("gimp")
assert payload["last_action"]["ok"] is False
assert str(resolved) in payload["last_action"]["message"]
assert "gimp" in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path) -> None:
manager = _manager(tmp_path)
manager._save_installed(
{
"gimp": {"entry_point": "cli-anything-gimp", "source": "harness"},
"zoom": {"entry_point": "cli-anything-zoom", "source": "public"},
}
)
mentions = manager.mentioned_installed_apps("use @zoom and @krita, then @GIMP")
assert mentions == [
{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"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": "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)
with pytest.raises(CliAppError, match="not found"):
manager.install("missing")
with pytest.raises(CliAppError, match="unsupported"):
manager.install("jimeng")
def test_run_installed_cli_uses_argv_without_shell(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
class _Process:
returncode = 0
def communicate(self, *, timeout: int) -> tuple[str, str]:
assert timeout == 5
return "ARGS=['--json', 'project', 'list']", ""
def fake_popen(argv: list[str], **kwargs: object) -> _Process:
assert argv == [resolved, "--json", "project", "list"]
assert "shell" not in kwargs
assert kwargs["text"] is True
assert kwargs["encoding"] == "utf-8"
assert kwargs["errors"] == "replace"
return _Process()
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.Popen", fake_popen)
monkeypatch.setattr("nanobot.apps.cli.service._WindowsJob.create", lambda: None)
manager._save_installed(
{
"gimp": {
"version": "1.0.0",
"entry_point": "cli-anything-gimp",
"source": "harness",
"strategy": "pip",
}
}
)
result = manager.run("gimp", ["project", "list"], json_output=True)
assert "CLI app 'gimp' exited 0" in result
assert "['--json', 'project', 'list']" in result
def test_run_reports_created_artifacts(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
class _Process:
returncode = 0
def communicate(self, *, timeout: int) -> tuple[str, str]:
assert timeout == 5
return "done", ""
def fake_popen(argv: list[str], **kwargs: object) -> _Process:
assert argv == [resolved, "render"]
cwd = Path(str(kwargs["cwd"]))
(cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
return _Process()
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.Popen", fake_popen)
monkeypatch.setattr("nanobot.apps.cli.service._WindowsJob.create", lambda: None)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
result = manager.run("gimp", ["render"])
assert "Artifacts created or updated:" in result
assert "diagram.png (previewable image" in result
assert "![diagram](diagram.png)" in result
def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
with pytest.raises(CliAppError, match="outside the configured workspace"):
manager.run("gimp", working_dir="/etc", restrict_to_workspace=True)
def test_install_uses_uv_pip_when_pip_unavailable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(CliAppManager, "_pip_available", staticmethod(lambda: False))
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda command: "/usr/bin/uv" if command == "uv" else None,
)
monkeypatch.setattr(manager, "_run_argv", fake_run)
monkeypatch.setattr(manager, "_fetch_skill_content", lambda app: None)
manager.install("gimp")
assert calls[0][:6] == [
"uv",
"pip",
"install",
"--python",
sys.executable,
"cli-anything-gimp",
]
def test_update_uses_uv_pip_reinstall_when_pip_unavailable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
monkeypatch.setattr(CliAppManager, "_pip_available", staticmethod(lambda: False))
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda command: "/usr/bin/uv" if command == "uv" else None,
)
argv = manager._pip_install_argv(
{"name": "gimp", "install_cmd": "pip install cli-anything-gimp"},
update=True,
)
assert argv == [
"uv",
"pip",
"install",
"--python",
sys.executable,
"--upgrade",
"--reinstall",
"cli-anything-gimp",
]
def test_uninstall_uses_uv_pip_when_pip_unavailable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"suno": {"entry_point": "suno"}})
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(CliAppManager, "_pip_available", staticmethod(lambda: False))
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda command: "/usr/bin/uv" if command == "uv" else None,
)
monkeypatch.setattr(manager, "_run_argv", fake_run)
manager.uninstall("suno")
assert calls[0] == [
"uv",
"pip",
"uninstall",
"--python",
sys.executable,
"suno-cli",
]
@pytest.mark.asyncio
async def test_run_async_keeps_event_loop_responsive(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "python"}})
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda entry: sys.executable if entry == "python" else None,
)
task = asyncio.create_task(
manager.run_async(
"gimp",
["-c", "import time; time.sleep(0.08); print('done')"],
)
)
for _ in range(3):
await asyncio.sleep(0.01)
assert not task.done()
result = await asyncio.wait_for(task, timeout=2)
assert "CLI app 'gimp' exited 0" in result
assert "done" in result
@pytest.mark.asyncio
async def test_run_async_cancellation_terminates_process_tree(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "python"}})
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda entry: sys.executable if entry == "python" else None,
)
started = manager.workspace / "parent-started.txt"
orphan_marker = manager.workspace / "orphan-survived.txt"
child_code = (
"import time; from pathlib import Path; time.sleep(0.5); "
f"Path({str(orphan_marker)!r}).write_text('orphan', encoding='utf-8')"
)
parent_code = (
"import subprocess, sys, time; from pathlib import Path; "
f"subprocess.Popen([sys.executable, '-c', {child_code!r}]); "
f"Path({str(started)!r}).write_text('started', encoding='utf-8'); "
"time.sleep(30)"
)
task = asyncio.create_task(manager.run_async("gimp", ["-c", parent_code]))
try:
for _ in range(200):
if started.exists():
break
await asyncio.sleep(0.01)
assert started.exists()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=3)
await asyncio.sleep(0.8)
assert not orphan_marker.exists()
finally:
if not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
def _parent_exits_with_pipe_holding_descendant(started: Path, marker: Path) -> str:
child_code = (
"import time; from pathlib import Path; time.sleep(1.5); "
f"Path({str(marker)!r}).write_text('survived', encoding='utf-8')"
)
return (
"import subprocess, sys, time; from pathlib import Path; "
"time.sleep(0.25); "
f"subprocess.Popen([sys.executable, '-c', {child_code!r}]); "
f"Path({str(started)!r}).write_text('spawned', encoding='utf-8')"
)
@pytest.mark.asyncio
async def test_run_async_timeout_kills_pipe_holding_descendant_after_parent_exit(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "python"}})
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda entry: sys.executable if entry == "python" else None,
)
started = manager.workspace / "descendant-started.txt"
marker = manager.workspace / "descendant-survived.txt"
parent_code = _parent_exits_with_pipe_holding_descendant(started, marker)
before = time.monotonic()
result = await asyncio.wait_for(
manager.run_async("gimp", ["-c", parent_code], timeout=1),
timeout=4,
)
elapsed = time.monotonic() - before
assert started.exists()
assert result == "CLI app 'gimp' timed out after 1s"
assert elapsed < 4
await asyncio.sleep(0.9)
assert not marker.exists()
def test_run_timeout_kills_pipe_holding_descendant_after_parent_exit(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "python"}})
monkeypatch.setattr(
"nanobot.apps.cli.service.shutil.which",
lambda entry: sys.executable if entry == "python" else None,
)
started = manager.workspace / "sync-descendant-started.txt"
marker = manager.workspace / "sync-descendant-survived.txt"
parent_code = _parent_exits_with_pipe_holding_descendant(started, marker)
before = time.monotonic()
result = manager.run("gimp", ["-c", parent_code], timeout=1)
elapsed = time.monotonic() - before
assert started.exists()
assert result == "CLI app 'gimp' timed out after 1s"
assert elapsed < 4
time.sleep(0.9)
assert not marker.exists()