mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(tui): confirm before installing third-party plugins (#1088)
* feat(tui): confirm before installing third-party plugins * chore: add changeset for third-party plugin install confirmation * docs: note third-party plugin install confirmation prompt * fix: harden third-party plugin install confirmation
This commit is contained in:
parent
f1fad7222c
commit
0030f76c5c
9 changed files with 373 additions and 40 deletions
5
.changeset/confirm-third-party-plugin-install.md
Normal file
5
.changeset/confirm-third-party-plugin-install.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Add a confirmation prompt before installing third-party plugins.
|
||||
|
|
@ -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 <local-path-or-zip-url>');
|
||||
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<boolean> {
|
||||
// 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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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'}`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ You can also use slash commands directly:
|
|||
| `/plugins mcp enable <id> <server>` | Enable an MCP server declared by a plugin |
|
||||
| `/plugins mcp disable <id> <server>` | 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
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会
|
|||
| `/plugins mcp enable <id> <server>` | 启用 plugin 声明的 MCP server |
|
||||
| `/plugins mcp disable <id> <server>` | 禁用 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 安装
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue