From 3554f7e7d6e472413aa7a9873d7a2eef5f2b819c Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 24 Jun 2026 21:58:13 +0800 Subject: [PATCH] feat(plugins): source Superpowers from GitHub and show update badges (#1066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * docs(plugins): document Installed tab update badges * 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. * 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. * 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. * feat(plugins): show installing state inside the plugins panel 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. * 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. * fix(plugins): dedupe install success notice Drop the redundant showNotice on marketplace installs so the success message is shown only once, symmetric with remove. * fix(plugins): reset installing state on install failure 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. --- .changeset/installed-plugin-update-badges.md | 5 + .gitignore | 1 - .oxlintrc.json | 1 - apps/kimi-code/src/tui/commands/plugins.ts | 68 +++++--- .../components/dialogs/plugins-selector.ts | 65 ++++++- .../kimi-code/src/utils/plugin-marketplace.ts | 127 +++++++++++++- .../dialogs/plugins-selector.test.ts | 87 +++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 48 ++++- .../test/utils/plugin-marketplace.test.ts | 165 +++++++++++++++++- docs/en/customization/plugins.md | 5 +- docs/zh/customization/plugins.md | 5 +- package.json | 1 + plugins/marketplace.json | 3 +- 13 files changed, 534 insertions(+), 47 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 000000000..2d9c77508 --- /dev/null +++ b/.changeset/installed-plugin-update-badges.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Show update badges on the /plugins Installed tab, where Enter now installs the available update and I opens plugin details. diff --git a/.gitignore b/.gitignore index 8d331c7ca..ae25ce57b 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 77a86ac9a..877553f49 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 f7fcaa0b8..b220b4817 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -167,22 +167,28 @@ 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)}`); }); }, 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); } @@ -287,6 +293,7 @@ async function applyPluginEnabled( async function handlePluginsPanelSelection( host: SlashCommandHost, + panel: PluginsPanelComponent, selection: PluginsPanelSelection, ): Promise { switch (selection.kind) { @@ -320,18 +327,35 @@ async function handlePluginsPanelSelection( await showPluginsPicker(host, { initialTab: 'installed' }); return; case 'install': { - host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`); - await installPluginFromSource(host, selection.entry.source, { successNotice: 'marketplace' }); + panel.setInstalling(selection.entry.displayName); + host.state.ui.requestRender(); + 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(); return; } - case 'install-source': - host.showStatus(`Installing plugin from ${truncateForStatus(selection.source)}…`); - await installPluginFromSource(host, selection.source, { successNotice: 'marketplace' }); + case 'install-source': { + panel.setInstalling(truncateForStatus(selection.source)); + host.state.ui.requestRender(); + 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; + } } } @@ -362,7 +386,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( @@ -394,25 +419,21 @@ 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.'; + 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'; @@ -421,15 +442,8 @@ 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.`, - ); - 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.`, - ); - } + host.showStatus(`${action} (${summary.id}).${mcpHint}`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } function describeInstallAction( 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 4a51e4979..fbab65d1b 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,16 @@ export class PluginsPanelComponent extends Container implements Focusable { this.market = { status: 'error', message }; } + setInstalling(label: string): void { + this.installing = label; + this.invalidate(); + } + + clearInstalling(): void { + this.installing = undefined; + this.invalidate(); + } + private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] { return PLUGINS_PANEL_TABS[this.activeTabIndex]!; } @@ -351,7 +362,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?.(); } @@ -423,6 +436,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 }); } } @@ -452,11 +475,14 @@ 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 = 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'; @@ -497,6 +523,23 @@ export class PluginsPanelComponent extends Container implements Focusable { lines.push(mutedHintLine(` ${installed.length} installed`, colors)); } + 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' ? { entry, local: status.local, latest: status.latest } : undefined; + } + private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] { const colors = currentTheme.palette; const selected = index === this.selectedIndex; @@ -504,10 +547,15 @@ 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.installedUpdateStatus(plugin); let line = prefix + labelStyle(plugin.displayName); if (status !== undefined) { line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status); } + if (update !== undefined) { + 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); } @@ -580,6 +628,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/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index e1c862899..be55e798e 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 { @@ -172,12 +187,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 +250,105 @@ 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; +} + +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/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index c652c28a1..694e8a227 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({ @@ -213,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 @@ -253,6 +314,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/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 621f11653..6c3dc484e 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 @@ -3142,8 +3142,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(() => { @@ -3151,6 +3151,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); diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index d3577bc12..9c220b868 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: join(REPO_ROOT, 'plugins/curated/superpowers'), + 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: join(REPO_ROOT, 'plugins/curated/superpowers'), + source: 'https://github.com/obra/superpowers', }), ); } finally { @@ -221,6 +235,153 @@ 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 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'); + }); + }); + + 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/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 3be6cb441..09476f607 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: @@ -34,7 +35,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 092d17f35..244685489 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` | 返回或取消 | 也可以直接使用斜杠命令: @@ -34,7 +35,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 安装 diff --git a/package.json b/package.json index d28b86060..825b6f67f 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", diff --git a/plugins/marketplace.json b/plugins/marketplace.json index 70544f9d4..1e514a33a 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" } ] }