diff --git a/.changeset/confirm-third-party-plugin-install.md b/.changeset/confirm-third-party-plugin-install.md new file mode 100644 index 000000000..38e979281 --- /dev/null +++ b/.changeset/confirm-third-party-plugin-install.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a confirmation prompt before installing third-party plugins. diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index b220b4817..ff90e4914 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -4,9 +4,11 @@ import { isAbsolute, join, resolve } from 'node:path'; import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, PluginRemoveConfirmComponent, PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, type PluginRemoveConfirmResult, type PluginsPanelSelection, @@ -18,7 +20,7 @@ import { } from '../components/messages/plugins-status-panel'; import { UsagePanelComponent } from '../components/messages/usage-panel'; import { formatErrorMessage } from '../utils/event-payload'; -import { formatPluginSourceLabel } from '../utils/plugin-source-label'; +import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label'; import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; import type { SlashCommandHost } from './dispatch'; @@ -63,6 +65,10 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showError('Usage: /plugins install '); return; } + if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) { + host.showStatus('Install cancelled.'); + return; + } const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`); try { await installPluginFromSource(host, source); @@ -266,6 +272,67 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise< }); } +async function confirmInstallTrust( + host: SlashCommandHost, + label: string, + official: boolean, +): Promise { + // Kimi-built official plugins are trusted implicitly; anything else requires + // the user to explicitly opt in via the trust prompt. + if (official) return true; + return new Promise((resolveConfirmed) => { + host.mountEditorReplacement( + new PluginInstallTrustConfirmComponent({ + label, + onDone: (result: PluginInstallTrustConfirmResult) => { + host.restoreEditor(); + resolveConfirmed(result.kind === 'confirm'); + }, + }), + ); + }); +} + +async function installFromPanel( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source: string, + label: string, + official: boolean, +): Promise { + if (!(await confirmInstallTrust(host, label, official))) { + host.showStatus(`Install cancelled: ${label}.`); + host.restoreEditor(); + return; + } + // Official installs keep the panel mounted and show the inline installing + // state; third-party installs pass through a trust prompt that replaces the + // panel, so fall back to a transcript status for those. + if (official) { + panel.setInstalling(truncateForStatus(label)); + } else { + host.showStatus(`Installing or updating ${label} from marketplace...`); + } + host.state.ui.requestRender(); + try { + await installPluginFromSource(host, source); + } catch (error) { + if (official) { + panel.clearInstalling(); + host.state.ui.requestRender(); + } else { + // The trust prompt replaced the panel; re-mount it so the user can retry + // instead of being dropped back at the editor. + host.mountEditorReplacement(panel); + } + host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); + return; + } + // Close the panel after installing so the result status and the + // "/reload or /new" tip are visible in the transcript. + host.restoreEditor(); +} + async function applyPluginEnabled( host: SlashCommandHost, id: string, @@ -326,36 +393,24 @@ async function handlePluginsPanelSelection( await reloadPlugins(host); await showPluginsPicker(host, { initialTab: 'installed' }); return; - case 'install': { - panel.setInstalling(selection.entry.displayName); - host.state.ui.requestRender(); - try { - await installPluginFromSource(host, selection.entry.source); - } catch (error) { - panel.clearInstalling(); - host.state.ui.requestRender(); - host.showError(`Failed to install ${selection.entry.displayName}: ${formatErrorMessage(error)}`); - return; - } - // Close the panel after installing so the success notice and the - // "/reload or /new" / post-install tip are visible in the transcript. - host.restoreEditor(); + case 'install': + await installFromPanel( + host, + panel, + selection.entry.source, + selection.entry.displayName, + isOfficialPluginSource(selection.entry.source), + ); return; - } - case 'install-source': { - panel.setInstalling(truncateForStatus(selection.source)); - host.state.ui.requestRender(); - try { - await installPluginFromSource(host, selection.source); - } catch (error) { - panel.clearInstalling(); - host.state.ui.requestRender(); - host.showError(`Failed to install from ${truncateForStatus(selection.source)}: ${formatErrorMessage(error)}`); - return; - } - host.restoreEditor(); + case 'install-source': + await installFromPanel( + host, + panel, + selection.source, + selection.source, + isOfficialPluginSource(selection.source), + ); return; - } } } diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index 24098cd59..fb5094af1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -37,6 +37,8 @@ export interface ChoicePickerOptions { readonly hint?: string; readonly formatHint?: (text: string) => string; readonly notice?: string; + /** Color tone for the notice line. Defaults to 'success'. */ + readonly noticeTone?: 'success' | 'warning'; readonly options: readonly ChoiceOption[]; readonly currentValue?: string; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ @@ -142,8 +144,12 @@ export class ChoicePickerComponent extends Container implements Focusable { ); } if (this.opts.notice !== undefined) { + const tone = this.opts.noticeTone ?? 'success'; + const noticeWidth = Math.max(1, width - 1); for (const noticeLine of this.opts.notice.split(/\r?\n/)) { - lines.push(currentTheme.fg('success', ` ${noticeLine}`)); + for (const wrapped of wrapDescription(noticeLine, noticeWidth)) { + lines.push(currentTheme.fg(tone, ` ${wrapped}`)); + } } } lines.push(''); 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 fbab65d1b..fcc09941d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -24,6 +24,8 @@ const MCP_SERVER_PREFIX = 'mcp:'; const REMOVE_CONFIRM_CANCEL = 'cancel'; const REMOVE_CONFIRM_REMOVE = 'remove'; +const INSTALL_TRUST_EXIT = 'exit'; +const INSTALL_TRUST_TRUST = 'trust'; const ELLIPSIS = '…'; interface PluginsOverviewItem { @@ -193,6 +195,55 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { } } +export type PluginInstallTrustConfirmResult = + | { readonly kind: 'confirm' } + | { readonly kind: 'cancel' }; + +export interface PluginInstallTrustConfirmOptions { + /** Plugin display name or source, shown in the title for identification. */ + readonly label: string; + readonly onDone: (result: PluginInstallTrustConfirmResult) => void; +} + +/** + * Confirmation shown before installing a third-party (unofficial) plugin. + * Defaults to "Exit" so the user must explicitly switch to "Trust and install" + * to proceed with a plugin that Kimi has not reviewed. + */ +export class PluginInstallTrustConfirmComponent extends ChoicePickerComponent { + constructor(opts: PluginInstallTrustConfirmOptions) { + super({ + title: `Install third-party plugin ${opts.label}?`, + hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel', + formatHint: mutedHintLine, + notice: + '⚠️ This is a third-party plugin that Kimi has not reviewed. It can bundle MCP servers, ' + + 'skills, or files that run code and access your workspace. Install it only if you ' + + 'trust the source.', + noticeTone: 'warning', + options: [ + { + value: INSTALL_TRUST_EXIT, + label: 'Exit', + description: 'Cancel the installation.', + }, + { + value: INSTALL_TRUST_TRUST, + label: 'Trust and install', + tone: 'danger', + description: 'Install this third-party plugin anyway.', + }, + ], + onSelect: (value) => { + opts.onDone(value === INSTALL_TRUST_TRUST ? { kind: 'confirm' } : { kind: 'cancel' }); + }, + onCancel: () => { + opts.onDone({ kind: 'cancel' }); + }, + }); + } +} + function overviewPluginDescription(plugin: PluginSummary): string { const state = plugin.state === 'ok' ? '' : ` · state ${plugin.state}`; const skills = `${plugin.skillCount} skill${plugin.skillCount === 1 ? '' : 's'}`; diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts index eaddeae65..d475313ae 100644 --- a/apps/kimi-code/src/tui/utils/plugin-source-label.ts +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -50,6 +50,26 @@ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel { } } +/** + * Returns true only for install sources that are unambiguously Kimi-built + * official plugins — an https URL under the official Kimi CDN plugin path. + * Everything else (local paths, GitHub repos, curated or third-party URLs) + * is treated as unofficial and should be confirmed before install. + */ +export function isOfficialPluginSource(source: string): boolean { + const trimmed = source.trim(); + if (!trimmed.startsWith('https://')) return false; + try { + const url = new URL(trimmed); + return ( + url.hostname === 'code.kimi.com' && + url.pathname.startsWith('/kimi-code/plugins/official/') + ); + } catch { + return false; + } +} + function hostFromUrl(raw: string): string | undefined { try { const url = new URL(raw); 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 694e8a227..83475e345 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 @@ -2,18 +2,20 @@ import { describe, expect, it, vi } from 'vitest'; import chalk from 'chalk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, PluginRemoveConfirmComponent, PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, type PluginRemoveConfirmResult, type PluginsPanelSelection, } from '#/tui/components/dialogs/plugins-selector'; import { currentTheme } from '#/tui/theme'; import { darkColors, lightColors } from '#/tui/theme/colors'; -import { pluginTrustLabel } from '#/tui/utils/plugin-source-label'; +import { isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; -const ANSI_SGR = /\u001b\[[0-9;]*m/g; +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?'); @@ -37,6 +39,12 @@ function dangerShortcut(text: string): string { return withAnsiColors(() => chalk.hex(darkColors.error).bold(text)); } +function warningMark(): string { + // Opening ANSI escape for the warning color; the install-trust notice is the + // only element in that dialog using it, so its presence confirms the tone. + return withAnsiColors(() => chalk.hex(darkColors.warning)('\u0001').split('\u0001')[0]!); +} + const superpowers = { id: 'superpowers', displayName: 'Superpowers', @@ -132,6 +140,20 @@ describe('plugins selector dialogs', () => { })).toBe('third-party'); }); + it('treats only the official Kimi CDN path as a trusted install source', () => { + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true); + // Curated and other Kimi CDN paths are not "official" for the install gate. + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false); + expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false); + // Non-Kimi hosts, non-https schemes, local paths, and GitHub sources are unofficial. + expect(isOfficialPluginSource('https://example.test/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('http://code.kimi.com/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('./plugins/kimi-datasource')).toBe(false); + expect(isOfficialPluginSource('/abs/path/to/plugin')).toBe(false); + expect(isOfficialPluginSource('github.com/owner/repo')).toBe(false); + expect(isOfficialPluginSource('not a url')).toBe(false); + }); + it('opens on the Installed tab with the four panel tabs', () => { const { panel } = makePanel({ installed: [superpowers] }); const out = strip(renderRaw(panel)); @@ -278,7 +300,7 @@ describe('plugins selector dialogs', () => { const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); // Catalog still loading (entries empty); pressing ↓ must not drive the // selection negative, or the later Enter would read entries[-1]. - panel.handleInput('\u001b[B'); // ↓ + panel.handleInput('\u001B[B'); // ↓ panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ @@ -461,7 +483,7 @@ describe('plugins selector dialogs', () => { }, }); - picker.handleInput('\u001b[B'); + picker.handleInput('\u001B[B'); const raw = renderRaw(picker); expect(strip(raw)).toContain('Enter/Space select'); // The destructive option label keeps its danger styling (error + bold). @@ -471,4 +493,49 @@ describe('plugins selector dialogs', () => { expect(results).toEqual([{ kind: 'confirm' }]); }); + + it('defaults the third-party install trust prompt to exit', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, + }); + + const raw = renderRaw(picker); + const out = raw.split('\n').map(strip); + expect(out).toContain(' Install third-party plugin Superpowers?'); + expect(out).toContain(' ? Exit'); + expect(out).toContain(' Cancel the installation.'); + expect(out).toContain(' Install this third-party plugin anyway.'); + // The warning explains why confirmation is required and uses the + // design-system warning color rather than muted/default text. + expect(out.some((line) => line.includes('Kimi has not reviewed'))).toBe(true); + expect(out.some((line) => line.includes('trust the source'))).toBe(true); + expect(raw).toContain(warningMark()); + + picker.handleInput('\r'); + expect(results).toEqual([{ kind: 'cancel' }]); + }); + + it('installs a third-party plugin only after switching to trust', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, + }); + + picker.handleInput('\u001B[B'); + const raw = renderRaw(picker); + expect(strip(raw)).toContain('Enter/Space select'); + // The opt-in option keeps its danger styling (error + bold). + expect(raw).toContain(dangerShortcut('Trust and install')); + + picker.handleInput('\r'); + + expect(results).toEqual([{ kind: 'confirm' }]); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 6c3dc484e..3cc7f5140 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -23,6 +23,7 @@ import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector' import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, PluginRemoveConfirmComponent, PluginsPanelComponent, @@ -3092,17 +3093,46 @@ command = "vim" expect(session.installPlugin).not.toHaveBeenCalled(); }); - it('installs from a positional source on /plugins install', async () => { + it('installs from a positional source on /plugins install after trusting it', async () => { const session = makeSession(); const { driver } = await makeDriver(session); driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + await vi.waitFor(() => { expect(session.installPlugin).toHaveBeenCalledWith('/tmp/proj-a/plugins/kimi-datasource'); }); }); + it('does not install when the third-party trust prompt is dismissed', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\r'); // default option is "Exit" + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + }); + expect(session.installPlugin).not.toHaveBeenCalled(); + }); + it('loads a local plugin marketplace file and installs from it', async () => { const marketplaceDir = await makeTempHome(); const marketplacePath = join(marketplaceDir, 'marketplace.json'); @@ -3115,7 +3145,7 @@ command = "vim" tier: 'official', displayName: 'Kimi Datasource', description: 'Datasource plugin', - source: './kimi-datasource', + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', }, ], }), @@ -3138,7 +3168,9 @@ command = "vim" panel.handleInput('\r'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'kimi-datasource')); + expect(session.installPlugin).toHaveBeenCalledWith( + 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + ); }); await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); @@ -3162,7 +3194,7 @@ command = "vim" id: 'kimi-datasource', tier: 'official', displayName: 'Kimi Datasource', - source: './kimi-datasource', + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', }, ], }), @@ -3195,6 +3227,103 @@ command = "vim" }); }); + it('prompts for trust before installing a third-party marketplace entry', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + description: 'Curated plugin', + source: './superpowers', + }, + ], + }), + 'utf8', + ); + const session = makeSession(); + const { driver } = await makeDriver(session); + + // Passing the marketplace path opens the panel directly on the Third-party tab. + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'superpowers')); + }); + }); + + it('restores the panel when a third-party marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + source: './superpowers', + }, + ], + }), + 'utf8', + ); + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + // The failed install must return the user to the marketplace panel so they + // can retry, rather than dropping them back at the editor. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(panel); + }); + }); + it('removes a plugin record without auto-running any cleanup skill', async () => { const session = makeSession(); const { driver } = await makeDriver(session); diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 09476f607..fb7bde597 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -35,7 +35,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. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. 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). +The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. 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 244685489..458a09296 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -35,7 +35,7 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `/plugins mcp enable ` | 启用 plugin 声明的 MCP server | | `/plugins mcp disable ` | 禁用 plugin 声明的 MCP server | -**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。 +**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。 ### 从 GitHub 安装