From 362c353c44f8876be430274988e1dd46b6e501fa Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 4 Aug 2026 18:28:42 +0800 Subject: [PATCH 1/3] fix(cli): show built-in capabilities before the first session exists The lazy-session refactor left capability calls going through requireSession(), so on a session-less v2 startup /plugins reported the capabilities unavailable and hid the built-in rows behind the promo. Like plugin management, capability readiness and installs are app-global on the v2 engine: the node-sdk harness gains a capability facade over the global channel, and the TUI resolves session-or-harness for every capability call. --- .changeset/capability-sessionless.md | 6 +++ apps/kimi-code/src/tui/commands/plugins.ts | 29 +++++++--- .../tui/commands/plugins-capability.test.ts | 26 +++++---- packages/node-sdk/src/kimi-harness.ts | 21 +++++++- packages/node-sdk/src/session.ts | 2 +- packages/node-sdk/test/kimi-harness.test.ts | 54 +++++++++++++++++++ 6 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 .changeset/capability-sessionless.md diff --git a/.changeset/capability-sessionless.md b/.changeset/capability-sessionless.md new file mode 100644 index 00000000000..7c13359d226 --- /dev/null +++ b/.changeset/capability-sessionless.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Fix the built-in capability rows (Kimi Computer Use, Kimi WebBridge) missing from `/plugins` before the first session exists: capability status and installs now resolve through the app-global channel like plugin management, so the Official tab and setup actions work on a session-less v2 startup. diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 4e729aec3a0..2afc34c80a2 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -195,6 +195,23 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri } } +/** + * Resolve the capability API. Like plugin state, capability state is + * app-global on the v2 engine, so a session-less startup still gets + * readiness and installs through the harness's global facade; with a live + * session the session's own API is used (v1 included, where the capability + * surface then reports itself unavailable). + */ +type CapabilityApi = Pick; + +async function resolveCapabilityApi(host: SlashCommandHost): Promise { + if (host.session !== undefined) return host.session; + if (!host.engineV2) { + throw new Error(NO_ACTIVE_SESSION_MESSAGE); + } + return host.harness; +} + async function showPluginsPicker( host: SlashCommandHost, options?: ShowPluginsPickerOptions, @@ -210,7 +227,7 @@ async function showPluginsPicker( let capabilities: readonly CapabilityStatus[] = []; if (host.engineV2) { try { - capabilities = await host.requireSession().listCapabilities(); + capabilities = await (await resolveCapabilityApi(host)).listCapabilities(); } catch (error) { host.showStatus( `Capability status unavailable: ${formatErrorMessage(error)}. Plugin management remains available.`, @@ -411,12 +428,12 @@ async function pollCapabilityInstall( id: string, label: string, ): Promise { - const session = host.requireSession(); + const api = await resolveCapabilityApi(host); for (let attempt = 0; attempt < CAPABILITY_POLL_ATTEMPTS; attempt += 1) { await new Promise((resolve) => { setTimeout(resolve, CAPABILITY_POLL_INTERVAL_MS); }); - const status = await session.getCapability(id); + const status = await api.getCapability(id); if (!status.install.running) return status; const step = status.install.step ?? 'configuring runtime'; const percent = status.install.percent; @@ -445,16 +462,16 @@ async function installCapabilityFromPanel( // reserved for unreviewed third-party plugins. panel.setInstalling(truncateForStatus(label)); host.state.ui.requestRender(); - const session = host.requireSession(); + const api = await resolveCapabilityApi(host); try { // An install already running (started from another panel or client) is // followed, not restarted — the service rejects duplicate starts even // though the original is healthy. - const alreadyRunning = await session + const alreadyRunning = await api .getCapability(entry.id) .then((status) => status.install.running, () => false); if (!alreadyRunning) { - await session.installCapability(entry.id); + await api.installCapability(entry.id); } } catch (error) { panel.clearInstalling(); diff --git a/apps/kimi-code/test/tui/commands/plugins-capability.test.ts b/apps/kimi-code/test/tui/commands/plugins-capability.test.ts index c62b509701a..75643df68bb 100644 --- a/apps/kimi-code/test/tui/commands/plugins-capability.test.ts +++ b/apps/kimi-code/test/tui/commands/plugins-capability.test.ts @@ -9,27 +9,31 @@ function fakeHost(overrides: { engineV2?: boolean; capabilityStatus?: () => Promise<{ state?: string; + steps?: readonly unknown[]; install: { running: boolean; step?: string; percent?: number; error?: string }; }>; }) { const statuses: string[] = []; const renders: number[] = []; const installCapability = vi.fn(() => Promise.resolve()); - const session = { - getCapability: - overrides.capabilityStatus ?? - (() => Promise.resolve({ state: 'ready', steps: [], install: { running: false } })), - installCapability, - removePlugin: () => Promise.resolve(), - }; + const getCapability = + overrides.capabilityStatus ?? + (() => Promise.resolve({ state: 'ready', steps: [], install: { running: false } })); const host = { engineV2: overrides.engineV2 ?? false, - // Session-less (lazy session): plugin calls fall back to the harness facade. + // Session-less (lazy session): plugin and capability calls fall back to + // the harness facade. session: undefined, harness: { removePlugin: () => Promise.resolve(), + getCapability, + installCapability, + listCapabilities: () => Promise.resolve([]), }, - requireSession: () => session, + requireSession: () => ({ + getCapability, + installCapability, + }), showStatus: (text: string) => { statuses.push(text); }, @@ -91,6 +95,7 @@ describe('plugins command capability surface', () => { it('polls progress into the panel until the install settles', async () => { let calls = 0; const { host } = fakeHost({ + engineV2: true, capabilityStatus: () => { calls += 1; if (calls === 1) { @@ -122,7 +127,7 @@ describe('plugins command capability surface', () => { }); it('starts a capability install only when none is running', async () => { - const idle = fakeHost({}); + const idle = fakeHost({ engineV2: true }); await installCapabilityFromPanel( idle.host, fakePanel().panel, @@ -134,6 +139,7 @@ describe('plugins command capability surface', () => { it('follows an in-progress capability install instead of restarting it', async () => { let calls = 0; const { host, installCapability, statuses } = fakeHost({ + engineV2: true, capabilityStatus: () => { calls += 1; // The pre-check sees the running install; the poll then sees it settle. diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index bc203f13b13..3aa1843debe 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -7,11 +7,12 @@ import { type ExperimentalFeatureState, } from '@moonshot-ai/agent-core'; -import { Session } from '#/session'; +import { capabilityRpc, Session } from '#/session'; import type { KimiAuthFacade } from '#/auth'; import type { SDKRpcClientBase } from '#/rpc'; import type { AuthenticateMcpServerOptions, + CapabilityStatus, ConfigDiagnostics, CreateSessionOptions, ExportSessionInput, @@ -312,6 +313,24 @@ export class KimiHarness { return this.rpc.getPluginInfo(id); } + /** + * App-global capability readiness and setup (the built-in product + * capabilities kimi-cu / kimi-webbridge), no session required. Routed + * through the same global channel as session capability calls; requires + * the v2 engine and throws on v1, which has no capability surface. + */ + async listCapabilities(): Promise { + return capabilityRpc(this.rpc).listCapabilities(); + } + + async getCapability(id: string): Promise { + return capabilityRpc(this.rpc).getCapability(id); + } + + async installCapability(id: string): Promise { + return capabilityRpc(this.rpc).installCapability(id); + } + /** * Trust state of `workDir` (agent-core-v2 only; the v1 engine reports an * always-trusted workspace). Querying may register the workDir as a diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 25fb73aee6f..5e4db69f899 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -62,7 +62,7 @@ interface CapabilityRpcSurface { installCapability(id: string): Promise; } -function capabilityRpc(rpc: SDKRpcClientBase): CapabilityRpcSurface { +export function capabilityRpc(rpc: SDKRpcClientBase): CapabilityRpcSurface { const candidate = rpc as Partial; if ( typeof candidate.listCapabilities !== 'function' || diff --git a/packages/node-sdk/test/kimi-harness.test.ts b/packages/node-sdk/test/kimi-harness.test.ts index 8e8e21eb7c6..4f7c82ca40a 100644 --- a/packages/node-sdk/test/kimi-harness.test.ts +++ b/packages/node-sdk/test/kimi-harness.test.ts @@ -28,6 +28,60 @@ class StubRpc extends SDKRpcClientBase { } } +function makeHarnessWithRpc(rpc: SDKRpcClientBase): KimiHarness { + return new KimiHarness(rpc, { + homeDir: '/tmp/home', + configPath: '/tmp/config.toml', + auth: { status: async () => ({ providers: [] }) } as never, + telemetry: recordingTelemetry([]), + ensureConfigFile: async () => undefined, + onClose: () => undefined, + }); +} + +describe('KimiHarness capability facade', () => { + const ready = { + id: 'kimi-webbridge', + displayName: 'Kimi WebBridge', + description: 'd', + supported: true, + state: 'ready', + steps: [], + install: { running: false }, + } as const; + + it('routes capability calls through the global channel with no session', async () => { + const calls: string[] = []; + class CapabilityRpc extends StubRpc { + async listCapabilities() { + calls.push('list'); + return [ready]; + } + async getCapability(id: string) { + calls.push(`get:${id}`); + return ready; + } + async installCapability(id: string) { + calls.push(`install:${id}`); + return ready; + } + } + const harness = makeHarnessWithRpc(new CapabilityRpc()); + + expect(await harness.listCapabilities()).toEqual([ready]); + expect((await harness.getCapability('kimi-webbridge')).state).toBe('ready'); + await harness.installCapability('kimi-webbridge'); + expect(calls).toEqual(['list', 'get:kimi-webbridge', 'install:kimi-webbridge']); + }); + + it('reports the capability surface as unavailable on v1', async () => { + // The v1 rpc has no capability methods, exactly like the real v1 client. + const harness = makeHarnessWithRpc(new StubRpc()); + await expect(harness.listCapabilities()).rejects.toThrow(/requires v2/); + await expect(harness.installCapability('kimi-cu')).rejects.toThrow(/requires v2/); + }); +}); + describe('KimiHarness imageLimits', () => { it('exposes the in-process core [image] limits loaded from config.toml', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-harness-')); From 82bfe258faef3008b734bdd76f50ce447559f21e Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 4 Aug 2026 18:37:28 +0800 Subject: [PATCH 2/3] fix(cli): count the dev marketplace server as the default catalog dev.mjs always points KIMI_CODE_PLUGIN_MARKETPLACE_URL at its own repo-serving server, which the override gate mistook for a user-configured marketplace and suppressed the built-in capability rows in every dev run. The dev server now marks itself, and the gate treats that marked URL as the default catalog while still honoring real overrides (slash-command source, user-set env, KIMI_CODE_DEV_MARKETPLACE_URL). --- .changeset/capability-sessionless.md | 2 +- apps/kimi-code/scripts/dev.mjs | 3 +++ apps/kimi-code/src/tui/commands/plugins.ts | 27 ++++++++++++------- .../tui/commands/plugins-capability.test.ts | 27 +++++++++++++++++-- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/.changeset/capability-sessionless.md b/.changeset/capability-sessionless.md index 7c13359d226..86f523c7fcf 100644 --- a/.changeset/capability-sessionless.md +++ b/.changeset/capability-sessionless.md @@ -3,4 +3,4 @@ "@moonshot-ai/kimi-code-sdk": patch --- -Fix the built-in capability rows (Kimi Computer Use, Kimi WebBridge) missing from `/plugins` before the first session exists: capability status and installs now resolve through the app-global channel like plugin management, so the Official tab and setup actions work on a session-less v2 startup. +Fix the built-in capability rows (Kimi Computer Use, Kimi WebBridge) missing from `/plugins`: capability status and installs now resolve through the app-global channel like plugin management, so they work on a session-less v2 startup, and the rows also show up under the dev marketplace server (which serves this repo's own catalog and is no longer mistaken for a user override). diff --git a/apps/kimi-code/scripts/dev.mjs b/apps/kimi-code/scripts/dev.mjs index 3f50b969c30..124413a03ea 100644 --- a/apps/kimi-code/scripts/dev.mjs +++ b/apps/kimi-code/scripts/dev.mjs @@ -32,6 +32,9 @@ if (externalUrl !== undefined && externalUrl.length > 0) { const inherited = process.env[MARKETPLACE_ENV]?.trim(); marketplaceServer = await startPluginMarketplaceServer(); env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl; + // Marks the URL as the dev server's own (serving this repo's catalog), so + // the CLI can tell it apart from a user-configured marketplace override. + env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] = '1'; console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`); if (inherited !== undefined && inherited.length > 0 && inherited !== marketplaceServer.marketplaceUrl) { console.error( diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 2afc34c80a2..5edd65fc91c 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -240,9 +240,7 @@ async function showPluginsPicker( installed: plugins, installedIds: new Set(plugins.map((plugin) => plugin.id)), capabilities, - catalogIsDefault: - options?.marketplaceSource === undefined && - process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined, + catalogIsDefault: isDefaultMarketplaceCatalog(options?.marketplaceSource), initialTab: options?.initialTab, selectedId: options?.selectedId, pluginHint: options?.pluginHint, @@ -295,6 +293,21 @@ function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketp }; } +/** + * Injection is part of the DEFAULT catalog experience only: any explicit + * replacement (the slash-command source or a user-set env override) opts out + * wholesale. The dev marketplace server started by scripts/dev.mjs serves + * this repo's own catalog and marks itself, so it still counts as default. + */ +function isDefaultMarketplaceCatalog( + source: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (source !== undefined) return false; + if (env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined) return true; + return env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] === '1'; +} + async function loadMarketplaceCatalog( host: SlashCommandHost, panel: PluginsPanelComponent, @@ -302,16 +315,11 @@ async function loadMarketplaceCatalog( capabilities: readonly CapabilityStatus[], ): Promise { try { - // Injection is part of the DEFAULT catalog experience only: any explicit - // replacement (the slash-command source or the env override) opts out - // wholesale — its same-id rows are never masked and its failures surface. - const isDefaultCatalog = - source === undefined && process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined; const marketplace = await loadPluginMarketplace({ workDir: host.state.appState.workDir, source, builtInEntries: - host.engineV2 && isDefaultCatalog + host.engineV2 && isDefaultMarketplaceCatalog(source) ? capabilities.map(capabilityMarketplaceEntry) : undefined, }); @@ -448,6 +456,7 @@ async function pollCapabilityInstall( export const __pluginsCommandInternals = { isCapabilityEntry, installCapabilityFromPanel, + isDefaultMarketplaceCatalog, pollCapabilityInstall, removePlugin, }; diff --git a/apps/kimi-code/test/tui/commands/plugins-capability.test.ts b/apps/kimi-code/test/tui/commands/plugins-capability.test.ts index 75643df68bb..1d3ae3cc572 100644 --- a/apps/kimi-code/test/tui/commands/plugins-capability.test.ts +++ b/apps/kimi-code/test/tui/commands/plugins-capability.test.ts @@ -2,8 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { __pluginsCommandInternals } from '#/tui/commands/plugins'; -const { isCapabilityEntry, installCapabilityFromPanel, pollCapabilityInstall, removePlugin } = - __pluginsCommandInternals; +const { + isCapabilityEntry, + installCapabilityFromPanel, + isDefaultMarketplaceCatalog, + pollCapabilityInstall, + removePlugin, +} = __pluginsCommandInternals; function fakeHost(overrides: { engineV2?: boolean; @@ -120,6 +125,24 @@ describe('plugins command capability surface', () => { expect(statuses.some((s) => s.includes('plugin wiring is disabled for new sessions'))).toBe(true); }); + it('treats only the default catalog (and the dev server) as injectable', () => { + expect(isDefaultMarketplaceCatalog(undefined, {})).toBe(true); + // The dev marketplace server started by scripts/dev.mjs serves this + // repo's own catalog — it counts as default, not as a user override. + expect( + isDefaultMarketplaceCatalog(undefined, { + KIMI_CODE_PLUGIN_MARKETPLACE_URL: 'http://127.0.0.1:60056/marketplace.json', + KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER: '1', + }), + ).toBe(true); + expect( + isDefaultMarketplaceCatalog(undefined, { + KIMI_CODE_PLUGIN_MARKETPLACE_URL: 'https://example.test/marketplace.json', + }), + ).toBe(false); + expect(isDefaultMarketplaceCatalog('https://example.test/marketplace.json', {})).toBe(false); + }); + it('removePlugin stays quiet for non-capability plugins', async () => { const { host, statuses } = fakeHost({ engineV2: true }); await removePlugin(host, 'superpowers'); From 390a4f58917f01304206fc9f2222043aea0c6958 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 5 Aug 2026 14:12:07 +0800 Subject: [PATCH 3/3] fix(cli): align built-in capability updates --- .changeset/capability-sessionless.md | 2 +- apps/kimi-code/src/tui/commands/plugins.ts | 107 ++++++++----- .../components/dialogs/plugins-selector.ts | 122 ++------------ .../kimi-code/src/utils/plugin-marketplace.ts | 13 +- .../tui/commands/plugins-capability.test.ts | 45 +++++- .../dialogs/plugins-selector.test.ts | 72 +++++---- .../test/utils/plugin-marketplace.test.ts | 7 +- docs/en/customization/plugins.md | 2 +- docs/zh/customization/plugins.md | 2 +- .../src/app/capability/entries/kimiCu.ts | 128 ++++++++++++--- .../app/capability/entries/kimiWebbridge.ts | 85 +++++++--- .../test/app/capability/kimiCu.test.ts | 151 +++++++++++++++++- .../test/app/capability/kimiWebbridge.test.ts | 61 ++++++- plugins/marketplace.json | 9 ++ 14 files changed, 561 insertions(+), 245 deletions(-) diff --git a/.changeset/capability-sessionless.md b/.changeset/capability-sessionless.md index 86f523c7fcf..f5ebbd575da 100644 --- a/.changeset/capability-sessionless.md +++ b/.changeset/capability-sessionless.md @@ -3,4 +3,4 @@ "@moonshot-ai/kimi-code-sdk": patch --- -Fix the built-in capability rows (Kimi Computer Use, Kimi WebBridge) missing from `/plugins`: capability status and installs now resolve through the app-global channel like plugin management, so they work on a session-less v2 startup, and the rows also show up under the dev marketplace server (which serves this repo's own catalog and is no longer mistaken for a user override). +Fix built-in capability availability and installed status in `/plugins`, preserve legacy WebBridge skills as backups during updates, and prevent Computer Use updates from duplicating or disconnecting MCP servers. diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 5edd65fc91c..8c046c9e85f 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -1,7 +1,13 @@ import { homedir as osHomedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; -import type { CapabilityStatus, PluginInfo, PluginSummary, Session } from '@moonshot-ai/kimi-code-sdk'; +import { + log, + type CapabilityStatus, + type PluginInfo, + type PluginSummary, + type Session, +} from '@moonshot-ai/kimi-code-sdk'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { @@ -9,8 +15,6 @@ import { PluginMcpSelectorComponent, PluginRemoveConfirmComponent, PluginsPanelComponent, - describeCapabilityIssues, - formatCapabilityVersion, type PluginInstallTrustConfirmResult, type PluginMcpSelection, type PluginRemoveConfirmResult, @@ -212,6 +216,27 @@ async function resolveCapabilityApi(host: SlashCommandHost): Promise step.state !== 'ok'); + if ( + capability.install.error !== undefined || + (installed !== false && hasStepIssues) + ) { + log.warn('capability needs attention', payload); + } else { + log.info('capability status', payload); + } +} + async function showPluginsPicker( host: SlashCommandHost, options?: ShowPluginsPickerOptions, @@ -229,16 +254,18 @@ async function showPluginsPicker( try { capabilities = await (await resolveCapabilityApi(host)).listCapabilities(); } catch (error) { - host.showStatus( - `Capability status unavailable: ${formatErrorMessage(error)}. Plugin management remains available.`, - 'warning', - ); + log.warn('capability status unavailable', { error }); } } + const installedIds = new Set(plugins.map((plugin) => plugin.id)); + for (const capability of capabilities) { + logCapabilityStatus(capability, installedIds.has(capability.id)); + } + const panel = new PluginsPanelComponent({ installed: plugins, - installedIds: new Set(plugins.map((plugin) => plugin.id)), + installedIds, capabilities, catalogIsDefault: isDefaultMarketplaceCatalog(options?.marketplaceSource), initialTab: options?.initialTab, @@ -428,27 +455,28 @@ function isCapabilityId(host: SlashCommandHost, id: string): boolean { return host.engineV2 && (id === 'kimi-cu' || id === 'kimi-webbridge'); } -/** Poll a background capability install, mirroring progress into the - * panel's inline installing line until it settles (or we run out of budget). */ +/** Poll a background capability install until it settles (or we run out of budget). */ async function pollCapabilityInstall( host: SlashCommandHost, - panel: PluginsPanelComponent, id: string, - label: string, ): Promise { const api = await resolveCapabilityApi(host); + let previousProgress = ''; for (let attempt = 0; attempt < CAPABILITY_POLL_ATTEMPTS; attempt += 1) { await new Promise((resolve) => { setTimeout(resolve, CAPABILITY_POLL_INTERVAL_MS); }); const status = await api.getCapability(id); if (!status.install.running) return status; - const step = status.install.step ?? 'configuring runtime'; - const percent = status.install.percent; - panel.setInstalling( - `${truncateForStatus(label)} — ${step}${percent !== undefined ? ` ${percent}%` : ''}`, - ); - host.state.ui.requestRender(); + const progress = `${status.install.step ?? ''}:${status.install.percent ?? ''}`; + if (progress !== previousProgress) { + previousProgress = progress; + log.info('capability install progress', { + capabilityId: id, + step: status.install.step, + percent: status.install.percent, + }); + } } return undefined; } @@ -472,6 +500,7 @@ async function installCapabilityFromPanel( panel.setInstalling(truncateForStatus(label)); host.state.ui.requestRender(); const api = await resolveCapabilityApi(host); + log.info('capability install requested', { capabilityId: entry.id }); try { // An install already running (started from another panel or client) is // followed, not restarted — the service rejects duplicate starts even @@ -481,8 +510,11 @@ async function installCapabilityFromPanel( .then((status) => status.install.running, () => false); if (!alreadyRunning) { await api.installCapability(entry.id); + } else { + log.info('following running capability install', { capabilityId: entry.id }); } } catch (error) { + log.warn('capability install failed to start', { capabilityId: entry.id, error }); panel.clearInstalling(); host.state.ui.requestRender(); host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); @@ -491,8 +523,9 @@ async function installCapabilityFromPanel( } let result: CapabilityStatus | undefined; try { - result = await pollCapabilityInstall(host, panel, entry.id, label); - } catch { + result = await pollCapabilityInstall(host, entry.id); + } catch (error) { + log.warn('capability install polling failed', { capabilityId: entry.id, error }); result = undefined; } panel.clearInstalling(); @@ -500,40 +533,32 @@ async function installCapabilityFromPanel( // plain plugin install flow. host.restoreEditor(); if (result === undefined) { - host.showStatus(`${label} setup is still running in the background; /plugins shows its state.`); + host.showStatus(`${label} installation is still running in the background.`); return; } + logCapabilityStatus(result); if (result.install.error !== undefined) { - host.showError(`${label} setup failed: ${result.install.error}. Install again from /plugins to retry.`); + host.showError(`${label} installation failed. Check the logs and install again from /plugins.`); return; } if (result.state !== 'ready') { - const issues = describeCapabilityIssues(result); - host.showStatus( - `${label} setup is incomplete${issues.length > 0 ? `: ${issues}` : ''}.`, - 'warning', - ); - if (result.id === 'kimi-cu' && result.steps.some((step) => step.id === 'permissions' && step.state !== 'ok')) { + const permissionsRequired = + entry.id === 'kimi-cu' && + result.steps.some((step) => step.id === 'permissions' && step.state !== 'ok'); + if (permissionsRequired) { host.showStatus( - 'Grant Accessibility and Screen Recording in System Settings → Privacy & Security, then reopen /plugins to recheck.', + 'Grant Accessibility and Screen Recording in System Settings → Privacy & Security.', 'warning', ); + } else { + host.showError( + `${label} installation did not complete. Check the logs and install again from /plugins.`, + ); } host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); return; } - host.showStatus( - `${label} is ready${result.version !== undefined ? ` (${formatCapabilityVersion(result.version)})` : ''}.`, - ); - const skillShadow = result.steps.find( - (step) => step.id === 'skill-shadow' && step.state !== 'ok', - ); - if (skillShadow?.detail !== undefined) { - host.showStatus( - `A user-installed kimi-webbridge skill is shadowing the managed plugin. Remove it manually: ${skillShadow.detail}`, - 'warning', - ); - } + host.showStatus(`${label} is installed.`); host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index b2238df5718..f1c50a986d3 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -295,9 +295,6 @@ function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: st // warning — the two used to share near-identical green-ish treatments in // the same column and read as interchangeable. if (status.startsWith('update')) return chalk.hex(colors.warning); - if (status === 'finish setup' || status === 'installing…' || status === 'unsupported') { - return chalk.hex(colors.warning); - } if (status.startsWith('installed')) return chalk.hex(colors.textDim); return chalk.hex(colors.primary); } @@ -570,11 +567,6 @@ export class PluginsPanelComponent extends Container implements Focusable { } if (matchesKey(data, Key.enter)) { if (plugin === undefined) return; - const capability = this.capabilityFor(plugin.id); - if (capability !== undefined && capabilityNeedsSetup(capability)) { - this.opts.onSelect({ kind: 'install', entry: capabilityMarketplaceEntry(capability) }); - return; - } const update = this.installedUpdateStatus(plugin); if (update !== undefined) { this.opts.onSelect({ kind: 'install', entry: update.entry }); @@ -667,10 +659,8 @@ export class PluginsPanelComponent extends Container implements Focusable { private installedHint(): string { const plugin = this.opts.installed[this.selectedIndex]; - const capability = plugin === undefined ? undefined : this.capabilityFor(plugin.id); - const needsSetup = capability !== undefined && capabilityNeedsSetup(capability); const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined; - const enter = needsSetup ? 'Enter finish setup' : hasUpdate ? 'Enter update' : 'Enter details'; + const enter = hasUpdate ? 'Enter update' : 'Enter details'; return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`; } @@ -692,7 +682,6 @@ export class PluginsPanelComponent extends Container implements Focusable { const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); const status = pluginStatus(plugin); const update = this.installedUpdateStatus(plugin); - const capability = this.capabilityFor(plugin.id); let line = prefix + labelStyle(plugin.displayName); if (status !== undefined) { line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status); @@ -701,31 +690,12 @@ export class PluginsPanelComponent extends Container implements Focusable { const badge = `update ${update.local} → ${update.latest}`; line += ' ' + marketplaceStatusStyle(badge, colors)(badge); } - if (capability !== undefined && capability.state !== 'ready') { - const badge = capability.install.running - ? 'installing…' - : capabilityNeedsSetup(capability) - ? 'setup incomplete' - : capability.state === 'unsupported' - ? 'unsupported' - : undefined; - if (badge !== undefined) { - // Unsupported is a fact, not a problem: dim it; actionable setup - // states keep the warning tone. - line += ' ' + (badge === 'unsupported' ? chalk.hex(colors.textDim)(badge) : chalk.hex(colors.warning)(badge)); - } - } if (this.opts.pluginHint?.id === plugin.id) { line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); } const descWidth = Math.max(1, width - 4); const out = [line]; - const capabilityIssues = capability === undefined ? '' : describeCapabilityIssues(capability); - const description = - capabilityIssues.length === 0 - ? overviewPluginDescription(plugin) - : `${overviewPluginDescription(plugin)} · ${capabilityIssues}`; - for (const descLine of wrapOverviewDescription(description, descWidth)) { + for (const descLine of wrapOverviewDescription(overviewPluginDescription(plugin), descWidth)) { out.push(mutedHintLine(` ${descLine}`, colors)); } return out; @@ -798,18 +768,17 @@ export class PluginsPanelComponent extends Container implements Focusable { const capability = this.capabilityForEntry(entry); const status = isPinnedWebBridgeEntry(entry) ? 'open in browser' - : capability === undefined - ? marketplaceEntryStatus(entry, this.installedVersions) - : capabilityRowStatus(capability, entry); + : capability?.install.running === true + ? 'installing…' + : marketplaceEntryStatus(entry, this.installedVersions); const line = prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status); const descWidth = Math.max(1, width - 4); const out = [line]; - const capabilityIssues = capability === undefined ? '' : describeCapabilityIssues(capability); const description = - capabilityIssues.length === 0 - ? marketplaceEntryDescription(entry) - : `${marketplaceEntryDescription(entry)} · ${capabilityIssues}`; + this.activeTab.id === 'official' + ? officialMarketplaceEntryDescription(entry) + : marketplaceEntryDescription(entry); for (const descLine of wrapOverviewDescription(description, descWidth)) { out.push(mutedHintLine(` ${descLine}`, colors)); } @@ -829,7 +798,7 @@ export class PluginsPanelComponent extends Container implements Focusable { chalk.hex(colors.primary)('─'.repeat(width)), chalk.hex(colors.primary).bold(' Plugins'), '', - chalk.hex(colors.textMuted)(` Installing ${this.installing} from marketplace…`), + chalk.hex(colors.textMuted)(` Installing ${this.installing}…`), '', chalk.hex(colors.primary)('─'.repeat(width)), ]; @@ -882,6 +851,10 @@ function marketplaceEntryDescription(entry: PluginMarketplaceEntry): string { return `${description} · id ${entry.id}${version}${tierSuffix}${keywords}`; } +function officialMarketplaceEntryDescription(entry: PluginMarketplaceEntry): string { + return entry.description ?? ''; +} + function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { if (tier === 'official') return 'Official plugin'; if (tier === 'curated') return 'Curated plugin'; @@ -899,75 +872,6 @@ function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketp }; } -/** - * Setup is actionable only for these states. An `unsupported` capability - * (wrong OS/arch) can only fail — the service rejects its install — so the - * panel must not offer a "finish setup" action that ends in an error; it - * renders as unsupported instead. - */ -function capabilityNeedsSetup(capability: CapabilityStatus): boolean { - return ( - (capability.state === 'not_installed' || capability.state === 'partial') && - !capability.install.running - ); -} - -function capabilityRowStatus( - capability: CapabilityStatus, - entry: PluginMarketplaceEntry, -): string { - if (capability.install.running) return 'installing…'; - switch (capability.state) { - case 'ready': - return capability.version === undefined - ? 'ready' - : `ready · ${formatCapabilityVersion(capability.version)}`; - case 'partial': - return 'finish setup'; - case 'not_installed': - return installStatus(entry); - case 'unsupported': - return 'unsupported'; - } -} - -export function formatCapabilityVersion(version: string): string { - return version.startsWith('v') ? version : `v${version}`; -} - -export function describeCapabilityIssues(capability: CapabilityStatus): string { - const issues: string[] = []; - const required = capability.steps.filter( - (step) => step.optional !== true && step.state !== 'ok', - ); - if (required.length > 0) { - issues.push(`needs ${required.map(formatCapabilityStep).join(', ')}`); - } - const extension = capability.steps.find( - (step) => step.id === 'extension' && step.state !== 'ok', - ); - if (extension !== undefined) issues.push('browser extension not connected'); - const skillShadow = capability.steps.find( - (step) => step.id === 'skill-shadow' && step.state !== 'ok', - ); - if (skillShadow !== undefined) issues.push('user skill shadows managed plugin'); - return issues.join(', '); -} - -function formatCapabilityStep(step: CapabilityStatus['steps'][number]): string { - const label = - step.id === 'daemon-binary' - ? 'daemon binary' - : step.id === 'skill' - ? 'agent skill' - : step.id; - if (step.detail === undefined || step.detail.length === 0) return label; - const detail = step.detail - .replaceAll('screenRecording', 'screen recording') - .replaceAll(',', ', '); - return `${label} (${detail})`; -} - function installStatus(entry: PluginMarketplaceEntry): string { return entry.version === undefined ? 'install' : `install v${entry.version}`; } diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index 4ab1d7bd044..2ad4caa8906 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -125,17 +125,22 @@ export async function loadPluginMarketplace( * client instead of being served by the marketplace catalog, so their * visibility is bound to the client version — older clients never see them. * Same-id catalog rows are MASKED, not merged: what these ids mean stays - * decided by the client release, and a future official marketplace listing - * only reaches older clients (whose fix is to upgrade). No `version` is - * pinned: reinstalling uses the latest managed artifacts. + * decided by the client release. The catalog may contribute only its version + * so the built-in row can use the normal update badge while keeping the + * capability install route and client-owned copy. */ function withBuiltInEntries( marketplace: PluginMarketplace, builtIns: readonly PluginMarketplaceEntry[], ): PluginMarketplace { const builtInIds = new Set(builtIns.map((entry) => entry.id)); + const catalogById = new Map(marketplace.plugins.map((entry) => [entry.id, entry])); const catalog = marketplace.plugins.filter((entry) => !builtInIds.has(entry.id)); - return { ...marketplace, plugins: [...catalog, ...builtIns] }; + const enrichedBuiltIns = builtIns.map((entry) => { + const version = catalogById.get(entry.id)?.version; + return version === undefined ? entry : { ...entry, version }; + }); + return { ...marketplace, plugins: [...catalog, ...enrichedBuiltIns] }; } async function withLatestVersions( diff --git a/apps/kimi-code/test/tui/commands/plugins-capability.test.ts b/apps/kimi-code/test/tui/commands/plugins-capability.test.ts index 1d3ae3cc572..ab40816ef2b 100644 --- a/apps/kimi-code/test/tui/commands/plugins-capability.test.ts +++ b/apps/kimi-code/test/tui/commands/plugins-capability.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { log } from '@moonshot-ai/kimi-code-sdk'; import { __pluginsCommandInternals } from '#/tui/commands/plugins'; @@ -69,6 +70,8 @@ function fakePanel() { describe('plugins command capability surface', () => { beforeEach(() => { vi.restoreAllMocks(); + vi.spyOn(log, 'info').mockImplementation(() => undefined); + vi.spyOn(log, 'warn').mockImplementation(() => undefined); }); it('routes built-in entries through capabilities only on v2', () => { @@ -97,7 +100,7 @@ describe('plugins command capability surface', () => { ).toBe(false); }); - it('polls progress into the panel until the install settles', async () => { + it('logs progress without replacing the generic installing label', async () => { let calls = 0; const { host } = fakeHost({ engineV2: true, @@ -109,12 +112,14 @@ describe('plugins command capability surface', () => { return Promise.resolve({ install: { running: false } }); }, }); - const { panel, lines } = fakePanel(); - - const result = await pollCapabilityInstall(host, panel, 'kimi-cu', 'Kimi Computer Use'); + const result = await pollCapabilityInstall(host, 'kimi-cu'); expect(result?.install.running).toBe(false); - expect(lines).toContain('Kimi Computer Use — download 40%'); + expect(log.info).toHaveBeenCalledWith('capability install progress', { + capabilityId: 'kimi-cu', + step: 'download', + percent: 40, + }); }); it('removePlugin notes that capability runtimes are left untouched', async () => { @@ -184,6 +189,34 @@ describe('plugins command capability surface', () => { // install must be followed via polling, never reported as a failure. expect(installCapability).not.toHaveBeenCalled(); expect(statuses.some((s) => s.includes('Failed to install'))).toBe(false); - expect(statuses.some((s) => s.includes('is ready'))).toBe(true); + expect(statuses.some((s) => s.includes('is installed'))).toBe(true); + }); + + it('shows required permissions once after installation instead of exposing step details', async () => { + const { host, statuses } = fakeHost({ + engineV2: true, + capabilityStatus: () => Promise.resolve({ + id: 'kimi-cu', + state: 'partial', + steps: [{ id: 'permissions', state: 'missing', detail: 'screenRecording' }], + install: { running: false }, + }), + }); + + await installCapabilityFromPanel( + host, + fakePanel().panel, + { id: 'kimi-cu', displayName: 'Kimi Computer Use', source: 'capability:kimi-cu' } as never, + ); + + expect(statuses.some((s) => s.includes('Grant Accessibility and Screen Recording'))).toBe(true); + expect(statuses.some((s) => s.includes('screenRecording'))).toBe(false); + expect(log.warn).toHaveBeenCalledWith( + 'capability needs attention', + expect.objectContaining({ + capabilityId: 'kimi-cu', + steps: [expect.objectContaining({ detail: 'screenRecording' })], + }), + ); }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 0d6743ec726..3acd14ee4f4 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -62,7 +62,15 @@ const superpowers = { }; const officialEntries = [ - { id: 'kimi-datasource', tier: 'official' as const, displayName: 'Kimi Datasource', version: '3.1.1', source: 'https://x/d.zip' }, + { + id: 'kimi-datasource', + tier: 'official' as const, + displayName: 'Kimi Datasource', + description: 'Query supported data sources', + version: '3.1.1', + source: 'https://x/d.zip', + keywords: ['data'], + }, ]; const thirdPartyEntries = [ { id: 'superpowers', tier: 'curated' as const, displayName: 'Superpowers', source: 'https://x/s.zip' }, @@ -396,6 +404,11 @@ describe('plugins selector dialogs', () => { panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); const out = strip(renderRaw(panel)); expect(out).toContain('Kimi Datasource install'); + expect(out).toContain('Query supported data sources'); + expect(out).not.toContain('Query supported data sources · v3.1.1'); + expect(out).not.toContain('id kimi-datasource'); + expect(out).not.toContain('Official plugin'); + expect(out).not.toContain('· data'); expect(out).toContain('0 installed · 1 available'); }); @@ -434,7 +447,6 @@ describe('plugins selector dialogs', () => { const out = strip(renderRaw(panel)); expect(out).toContain('Kimi WebBridge (fork) install'); - expect(out).not.toContain('finish setup'); panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ @@ -459,8 +471,11 @@ describe('plugins selector dialogs', () => { // remote catalog: the engine-known rows render (and the promo is // suppressed by the real webbridge row). const out = strip(renderRaw(panel)); - expect(out).toContain('Kimi Computer Use finish setup'); + expect(out).toContain('Kimi Computer Use install'); expect(out).toContain('Kimi WebBridge install'); + expect(out).toContain('Background GUI automation'); + expect(out).not.toContain('id kimi-cu'); + expect(out).not.toContain('Official plugin'); expect(out).not.toContain('open in browser'); expect(out).toContain('Loading marketplace'); @@ -573,7 +588,7 @@ describe('plugins selector dialogs', () => { const { panel } = makePanel({ installed: [superpowers] }); panel.setInstalling('Superpowers'); const out = strip(renderRaw(panel)); - expect(out).toContain('Installing Superpowers from marketplace'); + expect(out).toContain('Installing Superpowers…'); }); it('keeps a valid selection if ↓ is pressed while the catalog is loading', () => { @@ -633,7 +648,7 @@ describe('plugins selector dialogs', () => { expect(out).toContain('Superpowers enabled update 4.0.0 → 5.0.0'); }); - it('shows incomplete capability setup on installed and official rows', () => { + it('keeps installation state separate from capability readiness', () => { const installed = [ { ...superpowers, id: 'kimi-cu', displayName: 'Kimi Computer Use', version: '0.5.4' }, ]; @@ -652,16 +667,18 @@ describe('plugins selector dialogs', () => { panel.setMarketplace(entries, '/tmp/marketplace.json'); const installedOut = strip(renderRaw(panel)); - expect(installedOut).toContain('Kimi Computer Use enabled setup incomplete'); - expect(installedOut).toContain('needs permissions (screen recording)'); + expect(installedOut).toContain('Kimi Computer Use enabled'); + expect(installedOut).not.toContain('setup incomplete'); + expect(installedOut).not.toContain('needs permissions'); panel.handleInput('\t'); const officialOut = strip(renderRaw(panel)); - expect(officialOut).toContain('Kimi Computer Use finish setup'); - expect(officialOut).toContain('needs permissions (screen recording)'); + expect(officialOut).toContain('Kimi Computer Use installed · v0.5.4'); + expect(officialOut).toContain('1 installed · 0 available'); + expect(officialOut).not.toContain('needs permissions'); }); - it('continues incomplete capability setup from the Installed tab on Enter', () => { + it('keeps Enter on the Installed tab consistent with other plugins', () => { const installed = [ { ...superpowers, id: 'kimi-cu', displayName: 'Kimi Computer Use', version: '0.5.4' }, ]; @@ -669,16 +686,10 @@ describe('plugins selector dialogs', () => { panel.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ - kind: 'install', - entry: expect.objectContaining({ id: 'kimi-cu', source: 'capability:kimi-cu' }), - }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'kimi-cu' }); }); - it('renders an unsupported capability as a fact, not a setup action', () => { - // e.g. the kimi-cu plugin installed on Linux (shared home / v1 path): - // Enter can only end in the service rejecting the install, so the row - // must not offer "finish setup". + it('keeps unsupported capability diagnostics out of the Installed list', () => { const installed = [ { ...superpowers, id: 'kimi-cu', displayName: 'Kimi Computer Use', version: '0.5.4' }, ]; @@ -688,15 +699,14 @@ describe('plugins selector dialogs', () => { const { panel, onSelect } = makePanel({ installed, capabilities }); const out = strip(renderRaw(panel)); - expect(out).toContain('Kimi Computer Use enabled unsupported'); - expect(out).not.toContain('setup incomplete'); - expect(out).not.toContain('finish setup'); + expect(out).toContain('Kimi Computer Use enabled'); + expect(out).not.toContain('unsupported'); panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'kimi-cu' }); }); - it('does not duplicate a daemon version prefix', () => { + it('does not expose capability readiness, version, or optional issues in the marketplace', () => { const capabilities = [ makeCapability({ id: 'kimi-webbridge', @@ -711,18 +721,23 @@ describe('plugins selector dialogs', () => { ], }), ]; - const { panel } = makePanel({ capabilities, initialTab: 'official' }); + const installed = [ + { ...superpowers, id: 'kimi-webbridge', displayName: 'Kimi WebBridge', version: '1.11.3' }, + ]; + const { panel } = makePanel({ installed, capabilities, initialTab: 'official' }); panel.setMarketplace( [{ id: 'kimi-webbridge', displayName: 'Kimi WebBridge', source: 'capability:kimi-webbridge', tier: 'official', builtIn: true }], '/tmp/marketplace.json', ); const out = strip(renderRaw(panel)); - expect(out).toContain('ready · v1.11.5'); - expect(out).not.toContain('vv1.11.5'); + expect(out).toContain('Kimi WebBridge installed'); + expect(out).not.toContain('ready'); + expect(out).not.toContain('v1.11.5'); + expect(out).not.toContain('browser extension'); }); - it('shows manual cleanup when a user skill shadows the managed plugin', () => { + it('keeps capability repair details out of marketplace rows', () => { const capabilities = [ makeCapability({ id: 'kimi-webbridge', @@ -743,8 +758,9 @@ describe('plugins selector dialogs', () => { ); const out = strip(renderRaw(panel)); - expect(out).toContain('needs agent skill'); - expect(out).toContain('user skill shadows managed plugin'); + expect(out).toContain('Kimi WebBridge install'); + expect(out).not.toContain('agent skill'); + expect(out).not.toContain('skill shadows'); }); it('does not show an update badge on the Installed tab before the marketplace loads', () => { diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 7028af9a997..5bc803ec3df 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -154,7 +154,7 @@ describe('loadPluginMarketplace', () => { }); // The util owns no product knowledge: entries come from the caller (the - // engine's capability registry), and no version is pinned. + // engine's capability registry), and no version is invented. expect(marketplace.plugins).toEqual(builtInEntries); expect(marketplace.plugins.map((entry) => entry.version)).toEqual([undefined, undefined]); }); @@ -170,6 +170,7 @@ describe('loadPluginMarketplace', () => { id: 'kimi-webbridge', tier: 'official', displayName: 'Kimi WebBridge', + version: '1.12.0', source: './kimi-webbridge', }, ], @@ -184,11 +185,11 @@ describe('loadPluginMarketplace', () => { }); // What the built-in ids mean stays decided by the client release: the - // catalog's own kimi-webbridge row is masked, only the injected one - // survives — a future official listing would only reach older clients. + // catalog's row contributes the version, but not its source or copy. const webbridge = marketplace.plugins.filter((entry) => entry.id === 'kimi-webbridge'); expect(webbridge).toHaveLength(1); expect(webbridge[0]?.source).toBe('capability:kimi-webbridge'); + expect(webbridge[0]?.version).toBe('1.12.0'); expect(marketplace.plugins.some((entry) => entry.id === 'kimi-cu')).toBe(true); }); diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index aa592c34376..231dc84fb27 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -33,7 +33,7 @@ You can also use slash commands directly: | `/plugins mcp enable ` | Enable an MCP server declared by a plugin | | `/plugins mcp disable ` | Disable an MCP server declared by a plugin | -The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. When a turn that used an outdated plugin (its MCP tool or a `/:` slash command) ends, a one-time notice also points you to `/plugins` for the update; each new marketplace version is announced once. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. On the v2 engine, the Official tab also lists the built-in product capabilities (Kimi Computer Use — macOS only — and Kimi WebBridge): these rows are injected by the client rather than served by the remote catalog, and each shows its setup state (`install` / `finish setup` / `ready`, with live progress while installing). Pressing Enter runs the full runtime setup — binary runtime and wiring plugin together; reinstalling later installs the current version. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). Installing a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source. +The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. When a turn that used an outdated plugin (its MCP tool or a `/:` slash command) ends, a one-time notice also points you to `/plugins` for the update; each new marketplace version is announced once. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. On the v2 engine, the Official tab also lists the built-in product capabilities (Kimi Computer Use — macOS only — and Kimi WebBridge). Their identity and install action come from the client, while the marketplace may supply a version for the normal `install` / `installed` / `update` status. Detailed runtime checks and install progress go to the log instead of changing the installed state. Pressing Enter for an install or update refreshes the binary runtime and wiring plugin together. When Kimi WebBridge is installed or updated, legacy standalone copies of its Skill are moved to `$KIMI_CODE_HOME/backups/kimi-webbridge-skills/` before the managed plugin takes over; the old files are backed up, not deleted. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). Installing a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source. ### Installing from GitHub diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 60d7c13dfe6..778ddc45698 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -33,7 +33,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以 | `/plugins mcp enable ` | 启用 plugin 声明的 MCP server | | `/plugins mcp disable ` | 禁用 plugin 声明的 MCP server | -**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。当一个使用了过时 plugin(其 MCP 工具或 `/:` 斜杠命令)的 turn 结束后,也会出现一次性提示,引导你到 `/plugins` 更新;每个新的 marketplace 版本只提醒一次。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。在 v2 引擎下,**Official** tab 还会列出内置产品能力(Kimi Computer Use——仅限 macOS——和 Kimi WebBridge):这些条目由客户端注入(不来自远端目录),每行显示部署状态(`install` / `finish setup` / `ready`,安装中显示实时进度)。回车执行完整的运行时部署(二进制运行时与接线插件一起装好);之后再装一遍即为升级。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。 +**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。当一个使用了过时 plugin(其 MCP 工具或 `/:` 斜杠命令)的 turn 结束后,也会出现一次性提示,引导你到 `/plugins` 更新;每个新的 marketplace 版本只提醒一次。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。在 v2 引擎下,**Official** tab 还会列出内置产品能力(Kimi Computer Use——仅限 macOS——和 Kimi WebBridge)。条目的身份和安装操作由客户端提供,marketplace 可以提供版本号,用于普通的 `install` / `installed` / `update` 状态;详细的运行时检查和安装进度写入日志,不再改变已安装状态。对可安装或可更新的条目按回车,会同时刷新二进制运行时和接线 plugin。安装或更新 Kimi WebBridge 时,旧的 standalone Skill 会先移动到 `$KIMI_CODE_HOME/backups/kimi-webbridge-skills/`,再由托管 plugin 接管;旧文件只备份,不删除。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。 ### 从 GitHub 安装 diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts index 7ee6133aefa..ce5a3b4ba5a 100644 --- a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts +++ b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts @@ -11,8 +11,9 @@ * request permissions) with structured progress and errors instead of a * shell pipe. Elevation when /Applications is not writable goes through * `osascript ... with administrator privileges` (native auth dialog). - * Installs are detect-first and idempotent: only unsatisfied layers are - * redone, setup re-enables a previously disabled wiring plugin (and its + * Installs are detect-first and idempotent: setup always refreshes the wiring + * plugin, only unsatisfied runtime layers are redone, and setup re-enables a + * previously disabled wiring plugin (and its * MCP servers), the app step requires an executable binary with bundle * metadata, the archive is staged and unpacked before the old service is * stopped, and cleanup of old processes is best-effort — a wedged old @@ -21,7 +22,7 @@ */ import { constants } from 'node:fs'; -import { mkdtemp, readFile, rm, access } from 'node:fs/promises'; +import { access, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -48,6 +49,12 @@ interface PermissionStatus { readonly screenRecording: boolean; } +interface LegacyMcpFile { + readonly raw: string; + readonly value: Record; + readonly servers: Record; +} + export function parsePermissionStatus(output: string): PermissionStatus | undefined { const match = /(?:permissions|permissionStatus):\s*accessibility=(true|false)\s+screenRecording=(true|false)/.exec( @@ -75,6 +82,36 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function objectRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function parseLegacyMcpFile(raw: string, appBin: string): LegacyMcpFile | undefined { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return undefined; + } + const root = objectRecord(value); + const servers = objectRecord(root?.['mcpServers']); + const legacy = objectRecord(servers?.['kimi-cu']); + if (root === undefined || servers === undefined || legacy === undefined) return undefined; + if (legacy['command'] !== appBin) return undefined; + if (legacy['enabled'] === false) return undefined; + const args = legacy['args']; + if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) return undefined; + const isKnownArgs = + (args.length === 1 && args[0] === 'mcp') || + (args.length === 3 && args[0] === 'mcp' && args[1] === '-s' && args[2] === 'user'); + if (!isKnownArgs) return undefined; + const knownKeys = new Set(['args', 'command']); + if (Object.keys(legacy).some((key) => !knownKeys.has(key))) return undefined; + return { raw, value: root, servers }; +} + function shQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } @@ -91,6 +128,7 @@ export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry const probeTimeoutMs = ctx.detectProbeTimeoutMs ?? DETECT_PROBE_TIMEOUT_MS; const commandTimeoutMs = ctx.commandTimeoutMs ?? COMMAND_TIMEOUT_MS; const supported = ctx.platform === 'darwin'; + const userMcpConfigPath = path.join(ctx.kimiHomeDir, 'mcp.json'); async function exists(p: string): Promise { return access(p).then( @@ -122,6 +160,38 @@ export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry return parsePermissionStatus(result.stdout); } + async function legacyMcpFile(): Promise { + try { + return parseLegacyMcpFile(await readFile(userMcpConfigPath, 'utf8'), appBin); + } catch { + return undefined; + } + } + + async function removeLegacyMcpRegistration( + legacy: LegacyMcpFile | undefined, + ): Promise { + if (legacy === undefined) return false; + + const nextServers = { ...legacy.servers }; + delete nextServers['kimi-cu']; + const next = { ...legacy.value, mcpServers: nextServers }; + const mode = (await stat(userMcpConfigPath)).mode & 0o777; + const tempPath = `${userMcpConfigPath}.kimi-cu-migration-${process.pid}-${Date.now()}`; + try { + await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode, + }); + if ((await readFile(userMcpConfigPath, 'utf8')) !== legacy.raw) return false; + await rename(tempPath, userMcpConfigPath); + } finally { + await rm(tempPath, { force: true }).catch(() => undefined); + } + return true; + } + async function detect(): Promise { const steps: CapabilityStep[] = []; @@ -142,6 +212,15 @@ export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry detail: mcpGap ?? plugin?.version, }); + if ((await legacyMcpFile()) !== undefined) { + steps.push({ + id: 'legacy-mcp', + state: 'missing', + detail: 'duplicate standalone kimi-cu MCP registration', + optional: true, + }); + } + const version = await readAppBundleVersion(infoPlist); const appExists = await exists(appBin); const appUsable = appExists && (await executable(appBin)) && (await exists(infoPlist)); @@ -203,7 +282,10 @@ export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry await bestEffort(appBin, ['uninstall']); } await bestEffort('launchctl', ['bootout', `gui/${uid}/${LAUNCHD_LABEL}`]); - for (const mode of ['mcp', 'service', 'overlay']) { + // Keep connected MCP frontends alive while the app bundle is replaced. + // Their work is delegated to the service below; killing them makes the + // client report an installation-driven restart as an unexpected failure. + for (const mode of ['service', 'overlay']) { await bestEffort('pkill', ['-f', `${APP_BUNDLE}/Contents/MacOS/kimi-cu[[:space:]]+${mode}`]); } await new Promise((resolve) => { @@ -238,31 +320,37 @@ export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry } const before = await detect(); + const legacyMcpBefore = await legacyMcpFile(); const stepStates = new Map(before.steps.map((step) => [step.id, step.state])); const readyBefore = before.steps .filter((step) => step.optional !== true) .every((step) => step.state === 'ok'); - if (stepStates.get('plugin') !== 'ok' || readyBefore) { - report('plugin'); - const summary = await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL }); - if (!summary.enabled) { - await ctx.plugins.setPluginEnabled({ id: PLUGIN_ID, enabled: true }); - } - if (summary.enabledMcpServerCount < summary.mcpServerCount) { - const info = await ctx.plugins.getPluginInfo({ id: PLUGIN_ID }); - for (const server of info.mcpServers) { - if (!server.enabled) { - await ctx.plugins.setPluginMcpServerEnabled({ - id: PLUGIN_ID, - server: server.name, - enabled: true, - }); - } + report('plugin'); + const summary = await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL }); + if (!summary.enabled) { + await ctx.plugins.setPluginEnabled({ id: PLUGIN_ID, enabled: true }); + } + if (summary.enabledMcpServerCount < summary.mcpServerCount) { + const info = await ctx.plugins.getPluginInfo({ id: PLUGIN_ID }); + for (const server of info.mcpServers) { + if (!server.enabled) { + await ctx.plugins.setPluginMcpServerEnabled({ + id: PLUGIN_ID, + server: server.name, + enabled: true, + }); } } } + // A read-only or concurrently edited user config must not block the app + // installation. Detection keeps the duplicate as an optional warning so + // clients can record it in logs and a later install can retry migration. + if (await removeLegacyMcpRegistration(legacyMcpBefore).catch(() => false)) { + report('mcp-config'); + } + const installApp = stepStates.get('app') !== 'ok' || readyBefore; if (installApp) { const workDir = await mkdtemp(path.join(tmpdir(), 'kimi-cu-install-')); diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts b/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts index 7d28d777722..1edf8d1e929 100644 --- a/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts +++ b/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts @@ -12,12 +12,13 @@ * are detect-first and idempotent: only unsatisfied layers are redone, * setup re-enables a previously disabled wiring plugin, the binary step * requires the executable bit on POSIX (an interrupted install reads as - * missing and re-downloads), and user-source skill shadows are reported - * as an optional step for manual cleanup instead of being deleted. + * missing and re-downloads). Legacy standalone skill copies are moved into + * a Kimi Code backup after the managed plugin has been refreshed, so plugin + * updates become authoritative without deleting user files. */ import { constants } from 'node:fs'; -import { access, chmod, mkdir, rename, rm } from 'node:fs/promises'; +import { access, chmod, mkdir, mkdtemp, rename, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -67,10 +68,23 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit const binName = ctx.platform === 'win32' ? 'kimi-webbridge.exe' : 'kimi-webbridge'; const binPath = path.join(binDir, binName); const userSourceSkillDirs = [ - path.join(ctx.kimiHomeDir, 'skills', 'kimi-webbridge'), - path.join(ctx.userHomeDir, '.agents', 'skills', 'kimi-webbridge'), + { + label: 'kimi-code', + path: path.join(ctx.kimiHomeDir, 'skills', 'kimi-webbridge'), + }, + { + label: 'agents', + path: path.join(ctx.userHomeDir, '.agents', 'skills', 'kimi-webbridge'), + }, ]; + const standaloneSkillBackupDir = path.join( + ctx.kimiHomeDir, + 'backups', + 'kimi-webbridge-skills', + ); const supported = binaryAssetName(ctx.platform, ctx.arch) !== undefined; + let standaloneSkillBackupPath: string | undefined; + let standaloneSkillMigrationError: string | undefined; async function exists(p: string): Promise { return access(p).then( @@ -99,6 +113,24 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit } } + async function standaloneSkillDirs(): Promise { + const checked = await Promise.all( + userSourceSkillDirs.map(async (entry) => ({ ...entry, present: await exists(entry.path) })), + ); + return checked.filter((entry) => entry.present); + } + + async function migrateStandaloneSkills(): Promise { + const skills = await standaloneSkillDirs(); + if (skills.length === 0) return undefined; + await mkdir(standaloneSkillBackupDir, { recursive: true }); + const backupRoot = await mkdtemp(path.join(standaloneSkillBackupDir, 'migration-')); + for (const skill of skills) { + await rename(skill.path, path.join(backupRoot, skill.label)); + } + return backupRoot; + } + async function detect(): Promise { const steps: CapabilityStep[] = []; @@ -136,16 +168,20 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit detail: mcpGap ?? plugin?.version, }); - const skillShadows = ( - await Promise.all( - userSourceSkillDirs.map(async (dir) => ({ dir, present: await exists(dir) })), - ) - ).filter((item) => item.present); - if (skillShadows.length > 0) { + const standaloneSkills = await standaloneSkillDirs(); + if (standaloneSkills.length > 0) { steps.push({ - id: 'skill-shadow', - state: 'failed', - detail: skillShadows.map((item) => item.dir).join(', '), + id: 'standalone-skill-migration', + state: 'missing', + detail: + standaloneSkillMigrationError ?? standaloneSkills.map((item) => item.path).join(', '), + optional: true, + }); + } else if (await exists(standaloneSkillBackupDir)) { + steps.push({ + id: 'standalone-skill-migration', + state: 'ok', + detail: standaloneSkillBackupPath ?? standaloneSkillBackupDir, optional: true, }); } @@ -181,6 +217,8 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit const readyBefore = before.steps .filter((step) => step.optional !== true) .every((step) => step.state === 'ok'); + const standaloneSkillMigrationPending = + stepStates.get('standalone-skill-migration') === 'missing'; if (stepStates.get('daemon-binary') !== 'ok' || readyBefore) { await installBinary(report, asset); } @@ -197,11 +235,20 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit await waitForDaemon(); } - if (stepStates.get('skill') !== 'ok' || readyBefore) { - report('skill'); - const summary = await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL }); - if (!summary.enabled) { - await ctx.plugins.setPluginEnabled({ id: PLUGIN_ID, enabled: true }); + report('skill'); + const summary = await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL }); + if (!summary.enabled) { + await ctx.plugins.setPluginEnabled({ id: PLUGIN_ID, enabled: true }); + } + + if (standaloneSkillMigrationPending) { + report('standalone-skill-migration'); + try { + standaloneSkillBackupPath = await migrateStandaloneSkills(); + standaloneSkillMigrationError = undefined; + } catch (error) { + standaloneSkillMigrationError = + `Could not back up the standalone kimi-webbridge skill: ${error instanceof Error ? error.message : String(error)}`; } } } diff --git a/packages/agent-core-v2/test/app/capability/kimiCu.test.ts b/packages/agent-core-v2/test/app/capability/kimiCu.test.ts index 058a51eb0fb..d4d7b5c7047 100644 --- a/packages/agent-core-v2/test/app/capability/kimiCu.test.ts +++ b/packages/agent-core-v2/test/app/capability/kimiCu.test.ts @@ -5,7 +5,7 @@ * host processes, fake plugins). */ -import { mkdir, mkdtemp, rm, writeFile, chmod } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { Readable, Writable } from 'node:stream'; @@ -76,6 +76,7 @@ function fakeHostProcess( function fakePlugins( installed: Array<{ id: string; enabled: boolean; state: string; version?: string; enabledMcp?: number }>, + onInstall?: () => void | Promise, ): { service: IPluginService; installs: string[]; @@ -116,21 +117,22 @@ function fakePlugins( ], } as never); }, - installPlugin: (input: { source: string }) => { + installPlugin: async (input: { source: string }) => { installs.push(input.source); + await onInstall?.(); // Upsert semantics of the real manager: a new id installs enabled, an // existing record keeps its (possibly disabled) enabled flag. const existing = installed.find((p) => p.id === 'kimi-cu'); if (existing === undefined) { installed.push({ id: 'kimi-cu', enabled: true, state: 'ok' }); - return Promise.resolve({ enabled: true, mcpServerCount: 1, enabledMcpServerCount: 1 } as never); + return { enabled: true, mcpServerCount: 1, enabledMcpServerCount: 1 } as never; } existing.state = 'ok'; - return Promise.resolve({ + return { enabled: existing.enabled, mcpServerCount: 1, enabledMcpServerCount: existing.enabledMcp ?? 1, - } as never); + } as never; }, setPluginEnabled: (input: { id: string; enabled: boolean }) => { enabledCalls.push(input); @@ -314,6 +316,119 @@ describe('kimi-cu entry', () => { expect(host.calls.every((call) => call.includes('service-status') || call.includes('xpc-ping'))).toBe(true); }); + it('migrates the exact legacy standalone MCP registration after installing the plugin', async () => { + const applicationsDir = await fakeAppBundle(); + const appBin = path.join(applicationsDir, 'KimiCU.app', 'Contents', 'MacOS', 'kimi-cu'); + const kimiHomeDir = path.join(root, 'kimi-home'); + await mkdir(kimiHomeDir, { recursive: true }); + await writeFile( + path.join(kimiHomeDir, 'mcp.json'), + `${JSON.stringify({ + mcpServers: { + 'kimi-cu': { command: appBin, args: ['mcp', '-s', 'user'] }, + custom: { command: 'custom-mcp', args: [] }, + }, + })}\n`, + ); + const plugins = fakePlugins([]); + const host = fakeHostProcess([ + { match: 'service-status', code: 0, stdout: 'SMAppService status=1' }, + { match: 'xpc-ping', code: 0, stdout: 'permissionStatus: accessibility=true screenRecording=true' }, + ]); + const entry = createKimiCuEntry( + makeCtx({ + applicationsDir, + kimiHomeDir, + plugins: plugins.service, + hostProcess: host.service, + }), + ); + + expect((await entry.detect()).steps).toContainEqual({ + id: 'legacy-mcp', + state: 'missing', + detail: 'duplicate standalone kimi-cu MCP registration', + optional: true, + }); + const reports: string[] = []; + await entry.install((step) => reports.push(step)); + + const migrated = JSON.parse(await readFile(path.join(kimiHomeDir, 'mcp.json'), 'utf8')) as { + mcpServers: Record; + }; + expect(migrated.mcpServers['kimi-cu']).toBeUndefined(); + expect(migrated.mcpServers['custom']).toEqual({ command: 'custom-mcp', args: [] }); + expect(reports).toEqual(['plugin', 'mcp-config']); + }); + + it('leaves the legacy MCP config untouched when it changes during setup', async () => { + const applicationsDir = await fakeAppBundle(); + const appBin = path.join(applicationsDir, 'KimiCU.app', 'Contents', 'MacOS', 'kimi-cu'); + const kimiHomeDir = path.join(root, 'kimi-home'); + await mkdir(kimiHomeDir, { recursive: true }); + const configPath = path.join(kimiHomeDir, 'mcp.json'); + const legacy = { command: appBin, args: ['mcp', '-s', 'user'] }; + await writeFile(configPath, `${JSON.stringify({ mcpServers: { 'kimi-cu': legacy } })}\n`); + const concurrentConfig = { + mcpServers: { + 'kimi-cu': legacy, + addedDuringSetup: { command: 'another-mcp', args: [] }, + }, + }; + const plugins = fakePlugins([], async () => { + await writeFile(configPath, `${JSON.stringify(concurrentConfig, null, 2)}\n`); + }); + const host = fakeHostProcess([ + { match: 'service-status', code: 0, stdout: 'SMAppService status=1' }, + { match: 'xpc-ping', code: 0, stdout: 'permissionStatus: accessibility=true screenRecording=true' }, + ]); + const entry = createKimiCuEntry( + makeCtx({ + applicationsDir, + kimiHomeDir, + plugins: plugins.service, + hostProcess: host.service, + }), + ); + const reports: string[] = []; + + await entry.install((step) => reports.push(step)); + + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual(concurrentConfig); + expect(reports).toEqual(['plugin']); + }); + + it('does not migrate a customized standalone MCP registration', async () => { + const applicationsDir = await fakeAppBundle(); + const appBin = path.join(applicationsDir, 'KimiCU.app', 'Contents', 'MacOS', 'kimi-cu'); + const kimiHomeDir = path.join(root, 'kimi-home'); + await mkdir(kimiHomeDir, { recursive: true }); + const configPath = path.join(kimiHomeDir, 'mcp.json'); + const custom = { + mcpServers: { + 'kimi-cu': { command: appBin, args: ['mcp', '-s', 'user'], env: { CUSTOM: '1' } }, + }, + }; + await writeFile(configPath, `${JSON.stringify(custom)}\n`); + const plugins = fakePlugins([]); + const host = fakeHostProcess([ + { match: 'service-status', code: 0, stdout: 'SMAppService status=1' }, + { match: 'xpc-ping', code: 0, stdout: 'permissionStatus: accessibility=true screenRecording=true' }, + ]); + const entry = createKimiCuEntry( + makeCtx({ + applicationsDir, + kimiHomeDir, + plugins: plugins.service, + hostProcess: host.service, + }), + ); + + await entry.install(() => {}); + + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual(custom); + }); + it('marks probe steps failed instead of throwing when the binary is wedged', async () => { const applicationsDir = await fakeAppBundle(); const plugins = fakePlugins([]); @@ -366,6 +481,30 @@ describe('kimi-cu entry', () => { expect(plugins.enabledCalls).toEqual([{ id: 'kimi-cu', enabled: true }]); }); + it('refreshes the wiring plugin when permissions are the only missing layer', async () => { + const applicationsDir = await fakeAppBundle(); + const plugins = fakePlugins([ + { id: 'kimi-cu', enabled: true, state: 'ok', version: '0.5.4' }, + ]); + const host = fakeHostProcess([ + { match: 'service-status', code: 0, stdout: 'SMAppService status=1' }, + { + match: 'xpc-ping', + code: 0, + stdout: 'permissionStatus: accessibility=true screenRecording=false', + }, + ]); + const entry = createKimiCuEntry( + makeCtx({ applicationsDir, plugins: plugins.service, hostProcess: host.service }), + ); + + await entry.install(() => {}); + + expect(plugins.installs).toEqual([ + 'https://cdn.kimi.com/kimi-computer-use/latest/kimi-cu-plugin.zip', + ]); + }); + it('continues the replacement when the old-binary cleanup hangs', async () => { const applicationsDir = await fakeAppBundle(); const plugins = fakePlugins([{ id: 'kimi-cu', enabled: true, state: 'ok', version: '0.5.4' }]); @@ -412,6 +551,8 @@ describe('kimi-cu entry', () => { // Fully ready → explicit reinstall exercises the cleanup path. await entry.install(() => {}); expect(host.calls.some((call) => call.includes('ditto'))).toBe(true); + expect(host.calls.some((call) => call.includes('pkill') && call.includes('+mcp'))).toBe(false); + expect(host.calls.some((call) => call.includes('pkill') && call.includes('+service'))).toBe(true); }); it('reports the plugin layer missing when its MCP server is disabled', async () => { diff --git a/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts b/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts index 0e9a44ddf27..6ce01a74f48 100644 --- a/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts +++ b/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts @@ -217,7 +217,7 @@ describe('kimi-webbridge entry', () => { ]); }); - it('reports user skill shadows for manual cleanup without deleting them', async () => { + it('backs up standalone skills after refreshing the managed plugin', async () => { const kimiHome = path.join(root, 'kimi-home'); const userHome = path.join(root, 'user-home'); await mkdir(path.join(kimiHome, 'skills', 'kimi-webbridge'), { recursive: true }); @@ -232,14 +232,31 @@ describe('kimi-webbridge entry', () => { const detected = await entry.detect(); - expect(detected.steps.find((step) => step.id === 'skill-shadow')).toEqual({ - id: 'skill-shadow', - state: 'failed', + expect(detected.steps.find((step) => step.id === 'standalone-skill-migration')).toEqual({ + id: 'standalone-skill-migration', + state: 'missing', detail: `${path.join(kimiHome, 'skills', 'kimi-webbridge')}, ${path.join(userHome, '.agents', 'skills', 'kimi-webbridge')}`, optional: true, }); - await access(path.join(kimiHome, 'skills', 'kimi-webbridge', 'SKILL.md')); - await access(path.join(userHome, '.agents', 'skills', 'kimi-webbridge', 'SKILL.md')); + const reports: string[] = []; + await entry.install((step) => reports.push(step)); + + expect(plugins.installs).toEqual([ + 'https://code.kimi.com/kimi-code/plugins/official/kimi-webbridge.zip', + ]); + expect(reports).toContain('standalone-skill-migration'); + await expect(access(path.join(kimiHome, 'skills', 'kimi-webbridge'))).rejects.toThrow(); + await expect(access(path.join(userHome, '.agents', 'skills', 'kimi-webbridge'))).rejects.toThrow(); + + const backupDir = path.join(kimiHome, 'backups', 'kimi-webbridge-skills'); + const backups = await readdir(backupDir); + expect(backups).toHaveLength(1); + await expect( + readFile(path.join(backupDir, backups[0]!, 'kimi-code', 'SKILL.md'), 'utf8'), + ).resolves.toBe('old'); + await expect( + readFile(path.join(backupDir, backups[0]!, 'agents', 'SKILL.md'), 'utf8'), + ).resolves.toBe('old'); }); it('installs end-to-end: download, start-if-down, and plugin wiring', async () => { @@ -340,6 +357,37 @@ describe('kimi-webbridge entry', () => { expect(plugins.installs).toHaveLength(1); }); + it('refreshes the wiring plugin when daemon recovery is the only missing layer', async () => { + const userHome = path.join(root, 'user-home'); + await mkdir(path.join(userHome, '.kimi-webbridge', 'bin'), { recursive: true }); + const binPath = path.join(userHome, '.kimi-webbridge', 'bin', 'kimi-webbridge'); + await writeFile(binPath, 'bin'); + await chmod(binPath, 0o755); + const plugins = fakePlugins([ + { id: 'kimi-webbridge', enabled: true, state: 'ok', version: '1.11.3' }, + ]); + const host = fakeHostProcess(); + const { fetchImpl } = fakeFetch({ + statusSequence: [ + { running: false }, + { running: false }, + { running: true, version: 'v1.11.3', extension_connected: true }, + ], + }); + const entry = createKimiWebbridgeEntry( + makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }), + ); + + await entry.install(() => {}); + + expect(plugins.installs).toEqual([ + 'https://code.kimi.com/kimi-code/plugins/official/kimi-webbridge.zip', + ]); + expect(host.calls.map((call) => `${call.command} ${call.args.join(' ')}`)).toEqual([ + `${binPath} start`, + ]); + }); + it('rejects install on unsupported platforms before any side effect', async () => { const plugins = fakePlugins([]); const entry = createKimiWebbridgeEntry( @@ -388,4 +436,3 @@ describe('kimi-webbridge entry', () => { expect(plugins.enabledCalls).toEqual([{ id: 'kimi-webbridge', enabled: true }]); }); }); - diff --git a/plugins/marketplace.json b/plugins/marketplace.json index ce41a8c8317..a532ab089ac 100644 --- a/plugins/marketplace.json +++ b/plugins/marketplace.json @@ -10,6 +10,15 @@ "keywords": ["data", "mcp"], "source": "./official/kimi-datasource" }, + { + "id": "kimi-webbridge", + "tier": "official", + "displayName": "Kimi WebBridge", + "version": "1.11.3", + "description": "Control your real browser from Kimi Code.", + "keywords": ["browser", "automation", "webbridge"], + "source": "./official/kimi-webbridge" + }, { "id": "superpowers", "tier": "curated",