mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-06 15:26:26 +00:00
build(cli): bundle the capability wiring plugins into client releases
Vendor the official kimi-cu plugin (v0.5.4, from the CU team's plugin zip) next to kimi-webbridge under plugins/official, copy both into apps/kimi-code/bundled-plugins at build time, and ship them in the npm package (files) and the native SEA blob (a new bundled-plugins asset set extracted into the native cache at startup, published to the engine via KIMI_CODE_BUNDLED_PLUGINS_DIR). Desktop points the same variable at its extraResources copy. The .gitignore build-output entries are anchored so sources under src/native and test/native stop being silently ignored.
This commit is contained in:
parent
d78c73329d
commit
8240dd32cc
14 changed files with 620 additions and 5 deletions
8
apps/kimi-code/.gitignore
vendored
8
apps/kimi-code/.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
38
apps/kimi-code/scripts/copy-bundled-plugins.mjs
Normal file
38
apps/kimi-code/scripts/copy-bundled-plugins.mjs
Normal file
|
|
@ -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}`);
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
33
apps/kimi-code/scripts/native/bundled-plugins.mjs
Normal file
33
apps/kimi-code/scripts/native/bundled-plugins.mjs
Normal file
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
214
apps/kimi-code/src/native/bundled-plugins.ts
Normal file
214
apps/kimi-code/src/native/bundled-plugins.ts
Normal file
|
|
@ -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<BundledPluginsManifest, 'version' | 'root'> & {
|
||||
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
|
||||
* `<plugin-id>/` 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;
|
||||
}
|
||||
}
|
||||
149
apps/kimi-code/test/native/bundled-plugins.test.ts
Normal file
149
apps/kimi-code/test/native/bundled-plugins.test.ts
Normal file
|
|
@ -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<string, string>): {
|
||||
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<string, Buffer>([
|
||||
['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<string, string> = {
|
||||
'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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
17
plugins/official/kimi-cu/README.md
Normal file
17
plugins/official/kimi-cu/README.md
Normal file
|
|
@ -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`
|
||||
9
plugins/official/kimi-cu/bin/kimi-cu-mcp
Executable file
9
plugins/official/kimi-cu/bin/kimi-cu-mcp
Executable file
|
|
@ -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
|
||||
52
plugins/official/kimi-cu/kimi.plugin.json
Normal file
52
plugins/official/kimi-cu/kimi.plugin.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
59
plugins/official/kimi-cu/skills/kimi-cu/SKILL.md
Normal file
59
plugins/official/kimi-cu/skills/kimi-cu/SKILL.md
Normal file
|
|
@ -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` 即可更新到最新版(经用户同意后也可代为执行)。
|
||||
Loading…
Add table
Add a link
Reference in a new issue