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" } ] }