Skip to content

Commit ebf6e81

Browse files
wbxl2000liruifengv
andauthored
feat: add plugin manager and official plugins (#119)
* feat: add plugin manager and official plugins * fix(agent-core): honor plugin capability overrides * fix: restrict plugin zip root detection * Update apps/kimi-code/src/constant/app.ts Co-authored-by: liruifengv <liruifeng1024@gmail.com> Signed-off-by: qer <wbxl2000@outlook.com> --------- Signed-off-by: qer <wbxl2000@outlook.com> Co-authored-by: liruifengv <liruifeng1024@gmail.com>
1 parent 2c7a8cc commit ebf6e81

75 files changed

Lines changed: 7327 additions & 23 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/plugins-v1.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@moonshot-ai/agent-core": minor
3+
"@moonshot-ai/kimi-code-sdk": minor
4+
"@moonshot-ai/kimi-code": minor
5+
---
6+
7+
Add user-global plugin installation, interactive plugin management, plugin-provided skills, and plugin-owned MCP servers.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/agent-core": patch
3+
"@moonshot-ai/kimi-code": patch
4+
---
5+
6+
Restrict plugin zip installs to manifests at the archive root or a single wrapper directory.

‎.gitignore‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ coverage/
1010
.claude
1111
.conductor
1212
.kimi-stash-dir
13-
superpowers/
13+
plugins/cdn/
14+
superpowers

‎.oxlintrc.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150
"node_modules/",
151151
"apps/*/scripts/",
152152
"docs/smoke-archive/",
153+
"plugins/curated/superpowers/",
153154
"*.generated.ts"
154155
]
155156
}

