refactor(plugins): minimize integration surface

This commit is contained in:
Xubin Ren
2026-08-11 01:19:08 +09:00
parent 82e50b0f1b
commit 976b2842bc
20 changed files with 87 additions and 182 deletions
+2 -12
View File
@@ -2309,18 +2309,8 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
### Agent Plugins v1 ### Agent Plugins v1
nanobot discovers [Agent Plugins](https://agent-plugins.org/) in nanobot discovers [Agent Plugins](https://agent-plugins.org/) in
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may provide skills, MCP `<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may add `mcp.json`,
servers, or both: `skills/<name>/SKILL.md`, or both.
```text
plugins/
└── release-tools/
├── plugin.json
├── mcp.json
└── skills/
└── release-notes/
└── SKILL.md
```
Directory presence means installed; activation is an explicit trust decision in **Apps**. Directory presence means installed; activation is an explicit trust decision in **Apps**.
Enabled skills use normal progressive loading and `$skill-name` invocation. Workspace skills Enabled skills use normal progressive loading and `$skill-name` invocation. Workspace skills
+31 -55
View File
@@ -29,15 +29,6 @@ _SETUP_TIMEOUT_SECONDS = 600
_MAX_LOGO_BYTES = 256 * 1024 _MAX_LOGO_BYTES = 256 * 1024
@dataclass(frozen=True)
class AgentPluginSkill:
"""One skill supplied by a valid Agent Plugins v1 package."""
name: str
path: Path
plugin: str
@dataclass(frozen=True) @dataclass(frozen=True)
class AgentPlugin: class AgentPlugin:
"""A validated, locally installed Agent Plugins v1 package.""" """A validated, locally installed Agent Plugins v1 package."""
@@ -71,14 +62,8 @@ def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
root = _contained_directory(workspace / "plugins", workspace) root = _contained_directory(workspace / "plugins", workspace)
if root is None: if root is None:
return [] return []
try:
candidates = sorted(root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect Agent Plugins directory: {}", exc)
return []
plugins: list[AgentPlugin] = [] plugins: list[AgentPlugin] = []
for candidate in candidates: for candidate in _children(root, "Agent Plugins directory"):
plugin_root = _contained_directory(candidate, root) plugin_root = _contained_directory(candidate, root)
if plugin_root is None: if plugin_root is None:
continue continue
@@ -88,13 +73,14 @@ def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
return plugins return plugins
def enabled_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]: def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
"""Return skills from plugins the user has explicitly enabled.""" """Return skills from plugins the user has explicitly enabled."""
skills: list[AgentPluginSkill] = [] return [
for plugin in _discover_agent_plugins(workspace): skill
if _enabled(workspace, plugin.name): for plugin in _discover_agent_plugins(workspace)
skills.extend(_discover_plugin_skills(plugin.name, plugin.root)) if _enabled(workspace, plugin.name)
return skills for skill in _discover_plugin_skills(plugin.name, plugin.root)
]
def _load_manifest(plugin_root: Path) -> AgentPlugin | None: def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
@@ -172,13 +158,12 @@ def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> Agent
if plugin is None: if plugin is None:
raise ValueError(f"unknown Agent Plugin '{name}'") raise ValueError(f"unknown Agent Plugin '{name}'")
data = _plugin_data_dir(workspace, plugin.name, create=True) data = _plugin_data_dir(workspace, plugin.name, create=True)
version = plugin.version or "unknown"
with FileLock(str(data / ".state.lock"), timeout=_SETUP_TIMEOUT_SECONDS + 10): with FileLock(str(data / ".state.lock"), timeout=_SETUP_TIMEOUT_SECONDS + 10):
if enabled: if enabled:
if plugin.install_command and _setup_version(workspace, plugin.name) != ( if plugin.install_command and _setup_version(workspace, plugin.name) != version:
plugin.version or "unknown"
):
_run_install(plugin, data) _run_install(plugin, data)
_write_state(data / "setup-version", plugin.version or "unknown") _write_state(data / "setup-version", version)
_write_state(data / "enabled", "1") _write_state(data / "enabled", "1")
else: else:
(data / "enabled").unlink(missing_ok=True) (data / "enabled").unlink(missing_ok=True)
@@ -209,12 +194,11 @@ def _plugin_logo(value: object, plugin_root: Path) -> Path | None:
try: try:
data = logo.read_bytes() if logo is not None else b"" data = logo.read_bytes() if logo is not None else b""
suffix = logo.suffix.lower() if logo is not None else "" suffix = logo.suffix.lower() if logo is not None else ""
valid = ( if len(data) <= _MAX_LOGO_BYTES and (
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n") suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff") or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP" or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
) ):
if valid and len(data) <= _MAX_LOGO_BYTES:
return logo return logo
except OSError: except OSError:
pass pass
@@ -334,7 +318,7 @@ def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12] workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
config_root = get_config_path().expanduser().resolve().parent config_root = get_config_path().expanduser().resolve().parent
plugin_root = _private_directory(config_root / "plugin-data", config_root, create=create) plugin_root = _private_directory(config_root / "plugin-data", config_root, create=create)
state_root = _private_directory(plugin_root / workspace_id, config_root, create=create) state_root = _private_directory(plugin_root / workspace_id, plugin_root, create=create)
data = state_root / name data = state_root / name
return _private_directory(data, state_root, create=True) if create else data return _private_directory(data, state_root, create=True) if create else data
@@ -394,44 +378,36 @@ def _run_install(plugin: AgentPlugin, data: Path) -> None:
raise RuntimeError(output or f"{plugin.display_name} setup failed") raise RuntimeError(output or f"{plugin.display_name} setup failed")
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[AgentPluginSkill]: def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
skills_root = _contained_directory(plugin_root / "skills", plugin_root) skills_root = _contained_directory(plugin_root / "skills", plugin_root)
if skills_root is None: if skills_root is None:
return [] return []
try: skills: list[tuple[str, Path]] = []
candidates = sorted(skills_root.iterdir(), key=lambda path: path.name) for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
except OSError as exc:
logger.warning("Could not inspect Agent Plugin '{}' skills: {}", plugin_name, exc)
return []
skills: list[AgentPluginSkill] = []
for candidate in candidates:
skill_root = _contained_directory(candidate, skills_root) skill_root = _contained_directory(candidate, skills_root)
if skill_root is None: if skill_root is None:
continue continue
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root) skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
if skill_file is None or not _valid_skill(skill_file, candidate.name, plugin_name): if skill_file is None:
continue continue
skills.append( try:
AgentPluginSkill(name=candidate.name, path=skill_file, plugin=plugin_name) metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
) except (OSError, UnicodeError):
metadata = None
if metadata is None or not valid_skill_metadata(metadata, candidate.name):
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
continue
skills.append((candidate.name, skill_file))
return skills return skills
def _valid_skill(path: Path, directory_name: str, plugin_name: str) -> bool: def _children(root: Path, label: str) -> list[Path]:
try: try:
content = path.read_text(encoding="utf-8") return sorted(root.iterdir(), key=lambda path: path.name)
except (OSError, UnicodeError): except OSError as exc:
return False logger.warning("Could not inspect {}: {}", label, exc)
metadata = parse_skill_metadata(content) return []
if metadata is None:
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
return False
if not valid_skill_metadata(metadata, directory_name):
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, directory_name)
return False
return True
def _contained_directory(path: Path, root: Path) -> Path | None: def _contained_directory(path: Path, root: Path) -> Path | None:
+5 -6
View File
@@ -104,18 +104,17 @@ class SkillsLoader:
plugin_skills = enabled_agent_plugin_skills(self.workspace) plugin_skills = enabled_agent_plugin_skills(self.workspace)
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace") skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
seen_names = {entry["name"] for entry in skills} seen_names = {entry["name"] for entry in skills}
for plugin_skill in plugin_skills: for name, path in plugin_skills:
if plugin_skill.name in seen_names: if name in seen_names:
continue continue
skills.append( skills.append(
{ {
"name": plugin_skill.name, "name": name,
"path": str(plugin_skill.path), "path": str(path),
"source": "plugin", "source": "plugin",
"plugin": plugin_skill.plugin,
} }
) )
seen_names.add(plugin_skill.name) seen_names.add(name)
if self.builtin_skills and self.builtin_skills.exists(): if self.builtin_skills and self.builtin_skills.exists():
skills.extend( skills.extend(
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names) self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
+12 -16
View File
@@ -212,26 +212,22 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
return cast(dict[str, Any], value) if isinstance(value, dict) else None return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _safe_skill_name(name: str) -> str: def _skill_name(name: str, *, legacy: bool = False) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).replace("_", "-").strip("-")
return f"cli-app-{clean or 'app'}"
def _legacy_skill_name(name: str) -> str:
"""Return the workspace skill name emitted before Agent Plugins support."""
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-") clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
if not legacy:
clean = clean.replace("_", "-")
return f"cli-app-{clean or 'app'}" return f"cli-app-{clean or 'app'}"
def _plugin_skill_relative_path(name: str) -> str: def _plugin_skill_relative_path(name: str) -> str:
skill_name = _safe_skill_name(name) skill_name = _skill_name(name)
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md" return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
def cli_app_skill_relative_path(workspace: Path, name: str) -> str: def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
"""Return a CLI App's skill path, including the legacy location.""" """Return a CLI App's skill path, including the legacy location."""
canonical = _plugin_skill_relative_path(name) canonical = _plugin_skill_relative_path(name)
legacy = f"skills/{_legacy_skill_name(name)}/SKILL.md" legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
if not (workspace / canonical).is_file() and (workspace / legacy).is_file(): if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
return legacy return legacy
return canonical return canonical
@@ -733,7 +729,7 @@ class CliAppManager:
entry_point = str(app.get("entry_point") or "") entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app) strategy = self._strategy(app)
skill_path = _plugin_skill_relative_path(name) skill_path = _plugin_skill_relative_path(name)
plugin_path = f"plugins/{_safe_skill_name(name)}" plugin_path = f"plugins/{_skill_name(name)}"
capabilities = [ capabilities = [
compact_dict({ compact_dict({
"type": "cli", "type": "cli",
@@ -1054,7 +1050,7 @@ class CliAppManager:
entry = str(app.get("entry_point") or f"cli-anything-{name}") entry = str(app.get("entry_point") or f"cli-anything-{name}")
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024] description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
return f"""--- return f"""---
name: {_safe_skill_name(name)} name: {_skill_name(name)}
description: {json.dumps(description, ensure_ascii=False)} description: {json.dumps(description, ensure_ascii=False)}
--- ---
@@ -1096,18 +1092,18 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
path = self.workspace / _plugin_skill_relative_path(name) path = self.workspace / _plugin_skill_relative_path(name)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
content = self._fetch_skill_content(app) or self._fallback_skill(app) content = self._fetch_skill_content(app) or self._fallback_skill(app)
content = normalize_skill_document(content, _safe_skill_name(name)) or self._fallback_skill(app) content = normalize_skill_document(content, _skill_name(name)) or self._fallback_skill(app)
content = self._with_nanobot_skill_note(content, app) content = self._with_nanobot_skill_note(content, app)
path.write_text(content, encoding="utf-8") path.write_text(content, encoding="utf-8")
plugin_root = path.parents[2] plugin_root = path.parents[2]
manifest = compact_dict({ manifest = compact_dict({
"$schema": AGENT_PLUGIN_SCHEMA, "$schema": AGENT_PLUGIN_SCHEMA,
"name": _safe_skill_name(str(app["name"])), "name": _skill_name(str(app["name"])),
"version": str(app.get("version") or ""), "version": str(app.get("version") or ""),
"description": _catalog_description(app), "description": _catalog_description(app),
}) })
_write_json(plugin_root / "plugin.json", manifest) _write_json(plugin_root / "plugin.json", manifest)
legacy_dir = self.workspace / "skills" / _legacy_skill_name(str(app["name"])) legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
if legacy_dir.is_dir(): if legacy_dir.is_dir():
shutil.rmtree(legacy_dir) shutil.rmtree(legacy_dir)
return path return path
@@ -1116,7 +1112,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2] plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
if plugin_root.is_dir(): if plugin_root.is_dir():
shutil.rmtree(plugin_root) shutil.rmtree(plugin_root)
legacy_dir = self.workspace / "skills" / _legacy_skill_name(name) legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
if legacy_dir.is_dir(): if legacy_dir.is_dir():
shutil.rmtree(legacy_dir) shutil.rmtree(legacy_dir)
@@ -1128,7 +1124,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
installed[str(app["name"])] = entry installed[str(app["name"])] = entry
self._save_installed(installed) self._save_installed(installed)
self.install_skill(app) self.install_skill(app)
set_agent_plugin_enabled(self.workspace, _safe_skill_name(str(app["name"])), True) set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
return entry return entry
def install(self, name: str) -> dict[str, Any]: def install(self, name: str) -> dict[str, Any]:
+11 -20
View File
@@ -878,8 +878,6 @@ def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
"brand_color": plugin.accent_color, "brand_color": plugin.accent_color,
"required_fields": [], "required_fields": [],
"connection_summary": ", ".join(state.mcp_servers), "connection_summary": ", ".join(state.mcp_servers),
"enabled_tools": ["*"],
"tool_names": [],
"source": "agent-plugin", "source": "agent-plugin",
} }
@@ -902,16 +900,12 @@ def mcp_presets_payload(
for name, cfg in sorted(config.tools.mcp_servers.items()) for name, cfg in sorted(config.tools.mcp_servers.items())
if name not in known if name not in known
] ]
plugin_states = [
state
for state in discover_agent_plugin_states(config.workspace_path)
if state.mcp_servers or state.plugin.install_command
]
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)} existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
plugin_rows = [ plugin_rows = [
row _agent_plugin_payload(state)
for state in plugin_states for state in discover_agent_plugin_states(config.workspace_path)
if (row := _agent_plugin_payload(state))["name"] not in existing_names if (state.mcp_servers or state.plugin.install_command)
and f"plugin-{state.plugin.name}" not in existing_names
] ]
payload: dict[str, Any] = { payload: dict[str, Any] = {
"presets": [*preset_rows, *custom_rows, *plugin_rows], "presets": [*preset_rows, *custom_rows, *plugin_rows],
@@ -1449,16 +1443,13 @@ async def mcp_presets_settings_action(
if name.startswith("plugin-"): if name.startswith("plugin-"):
plugin_config = load_config(config_path) if config_path is not None else load_config() plugin_config = load_config(config_path) if config_path is not None else load_config()
plugin_name = name.removeprefix("plugin-") plugin_name = name.removeprefix("plugin-")
plugin_state = next( plugin_states = discover_agent_plugin_states(plugin_config.workspace_path)
( plugin_state = next((state for state in plugin_states if state.plugin.name == plugin_name), None)
state if (
for state in discover_agent_plugin_states(plugin_config.workspace_path) name not in plugin_config.tools.mcp_servers
if state.plugin.name == plugin_name and plugin_state is not None
and (state.mcp_servers or state.plugin.install_command) and (plugin_state.mcp_servers or plugin_state.plugin.install_command)
), ):
None,
)
if name not in plugin_config.tools.mcp_servers and plugin_state is not None:
if action not in {"enable", "disable"}: if action not in {"enable", "disable"}:
raise McpPresetError("Agent Plugins support enable and disable actions only") raise McpPresetError("Agent Plugins support enable and disable actions only")
if ( if (
+1 -2
View File
@@ -80,7 +80,7 @@ def _write_setup_plugin(workspace: Path) -> tuple[Path, Path]:
def _loaded_plugin_skills(workspace: Path) -> list[str]: def _loaded_plugin_skills(workspace: Path) -> list[str]:
return [skill.name for skill in enabled_agent_plugin_skills(workspace)] return [name for name, _ in enabled_agent_plugin_skills(workspace)]
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None: def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
@@ -95,7 +95,6 @@ def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
"name": "release-notes", "name": "release-notes",
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"), "path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
"source": "plugin", "source": "plugin",
"plugin": "acme-tools",
} }
] ]
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"] assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
-9
View File
@@ -158,24 +158,15 @@ def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
with pytest.raises(McpPresetError, match="enable and disable"): with pytest.raises(McpPresetError, match="enable and disable"):
asyncio.run(plugin_action("remove")) asyncio.run(plugin_action("remove"))
def test_explicit_mcp_config_wins_over_plugin_catalog_name(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = json.loads(config_path.read_text(encoding="utf-8")) config = json.loads(config_path.read_text(encoding="utf-8"))
config["tools"] = { config["tools"] = {
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}} "mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
} }
config_path.write_text(json.dumps(config), encoding="utf-8") config_path.write_text(json.dumps(config), encoding="utf-8")
_write_agent_plugin(load_config().workspace_path)
rows = [ rows = [
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop" item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
] ]
assert len(rows) == 1 assert len(rows) == 1
assert rows[0]["source"] == "custom" assert rows[0]["source"] == "custom"
+16 -17
View File
@@ -7701,11 +7701,11 @@ function McpAppsCatalogRow({
const toolsBusy = actionKey === `tools:${preset.name}`; const toolsBusy = actionKey === `tools:${preset.name}`;
const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy; const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy;
const agentPlugin = preset.source === "agent-plugin"; const agentPlugin = preset.source === "agent-plugin";
const toggleable = preset.enabled !== undefined;
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
const hasFields = preset.required_fields.length > 0; const hasFields = preset.required_fields.length > 0;
const needsSetupInput = missingFields.length > 0; const needsSetupInput = missingFields.length > 0;
const pluginEnabled = agentPlugin && preset.enabled === true; const readyInstalled = preset.enabled ?? (preset.installed && preset.configured);
const readyInstalled = agentPlugin ? pluginEnabled : preset.installed && preset.configured;
const canEnable = const canEnable =
preset.install_supported && preset.install_supported &&
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim()))); (missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
@@ -7717,8 +7717,8 @@ function McpAppsCatalogRow({
const detail = agentPlugin && preset.requires const detail = agentPlugin && preset.requires
? `${description} · ${preset.requires}` ? `${description} · ${preset.requires}`
: description || preset.requires; : description || preset.requires;
const statusLabel = agentPlugin const statusLabel = toggleable
? tx("settings.apps.pluginEnabled", "Plugin enabled") ? tx("settings.nanobotFeatures.enabled", "Enabled")
: mcpPresetStatusLabel(preset.status, tx); : mcpPresetStatusLabel(preset.status, tx);
useEffect(() => { useEffect(() => {
@@ -7754,7 +7754,7 @@ function McpAppsCatalogRow({
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3> <h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
<AppsTypeBadge> <AppsTypeBadge>
{agentPlugin {agentPlugin
? tx("settings.apps.pluginLabel", "Plugin") ? tx("settings.apps.filterPlugins", "Plugins")
: tx("settings.apps.mcpLabel", "Integration")} : tx("settings.apps.mcpLabel", "Integration")}
</AppsTypeBadge> </AppsTypeBadge>
</div> </div>
@@ -7775,31 +7775,31 @@ function McpAppsCatalogRow({
</AppsActionButton> </AppsActionButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{!agentPlugin ? ( {!toggleable ? (
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}> <DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
<PlayCircle aria-hidden /> <PlayCircle aria-hidden />
{tx("settings.mcp.test", "Test")} {tx("settings.mcp.test", "Test")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{!agentPlugin && toolNames.length ? ( {!toggleable && toolNames.length ? (
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}> <DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
<SlidersHorizontal aria-hidden /> <SlidersHorizontal aria-hidden />
{tx("settings.mcp.toolScope", "Tools")} {tx("settings.mcp.toolScope", "Tools")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
<DropdownMenuItem <DropdownMenuItem
tone={agentPlugin ? undefined : "destructive"} tone={toggleable ? undefined : "destructive"}
disabled={busy} disabled={busy}
onClick={() => onAction(agentPlugin ? "disable" : "remove", preset.name)} onClick={() => onAction(toggleable ? "disable" : "remove", preset.name)}
> >
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />} {toggleable ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
{agentPlugin {toggleable
? tx("settings.apps.pluginDisable", "Disable") ? tx("settings.nanobotFeatures.disable", "Disable")
: tx("settings.mcp.remove", "Remove")} : tx("settings.mcp.remove", "Remove")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{!agentPlugin ? ( {!toggleable ? (
<AppsActionButton <AppsActionButton
ariaLabel={tx("settings.mcp.remove", "Remove")} ariaLabel={tx("settings.mcp.remove", "Remove")}
busy={removeBusy} busy={removeBusy}
@@ -7811,9 +7811,9 @@ function McpAppsCatalogRow({
</AppsActionButton> </AppsActionButton>
) : null} ) : null}
</> </>
) : agentPlugin && preset.installed ? ( ) : preset.enabled === false ? (
<AppsActionButton <AppsActionButton
ariaLabel={tx("settings.apps.pluginEnable", "Enable plugin")} ariaLabel={tx("settings.nanobotFeatures.enable", "Enable")}
busy={enableBusy} busy={enableBusy}
onClick={() => onAction("enable", preset.name, values)} onClick={() => onAction("enable", preset.name, values)}
> >
@@ -8023,8 +8023,7 @@ function appsTitle(item: AppsCatalogItem): string {
function appsReady(item: AppsCatalogItem): boolean { function appsReady(item: AppsCatalogItem): boolean {
if (item.kind === "cli") return item.app.installed; if (item.kind === "cli") return item.app.installed;
if (item.preset.source === "agent-plugin") return item.preset.enabled === true; return item.preset.enabled ?? (item.preset.installed && item.preset.configured);
return item.preset.installed && item.preset.configured;
} }
function appsSearchText(item: AppsCatalogItem): string { function appsSearchText(item: AppsCatalogItem): string {
-4
View File
@@ -585,10 +585,6 @@
"description": "Add tools to nanobot, then @ them in chat.", "description": "Add tools to nanobot, then @ them in chat.",
"cliLabel": "App", "cliLabel": "App",
"mcpLabel": "Integration", "mcpLabel": "Integration",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin enabled",
"pluginEnable": "Enable plugin",
"pluginDisable": "Disable",
"channelLabel": "Channel", "channelLabel": "Channel",
"featureLabel": "Feature", "featureLabel": "Feature",
"filterAll": "Ready", "filterAll": "Ready",
-4
View File
@@ -572,10 +572,6 @@
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.", "description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
"cliLabel": "Aplicación", "cliLabel": "Aplicación",
"mcpLabel": "Integración", "mcpLabel": "Integración",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin activado",
"pluginEnable": "Activar plugin",
"pluginDisable": "Desactivar",
"channelLabel": "Canal", "channelLabel": "Canal",
"featureLabel": "Función", "featureLabel": "Función",
"filterAll": "Listo", "filterAll": "Listo",
-4
View File
@@ -571,10 +571,6 @@
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.", "description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
"cliLabel": "Application", "cliLabel": "Application",
"mcpLabel": "Intégration", "mcpLabel": "Intégration",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin activé",
"pluginEnable": "Activer le plugin",
"pluginDisable": "Désactiver",
"channelLabel": "Canal", "channelLabel": "Canal",
"featureLabel": "Fonction", "featureLabel": "Fonction",
"filterAll": "Prêts", "filterAll": "Prêts",
-4
View File
@@ -571,10 +571,6 @@
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.", "description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
"cliLabel": "Aplikasi", "cliLabel": "Aplikasi",
"mcpLabel": "Integrasi", "mcpLabel": "Integrasi",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin aktif",
"pluginEnable": "Aktifkan plugin",
"pluginDisable": "Nonaktifkan",
"channelLabel": "Kanal", "channelLabel": "Kanal",
"featureLabel": "Fitur", "featureLabel": "Fitur",
"filterAll": "Siap", "filterAll": "Siap",
-4
View File
@@ -571,10 +571,6 @@
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。", "description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
"cliLabel": "アプリ", "cliLabel": "アプリ",
"mcpLabel": "連携", "mcpLabel": "連携",
"pluginLabel": "プラグイン",
"pluginEnabled": "プラグインは有効です",
"pluginEnable": "プラグインを有効にする",
"pluginDisable": "無効にする",
"channelLabel": "チャンネル", "channelLabel": "チャンネル",
"featureLabel": "機能", "featureLabel": "機能",
"filterAll": "使用可能", "filterAll": "使用可能",
-4
View File
@@ -571,10 +571,6 @@
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.", "description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
"cliLabel": "앱", "cliLabel": "앱",
"mcpLabel": "연동", "mcpLabel": "연동",
"pluginLabel": "플러그인",
"pluginEnabled": "플러그인 활성화됨",
"pluginEnable": "플러그인 활성화",
"pluginDisable": "비활성화",
"channelLabel": "채널", "channelLabel": "채널",
"featureLabel": "기능", "featureLabel": "기능",
"filterAll": "사용 가능", "filterAll": "사용 가능",
-4
View File
@@ -585,10 +585,6 @@
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.", "description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
"cliLabel": "Aplicativo", "cliLabel": "Aplicativo",
"mcpLabel": "Integração", "mcpLabel": "Integração",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin ativado",
"pluginEnable": "Ativar plugin",
"pluginDisable": "Desativar",
"channelLabel": "Canal", "channelLabel": "Canal",
"featureLabel": "Recurso", "featureLabel": "Recurso",
"filterAll": "Prontos", "filterAll": "Prontos",
-4
View File
@@ -571,10 +571,6 @@
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.", "description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
"cliLabel": "Ứng dụng", "cliLabel": "Ứng dụng",
"mcpLabel": "Tích hợp", "mcpLabel": "Tích hợp",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin đã bật",
"pluginEnable": "Bật plugin",
"pluginDisable": "Tắt",
"channelLabel": "Kênh", "channelLabel": "Kênh",
"featureLabel": "Tính năng", "featureLabel": "Tính năng",
"filterAll": "Sẵn sàng", "filterAll": "Sẵn sàng",
-4
View File
@@ -585,10 +585,6 @@
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。", "description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
"cliLabel": "应用", "cliLabel": "应用",
"mcpLabel": "集成", "mcpLabel": "集成",
"pluginLabel": "插件",
"pluginEnabled": "插件已启用",
"pluginEnable": "启用插件",
"pluginDisable": "停用",
"channelLabel": "渠道", "channelLabel": "渠道",
"featureLabel": "能力", "featureLabel": "能力",
"filterAll": "可用", "filterAll": "可用",
-4
View File
@@ -571,10 +571,6 @@
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。", "description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
"cliLabel": "應用程式", "cliLabel": "應用程式",
"mcpLabel": "整合", "mcpLabel": "整合",
"pluginLabel": "外掛",
"pluginEnabled": "外掛已啟用",
"pluginEnable": "啟用外掛",
"pluginDisable": "停用",
"channelLabel": "通訊管道", "channelLabel": "通訊管道",
"featureLabel": "功能", "featureLabel": "功能",
"filterAll": "就緒", "filterAll": "就緒",
+3 -1
View File
@@ -9,7 +9,9 @@ export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload
} }
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] { export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
return payload.presets.filter((preset) => preset.installed && preset.configured); return payload.presets.filter(
(preset) => preset.enabled ?? (preset.installed && preset.configured),
);
} }
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void { export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
+6 -4
View File
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView"; import { SettingsView } from "@/components/settings/SettingsView";
import { installedMcpPresetsFromPayload } from "@/lib/mcp-preset-events";
import { ClientProvider } from "@/providers/ClientProvider"; import { ClientProvider } from "@/providers/ClientProvider";
import type { import type {
ChannelSetupContract, ChannelSetupContract,
@@ -674,6 +675,7 @@ describe("SettingsView Apps catalog", () => {
connection_summary: "computer-use", connection_summary: "computer-use",
source: "agent-plugin", source: "agent-plugin",
}; };
expect(installedMcpPresetsFromPayload({ presets: [plugin], installed_count: 0 })).toEqual([]);
const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload()); if (url === "/api/settings") return jsonResponse(settingsPayload());
@@ -701,11 +703,11 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView(); renderSettingsView();
expect(await screen.findByText("Computer Use")).toBeInTheDocument(); expect(await screen.findByText("Computer Use")).toBeInTheDocument();
expect(screen.getByText("Plugin")).toBeInTheDocument(); expect(screen.getByText("Plugins")).toBeInTheDocument();
expect( expect(
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"), screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Enable plugin" })); fireEvent.click(screen.getByRole("button", { name: "Enable" }));
await waitFor(() => { await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith( expect(requestMutationMock).toHaveBeenCalledWith(
@@ -715,7 +717,7 @@ describe("SettingsView Apps catalog", () => {
); );
}); });
expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument(); expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument();
const enabledButton = screen.getByRole("button", { name: "Plugin enabled" }); const enabledButton = screen.getByRole("button", { name: "Enabled" });
await waitFor(() => expect(enabledButton).toBeEnabled()); await waitFor(() => expect(enabledButton).toBeEnabled());
fireEvent.pointerDown(enabledButton, { button: 0, ctrlKey: false }); fireEvent.pointerDown(enabledButton, { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" }));
@@ -728,7 +730,7 @@ describe("SettingsView Apps catalog", () => {
); );
}); });
expect(await screen.findByText("Computer Use disabled.")).toBeInTheDocument(); expect(await screen.findByText("Computer Use disabled.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Enable plugin" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Enable" })).toBeInTheDocument();
}); });
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => { it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {