diff --git a/.changeset/plugin-marketplace-update-badge.md b/.changeset/plugin-marketplace-update-badge.md new file mode 100644 index 000000000..0364658b5 --- /dev/null +++ b/.changeset/plugin-marketplace-update-badge.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Show available plugin updates in the marketplace. An installed plugin whose marketplace version is newer than the local version now renders an `update ` badge (and updates in place on Enter); up-to-date plugins show `installed · v`. The marketplace `version` served in dev and written by the CDN build is now stamped from each plugin's manifest so "latest" stays accurate. diff --git a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs index 59d05fa86..eaa8d331f 100644 --- a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs +++ b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs @@ -6,6 +6,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import yazl from 'yazl'; +import { readPluginManifestVersion } from './plugin-manifest-version.mjs'; + const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, '../../..'); const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins'); @@ -46,7 +48,13 @@ export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) { continue; } const result = await materializeEntrySource(entry.source, pluginsRoot, outDir); - plugins.push({ ...entry, source: result.source }); + let stamped = { ...entry, source: result.source }; + if (isLocalRelativeSource(entry.source)) { + // Stamp the version from the plugin's real manifest so "latest" stays truthful. + const version = await readPluginManifestVersion(resolveInsideRoot(pluginsRoot, entry.source)); + if (version !== undefined) stamped = { ...stamped, version }; + } + plugins.push(stamped); if (result.archive !== undefined) archives.push(result.archive); } diff --git a/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs b/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs index f65b4fe8a..2f7226737 100644 --- a/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs +++ b/apps/kimi-code/scripts/dev-plugin-marketplace-server.mjs @@ -7,6 +7,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import yazl from 'yazl'; +import { readPluginManifestVersion } from './plugin-manifest-version.mjs'; + const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, '../../..'); const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins'); @@ -109,7 +111,10 @@ async function rewriteMarketplaceJson(raw, pluginsRoot) { if (!isLocalRelativeSource(entry.source)) return entry; const sourcePath = resolveInsideRoot(pluginsRoot, entry.source); if (!(await isDirectory(sourcePath))) return entry; - return { ...entry, source: withZipExtension(entry.source) }; + // Stamp the version from the plugin's real manifest so "latest" stays truthful. + const version = await readPluginManifestVersion(sourcePath); + const withVersion = version !== undefined ? { ...entry, version } : entry; + return { ...withVersion, source: withZipExtension(withVersion.source) }; }), ); diff --git a/apps/kimi-code/scripts/plugin-manifest-version.mjs b/apps/kimi-code/scripts/plugin-manifest-version.mjs new file mode 100644 index 000000000..1fd768af5 --- /dev/null +++ b/apps/kimi-code/scripts/plugin-manifest-version.mjs @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +// Read a local plugin directory's declared version from its manifest, mirroring +// the plugin loader's precedence (packages/agent-core/src/plugin/manifest.ts): +// `kimi.plugin.json` is authoritative once it exists, and `.kimi-plugin/plugin.json` +// is only consulted when the root manifest is absent. Returns undefined when no +// manifest is present or the chosen manifest has no version — callers then leave +// the marketplace entry's existing version untouched. +export async function readPluginManifestVersion(pluginDir) { + for (const rel of ['kimi.plugin.json', '.kimi-plugin/plugin.json']) { + const raw = await readFileOrUndefined(resolve(pluginDir, rel)); + if (raw === undefined) continue; // manifest absent — fall back to the next candidate + return versionFromManifest(raw); // the chosen manifest wins, even if it has no version + } + return undefined; +} + +async function readFileOrUndefined(file) { + try { + return await readFile(file, 'utf8'); + } catch { + return undefined; + } +} + +function versionFromManifest(raw) { + try { + const parsed = JSON.parse(raw); + if (parsed !== null && typeof parsed === 'object' && typeof parsed.version === 'string') { + const trimmed = parsed.version.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + } catch { + return undefined; + } + return undefined; +} diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index bbcd883a0..cbfed5dcb 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -178,7 +178,9 @@ async function showPluginMarketplacePicker(host: SlashCommandHost, source?: stri host.mountEditorReplacement( new PluginMarketplaceSelectorComponent({ entries: marketplace.plugins, - installedIds: new Set(installed.map((plugin) => plugin.id)), + installed: new Map( + installed.map((plugin): [string, string | undefined] => [plugin.id, plugin.version]), + ), source: marketplace.source, onSelect: (selection) => { // Every marketplace action re-mounts a picker, so let the handler do 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 daf1155a9..3a4cce8d1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -12,7 +12,7 @@ import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; import { printableChar } from '#/tui/utils/printable-key'; -import type { PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; +import { computeUpdateStatus, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; import { ChoicePickerComponent } from './choice-picker'; @@ -184,7 +184,7 @@ export type PluginMarketplaceSelection = export interface PluginMarketplaceSelectorOptions { readonly entries: readonly PluginMarketplaceEntry[]; - readonly installedIds: ReadonlySet; + readonly installed: ReadonlyMap; readonly source: string; readonly onSelect: (selection: PluginMarketplaceSelection) => void; readonly onCancel: () => void; @@ -200,7 +200,7 @@ export class PluginMarketplaceSelectorComponent extends Container implements Foc constructor(opts: PluginMarketplaceSelectorOptions) { super(); this.opts = opts; - this.items = buildMarketplaceItems(opts.entries, opts.installedIds); + this.items = buildMarketplaceItems(opts.entries, opts.installed); } handleInput(data: string): void { @@ -502,13 +502,13 @@ function overviewItemPluginId(item: PluginsOverviewItem): string | undefined { function buildMarketplaceItems( entries: readonly PluginMarketplaceEntry[], - installedIds: ReadonlySet, + installed: ReadonlyMap, ): PluginsOverviewItem[] { const items: PluginsOverviewItem[] = entries.map((entry) => ({ value: entry.id, kind: 'plugin', label: entry.displayName, - status: installedIds.has(entry.id) ? 'installed' : installStatus(entry), + status: marketplaceItemStatus(entry, installed), description: marketplaceEntryDescription(entry), })); items.push({ @@ -556,13 +556,13 @@ function mcpItemServerName(item: PluginsOverviewItem): string | undefined { function marketplaceEntryDescription(entry: PluginMarketplaceEntry): string { const tier = marketplaceTierLabel(entry.tier); const description = entry.description ?? tier; - const version = entry.version !== undefined ? ` · v${entry.version}` : ''; const keywords = entry.keywords !== undefined && entry.keywords.length > 0 ? ` · ${entry.keywords.join(', ')}` : ''; const tierSuffix = entry.description !== undefined ? ` · ${tier}` : ''; - return `${description} · id ${entry.id}${version}${tierSuffix}${keywords}`; + // The version now lives in the status badge, so it is omitted here to avoid duplication. + return `${description} · id ${entry.id}${tierSuffix}${keywords}`; } function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { @@ -571,8 +571,19 @@ function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { return 'Plugin'; } -function installStatus(entry: PluginMarketplaceEntry): string { - return entry.version === undefined ? 'install' : `install v${entry.version}`; +function marketplaceItemStatus( + entry: PluginMarketplaceEntry, + installed: ReadonlyMap, +): string { + const status = computeUpdateStatus(entry.version, installed.get(entry.id), installed.has(entry.id)); + switch (status.kind) { + case 'update': + return `update ${status.local} → ${status.latest}`; + case 'up-to-date': + return status.version === undefined ? 'installed' : `installed · v${status.version}`; + case 'not-installed': + return entry.version === undefined ? 'install' : `install v${entry.version}`; + } } function sectionLabel(label: string): string { @@ -583,7 +594,8 @@ function statusStyle( item: PluginsOverviewItem, ): (text: string) => string { if (item.kind === 'action') return (text) => currentTheme.fg('textDim', text); - if (item.status === 'enabled' || item.status === 'installed') return (text) => currentTheme.fg('success', text); + if (item.status?.startsWith('update')) return (text) => currentTheme.fg('warning', text); + if (item.status === 'enabled' || item.status?.startsWith('installed')) return (text) => currentTheme.fg('success', text); if (item.status?.startsWith('install')) return (text) => currentTheme.fg('primary', text); if (item.status === 'disabled') return (text) => currentTheme.fg('textDim', text); if (item.status !== undefined && /^\d/.test(item.status)) return (text) => currentTheme.fg('textDim', text); diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index d11748a55..5553e94c1 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -3,6 +3,8 @@ import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gt, valid } from 'semver'; + import { KIMI_CODE_PLUGIN_MARKETPLACE_URL, KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, @@ -29,6 +31,36 @@ export interface PluginMarketplace { readonly plugins: readonly PluginMarketplaceEntry[]; } +export type PluginUpdateStatus = + | { readonly kind: 'not-installed' } + | { readonly kind: 'up-to-date'; readonly version?: string } + | { readonly kind: 'update'; readonly local: string; readonly latest: string }; + +/** + * Compare a marketplace entry's (latest) version against the locally installed + * version. Only reports `update` when both are valid semver and latest > local, + * so a stale or non-semver version never produces a spurious or downgrading prompt. + */ +export function computeUpdateStatus( + latest: string | undefined, + local: string | undefined, + installed: boolean, +): PluginUpdateStatus { + if (!installed) return { kind: 'not-installed' }; + if ( + latest !== undefined && + local !== undefined && + valid(latest) !== null && + valid(local) !== null && + gt(latest, local) + ) { + return { kind: 'update', local, latest }; + } + // Report only the actual installed version. When it is unknown, don't borrow the + // marketplace version — that would falsely claim "up to date" and hide future updates. + return { kind: 'up-to-date', version: local }; +} + interface MarketplaceLocation { readonly raw: string; readonly kind: 'remote' | 'local'; 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 34d7358e7..79d872cd3 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 @@ -171,7 +171,7 @@ describe('plugins selector dialogs', () => { keywords: ['workflow'], }, ], - installedIds: new Set(), + installed: new Map(), source: '/tmp/marketplace.json', onSelect, onCancel: vi.fn(), @@ -182,7 +182,7 @@ describe('plugins selector dialogs', () => { expect(out).toContain('Marketplace (1)'); expect(out).toContain('? Superpowers install v5.1.0'); expect(out).toContain( - `Workflow skills ${MID} id superpowers ${MID} v5.1.0 ${MID} Curated plugin ${MID} workflow`, + `Workflow skills ${MID} id superpowers ${MID} Curated plugin ${MID} workflow`, ); expect(out).toContain('Enter install/update'); expect(out).toContain('Actions'); @@ -209,7 +209,7 @@ describe('plugins selector dialogs', () => { keywords: ['workflow'], }, ], - installedIds: new Set(), + installed: new Map(), source: '/tmp/marketplace.json', onSelect, onCancel: vi.fn(), @@ -238,7 +238,7 @@ describe('plugins selector dialogs', () => { keywords: ['workflow'], }, ], - installedIds: new Set(), + installed: new Map(), source: '/tmp/marketplace.json', onSelect: vi.fn(), onCancel, @@ -258,7 +258,7 @@ describe('plugins selector dialogs', () => { source: 'https://example.com/superpowers.zip', }, ], - installedIds: new Set(['superpowers']), + installed: new Map([['superpowers', undefined]]), source: '/tmp/marketplace.json', onSelect, onCancel: vi.fn(), @@ -275,6 +275,48 @@ describe('plugins selector dialogs', () => { }); }); + it('shows an update badge when the installed version is older than the marketplace', () => { + const picker = new PluginMarketplaceSelectorComponent({ + entries: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + version: '5.1.0', + source: 'https://example.com/superpowers.zip', + }, + ], + installed: new Map([['superpowers', '5.0.0']]), + source: '/tmp/marketplace.json', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip).join('\n'); + expect(out).toContain('? Superpowers update 5.0.0 → 5.1.0'); + }); + + it('shows installed with the version when already up to date', () => { + const picker = new PluginMarketplaceSelectorComponent({ + entries: [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + version: '5.1.0', + source: 'https://example.com/superpowers.zip', + }, + ], + installed: new Map([['superpowers', '5.1.0']]), + source: '/tmp/marketplace.json', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = picker.render(120).map(strip).join('\n'); + expect(out).toContain(`? Superpowers installed ${MID} v5.1.0`); + }); + it('toggles an installed plugin from the overview with space', () => { const onSelect = vi.fn(); const picker = new PluginsOverviewSelectorComponent({ diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 0df71a811..d7430b5ad 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -6,10 +6,58 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; -import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../..'); +describe('computeUpdateStatus', () => { + it('reports not-installed when the plugin is absent', () => { + expect(computeUpdateStatus('1.0.0', undefined, false)).toEqual({ kind: 'not-installed' }); + }); + + it('reports an update when the marketplace version is newer', () => { + expect(computeUpdateStatus('5.1.0', '5.0.0', true)).toEqual({ + kind: 'update', + local: '5.0.0', + latest: '5.1.0', + }); + }); + + it('reports up-to-date when versions match', () => { + expect(computeUpdateStatus('5.1.0', '5.1.0', true)).toEqual({ + kind: 'up-to-date', + version: '5.1.0', + }); + }); + + it('does not offer a downgrade when the local version is ahead', () => { + expect(computeUpdateStatus('3.1.1', '3.2.0', true)).toEqual({ + kind: 'up-to-date', + version: '3.2.0', + }); + }); + + it('never reports an update for non-semver versions', () => { + expect(computeUpdateStatus('latest', '5.0.0', true).kind).toBe('up-to-date'); + expect(computeUpdateStatus('5.1.0', 'dev', true).kind).toBe('up-to-date'); + }); + + it('shows the local version even when the marketplace omits one', () => { + expect(computeUpdateStatus(undefined, '5.0.0', true)).toEqual({ + kind: 'up-to-date', + version: '5.0.0', + }); + }); + + it('does not claim the marketplace version as installed when the local version is unknown', () => { + // No spurious `installed · v`, and no permanent suppression of updates. + expect(computeUpdateStatus('5.1.0', undefined, true)).toEqual({ + kind: 'up-to-date', + version: undefined, + }); + }); +}); + describe('loadPluginMarketplace', () => { it('loads a local marketplace file and resolves relative plugin sources', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 37699ee0b..2772c4e69 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -15,6 +15,8 @@ Run `/plugins` in the TUI to open the plugin manager, where you can perform all | `M` | Manage MCP servers for the selected plugin | | `←` or `Esc` | Go back to the previous level | +In the marketplace list, an installed plugin with a newer version available shows `update `, an up-to-date one shows `installed · v`, and an uninstalled one shows `install v`. Select an updatable entry and press `Enter` to update. + You can also use slash commands directly: | Command | Description | diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 0c564aebe..220adb5b4 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -15,6 +15,8 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `M` | 管理选中 plugin 的 MCP servers | | `←` 或 `Esc` | 返回上一层 | +在 marketplace 列表里,已安装且有新版本的 plugin 会显示 `update <本地版本> → <最新版本>`,已是最新显示 `installed · v<版本>`,未安装显示 `install v<版本>`。选中可更新的项按 `Enter` 即可更新。 + 也可以直接使用斜杠命令: | 命令 | 说明 |