diff --git a/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore index 901b7a6d2..8270c8901 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -1,5 +1,5 @@ # Copied from packages/kimi-core at build time -agents/ +/agents/ # Generated at build time by scripts/build-vis-asset.mjs. # Only the ~150KB base64 VALUE file is ignored; the committed `.d.ts` stub @@ -8,4 +8,8 @@ agents/ src/generated/vis-web-asset.ts # Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs -native/ +# (anchored: src/native and test/native hold tracked sources) +/native/ + +# Copied from plugins/official at build time by scripts/copy-bundled-plugins.mjs +/bundled-plugins/ diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index f78eac615..33f1ce846 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -29,6 +29,7 @@ "dist", "dist-web", "native", + "bundled-plugins", "scripts/postinstall.mjs", "scripts/postinstall", "README.md" @@ -50,7 +51,7 @@ "provenance": true }, "scripts": { - "build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs", + "build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs && node scripts/copy-bundled-plugins.mjs", "prebuild": "node scripts/build-vis-asset.mjs", "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", "smoke": "node scripts/smoke.mjs", diff --git a/apps/kimi-code/scripts/copy-bundled-plugins.mjs b/apps/kimi-code/scripts/copy-bundled-plugins.mjs new file mode 100644 index 000000000..ee5fbc7eb --- /dev/null +++ b/apps/kimi-code/scripts/copy-bundled-plugins.mjs @@ -0,0 +1,38 @@ +import { cp, mkdir, rm, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = resolve(appRoot, '../..'); +const source = resolve(repoRoot, 'plugins', 'official'); +const target = resolve(appRoot, 'bundled-plugins'); + +// The built-in capability wiring plugins ship inside the client release (see +// packages/agent-core-v2/src/app/capability/bundledPlugins.ts). Only the two +// capability entries are bundled — other official plugins stay marketplace +// catalog entries. +const PLUGIN_IDS = ['kimi-cu', 'kimi-webbridge']; + +async function assertPluginDir(id) { + const dir = resolve(source, id); + const manifest = resolve(dir, 'kimi.plugin.json'); + try { + const info = await stat(manifest); + if (!info.isFile()) { + throw new Error('not a file'); + } + } catch { + throw new Error(`Bundled plugin manifest was not found at ${manifest}.`); + } + return dir; +} + +await rm(target, { recursive: true, force: true }); +await mkdir(target, { recursive: true }); + +for (const id of PLUGIN_IDS) { + const srcDir = await assertPluginDir(id); + await cp(srcDir, resolve(target, id), { recursive: true }); +} + +console.log(`Copied bundled capability plugins (${PLUGIN_IDS.join(', ')}) to ${target}`); diff --git a/apps/kimi-code/scripts/native/02-sea-blob.mjs b/apps/kimi-code/scripts/native/02-sea-blob.mjs index 434a7861c..ed42f4010 100644 --- a/apps/kimi-code/scripts/native/02-sea-blob.mjs +++ b/apps/kimi-code/scripts/native/02-sea-blob.mjs @@ -6,6 +6,10 @@ import { nativeAssetManifestKey, nativeAssetSummary, } from './assets.mjs'; +import { + bundledPluginsManifestKey, + collectBundledPlugins, +} from './bundled-plugins.mjs'; import { fail, run } from './exec.mjs'; import { appRoot, @@ -33,18 +37,29 @@ async function writeSeaConfig(target) { target, }); const web = await collectWebAssets({ appRoot, target }); + const bundledPlugins = await collectBundledPlugins({ appRoot, target }); const manifestPath = resolve(nativeManifestDir(target), 'manifest.json'); const webManifestPath = resolve(nativeIntermediatesDir(), 'web-assets', target, 'manifest.json'); + const bundledPluginsManifestPath = resolve( + nativeIntermediatesDir(), + 'bundled-plugins', + target, + 'manifest.json', + ); await mkdir(dirname(manifestPath), { recursive: true }); await mkdir(dirname(webManifestPath), { recursive: true }); + await mkdir(dirname(bundledPluginsManifestPath), { recursive: true }); await writeFile(manifestPath, manifestJson); await writeFile(webManifestPath, web.manifestJson); + await writeFile(bundledPluginsManifestPath, bundledPlugins.manifestJson); const seaAssets = { [nativeAssetManifestKey(target)]: manifestPath, [webAssetManifestKey(target)]: webManifestPath, + [bundledPluginsManifestKey(target)]: bundledPluginsManifestPath, ...assets, ...web.assets, + ...bundledPlugins.assets, }; const config = { main: nativeJsBundlePath(), @@ -65,6 +80,9 @@ async function writeSeaConfig(target) { console.log( `Collected web assets for ${web.manifest.target}: ${web.manifest.files.length} files`, ); + console.log( + `Collected bundled plugins for ${bundledPlugins.manifest.target}: ${bundledPlugins.manifest.files.length} files`, + ); } export async function runSeaBlobStep() { diff --git a/apps/kimi-code/scripts/native/bundled-plugins.mjs b/apps/kimi-code/scripts/native/bundled-plugins.mjs new file mode 100644 index 000000000..5cbdd7e52 --- /dev/null +++ b/apps/kimi-code/scripts/native/bundled-plugins.mjs @@ -0,0 +1,33 @@ +import { resolve } from 'node:path'; + +import { + BUNDLED_PLUGINS_MANIFEST_VERSION, + buildBundledPluginsAssetKey, + buildBundledPluginsManifestKey, +} from './manifest.mjs'; +import { collectAssetRoot } from './web-assets.mjs'; + +export { BUNDLED_PLUGINS_MANIFEST_VERSION }; + +const BUNDLED_PLUGINS_DIR = 'bundled-plugins'; + +export function bundledPluginsManifestKey(target) { + return buildBundledPluginsManifestKey(target); +} + +export function bundledPluginsAssetKey(target, relativePath) { + return buildBundledPluginsAssetKey(target, relativePath); +} + +export async function collectBundledPlugins({ appRoot, target }) { + const buildCommand = 'pnpm --filter @moonshot-ai/kimi-code run build'; + return collectAssetRoot({ + appRoot, + target, + root: BUNDLED_PLUGINS_DIR, + requiredFile: 'kimi-webbridge/kimi.plugin.json', + missingMessage: `Bundled capability plugins were not found at ${resolve(appRoot, BUNDLED_PLUGINS_DIR)}. Run \`${buildCommand}\` (or scripts/copy-bundled-plugins.mjs) before building native SEA assets. App root: ${appRoot}`, + assetKey: bundledPluginsAssetKey, + version: BUNDLED_PLUGINS_MANIFEST_VERSION, + }); +} diff --git a/apps/kimi-code/scripts/native/manifest.mjs b/apps/kimi-code/scripts/native/manifest.mjs index 30d5e9da3..66410445f 100644 --- a/apps/kimi-code/scripts/native/manifest.mjs +++ b/apps/kimi-code/scripts/native/manifest.mjs @@ -1,5 +1,6 @@ export const NATIVE_ASSET_MANIFEST_VERSION = 1; export const WEB_ASSET_MANIFEST_VERSION = 1; +export const BUNDLED_PLUGINS_MANIFEST_VERSION = 1; export function buildManifestKey(target) { return `native/${target}/manifest.json`; @@ -20,3 +21,11 @@ export function buildWebManifestKey(target) { export function buildWebAssetKey(target, relativePath) { return `web/${target}/dist-web/${relativePath}`; } + +export function buildBundledPluginsManifestKey(target) { + return `bundled-plugins/${target}/manifest.json`; +} + +export function buildBundledPluginsAssetKey(target, relativePath) { + return `bundled-plugins/${target}/files/${relativePath}`; +} diff --git a/apps/kimi-code/scripts/native/web-assets.mjs b/apps/kimi-code/scripts/native/web-assets.mjs index 8f8a893c5..e4cd57256 100644 --- a/apps/kimi-code/scripts/native/web-assets.mjs +++ b/apps/kimi-code/scripts/native/web-assets.mjs @@ -62,13 +62,14 @@ export function webAssetKey(target, relativePath) { return buildWebAssetKey(target, relativePath); } -async function collectAssetRoot({ +export async function collectAssetRoot({ appRoot, target, root, requiredFile, missingMessage, assetKey, + version = WEB_ASSET_MANIFEST_VERSION, }) { const assetRoot = resolve(appRoot, ...root.split('/')); await assertBuiltAssetRoot({ assetRoot, requiredFile, message: missingMessage }); @@ -91,7 +92,7 @@ async function collectAssetRoot({ } const manifest = { - version: WEB_ASSET_MANIFEST_VERSION, + version, target, root, files: manifestFiles, diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 28ed63a90..d68bbe78b 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -36,6 +36,7 @@ import { runUpdatePreflight } from './cli/update/preflight'; import { createKimiCodeHostIdentity, getVersion } from './cli/version'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; +import { publishNativeBundledPluginsDir } from './native/bundled-plugins'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; @@ -142,6 +143,16 @@ export function main(): void { // invalid proxy URL is reported and ignored rather than aborting startup. installGlobalProxyDispatcher(); installNativeModuleHook(); + // SEA builds embed the capability wiring plugins (kimi-cu / kimi-webbridge) + // as blob assets; extract them into the native cache and point the engine's + // capability domain at them. A failure here must not abort startup — the + // engine's own probes still cover npm/dev, and a missing bundle surfaces as + // a clear capability-install error. + try { + publishNativeBundledPluginsDir(); + } catch (error) { + log.warn('failed to extract bundled capability plugins', { error }); + } if (runNativeAssetSmokeIfRequested()) return; // Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw. diff --git a/apps/kimi-code/src/native/bundled-plugins.ts b/apps/kimi-code/src/native/bundled-plugins.ts new file mode 100644 index 000000000..83b240ae2 --- /dev/null +++ b/apps/kimi-code/src/native/bundled-plugins.ts @@ -0,0 +1,214 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { KIMI_BUILD_INFO } from '#/cli/build-info'; +import { + getNativeCacheBase, + getSeaAssetSource, + type NativeAssetSource, +} from './native-assets'; +import { + BUNDLED_PLUGINS_MANIFEST_VERSION as MANIFEST_VERSION, + buildBundledPluginsManifestKey, +} from '../../scripts/native/manifest.mjs'; + +export const BUNDLED_PLUGINS_MANIFEST_VERSION = MANIFEST_VERSION; + +/** + * The environment variable the engine's capability domain reads to locate the + * bundled wiring plugins. Kept in sync with `BUNDLED_PLUGINS_DIR_ENV` in + * `packages/agent-core-v2/src/app/capability/bundledPlugins.ts` — the app + * cannot import the engine constant (layering), so both sides pin the string. + */ +export const BUNDLED_PLUGINS_DIR_ENV = 'KIMI_CODE_BUNDLED_PLUGINS_DIR'; + +export interface BundledPluginsFile { + readonly assetKey: string; + readonly relativePath: string; + readonly sha256: string; +} + +export interface BundledPluginsManifest { + readonly version: typeof BUNDLED_PLUGINS_MANIFEST_VERSION; + readonly target: string; + readonly root: 'bundled-plugins'; + readonly files: readonly BundledPluginsFile[]; +} + +export type BundledPluginsSource = NativeAssetSource; + +export interface BundledPluginsOptions { + readonly source?: BundledPluginsSource | null; + readonly manifest?: BundledPluginsManifest | null; + readonly cacheBase?: string; + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly homeDir?: string; + readonly version?: string; +} + +type RawBundledPluginsManifest = Omit & { + readonly version: number; + readonly root: string; +}; + +function currentTarget(): string { + return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`; +} + +function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer { + if (Buffer.isBuffer(value)) return value; + if (typeof value === 'string') return Buffer.from(value); + if (ArrayBuffer.isView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + return Buffer.from(value); +} + +function sha256(bytes: Buffer | Uint8Array | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function sanitizeSegment(value: string): string { + const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_'); + return sanitized.length > 0 ? sanitized : 'unknown'; +} + +function readFileSha256(path: string): string | null { + try { + return sha256(readFileSync(path)); + } catch { + return null; + } +} + +function ensureFile(path: string, bytes: Buffer, expectedSha256: string): void { + if (readFileSha256(path) === expectedSha256) return; + + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, bytes, { mode: 0o644 }); + + try { + renameSync(tempPath, path); + return; + } catch { + if (readFileSha256(path) === expectedSha256) { + rmSync(tempPath, { force: true }); + return; + } + } + + try { + rmSync(path, { force: true }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + if (readFileSha256(path) === expectedSha256) return; + throw error; + } +} + +function assertSafeRelativePath(relativePath: string): void { + if ( + relativePath.length === 0 || + relativePath.startsWith('/') || + relativePath.includes('\\') || + relativePath.split('/').includes('..') || + /^[A-Za-z]:/.test(relativePath) + ) { + throw new Error(`Invalid bundled plugin relative path: ${relativePath}`); + } +} + +export function bundledPluginsManifestKey(target: string = currentTarget()): string { + return buildBundledPluginsManifestKey(target); +} + +export function getEmbeddedBundledPluginsManifest( + source: BundledPluginsSource | null = getSeaAssetSource(), + target = currentTarget(), +): BundledPluginsManifest | null { + if (source === null) return null; + const key = bundledPluginsManifestKey(target); + if (!source.getAssetKeys().includes(key)) return null; + const raw = source.getRawAsset(key); + const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawBundledPluginsManifest; + if (manifest.version !== BUNDLED_PLUGINS_MANIFEST_VERSION) { + throw new Error(`Unsupported bundled plugins manifest version: ${manifest.version}`); + } + if (manifest.target !== target) { + throw new Error(`Bundled plugins manifest target mismatch: ${manifest.target} !== ${target}`); + } + if (manifest.root !== 'bundled-plugins') { + throw new Error(`Unsupported bundled plugins root: ${manifest.root}`); + } + return manifest as BundledPluginsManifest; +} + +export function getBundledPluginsCacheRoot( + manifest: BundledPluginsManifest, + options: BundledPluginsOptions = {}, +): string { + const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); + const manifestHash = sha256(JSON.stringify(manifest)); + return join( + getNativeCacheBase({ + cacheBase: options.cacheBase, + env: options.env, + platform: options.platform, + homeDir: options.homeDir, + }), + 'bundled-plugins', + version, + sanitizeSegment(manifest.target), + manifestHash, + manifest.root, + ); +} + +/** + * Extract the embedded bundled capability plugins (kimi-cu / kimi-webbridge + * wiring) into the native asset cache and return the directory that holds one + * `/` subdirectory per plugin. Returns null when this is not a SEA + * build or the blob predates bundled plugins — callers then leave the + * engine's own npm/dev probes to find the plugins. + */ +export function getNativeBundledPluginsDir(options: BundledPluginsOptions = {}): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const manifest = options.manifest ?? getEmbeddedBundledPluginsManifest(source, currentTarget()); + if (manifest === null) return null; + + const cacheRoot = getBundledPluginsCacheRoot(manifest, options); + for (const file of manifest.files) { + assertSafeRelativePath(file.relativePath); + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Bundled plugin checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256); + } + return cacheRoot; +} + +/** + * Publish the extracted bundled-plugins directory to the engine's capability + * domain (which reads `KIMI_CODE_BUNDLED_PLUGINS_DIR`). No-op outside SEA + * builds, where the npm layout / source checkout probes already resolve. + */ +export function publishNativeBundledPluginsDir( + options: BundledPluginsOptions = {}, + env: NodeJS.ProcessEnv = process.env, +): void { + if (env[BUNDLED_PLUGINS_DIR_ENV] !== undefined) return; + const dir = getNativeBundledPluginsDir(options); + if (dir !== null) { + env[BUNDLED_PLUGINS_DIR_ENV] = dir; + } +} diff --git a/apps/kimi-code/test/native/bundled-plugins.test.ts b/apps/kimi-code/test/native/bundled-plugins.test.ts new file mode 100644 index 000000000..f28835225 --- /dev/null +++ b/apps/kimi-code/test/native/bundled-plugins.test.ts @@ -0,0 +1,149 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + BUNDLED_PLUGINS_DIR_ENV, + BUNDLED_PLUGINS_MANIFEST_VERSION, + getBundledPluginsCacheRoot, + getNativeBundledPluginsDir, + publishNativeBundledPluginsDir, + type BundledPluginsManifest, + type BundledPluginsSource, +} from '#/native/bundled-plugins'; + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function fakeBundledPlugins(files: Record): { + manifest: BundledPluginsManifest; + source: BundledPluginsSource; +} { + const manifest: BundledPluginsManifest = { + version: BUNDLED_PLUGINS_MANIFEST_VERSION, + target: 'test-target', + root: 'bundled-plugins', + files: Object.entries(files).map(([relativePath, content]) => ({ + assetKey: `bundled-plugins/test-target/files/${relativePath}`, + relativePath, + sha256: sha256(content), + })), + }; + const assets = new Map([ + ['bundled-plugins/test-target/manifest.json', Buffer.from(JSON.stringify(manifest))], + ...Object.entries(files).map(([relativePath, content]) => [ + `bundled-plugins/test-target/files/${relativePath}`, + Buffer.from(content), + ] as const), + ]); + return { + manifest, + source: { + getAssetKeys: () => [...assets.keys()], + getRawAsset: (assetKey) => { + const asset = assets.get(assetKey); + if (asset === undefined) throw new Error(`missing test asset: ${assetKey}`); + return asset; + }, + }, + }; +} + +const PLUGIN_FILES: Record = { + 'kimi-cu/kimi.plugin.json': '{"name":"kimi-cu"}\n', + 'kimi-cu/bin/kimi-cu-mcp': '#!/bin/sh\n', + 'kimi-webbridge/kimi.plugin.json': '{"name":"kimi-webbridge"}\n', + 'kimi-webbridge/skills/kimi-webbridge/SKILL.md': '# skill\n', +}; + +describe('bundled plugins (native SEA assets)', () => { + it('extracts embedded plugin files into a bundled-plugins cache directory', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-bundled-plugins-runtime-')); + try { + const { manifest, source } = fakeBundledPlugins(PLUGIN_FILES); + + const pluginsDir = getNativeBundledPluginsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(pluginsDir).toBe( + getBundledPluginsCacheRoot(manifest, { cacheBase: dir, version: 'test' }), + ); + for (const [relativePath, content] of Object.entries(PLUGIN_FILES)) { + expect(readFileSync(join(pluginsDir ?? '', ...relativePath.split('/')), 'utf-8')).toBe( + content, + ); + } + expect(existsSync(join(dir, 'bundled-plugins', 'test', 'test-target'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('repairs corrupted extracted files on the next lookup', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-bundled-plugins-repair-')); + try { + const { manifest, source } = fakeBundledPlugins(PLUGIN_FILES); + + const pluginsDir = getNativeBundledPluginsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + const manifestPath = join(pluginsDir ?? '', 'kimi-cu', 'kimi.plugin.json'); + writeFileSync(manifestPath, 'broken'); + + const repairedDir = getNativeBundledPluginsDir({ + cacheBase: dir, + manifest, + source, + version: 'test', + }); + + expect(repairedDir).toBe(pluginsDir); + expect(readFileSync(manifestPath, 'utf-8')).toBe('{"name":"kimi-cu"}\n'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns null when no SEA bundled-plugins source is available', () => { + expect(getNativeBundledPluginsDir({ source: null })).toBeNull(); + }); + + it('publishes the extracted directory through the engine env contract', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-bundled-plugins-publish-')); + try { + const { manifest, source } = fakeBundledPlugins(PLUGIN_FILES); + const env: NodeJS.ProcessEnv = {}; + + publishNativeBundledPluginsDir({ cacheBase: dir, manifest, source, version: 'test' }, env); + + const published = env[BUNDLED_PLUGINS_DIR_ENV]; + expect(published).toBe( + getBundledPluginsCacheRoot(manifest, { cacheBase: dir, version: 'test' }), + ); + // An explicit override (desktop extraResources, tests) always wins. + const overrideEnv: NodeJS.ProcessEnv = { [BUNDLED_PLUGINS_DIR_ENV]: '/opt/custom' }; + publishNativeBundledPluginsDir( + { cacheBase: dir, manifest, source, version: 'test' }, + overrideEnv, + ); + expect(overrideEnv[BUNDLED_PLUGINS_DIR_ENV]).toBe('/opt/custom'); + // No SEA source → nothing published. + const emptyEnv: NodeJS.ProcessEnv = {}; + publishNativeBundledPluginsDir({ source: null }, emptyEnv); + expect(emptyEnv[BUNDLED_PLUGINS_DIR_ENV]).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/plugins/official/kimi-cu/README.md b/plugins/official/kimi-cu/README.md new file mode 100644 index 000000000..2cf896867 --- /dev/null +++ b/plugins/official/kimi-cu/README.md @@ -0,0 +1,17 @@ +# Kimi Computer Use + +让 AI agent 在 macOS 上安静地操作图形界面:读取任意 app 的界面状态(无障碍树 + 截图),并在后台完成点击、输入、滚动、拖拽 —— 全程不移动你的鼠标、不把目标 app 切到前台,你可以继续正常使用电脑。 + +## 前置:安装 KimiCU.app + +本插件依赖本机已安装的 KimiCU.app。一键安装: + +```bash +curl -fsSL https://cdn.kimi.com/kimi-computer-use/latest/setup_macos.sh | bash +``` + +安装后,在 **系统设置 → 隐私与安全性** 中为 KimiCU 开启 **辅助功能** 与 **屏幕录制**。 + +## 工具 + +`list_apps` · `get_app_state` · `click` · `type_text` · `press_key` · `scroll` · `set_value` · `perform_secondary_action` · `select_text` · `drag` diff --git a/plugins/official/kimi-cu/bin/kimi-cu-mcp b/plugins/official/kimi-cu/bin/kimi-cu-mcp new file mode 100755 index 000000000..07bdd1210 --- /dev/null +++ b/plugins/official/kimi-cu/bin/kimi-cu-mcp @@ -0,0 +1,9 @@ +#!/bin/sh +# kimi-code 只允许 stdio 插件的 command 是 PATH 命令或 ./ 相对路径, +# 故经由本 wrapper 转发到系统安装的 KimiCU.app。 +APP="/Applications/KimiCU.app/Contents/MacOS/kimi-cu" +if [ ! -x "$APP" ]; then + echo "KimiCU.app 未安装,安装命令: curl -fsSL https://cdn.kimi.com/kimi-computer-use/latest/setup_macos.sh | bash" >&2 + exit 1 +fi +exec "$APP" mcp diff --git a/plugins/official/kimi-cu/kimi.plugin.json b/plugins/official/kimi-cu/kimi.plugin.json new file mode 100644 index 000000000..14ca8ff21 --- /dev/null +++ b/plugins/official/kimi-cu/kimi.plugin.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://kimi.com/schemas/kimi.plugin.schema.json", + "name": "kimi-cu", + "version": "0.5.4", + "description": "macOS Computer Use:后台读取无障碍树 + 截图,不抢鼠标、不切前台地完成点击 / 输入 / 滚动 / 拖拽。", + "keywords": [ + "computer-use", + "macos", + "accessibility", + "automation", + "screenshot", + "gui" + ], + "author": "Moonshot AI", + "license": "Proprietary", + "skills": "./skills/", + "interface": { + "displayName": "Kimi Computer Use", + "shortDescription": "不抢鼠标、不切前台的 macOS 后台 GUI 操作", + "longDescription": "Kimi Computer Use 让 AI agent 在 macOS 上安静地操作图形界面:通过无障碍(AX)树 + 智能截图读取任意 app 的界面状态,并在后台完成点击、输入、滚动、拖拽、调用元素自带动作等操作 —— 全程不移动用户真实鼠标、不把目标 app 切到前台,用户可以继续正常使用电脑。\n\n前置要求:本插件依赖本机已安装的 KimiCU.app,并需在系统设置中授予「辅助功能」与「屏幕录制」权限。一键安装:\n\ncurl -fsSL https://cdn.kimi.com/kimi-computer-use/latest/setup_macos.sh | bash", + "developerName": "Moonshot AI", + "iconUrl": "https://cdn.kimi.com/kimi-computer-use/assets/icon.png", + "category": "PRODUCTIVITY", + "hostKind": "local", + "platforms": ["macos"], + "mcpOverrides": { + "mac": { + "displayName": "Kimi Computer Use", + "iconUrl": "https://cdn.kimi.com/kimi-computer-use/assets/icon.png" + } + } + }, + "mcpServers": { + "mac": { + "command": "sh", + "args": ["./bin/kimi-cu-mcp"], + "cwd": "./", + "enabledTools": [ + "list_apps", + "get_app_state", + "click", + "type_text", + "press_key", + "scroll", + "set_value", + "perform_secondary_action", + "select_text", + "drag" + ] + } + } +} diff --git a/plugins/official/kimi-cu/skills/kimi-cu/SKILL.md b/plugins/official/kimi-cu/skills/kimi-cu/SKILL.md new file mode 100644 index 000000000..a8f4a3585 --- /dev/null +++ b/plugins/official/kimi-cu/skills/kimi-cu/SKILL.md @@ -0,0 +1,59 @@ +--- +name: kimi-cu +description: | + macOS Computer Use:操作本机桌面 app。当用户要求点击、输入、滚动、拖拽、读取某个 app 的界面内容或截图,说「帮我点一下 X」「在 Y 里输入」「看看 Z app 什么状态」「列一下打开的 app」,或任何需要在 macOS 图形界面上代替用户动手的请求时,使用本 skill。所有操作后台执行,不抢用户鼠标、不切换前台。 +--- + +# kimi-cu — macOS 后台 GUI 操作 + +后台读取无障碍(AX)树 + 截图感知界面,后台注入事件完成操作,用户的鼠标、键盘、前台焦点全程不受影响。各工具的参数细节以工具自身的 schema 为准,本文只讲跨工具的工作流和守则。 + +## 工作流 + +1. `list_apps` 找到目标 app。 +2. `get_app_state` 获取无障碍树 + 截图,这是后续操作的状态来源:树节点的 **index** 供 `click` / `set_value` / `perform_secondary_action` / `select_text` 直接引用;**截图像素坐标**供 `click` / `scroll` / `drag` 使用(工具自动换算到真实窗口)。需要低上下文观察时用 `mode:"image"` 只取截图、`mode:"ax"` 只取 AX 文本;只关心部分元素时加 `ax_filter`。 +3. 执行操作。 +4. 重要操作后再次 `get_app_state` 验证界面达到预期。界面状态以工具返回为准,不要凭空断言成功。 + +读取界面、拿截图、取坐标都必须使用 MCP 的 `get_app_state`。不要通过 shell 跑 `kimi-cu shot` / `app-state` / `windows` 来替代 MCP;这些 CLI 不会刷新 MCP 快照缓存,用它们得到的坐标/index 不能作为后续 MCP 操作依据。 + +界面一旦变化,旧快照的 index 和坐标即失效,必须重新 `get_app_state`;找不到目标元素时,先 `scroll` 让它进入可视区域再重新获取。 + +## 用法偏好 + +- 输入普通表单优先 `set_value`(原子、写后校验;Electron/Web 文本框走无前台后台替换路径:先清空再写入,目标窗口被全覆盖时自动改用 AX 聚焦 + 编辑命令清空 + AX 直写兜底,无需露出窗口、绝不把目标 app 拉前台);聊天框、富文本、Electron/Web 输入区仍优先使用 `type_text`,因为它能显式控制聚焦、清空和发送。`type_text` 可传可选 `index` 或截图坐标 `x/y` 先聚焦目标再输入:内部走真实后台鼠标点击建立渲染层焦点——这是 Electron/Web 输入区能收到键盘输入的前提。**聚焦输入框请用 `type_text` 的 `index`,不要用 `click(index)`**(后者走 AXPress,只设 AX 焦点,渲染层未聚焦,打字会落空)。都不传 `index/x/y` 时,`type_text` 只向当前焦点注入。 +- 列表重排、删除项等优先 `perform_secondary_action` 调用元素自带动作,`drag` 是最后手段。 +- **右键菜单 / 上下文菜单**:优先 `perform_secondary_action(index, action:"AXShowMenu")`——原生 app 与 Electron/Web 通常都可靠。`click(mouse_button:"right")` 是合成真实右键,**只对原生 app 有效,Electron/Web 会忽略**,这类应用别用右键 `click` 弹菜单。 +- **滚动**:原生滚动区/列表优先 `scroll(index, dy)`(按元素 AX 滚动,整页粒度,dy>0 上);普通页面用 `scroll(x, y, dy)` 截图坐标。 + +## 安全守则 + +1. 删除、发送、提交、付款等不可逆操作,执行前向用户复述将做的事并获得确认。 +2. 不要用 AppleScript / cliclick 等手段绕过设计不变量(永不移动真实鼠标、永不切换前台)。 +3. 截图中出现密码、银行等敏感内容时,仅完成用户明确要求的操作,不读取或复述无关信息。 + +## 排障(工具调用失败时) + +KimiCU 的权限由 launchd 后台服务持有并在服务内执行操作,agent 进程不需要也不会有这些权限。因此**不要用 `kimi-cu doctor` 判断权限**——它检查的是调用者进程,从你这里跑必然显示 ❌,不代表服务故障。 + +按顺序排查(Bash): + +```bash +ls /Applications/KimiCU.app/Contents/MacOS/kimi-cu # 1) app 是否存在 +/Applications/KimiCU.app/Contents/MacOS/kimi-cu service-status # 2) 服务是否注册运行 +/Applications/KimiCU.app/Contents/MacOS/kimi-cu xpc-ping # 3) 权限判定以这条为准 +# 正常输出:permissionStatus: accessibility=true screenRecording=true +``` + +- **app 不存在** → 告知用户:KimiCU.app 未安装,请运行以下命令安装(或经用户同意后代为执行): + ```bash + curl -fsSL https://cdn.kimi.com/kimi-computer-use/latest/setup_macos.sh | bash + ``` +- **服务未运行 / xpc-ping 不通** → `/Applications/KimiCU.app/Contents/MacOS/kimi-cu install` +- **权限为 false,或截图全黑/树为空** → `/Applications/KimiCU.app/Contents/MacOS/kimi-cu request-permissions --ax --screen`,并告知用户去「系统设置 → 隐私与安全性」打开 KimiCU 的「辅助功能」「屏幕录制」开关(系统要求必须用户手点),完成后直接重试即可。 + +CLI 仅用于以上排障,正常操作一律走 MCP 工具;尤其不要用 `kimi-cu shot` 做日常截图观察。 + +## 版本更新 + +工具结果末尾若出现「KimiCU 有新版本」提示,请转告用户:在终端运行 `kimi-cu upgrade` 即可更新到最新版(经用户同意后也可代为执行)。