Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/plugin-install-from-github.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---

Install plugins directly from GitHub repository URLs, and surface each install's origin and trust level (kimi-official, curated, third-party) in the plugin manager.
7 changes: 7 additions & 0 deletions .changeset/restrict-plugin-trust-badges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code-sdk": patch
"@moonshot-ai/kimi-code": patch
---

Restrict plugin trust badges to Kimi-hosted plugin CDN URL patterns.
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export interface SlashCommandHost {
// UI
showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle;
showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle;
showProgressSpinner(label: string): LoginProgressSpinnerHandle;

// Theme
applyTheme(theme: Theme, resolved?: ResolvedTheme): void;
Expand Down
65 changes: 60 additions & 5 deletions apps/kimi-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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 { loadPluginMarketplace } from '#/utils/plugin-marketplace';
import type { SlashCommandHost } from './dispatch';

Expand Down Expand Up @@ -61,7 +62,14 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri
host.showError('Usage: /plugins install <local-path-or-zip-url>');
return;
}
await installPluginFromSource(host, source);
const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`);
try {
await installPluginFromSource(host, source);
spinner.stop({ ok: true, label: `Install finished — see details below.` });
} catch (error) {
spinner.stop({ ok: false, label: `Install failed: ${formatErrorMessage(error)}` });
throw error;
}
return;
}
if (sub === 'marketplace') {
Expand Down Expand Up @@ -382,19 +390,35 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise<voi
async function installPluginFromSource(
host: SlashCommandHost,
source: string,
options?: { readonly successNotice?: 'marketplace' },
options?: {
readonly successNotice?: 'marketplace';
},
): Promise<void> {
const summary = await host.requireSession().installPlugin(
const session = host.requireSession();
const beforeList = await session.listPlugins();
const summary = await session.installPlugin(
resolvePluginInstallSource(source, host.state.appState.workDir),
);
showPluginInstallResult(host, beforeList, summary, options);
}

function showPluginInstallResult(
host: SlashCommandHost,
beforeList: readonly PluginSummary[],
summary: PluginSummary,
options?: {
readonly successNotice?: 'marketplace';
},
): void {
const previous = beforeList.find((entry) => entry.id === summary.id);
const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers';
const mcpHint =
summary.mcpServerCount > 0
? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.`
: '';
const installVerb = options?.successNotice === 'marketplace' ? 'Installed or updated' : 'Installed';
const action = describeInstallAction(previous, summary);
host.showStatus(
`${installVerb} ${summary.displayName} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`,
`${action} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`,
);
if (options?.successNotice === 'marketplace') {
host.showNotice(
Expand All @@ -404,6 +428,37 @@ async function installPluginFromSource(
}
}

function describeInstallAction(
previous: PluginSummary | undefined,
next: PluginSummary,
): string {
const sourceLabel = formatPluginSourceLabel(next);
const versionFromTo = (prev?: string, cur?: string): string => {
if (prev === undefined || prev === cur) return cur === undefined ? '' : ` ${cur}`;
return ` ${prev} → ${cur ?? '-'}`;
};
if (previous === undefined) {
return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} from ${sourceLabel}`;
}
if (sourceIdentity(previous) !== sourceIdentity(next)) {
const prevSourceLabel = formatPluginSourceLabel(previous);
return `Migrated ${next.displayName}: ${prevSourceLabel} → ${sourceLabel}${versionFromTo(previous.version, next.version)}`;
}
return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} from ${sourceLabel}`;
}

function sourceIdentity(plugin: PluginSummary): string {
if (plugin.source === 'github' && plugin.github !== undefined) {
return `github:${plugin.github.owner}/${plugin.github.repo}`;
}
return plugin.source;
}

function truncateForStatus(input: string): string {
const max = 80;
return input.length > max ? `${input.slice(0, max - 1)}…` : input;
}

async function reloadPlugins(host: SlashCommandHost): Promise<void> {
const summary = await host.requireSession().reloadPlugins();
const line = `Reload: +${summary.added.length} -${summary.removed.length}` +
Expand Down
12 changes: 7 additions & 5 deletions apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-a
import chalk from 'chalk';

import type { ColorPalette } from '#/tui/theme/colors';
import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label';
import { printableChar } from '#/tui/utils/printable-key';
import type { PluginMarketplaceEntry } from '#/utils/plugin-marketplace';

Expand Down Expand Up @@ -480,7 +481,9 @@ function overviewPluginDescription(plugin: PluginSummary): string {
? ` · MCP ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount}`
: '';
const diagnostics = plugin.hasErrors ? ' · diagnostics available' : '';
return `id ${plugin.id} · ${skills}${mcp}${state}${diagnostics}`;
const source = ` · ${formatPluginSourceLabel(plugin)}`;
const trust = ` · ${pluginTrustLabel(plugin)}`;
return `id ${plugin.id} · ${skills}${mcp}${source}${trust}${state}${diagnostics}`;
}

function pluginStatus(plugin: PluginSummary): string {
Expand Down Expand Up @@ -509,7 +512,7 @@ function buildMarketplaceItems(
kind: 'plugin',
label: entry.displayName,
status: installedIds.has(entry.id) ? 'installed' : installStatus(entry),
description: marketplaceEntryDescription(entry, installedIds.has(entry.id)),
description: marketplaceEntryDescription(entry),
}));
items.push({
value: 'back',
Expand Down Expand Up @@ -553,8 +556,7 @@ function mcpItemServerName(item: PluginsOverviewItem): string | undefined {
return item.value.slice(MCP_SERVER_PREFIX.length);
}

function marketplaceEntryDescription(entry: PluginMarketplaceEntry, installed: boolean): string {
const action = installed ? 'Enter/Space update' : 'Enter/Space install';
function marketplaceEntryDescription(entry: PluginMarketplaceEntry): string {
const tier = marketplaceTierLabel(entry.tier);
const description = entry.description ?? tier;
const version = entry.version !== undefined ? ` · v${entry.version}` : '';
Expand All @@ -563,7 +565,7 @@ function marketplaceEntryDescription(entry: PluginMarketplaceEntry, installed: b
? ` · ${entry.keywords.join(', ')}`
: '';
const tierSuffix = entry.description !== undefined ? ` · ${tier}` : '';
return `${action} · ${description} · id ${entry.id}${version}${tierSuffix}${keywords}`;
return `${description} · id ${entry.id}${version}${tierSuffix}${keywords}`;
}

function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
import chalk from 'chalk';

import type { ColorPalette } from '../../theme/colors';
import {
CURATED_BADGE,
OFFICIAL_BADGE,
THIRD_PARTY_BADGE,
type PluginTrustLabel,
formatPluginSourceLabel,
pluginTrustLabel,
} from '../../utils/plugin-source-label';

export interface PluginsListPanelInput {
readonly colors: ColorPalette;
Expand All @@ -12,6 +20,7 @@ export function buildPluginsListLines(input: PluginsListPanelInput): readonly st
const muted = chalk.hex(input.colors.textDim);
const value = chalk.hex(input.colors.text);
const success = chalk.hex(input.colors.success);
const primary = chalk.hex(input.colors.primary);
const warning = chalk.hex(input.colors.warning);
if (input.plugins.length === 0) {
return [
Expand All @@ -20,13 +29,22 @@ export function buildPluginsListLines(input: PluginsListPanelInput): readonly st
value('Run /plugins to install one.'),
];
}
const renderTrustBadge = (label: PluginTrustLabel): string => {
if (label === 'official') return success(`[${OFFICIAL_BADGE}]`);
if (label === 'curated') return primary(`[${CURATED_BADGE}]`);
return muted(`[${THIRD_PARTY_BADGE}]`);
};
const lines: string[] = [];
for (const plugin of input.plugins) {
const enabled = plugin.enabled ? success('enabled') : muted('disabled');
const state = plugin.state === 'ok' ? '' : ` [${plugin.state}]`;
const version = plugin.version ?? '-';
const diagnostics = plugin.hasErrors ? warning(' | diagnostics: see /plugins info') : '';
lines.push(`${value(plugin.displayName)} (${muted(plugin.id)}) ${muted(version)} | ${enabled}${state}`);
const sourceTag = muted(`[${formatPluginSourceLabel(plugin)}]`);
const trustBadge = ` ${renderTrustBadge(pluginTrustLabel(plugin))}`;
lines.push(
`${value(plugin.displayName)} (${muted(plugin.id)}) ${muted(version)} ${sourceTag}${trustBadge} | ${enabled}${state}`,
);
const mcp =
plugin.mcpServerCount > 0
? ` | ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount} mcp`
Expand All @@ -36,6 +54,7 @@ export function buildPluginsListLines(input: PluginsListPanelInput): readonly st
return lines;
}


export interface PluginsInfoPanelInput {
readonly colors: ColorPalette;
readonly info: PluginInfo;
Expand All @@ -48,14 +67,37 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st
const success = chalk.hex(input.colors.success);
const warning = chalk.hex(input.colors.warning);
const error = chalk.hex(input.colors.error);
const primary = chalk.hex(input.colors.primary);
const status = info.enabled ? success('enabled') : muted('disabled');
const trustLine = (() => {
const label = pluginTrustLabel(info);
if (label === 'official') {
return `${muted('Trust:')} ${success(OFFICIAL_BADGE)} ${muted('(Kimi-built and -maintained)')}`;
}
if (label === 'curated') {
return `${muted('Trust:')} ${primary(CURATED_BADGE)} ${muted('(Kimi-reviewed, upstream-maintained)')}`;
}
return `${muted('Trust:')} ${muted(THIRD_PARTY_BADGE)}`;
})();
const lines: string[] = [
`${value(info.displayName)} (${muted(info.id)}) ${muted(info.version ?? '')}`.trim(),
`${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state, input.colors)}`,
trustLine,
`${muted('Source:')} ${value(info.source)}`,
`${muted('Root:')} ${value(info.root)}`,
];
if (info.source === 'github' && info.github !== undefined) {
const refLabel = `${info.github.ref.kind}:${info.github.ref.value}`;
lines.push(`${muted('GitHub:')} ${value(`${info.github.owner}/${info.github.repo}`)} ${muted(`@${refLabel}`)}`);
if (info.github.installedSha !== undefined) {
lines.push(`${muted('Installed SHA:')} ${value(info.github.installedSha)}`);
}
}
if (info.originalSource !== undefined) lines.push(`${muted('Original source:')} ${value(info.originalSource)}`);
lines.push(`${muted('Installed at:')} ${value(info.installedAt)}`);
if (info.updatedAt !== undefined && info.updatedAt !== info.installedAt) {
lines.push(`${muted('Last updated:')} ${value(info.updatedAt)}`);
}
if (info.manifestPath !== undefined) {
const kindSuffix = info.manifestKind !== undefined ? ` ${muted(`(${info.manifestKind})`)}` : '';
lines.push(`${muted('Manifest:')} ${value(info.manifestPath)}${kindSuffix}`);
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,10 @@ export class KimiTUI {
}

showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle {
return this.showProgressSpinner(label);
}

showProgressSpinner(label: string): LoginProgressSpinnerHandle {
const tint = (s: string): string => chalk.hex(this.state.theme.colors.primary)(s);
const spinner = new MoonLoader(this.state.ui, 'braille', tint, label);
this.state.transcriptContainer.addChild(new Spacer(1));
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,5 @@ export interface PendingExit {
export interface LoginProgressSpinnerHandle {
stop(opts: { ok: boolean; label: string }): void;
}

export type ProgressSpinnerHandle = LoginProgressSpinnerHandle;
61 changes: 61 additions & 0 deletions apps/kimi-code/src/tui/utils/plugin-source-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk';

export const OFFICIAL_BADGE = 'official';
export const CURATED_BADGE = 'curated';
export const THIRD_PARTY_BADGE = 'third-party';

export type PluginTrustLabel = 'official' | 'curated' | 'third-party';

/**
* Human-readable provenance label for a plugin, suitable for inline display
* in `/plugins` overviews and lists.
*
* - github source → `github <owner>/<repo>@<ref>`
* - zip-url with parseable URL → `via <host[:port]>`
* - everything else → raw source kind (`local-path`, `zip-url`)
*/
export function formatPluginSourceLabel(plugin: PluginSummary): string {
if (plugin.source === 'github' && plugin.github !== undefined) {
return `github ${plugin.github.owner}/${plugin.github.repo}@${plugin.github.ref.value}`;
}
if (plugin.source === 'zip-url' && plugin.originalSource !== undefined) {
const host = hostFromUrl(plugin.originalSource);
if (host !== undefined) return `via ${host}`;
}
return plugin.source;
}

/**
* Returns one of three trust labels for a plugin. Only Kimi-hosted plugin zip
* paths receive official or curated badges. Everything else is third-party.
*/
export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel {
if (plugin.source !== 'zip-url' || plugin.originalSource === undefined) {
return 'third-party';
}
try {
const url = new URL(plugin.originalSource);
if (url.protocol !== 'https:' || url.hostname !== 'code.kimi.com') {
return 'third-party';
}
if (url.pathname.startsWith('/kimi-code/plugins/official/')) {
return 'official';
}
if (url.pathname.startsWith('/kimi-code/plugins/curated/')) {
return 'curated';
}
return 'third-party';
} catch {
return 'third-party';
}
}

function hostFromUrl(raw: string): string | undefined {
try {
const url = new URL(raw);
if (url.port.length > 0) return `${url.hostname}:${url.port}`;
return url.hostname;
} catch {
return undefined;
}
}
Loading
Loading