mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
46f909d694
commit
40506f49d6
11 changed files with 215 additions and 19 deletions
5
.changeset/plugin-marketplace-update-badge.md
Normal file
5
.changeset/plugin-marketplace-update-badge.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
38
apps/kimi-code/scripts/plugin-manifest-version.mjs
Normal file
38
apps/kimi-code/scripts/plugin-manifest-version.mjs
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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-'));
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会
|
|||
| `M` | 管理选中 plugin 的 MCP servers |
|
||||
| `←` 或 `Esc` | 返回上一层 |
|
||||
|
||||
在 marketplace 列表里,已安装且有新版本的 plugin 会显示 `update <本地版本> → <最新版本>`,已是最新显示 `installed · v<版本>`,未安装显示 `install v<版本>`。选中可更新的项按 `Enter` 即可更新。
|
||||
|
||||
也可以直接使用斜杠命令:
|
||||
|
||||
| 命令 | 说明 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue