feat(tui): show available plugin updates in the marketplace (#593)

Installed plugins whose marketplace version is newer than the local
version now render an `update <local> → <latest>` badge and update in
place on Enter; up-to-date plugins show `installed · v<version>`.

Dev-server and CDN-build marketplace generation now stamp each entry's
version from the plugin manifest so the advertised "latest" stays accurate.
Adds a pure computeUpdateStatus() (semver, no spurious downgrades) with tests.

Co-authored-by: qer <Anna_Knapprfr@mail.com>
This commit is contained in:
qer 2026-06-09 21:28:36 +08:00 committed by GitHub
parent 46f909d694
commit 40506f49d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 215 additions and 19 deletions

View file

@ -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 <local> → <latest>` badge (and updates in place on Enter); up-to-date plugins show `installed · v<version>`. The marketplace `version` served in dev and written by the CDN build is now stamped from each plugin's manifest so "latest" stays accurate.

View file

@ -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);
}

View file

@ -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) };
}),
);

View file

@ -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;
}

View file

@ -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

View file

@ -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<string>;
readonly installed: ReadonlyMap<string, string | undefined>;
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<string>,
installed: ReadonlyMap<string, string | undefined>,
): 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, string | undefined>,
): 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);

View file

@ -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';

View file

@ -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({

View file

@ -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<latest>`, 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-'));

View file

@ -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 <local> → <latest>`, an up-to-date one shows `installed · v<version>`, and an uninstalled one shows `install v<version>`. Select an updatable entry and press `Enter` to update.
You can also use slash commands directly:
| Command | Description |

View file

@ -15,6 +15,8 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会
| `M` | 管理选中 plugin 的 MCP servers |
| `←``Esc` | 返回上一层 |
在 marketplace 列表里,已安装且有新版本的 plugin 会显示 `update <本地版本> → <最新版本>`,已是最新显示 `installed · v<版本>`,未安装显示 `install v<版本>`。选中可更新的项按 `Enter` 即可更新。
也可以直接使用斜杠命令:
| 命令 | 说明 |