feat: add CLI Apps settings MVP
This commit is contained in:
@@ -362,6 +362,21 @@ class TestBuildMessages:
|
||||
assert "Other chat goal." not in str(without_goal[-1]["content"])
|
||||
assert "Goal (active):" not in str(without_goal[-1]["content"])
|
||||
|
||||
def test_current_runtime_lines_are_injected(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
messages = builder.build_messages(
|
||||
[],
|
||||
"please use @zoom tonight",
|
||||
current_runtime_lines=[
|
||||
"CLI App Attachment: @zoom (installed; tool=run_cli_app; entry_point=cli-anything-zoom).",
|
||||
],
|
||||
)
|
||||
user_msg = str(messages[-1]["content"])
|
||||
|
||||
assert "CLI App Attachment: @zoom" in user_msg
|
||||
assert "tool=run_cli_app" in user_msg
|
||||
assert "entry_point=cli-anything-zoom" in user_msg
|
||||
|
||||
def test_consecutive_same_role_merged(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
history = [{"role": "user", "content": "previous user message"}]
|
||||
|
||||
@@ -359,6 +359,31 @@ def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
|
||||
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
|
||||
|
||||
|
||||
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
|
||||
session = Session(key="test:cli-app")
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "please use @drawio",
|
||||
"cli_apps": [{
|
||||
"name": "drawio",
|
||||
"entry_point": "cli-anything-drawio",
|
||||
}],
|
||||
}
|
||||
)
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert history == [{
|
||||
"role": "user",
|
||||
"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]"
|
||||
),
|
||||
}]
|
||||
|
||||
|
||||
def test_get_history_ignores_media_kwarg_on_non_user_rows():
|
||||
"""``media`` only ever appears on user entries in practice, but the
|
||||
synthesizer must be defensive: assistants / tools with list content
|
||||
|
||||
@@ -105,6 +105,43 @@ async def test_message_without_media_backward_compatible() -> None:
|
||||
assert call.kwargs["media"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_forwards_normalized_cli_app_attachments() -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "please use @drawio",
|
||||
"webui": True,
|
||||
"cli_apps": [
|
||||
{
|
||||
"name": "DrawIO",
|
||||
"display_name": "Draw.io",
|
||||
"category": "diagram",
|
||||
"entry_point": "cli-anything-drawio",
|
||||
"logo_url": "https://example.invalid/drawio.svg",
|
||||
"brand_color": "#F08705",
|
||||
},
|
||||
{"name": "bad name", "entry_point": "nope"},
|
||||
],
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
metadata = channel._handle_message.call_args.kwargs["metadata"]
|
||||
assert metadata["webui"] is True
|
||||
assert metadata["cli_apps"] == [{
|
||||
"name": "drawio",
|
||||
"display_name": "Draw.io",
|
||||
"category": "diagram",
|
||||
"entry_point": "cli-anything-drawio",
|
||||
"logo_url": "https://example.invalid/drawio.svg",
|
||||
"brand_color": "#F08705",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
|
||||
@@ -140,6 +140,75 @@ async def test_sessions_routes_require_bearer_token(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_apps_routes_require_token_and_return_payload(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.websocket.cli_apps_payload",
|
||||
lambda: {
|
||||
"apps": [
|
||||
{
|
||||
"name": "gimp",
|
||||
"display_name": "GIMP",
|
||||
"category": "image",
|
||||
"description": "Image editing",
|
||||
"requires": "Python",
|
||||
"source": "harness",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
"install_supported": True,
|
||||
"installed": False,
|
||||
"available": False,
|
||||
"status": "not_installed",
|
||||
"logo_url": None,
|
||||
"brand_color": None,
|
||||
"skill_installed": False,
|
||||
}
|
||||
],
|
||||
"installed_count": 0,
|
||||
"catalog_updated_at": "2026-04-18",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.websocket.cli_apps_action",
|
||||
lambda action, query: {
|
||||
"apps": [],
|
||||
"installed_count": 1,
|
||||
"catalog_updated_at": "2026-04-18",
|
||||
"last_action": {"ok": True, "message": f"{action}:{query['name'][0]}"},
|
||||
},
|
||||
)
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29912)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps")
|
||||
assert deny.status_code == 401
|
||||
|
||||
boot = await _http_get("http://127.0.0.1:29912/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
catalog = await _http_get(
|
||||
"http://127.0.0.1:29912/api/settings/cli-apps",
|
||||
headers=auth,
|
||||
)
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()["apps"][0]["name"] == "gimp"
|
||||
|
||||
installed = await _http_get(
|
||||
"http://127.0.0.1:29912/api/settings/cli-apps/install?name=gimp",
|
||||
headers=auth,
|
||||
)
|
||||
assert installed.status_code == 200
|
||||
assert installed.json()["last_action"]["message"] == "install:gimp"
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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["clibrowser"]["install_supported"] is False
|
||||
assert apps["jimeng"]["install_supported"] is False
|
||||
assert apps["suno"]["install_supported"] is True
|
||||
assert apps["gimp"]["logo_url"]
|
||||
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_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,
|
||||
"_fetch_skill_content",
|
||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
||||
)
|
||||
|
||||
payload = manager.install("gimp")
|
||||
|
||||
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
|
||||
assert payload["last_action"]["ok"] is True
|
||||
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"
|
||||
assert skill.is_file()
|
||||
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
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.cli_apps.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.cli_apps.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_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"}})
|
||||
skill_dir = manager.workspace / "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 skill_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)
|
||||
|
||||
payload = manager.uninstall("suno")
|
||||
|
||||
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "suno-cli"]]
|
||||
assert payload["last_action"]["ok"] is True
|
||||
|
||||
|
||||
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": "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",
|
||||
"tool": "run_cli_app",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
cli = bin_dir / "cli-anything-gimp"
|
||||
cli.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import sys\n"
|
||||
"print('ARGS=' + repr(sys.argv[1:]))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cli.chmod(cli.stat().st_mode | stat.S_IEXEC)
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}")
|
||||
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_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)
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.agent.tools.cli_apps import CliAppsTool
|
||||
from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
|
||||
|
||||
|
||||
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 test_run_cli_app_uses_installed_registry_app(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
data_dir = tmp_path / "data"
|
||||
registry = {
|
||||
"meta": {"updated": "2026-04-16"},
|
||||
"clis": [
|
||||
{
|
||||
"name": "gimp",
|
||||
"display_name": "GIMP",
|
||||
"version": "1.0.0",
|
||||
"description": "Image editing",
|
||||
"category": "image",
|
||||
"install_cmd": "pip install cli-anything-gimp",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
}
|
||||
],
|
||||
}
|
||||
_write_cache(data_dir / "harness_registry_cache.json", registry)
|
||||
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
||||
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
|
||||
{"gimp": {"entry_point": "cli-anything-gimp"}}
|
||||
)
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
cli = bin_dir / "cli-anything-gimp"
|
||||
cli.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import sys\n"
|
||||
"print('tool:' + ' '.join(sys.argv[1:]))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cli.chmod(cli.stat().st_mode | stat.S_IEXEC)
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}")
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
|
||||
tool = CliAppsTool(
|
||||
workspace=workspace,
|
||||
restrict_to_workspace=True,
|
||||
runtime=CliAppsRuntimeConfig(run_timeout=5),
|
||||
)
|
||||
assert tool.name == "run_cli_app"
|
||||
|
||||
result = asyncio.run(
|
||||
tool.execute(
|
||||
name="gimp",
|
||||
args=["project", "list"],
|
||||
json=True,
|
||||
working_dir=str(workspace),
|
||||
)
|
||||
)
|
||||
|
||||
assert "CLI app 'gimp' exited 0" in result
|
||||
assert "tool:--json project list" in result
|
||||
|
||||
|
||||
def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
data_dir = tmp_path / "data"
|
||||
registry = {
|
||||
"meta": {"updated": "2026-04-16"},
|
||||
"clis": [
|
||||
{
|
||||
"name": "gimp",
|
||||
"display_name": "GIMP",
|
||||
"version": "1.0.0",
|
||||
"description": "Image editing",
|
||||
"category": "image",
|
||||
"install_cmd": "pip install cli-anything-gimp",
|
||||
"entry_point": "cli-anything-gimp",
|
||||
}
|
||||
],
|
||||
}
|
||||
_write_cache(data_dir / "harness_registry_cache.json", registry)
|
||||
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
|
||||
|
||||
result = asyncio.run(tool.execute(name="gimp"))
|
||||
|
||||
assert "not installed" in result
|
||||
|
||||
|
||||
def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Path, monkeypatch) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
data_dir = tmp_path / "data"
|
||||
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
|
||||
{"drawio": {"entry_point": "cli-anything-drawio"}}
|
||||
)
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
|
||||
tool = CliAppsTool(workspace=workspace)
|
||||
|
||||
assert "Settings CLI Apps: drawio" in tool.description
|
||||
assert "ordinary system CLIs such as git, gh" in tool.description
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests for CLI Apps loop helpers."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.cli_apps.service import CliAppManager
|
||||
from nanobot.cli_apps.utils import runtime_lines, session_extra
|
||||
|
||||
|
||||
def test_session_extra_returns_cli_apps_only_when_present() -> None:
|
||||
cli_apps = [{"name": "zoom"}]
|
||||
assert session_extra({"cli_apps": cli_apps}) == {"cli_apps": cli_apps}
|
||||
assert session_extra({}) == {}
|
||||
assert session_extra(None) == {}
|
||||
|
||||
|
||||
def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
||||
data_dir = tmp_path / "data"
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
manager = CliAppManager(workspace=tmp_path)
|
||||
manager._save_installed(
|
||||
{
|
||||
"zoom": {
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"source": "harness",
|
||||
},
|
||||
"krita": {
|
||||
"entry_point": "cli-anything-krita",
|
||||
"source": "harness",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
lines = runtime_lines(
|
||||
SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}),
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
joined = "\n".join(lines)
|
||||
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
|
||||
|
||||
|
||||
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
||||
lines = runtime_lines(
|
||||
SimpleNamespace(
|
||||
content="please use @zoom tonight",
|
||||
metadata={
|
||||
"cli_apps": [{
|
||||
"name": "zoom",
|
||||
"entry_point": "cli-anything-zoom",
|
||||
"display_name": "Zoom",
|
||||
}],
|
||||
},
|
||||
),
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
joined = "\n".join(lines)
|
||||
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
|
||||
@@ -91,6 +91,7 @@ def test_discover_finds_concrete_tools():
|
||||
class_names = {cls.__name__ for cls in discovered}
|
||||
assert "ApplyPatchTool" in class_names
|
||||
assert "ExecTool" in class_names
|
||||
assert "CliAppsTool" in class_names
|
||||
assert "MessageTool" in class_names
|
||||
assert "SpawnTool" in class_names
|
||||
assert "WriteStdinTool" in class_names
|
||||
@@ -366,6 +367,7 @@ def test_config_defaults():
|
||||
assert config.tools.my.enable is True
|
||||
assert config.tools.my.allow_set is False
|
||||
assert config.tools.image_generation.enabled is False
|
||||
assert config.tools.cli_apps.enable is True
|
||||
assert config.tools.restrict_to_workspace is False
|
||||
|
||||
|
||||
|
||||
@@ -143,6 +143,50 @@ def test_replay_tool_events_dedupes_finish_after_start() -> None:
|
||||
'exec({"cmd": "ls"})',
|
||||
'read_file({"path": "notes.md"})',
|
||||
]
|
||||
assert msgs[0]["toolEvents"][0]["phase"] == "end"
|
||||
assert msgs[0]["toolEvents"][0]["call_id"] == "call-exec"
|
||||
|
||||
|
||||
def test_replay_tool_events_keeps_phase_update_when_trace_is_deduped() -> None:
|
||||
args = {"name": "github", "args": ["repo", "view"], "json": "true"}
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-tool",
|
||||
"text": "",
|
||||
"kind": "tool_hint",
|
||||
"tool_events": [
|
||||
{
|
||||
"phase": "start",
|
||||
"call_id": "call-cli",
|
||||
"name": "run_cli_app",
|
||||
"arguments": args,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-tool",
|
||||
"text": "",
|
||||
"kind": "progress",
|
||||
"tool_events": [
|
||||
{
|
||||
"phase": "error",
|
||||
"call_id": "call-cli",
|
||||
"name": "run_cli_app",
|
||||
"arguments": args,
|
||||
"error": "Error: CLI app 'github' not found",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["traces"] == [
|
||||
'run_cli_app({"name": "github", "args": ["repo", "view"], "json": "true"})',
|
||||
]
|
||||
assert msgs[0]["toolEvents"][0]["phase"] == "error"
|
||||
assert msgs[0]["toolEvents"][0]["error"] == "Error: CLI app 'github' not found"
|
||||
|
||||
|
||||
def test_replay_file_edit_progress_merges_after_interleaved_activity(tmp_path, monkeypatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user