feat(apps): unify CLI apps and MCP (#3991)

* refactor(cli): load bundled apps from catalog

* feat(plugins): unify CLI and MCP settings

* feat(plugins): add settings category filter

* style(plugins): refine settings catalog

* refactor(cli): load nanobot apps from repo catalog

* feat(store): add capability store entry

* feat(apps): rename capability store

* fix(apps): verify clean app removal

* fix(apps): keep main sidebar on apps view

* feat(apps): add shared app manifest protocol

* fix(apps): dismiss app status message

* refactor(apps): move CLI adapter under apps

* refactor(apps): drop legacy cli apps package
This commit is contained in:
Xubin Ren 2026-05-25 20:07:02 +08:00 committed by GitHub
parent 179acfe104
commit 418cb23da2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2026 additions and 903 deletions

View File

@ -13,7 +13,7 @@ from nanobot.agent.skills import SkillsLoader
from nanobot.agent.tools import mcp as mcp_tools
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage
from nanobot.cli_apps import utils as cli_app_utils
from nanobot.apps.cli import utils as cli_app_utils
from nanobot.session.goal_state import goal_state_runtime_lines
from nanobot.utils.helpers import (
current_time_str,

View File

@ -9,7 +9,7 @@ from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import Base

5
nanobot/apps/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""Shared app protocol helpers."""
from nanobot.apps.protocol import APP_PROTOCOL_SCHEMA, app_manifest
__all__ = ["APP_PROTOCOL_SCHEMA", "app_manifest"]

View File

@ -1,6 +1,6 @@
"""CLI Apps integration helpers."""
"""CLI app adapter for the unified Apps domain."""
from nanobot.cli_apps.service import (
from nanobot.apps.cli.service import (
CliAppError,
CliAppManager,
CliAppsRuntimeConfig,

View File

@ -11,12 +11,14 @@ import subprocess
import sys
import time
from dataclasses import dataclass
from importlib import metadata as importlib_metadata
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.paths import get_runtime_subdir
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
@ -139,7 +141,7 @@ _BRANDS: dict[str, tuple[str, str]] = {
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
"3mf": ("3mf.io", "#00A1DE"),
"anygen": ("anygen.com", "#111827"),
"anygen": ("anygen.io", "#111827"),
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
@ -244,6 +246,29 @@ def _pip_uninstall_args_from_command(command: str) -> list[str] | None:
return packages
def _console_script_distribution(entry_point: str) -> str | None:
if not entry_point:
return None
try:
distributions = importlib_metadata.distributions()
except Exception:
return None
for distribution in distributions:
try:
entry_points = distribution.entry_points
except Exception:
continue
for item in entry_points:
if item.group != "console_scripts" or item.name != entry_point:
continue
try:
name = distribution.metadata.get("Name")
except Exception:
name = None
return str(name or getattr(distribution, "name", "") or "").strip() or None
return None
def _brand_key(value: str) -> str:
return _SAFE_NAME_RE.sub("-", value.lower()).replace("_", "-").strip("-")
@ -540,8 +565,86 @@ class CliAppManager:
"logo_url": logo_url,
"brand_color": brand_color,
"skill_installed": self._skill_path(name).is_file(),
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
}
def _package_ref(self, app: dict[str, Any]) -> dict[str, Any] | None:
strategy = self._strategy(app)
name = ""
if strategy == "pip":
try:
uninstall = self._pip_uninstall_argv(app)
except CliAppError:
uninstall = None
name = uninstall[-1] if uninstall else ""
elif strategy == "npm":
name = str(app.get("npm_package") or "").strip()
elif strategy in {"brew", "uv"}:
try:
uninstall = self._argv_for_action(app, "uninstall")
except CliAppError:
uninstall = None
if uninstall:
name = uninstall[-1]
if not strategy or strategy in {"unsupported", "bundled"}:
return None
return compact_dict({"manager": strategy, "name": name})
def _manifest_payload(
self,
app: dict[str, Any],
*,
logo_url: str | None,
brand_color: str | None,
) -> dict[str, Any]:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app)
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
capabilities = [
compact_dict({
"type": "cli",
"entry_point": entry_point,
"package": self._package_ref(app),
}),
{"type": "skill", "path": skill_path},
]
install_supported = self._install_supported(app)
install = compact_dict({
"supported": install_supported,
"strategy": strategy,
"managed_paths": [skill_path],
"verification": ["entry_point_available"] if entry_point else [],
})
remove = compact_dict({
"supported": strategy != "unsupported",
"strategy": strategy,
"managed_paths": [skill_path],
"verification": (
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
if strategy not in {"bundled", "unsupported"}
else ["nanobot_state_absent", "managed_paths_absent"]
),
})
return app_manifest(
app_id=name,
display_name=str(app.get("display_name") or name),
version=str(app.get("version") or ""),
description=str(app.get("description") or ""),
category=str(app.get("category") or "uncategorized"),
source=f"cli-anything:{app.get('_source') or 'harness'}",
logo_url=logo_url,
brand_color=brand_color,
capabilities=capabilities,
install=install,
remove=remove,
trust={
"registry": "cli-anything",
"level": "catalog",
"review_status": "catalog_entry",
},
)
def payload(self, *, force_refresh: bool = False) -> dict[str, Any]:
apps, updated = self.catalog(force_refresh=force_refresh)
installed = self._load_installed()
@ -581,7 +684,14 @@ class CliAppManager:
prefix.extend(["--upgrade", "--force-reinstall"])
return prefix + args
def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]:
def _pip_uninstall_argv(
self,
app: dict[str, Any],
installed_entry: dict[str, Any] | None = None,
) -> list[str]:
distribution = str((installed_entry or {}).get("pip_distribution") or "").strip()
if distribution:
return [sys.executable, "-m", "pip", "uninstall", "-y", distribution]
uninstall_cmd = str(app.get("uninstall_cmd") or "")
packages = _pip_uninstall_args_from_command(uninstall_cmd)
if packages:
@ -619,14 +729,19 @@ class CliAppManager:
raise CliAppError(f"unsupported {expected} command")
return argv
def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None:
def _argv_for_action(
self,
app: dict[str, Any],
action: str,
installed_entry: dict[str, Any] | None = None,
) -> list[str] | None:
strategy = self._strategy(app)
if strategy == "pip":
if action == "install":
return self._pip_install_argv(app)
if action == "update":
return self._pip_install_argv(app, update=True)
return self._pip_uninstall_argv(app)
return self._pip_uninstall_argv(app, installed_entry=installed_entry)
if strategy == "npm":
return self._npm_argv(app, action)
if strategy == "brew":
@ -648,13 +763,23 @@ class CliAppManager:
)
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
return {
entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app)
entry: dict[str, Any] = {
"version": app.get("version") or "unknown",
"entry_point": app.get("entry_point") or "",
"entry_point": entry_point,
"source": app.get("_source") or "harness",
"strategy": self._strategy(app),
"strategy": strategy,
"installed_at": int(_now()),
}
resolved = shutil.which(entry_point) if entry_point else None
if resolved:
entry["entry_point_path"] = resolved
if strategy == "pip":
distribution = _console_script_distribution(entry_point)
if distribution:
entry["pip_distribution"] = distribution
return entry
def _fetch_skill_content(self, app: dict[str, Any]) -> str | None:
skill_md = str(app.get("skill_md") or "").strip()
@ -730,11 +855,13 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
def _record_installed(self, app: dict[str, Any]) -> None:
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
installed = self._load_installed()
installed[str(app["name"])] = self._installed_entry(app)
entry = self._installed_entry(app)
installed[str(app["name"])] = entry
self._save_installed(installed)
self.install_skill(app)
return entry
def install(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
@ -745,7 +872,14 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "")
if detect_cmd and _command_exists(detect_cmd):
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"CLI for {app['display_name']} is available."}}
return self.payload() | {
"last_action": {
"ok": True,
"message": f"CLI for {app['display_name']} is available.",
"installed": True,
"verification": ["entry_point_available", "state_recorded"],
}
}
note = app.get("install_notes") or f"{app['display_name']} is bundled with its parent app."
raise CliAppError(str(note))
argv = self._argv_for_action(app, "install")
@ -754,7 +888,14 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "install failed"), status=500)
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Installed CLI for {app['display_name']}."}}
return self.payload() | {
"last_action": {
"ok": True,
"message": f"Installed CLI for {app['display_name']}.",
"installed": True,
"verification": ["package_manager_ok", "state_recorded", "managed_paths_present"],
}
}
def update(self, name: str) -> dict[str, Any]:
app = self.get_app(name, force_refresh=True)
@ -762,30 +903,94 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
raise CliAppError("CLI app is not installed")
if self._strategy(app) == "bundled":
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Checked {app['display_name']}."}}
return self.payload() | {
"last_action": {
"ok": True,
"message": f"Checked {app['display_name']}.",
"installed": True,
"verification": ["state_recorded"],
}
}
argv = self._argv_for_action(app, "update")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "update failed"), status=500)
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Updated CLI for {app['display_name']}."}}
return self.payload() | {
"last_action": {
"ok": True,
"message": f"Updated CLI for {app['display_name']}.",
"installed": True,
"verification": ["package_manager_ok", "state_recorded", "managed_paths_present"],
}
}
def uninstall(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
installed = self._load_installed()
if str(app["name"]) not in installed:
raise CliAppError("CLI app is not installed")
if self._strategy(app) != "bundled":
argv = self._argv_for_action(app, "uninstall")
raw_installed_entry = installed.get(str(app["name"]))
installed_entry = raw_installed_entry if isinstance(raw_installed_entry, dict) else {}
strategy = self._strategy(app)
entry_point = str(app.get("entry_point") or "").strip()
managed_entry_path = str(installed_entry.get("entry_point_path") or "").strip()
if strategy != "bundled":
argv = self._argv_for_action(app, "uninstall", installed_entry=installed_entry)
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "uninstall failed"), status=500)
still_managed = bool(managed_entry_path and Path(managed_entry_path).exists())
still_available = bool(entry_point and shutil.which(entry_point))
if still_managed or (not managed_entry_path and still_available):
reason = (
f"the recorded entry point at {managed_entry_path} still exists"
if still_managed
else f"{entry_point} is still available on PATH"
)
message = (
f"Uninstall for {app['display_name']} completed, but {reason}, "
"so nanobot kept it installed."
)
return self.payload() | {
"last_action": {
"ok": False,
"message": message,
"removed": False,
"still_available": True,
"verification_failed": ["entry_point_absent"],
}
}
else:
still_available = bool(entry_point and shutil.which(entry_point))
installed.pop(str(app["name"]), None)
self._save_installed(installed)
self.remove_skill(str(app["name"]))
return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_name']}."}}
if strategy == "bundled" and still_available:
message = (
f"Removed {app['display_name']} from nanobot. {entry_point} "
"is still available because it is managed outside nanobot."
)
elif still_available:
message = (
f"Uninstalled CLI for {app['display_name']}, but another {entry_point} "
"is still available on PATH."
)
else:
message = f"Uninstalled CLI for {app['display_name']}."
return self.payload() | {
"last_action": {
"ok": True,
"message": message,
"removed": True,
"still_available": still_available,
"verification": ["state_absent", "managed_paths_absent"]
if still_available
else ["entry_point_absent", "state_absent", "managed_paths_absent"],
}
}
def test(self, name: str) -> dict[str, Any]:
app = self.get_app(name)

View File

@ -46,7 +46,7 @@ def _cli_app_runtime_lines(
if "@" not in text:
return []
try:
from nanobot.cli_apps import CliAppManager
from nanobot.apps.cli import CliAppManager
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
except Exception:

56
nanobot/apps/protocol.py Normal file
View File

@ -0,0 +1,56 @@
"""Neutral manifest shape for settings-managed agent apps.
The manifest is intentionally descriptive. Installers still live in their
own adapters, while this protocol gives the WebUI and future registries one
small vocabulary for capabilities, trust, and verified install/remove plans.
"""
from __future__ import annotations
from typing import Any
APP_PROTOCOL_SCHEMA = "agent-app.v1"
def compact_dict(values: dict[str, Any]) -> dict[str, Any]:
"""Drop empty optional values while preserving explicit booleans and zeros."""
return {
key: value
for key, value in values.items()
if value is not None and value != "" and value != [] and value != {}
}
def app_manifest(
*,
app_id: str,
display_name: str,
description: str,
category: str,
source: str,
capabilities: list[dict[str, Any]],
install: dict[str, Any],
remove: dict[str, Any],
trust: dict[str, Any],
version: str | None = None,
logo_url: str | None = None,
brand_color: str | None = None,
docs_url: str | None = None,
) -> dict[str, Any]:
"""Build a stable app manifest dictionary."""
return compact_dict({
"schema": APP_PROTOCOL_SCHEMA,
"id": app_id,
"display_name": display_name,
"version": version,
"description": description,
"category": category,
"source": source,
"logo_url": logo_url,
"brand_color": brand_color,
"docs_url": docs_url,
"capabilities": capabilities,
"install": install,
"remove": remove,
"trust": trust,
})

View File

@ -10,10 +10,11 @@ import pydantic
from loguru import logger
from pydantic import BaseModel
from nanobot.config.schema import Config
from nanobot.config.schema import Config, _resolve_tool_config_refs
# Global variable to store current config path (for multi-instance support)
_current_config_path: Path | None = None
_schema_refs_ready = False
def set_config_path(path: Path) -> None:
@ -39,6 +40,11 @@ def load_config(config_path: Path | None = None) -> Config:
Returns:
Loaded configuration object.
"""
global _schema_refs_ready
if not _schema_refs_ready:
_resolve_tool_config_refs()
_schema_refs_ready = True
path = config_path or get_config_path()
config = Config()

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import re
from typing import Any
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.loader import load_config
QueryParams = dict[str, list[str]]

View File

@ -16,6 +16,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal, Mapping
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
from nanobot.config.paths import get_runtime_subdir
@ -475,6 +476,20 @@ def _with_managed_stdio_cwd(name: str, cfg: MCPServerConfig) -> MCPServerConfig:
return cfg
def _remove_managed_stdio_cwd(name: str, cfg: MCPServerConfig | None) -> bool:
if cfg is None or not cfg.cwd:
return False
cwd = Path(cfg.cwd).expanduser().resolve(strict=False)
managed = (get_runtime_subdir("mcp") / name).resolve(strict=False)
if cwd != managed or not cwd.exists():
return False
if cwd.is_symlink() or cwd.is_file():
cwd.unlink()
else:
shutil.rmtree(cwd)
return True
def _url_with_param(url: str, key: str, value: str) -> str:
parsed = urllib.parse.urlsplit(url)
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
@ -647,10 +662,108 @@ def _tool_allowlist(cfg: MCPServerConfig | None) -> list[str]:
return list(cfg.enabled_tools)
def _managed_mcp_path(name: str, cfg: MCPServerConfig | None) -> list[str]:
if cfg is None or not cfg.command:
return []
return [f"runtime:mcp/{name}"]
def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]:
server = preset.server
managed_paths = _managed_mcp_path(preset.name, server)
field_specs = [
compact_dict({
"name": field.name,
"target": field.target[0],
"required": field.required,
"secret": field.secret,
"env_var": field.env_var,
})
for field in preset.fields
]
capabilities = [
compact_dict({
"type": "mcp",
"transport": preset.transport,
"command": server.command if server and server.command else None,
"args": list(server.args) if server and server.command else None,
"url": _connection_summary(server) if server and server.url else None,
"fields": field_specs,
})
]
return app_manifest(
app_id=preset.name,
display_name=preset.display_name,
description=preset.description,
category=preset.category,
source="mcp-preset",
docs_url=preset.docs_url,
logo_url=logo_url,
brand_color=preset.brand_color,
capabilities=capabilities,
install=compact_dict({
"supported": preset.install_supported,
"strategy": "config",
"managed_paths": managed_paths,
"verification": ["config_present", "dependency_available"],
}),
remove=compact_dict({
"supported": True,
"strategy": "config",
"managed_paths": managed_paths,
"verification": ["config_absent", "managed_paths_absent"] if managed_paths else ["config_absent"],
}),
trust={
"registry": "mcp-presets",
"level": "builtin",
"review_status": "builtin_preset",
},
)
def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
transport = cfg.type or ("stdio" if cfg.command else "streamableHttp")
managed_paths: list[str] = []
return app_manifest(
app_id=name,
display_name=name,
description="Custom MCP server from nanobot config.",
category="custom",
source="mcp-custom",
brand_color="#64748B",
capabilities=[
compact_dict({
"type": "mcp",
"transport": transport,
"command": cfg.command or None,
"url": _connection_summary(cfg) if cfg.url else None,
})
],
install=compact_dict({
"supported": True,
"strategy": "config",
"managed_paths": managed_paths,
"verification": ["config_present", "dependency_available"],
}),
remove=compact_dict({
"supported": True,
"strategy": "config",
"managed_paths": managed_paths,
"verification": ["config_absent", "managed_paths_absent"] if managed_paths else ["config_absent"],
}),
trust={
"registry": "user-config",
"level": "user",
"review_status": "user_managed",
},
)
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
cfg = configured_servers.get(preset.name)
status = _status_for(preset, cfg)
configured = cfg is not None and status not in {"missing_credentials"}
logo_url = _favicon_url(preset.brand_domain)
return {
"name": preset.name,
"display_name": preset.display_name,
@ -665,12 +778,13 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo
"configured": configured,
"available": configured and _config_available(cfg),
"status": status,
"logo_url": _favicon_url(preset.brand_domain),
"logo_url": logo_url,
"brand_color": preset.brand_color,
"required_fields": [_field_payload(field, cfg) for field in preset.fields],
"connection_summary": _connection_summary(cfg),
"enabled_tools": _tool_allowlist(cfg),
"source": "preset",
"manifest": _preset_manifest(preset, logo_url=logo_url),
}
@ -705,6 +819,7 @@ def _custom_payload(
"enabled_tools": _tool_allowlist(cfg),
"tool_names": tool_names or [],
"source": "custom",
"manifest": _custom_manifest(name, cfg),
}
@ -744,10 +859,17 @@ def _action_message(action: str, preset: McpPreset, *, ok: bool = True) -> dict[
"remove": "Removed",
"test": "Checked",
}.get(action, "Updated")
return {
payload: dict[str, Any] = {
"ok": ok,
"message": f"{verb} MCP preset for {preset.display_name}.",
}
if action == "enable":
payload["installed"] = True
payload["verification"] = ["config_present"]
elif action == "remove":
payload["removed"] = True
payload["verification"] = ["config_absent"]
return payload
def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[str, Any]:
@ -758,10 +880,17 @@ def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[s
"tools": "Updated tools for",
"remove": "Removed",
}.get(action, "Updated")
return {
payload: dict[str, Any] = {
"ok": ok,
"message": f"{verb} MCP server {name}.",
}
if action in {"custom", "import", "import-cursor"}:
payload["installed"] = True
payload["verification"] = ["config_present"]
elif action == "remove":
payload["removed"] = True
payload["verification"] = ["config_absent"]
return payload
def _scrub_test_error(text: str) -> str:
@ -1113,7 +1242,14 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
if action == "remove":
if preset is None and name not in config.tools.mcp_servers:
raise McpPresetError("unknown MCP server", status=404)
removed_runtime_files = False
cleanup_error = ""
if name in config.tools.mcp_servers:
existing_cfg = config.tools.mcp_servers[name]
try:
removed_runtime_files = _remove_managed_stdio_cwd(name, existing_cfg)
except OSError as exc:
cleanup_error = str(exc)
del config.tools.mcp_servers[name]
save_config(config)
last_action = (
@ -1121,6 +1257,16 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
if preset is not None
else _server_action_message(action, name)
)
if removed_runtime_files:
last_action["message"] = f"{last_action['message']} Removed managed runtime files."
last_action["managed_paths_removed"] = [f"runtime:mcp/{name}"]
last_action["verification"] = ["config_absent", "managed_paths_absent"]
if cleanup_error:
last_action["ok"] = False
last_action["message"] = (
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
)
last_action["verification_failed"] = ["managed_paths_absent"]
payload = mcp_presets_payload(last_action=last_action)
payload["requires_restart"] = True
return payload

View File

@ -5,10 +5,11 @@ import subprocess
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
def _write_cache(path: Path, registry: dict) -> None:
@ -146,6 +147,15 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -
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"
@ -156,6 +166,33 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -
)
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",
}
],
},
)
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_install_dispatches_safe_pip_and_installs_skill(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@ -179,6 +216,8 @@ def test_install_dispatches_safe_pip_and_installs_skill(
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"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
@ -186,6 +225,49 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
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"
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
manager = _manager(tmp_path)
@ -206,7 +288,7 @@ def test_fetch_skill_content_rejects_untrusted_urls(
def fail_get(*args, **kwargs):
raise AssertionError("untrusted skill URL should not be fetched")
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fail_get)
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
assert manager._fetch_skill_content({
"name": "evil",
@ -236,7 +318,7 @@ def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
seen.append(url)
return Response()
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fake_get)
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fake_get)
content = manager._fetch_skill_content({
"name": "gimp",
@ -293,6 +375,91 @@ def test_uninstall_uses_safe_python_m_pip_uninstall_command(
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)
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(
"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(
@ -341,7 +508,7 @@ def test_run_installed_cli_uses_argv_without_shell(
_seed_catalog(manager)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.cli_apps.service.shutil.which",
"nanobot.apps.cli.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
@ -354,7 +521,7 @@ def test_run_installed_cli_uses_argv_without_shell(
stderr="",
)
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
manager._save_installed(
{
"gimp": {
@ -380,7 +547,7 @@ def test_run_reports_created_artifacts(
_seed_catalog(manager)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.cli_apps.service.shutil.which",
"nanobot.apps.cli.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
@ -389,7 +556,7 @@ def test_run_reports_created_artifacts(
(cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
return subprocess.CompletedProcess(argv, 0, stdout="done", stderr="")
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
result = manager.run("gimp", ["render"])

View File

@ -7,7 +7,7 @@ import time
from pathlib import Path
from nanobot.agent.tools.cli_apps import CliAppsTool
from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
def _write_cache(path: Path, registry: dict) -> None:
@ -46,7 +46,7 @@ def test_run_cli_app_uses_installed_registry_app(
)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.cli_apps.service.shutil.which",
"nanobot.apps.cli.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
@ -59,8 +59,8 @@ def test_run_cli_app_uses_installed_registry_app(
stderr="",
)
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(
workspace=workspace,
@ -102,7 +102,7 @@ def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> Non
}
_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)
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
result = asyncio.run(tool.execute(name="gimp"))
@ -117,7 +117,7 @@ def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Pa
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)
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(workspace=workspace)

View File

@ -2,8 +2,8 @@
from types import SimpleNamespace
from nanobot.cli_apps.service import CliAppManager
from nanobot.cli_apps.utils import runtime_lines, session_extra
from nanobot.apps.cli.service import CliAppManager
from nanobot.apps.cli.utils import runtime_lines, session_extra
def test_session_extra_returns_cli_apps_only_when_present() -> None:
@ -15,7 +15,7 @@ def test_session_extra_returns_cli_apps_only_when_present() -> 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)
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
manager = CliAppManager(workspace=tmp_path)
manager._save_installed(
{

View File

@ -43,6 +43,15 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
assert browserbase["install_supported"] is True
assert browserbase["required_fields"][0]["configured"] is False
assert "browserbaseApiKey" not in browserbase["connection_summary"]
manifest = browserbase["manifest"]
assert manifest["schema"] == "agent-app.v1"
assert manifest["id"] == "browserbase"
assert manifest["source"] == "mcp-preset"
assert manifest["capabilities"][0]["type"] == "mcp"
assert manifest["capabilities"][0]["transport"] == "streamableHttp"
assert manifest["install"]["strategy"] == "config"
assert manifest["remove"]["verification"] == ["config_absent"]
assert manifest["trust"]["review_status"] == "builtin_preset"
def test_enable_browserbase_writes_scrubbed_config_payload(
@ -61,6 +70,8 @@ def test_enable_browserbase_writes_scrubbed_config_payload(
assert payload["requires_restart"] is True
assert payload["last_action"]["ok"] is True
assert payload["last_action"]["installed"] is True
assert payload["last_action"]["verification"] == ["config_present"]
preset = next(row for row in payload["presets"] if row["name"] == "browserbase")
assert preset["installed"] is True
assert preset["configured"] is True
@ -149,14 +160,43 @@ def test_enable_firecrawl_writes_scrubbed_env(tmp_path, monkeypatch: pytest.Monk
def test_remove_mcp_preset_updates_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
mcp_presets_action("enable", {"name": ["playwright"]})
managed_cwd = tmp_path / "mcp" / "playwright"
(managed_cwd / "cache.txt").write_text("managed runtime data", encoding="utf-8")
payload = mcp_presets_action("remove", {"name": ["playwright"]})
assert payload["requires_restart"] is True
assert payload["last_action"]["ok"] is True
assert payload["last_action"]["removed"] is True
assert payload["last_action"]["managed_paths_removed"] == ["runtime:mcp/playwright"]
assert not managed_cwd.exists()
config = load_config()
assert "playwright" not in config.tools.mcp_servers
def test_remove_custom_mcp_server_preserves_user_cwd(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
user_cwd = tmp_path / "user-cwd"
user_cwd.mkdir()
custom_mcp_action(
"custom",
{
"name": ["internal-docs"],
"transport": ["stdio"],
"command": ["node"],
"args": ['["server.js"]'],
"cwd": [str(user_cwd)],
},
)
payload = mcp_presets_action("remove", {"name": ["internal-docs"]})
assert payload["last_action"]["ok"] is True
assert user_cwd.exists()
config = load_config()
assert "internal-docs" not in config.tools.mcp_servers
def test_test_mcp_preset_reports_missing_dependency(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@ -282,6 +322,10 @@ def test_custom_mcp_server_writes_config_and_catalog_row(
assert row["source"] == "custom"
assert row["transport"] == "stdio"
assert row["connection_summary"] == "node server.js"
assert row["manifest"]["schema"] == "agent-app.v1"
assert row["manifest"]["source"] == "mcp-custom"
assert row["manifest"]["capabilities"][0]["command"] == "node"
assert "server.js" not in str(row["manifest"])
assert "docs-secret-value" not in str(payload)
config = load_config()
assert config.tools.mcp_servers["internal-docs"].args == ["server.js"]

View File

@ -4,7 +4,7 @@ import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
import { Sidebar } from "@/components/Sidebar";
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
import { SettingsView } from "@/components/settings/SettingsView";
import { SettingsView, type SettingsSectionKey } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
@ -46,7 +46,7 @@ const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings";
type ShellView = "chat" | "settings" | "apps";
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
@ -325,6 +325,7 @@ function Shell({
useSidebarState(sessions, !loading);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [view, setView] = useState<ShellView>("chat");
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionKey>("overview");
const [desktopSidebarOpen, setDesktopSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
@ -588,12 +589,20 @@ function Shell({
[onSelectChat],
);
const onOpenSettings = useCallback(() => {
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
setSessionSearchOpen(false);
setSettingsInitialSection(section);
setView("settings");
setMobileSidebarOpen(false);
}, []);
const onOpenApps = useCallback(() => {
setSessionSearchOpen(false);
setSettingsInitialSection("apps");
setView("apps");
setMobileSidebarOpen(false);
}, []);
const onBackToChat = useCallback(() => {
setView("chat");
setMobileSidebarOpen(false);
@ -711,6 +720,12 @@ function Shell({
});
return;
}
if (view === "apps") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.apps", { defaultValue: "Apps" }),
});
return;
}
document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base");
@ -728,7 +743,9 @@ function Shell({
onRequestRename,
onToggleArchive,
onOpenSettings,
onOpenApps,
onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" ? "apps" as const : null,
onToggleArchived,
onUpdateView: onUpdateSidebarView,
pinnedKeys: sidebarState.pinned_keys,
@ -805,7 +822,7 @@ function Shell({
<div
className={cn(
"absolute inset-0 flex flex-col",
view === "settings" && "invisible pointer-events-none",
view !== "chat" && "invisible pointer-events-none",
)}
>
<ThreadShell
@ -820,10 +837,12 @@ function Shell({
hideSidebarToggleOnDesktop
/>
</div>
{view === "settings" && (
{view !== "chat" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
theme={theme}
initialSection={settingsInitialSection}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}

View File

@ -6,6 +6,7 @@ import {
Search,
Settings,
SquarePen,
Blocks,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@ -41,7 +42,9 @@ interface SidebarProps {
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onOpenSettings: () => void;
onOpenApps: () => void;
onOpenSearch: () => void;
activeUtility?: "apps" | null;
onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void;
@ -129,6 +132,13 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenSearch}
icon={<Search className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.apps")}
onClick={props.onOpenApps}
active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarViewMenu
compact={collapsed}
view={props.viewState}
@ -201,12 +211,14 @@ function SidebarActionButton({
label,
icon,
onClick,
active = false,
className,
}: {
collapsed: boolean;
label: string;
icon: ReactNode;
onClick: () => void;
active?: boolean;
className?: string;
}) {
return (
@ -214,14 +226,16 @@ function SidebarActionButton({
type="button"
variant="ghost"
aria-label={label}
aria-current={active ? "page" : undefined}
title={collapsed ? label : undefined}
onClick={onClick}
onClick={() => onClick()}
className={cn(
"group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
collapsed
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]",
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
className,
)}
>

File diff suppressed because it is too large Load Diff

View File

@ -63,7 +63,8 @@
"language": {
"label": "Language",
"ariaLabel": "Change language"
}
},
"apps": "Apps"
},
"settings": {
"backToChat": "Back to chat",
@ -83,7 +84,8 @@
"cliApps": "CLI Apps",
"mcp": "MCP",
"runtime": "Runtime",
"advanced": "Advanced"
"advanced": "Advanced",
"apps": "Apps"
},
"sections": {
"interface": "Interface",
@ -96,12 +98,13 @@
"imageDefaults": "Defaults",
"webSearch": "Web search",
"webBehavior": "Behavior",
"cliApps": "CLI Apps",
"mcp": "MCP",
"cliApps": "CLI apps",
"mcp": "MCP services",
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capabilities",
"integrations": "Integrations"
"integrations": "Integrations",
"apps": "Apps"
},
"models": {
"selectModel": "Select model",
@ -380,6 +383,20 @@
"selectSize": "Select size",
"configureProvider": "Configure provider",
"missingCredential": "Configure this provider before enabling image generation."
},
"apps": {
"description": "Add app CLIs and MCP services nanobot can use from chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "All",
"filterCli": "CLI apps",
"filterMcp": "MCP services",
"enabledSummary": "{{count}} enabled",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Search Apps",
"featured": "Featured",
"loading": "Loading Apps...",
"empty": "No apps match this filter."
}
},
"chat": {
@ -596,10 +613,10 @@
}
},
"mentions": {
"ariaLabel": "Apps and MCP",
"label": "Plugins",
"cliGroup": "CLI Apps",
"mcpGroup": "MCP servers",
"ariaLabel": "Apps",
"label": "Apps",
"cliGroup": "CLI apps",
"mcpGroup": "MCP services",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Use @{{name}} as a local CLI app",

View File

@ -63,7 +63,8 @@
"language": {
"label": "Idioma",
"ariaLabel": "Cambiar idioma"
}
},
"apps": "Apps"
},
"settings": {
"backToChat": "Volver al chat",
@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Apps CLI",
"mcp": "MCP"
"mcp": "MCP",
"apps": "Apps"
},
"sections": {
"interface": "Interfaz",
@ -101,7 +103,8 @@
"capabilities": "Capacidades",
"integrations": "Integrations",
"cliApps": "Apps CLI",
"mcp": "MCP"
"mcp": "Servicios MCP",
"apps": "Apps"
},
"rows": {
"theme": "Tema",
@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
},
"apps": {
"description": "Añade CLI de apps y servicios MCP que nanobot puede usar desde el chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Todo",
"filterCli": "Apps CLI",
"filterMcp": "Servicios MCP",
"enabledSummary": "{{count}} activados",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Buscar apps",
"featured": "Destacadas",
"loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro."
}
},
"chat": {
@ -607,10 +624,10 @@
"io": "No se pudo leer este archivo"
},
"mentions": {
"ariaLabel": "Apps y MCP",
"label": "Plugins",
"ariaLabel": "Apps",
"label": "Apps",
"cliGroup": "Apps CLI",
"mcpGroup": "Servidores MCP",
"mcpGroup": "Servicios MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Usar @{{name}} como app CLI local",

View File

@ -63,7 +63,8 @@
"language": {
"label": "Langue",
"ariaLabel": "Changer de langue"
}
},
"apps": "Apps"
},
"settings": {
"backToChat": "Retour à la discussion",
@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Apps CLI",
"mcp": "MCP"
"mcp": "MCP",
"apps": "Apps"
},
"sections": {
"interface": "Interface",
@ -101,7 +103,8 @@
"capabilities": "Capacités",
"integrations": "Integrations",
"cliApps": "Apps CLI",
"mcp": "MCP"
"mcp": "Services MCP",
"apps": "Apps"
},
"rows": {
"theme": "Thème",
@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
},
"apps": {
"description": "Ajoutez des CLI dapps et des services MCP que nanobot peut utiliser dans le chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Tout",
"filterCli": "Apps CLI",
"filterMcp": "Services MCP",
"enabledSummary": "{{count}} activés",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Rechercher des apps",
"featured": "En vedette",
"loading": "Chargement des apps...",
"empty": "Aucune app ne correspond à ce filtre."
}
},
"chat": {
@ -607,10 +624,10 @@
"io": "Impossible de lire ce fichier"
},
"mentions": {
"ariaLabel": "Apps et MCP",
"label": "Plugins",
"ariaLabel": "Apps",
"label": "Apps",
"cliGroup": "Apps CLI",
"mcpGroup": "Serveurs MCP",
"mcpGroup": "Services MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Utiliser @{{name}} comme app CLI locale",

View File

@ -63,7 +63,8 @@
"language": {
"label": "Bahasa",
"ariaLabel": "Ganti bahasa"
}
},
"apps": "Aplikasi"
},
"settings": {
"backToChat": "Kembali ke obrolan",
@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Aplikasi CLI",
"mcp": "MCP"
"mcp": "MCP",
"apps": "Aplikasi"
},
"sections": {
"interface": "Antarmuka",
@ -100,8 +102,9 @@
"safety": "Safety",
"capabilities": "Kapabilitas",
"integrations": "Integrations",
"cliApps": "Aplikasi CLI",
"mcp": "MCP"
"cliApps": "App CLI",
"mcp": "Layanan MCP",
"apps": "Aplikasi"
},
"rows": {
"theme": "Tema",
@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
},
"apps": {
"description": "Tambahkan CLI app dan layanan MCP yang dapat digunakan nanobot dari chat.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Semua",
"filterCli": "App CLI",
"filterMcp": "Layanan MCP",
"enabledSummary": "{{count}} aktif",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Cari aplikasi",
"featured": "Unggulan",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok dengan filter ini."
}
},
"chat": {
@ -607,10 +624,10 @@
"io": "Tidak dapat membaca file ini"
},
"mentions": {
"ariaLabel": "Aplikasi dan MCP",
"label": "Plugin",
"cliGroup": "Aplikasi CLI",
"mcpGroup": "Server MCP",
"ariaLabel": "Aplikasi",
"label": "Aplikasi",
"cliGroup": "App CLI",
"mcpGroup": "Layanan MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",

View File

@ -63,7 +63,8 @@
"language": {
"label": "言語",
"ariaLabel": "言語を変更"
}
},
"apps": "アプリ"
},
"settings": {
"backToChat": "チャットに戻る",
@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "CLI アプリ",
"mcp": "MCP"
"mcp": "MCP",
"apps": "アプリ"
},
"sections": {
"interface": "インターフェース",
@ -101,7 +103,8 @@
"capabilities": "機能",
"integrations": "Integrations",
"cliApps": "CLI アプリ",
"mcp": "MCP"
"mcp": "MCP サービス",
"apps": "アプリ"
},
"rows": {
"theme": "テーマ",
@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
},
"apps": {
"description": "チャットから nanobot が使えるアプリ CLI と MCP サービスを追加します。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "すべて",
"filterCli": "CLI アプリ",
"filterMcp": "MCP サービス",
"enabledSummary": "{{count}} 件有効",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "アプリを検索",
"featured": "おすすめ",
"loading": "アプリを読み込み中...",
"empty": "このフィルターに一致するアプリはありません。"
}
},
"chat": {
@ -607,10 +624,10 @@
"io": "このファイルを読み込めません"
},
"mentions": {
"ariaLabel": "アプリと MCP",
"label": "プラグイン",
"ariaLabel": "アプリ",
"label": "アプリ",
"cliGroup": "CLI アプリ",
"mcpGroup": "MCP サーバー",
"mcpGroup": "MCP サービス",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",

View File

@ -63,7 +63,8 @@
"language": {
"label": "언어",
"ariaLabel": "언어 변경"
}
},
"apps": "앱"
},
"settings": {
"backToChat": "채팅으로 돌아가기",
@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "CLI 앱",
"mcp": "MCP"
"mcp": "MCP",
"apps": "앱"
},
"sections": {
"interface": "인터페이스",
@ -101,7 +103,8 @@
"capabilities": "기능",
"integrations": "Integrations",
"cliApps": "CLI 앱",
"mcp": "MCP"
"mcp": "MCP 서비스",
"apps": "앱"
},
"rows": {
"theme": "테마",
@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
},
"apps": {
"description": "채팅에서 nanobot이 사용할 수 있는 앱 CLI와 MCP 서비스를 추가합니다.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "전체",
"filterCli": "CLI 앱",
"filterMcp": "MCP 서비스",
"enabledSummary": "{{count}}개 활성화됨",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "앱 검색",
"featured": "추천",
"loading": "앱 불러오는 중...",
"empty": "이 필터와 일치하는 앱이 없습니다."
}
},
"chat": {
@ -607,10 +624,10 @@
"io": "이 파일을 읽을 수 없습니다"
},
"mentions": {
"ariaLabel": "앱 및 MCP",
"label": "플러그인",
"ariaLabel": "앱",
"label": "",
"cliGroup": "CLI 앱",
"mcpGroup": "MCP 서",
"mcpGroup": "MCP 서비스",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",

View File

@ -63,7 +63,8 @@
"language": {
"label": "Ngôn ngữ",
"ariaLabel": "Đổi ngôn ngữ"
}
},
"apps": "Ứng dụng"
},
"settings": {
"backToChat": "Quay lại trò chuyện",
@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP"
"mcp": "MCP",
"apps": "Ứng dụng"
},
"sections": {
"interface": "Giao diện",
@ -101,7 +103,8 @@
"capabilities": "Khả năng",
"integrations": "Integrations",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP"
"mcp": "Dịch vụ MCP",
"apps": "Ứng dụng"
},
"rows": {
"theme": "Giao diện",
@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
},
"apps": {
"description": "Thêm CLI ứng dụng và dịch vụ MCP để nanobot dùng trong trò chuyện.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "Tất cả",
"filterCli": "Ứng dụng CLI",
"filterMcp": "Dịch vụ MCP",
"enabledSummary": "{{count}} đã bật",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Tìm ứng dụng",
"featured": "Nổi bật",
"loading": "Đang tải ứng dụng...",
"empty": "Không có ứng dụng nào khớp với bộ lọc này."
}
},
"chat": {
@ -607,10 +624,10 @@
"io": "Không thể đọc tệp này"
},
"mentions": {
"ariaLabel": "Ứng dụng và MCP",
"label": "Plugin",
"ariaLabel": "Ứng dụng",
"label": "Ứng dụng",
"cliGroup": "Ứng dụng CLI",
"mcpGroup": "Máy chủ MCP",
"mcpGroup": "Dịch vụ MCP",
"cliBadge": "CLI",
"mcpBadge": "MCP",
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",

View File

@ -63,7 +63,8 @@
"language": {
"label": "语言",
"ariaLabel": "切换语言"
}
},
"apps": "应用"
},
"settings": {
"backToChat": "返回对话",
@ -83,7 +84,8 @@
"cliApps": "CLI 应用",
"mcp": "MCP",
"runtime": "运行时",
"advanced": "高级"
"advanced": "高级",
"apps": "应用"
},
"sections": {
"interface": "界面",
@ -97,11 +99,12 @@
"webSearch": "网页搜索",
"webBehavior": "行为",
"cliApps": "CLI 应用",
"mcp": "MCP",
"mcp": "MCP 服务",
"identity": "身份",
"safety": "安全",
"capabilities": "能力",
"integrations": "集成"
"integrations": "集成",
"apps": "应用"
},
"models": {
"selectModel": "选择模型",
@ -380,6 +383,20 @@
"selectSize": "选择尺寸",
"configureProvider": "配置服务商",
"missingCredential": "启用图片生成前,请先配置这个服务商。"
},
"apps": {
"description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "全部",
"filterCli": "CLI 应用",
"filterMcp": "MCP 服务",
"enabledSummary": "已启用 {{count}} 个",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "搜索应用",
"featured": "精选",
"loading": "正在加载应用...",
"empty": "没有符合筛选条件的应用。"
}
},
"chat": {
@ -595,8 +612,8 @@
}
},
"mentions": {
"ariaLabel": "应用和 MCP",
"label": "插件",
"ariaLabel": "应用",
"label": "应用",
"cliGroup": "CLI 应用",
"mcpGroup": "MCP 服务",
"cliBadge": "CLI",

View File

@ -63,7 +63,8 @@
"language": {
"label": "語言",
"ariaLabel": "切換語言"
}
},
"apps": "應用"
},
"settings": {
"backToChat": "返回對話",
@ -83,7 +84,8 @@
"runtime": "Runtime",
"advanced": "Advanced",
"cliApps": "CLI 應用",
"mcp": "MCP"
"mcp": "MCP",
"apps": "應用"
},
"sections": {
"interface": "介面",
@ -101,7 +103,8 @@
"capabilities": "功能",
"integrations": "Integrations",
"cliApps": "CLI 應用",
"mcp": "MCP"
"mcp": "MCP 服務",
"apps": "應用"
},
"rows": {
"theme": "主題",
@ -380,6 +383,20 @@
},
"legal": {
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
},
"apps": {
"description": "新增 nanobot 可在聊天中使用的 App CLI 與 MCP 服務。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"filterAll": "全部",
"filterCli": "CLI 應用",
"filterMcp": "MCP 服務",
"enabledSummary": "已啟用 {{count}} 個",
"caption": "{{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "搜尋應用",
"featured": "精選",
"loading": "正在載入應用...",
"empty": "沒有符合篩選條件的應用。"
}
},
"chat": {
@ -607,8 +624,8 @@
"io": "無法讀取這個檔案"
},
"mentions": {
"ariaLabel": "應用和 MCP",
"label": "插件",
"ariaLabel": "應用",
"label": "應用",
"cliGroup": "CLI 應用",
"mcpGroup": "MCP 服務",
"cliBadge": "CLI",

View File

@ -280,6 +280,59 @@ export interface SettingsPayload {
restart_required_sections?: Array<"runtime" | "web" | "image">;
}
export interface AppPackageRef {
manager: string;
name?: string;
}
export interface AppCapability {
type: "cli" | "mcp" | "skill" | string;
entry_point?: string;
package?: AppPackageRef;
path?: string;
transport?: string;
command?: string;
args?: string[];
url?: string;
fields?: Array<{
name: string;
target?: string;
required?: boolean;
secret?: boolean;
env_var?: string | null;
}>;
}
export interface AppPlan {
supported: boolean;
strategy?: string;
managed_paths?: string[];
verification?: string[];
}
export interface AppTrust {
registry: string;
level: string;
review_status: string;
}
export interface AppManifest {
schema: "agent-app.v1" | string;
id: string;
display_name: string;
version?: string;
description: string;
category: string;
source: string;
logo_url?: string | null;
brand_color?: string | null;
docs_url?: string | null;
capabilities: AppCapability[];
install: AppPlan;
remove: AppPlan;
trust: AppTrust;
}
export interface CliAppInfo {
name: string;
display_name: string;
@ -295,6 +348,7 @@ export interface CliAppInfo {
logo_url?: string | null;
brand_color?: string | null;
skill_installed: boolean;
manifest?: AppManifest;
}
export interface CliAppsPayload {
@ -304,7 +358,12 @@ export interface CliAppsPayload {
last_action?: {
ok: boolean;
message: string;
installed?: boolean;
removed?: boolean;
output?: string | null;
still_available?: boolean;
verification?: string[];
verification_failed?: string[];
};
}
@ -342,6 +401,7 @@ export interface McpPresetInfo {
error?: string | null;
enabled_tools?: string[];
source?: "preset" | "custom" | string;
manifest?: AppManifest;
}
export interface McpPresetsPayload {
@ -364,6 +424,11 @@ export interface McpPresetsPayload {
last_action?: {
ok: boolean;
message: string;
installed?: boolean;
removed?: boolean;
managed_paths_removed?: string[];
verification?: string[];
verification_failed?: string[];
tool_count?: number;
tool_names?: string[];
checked_at?: string | null;

View File

@ -13,6 +13,100 @@ const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
let mockSessions: ChatSummary[] = [];
function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as Response;
}
function baseSettingsPayload() {
return {
agent: {
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
model_preset: "default",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
timezone: "UTC",
bot_name: "nanobot",
bot_icon: "nb",
tool_hint_max_length: 40,
},
model_presets: [{
name: "default",
label: "Default",
active: true,
is_default: true,
model: "openai/gpt-4o",
provider: "auto",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
}],
providers: [],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
max_results: 5,
timeout: 30,
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: false,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_append_set: false,
},
requires_restart: false,
};
}
vi.mock("@/hooks/useSessions", async (importOriginal) => {
const React = await import("react");
const actual = await importOriginal<typeof import("@/hooks/useSessions")>();
@ -709,6 +803,9 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
@ -731,6 +828,7 @@ describe("App layout", () => {
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Apps" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
@ -822,6 +920,42 @@ describe("App layout", () => {
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
it("opens Apps from the main sidebar without replacing the sidebar", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const href = String(input);
if (href === "/api/settings") {
return jsonResponse(baseSettingsPayload());
}
if (href === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" });
}
if (href === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
fireEvent.click(appsButton);
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute(
"aria-current",
"page",
);
expect(document.title).toBe("Apps · nanobot");
});
it("returns from settings to the blank start page when no session was active", async () => {
mockSessions = [
{

View File

@ -28,6 +28,7 @@ const SETTINGS_NAV_KEYS = [
"models",
"image",
"web",
"apps",
"runtime",
"advanced",
];

View File

@ -0,0 +1,191 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as Response;
}
function settingsPayload() {
return {
agent: {
model: "openai/gpt-4o",
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
model_preset: "default",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
timezone: "UTC",
bot_name: "nanobot",
bot_icon: "nb",
tool_hint_max_length: 40,
},
model_presets: [{
name: "default",
label: "Default",
active: true,
is_default: true,
model: "openai/gpt-4o",
provider: "auto",
max_tokens: 8192,
context_window_tokens: 65536,
temperature: 0.1,
reasoning_effort: null,
}],
providers: [],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
max_results: 5,
timeout: 30,
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
},
web: {
enable: true,
proxy: null,
user_agent: null,
search: { max_results: 5, timeout: 30 },
fetch: { use_jina_reader: true },
},
image_generation: {
enabled: false,
provider: "openrouter",
provider_configured: false,
model: "openai/gpt-5.4-image-2",
default_aspect_ratio: "1:1",
default_image_size: "1K",
max_images_per_turn: 4,
save_dir: "generated",
providers: [],
},
runtime: {
config_path: "/tmp/config.json",
workspace_path: "/tmp/workspace",
gateway_host: "127.0.0.1",
gateway_port: 18790,
heartbeat: {
enabled: true,
interval_s: 1800,
keep_recent_messages: 8,
},
dream: {
schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
},
unified_session: false,
},
advanced: {
restrict_to_workspace: false,
ssrf_whitelist_count: 0,
mcp_server_count: 0,
exec_enabled: true,
exec_sandbox: null,
exec_path_append_set: false,
},
requires_restart: false,
};
}
const installedAnyGen = {
name: "anygen",
display_name: "AnyGen",
category: "generation",
description: "Generate docs, slides, websites and more via AnyGen cloud API",
requires: "ANYGEN_API_KEY",
source: "harness",
entry_point: "cli-anything-anygen",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://www.google.com/s2/favicons?domain=anygen.io&sz=64",
brand_color: "#111827",
skill_installed: true,
};
function renderSettingsView() {
render(
<ClientProvider client={{} as never} token="tok">
<SettingsView
theme="light"
initialSection="apps"
onToggleTheme={() => {}}
onBackToChat={() => {}}
onModelNameChange={() => {}}
/>
</ClientProvider>,
);
}
describe("SettingsView Apps catalog", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") {
return jsonResponse(settingsPayload());
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({
apps: [installedAnyGen],
installed_count: 1,
catalog_updated_at: "2026-04-18",
});
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url === "/api/settings/cli-apps/uninstall?name=anygen") {
return jsonResponse({
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
installed_count: 0,
catalog_updated_at: "2026-04-18",
last_action: {
ok: true,
message: "Uninstalled CLI for AnyGen.",
still_available: false,
},
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
const uninstall = screen.getByRole("button", { name: "Uninstall CLI" });
fireEvent.click(uninstall);
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/cli-apps/uninstall?name=anygen",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
),
);
expect(await screen.findByText("Uninstalled CLI for AnyGen.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
});

View File

@ -346,7 +346,7 @@ describe("ThreadComposer", () => {
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
const palette = screen.getByRole("listbox", { name: "Apps and MCP" });
const palette = screen.getByRole("listbox", { name: "Apps" });
expect(palette).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
"aria-selected",
@ -365,7 +365,7 @@ describe("ThreadComposer", () => {
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));

View File

@ -1088,7 +1088,7 @@ describe("ThreadShell", () => {
));
const input = await screen.findByLabelText("Message input");
expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
const payload: CliAppsPayload = {
apps: [{
@ -1116,7 +1116,7 @@ describe("ThreadShell", () => {
});
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
expect(screen.getByRole("listbox", { name: "Apps and MCP" })).toBeInTheDocument();
expect(screen.getByRole("listbox", { name: "Apps" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
});
});