From b321559346f97b8469fee603b9819d802da85b02 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 14:19:33 +0800 Subject: [PATCH 1/9] feat(plugins): source Superpowers from GitHub and show update badges Source the Superpowers plugin from its GitHub release (v6.0.3) instead of a vendored copy, and drop the explicit version field. Derive marketplace entry versions from GitHub source URLs when the version field is omitted, keeping the source URL the single source of truth. Show update badges for installed plugins on the /plugins Installed tab. --- .changeset/installed-plugin-update-badges.md | 5 ++ .gitignore | 1 - .oxlintrc.json | 1 - apps/kimi-code/src/tui/commands/plugins.ts | 14 ++-- .../components/dialogs/plugins-selector.ts | 16 ++++- .../kimi-code/src/utils/plugin-marketplace.ts | 43 +++++++++++- .../dialogs/plugins-selector.test.ts | 26 ++++++++ .../test/utils/plugin-marketplace.test.ts | 65 ++++++++++++++++++- plugins/marketplace.json | 3 +- 9 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 .changeset/installed-plugin-update-badges.md diff --git a/.changeset/installed-plugin-update-badges.md b/.changeset/installed-plugin-update-badges.md new file mode 100644 index 0000000000..0e067d3973 --- /dev/null +++ b/.changeset/installed-plugin-update-badges.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Show update badges for installed plugins on the /plugins Installed tab. diff --git a/.gitignore b/.gitignore index 8d331c7ca6..ae25ce57b6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ coverage/ .conductor .kimi-stash-dir plugins/cdn/ -superpowers .worktrees/ .kimi-code/local.toml diff --git a/.oxlintrc.json b/.oxlintrc.json index 77a86ac9af..877553f49a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,7 +150,6 @@ "node_modules/", "apps/*/scripts/", "docs/smoke-archive/", - "plugins/curated/superpowers/", "*.generated.ts" ] } diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index f7fcaa0b89..19b3a7da77 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -174,15 +174,21 @@ async function showPluginsPicker( onCancel: () => { host.restoreEditor(); }, - // The Official/Third-party tabs fetch their catalog lazily so /plugins - // opens instantly and Installed/Custom keep working even when the - // marketplace is unreachable. + // Every tab except Custom needs the catalog: Official/Third-party list it, + // and Installed uses it to show update badges. The Installed/Custom tabs + // keep working even when the marketplace is unreachable (badges simply stay + // hidden until data arrives). onRequestMarketplace: () => { void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); }, }); host.mountEditorReplacement(panel); - if (options?.initialTab === 'official' || options?.initialTab === 'third-party') { + // Kick off the catalog fetch for any tab that needs it: Installed uses it for + // update badges, Official/Third-party list it. Custom never reads the catalog, + // so skip the fetch there. Done here (after `panel` is initialized) rather + // than inside the component constructor, because the callback above closes + // over `panel`. + if (options?.initialTab !== 'custom') { panel.setMarketplaceLoading(); void loadMarketplaceCatalog(host, panel, options?.marketplaceSource); } 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 4a51e4979c..57d9cef4c9 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -351,7 +351,9 @@ export class PluginsPanelComponent extends Container implements Focusable { } private requestMarketplaceIfNeeded(): void { - if (this.market.status === 'idle' && this.activeTab.id !== 'installed' && this.activeTab.id !== 'custom') { + // The Installed tab also needs the catalog to render update badges; only the + // Custom tab (manual URL entry) can skip the fetch entirely. + if (this.market.status === 'idle' && this.activeTab.id !== 'custom') { this.market = { status: 'loading' }; this.opts.onRequestMarketplace?.(); } @@ -497,6 +499,14 @@ export class PluginsPanelComponent extends Container implements Focusable { lines.push(mutedHintLine(` ${installed.length} installed`, colors)); } + private installedUpdateBadge(plugin: PluginSummary): string | undefined { + if (this.market.status !== 'loaded') return undefined; + const entry = this.market.entries.find((e) => e.id === plugin.id); + if (entry === undefined) return undefined; + const status = computeUpdateStatus(entry.version, plugin.version, true); + return status.kind === 'update' ? `update ${status.local} → ${status.latest}` : undefined; + } + private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] { const colors = currentTheme.palette; const selected = index === this.selectedIndex; @@ -504,10 +514,14 @@ export class PluginsPanelComponent extends Container implements Focusable { const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); const status = pluginStatus(plugin); + const update = this.installedUpdateBadge(plugin); let line = prefix + labelStyle(plugin.displayName); if (status !== undefined) { line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status); } + if (update !== undefined) { + line += ' ' + marketplaceStatusStyle(update, colors)(update); + } if (this.opts.pluginHint?.id === plugin.id) { line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); } diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index e1c8628998..5b8e0a0bb1 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -172,12 +172,13 @@ function parseMarketplaceEntry( if (source === undefined) { throw new Error(`Plugin marketplace entry ${id} must define "source".`); } + const resolvedSource = resolveEntrySource(source, location); return { id, displayName: stringField(value, 'displayName') ?? stringField(value, 'name') ?? id, - source: resolveEntrySource(source, location), + source: resolvedSource, tier: parseMarketplaceTier(value, id), - version: stringField(value, 'version'), + version: stringField(value, 'version') ?? deriveVersionFromGithubSource(resolvedSource), description: stringField(value, 'description') ?? stringField(value, 'shortDescription'), homepage: stringField(value, 'homepage') ?? stringField(value, 'websiteURL'), keywords: stringArrayField(value, 'keywords'), @@ -234,6 +235,44 @@ function resolveEntrySource(source: string, location: MarketplaceLocation): stri return resolve(dirname(location.resolved), trimmed); } +/** + * Best-effort derivation of a semver version from a GitHub source URL that pins + * a specific ref. Lets a marketplace entry omit `version` when the source + * already encodes the release (for example `/releases/tag/v6.0.3`), keeping the + * source URL the single source of truth and avoiding drift between the two. + * + * Only refs shaped like semver (`v6.0.3`, `6.0.3`, `6.0.3-rc.1`) are accepted; + * bare repo URLs, branch names and commit SHAs yield `undefined`, so update + * detection degrades to "unknown" instead of comparing meaningless values. + */ +function deriveVersionFromGithubSource(source: string): string | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') { + return undefined; + } + // Pathname shape: ///. Recognized tails: + // releases/tag/ + // tree/ + // commit/ + const [, , kind, a, b] = url.pathname.split('/').filter(Boolean); + const ref = + kind === 'releases' && a === 'tag' ? b : kind === 'tree' || kind === 'commit' ? a : undefined; + if (ref === undefined) return undefined; + let decoded: string; + try { + decoded = decodeURIComponent(ref); + } catch { + decoded = ref; + } + const candidate = decoded.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; +} + function resolveLocalPath(input: string, workDir: string): string { if (input === '~') return homedir(); if (input.startsWith('~/')) return join(homedir(), input.slice(2)); 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 c652c28a1a..1991bfa5b6 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 @@ -253,6 +253,32 @@ describe('plugins selector dialogs', () => { expect(out).toContain('Superpowers update 4.0.0 → 5.0.0'); }); + it('shows an update badge on the Installed tab when the marketplace version is newer', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers enabled update 4.0.0 → 5.0.0'); + }); + + it('does not show an update badge on the Installed tab before the marketplace loads', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const { panel } = makePanel({ installed }); + // The marketplace has not been loaded yet, so the badge stays hidden rather + // than guessing. + const out = strip(renderRaw(panel)); + expect(out).not.toContain('update'); + }); + it('shows installed · v when the installed plugin is up to date', () => { const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; const entries = [ diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index d3577bc129..73905dd230 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -135,7 +135,7 @@ describe('loadPluginMarketplace', () => { id: 'superpowers', displayName: 'Superpowers', tier: 'curated', - source: join(REPO_ROOT, 'plugins/curated/superpowers'), + source: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', }), ); expect(marketplace.plugins).toContainEqual( @@ -197,7 +197,7 @@ describe('loadPluginMarketplace', () => { expect(marketplace.plugins).toContainEqual( expect.objectContaining({ id: 'superpowers', - source: join(REPO_ROOT, 'plugins/curated/superpowers'), + source: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', }), ); } finally { @@ -221,6 +221,67 @@ describe('loadPluginMarketplace', () => { })).rejects.toThrow(/fetch failed/); }); + describe('version derivation from a GitHub source', () => { + async function loadEntry(source: string, version?: string) { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + source, + version, + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file }); + return marketplace.plugins[0]!; + } + + it('derives a version from a /releases/tag/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('derives a version from a /tree/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/tree/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('accepts a tag without a leading v', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); + + it('does not derive a version from a bare repo URL', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers'); + expect(entry.version).toBeUndefined(); + }); + + it('does not derive a version from a commit SHA', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/commit/abc1234'); + expect(entry.version).toBeUndefined(); + }); + + it('does not derive a version from a non-GitHub URL', async () => { + const entry = await loadEntry('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip'); + expect(entry.version).toBeUndefined(); + }); + + it('lets an explicit version override the derived one', async () => { + const entry = await loadEntry( + 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + '9.9.9', + ); + expect(entry.version).toBe('9.9.9'); + }); + }); + it('accepts legacy marketplace type aliases as normal plugins', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); const file = join(dir, 'marketplace.json'); diff --git a/plugins/marketplace.json b/plugins/marketplace.json index 70544f9d4f..94d6a396ec 100644 --- a/plugins/marketplace.json +++ b/plugins/marketplace.json @@ -14,11 +14,10 @@ "id": "superpowers", "tier": "curated", "displayName": "Superpowers", - "version": "5.1.0", "description": "Planning, TDD, debugging, and delivery workflows for coding agents.", "homepage": "https://github.com/obra/superpowers", "keywords": ["skills", "planning", "tdd", "debugging", "code-review"], - "source": "./curated/superpowers" + "source": "https://github.com/obra/superpowers/releases/tag/v6.0.3" } ] } From 06cd76da1910bdf9535fdaf0528ea32ebd2609b4 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 14:21:12 +0800 Subject: [PATCH 2/9] docs(plugins): document Installed tab update badges --- docs/en/customization/plugins.md | 2 +- docs/zh/customization/plugins.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 3be6cb4411..5894e55e41 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -34,7 +34,7 @@ You can also use slash commands directly: | `/plugins mcp enable ` | Enable an MCP server declared by a plugin | | `/plugins mcp disable ` | Disable an MCP server declared by a plugin | -The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load when you switch to those tabs. 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 from GitHub diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 092d17f35b..1a5f39e357 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -34,7 +34,7 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `/plugins mcp enable ` | 启用 plugin 声明的 MCP server | | `/plugins mcp disable ` | 禁用 plugin 声明的 MCP server | -**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。切到对应 tab 时才会加载 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`(其他所有情况)。 ### 从 GitHub 安装 From 32946970b090c216de114d032eeaa938e81662cd Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 14:36:10 +0800 Subject: [PATCH 3/9] fix(plugins): stamp GitHub source version in CDN catalog Older CLIs only read the explicit marketplace version and cannot derive it from a GitHub source URL. When publishing the CDN catalog, stamp the version derived from a pinned GitHub source so those clients still surface update badges. The source plugins/marketplace.json keeps no explicit version; the version is derived at build time instead. --- .../scripts/build-plugin-marketplace-cdn.mjs | 43 +++++++++ .../build-plugin-marketplace-cdn.test.ts | 93 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 apps/kimi-code/test/scripts/build-plugin-marketplace-cdn.test.ts diff --git a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs index eaa8d331fd..89fe0fbe4e 100644 --- a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs +++ b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs @@ -53,6 +53,12 @@ export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) { // 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 }; + } else if (stamped.version === undefined) { + // For a pinned GitHub source, derive the version from the URL so older CLIs + // (which only read the explicit catalog version and cannot derive it + // themselves) still surface update badges against the published catalog. + const derived = deriveVersionFromGithubSource(result.source); + if (derived !== undefined) stamped = { ...stamped, version: derived }; } plugins.push(stamped); if (result.archive !== undefined) archives.push(result.archive); @@ -201,6 +207,43 @@ function isLocalRelativeSource(source) { ); } +/** + * Derive a semver-shaped version from a pinned GitHub source URL. Mirrors the + * CLI-side `deriveVersionFromGithubSource` in + * `apps/kimi-code/src/utils/plugin-marketplace.ts` — keep the two in sync so + * the catalog version stamped here matches what the CLI would derive itself. + * + * Only refs shaped like semver (`v6.0.3`, `6.0.3`, `6.0.3-rc.1`) are accepted; + * bare repo URLs, branch names and commit SHAs yield `undefined`. + */ +function deriveVersionFromGithubSource(source) { + let url; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') { + return undefined; + } + // Pathname shape: ///. Recognized tails: + // releases/tag/ + // tree/ + // commit/ + const [, , kind, a, b] = url.pathname.split('/').filter(Boolean); + const ref = + kind === 'releases' && a === 'tag' ? b : kind === 'tree' || kind === 'commit' ? a : undefined; + if (ref === undefined) return undefined; + let decoded; + try { + decoded = decodeURIComponent(ref); + } catch { + decoded = ref; + } + const candidate = decoded.replace(/^v/i, ''); + return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(candidate) ? candidate : undefined; +} + function withZipExtension(source) { const trimmed = source.trim().replace(/\/+$/, ''); return extname(trimmed) === '.zip' ? trimmed : `${trimmed}.zip`; diff --git a/apps/kimi-code/test/scripts/build-plugin-marketplace-cdn.test.ts b/apps/kimi-code/test/scripts/build-plugin-marketplace-cdn.test.ts new file mode 100644 index 0000000000..3b0a6299b2 --- /dev/null +++ b/apps/kimi-code/test/scripts/build-plugin-marketplace-cdn.test.ts @@ -0,0 +1,93 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { buildPluginMarketplaceCdn } from '../../scripts/build-plugin-marketplace-cdn.mjs'; + +async function writeMarketplace(dir: string, plugins: readonly unknown[]): Promise { + await writeFile(join(dir, 'marketplace.json'), JSON.stringify({ version: '1', plugins }), 'utf8'); +} + +async function readOutput(dir: string): Promise<{ plugins: readonly Record[] }> { + return JSON.parse(await readFile(join(dir, 'marketplace.json'), 'utf8')) as { + plugins: readonly Record[]; + }; +} + +describe('buildPluginMarketplaceCdn', () => { + it('stamps a derived version for a pinned GitHub release source', async () => { + const root = await mkdtemp(join(tmpdir(), 'cdn-src-')); + const out = await mkdtemp(join(tmpdir(), 'cdn-out-')); + await writeMarketplace(root, [ + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + source: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + }, + ]); + + await buildPluginMarketplaceCdn({ pluginsRoot: root, outDir: out }); + const output = await readOutput(out); + + expect(output.plugins[0]).toMatchObject({ + id: 'superpowers', + source: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + version: '6.0.3', + }); + }); + + it('derives a version from a /tree/ source', async () => { + const root = await mkdtemp(join(tmpdir(), 'cdn-src-')); + const out = await mkdtemp(join(tmpdir(), 'cdn-out-')); + await writeMarketplace(root, [ + { + id: 'demo', + displayName: 'Demo', + source: 'https://github.com/owner/repo/tree/v1.2.3', + }, + ]); + + await buildPluginMarketplaceCdn({ pluginsRoot: root, outDir: out }); + const output = await readOutput(out); + + expect(output.plugins[0]).toMatchObject({ version: '1.2.3' }); + }); + + it('does not stamp a version for a bare GitHub URL', async () => { + const root = await mkdtemp(join(tmpdir(), 'cdn-src-')); + const out = await mkdtemp(join(tmpdir(), 'cdn-out-')); + await writeMarketplace(root, [ + { + id: 'demo', + displayName: 'Demo', + source: 'https://github.com/owner/repo', + }, + ]); + + await buildPluginMarketplaceCdn({ pluginsRoot: root, outDir: out }); + const output = await readOutput(out); + + expect(output.plugins[0]?.['version']).toBeUndefined(); + }); + + it('keeps an explicit version over a derived one', async () => { + const root = await mkdtemp(join(tmpdir(), 'cdn-src-')); + const out = await mkdtemp(join(tmpdir(), 'cdn-out-')); + await writeMarketplace(root, [ + { + id: 'demo', + displayName: 'Demo', + version: '9.9.9', + source: 'https://github.com/owner/repo/releases/tag/v1.2.3', + }, + ]); + + await buildPluginMarketplaceCdn({ pluginsRoot: root, outDir: out }); + const output = await readOutput(out); + + expect(output.plugins[0]?.['version']).toBe('9.9.9'); + }); +}); From 459a1978473a65b6e42c0ed3055c5ae563de0636 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 14:51:24 +0800 Subject: [PATCH 4/9] feat(plugins): resolve latest version for bare GitHub sources at runtime Point the Superpowers marketplace entry at the bare GitHub repo URL so it tracks the latest release instead of a pinned tag. When a marketplace entry omits version and its source is a bare GitHub repo URL, resolve the latest release tag at load time (via the /releases/latest redirect) to fill the version for update detection. Revert the build-time version stamping; it is no longer needed. Older CLIs that only read the explicit catalog version will no longer see update badges for Superpowers, since the catalog no longer carries one. --- .../scripts/build-plugin-marketplace-cdn.mjs | 43 ------- .../kimi-code/src/utils/plugin-marketplace.ts | 84 ++++++++++++- .../build-plugin-marketplace-cdn.test.ts | 93 -------------- .../test/utils/plugin-marketplace.test.ts | 114 ++++++++++++++++-- plugins/marketplace.json | 2 +- 5 files changed, 188 insertions(+), 148 deletions(-) delete mode 100644 apps/kimi-code/test/scripts/build-plugin-marketplace-cdn.test.ts diff --git a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs index 89fe0fbe4e..eaa8d331fd 100644 --- a/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs +++ b/apps/kimi-code/scripts/build-plugin-marketplace-cdn.mjs @@ -53,12 +53,6 @@ export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) { // 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 }; - } else if (stamped.version === undefined) { - // For a pinned GitHub source, derive the version from the URL so older CLIs - // (which only read the explicit catalog version and cannot derive it - // themselves) still surface update badges against the published catalog. - const derived = deriveVersionFromGithubSource(result.source); - if (derived !== undefined) stamped = { ...stamped, version: derived }; } plugins.push(stamped); if (result.archive !== undefined) archives.push(result.archive); @@ -207,43 +201,6 @@ function isLocalRelativeSource(source) { ); } -/** - * Derive a semver-shaped version from a pinned GitHub source URL. Mirrors the - * CLI-side `deriveVersionFromGithubSource` in - * `apps/kimi-code/src/utils/plugin-marketplace.ts` — keep the two in sync so - * the catalog version stamped here matches what the CLI would derive itself. - * - * Only refs shaped like semver (`v6.0.3`, `6.0.3`, `6.0.3-rc.1`) are accepted; - * bare repo URLs, branch names and commit SHAs yield `undefined`. - */ -function deriveVersionFromGithubSource(source) { - let url; - try { - url = new URL(source); - } catch { - return undefined; - } - if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') { - return undefined; - } - // Pathname shape: ///. Recognized tails: - // releases/tag/ - // tree/ - // commit/ - const [, , kind, a, b] = url.pathname.split('/').filter(Boolean); - const ref = - kind === 'releases' && a === 'tag' ? b : kind === 'tree' || kind === 'commit' ? a : undefined; - if (ref === undefined) return undefined; - let decoded; - try { - decoded = decodeURIComponent(ref); - } catch { - decoded = ref; - } - const candidate = decoded.replace(/^v/i, ''); - return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(candidate) ? candidate : undefined; -} - function withZipExtension(source) { const trimmed = source.trim().replace(/\/+$/, ''); return extname(trimmed) === '.zip' ? trimmed : `${trimmed}.zip`; diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index 5b8e0a0bb1..be55e798e9 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -81,17 +81,32 @@ export async function loadPluginMarketplace( configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL, options.workDir, ); + const fetchImpl = options.fetchImpl ?? fetch; let raw: string; try { - raw = await readMarketplaceText(location, options.fetchImpl ?? fetch); + raw = await readMarketplaceText(location, fetchImpl); } catch (error) { const fallback = configuredSource === undefined ? await getSourceCheckoutMarketplaceLocation() : undefined; if (fallback === undefined) throw error; - raw = await readMarketplaceText(fallback, options.fetchImpl ?? fetch); - return parsePluginMarketplace(raw, fallback); + raw = await readMarketplaceText(fallback, fetchImpl); + return withLatestVersions(parsePluginMarketplace(raw, fallback), fetchImpl); } - return parsePluginMarketplace(raw, location); + return withLatestVersions(parsePluginMarketplace(raw, location), fetchImpl); +} + +async function withLatestVersions( + marketplace: PluginMarketplace, + fetchImpl: typeof fetch, +): Promise { + const plugins = await Promise.all( + marketplace.plugins.map(async (entry) => { + if (entry.version !== undefined) return entry; + const latest = await resolveLatestGithubRelease(entry.source, fetchImpl); + return latest === undefined ? entry : { ...entry, version: latest }; + }), + ); + return { ...marketplace, plugins }; } export function parsePluginMarketplace(raw: string, location: MarketplaceLocation): PluginMarketplace { @@ -273,6 +288,67 @@ function deriveVersionFromGithubSource(source: string): string | undefined { return valid(candidate) !== null ? candidate : undefined; } +async function resolveLatestGithubRelease( + source: string, + fetchImpl: typeof fetch, +): Promise { + const repo = parseGithubRepo(source); + if (repo === undefined) return undefined; + try { + const tag = await fetchLatestReleaseTag(repo.owner, repo.repo, fetchImpl); + if (tag === undefined) return undefined; + const candidate = tag.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; + } catch { + return undefined; + } +} + +function parseGithubRepo(source: string): { owner: string; repo: string } | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined; + // Only bare repo URLs (//) qualify — URLs with a ref tail are + // already handled by deriveVersionFromGithubSource. + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length !== 2) return undefined; + const [owner, repo] = segments; + return { owner: owner!, repo: repo! }; +} + +async function fetchLatestReleaseTag( + owner: string, + repo: string, + fetchImpl: typeof fetch, +): Promise { + // Avoid api.github.com: its anonymous quota is shared with the user's browser + // and other tools, and a first-time lookup failing because something else + // burned the budget is unacceptable. The /releases/latest UI route 302s to + // the tag and is not part of the API quota. + const url = `https://github.com/${owner}/${repo}/releases/latest`; + const resp = await fetchImpl(url, { redirect: 'manual' }); + if (resp.status === 404) return undefined; + if (resp.status !== 301 && resp.status !== 302) { + throw new Error( + `Could not look up latest release of ${owner}/${repo}: HTTP ${resp.status} (${url}).`, + ); + } + const location = resp.headers.get('location'); + if (location === null) return undefined; + const match = /\/releases\/tag\/([^/?#]+)/.exec(location); + const tag = match?.[1]; + if (tag === undefined) return undefined; + try { + return decodeURIComponent(tag); + } catch { + return tag; + } +} + function resolveLocalPath(input: string, workDir: string): string { if (input === '~') return homedir(); if (input.startsWith('~/')) return join(homedir(), input.slice(2)); diff --git a/apps/kimi-code/test/scripts/build-plugin-marketplace-cdn.test.ts b/apps/kimi-code/test/scripts/build-plugin-marketplace-cdn.test.ts deleted file mode 100644 index 3b0a6299b2..0000000000 --- a/apps/kimi-code/test/scripts/build-plugin-marketplace-cdn.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -import { buildPluginMarketplaceCdn } from '../../scripts/build-plugin-marketplace-cdn.mjs'; - -async function writeMarketplace(dir: string, plugins: readonly unknown[]): Promise { - await writeFile(join(dir, 'marketplace.json'), JSON.stringify({ version: '1', plugins }), 'utf8'); -} - -async function readOutput(dir: string): Promise<{ plugins: readonly Record[] }> { - return JSON.parse(await readFile(join(dir, 'marketplace.json'), 'utf8')) as { - plugins: readonly Record[]; - }; -} - -describe('buildPluginMarketplaceCdn', () => { - it('stamps a derived version for a pinned GitHub release source', async () => { - const root = await mkdtemp(join(tmpdir(), 'cdn-src-')); - const out = await mkdtemp(join(tmpdir(), 'cdn-out-')); - await writeMarketplace(root, [ - { - id: 'superpowers', - tier: 'curated', - displayName: 'Superpowers', - source: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', - }, - ]); - - await buildPluginMarketplaceCdn({ pluginsRoot: root, outDir: out }); - const output = await readOutput(out); - - expect(output.plugins[0]).toMatchObject({ - id: 'superpowers', - source: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', - version: '6.0.3', - }); - }); - - it('derives a version from a /tree/ source', async () => { - const root = await mkdtemp(join(tmpdir(), 'cdn-src-')); - const out = await mkdtemp(join(tmpdir(), 'cdn-out-')); - await writeMarketplace(root, [ - { - id: 'demo', - displayName: 'Demo', - source: 'https://github.com/owner/repo/tree/v1.2.3', - }, - ]); - - await buildPluginMarketplaceCdn({ pluginsRoot: root, outDir: out }); - const output = await readOutput(out); - - expect(output.plugins[0]).toMatchObject({ version: '1.2.3' }); - }); - - it('does not stamp a version for a bare GitHub URL', async () => { - const root = await mkdtemp(join(tmpdir(), 'cdn-src-')); - const out = await mkdtemp(join(tmpdir(), 'cdn-out-')); - await writeMarketplace(root, [ - { - id: 'demo', - displayName: 'Demo', - source: 'https://github.com/owner/repo', - }, - ]); - - await buildPluginMarketplaceCdn({ pluginsRoot: root, outDir: out }); - const output = await readOutput(out); - - expect(output.plugins[0]?.['version']).toBeUndefined(); - }); - - it('keeps an explicit version over a derived one', async () => { - const root = await mkdtemp(join(tmpdir(), 'cdn-src-')); - const out = await mkdtemp(join(tmpdir(), 'cdn-out-')); - await writeMarketplace(root, [ - { - id: 'demo', - displayName: 'Demo', - version: '9.9.9', - source: 'https://github.com/owner/repo/releases/tag/v1.2.3', - }, - ]); - - await buildPluginMarketplaceCdn({ pluginsRoot: root, outDir: out }); - const output = await readOutput(out); - - expect(output.plugins[0]?.['version']).toBe('9.9.9'); - }); -}); diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 73905dd230..9c220b8681 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -125,9 +125,22 @@ describe('loadPluginMarketplace', () => { }); it('includes Superpowers in the repository marketplace fixture', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/releases/latest')) { + return { + status: 302, + headers: new Headers({ + location: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + }), + } as Response; + } + return { status: 404, headers: new Headers() } as Response; + }) as unknown as typeof fetch; const marketplace = await loadPluginMarketplace({ workDir: REPO_ROOT, source: join(REPO_ROOT, 'plugins/marketplace.json'), + fetchImpl, }); expect(marketplace.plugins).toContainEqual( @@ -135,7 +148,8 @@ describe('loadPluginMarketplace', () => { id: 'superpowers', displayName: 'Superpowers', tier: 'curated', - source: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + source: 'https://github.com/obra/superpowers', + version: '6.0.3', }), ); expect(marketplace.plugins).toContainEqual( @@ -197,7 +211,7 @@ describe('loadPluginMarketplace', () => { expect(marketplace.plugins).toContainEqual( expect.objectContaining({ id: 'superpowers', - source: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + source: 'https://github.com/obra/superpowers', }), ); } finally { @@ -258,11 +272,6 @@ describe('loadPluginMarketplace', () => { expect(entry.version).toBe('6.0.3'); }); - it('does not derive a version from a bare repo URL', async () => { - const entry = await loadEntry('https://github.com/obra/superpowers'); - expect(entry.version).toBeUndefined(); - }); - it('does not derive a version from a commit SHA', async () => { const entry = await loadEntry('https://github.com/obra/superpowers/commit/abc1234'); expect(entry.version).toBeUndefined(); @@ -282,6 +291,97 @@ describe('loadPluginMarketplace', () => { }); }); + describe('latest release resolution for bare GitHub sources', () => { + async function loadWithLatest(source: string, fetchImpl: typeof fetch) { + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ plugins: [{ id: 'demo', displayName: 'Demo', source }] }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + return marketplace.plugins[0]!; + } + + function redirectFetch(location: string): typeof fetch { + return vi.fn(async () => ({ + status: 302, + headers: new Headers({ location }), + })) as unknown as typeof fetch; + } + + it('fills the version from /releases/latest for a bare repo URL', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/v6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); + }); + + it('strips a leading v from the resolved latest tag', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); + }); + + it('leaves version undefined when the repo has no release', async () => { + const fetchImpl = vi.fn(async () => ({ + status: 404, + headers: new Headers(), + })) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); + }); + + it('degrades gracefully when the latest lookup throws', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); + }); + + it('does not query latest when the source already pins a ref', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest( + 'https://github.com/owner/repo/releases/tag/v6.0.3', + fetchImpl, + ); + expect(entry.version).toBe('6.0.3'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('keeps an explicit version without querying latest', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + version: '9.9.9', + source: 'https://github.com/owner/repo', + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + expect(marketplace.plugins[0]?.version).toBe('9.9.9'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + }); + it('accepts legacy marketplace type aliases as normal plugins', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-plugin-marketplace-')); const file = join(dir, 'marketplace.json'); diff --git a/plugins/marketplace.json b/plugins/marketplace.json index 94d6a396ec..1e514a33ab 100644 --- a/plugins/marketplace.json +++ b/plugins/marketplace.json @@ -17,7 +17,7 @@ "description": "Planning, TDD, debugging, and delivery workflows for coding agents.", "homepage": "https://github.com/obra/superpowers", "keywords": ["skills", "planning", "tdd", "debugging", "code-review"], - "source": "https://github.com/obra/superpowers/releases/tag/v6.0.3" + "source": "https://github.com/obra/superpowers" } ] } From cd82635ffe45db6b4b886350f3431a46a09c09a8 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 15:12:36 +0800 Subject: [PATCH 5/9] feat(plugins): make Enter update and add I for details on Installed tab On the Installed tab, Enter now installs the available update when one is present, and falls back to opening plugin details otherwise. Add the I key to always open plugin details, so details remain reachable when Enter is occupied by an update. Update the installed hint, docs and changeset accordingly. --- .changeset/installed-plugin-update-badges.md | 2 +- .../components/dialogs/plugins-selector.ts | 30 +++++++++-- .../dialogs/plugins-selector.test.ts | 54 +++++++++++++++++++ docs/en/customization/plugins.md | 3 +- docs/zh/customization/plugins.md | 3 +- 5 files changed, 84 insertions(+), 8 deletions(-) diff --git a/.changeset/installed-plugin-update-badges.md b/.changeset/installed-plugin-update-badges.md index 0e067d3973..2d9c775089 100644 --- a/.changeset/installed-plugin-update-badges.md +++ b/.changeset/installed-plugin-update-badges.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Show update badges for installed plugins on the /plugins Installed tab. +Show update badges on the /plugins Installed tab, where Enter now installs the available update and I opens plugin details. 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 57d9cef4c9..01a5763f4c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -425,6 +425,16 @@ export class PluginsPanelComponent extends Container implements Focusable { return; } if (matchesKey(data, Key.enter)) { + if (plugin === undefined) return; + const update = this.installedUpdateStatus(plugin); + if (update !== undefined) { + this.opts.onSelect({ kind: 'install', entry: update.entry }); + } else { + this.opts.onSelect({ kind: 'details', id: plugin.id }); + } + return; + } + if (ch === 'i' || ch === 'I') { if (plugin !== undefined) this.opts.onSelect({ kind: 'details', id: plugin.id }); } } @@ -458,7 +468,7 @@ export class PluginsPanelComponent extends Container implements Focusable { const tab = this.activeTab.id; const hint = tab === 'installed' - ? ' Tab switch · Space toggle · D remove · M MCP · Enter details · R reload · Esc cancel' + ? this.installedHint() : tab === 'custom' ? ' Tab switch · Enter install · Esc cancel' : ' Tab switch · ↑↓ navigate · Enter open/install · Esc cancel'; @@ -499,12 +509,21 @@ export class PluginsPanelComponent extends Container implements Focusable { lines.push(mutedHintLine(` ${installed.length} installed`, colors)); } - private installedUpdateBadge(plugin: PluginSummary): string | undefined { + private installedHint(): string { + const plugin = this.opts.installed[this.selectedIndex]; + const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined; + const enter = hasUpdate ? 'Enter update' : 'Enter details'; + return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`; + } + + private installedUpdateStatus( + plugin: PluginSummary, + ): { entry: PluginMarketplaceEntry; local: string; latest: string } | undefined { if (this.market.status !== 'loaded') return undefined; const entry = this.market.entries.find((e) => e.id === plugin.id); if (entry === undefined) return undefined; const status = computeUpdateStatus(entry.version, plugin.version, true); - return status.kind === 'update' ? `update ${status.local} → ${status.latest}` : undefined; + return status.kind === 'update' ? { entry, local: status.local, latest: status.latest } : undefined; } private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] { @@ -514,13 +533,14 @@ export class PluginsPanelComponent extends Container implements Focusable { const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); const status = pluginStatus(plugin); - const update = this.installedUpdateBadge(plugin); + const update = this.installedUpdateStatus(plugin); let line = prefix + labelStyle(plugin.displayName); if (status !== undefined) { line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status); } if (update !== undefined) { - line += ' ' + marketplaceStatusStyle(update, colors)(update); + const badge = `update ${update.local} → ${update.latest}`; + line += ' ' + marketplaceStatusStyle(badge, colors)(badge); } if (this.opts.pluginHint?.id === plugin.id) { line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); 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 1991bfa5b6..9eeb2fdade 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 @@ -180,6 +180,60 @@ describe('plugins selector dialogs', () => { expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); }); + it('Enter on an installed plugin with an available update installs it', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'superpowers' }), + }); + }); + + it('Enter on an up-to-date installed plugin opens details', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('I on an installed plugin opens details even when an update is available', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('i'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + it('renders the inline plugin hint on the installed row', () => { const datasource = { ...superpowers, id: 'kimi-datasource', displayName: 'Kimi Datasource', skillCount: 1 }; const { panel } = makePanel({ diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 5894e55e41..09476f6074 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -15,7 +15,8 @@ Run `/plugins` in the TUI to open the plugin manager. It is a single panel with | `D` | Remove the selected installed plugin (Installed tab) | | `M` | Manage MCP servers for the selected plugin (Installed tab) | | `R` | Reload `installed.json` and all manifests (Installed tab) | -| `Enter` | Installed tab: view plugin details · Official/Third-party tab: install or update · Custom tab: install | +| `Enter` | Installed tab: install the available update, or view details if up to date · Official/Third-party tab: install or update · Custom tab: install | +| `I` | View plugin details (Installed tab) | | `Esc` | Go back or cancel | You can also use slash commands directly: diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 1a5f39e357..2446854894 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -15,7 +15,8 @@ Kimi Code CLI 对 plugin 采用保守的加载策略:安装 plugin 时不会 | `D` | 移除选中的已安装 plugin(Installed tab) | | `M` | 管理选中 plugin 的 MCP servers(Installed tab) | | `R` | 重新加载 `installed.json` 和所有 manifest(Installed tab) | -| `Enter` | Installed tab:查看 plugin 详情 · Official/Third-party tab:安装或更新 · Custom tab:安装 | +| `Enter` | Installed tab:有更新时安装更新,否则查看 plugin 详情 · Official/Third-party tab:安装或更新 · Custom tab:安装 | +| `I` | 查看 plugin 详情(Installed tab) | | `Esc` | 返回或取消 | 也可以直接使用斜杠命令: From 9b2c354f8dd40397c0de85d215e7d2d8a4d2de8f Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 15:19:16 +0800 Subject: [PATCH 6/9] feat(plugins): show installing state inside the plugins panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the "Installing … from marketplace" notice from a transient status message into the plugins panel itself, so the user sees progress in the interactive card while an install or update is in flight. --- apps/kimi-code/src/tui/commands/plugins.ts | 12 ++++++---- .../components/dialogs/plugins-selector.ts | 22 +++++++++++++++++++ .../dialogs/plugins-selector.test.ts | 7 ++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 19b3a7da77..41bc2044de 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -167,7 +167,7 @@ async function showPluginsPicker( // Each branch of the handler either mounts the next view or restores the // editor itself, so do not pre-restore here — that would flash the editor // for in-place actions like toggling a plugin. - void handlePluginsPanelSelection(host, selection).catch((error: unknown) => { + void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => { host.showError(`/plugins failed: ${formatErrorMessage(error)}`); }); }, @@ -293,6 +293,7 @@ async function applyPluginEnabled( async function handlePluginsPanelSelection( host: SlashCommandHost, + panel: PluginsPanelComponent, selection: PluginsPanelSelection, ): Promise { switch (selection.kind) { @@ -326,18 +327,21 @@ async function handlePluginsPanelSelection( await showPluginsPicker(host, { initialTab: 'installed' }); return; case 'install': { - host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`); + panel.setInstalling(selection.entry.displayName); + host.state.ui.requestRender(); await installPluginFromSource(host, selection.entry.source, { successNotice: 'marketplace' }); // Close the panel after installing so the success notice and the // "/reload or /new" / post-install tip are visible in the transcript. host.restoreEditor(); return; } - case 'install-source': - host.showStatus(`Installing plugin from ${truncateForStatus(selection.source)}…`); + case 'install-source': { + panel.setInstalling(truncateForStatus(selection.source)); + host.state.ui.requestRender(); await installPluginFromSource(host, selection.source, { successNotice: 'marketplace' }); host.restoreEditor(); return; + } } } 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 01a5763f4c..1ffe3f54da 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -289,6 +289,7 @@ export class PluginsPanelComponent extends Container implements Focusable { private activeTabIndex: number; private selectedIndex = 0; private market: MarketState = { status: 'idle' }; + private installing: string | undefined; constructor(opts: PluginsPanelOptions) { super(); @@ -323,6 +324,11 @@ export class PluginsPanelComponent extends Container implements Focusable { this.market = { status: 'error', message }; } + setInstalling(label: string): void { + this.installing = label; + this.invalidate(); + } + private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] { return PLUGINS_PANEL_TABS[this.activeTabIndex]!; } @@ -464,6 +470,9 @@ export class PluginsPanelComponent extends Container implements Focusable { } override render(width: number): string[] { + if (this.installing !== undefined) { + return this.renderInstalling(width); + } const colors = currentTheme.palette; const tab = this.activeTab.id; const hint = @@ -614,6 +623,19 @@ export class PluginsPanelComponent extends Container implements Focusable { lines.push(''); lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors)); } + + private renderInstalling(width: number): string[] { + const colors = currentTheme.palette; + const lines = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Plugins'), + '', + chalk.hex(colors.textMuted)(` Installing ${this.installing} from marketplace…`), + '', + chalk.hex(colors.primary)('─'.repeat(width)), + ]; + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } } function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] { 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 9eeb2fdade..694e8a2279 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 @@ -267,6 +267,13 @@ describe('plugins selector dialogs', () => { }); }); + it('renders an installing state while an install is in progress', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.setInstalling('Superpowers'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Installing Superpowers from marketplace'); + }); + it('keeps a valid selection if ↓ is pressed while the catalog is loading', () => { const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); // Catalog still loading (entries empty); pressing ↓ must not drive the From 3b30b10da741f6a7796dfc74549ad155da87a74b Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 15:33:48 +0800 Subject: [PATCH 7/9] feat(plugins): highlight reload hint and add dev:cli:marketplace Highlight "Run /new or /reload to apply plugin changes." in warning color after plugin install and remove, and make the two notices symmetric. Add a root dev:cli:marketplace script that points the dev CLI at the production marketplace instead of the local dev server. --- apps/kimi-code/src/tui/commands/plugins.ts | 12 +++++++----- package.json | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 41bc2044de..202a53d772 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -372,7 +372,8 @@ async function handlePluginMcpSelection( async function removePlugin(host: SlashCommandHost, id: string): Promise { await host.requireSession().removePlugin(id); - host.showStatus(`Removed ${id}. Run /reload or /new to apply plugin changes.`); + host.showStatus(`Removed ${id}.`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } async function renderPluginsList( @@ -416,6 +417,8 @@ async function installPluginFromSource( showPluginInstallResult(host, beforeList, summary, options); } +const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; + function showPluginInstallResult( host: SlashCommandHost, beforeList: readonly PluginSummary[], @@ -431,13 +434,12 @@ function showPluginInstallResult( ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.` : ''; const action = describeInstallAction(previous, summary); - host.showStatus( - `${action} (${summary.id}).${mcpHint} Run /new or /reload to apply plugin changes.`, - ); + host.showStatus(`${action} (${summary.id}).${mcpHint}`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); if (options?.successNotice === 'marketplace') { host.showNotice( `Installed or updated ${summary.displayName}`, - `Marketplace install or update succeeded for ${summary.id}. Run /new or /reload to apply plugin changes.`, + `Marketplace install or update succeeded for ${summary.id}.`, ); } } diff --git a/package.json b/package.json index d28b860600..825b6f67f5 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "pnpm -r run build", "build:packages": "pnpm -r --filter './packages/*' run build", "dev:cli": "pnpm -C apps/kimi-code run dev", + "dev:cli:marketplace": "KIMI_CODE_DEV_MARKETPLACE_URL=https://code.kimi.com/kimi-code/plugins/marketplace.json pnpm -C apps/kimi-code run dev", "dev:web": "pnpm -C apps/kimi-web run dev", "dev:server": "pnpm -C apps/kimi-code run dev:server", "build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace", From 421d53e2c331355beca3a03761ceda43c63817ac Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 15:39:47 +0800 Subject: [PATCH 8/9] fix(plugins): dedupe install success notice Drop the redundant showNotice on marketplace installs so the success message is shown only once, symmetric with remove. --- apps/kimi-code/src/tui/commands/plugins.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 202a53d772..66c8a5f77d 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -329,7 +329,7 @@ async function handlePluginsPanelSelection( case 'install': { panel.setInstalling(selection.entry.displayName); host.state.ui.requestRender(); - await installPluginFromSource(host, selection.entry.source, { successNotice: 'marketplace' }); + await installPluginFromSource(host, selection.entry.source); // Close the panel after installing so the success notice and the // "/reload or /new" / post-install tip are visible in the transcript. host.restoreEditor(); @@ -338,7 +338,7 @@ async function handlePluginsPanelSelection( case 'install-source': { panel.setInstalling(truncateForStatus(selection.source)); host.state.ui.requestRender(); - await installPluginFromSource(host, selection.source, { successNotice: 'marketplace' }); + await installPluginFromSource(host, selection.source); host.restoreEditor(); return; } @@ -405,16 +405,13 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { 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); + showPluginInstallResult(host, beforeList, summary); } const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; @@ -423,9 +420,6 @@ 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'; @@ -436,12 +430,6 @@ function showPluginInstallResult( const action = describeInstallAction(previous, summary); host.showStatus(`${action} (${summary.id}).${mcpHint}`); host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); - if (options?.successNotice === 'marketplace') { - host.showNotice( - `Installed or updated ${summary.displayName}`, - `Marketplace install or update succeeded for ${summary.id}.`, - ); - } } function describeInstallAction( From f4eac9e9c9c0cc2f2a837ce9705fb5394aa0ba08 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 15:54:12 +0800 Subject: [PATCH 9/9] fix(plugins): reset installing state on install failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a marketplace or Custom-tab install rejects, clear the installing state and return to the list so the user can retry, instead of leaving the panel stuck on the one-way "Installing…" view. --- apps/kimi-code/src/tui/commands/plugins.ts | 18 ++++++- .../components/dialogs/plugins-selector.ts | 5 ++ .../test/tui/kimi-tui-message-flow.test.ts | 48 ++++++++++++++++++- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 66c8a5f77d..b220b48171 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -329,7 +329,14 @@ async function handlePluginsPanelSelection( case 'install': { panel.setInstalling(selection.entry.displayName); host.state.ui.requestRender(); - await installPluginFromSource(host, selection.entry.source); + 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(); @@ -338,7 +345,14 @@ async function handlePluginsPanelSelection( case 'install-source': { panel.setInstalling(truncateForStatus(selection.source)); host.state.ui.requestRender(); - await installPluginFromSource(host, selection.source); + 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(); return; } 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 1ffe3f54da..fbab65d1b2 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -329,6 +329,11 @@ export class PluginsPanelComponent extends Container implements Focusable { this.invalidate(); } + clearInstalling(): void { + this.installing = undefined; + this.invalidate(); + } + private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] { return PLUGINS_PANEL_TABS[this.activeTabIndex]!; } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index a8a019b690..8c4528e53d 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -3118,8 +3118,8 @@ command = "vim" }); await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Installing or updating Kimi Datasource from marketplace...'); - expect(transcript).toContain('Installed or updated Demo'); + expect(transcript).toContain('Installed Demo'); + expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); }); // Installing closes the panel so the success notice / reload tip is visible. await vi.waitFor(() => { @@ -3127,6 +3127,50 @@ command = "vim" }); }); + it('returns to the plugin list when a marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'kimi-datasource', + tier: 'official', + displayName: 'Kimi Datasource', + source: './kimi-datasource', + }, + ], + }), + 'utf8', + ); + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = marketplacePath; + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); + const session = makeSession({ installPlugin }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins marketplace'); + + 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('Kimi Datasource'); + }); + panel.handleInput('\r'); + + // The panel must not get stuck on the one-way "Installing…" view; it should + // return to the list so the user can retry. + await vi.waitFor(() => { + const rendered = stripSgr(panel.render(120).join('\n')); + expect(rendered).toContain('Kimi Datasource'); + expect(rendered).not.toContain('Installing'); + }); + }); + it('removes a plugin record without auto-running any cleanup skill', async () => { const session = makeSession(); const { driver } = await makeDriver(session);