mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +00:00
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:
parent
179acfe104
commit
418cb23da2
@ -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,
|
||||
|
||||
@ -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
5
nanobot/apps/__init__.py
Normal 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"]
|
||||
@ -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,
|
||||
@ -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)
|
||||
@ -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
56
nanobot/apps/protocol.py
Normal 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,
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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]]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 d’apps 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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 アプリとして使用",
|
||||
|
||||
@ -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 앱으로 사용",
|
||||
|
||||
@ -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ộ",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
@ -28,6 +28,7 @@ const SETTINGS_NAV_KEYS = [
|
||||
"models",
|
||||
"image",
|
||||
"web",
|
||||
"apps",
|
||||
"runtime",
|
||||
"advanced",
|
||||
];
|
||||
|
||||
191
webui/src/tests/settings-view.test.tsx
Normal file
191
webui/src/tests/settings-view.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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" }));
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user