‎apps/kimi-code/package.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@
5353
"produce:native:manifest": "node scripts/native/produce-manifest.mjs",
5454
"release:native:resolve": "node scripts/native/resolve-release.mjs",
5555
"test:native:smoke": "node scripts/native/smoke.mjs",
56-
"dev": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
56+
"dev": "node scripts/dev.mjs",
57+
"dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
58+
"dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
59+
"build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",
5760
"dev:prod": "node dist/main.mjs",
5861
"clean": "rm -rf dist",
5962
"typecheck": "tsc -p tsconfig.json --noEmit",
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
#!/usr/bin/env node
2+
import { createWriteStream } from 'node:fs';
3+
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
4+
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
5+
import { fileURLToPath, pathToFileURL } from 'node:url';
6+
7+
import yazl from 'yazl';
8+
9+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
10+
const REPO_ROOT = resolve(SCRIPT_DIR, '../../..');
11+
const DEFAULT_PLUGINS_ROOT = resolve(REPO_ROOT, 'plugins');
12+
const DEFAULT_OUT_DIR = resolve(DEFAULT_PLUGINS_ROOT, 'cdn');
13+
const SENTINEL = '.kimi-plugin-marketplace-build.json';
14+
const SKIP_DIRS = new Set(['.git', 'node_modules']);
15+
const SKIP_FILES = new Set(['.DS_Store']);
16+
17+
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
18+
if (isMain) {
19+
try {
20+
const options = parseArgs(process.argv.slice(2));
21+
const pluginsRoot = resolve(options.pluginsRoot ?? DEFAULT_PLUGINS_ROOT);
22+
const outDir = resolve(options.outDir ?? DEFAULT_OUT_DIR);
23+
await buildPluginMarketplaceCdn({ pluginsRoot, outDir });
24+
} catch (error) {
25+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
26+
process.exit(1);
27+
}
28+
}
29+
30+
export async function buildPluginMarketplaceCdn({ pluginsRoot, outDir }) {
31+
assertSafeOutputDir(pluginsRoot, outDir);
32+
await prepareOutputDir(outDir);
33+
34+
const marketplacePath = resolveInsideRoot(pluginsRoot, 'marketplace.json');
35+
const raw = await readFile(marketplacePath, 'utf8');
36+
const parsed = JSON.parse(raw);
37+
if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) {
38+
throw new Error('plugins/marketplace.json must contain a "plugins" array.');
39+
}
40+
41+
const archives = [];
42+
const plugins = [];
43+
for (const entry of parsed.plugins) {
44+
if (!isRecord(entry) || typeof entry.source !== 'string') {
45+
plugins.push(entry);
46+
continue;
47+
}
48+
const result = await materializeEntrySource(entry.source, pluginsRoot, outDir);
49+
plugins.push({ ...entry, source: result.source });
50+
if (result.archive !== undefined) archives.push(result.archive);
51+
}
52+
53+
const outputMarketplace = {
54+
...parsed,
55+
plugins,
56+
};
57+
await writeFile(
58+
resolveInsideRoot(outDir, 'marketplace.json'),
59+
JSON.stringify(outputMarketplace, null, 2) + '\n',
60+
'utf8',
61+
);
62+
await writeFile(
63+
resolveInsideRoot(outDir, SENTINEL),
64+
JSON.stringify(
65+
{
66+
generatedBy: 'build-plugin-marketplace-cdn',
67+
generatedAt: new Date().toISOString(),
68+
pluginsRoot,
69+
},
70+
null,
71+
2,
72+
) + '\n',
73+
'utf8',
74+
);
75+
76+
process.stdout.write(`Plugin marketplace CDN artifacts written to ${outDir}\n`);
77+
process.stdout.write(` marketplace.json\n`);
78+
for (const archive of archives) {
79+
process.stdout.write(` ${archive}\n`);
80+
}
81+
}
82+
83+
async function materializeEntrySource(source, pluginsRoot, outDir) {
84+
if (!isLocalRelativeSource(source)) return { source };
85+
86+
const sourcePath = resolveInsideRoot(pluginsRoot, source);
87+
const info = await stat(sourcePath).catch(() => undefined);
88+
if (info === undefined) {
89+
throw new Error(`Marketplace source does not exist: ${source}`);
90+
}
91+
92+
if (info.isDirectory()) {
93+
const zipSource = withZipExtension(source);
94+
const zipRel = stripRelativePrefix(zipSource);
95+
await zipDirectory(sourcePath, resolveInsideRoot(outDir, zipRel));
96+
return { source: zipSource, archive: zipRel };
97+
}
98+
99+
if (info.isFile() && extname(sourcePath) === '.zip') {
100+
const zipRel = stripRelativePrefix(source);
101+
await mkdir(dirname(resolveInsideRoot(outDir, zipRel)), { recursive: true });
102+
await cp(sourcePath, resolveInsideRoot(outDir, zipRel));
103+
return { source, archive: zipRel };
104+
}
105+
106+
throw new Error(`Marketplace source must be a directory or .zip file: ${source}`);
107+
}
108+
109+
async function zipDirectory(sourceRoot, outputFile) {
110+
await mkdir(dirname(outputFile), { recursive: true });
111+
const zipfile = new yazl.ZipFile();
112+
const output = createWriteStream(outputFile);
113+
const done = new Promise((resolveDone, rejectDone) => {
114+
output.on('close', resolveDone);
115+
output.on('error', rejectDone);
116+
zipfile.outputStream.on('error', rejectDone);
117+
});
118+
zipfile.outputStream.pipe(output);
119+
await addDirectoryToZip(zipfile, sourceRoot, basename(sourceRoot));
120+
zipfile.end();
121+
await done;
122+
}
123+
124+
async function addDirectoryToZip(zipfile, root, zipRoot) {
125+
const entries = await readdir(root, { withFileTypes: true });
126+
entries.sort((a, b) => a.name.localeCompare(b.name));
127+
for (const entry of entries) {
128+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
129+
if (entry.isFile() && SKIP_FILES.has(entry.name)) continue;
130+
131+
const absolutePath = resolve(root, entry.name);
132+
const zipPath = `${zipRoot}/${relative(root, absolutePath).replaceAll(sep, '/')}`;
133+
if (entry.isDirectory()) {
134+
await addDirectoryToZip(zipfile, absolutePath, zipPath);
135+
} else if (entry.isFile()) {
136+
zipfile.addFile(absolutePath, zipPath);
137+
}
138+
}
139+
}
140+
141+
async function prepareOutputDir(outDir) {
142+
const existing = await stat(outDir).catch(() => undefined);
143+
if (existing === undefined) {
144+
await mkdir(outDir, { recursive: true });
145+
return;
146+
}
147+
if (!existing.isDirectory()) {
148+
throw new Error(`Output path exists and is not a directory: ${outDir}`);
149+
}
150+
const entries = await readdir(outDir);
151+
if (entries.length > 0 && !entries.includes(SENTINEL)) {
152+
throw new Error(
153+
`Refusing to overwrite non-generated output directory: ${outDir}\n` +
154+
`Choose an empty --out-dir or remove it manually.`,
155+
);
156+
}
157+
await rm(outDir, { recursive: true, force: true });
158+
await mkdir(outDir, { recursive: true });
159+
}
160+
161+
function assertSafeOutputDir(pluginsRoot, outDir) {
162+
if (outDir === pluginsRoot) {
163+
throw new Error('Output directory must not be the plugins root.');
164+
}
165+
if (isWithin(pluginsRoot, outDir)) {
166+
throw new Error('Output directory must not contain the plugins root.');
167+
}
168+
}
169+
170+
function resolveInsideRoot(root, input) {
171+
const resolved = resolve(root, input);
172+
if (!isWithin(resolved, root)) {
173+
throw new Error(`Path escapes root: ${input}`);
174+
}
175+
return resolved;
176+
}
177+
178+
function isWithin(candidate, root) {
179+
const relativePath = relative(root, candidate);
180+
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
181+
}
182+
183+
function isLocalRelativeSource(source) {
184+
const trimmed = source.trim();
185+
return (
186+
trimmed.length > 0 &&
187+
!trimmed.startsWith('http://') &&
188+
!trimmed.startsWith('https://') &&
189+
!trimmed.startsWith('file://') &&
190+
!trimmed.startsWith('/') &&
191+
!trimmed.startsWith('~/') &&
192+
trimmed !== '~'
193+
);
194+
}
195+
196+
function withZipExtension(source) {
197+
const trimmed = source.trim().replace(/\/+$/, '');
198+
return extname(trimmed) === '.zip' ? trimmed : `${trimmed}.zip`;
199+
}
200+
201+
function stripRelativePrefix(source) {
202+
return source.trim().replace(/^\.\//, '');
203+
}
204+
205+
function isRecord(value) {
206+
return typeof value === 'object' && value !== null && !Array.isArray(value);
207+
}
208+
209+
function parseArgs(args) {
210+
const out = {};
211+
for (let i = 0; i < args.length; i++) {
212+
const arg = args[i];
213+
if (arg === '--plugins-root') {
214+
out.pluginsRoot = requiredValue(args, ++i, arg);
215+
continue;
216+
}
217+
if (arg === '--out-dir') {
218+
out.outDir = requiredValue(args, ++i, arg);
219+
continue;
220+
}
221+
if (arg === '-h' || arg === '--help') {
222+
printHelp();
223+
process.exit(0);
224+
}
225+
throw new Error(`Unknown argument: ${arg}`);
226+
}
227+
return out;
228+
}
229+
230+
function requiredValue(args, index, flag) {
231+
const value = args[index];
232+
if (value === undefined || value.startsWith('--')) {
233+
throw new Error(`${flag} requires a value.`);
234+
}
235+
return value;
236+
}
237+
238+
function printHelp() {
239+
process.stdout.write(`Usage: pnpm run build:plugin-marketplace [-- --out-dir <dir>]\n`);
240+
process.stdout.write(`\n`);
241+
process.stdout.write(`Build CDN-ready plugin marketplace artifacts.\n`);
242+
process.stdout.write(`\n`);
243+
process.stdout.write(`Options:\n`);
244+
process.stdout.write(` --plugins-root <dir> Source plugins root. Default: ${DEFAULT_PLUGINS_ROOT}\n`);
245+
process.stdout.write(` --out-dir <dir> Output directory. Default: ${DEFAULT_OUT_DIR}\n`);
246+
}

0 commit comments

Comments
 (0)