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
nanobot discovers [Agent Plugins](https://agent-plugins.org/) in
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may provide skills, MCP
servers, or both:
```text
plugins/
└── release-tools/
├── plugin.json
├── mcp.json
└── skills/
└── release-notes/
└── SKILL.md
```
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may add `mcp.json`,
`skills/<name>/SKILL.md`, or both.
Directory presence means installed; activation is an explicit trust decision in **Apps**.
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
@dataclass(frozen=True)
class AgentPluginSkill:
"""One skill supplied by a valid Agent Plugins v1 package."""
name: str
path: Path
plugin: str
@dataclass(frozen=True)
class AgentPlugin:
"""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)
if root is None:
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] = []
for candidate in candidates:
for candidate in _children(root, "Agent Plugins directory"):
plugin_root = _contained_directory(candidate, root)
if plugin_root is None:
continue
@@ -88,13 +73,14 @@ def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
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."""
skills: list[AgentPluginSkill] = []
for plugin in _discover_agent_plugins(workspace):
if _enabled(workspace, plugin.name):
skills.extend(_discover_plugin_skills(plugin.name, plugin.root))
return skills
return [
skill
for plugin in _discover_agent_plugins(workspace)
if _enabled(workspace, plugin.name)
for skill in _discover_plugin_skills(plugin.name, plugin.root)
]
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:
raise ValueError(f"unknown Agent Plugin '{name}'")
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):
if enabled:
if plugin.install_command and _setup_version(workspace, plugin.name) != (
plugin.version or "unknown"
):
if plugin.install_command and _setup_version(workspace, plugin.name) != version:
_run_install(plugin, data)
_write_state(data / "setup-version", plugin.version or "unknown")
_write_state(data / "setup-version", version)
_write_state(data / "enabled", "1")
else:
(data / "enabled").unlink(missing_ok=True)
@@ -209,12 +194,11 @@ def _plugin_logo(value: object, plugin_root: Path) -> Path | None:
try:
data = logo.read_bytes() if logo is not None else b""
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")
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"
)
if valid and len(data) <= _MAX_LOGO_BYTES:
):
return logo
except OSError:
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]
config_root = get_config_path().expanduser().resolve().parent
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
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")
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)
if skills_root is None:
return []
try:
candidates = sorted(skills_root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect Agent Plugin '{}' skills: {}", plugin_name, exc)
return []
skills: list[AgentPluginSkill] = []
for candidate in candidates:
skills: list[tuple[str, Path]] = []
for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
skill_root = _contained_directory(candidate, skills_root)
if skill_root is None:
continue
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
skills.append(
AgentPluginSkill(name=candidate.name, path=skill_file, plugin=plugin_name)
)
try:
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
def _valid_skill(path: Path, directory_name: str, plugin_name: str) -> bool:
def _children(root: Path, label: str) -> list[Path]:
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
return False
metadata = parse_skill_metadata(content)
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
return sorted(root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect {}: {}", label, exc)
return []
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)
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
seen_names = {entry["name"] for entry in skills}
for plugin_skill in plugin_skills:
if plugin_skill.name in seen_names:
for name, path in plugin_skills:
if name in seen_names:
continue
skills.append(
{
"name": plugin_skill.name,
"path": str(plugin_skill.path),
"name": name,
"path": str(path),
"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():
skills.extend(
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
def _safe_skill_name(name: str) -> 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."""
def _skill_name(name: str, *, legacy: bool = False) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
if not legacy:
clean = clean.replace("_", "-")
return f"cli-app-{clean or 'app'}"
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"
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
"""Return a CLI App's skill path, including the legacy location."""
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():
return legacy
return canonical
@@ -733,7 +729,7 @@ class CliAppManager:
entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app)
skill_path = _plugin_skill_relative_path(name)
plugin_path = f"plugins/{_safe_skill_name(name)}"
plugin_path = f"plugins/{_skill_name(name)}"
capabilities = [
compact_dict({
"type": "cli",
@@ -1054,7 +1050,7 @@ class CliAppManager:
entry = str(app.get("entry_point") or f"cli-anything-{name}")
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
return f"""---
name: {_safe_skill_name(name)}
name: {_skill_name(name)}
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.parent.mkdir(parents=True, exist_ok=True)
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)
path.write_text(content, encoding="utf-8")
plugin_root = path.parents[2]
manifest = compact_dict({
"$schema": AGENT_PLUGIN_SCHEMA,
"name": _safe_skill_name(str(app["name"])),
"name": _skill_name(str(app["name"])),
"version": str(app.get("version") or ""),
"description": _catalog_description(app),
})
_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():
shutil.rmtree(legacy_dir)
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]
if plugin_root.is_dir():
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():
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
self._save_installed(installed)
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
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,
"required_fields": [],
"connection_summary": ", ".join(state.mcp_servers),
"enabled_tools": ["*"],
"tool_names": [],
"source": "agent-plugin",
}
@@ -902,16 +900,12 @@ def mcp_presets_payload(
for name, cfg in sorted(config.tools.mcp_servers.items())
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)}
plugin_rows = [
row
for state in plugin_states
if (row := _agent_plugin_payload(state))["name"] not in existing_names
_agent_plugin_payload(state)
for state in discover_agent_plugin_states(config.workspace_path)
if (state.mcp_servers or state.plugin.install_command)
and f"plugin-{state.plugin.name}" not in existing_names
]
payload: dict[str, Any] = {
"presets": [*preset_rows, *custom_rows, *plugin_rows],
@@ -1449,16 +1443,13 @@ async def mcp_presets_settings_action(
if name.startswith("plugin-"):
plugin_config = load_config(config_path) if config_path is not None else load_config()
plugin_name = name.removeprefix("plugin-")
plugin_state = next(
(
state
for state in discover_agent_plugin_states(plugin_config.workspace_path)
if state.plugin.name == plugin_name
and (state.mcp_servers or state.plugin.install_command)
),
None,
)
if name not in plugin_config.tools.mcp_servers and plugin_state is not None:
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)
if (
name not in plugin_config.tools.mcp_servers
and plugin_state is not None
and (plugin_state.mcp_servers or plugin_state.plugin.install_command)
):
if action not in {"enable", "disable"}:
raise McpPresetError("Agent Plugins support enable and disable actions only")
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]:
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:
@@ -95,7 +95,6 @@ def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
"name": "release-notes",
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
"source": "plugin",
"plugin": "acme-tools",
}
]
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"):
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 = json.loads(config_path.read_text(encoding="utf-8"))
config["tools"] = {
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
}
config_path.write_text(json.dumps(config), encoding="utf-8")
_write_agent_plugin(load_config().workspace_path)
rows = [
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
]
assert len(rows) == 1
assert rows[0]["source"] == "custom"
+16 -17
View File
@@ -7701,11 +7701,11 @@ function McpAppsCatalogRow({
const toolsBusy = actionKey === `tools:${preset.name}`;
const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy;
const agentPlugin = preset.source === "agent-plugin";
const toggleable = preset.enabled !== undefined;
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
const hasFields = preset.required_fields.length > 0;
const needsSetupInput = missingFields.length > 0;
const pluginEnabled = agentPlugin && preset.enabled === true;
const readyInstalled = agentPlugin ? pluginEnabled : preset.installed && preset.configured;
const readyInstalled = preset.enabled ?? (preset.installed && preset.configured);
const canEnable =
preset.install_supported &&
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
@@ -7717,8 +7717,8 @@ function McpAppsCatalogRow({
const detail = agentPlugin && preset.requires
? `${description} · ${preset.requires}`
: description || preset.requires;
const statusLabel = agentPlugin
? tx("settings.apps.pluginEnabled", "Plugin enabled")
const statusLabel = toggleable
? tx("settings.nanobotFeatures.enabled", "Enabled")
: mcpPresetStatusLabel(preset.status, tx);
useEffect(() => {
@@ -7754,7 +7754,7 @@ function McpAppsCatalogRow({
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
<AppsTypeBadge>
{agentPlugin
? tx("settings.apps.pluginLabel", "Plugin")
? tx("settings.apps.filterPlugins", "Plugins")
: tx("settings.apps.mcpLabel", "Integration")}
</AppsTypeBadge>
</div>
@@ -7775,31 +7775,31 @@ function McpAppsCatalogRow({
</AppsActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!agentPlugin ? (
{!toggleable ? (
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
<PlayCircle aria-hidden />
{tx("settings.mcp.test", "Test")}
</DropdownMenuItem>
) : null}
{!agentPlugin && toolNames.length ? (
{!toggleable && toolNames.length ? (
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
<SlidersHorizontal aria-hidden />
{tx("settings.mcp.toolScope", "Tools")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
tone={agentPlugin ? undefined : "destructive"}
tone={toggleable ? undefined : "destructive"}
disabled={busy}
onClick={() => onAction(agentPlugin ? "disable" : "remove", preset.name)}
onClick={() => onAction(toggleable ? "disable" : "remove", preset.name)}
>
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
{agentPlugin
? tx("settings.apps.pluginDisable", "Disable")
{toggleable ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
{toggleable
? tx("settings.nanobotFeatures.disable", "Disable")
: tx("settings.mcp.remove", "Remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!agentPlugin ? (
{!toggleable ? (
<AppsActionButton
ariaLabel={tx("settings.mcp.remove", "Remove")}
busy={removeBusy}
@@ -7811,9 +7811,9 @@ function McpAppsCatalogRow({
</AppsActionButton>
) : null}
</>
) : agentPlugin && preset.installed ? (
) : preset.enabled === false ? (
<AppsActionButton
ariaLabel={tx("settings.apps.pluginEnable", "Enable plugin")}
ariaLabel={tx("settings.nanobotFeatures.enable", "Enable")}
busy={enableBusy}
onClick={() => onAction("enable", preset.name, values)}
>
@@ -8023,8 +8023,7 @@ function appsTitle(item: AppsCatalogItem): string {
function appsReady(item: AppsCatalogItem): boolean {
if (item.kind === "cli") return item.app.installed;
if (item.preset.source === "agent-plugin") return item.preset.enabled === true;
return item.preset.installed && item.preset.configured;
return item.preset.enabled ?? (item.preset.installed && item.preset.configured);
}
function appsSearchText(item: AppsCatalogItem): string {
-4
View File
@@ -585,10 +585,6 @@
"description": "Add tools to nanobot, then @ them in chat.",
"cliLabel": "App",
"mcpLabel": "Integration",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin enabled",
"pluginEnable": "Enable plugin",
"pluginDisable": "Disable",
"channelLabel": "Channel",
"featureLabel": "Feature",
"filterAll": "Ready",
-4
View File
@@ -572,10 +572,6 @@
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
"cliLabel": "Aplicación",
"mcpLabel": "Integración",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin activado",
"pluginEnable": "Activar plugin",
"pluginDisable": "Desactivar",
"channelLabel": "Canal",
"featureLabel": "Función",
"filterAll": "Listo",
-4
View File
@@ -571,10 +571,6 @@
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
"cliLabel": "Application",
"mcpLabel": "Intégration",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin activé",
"pluginEnable": "Activer le plugin",
"pluginDisable": "Désactiver",
"channelLabel": "Canal",
"featureLabel": "Fonction",
"filterAll": "Prêts",
-4
View File
@@ -571,10 +571,6 @@
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
"cliLabel": "Aplikasi",
"mcpLabel": "Integrasi",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin aktif",
"pluginEnable": "Aktifkan plugin",
"pluginDisable": "Nonaktifkan",
"channelLabel": "Kanal",
"featureLabel": "Fitur",
"filterAll": "Siap",
-4
View File
@@ -571,10 +571,6 @@
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
"cliLabel": "アプリ",
"mcpLabel": "連携",
"pluginLabel": "プラグイン",
"pluginEnabled": "プラグインは有効です",
"pluginEnable": "プラグインを有効にする",
"pluginDisable": "無効にする",
"channelLabel": "チャンネル",
"featureLabel": "機能",
"filterAll": "使用可能",
-4
View File
@@ -571,10 +571,6 @@
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
"cliLabel": "앱",
"mcpLabel": "연동",
"pluginLabel": "플러그인",
"pluginEnabled": "플러그인 활성화됨",
"pluginEnable": "플러그인 활성화",
"pluginDisable": "비활성화",
"channelLabel": "채널",
"featureLabel": "기능",
"filterAll": "사용 가능",
-4
View File
@@ -585,10 +585,6 @@
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
"cliLabel": "Aplicativo",
"mcpLabel": "Integração",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin ativado",
"pluginEnable": "Ativar plugin",
"pluginDisable": "Desativar",
"channelLabel": "Canal",
"featureLabel": "Recurso",
"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.",
"cliLabel": "Ứng dụng",
"mcpLabel": "Tích hợp",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin đã bật",
"pluginEnable": "Bật plugin",
"pluginDisable": "Tắt",
"channelLabel": "Kênh",
"featureLabel": "Tính năng",
"filterAll": "Sẵn sàng",
-4
View File
@@ -585,10 +585,6 @@
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
"cliLabel": "应用",
"mcpLabel": "集成",
"pluginLabel": "插件",
"pluginEnabled": "插件已启用",
"pluginEnable": "启用插件",
"pluginDisable": "停用",
"channelLabel": "渠道",
"featureLabel": "能力",
"filterAll": "可用",
-4
View File
@@ -571,10 +571,6 @@
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
"cliLabel": "應用程式",
"mcpLabel": "整合",
"pluginLabel": "外掛",
"pluginEnabled": "外掛已啟用",
"pluginEnable": "啟用外掛",
"pluginDisable": "停用",
"channelLabel": "通訊管道",
"featureLabel": "功能",
"filterAll": "就緒",
+3 -1
View File
@@ -9,7 +9,9 @@ export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload
}
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 {
+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 { SettingsView } from "@/components/settings/SettingsView";
import { installedMcpPresetsFromPayload } from "@/lib/mcp-preset-events";
import { ClientProvider } from "@/providers/ClientProvider";
import type {
ChannelSetupContract,
@@ -674,6 +675,7 @@ describe("SettingsView Apps catalog", () => {
connection_summary: "computer-use",
source: "agent-plugin",
};
expect(installedMcpPresetsFromPayload({ presets: [plugin], installed_count: 0 })).toEqual([]);
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
@@ -701,11 +703,11 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView();
expect(await screen.findByText("Computer Use")).toBeInTheDocument();
expect(screen.getByText("Plugin")).toBeInTheDocument();
expect(screen.getByText("Plugins")).toBeInTheDocument();
expect(
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Enable plugin" }));
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
@@ -715,7 +717,7 @@ describe("SettingsView Apps catalog", () => {
);
});
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());
fireEvent.pointerDown(enabledButton, { button: 0, ctrlKey: false });
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(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 () => {