Repository navigation
feat(cli): add built-in Computer Use and WebBridge capabilities #2407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 43 commits
db8b9da
27e9ee1
e9cd2e7
9fcaf3f
424485b
859de74
38c8880
431c1f1
1781895
262bdaa
58b19b1
2d0b304
49cd88e
4bdb2f6
0635e99
d78c733
8240dd3
4f5e1c4
eb447bf
77eb882
563877e
3b2d5ca
7e30843
fbeab15
dbc226b
c47ddc0
f27c495
a92828d
6ce8066
02c2f1f
3513924
5ccf8d9
536bd8f
f63daaf
f536246
60e8449
dd15307
a74cad2
75df70b
0368c15
e8abc27
863c050
efee8af
fd8633a
289289b
dc5f941
3bcdcbb
ebed5c4
b2e9169
921d58d
c567d9b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": minor | ||
| --- | ||
|
|
||
| Add Kimi Computer Use and Kimi WebBridge as built-in official marketplace entries in the v2 CLI. Installing from `/plugins` sets up the latest managed runtime and plugin together, reports incomplete manual steps, and supports retrying interrupted setup. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,15 @@ | ||
| import { homedir as osHomedir } from 'node:os'; | ||
| import { isAbsolute, join, resolve } from 'node:path'; | ||
|
|
||
| import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; | ||
| import type { CapabilityStatus, PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; | ||
|
|
||
| import { | ||
| PluginInstallTrustConfirmComponent, | ||
| PluginMcpSelectorComponent, | ||
| PluginRemoveConfirmComponent, | ||
| PluginsPanelComponent, | ||
| describeCapabilityIssues, | ||
| formatCapabilityVersion, | ||
| type PluginInstallTrustConfirmResult, | ||
| type PluginMcpSelection, | ||
| type PluginRemoveConfirmResult, | ||
|
|
@@ -25,8 +27,8 @@ import { | |
| isOfficialPluginInstall, | ||
| isOfficialPluginSource, | ||
| } from '../utils/plugin-source-label'; | ||
| import { QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app'; | ||
| import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; | ||
| import { KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app'; | ||
| import { loadPluginMarketplace, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; | ||
| import { openUrl } from '#/utils/open-url'; | ||
| import type { SlashCommandHost } from './dispatch'; | ||
|
|
||
|
|
@@ -169,9 +171,22 @@ async function showPluginsPicker( | |
| return; | ||
| } | ||
|
|
||
| let capabilities: readonly CapabilityStatus[] = []; | ||
| if (host.engineV2) { | ||
| try { | ||
| capabilities = await host.requireSession().listCapabilities(); | ||
| } catch (error) { | ||
| host.showStatus( | ||
| `Capability status unavailable: ${formatErrorMessage(error)}. Plugin management remains available.`, | ||
| 'warning', | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| const panel = new PluginsPanelComponent({ | ||
| installed: plugins, | ||
| installedIds: new Set(plugins.map((plugin) => plugin.id)), | ||
| capabilities, | ||
| initialTab: options?.initialTab, | ||
| selectedId: options?.selectedId, | ||
| pluginHint: options?.pluginHint, | ||
|
|
@@ -191,7 +206,7 @@ async function showPluginsPicker( | |
| // keep working even when the marketplace is unreachable (badges simply stay | ||
| // hidden until data arrives). | ||
| onRequestMarketplace: () => { | ||
| void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); | ||
| void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities); | ||
| }, | ||
| }); | ||
| host.mountEditorReplacement(panel); | ||
|
|
@@ -202,19 +217,46 @@ async function showPluginsPicker( | |
| // over `panel`. | ||
| if (options?.initialTab !== 'custom') { | ||
| panel.setMarketplaceLoading(); | ||
| void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); | ||
| void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Adapt a capability from the engine's registry into a catalog row. The | ||
| * engine is the single source of truth for what the built-in capabilities | ||
| * are — the CLI only renders them. The `capability:<id>` source marker | ||
| * routes installs through the capability flow (never a plain plugin | ||
| * install), so the row needs no real URL. | ||
| */ | ||
| function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketplaceEntry { | ||
| return { | ||
| id: capability.id, | ||
| displayName: capability.displayName, | ||
| description: capability.description, | ||
| tier: 'official', | ||
| source: `capability:${capability.id}`, | ||
| }; | ||
| } | ||
|
|
||
| async function loadMarketplaceCatalog( | ||
| host: SlashCommandHost, | ||
| panel: PluginsPanelComponent, | ||
| source?: string, | ||
| source: string | undefined, | ||
| capabilities: readonly CapabilityStatus[], | ||
| ): Promise<void> { | ||
| 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 | ||
| ? capabilities.map(capabilityMarketplaceEntry) | ||
| : undefined, | ||
| }); | ||
| panel.setMarketplace(marketplace.plugins, marketplace.source); | ||
| } catch (error) { | ||
|
|
@@ -299,6 +341,134 @@ async function confirmInstallTrust( | |
| }); | ||
| } | ||
|
|
||
| const CAPABILITY_POLL_INTERVAL_MS = 700; | ||
| const CAPABILITY_POLL_ATTEMPTS = 260; // ~3 minutes of runtime setup budget | ||
|
|
||
| /** Client-injected v2 entries install their runtime and plugin together. */ | ||
| function isCapabilityEntry(host: SlashCommandHost, entry: PluginMarketplaceEntry): boolean { | ||
| return host.engineV2 && entry.source === `capability:${entry.id}`; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an explicit remote marketplace contains a same-id row with Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| /** | ||
| * Closed-set id check for the post-remove note. The capability ids are part | ||
| * of the client/engine CONTRACT (mirrored in the klient zod enum), not | ||
| * product data that drifts — so they may be named here. What must not | ||
| * happen is the alternative: answering set membership by running | ||
| * `listCapabilities()`, which fires every entry's detector (seconds of | ||
| * probes) just to decide whether to print one hint line. | ||
| */ | ||
| 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). */ | ||
| async function pollCapabilityInstall( | ||
| host: SlashCommandHost, | ||
| panel: PluginsPanelComponent, | ||
| id: string, | ||
| label: string, | ||
| ): Promise<CapabilityStatus | undefined> { | ||
| const session = host.requireSession(); | ||
| 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); | ||
| 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(); | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| export const __pluginsCommandInternals = { | ||
| isCapabilityEntry, | ||
| installCapabilityFromPanel, | ||
| pollCapabilityInstall, | ||
| removePlugin, | ||
| }; | ||
|
|
||
| async function installCapabilityFromPanel( | ||
| host: SlashCommandHost, | ||
| panel: PluginsPanelComponent, | ||
| entry: PluginMarketplaceEntry, | ||
| ): Promise<void> { | ||
| const label = entry.displayName; | ||
| // Capability entries are official by construction; the trust prompt is | ||
| // reserved for unreviewed third-party plugins. | ||
| panel.setInstalling(truncateForStatus(label)); | ||
| host.state.ui.requestRender(); | ||
| const session = host.requireSession(); | ||
| 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 | ||
| .getCapability(entry.id) | ||
| .then((status) => status.install.running, () => false); | ||
| if (!alreadyRunning) { | ||
| await session.installCapability(entry.id); | ||
| } | ||
| } catch (error) { | ||
| panel.clearInstalling(); | ||
| host.state.ui.requestRender(); | ||
| host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); | ||
| host.restoreEditor(); | ||
| return; | ||
| } | ||
| let result: CapabilityStatus | undefined; | ||
| try { | ||
| result = await pollCapabilityInstall(host, panel, entry.id, label); | ||
| } catch { | ||
| result = undefined; | ||
| } | ||
| panel.clearInstalling(); | ||
| // Close the panel so the result lines land in the transcript, matching the | ||
| // plain plugin install flow. | ||
| host.restoreEditor(); | ||
| if (result === undefined) { | ||
| host.showStatus(`${label} setup is still running in the background; /plugins shows its state.`); | ||
| return; | ||
| } | ||
| if (result.install.error !== undefined) { | ||
| host.showError(`${label} setup failed: ${result.install.error}. Install again from /plugins to retry.`); | ||
| 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')) { | ||
| host.showStatus( | ||
| 'Grant Accessibility and Screen Recording in System Settings → Privacy & Security, then reopen /plugins to recheck.', | ||
| 'warning', | ||
| ); | ||
| } | ||
| 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(PLUGIN_RELOAD_HINT, 'warning'); | ||
| } | ||
|
|
||
| async function installFromPanel( | ||
| host: SlashCommandHost, | ||
| panel: PluginsPanelComponent, | ||
|
|
@@ -400,6 +570,10 @@ async function handlePluginsPanelSelection( | |
| await showPluginsPicker(host, { initialTab: 'installed' }); | ||
| return; | ||
| case 'install': | ||
| if (isCapabilityEntry(host, selection.entry)) { | ||
| await installCapabilityFromPanel(host, panel, selection.entry); | ||
| return; | ||
| } | ||
| await installFromPanel( | ||
| host, | ||
| panel, | ||
|
|
@@ -454,6 +628,11 @@ async function handlePluginMcpSelection( | |
| async function removePlugin(host: SlashCommandHost, id: string): Promise<void> { | ||
| await host.requireSession().removePlugin(id); | ||
| host.showStatus(`Removed ${id}.`); | ||
| if (isCapabilityId(host, id)) { | ||
| host.showStatus( | ||
| 'Note: the runtime binaries were left untouched, but Kimi Code plugin wiring is disabled for new sessions. Reinstall any time from the Official tab.', | ||
| ); | ||
| } | ||
| host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
KIMI_CODE_PLUGIN_MARKETPLACE_URLis set,/pluginsstill reaches this branch withsource === undefined, so the built-in capability rows are passed intoloadPluginMarketplaceeven though the user explicitly replaced the marketplace. That means a custom/env catalog that defineskimi-cuorkimi-webbridgeis masked bywithBuiltInEntries, and if the custom catalog is temporarily unreachable the loader returns a built-in-only Official tab instead of surfacing the catalog failure; only inject these rows when both the slash-command source and the env override are absent.Useful? React with 👍 / 👎.