diff --git a/.changeset/plugin-quota-note.md b/.changeset/plugin-quota-note.md new file mode 100644 index 000000000..47be2e0ab --- /dev/null +++ b/.changeset/plugin-quota-note.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show a quota consumption note after installing official plugins that bill against plan quota (such as Kimi Datasource). diff --git a/.changeset/plugin-update-notice.md b/.changeset/plugin-update-notice.md new file mode 100644 index 000000000..c08558775 --- /dev/null +++ b/.changeset/plugin-update-notice.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show an update notice when a turn that used an outdated plugin ends and the Official Marketplace has a newer version; each new version is announced once. Run /plugins to install the latest version. diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index f09cc600a..59aa18797 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -48,6 +48,7 @@ export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log'; +export const KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME = 'plugin-notices.json'; export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; export const KIMI_CODE_BANNER_DIR_NAME = 'banner'; export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json'; @@ -79,6 +80,9 @@ export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json` export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; +// Official plugins whose usage bills against the user's plan quota. Installing +// one of these shows a quota note after the install result. +export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['kimi-datasource']; export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`; export const KIMI_CODE_INSTALL_PS1_URL = `${KIMI_CODE_CDN_BASE}/install.ps1`; // Official download page, referenced by prompt copy that steers users away diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index b69f0724a..51bd3515a 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -20,7 +20,12 @@ import { } from '../components/messages/plugins-status-panel'; import { UsagePanelComponent } from '../components/messages/usage-panel'; import { formatErrorMessage } from '../utils/event-payload'; -import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label'; +import { + formatPluginSourceLabel, + isOfficialPluginInstall, + isOfficialPluginSource, +} from '../utils/plugin-source-label'; +import { QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app'; import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; import { openUrl } from '#/utils/open-url'; import type { SlashCommandHost } from './dispatch'; @@ -492,6 +497,8 @@ async function installPluginFromSource( const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; +const PLUGIN_QUOTA_NOTE = 'Note: This plugin consumes your quota.'; + function showPluginInstallResult( host: SlashCommandHost, beforeList: readonly PluginSummary[], @@ -506,6 +513,11 @@ function showPluginInstallResult( const action = describeInstallAction(previous, summary); host.showStatus(`${action} (${summary.id}).${mcpHint}`); host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); + // Gate on provenance, not just the id: a local/GitHub fork whose manifest + // reuses a billed plugin's id is not the official quota-consuming build. + if (QUOTA_CONSUMING_PLUGIN_IDS.includes(summary.id) && isOfficialPluginInstall(summary)) { + host.showStatus(PLUGIN_QUOTA_NOTE, 'warning'); + } } function describeInstallAction( diff --git a/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts b/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts new file mode 100644 index 000000000..ab6d72807 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts @@ -0,0 +1,208 @@ +import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { + computeUpdateStatus, + loadPluginMarketplace, + type PluginMarketplace, +} from '#/utils/plugin-marketplace'; +import { + readPluginUpdateNoticeState, + writePluginUpdateNoticeState, +} from '#/utils/plugin-update-notice-state'; +import { isOfficialPluginInstall } from '../utils/plugin-source-label'; + +/** + * The slice of the SDK session the notifier reads. Structurally satisfied by + * the full SDK `Session`, and easy to fake in tests. + */ +export interface PluginUpdateNotifierSession { + listMcpServers(): Promise; + listPlugins(): Promise; +} + +export interface PluginUpdateNotifierDeps { + readonly getSession: () => PluginUpdateNotifierSession | undefined; + readonly workDir: string; + readonly notify: (message: string) => void; + /** Overridable for tests; defaults to the shared marketplace loader. */ + readonly loadMarketplace?: () => Promise; + /** Overridable for tests; defaults to the updates dir under the data dir. */ + readonly stateFile?: string; +} + +const MCP_TOOL_NAME_PREFIX = 'mcp__'; +const PLUGIN_MCP_TOOL_NAME_PREFIX = `${MCP_TOOL_NAME_PREFIX}plugin-`; +// Plugin MCP servers run under the runtime name `plugin-:` +// (pluginMcpRuntimeName in packages/agent-core/src/plugin/manager.ts). +const PLUGIN_MCP_RUNTIME_NAME = /^plugin-([a-z0-9][a-z0-9_-]{0,63}):/; + +/** Cheap name check for plugin-provided MCP tools (`mcp__plugin-…`). */ +export function isPluginMcpToolName(toolName: string): boolean { + return toolName.startsWith(PLUGIN_MCP_TOOL_NAME_PREFIX); +} + +/** + * Mirror of sanitizeMcpNamePart in packages/agent-core/src/mcp/tool-naming.ts. + * MCP tool names on the wire carry the sanitized server name; the collapse + * step guarantees the `__` separator never appears inside a name part. + */ +function sanitizeMcpServerName(name: string): string { + return name.replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); +} + +/** + * Find the plugin behind a qualified MCP tool name by longest-prefix match + * against known server names. Prefix matching (rather than splitting on the + * `__` separator) survives core's 64-char truncation, which can cut the + * separator before appending the hash suffix; the boundary check keeps a + * shorter server name from matching another server's name, and longest match + * wins when one server name is a prefix of another. A name truncated inside + * the server part itself cannot be attributed reliably and stays unresolved. + */ +function matchPluginByToolName( + toolName: string, + serverPluginIds: Map, +): string | undefined { + let best: string | undefined; + let bestLength = 0; + for (const [serverName, pluginId] of serverPluginIds) { + const prefix = `${MCP_TOOL_NAME_PREFIX}${serverName}`; + if (!toolName.startsWith(prefix)) continue; + const boundary = toolName.charAt(prefix.length); + if (boundary !== '' && boundary !== '_') continue; + if (prefix.length > bestLength) { + best = pluginId; + bestLength = prefix.length; + } + } + return best; +} + +/** + * Shows a one-time "update detected" notice for outdated plugins. Callers + * report completed plugin usage (a plugin MCP tool name, or the plugin id of + * a `/:` turn — both reported once the turn's output has + * ended); the notifier checks the marketplace and persists the last notified + * version, so a plugin is re-notified only when the marketplace advertises a + * newer version than the one already shown. + * + * Entry points are fire-and-forget in production (never reject — the notice + * is a background nicety and any failure, e.g. an offline marketplace, is + * swallowed) and return an awaitable promise so tests can settle the queue + * deterministically. + */ +export class PluginUpdateNotifier { + private marketplacePromise: Promise | undefined; + private mcpServerPluginIds: Map | undefined; + private readonly inFlight = new Set(); + private queue: Promise = Promise.resolve(); + + constructor(private readonly deps: PluginUpdateNotifierDeps) {} + + handleMcpToolCompleted(toolName: string): Promise { + // Cheap bail before touching the RPC layer — most tools are not MCP tools, + // let alone plugin ones. + if (!isPluginMcpToolName(toolName)) return Promise.resolve(); + return this.resolvePluginId(toolName) + .then((pluginId) => { + if (pluginId !== undefined) return this.enqueue(pluginId); + return undefined; + }) + .catch(() => {}); + } + + handlePluginCommandCompleted(pluginId: string): Promise { + return this.enqueue(pluginId); + } + + private enqueue(pluginId: string): Promise { + // Serialize the read-modify-write cycle on the notice state file: two + // concurrent checks (e.g. a turn that used two outdated plugins) would + // otherwise read the same snapshot and the last write would drop the + // other plugin's entry. + this.queue = this.queue.then(() => this.checkAndNotify(pluginId)).catch(() => {}); + return this.queue; + } + + private async resolvePluginId(toolName: string): Promise { + const hit = matchPluginByToolName(toolName, await this.getMcpServerPluginIds()); + if (hit !== undefined) return hit; + // The map is memoized, but this notifier is reused across /reload, /new, + // and session switches, so plugins installed or enabled later in the same + // app run are missing from it. Refresh once on a miss before giving up. + return matchPluginByToolName(toolName, await this.loadMcpServerPluginIds()); + } + + private async getMcpServerPluginIds(): Promise> { + if (this.mcpServerPluginIds !== undefined) return this.mcpServerPluginIds; + return this.loadMcpServerPluginIds(); + } + + private async loadMcpServerPluginIds(): Promise> { + const map = new Map(); + const session = this.deps.getSession(); + // Without a session there is nothing to list; leave the cache unset so + // the next lookup retries instead of pinning an empty map. + if (session === undefined) return map; + const servers = await session.listMcpServers(); + for (const server of servers) { + const match = PLUGIN_MCP_RUNTIME_NAME.exec(server.name); + if (match?.[1] !== undefined) { + map.set(sanitizeMcpServerName(server.name), match[1]); + } + } + this.mcpServerPluginIds = map; + return map; + } + + private async checkAndNotify(pluginId: string): Promise { + if (this.inFlight.has(pluginId)) return; + this.inFlight.add(pluginId); + try { + const session = this.deps.getSession(); + if (session === undefined) return; + const marketplace = await this.loadCatalog(); + // Only the default official catalog can back an "Official Marketplace" + // notice — a custom catalog (KIMI_CODE_PLUGIN_MARKETPLACE_URL) may + // advertise anything under any id. + if (marketplace.source !== KIMI_CODE_PLUGIN_MARKETPLACE_URL) return; + const entry = marketplace.plugins.find((plugin) => plugin.id === pluginId); + if (entry === undefined) return; + const installed = (await session.listPlugins()).find((plugin) => plugin.id === pluginId); + if (installed === undefined) return; + // Only official installs are tracked against the Official Marketplace — + // a local/GitHub fork that happens to share a catalog id is not it. + if (!isOfficialPluginInstall(installed)) return; + const status = computeUpdateStatus(entry.version, installed.version, true); + if (status.kind !== 'update') return; + const state = await readPluginUpdateNoticeState(this.deps.stateFile); + if (state.notified[pluginId] === status.latest) return; + this.deps.notify( + `Update detected: ${installed.displayName} ${status.latest} is available. ` + + 'Run /plugins to install the latest version from the Official Marketplace.', + ); + await writePluginUpdateNoticeState( + { ...state, notified: { ...state.notified, [pluginId]: status.latest } }, + this.deps.stateFile, + ); + } finally { + this.inFlight.delete(pluginId); + } + } + + private loadCatalog(): Promise { + // Cached for the app run; a failed fetch is retried on the next invocation. + this.marketplacePromise ??= this.loadMarketplace().catch((error: unknown) => { + this.marketplacePromise = undefined; + throw error; + }); + return this.marketplacePromise; + } + + private loadMarketplace(): Promise { + const load = this.deps.loadMarketplace; + if (load !== undefined) return load(); + return loadPluginMarketplace({ workDir: this.deps.workDir }); + } +} diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 3a8b5ada3..b82f91961 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -73,6 +73,7 @@ import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; import type { BtwPanelController } from './btw-panel'; +import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier'; import type { StreamingUIController } from './streaming-ui'; import type { TasksBrowserController } from './tasks-browser'; import { SubAgentEventHandler } from './subagent-event-handler'; @@ -119,8 +120,12 @@ export interface SessionEventHost { export class SessionEventHandler { readonly subAgentEventHandler: SubAgentEventHandler; + private readonly pluginUpdateNotifier: PluginUpdateNotifier; - constructor(private readonly host: SessionEventHost) { + constructor( + private readonly host: SessionEventHost, + pluginUpdateNotifier?: PluginUpdateNotifier, + ) { this.subAgentEventHandler = new SubAgentEventHandler(host, { backgroundTasks: this.backgroundTasks, backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal, @@ -128,6 +133,15 @@ export class SessionEventHandler { this.syncBackgroundTaskBadge(); }, }); + this.pluginUpdateNotifier = + pluginUpdateNotifier ?? + new PluginUpdateNotifier({ + getSession: () => this.host.session, + workDir: host.state.appState.workDir, + notify: (message) => { + this.host.showStatus(message, 'warning'); + }, + }); } // Runtime state – owned by this handler, reset between sessions. @@ -142,6 +156,8 @@ export class SessionEventHandler { private goalCompletionAwaitingClear = false; private goalCompletionTurnEnded = false; private currentTurnHasAssistantText = false; + private pluginCommandTurns: Map = new Map(); + private pluginMcpToolsUsedInTurn: Set = new Set(); private pendingModelBlockedFallback: GoalChange | undefined; private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; @@ -158,6 +174,8 @@ export class SessionEventHandler { this.goalCompletionAwaitingClear = false; this.goalCompletionTurnEnded = false; this.currentTurnHasAssistantText = false; + this.pluginCommandTurns.clear(); + this.pluginMcpToolsUsedInTurn.clear(); this.pendingModelBlockedFallback = undefined; this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; @@ -293,9 +311,11 @@ export class SessionEventHandler { // Private handlers // --------------------------------------------------------------------------- - private handleTurnBegin(_event: TurnStartedEvent): void { - void _event; + private handleTurnBegin(event: TurnStartedEvent): void { this.currentTurnHasAssistantText = false; + if (event.origin?.kind === 'plugin_command') { + this.pluginCommandTurns.set(String(event.turnId), event.origin.pluginId); + } this.clearAgentSwarmProgress(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.setStep(0); @@ -348,6 +368,22 @@ export class SessionEventHandler { this.renderPendingModelBlockedFallback(); this.currentTurnHasAssistantText = false; this.goalCompletionTurnEnded = true; + // Plugin usage is reported once the whole turn's output has ended — but a + // cancelled turn cut the output short, so skip the notice there. + const reportPluginUsage = event.reason !== 'cancelled'; + const pluginCommandPluginId = this.pluginCommandTurns.get(String(event.turnId)); + if (pluginCommandPluginId !== undefined) { + this.pluginCommandTurns.delete(String(event.turnId)); + if (reportPluginUsage) { + void this.pluginUpdateNotifier.handlePluginCommandCompleted(pluginCommandPluginId); + } + } + if (reportPluginUsage) { + for (const toolName of this.pluginMcpToolsUsedInTurn) { + void this.pluginUpdateNotifier.handleMcpToolCompleted(toolName); + } + } + this.pluginMcpToolsUsedInTurn.clear(); this.scheduleQueuedGoalPromotion(); } @@ -582,6 +618,11 @@ export class SessionEventHandler { synthetic: event.synthetic, }; const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData); + if (matchedCall !== undefined && isPluginMcpToolName(matchedCall.name)) { + // Buffer plugin MCP usage for the turn; the update notice fires once the + // whole turn's output has ended (see handleTurnEnd). + this.pluginMcpToolsUsedInTurn.add(matchedCall.name); + } this.subAgentEventHandler.handleAgentSwarmToolResult( event.toolCallId, resultData, diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts index d475313ae..5a902db7d 100644 --- a/apps/kimi-code/src/tui/utils/plugin-source-label.ts +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -70,6 +70,20 @@ export function isOfficialPluginSource(source: string): boolean { } } +/** + * Returns true when an installed plugin provably came from a trusted official + * source — a zip download under the official CDN plugin path. Local paths, + * GitHub repos, and third-party URLs do not qualify, even when their manifest + * id matches an official plugin. + */ +export function isOfficialPluginInstall(plugin: PluginSummary): boolean { + return ( + plugin.source === 'zip-url' && + plugin.originalSource !== undefined && + isOfficialPluginSource(plugin.originalSource) + ); +} + function hostFromUrl(raw: string): string | undefined { try { const url = new URL(raw); diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index 83b681f1c..2127726cc 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -18,6 +18,7 @@ import { KIMI_CODE_HOME_ENV, KIMI_CODE_INPUT_HISTORY_DIR_NAME, KIMI_CODE_LOG_DIR_NAME, + KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME, KIMI_CODE_UPDATE_DIR_NAME, @@ -87,6 +88,17 @@ export function getUpdateRolloutLogFile(): string { return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME); } +/** + * Return the plugin update notice state file: `/updates/plugin-notices.json`. + */ +export function getPluginUpdateNoticeStateFile(): string { + return join( + getDataDir(), + KIMI_CODE_UPDATE_DIR_NAME, + KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, + ); +} + /** * Return the banner display state file: `/cache/banner/state.json`. */ diff --git a/apps/kimi-code/src/utils/plugin-update-notice-state.ts b/apps/kimi-code/src/utils/plugin-update-notice-state.ts new file mode 100644 index 000000000..7baebbe77 --- /dev/null +++ b/apps/kimi-code/src/utils/plugin-update-notice-state.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import { getPluginUpdateNoticeStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +/** + * Records, per plugin, the newest marketplace version an update notice was + * already shown for. A plugin is re-notified only when the marketplace + * advertises a version different from the recorded one. + */ +export type PluginUpdateNoticeState = { + version: 1; + /** pluginId -> latest marketplace version already notified. */ + notified: Record; +}; + +const PluginUpdateNoticeStateSchema = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const notified = (value as { notified?: unknown }).notified; + if (typeof notified !== 'object' || notified === null) { + return { ...(value as Record), notified: {} }; + } + + const normalizedNotified: Record = {}; + for (const [key, record] of Object.entries(notified)) { + if (key.length === 0 || typeof record !== 'string' || record.length === 0) continue; + normalizedNotified[key] = record; + } + + return { ...(value as Record), notified: normalizedNotified }; + }, + z + .object({ + version: z.literal(1), + notified: z.record(z.string().min(1), z.string().min(1)), + }) + .strict(), +); + +export function emptyPluginUpdateNoticeState(): PluginUpdateNoticeState { + return { + version: 1, + notified: {}, + }; +} + +export async function readPluginUpdateNoticeState( + filePath: string = getPluginUpdateNoticeStateFile(), +): Promise { + try { + return await readJsonFile( + filePath, + PluginUpdateNoticeStateSchema, + emptyPluginUpdateNoticeState(), + ); + } catch { + return emptyPluginUpdateNoticeState(); + } +} + +export async function writePluginUpdateNoticeState( + value: PluginUpdateNoticeState, + filePath: string = getPluginUpdateNoticeStateFile(), +): Promise { + await writeJsonFile(filePath, PluginUpdateNoticeStateSchema, value); +} 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 4c379820a..38ee3a67e 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 @@ -13,7 +13,7 @@ import { } from '#/tui/components/dialogs/plugins-selector'; import { currentTheme } from '#/tui/theme'; import { darkColors, lightColors } from '#/tui/theme/colors'; -import { isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; +import { isOfficialPluginInstall, isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; const ANSI_SGR = /\u001B\[[0-9;]*m/g; @@ -150,14 +150,51 @@ describe('plugins selector dialogs', () => { })).toBe('third-party'); }); + it('recognizes installed plugins by official provenance', () => { + const base = { + id: 'kimi-datasource', + displayName: 'Kimi Datasource', + enabled: true, + state: 'ok' as const, + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + }; + // Zip installs from the official CDN path. + expect(isOfficialPluginInstall({ + ...base, + source: 'zip-url', + originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + })).toBe(true); + // Same manifest id from a local path, GitHub, a loopback URL, or a + // third-party URL is not the official build. + expect(isOfficialPluginInstall({ ...base, source: 'local-path' })).toBe(false); + expect(isOfficialPluginInstall({ ...base, source: 'github' })).toBe(false); + expect(isOfficialPluginInstall({ + ...base, + source: 'zip-url', + originalSource: 'http://127.0.0.1:58627/kimi-code/plugins/official/kimi-datasource.zip', + })).toBe(false); + expect(isOfficialPluginInstall({ + ...base, + source: 'zip-url', + originalSource: 'https://example.test/kimi-code/plugins/official/kimi-datasource.zip', + })).toBe(false); + }); + it('treats only the official Kimi CDN path as a trusted install source', () => { expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true); // Curated and other Kimi CDN paths are not "official" for the install gate. expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false); expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false); - // Non-Kimi hosts, non-https schemes, local paths, and GitHub sources are unofficial. + // Non-Kimi hosts (loopback included), non-https schemes, local paths, and + // GitHub sources are unofficial. expect(isOfficialPluginSource('https://example.test/kimi-code/plugins/official/x.zip')).toBe(false); expect(isOfficialPluginSource('http://code.kimi.com/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('http://127.0.0.1:58627/kimi-code/plugins/official/x.zip')).toBe(false); expect(isOfficialPluginSource('./plugins/kimi-datasource')).toBe(false); expect(isOfficialPluginSource('/abs/path/to/plugin')).toBe(false); expect(isOfficialPluginSource('github.com/owner/repo')).toBe(false); diff --git a/apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts b/apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts new file mode 100644 index 000000000..ca66af33f --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts @@ -0,0 +1,301 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { + PluginUpdateNotifier, + type PluginUpdateNotifierSession, +} from '#/tui/controllers/plugin-update-notifier'; +import type { PluginMarketplace } from '#/utils/plugin-marketplace'; +import { readPluginUpdateNoticeState } from '#/utils/plugin-update-notice-state'; + +function makePluginSummary(overrides: Partial = {}): PluginSummary { + return { + id: 'kimi-datasource', + displayName: 'Kimi Datasource', + version: '3.3.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'zip-url', + originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + ...overrides, + }; +} + +function makeMarketplaceEntry( + id: string, + displayName: string, + version: string, +): PluginMarketplace['plugins'][number] { + return { + id, + displayName, + source: `https://code.kimi.com/kimi-code/plugins/official/${id}.zip`, + tier: 'official', + version, + }; +} + +function makeMarketplace(version = '3.4.0'): PluginMarketplace { + return { + source: KIMI_CODE_PLUGIN_MARKETPLACE_URL, + plugins: [makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', version)], + }; +} + +interface HarnessOptions { + readonly marketplace?: PluginMarketplace; + readonly installed?: readonly PluginSummary[]; + readonly mcpServers?: readonly string[]; + readonly loadMarketplace?: () => Promise; +} + +function makeHarness(options: HarnessOptions = {}) { + const session: PluginUpdateNotifierSession = { + listMcpServers: vi.fn(async () => + (options.mcpServers ?? ['plugin-kimi-datasource:data']).map((name) => ({ name })), + ), + listPlugins: vi.fn(async () => options.installed ?? [makePluginSummary()]), + }; + const notify = vi.fn(); + const loadMarketplace = vi.fn( + options.loadMarketplace ?? (async () => options.marketplace ?? makeMarketplace()), + ); + return { session, notify, loadMarketplace }; +} + +const DATASOURCE_TOOL = 'mcp__plugin-kimi-datasource_data__call_data_source_tool'; +const EXPECTED_MESSAGE = + 'Update detected: Kimi Datasource 3.4.0 is available. ' + + 'Run /plugins to install the latest version from the Official Marketplace.'; + +describe('PluginUpdateNotifier', () => { + let tempDir: string; + let stateFile: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'plugin-update-notifier-')); + stateFile = join(tempDir, 'plugin-notices.json'); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function makeNotifier(harness: ReturnType) { + return new PluginUpdateNotifier({ + getSession: () => harness.session, + workDir: tempDir, + notify: harness.notify, + loadMarketplace: harness.loadMarketplace, + stateFile, + }); + } + + it('notifies once after a plugin MCP tool completes, then stays silent for that version', async () => { + const harness = makeHarness(); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + + harness.notify.mockClear(); + // Follow-up checks run but hit the persisted "already notified" record + // instead of notifying again. + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.notify).not.toHaveBeenCalled(); + await notifier.handlePluginCommandCompleted('kimi-datasource'); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('ignores non-plugin tool names without touching the session', async () => { + const harness = makeHarness(); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted('Bash'); + await notifier.handleMcpToolCompleted('mcp__github__create_issue'); + + expect(harness.session.listMcpServers).not.toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify when the installed version is up to date', async () => { + const harness = makeHarness({ installed: [makePluginSummary({ version: '3.4.0' })] }); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.session.listPlugins).toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify for plugins absent from the marketplace', async () => { + const harness = makeHarness({ installed: [makePluginSummary({ id: 'local-only' })] }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('local-only'); + // No marketplace entry — the check bails before even listing plugins. + expect(harness.session.listPlugins).not.toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify when the catalog is not the official marketplace', async () => { + const harness = makeHarness({ + marketplace: { + source: 'https://example.test/custom-marketplace.json', + plugins: [makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', '3.4.0')], + }, + }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + // A custom catalog may advertise anything under any id — the check bails + // before comparing versions. + expect(harness.session.listPlugins).not.toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify for a same-id fork installed from a local path', async () => { + const harness = makeHarness({ + installed: [ + makePluginSummary({ source: 'local-path', originalSource: undefined }), + ], + }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + // Provenance is not official, so the marketplace version is irrelevant. + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('resolves plugin tools whose qualified name core truncated before the separator', async () => { + // Server part exactly 50 chars: the 64-char truncation cuts the whole + // `__` separator and tool name, leaving `mcp___`. + const serverName = `plugin-kimi-datasource:${'s'.repeat(27)}`; + const sanitized = `plugin-kimi-datasource_${'s'.repeat(27)}`; + expect(`mcp__${sanitized}`.length).toBe(55); + const truncatedToolName = `mcp__${sanitized}_a1b2c3d4`; + + const harness = makeHarness({ mcpServers: [serverName] }); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted(truncatedToolName); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + }); + + it('notifies after a plugin command turn ends', async () => { + const harness = makeHarness(); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + // Plugin commands resolve the plugin id directly — no MCP server lookup. + expect(harness.session.listMcpServers).not.toHaveBeenCalled(); + }); + + it('reminds again when the marketplace advertises a newer version', async () => { + const first = makeHarness({ marketplace: makeMarketplace('3.4.0') }); + const notifier = makeNotifier(first); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + expect(first.notify).toHaveBeenCalledTimes(1); + + // A new notifier (fresh app run) against the same state file stays silent + // for the already-notified version… + const second = makeHarness({ marketplace: makeMarketplace('3.4.0') }); + const secondNotifier = makeNotifier(second); + await secondNotifier.handlePluginCommandCompleted('kimi-datasource'); + expect(second.notify).not.toHaveBeenCalled(); + + // …but reminds once the marketplace moves to a newer version. + const third = makeHarness({ marketplace: makeMarketplace('3.5.0') }); + const thirdNotifier = makeNotifier(third); + await thirdNotifier.handlePluginCommandCompleted('kimi-datasource'); + expect(third.notify).toHaveBeenCalledWith( + 'Update detected: Kimi Datasource 3.5.0 is available. ' + + 'Run /plugins to install the latest version from the Official Marketplace.', + ); + }); + + it('swallows marketplace failures and retries on the next invocation', async () => { + let attempts = 0; + const harness = makeHarness({ + loadMarketplace: async () => { + attempts += 1; + if (attempts === 1) throw new Error('offline'); + return makeMarketplace(); + }, + }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + expect(harness.loadMarketplace).toHaveBeenCalledTimes(1); + expect(harness.notify).not.toHaveBeenCalled(); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + }); + + it('refreshes the memoized MCP server map when a lookup misses', async () => { + const harness = makeHarness({ mcpServers: [] }); + let servers: readonly string[] = []; + harness.session.listMcpServers = vi.fn(async () => servers.map((name) => ({ name }))); + const notifier = makeNotifier(harness); + + // The plugin's MCP server is not registered yet (the plugin gets + // installed later in the same app run, applied on /reload or /new). + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.session.listMcpServers).toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + + // After the reload the new server shows up; the next completion must + // refresh the memoized map instead of silently staying unresolved. + servers = ['plugin-kimi-datasource:data']; + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + }); + + it('keeps every notified plugin when a turn uses two outdated plugins', async () => { + const harness = makeHarness({ + marketplace: { + source: KIMI_CODE_PLUGIN_MARKETPLACE_URL, + plugins: [ + makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', '3.4.0'), + makeMarketplaceEntry('another-plugin', 'Another Plugin', '2.0.0'), + ], + }, + installed: [ + makePluginSummary(), + makePluginSummary({ + id: 'another-plugin', + displayName: 'Another Plugin', + version: '1.0.0', + }), + ], + }); + const notifier = makeNotifier(harness); + + await Promise.all([ + notifier.handlePluginCommandCompleted('kimi-datasource'), + notifier.handlePluginCommandCompleted('another-plugin'), + ]); + + expect(harness.notify).toHaveBeenCalledTimes(2); + // Both entries must survive in the persisted state — no lost update. + const state = await readPluginUpdateNoticeState(stateFile); + expect(state.notified).toEqual({ + 'kimi-datasource': '3.4.0', + 'another-plugin': '2.0.0', + }); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts new file mode 100644 index 000000000..3220d1b91 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { PluginUpdateNotifier } from '#/tui/controllers/plugin-update-notifier'; +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +const DATASOURCE_TOOL = 'mcp__plugin-kimi-datasource_data__call_data_source_tool'; + +function makeHost() { + const streamingUI = { + setTurnId: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + setStep: vi.fn(), + finalizeTurn: vi.fn(), + getTurnContext: vi.fn(() => ({ turnId: 1, step: 0 })), + registerToolCall: vi.fn(), + completeToolResult: vi.fn(), + setTodoList: vi.fn(), + }; + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'waiting', + model: 'kimi-model', + permissionMode: 'auto', + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: {}, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI, + requireSession: vi.fn(() => ({})), + setAppState: vi.fn(), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + updateActivityPane: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as never, streamingUI }; +} + +function makeNotifier() { + return { + handleMcpToolCompleted: vi.fn(), + handlePluginCommandCompleted: vi.fn(), + }; +} + +function toolCallStarted(name: string) { + return { + type: 'tool.call.started', + sessionId: 's1', + agentId: 'main', + turnId: 1, + toolCallId: 't1', + name, + args: {}, + } as never; +} + +function toolResult() { + return { + type: 'tool.result', + sessionId: 's1', + agentId: 'main', + turnId: 1, + toolCallId: 't1', + output: 'ok', + } as never; +} + +function turnEnded(reason: string, turnId = 1) { + return { + type: 'turn.ended', + sessionId: 's1', + agentId: 'main', + turnId, + reason, + } as never; +} + +function pluginCommandTurnStarted() { + return { + type: 'turn.started', + sessionId: 's1', + agentId: 'main', + turnId: 2, + origin: { + kind: 'plugin_command', + activationId: 'a1', + pluginId: 'kimi-datasource', + commandName: 'setup', + trigger: 'user-slash', + }, + } as never; +} + +const sendQueued = (): void => {}; + +describe('SessionEventHandler plugin update notices', () => { + it('reports plugin MCP usage only when the turn ends', () => { + const { host, streamingUI } = makeHost(); + const notifier = makeNotifier(); + streamingUI.completeToolResult.mockReturnValue({ name: DATASOURCE_TOOL, args: {} }); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(toolCallStarted(DATASOURCE_TOOL), sendQueued); + handler.handleEvent(toolResult(), sendQueued); + // The tool result alone must not trigger the notice mid-turn. + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + + handler.handleEvent(turnEnded('completed'), sendQueued); + expect(notifier.handleMcpToolCompleted).toHaveBeenCalledTimes(1); + expect(notifier.handleMcpToolCompleted).toHaveBeenCalledWith(DATASOURCE_TOOL); + }); + + it('skips the notice for a cancelled turn and clears the buffer', () => { + const { host, streamingUI } = makeHost(); + const notifier = makeNotifier(); + streamingUI.completeToolResult.mockReturnValue({ name: DATASOURCE_TOOL, args: {} }); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(toolCallStarted(DATASOURCE_TOOL), sendQueued); + handler.handleEvent(toolResult(), sendQueued); + handler.handleEvent(turnEnded('cancelled'), sendQueued); + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + + // A later completed turn must not replay the cancelled turn's usage. + handler.handleEvent(turnEnded('completed', 3), sendQueued); + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + }); + + it('ignores non-plugin tools', () => { + const { host, streamingUI } = makeHost(); + const notifier = makeNotifier(); + streamingUI.completeToolResult.mockReturnValue({ name: 'Bash', args: {} }); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(toolCallStarted('Bash'), sendQueued); + handler.handleEvent(toolResult(), sendQueued); + handler.handleEvent(turnEnded('completed'), sendQueued); + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + }); + + it('reports a finished plugin command turn', () => { + const { host } = makeHost(); + const notifier = makeNotifier(); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(pluginCommandTurnStarted(), sendQueued); + handler.handleEvent(turnEnded('completed', 2), sendQueued); + expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledTimes(1); + expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledWith('kimi-datasource'); + }); + + it('skips a cancelled plugin command turn', () => { + const { host } = makeHost(); + const notifier = makeNotifier(); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(pluginCommandTurnStarted(), sendQueued); + handler.handleEvent(turnEnded('cancelled', 2), sendQueued); + expect(notifier.handlePluginCommandCompleted).not.toHaveBeenCalled(); + }); +}); 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 7918d998f..13ee90ceb 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 @@ -4194,6 +4194,75 @@ command = "vim" }); }); + it('shows a quota note after installing a quota-consuming official plugin', async () => { + const session = makeSession({ + installPlugin: vi.fn(async () => ({ + id: 'kimi-datasource', + displayName: 'Kimi Datasource', + version: '3.3.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hasErrors: false, + source: 'zip-url', + originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + })), + }); + const { driver } = await makeDriver(session); + + // Official sources skip the trust prompt, so the install runs immediately. + driver.handleUserInput( + '/plugins install https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + ); + + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); + expect(transcript).toContain('Note: This plugin consumes your quota.'); + }); + }); + + it('does not show the quota note for a same-id fork installed from a local path', async () => { + const session = makeSession({ + installPlugin: vi.fn(async () => ({ + id: 'kimi-datasource', + displayName: 'Kimi Datasource', + version: '3.3.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hasErrors: false, + source: 'local-path', + })), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins install ./plugins/kimi-datasource-fork'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + // The manifest id matches a billed plugin, but a local-path install is + // not the official quota-consuming build. + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Installed Kimi Datasource'); + }); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Note: This plugin consumes your quota.', + ); + }); + it('does not install when the third-party trust prompt is dismissed', async () => { const session = makeSession(); const { driver } = await makeDriver(session); @@ -4260,6 +4329,7 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Installed Demo'); expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); + expect(transcript).not.toContain('Note: This plugin consumes your quota.'); }); // Installing closes the panel so the success notice / reload tip is visible. await vi.waitFor(() => { diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 52b86e7e4..486e0d920 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -33,7 +33,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 **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 a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source. +The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. When a turn that used an outdated plugin (its MCP tool or a `/:` slash command) ends, a one-time notice also points you to `/plugins` for the update; each new marketplace version is announced once. 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 a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source. ### Installing from GitHub @@ -82,7 +82,7 @@ You must first complete OAuth login with a Kimi Code account via `/login`. The p 2. Find **Kimi Datasource** and press `Enter` to install 3. After installation completes, run `/reload` or `/new` to activate the plugin -The current latest version is v3.3.0. The plugin does not update automatically — to upgrade to a newer version, repeat the installation steps above. +Using Kimi Datasource consumes your Kimi Code plan quota; the install result reminds you of this. The current latest version is v3.3.0. The plugin does not update automatically — to upgrade to a newer version, repeat the installation steps above. ### How to use diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index f3fe7499c..cd07290fa 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -33,7 +33,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以 | `/plugins mcp enable ` | 启用 plugin 声明的 MCP server | | `/plugins mcp disable ` | 禁用 plugin 声明的 MCP server | -**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。 +**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。当一个使用了过时 plugin(其 MCP 工具或 `/:` 斜杠命令)的 turn 结束后,也会出现一次性提示,引导你到 `/plugins` 更新;每个新的 marketplace 版本只提醒一次。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。 ### 从 GitHub 安装 @@ -82,7 +82,7 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直 2. 找到 **Kimi Datasource**,按 `Enter` 安装 3. 安装完成后运行 `/reload` 或 `/new` 激活 plugin -当前最新版本为 v3.3.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。 +使用 Kimi Datasource 会消耗你的 Kimi Code 套餐额度,安装结果中会提示这一点。当前最新版本为 v3.3.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。 ### 使用方式