@c4a/context-cli 0.5.29-alpha.2 → 0.5.29-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +71 -34
  2. package/cli.js +43511 -20153
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json.template +1 -1
  5. package/plugin/.codex-plugin/plugin.json.template +8 -5
  6. package/plugin/.cursor-plugin/plugin.json.template +29 -0
  7. package/plugin/README.md +127 -0
  8. package/plugin/README_CN.md +125 -0
  9. package/plugin/assets/icon.svg +3 -0
  10. package/plugin/assets/logo.svg +3 -0
  11. package/plugin/assets/workflow-en.png +0 -0
  12. package/plugin/assets/workflow.png +0 -0
  13. package/plugin/commands/align.md +50 -42
  14. package/plugin/commands/capture.md +27 -11
  15. package/plugin/commands/compile.md +91 -36
  16. package/plugin/commands/context.md +7 -4
  17. package/plugin/commands/drop.md +14 -6
  18. package/plugin/commands/extract.md +3 -3
  19. package/plugin/commands/init.md +48 -9
  20. package/plugin/commands/purge.md +6 -6
  21. package/plugin/commands/query.md +11 -3
  22. package/plugin/commands/status.md +4 -2
  23. package/plugin/skills/skill-align-workflow/SKILL.md +61 -0
  24. package/plugin/skills/skill-align-workflow/references/candidate-resolution.md +60 -0
  25. package/plugin/skills/skill-align-workflow/references/density-profile.md +23 -0
  26. package/plugin/skills/skill-align-workflow/references/gates.md +95 -0
  27. package/plugin/skills/skill-compile-close/SKILL.md +127 -0
  28. package/plugin/skills/skill-compile-draft/SKILL.md +409 -0
  29. package/plugin/skills/skill-context-query/SKILL.md +184 -0
  30. package/plugin/skills/skill-drop/SKILL.md +190 -0
  31. package/plugin/skills/skill-semantic-reconcile/SKILL.md +251 -0
  32. package/scripts/build-plugin.ts +674 -45
  33. package/templates/aspects/code/aspect.yaml +21 -0
  34. package/templates/aspects/code/prompt.md +33 -18
  35. package/templates/aspects/design-system/prompt.md +2 -3
  36. package/templates/aspects/graphql/prompt.md +2 -2
  37. package/templates/aspects/openapi/prompt.md +2 -2
  38. package/plugin/.claude-plugin/plugin.json +0 -16
  39. package/plugin/.codex-plugin/plugin.json +0 -35
  40. package/plugin/commands/capture-aspect.md +0 -17
  41. package/plugin/commands/capture-code.md +0 -25
  42. package/plugin/skills/align-finalize/SKILL.md +0 -137
  43. package/plugin/skills/align-propose/SKILL.md +0 -163
  44. package/plugin/skills/align-scan/SKILL.md +0 -161
  45. package/plugin/skills/align-scan/references/data-model.md +0 -343
  46. package/plugin/skills/align-scan/references/user-question-contract.md +0 -159
  47. package/plugin/skills/compile-close/SKILL.md +0 -122
  48. package/plugin/skills/compile-draft/SKILL.md +0 -252
  49. package/plugin/skills/context-query/SKILL.md +0 -166
  50. package/plugin/skills/drop/SKILL.md +0 -170
  51. package/plugin/skills/semantic-reconcile/SKILL.md +0 -129
@@ -1,69 +1,698 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * build-plugin.ts — materialize plugin manifests from templates by
4
- * substituting `__VERSION__` with the value from package.json.
3
+ * build-plugin.ts — materialize installable plugin trees from the human-edited
4
+ * plugin/ source directory directly into the nested c4a-plugins/ repo
5
+ * (remote: github.com/context4ai/context). c4a-plugins/ is .gitignore'd by
6
+ * c4a; it is its own git repo, the single source of marketplace distribution.
5
7
  *
6
- * Template sources:
8
+ * Source of truth:
9
+ * - plugin/commands/
10
+ * - plugin/skills/
7
11
  * - plugin/.claude-plugin/plugin.json.template
8
12
  * - plugin/.codex-plugin/plugin.json.template
13
+ * - plugin/.cursor-plugin/plugin.json.template
9
14
  *
10
- * Outputs:
11
- * - plugin/.claude-plugin/plugin.json
12
- * - plugin/.codex-plugin/plugin.json
15
+ * Generated outputs under c4a-plugins/:
16
+ * - claude/ — Claude plugin root
17
+ * - codex/ — Codex plugin root
18
+ * - cursor/ — Cursor plugin root
19
+ * - skills/ — Vercel-style standalone skills (no plugin manifest)
13
20
  *
14
- * Run after `bun run build` (or as part of it) so packaged tarballs ship
15
- * version-correct plugin manifests. Idempotent overwrites on every run.
21
+ * Plus three marketplace.json files at c4a-plugins/ root pointing at the
22
+ * respective subdirs, so a single repo serves four install paths.
16
23
  */
17
24
 
18
- import { readFile, writeFile } from "node:fs/promises";
19
- import { dirname, resolve } from "node:path";
25
+ import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
26
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
20
27
  import { fileURLToPath } from "node:url";
21
28
 
22
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
30
  const pkgRoot = resolve(__dirname, "..");
31
+ const repoRoot = resolve(pkgRoot, "../..");
32
+ // Integration tests stage build into a temp dir; allow override so the staged
33
+ // run does not require a real c4a-plugins/ clone next to the staged scripts.
34
+ const PLUGINS_ROOT = process.env.C4A_PLUGINS_ROOT
35
+ ? resolve(process.env.C4A_PLUGINS_ROOT)
36
+ : resolve(repoRoot, "c4a-plugins");
24
37
 
25
- async function main(): Promise<void> {
26
- const pkgRaw = await readFile(resolve(pkgRoot, "package.json"), "utf-8");
27
- const pkg = JSON.parse(pkgRaw) as { version?: string };
28
- const version = pkg.version;
29
- if (!version) {
30
- throw new Error("package.json is missing `version`");
31
- }
32
-
33
- const manifests = [
34
- {
35
- label: "claude",
36
- templatePath: resolve(pkgRoot, "plugin/.claude-plugin/plugin.json.template"),
37
- outputPath: resolve(pkgRoot, "plugin/.claude-plugin/plugin.json"),
38
- },
39
- {
40
- label: "codex",
41
- templatePath: resolve(pkgRoot, "plugin/.codex-plugin/plugin.json.template"),
42
- outputPath: resolve(pkgRoot, "plugin/.codex-plugin/plugin.json"),
43
- },
44
- ] as const;
45
-
46
- for (const manifest of manifests) {
47
- const template = await readFile(manifest.templatePath, "utf-8");
48
- const rendered = template.replace(/__VERSION__/g, version);
49
-
50
- await writeFile(manifest.outputPath, rendered);
51
-
52
- const parsed = JSON.parse(rendered) as { name?: string; version?: string; skills?: string };
53
- if (parsed.name !== "context") {
54
- throw new Error(`${manifest.label} plugin.json name must be "context", got "${parsed.name}"`);
38
+ interface CommandSource {
39
+ slug: string;
40
+ title: string;
41
+ description: string;
42
+ body: string;
43
+ }
44
+
45
+ const INTERNAL_PROCEDURES_DIR = "references/internal-procedures";
46
+
47
+ /**
48
+ * Rewrite `packaged context:skill-*` references in a command body so agents
49
+ * without a packaged-skill registry (Codex / Cursor / Vercel-style) get
50
+ * direct file references they can read instead of an unactionable verb.
51
+ *
52
+ * Claude has a packaged-skill registry and consumes the original phrasing
53
+ * verbatim, so this transformer is only applied to non-Claude builds.
54
+ */
55
+ function rewritePackagedSkillReferences(body: string, pathFor: (skill: string) => string): string {
56
+ let out = body;
57
+
58
+ // Imperative: "Use/Invoke packaged `context:skill-X` [and follow its procedure]" → read the file
59
+ // The optional trailer is consumed because "Read ... and follow it" already covers it.
60
+ out = out.replace(
61
+ /\b(?:Invoke|Use|invoke|use) packaged `context:(skill-[a-z-]+)`(?:\s+and follow its procedure)?/g,
62
+ (_match, skill: string) => `Read \`${pathFor(skill)}\` in full and follow it`,
63
+ );
64
+
65
+ // Imperative: "Feed/Pass <stuff> to packaged `context:skill-X`" pass to procedure file
66
+ // Do not inject "do not reconstruct from memory" here; the surrounding sentence
67
+ // already carries that warning in its own tail and double-stating it reads redundant.
68
+ out = out.replace(
69
+ /\b(Feed|Pass|feed|pass) ([^.`]*?) to packaged `context:(skill-[a-z-]+)`/g,
70
+ (_match, verb: string, mid: string, skill: string) =>
71
+ `${verb} ${mid} to the procedure in \`${pathFor(skill)}\` (read it in full first if you have not already)`,
72
+ );
73
+
74
+ // "run `context:skill-X`" (no "packaged") → consult the procedure file
75
+ out = out.replace(
76
+ /\brun `context:(skill-[a-z-]+)`/g,
77
+ (_match, skill: string) => `consult the procedure in \`${pathFor(skill)}\``,
78
+ );
79
+
80
+ // Anything left is a descriptive bare reference (e.g. "stop before invoking
81
+ // `context:skill-X`"); substitute the file path so the agent can resolve it.
82
+ // The wildcard form `context:skill-*` (with literal asterisk) stays untouched
83
+ // because the [a-z-]+ class does not match `*`.
84
+ out = out.replace(
85
+ /`context:(skill-[a-z-]+)`/g,
86
+ (_match, skill: string) => `\`${pathFor(skill)}\``,
87
+ );
88
+
89
+ return out;
90
+ }
91
+
92
+ function publicEntrySkillPath(skill: string): string {
93
+ return `${INTERNAL_PROCEDURES_DIR}/${skill}.md`;
94
+ }
95
+
96
+ function cursorCommandSkillPath(skill: string): string {
97
+ // Cursor command files live under `commands/`; bundled skills live under
98
+ // sibling `skills/`. Use a plain relative path that any markdown reader can
99
+ // resolve from the command file's location.
100
+ return `../skills/${skill}/SKILL.md`;
101
+ }
102
+
103
+ function cursorCommandFileName(slug: string): string {
104
+ return slug === "context" ? "context.md" : `context-${slug}.md`;
105
+ }
106
+
107
+ function rewriteClaudeSlashCommandsForCursor(body: string): string {
108
+ return body
109
+ .replace(/\/context:\*/g, "/context-*")
110
+ .replace(/\/context:([a-z-]+)/g, (_match, slug: string) =>
111
+ slug === "context" ? "/context" : `/context-${slug}`
112
+ )
113
+ .replace(/^## Your task$/gm, "## Workflow")
114
+ .replace(/\bPackaged skill invoked by\b/g, "Internal procedure invoked by")
115
+ .replace(/\bnot a user slash command\b/g, "not a user command");
116
+ }
117
+
118
+ /**
119
+ * Scan a generated build root for residual packaged-skill references that
120
+ * should have been rewritten in agent-facing entry files (Codex / Vercel
121
+ * public skills, Cursor commands).
122
+ *
123
+ * Skips internal skill bodies — directories named `internal-procedures` (the
124
+ * codex/vercel embed point) and any directory whose name starts with `skill-`
125
+ * (the cursor-build internal skills tree, mirrored from `plugin/skills/`).
126
+ * Cross-skill mentions inside those bodies are descriptive ("the next stage
127
+ * handles X") and stay untouched.
128
+ *
129
+ * Throws on any leak so future commands or rewrite-pattern gaps fail the
130
+ * build instead of silently shipping unactionable verbs.
131
+ */
132
+ async function verifyNoPackagedSkillLeak(root: string, label: string): Promise<void> {
133
+ const offenders: string[] = [];
134
+ const stack: string[] = [root];
135
+ while (stack.length > 0) {
136
+ const current = stack.pop()!;
137
+ const entries = await readdir(current, { withFileTypes: true });
138
+ for (const entry of entries) {
139
+ const path = join(current, entry.name);
140
+ if (entry.isDirectory()) {
141
+ if (entry.name === "internal-procedures" || entry.name.startsWith("skill-")) continue;
142
+ stack.push(path);
143
+ continue;
144
+ }
145
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
146
+ const content = await readFile(path, "utf8");
147
+ const matches = content.match(/`context:skill-[a-z-]+`/g);
148
+ if (matches && matches.length > 0) {
149
+ offenders.push(`${path}: ${[...new Set(matches)].join(", ")}`);
150
+ }
55
151
  }
56
- if (parsed.version !== version) {
57
- throw new Error(`${manifest.label} plugin.json version mismatch: ${parsed.version} vs ${version}`);
152
+ }
153
+ if (offenders.length > 0) {
154
+ throw new Error(
155
+ `${label} build still contains unrewritten packaged skill references in agent-facing entry files — extend rewritePackagedSkillReferences() to cover the new phrasing:\n${offenders.join("\n")}`,
156
+ );
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Rewrite `${CLAUDE_PLUGIN_ROOT}/skills/<name>/...` cross-skill references
162
+ * inside bundled internal procedure copies to plain relative paths that any
163
+ * agent can resolve. The number of `..` segments depends on how deep the
164
+ * file is below its build's "skills siblings root" (the directory under
165
+ * which all `skill-*` siblings live).
166
+ */
167
+ function rewriteClaudePluginRoot(content: string, levelsAboveSiblingsRoot: number): string {
168
+ const upPrefix = "../".repeat(levelsAboveSiblingsRoot);
169
+ return content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}\/skills\//g, upPrefix);
170
+ }
171
+
172
+ function depthOfFileBelowSiblingsRoot(filePath: string, siblingsRoot: string): number {
173
+ const rel = relative(siblingsRoot, dirname(filePath));
174
+ if (rel === "" || rel === ".") return 0;
175
+ return rel.split(sep).filter((seg: string) => seg.length > 0).length;
176
+ }
177
+
178
+ /**
179
+ * Recursively copy a markdown tree, applying `rewriteClaudePluginRoot` to
180
+ * every `.md` file based on its location below `siblingsRoot`. Non-markdown
181
+ * files are copied verbatim. Empty subtrees (no files, no recursive content)
182
+ * are skipped — the destination directory is created only when at least one
183
+ * descendant is actually written, so accidental empty source dirs don't
184
+ * propagate into build outputs.
185
+ */
186
+ async function copyMarkdownTreeRewrittenForPublicEntry(
187
+ src: string,
188
+ dest: string,
189
+ siblingsRoot: string,
190
+ ): Promise<boolean> {
191
+ const entries = await readdir(src, { withFileTypes: true });
192
+ let wroteSomething = false;
193
+ for (const entry of entries) {
194
+ const srcPath = join(src, entry.name);
195
+ const destPath = join(dest, entry.name);
196
+ if (entry.isDirectory()) {
197
+ const subtreeWrote = await copyMarkdownTreeRewrittenForPublicEntry(srcPath, destPath, siblingsRoot);
198
+ if (subtreeWrote) wroteSomething = true;
199
+ continue;
200
+ }
201
+ if (!entry.isFile()) continue;
202
+ if (!wroteSomething) {
203
+ await mkdir(dest, { recursive: true });
58
204
  }
59
- if (manifest.label === "codex" && parsed.skills !== "./skills/") {
60
- throw new Error(`codex plugin.json must point skills to "./skills/", got "${parsed.skills}"`);
205
+ if (entry.name.endsWith(".md")) {
206
+ const content = await readFile(srcPath, "utf8");
207
+ const depth = depthOfFileBelowSiblingsRoot(destPath, siblingsRoot);
208
+ await writeFile(destPath, rewriteClaudePluginRoot(content, depth), "utf8");
209
+ } else {
210
+ await cp(srcPath, destPath);
61
211
  }
212
+ wroteSomething = true;
213
+ }
214
+ return wroteSomething;
215
+ }
62
216
 
63
- process.stdout.write(`${manifest.label} plugin.json generated (name=${parsed.name}, version=${parsed.version})\n`);
217
+ /**
218
+ * Scan a generated build root for residual `${CLAUDE_PLUGIN_ROOT}` tokens.
219
+ * That token is Claude-specific and must not survive into Codex / Cursor /
220
+ * Vercel-style builds — agents on those platforms cannot resolve it.
221
+ */
222
+ async function verifyNoClaudePluginRootLeak(root: string, label: string): Promise<void> {
223
+ const offenders: string[] = [];
224
+ const stack: string[] = [root];
225
+ while (stack.length > 0) {
226
+ const current = stack.pop()!;
227
+ const entries = await readdir(current, { withFileTypes: true });
228
+ for (const entry of entries) {
229
+ const path = join(current, entry.name);
230
+ if (entry.isDirectory()) {
231
+ stack.push(path);
232
+ continue;
233
+ }
234
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
235
+ const content = await readFile(path, "utf8");
236
+ if (content.includes("${CLAUDE_PLUGIN_ROOT}")) {
237
+ offenders.push(path);
238
+ }
239
+ }
240
+ }
241
+ if (offenders.length > 0) {
242
+ throw new Error(
243
+ `${label} build contains \${CLAUDE_PLUGIN_ROOT} tokens — extend rewriteClaudePluginRoot() so non-Claude agents can resolve cross-skill paths:\n${offenders.join("\n")}`,
244
+ );
245
+ }
246
+ }
247
+
248
+ async function rewriteMarkdownFiles(root: string, rewrite: (content: string) => string): Promise<void> {
249
+ const stack = [root];
250
+ while (stack.length > 0) {
251
+ const current = stack.pop()!;
252
+ const entries = await readdir(current, { withFileTypes: true });
253
+ for (const entry of entries) {
254
+ const path = join(current, entry.name);
255
+ if (entry.isDirectory()) {
256
+ stack.push(path);
257
+ continue;
258
+ }
259
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
260
+ const original = await readFile(path, "utf8");
261
+ const next = rewrite(original);
262
+ if (next !== original) await writeFile(path, next, "utf8");
263
+ }
64
264
  }
65
265
  }
66
266
 
267
+
268
+ function generatedNotice(kind: string): string {
269
+ return [
270
+ `# Generated ${kind}`,
271
+ "",
272
+ "This directory is generated by `bun run build:plugin`.",
273
+ "Do not edit files here directly.",
274
+ "Edit `packages/context-cli/plugin/` and rerun the build instead.",
275
+ "",
276
+ ].join("\n");
277
+ }
278
+
279
+ function parseFrontmatter(markdown: string, fileName: string): { frontmatter: string; body: string } {
280
+ const match = markdown.match(/^---\n([\s\S]*?)\n---\n?/);
281
+ if (!match) throw new Error(`${fileName} must start with YAML frontmatter`);
282
+ return {
283
+ frontmatter: match[1] ?? "",
284
+ body: markdown.slice(match[0].length).trimStart(),
285
+ };
286
+ }
287
+
288
+ function quotedFrontmatterValue(frontmatter: string, key: string, fileName: string): string {
289
+ const match = frontmatter.match(new RegExp(`^${key}:\\s*["']([\\s\\S]*?)["']\\s*$`, "m"));
290
+ if (!match?.[1]) throw new Error(`${fileName} must declare quoted ${key}`);
291
+ return match[1];
292
+ }
293
+
294
+ function titleFromSlug(slug: string): string {
295
+ if (slug === "context") return "Router";
296
+ return slug
297
+ .split("-")
298
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
299
+ .join(" ");
300
+ }
301
+
302
+ function skillNameForCommand(slug: string): string {
303
+ return slug === "context" ? "context-router" : `context-${slug}`;
304
+ }
305
+
306
+ function codexSkillNameForCommand(slug: string): string {
307
+ return slug === "context" ? "router" : slug;
308
+ }
309
+
310
+ function claudeCommandForSlug(slug: string): string {
311
+ return slug === "context" ? "/context:context" : `/context:${slug}`;
312
+ }
313
+
314
+ function cursorCommandForSlug(slug: string): string {
315
+ return slug === "context" ? "/context" : `/context-${slug}`;
316
+ }
317
+
318
+ const CURSOR_COMMAND_SUMMARIES: Record<string, string> = {
319
+ align: "Review raw material and confirm the Node tree before compiling knowledge.",
320
+ capture: "Capture documents, source code, notes, inbox files, or refreshed sources into the workspace.",
321
+ compile: "Turn confirmed aligned sources into source-linked knowledge through draft and semantic review.",
322
+ context: "Route a natural-language C4A request to the right Context command.",
323
+ drop: "Plan and apply source retraction with semantic decisions and archive safety.",
324
+ extract: "Preview extraction output for a file without writing workspace state.",
325
+ init: "Choose workspace layout, language, and focus, then create the C4A workspace.",
326
+ purge: "Permanently delete archived dropped-source artifacts after explicit confirmation.",
327
+ query: "Answer from the local knowledge workspace with citations and gap handling.",
328
+ status: "Show workspace state, pending work, cache health, and the next useful command.",
329
+ };
330
+
331
+ function cursorCommandSummary(command: CommandSource): string {
332
+ return CURSOR_COMMAND_SUMMARIES[command.slug] ?? command.description;
333
+ }
334
+
335
+ function stripHtmlComments(markdown: string): string {
336
+ return markdown.replace(/<!--[\s\S]*?-->\n*/g, "");
337
+ }
338
+
339
+ function publicSkillBody(command: CommandSource, publicName: string): string {
340
+ const claudeCommand = claudeCommandForSlug(command.slug);
341
+ const rewrittenBody = rewritePackagedSkillReferences(command.body, publicEntrySkillPath)
342
+ .replace(/^## Your task$/gm, "## Workflow");
343
+ return `---\nname: ${publicName}\ndescription: >\n ${command.description} Equivalent to Claude ${claudeCommand}; use the local \`context\` CLI for workspace writes.\ntools:\n - Bash\n---\n\n# ${command.title}\n\nPublic C4A Context entry for agents that expose skills instead of Claude slash commands.\n\n- Public entry: \`${publicName}\`\n- Claude equivalent: \`${claudeCommand}\`\n- CLI primitive prefix: \`context ...\`\n- Internal procedures live under \`${INTERNAL_PROCEDURES_DIR}/\`; read each referenced file in full before following that step.\n\n---\n\n${rewrittenBody.trimEnd()}\n`;
344
+ }
345
+
346
+ async function readCommands(): Promise<CommandSource[]> {
347
+ const commandsRoot = join(pkgRoot, "plugin/commands");
348
+ const files = (await readdir(commandsRoot)).filter((file) => file.endsWith(".md")).sort();
349
+ const commands: CommandSource[] = [];
350
+ for (const file of files) {
351
+ const raw = await readFile(join(commandsRoot, file), "utf8");
352
+ const { frontmatter, body } = parseFrontmatter(raw, file);
353
+ const slug = basename(file, ".md");
354
+ commands.push({
355
+ slug,
356
+ title: titleFromSlug(slug),
357
+ description: quotedFrontmatterValue(frontmatter, "description", file),
358
+ body,
359
+ });
360
+ }
361
+ return commands;
362
+ }
363
+
364
+ async function renderManifest(input: {
365
+ templatePath: string;
366
+ outputPath: string;
367
+ version: string;
368
+ label: string;
369
+ }): Promise<Record<string, unknown>> {
370
+ const template = await readFile(input.templatePath, "utf8");
371
+ const rendered = template.replace(/__VERSION__/g, input.version);
372
+ await mkdir(dirname(input.outputPath), { recursive: true });
373
+ await writeFile(input.outputPath, rendered, "utf8");
374
+
375
+ const parsed = JSON.parse(rendered) as Record<string, unknown>;
376
+ if (parsed.name !== "context") {
377
+ throw new Error(`${input.label} plugin.json name must be "context", got "${String(parsed.name)}"`);
378
+ }
379
+ if (parsed.version !== input.version) {
380
+ throw new Error(`${input.label} plugin.json version mismatch: ${String(parsed.version)} vs ${input.version}`);
381
+ }
382
+ return parsed;
383
+ }
384
+
385
+ async function resetDir(dir: string): Promise<void> {
386
+ await rm(dir, { recursive: true, force: true });
387
+ await mkdir(dir, { recursive: true });
388
+ }
389
+
390
+ async function copyDir(from: string, to: string): Promise<void> {
391
+ await cp(from, to, { recursive: true });
392
+ }
393
+
394
+ async function copyAssetsIfPresent(outputRoot: string): Promise<void> {
395
+ const assetsRoot = join(pkgRoot, "plugin/assets");
396
+ if (await pathExists(assetsRoot)) {
397
+ await copyDir(assetsRoot, join(outputRoot, "assets"));
398
+ }
399
+ }
400
+
401
+ async function pathExists(path: string): Promise<boolean> {
402
+ try {
403
+ await stat(path);
404
+ return true;
405
+ } catch {
406
+ return false;
407
+ }
408
+ }
409
+
410
+ async function writeGeneratedGuards(root: string, agentFile: "CLAUDE.md" | "AGENTS.md", label: string): Promise<void> {
411
+ await writeFile(join(root, agentFile), generatedNotice(label), "utf8");
412
+ await writeFile(join(root, "README.md"), generatedNotice(label), "utf8");
413
+ await writeFile(join(root, ".generated"), "generated by packages/context-cli/scripts/build-plugin.ts\n", "utf8");
414
+ }
415
+
416
+ async function buildClaude(version: string): Promise<void> {
417
+ const out = join(PLUGINS_ROOT, "claude");
418
+ await resetDir(out);
419
+ await renderManifest({
420
+ label: "claude",
421
+ version,
422
+ templatePath: join(pkgRoot, "plugin/.claude-plugin/plugin.json.template"),
423
+ outputPath: join(out, ".claude-plugin/plugin.json"),
424
+ });
425
+ await copyDir(join(pkgRoot, "plugin/commands"), join(out, "commands"));
426
+ await copyDir(join(pkgRoot, "plugin/skills"), join(out, "skills"));
427
+ await writeGeneratedGuards(out, "CLAUDE.md", "Claude plugin build");
428
+ }
429
+
430
+ async function writePublicSkills(
431
+ outputRoot: string,
432
+ commands: readonly CommandSource[],
433
+ skillName: (slug: string) => string,
434
+ ): Promise<void> {
435
+ const skillsRoot = join(outputRoot, "skills");
436
+ await mkdir(skillsRoot, { recursive: true });
437
+ const internalSkillNames = (await readdir(join(pkgRoot, "plugin/skills")))
438
+ .filter((name) => name.startsWith("skill-"))
439
+ .sort();
440
+ for (const command of commands) {
441
+ const publicName = skillName(command.slug);
442
+ const skillRoot = join(skillsRoot, publicName);
443
+ await mkdir(skillRoot, { recursive: true });
444
+ await writeFile(join(skillRoot, "SKILL.md"), publicSkillBody(command, publicName), "utf8");
445
+ if (command.body.includes("context:skill-")) {
446
+ const referencesRoot = join(skillRoot, INTERNAL_PROCEDURES_DIR);
447
+ await mkdir(referencesRoot, { recursive: true });
448
+ for (const skill of internalSkillNames) {
449
+ const sourceRoot = join(pkgRoot, "plugin/skills", skill);
450
+ // skill-X.md sits directly in the siblings root; depth = 0.
451
+ const skillBody = await readFile(join(sourceRoot, "SKILL.md"), "utf8");
452
+ await writeFile(
453
+ join(referencesRoot, `${skill}.md`),
454
+ rewriteClaudePluginRoot(skillBody, 0),
455
+ "utf8",
456
+ );
457
+ const refs = join(sourceRoot, "references");
458
+ if (await pathExists(refs)) {
459
+ await copyMarkdownTreeRewrittenForPublicEntry(
460
+ refs,
461
+ join(referencesRoot, skill, "references"),
462
+ referencesRoot,
463
+ );
464
+ }
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ async function buildCodex(version: string, commands: readonly CommandSource[]): Promise<void> {
471
+ const out = join(PLUGINS_ROOT, "codex");
472
+ await resetDir(out);
473
+ const manifest = await renderManifest({
474
+ label: "codex",
475
+ version,
476
+ templatePath: join(pkgRoot, "plugin/.codex-plugin/plugin.json.template"),
477
+ outputPath: join(out, ".codex-plugin/plugin.json"),
478
+ });
479
+ if (manifest.skills !== "./skills/") {
480
+ throw new Error(`codex plugin.json must point skills to "./skills/", got "${String(manifest.skills)}"`);
481
+ }
482
+ await copyAssetsIfPresent(out);
483
+ await writePublicSkills(out, commands, codexSkillNameForCommand);
484
+ await writeGeneratedGuards(out, "AGENTS.md", "Codex plugin build");
485
+ await verifyNoPackagedSkillLeak(out, "codex");
486
+ await verifyNoClaudePluginRootLeak(out, "codex");
487
+ }
488
+
489
+ async function buildVercel(commands: readonly CommandSource[]): Promise<void> {
490
+ // Vercel-style standalone skills live directly under c4a-plugins/skills/.
491
+ // No plugin manifest, no per-build README; the top-level c4a-plugins/README.md
492
+ // owns onboarding for this install path.
493
+ const skillsRoot = join(PLUGINS_ROOT, "skills");
494
+ await resetDir(skillsRoot);
495
+ await writePublicSkills(PLUGINS_ROOT, commands, skillNameForCommand);
496
+ await verifyNoPackagedSkillLeak(skillsRoot, "vercel");
497
+ await verifyNoClaudePluginRootLeak(skillsRoot, "vercel");
498
+ }
499
+
500
+ async function writeCursorCommands(outRoot: string, commands: readonly CommandSource[]): Promise<void> {
501
+ const dest = join(outRoot, "commands");
502
+ await mkdir(dest, { recursive: true });
503
+ const sourceRoot = join(pkgRoot, "plugin/commands");
504
+ for (const command of commands) {
505
+ const sourcePath = join(sourceRoot, `${command.slug}.md`);
506
+ const raw = await readFile(sourcePath, "utf8");
507
+ const { frontmatter } = parseFrontmatter(raw, `${command.slug}.md`);
508
+ const rewrittenBody = rewriteClaudeSlashCommandsForCursor(
509
+ rewritePackagedSkillReferences(command.body, cursorCommandSkillPath),
510
+ );
511
+ const body = stripHtmlComments(rewrittenBody).trimStart();
512
+ const file = `---\n${frontmatter}\n---\n\n${cursorCommandSummary(command)}\n\n---\n\n${body.trimEnd()}\n`;
513
+ await writeFile(join(dest, cursorCommandFileName(command.slug)), file, "utf8");
514
+ }
515
+ }
516
+
517
+ async function buildCursor(version: string, commands: readonly CommandSource[]): Promise<void> {
518
+ const out = join(PLUGINS_ROOT, "cursor");
519
+ await resetDir(out);
520
+ const manifest = await renderManifest({
521
+ label: "cursor",
522
+ version,
523
+ templatePath: join(pkgRoot, "plugin/.cursor-plugin/plugin.json.template"),
524
+ outputPath: join(out, ".cursor-plugin/plugin.json"),
525
+ });
526
+ if (manifest.skills !== "./skills/") {
527
+ throw new Error(`cursor plugin.json must point skills to "./skills/", got "${String(manifest.skills)}"`);
528
+ }
529
+ if (manifest.commands !== "./commands/") {
530
+ throw new Error(`cursor plugin.json must point commands to "./commands/", got "${String(manifest.commands)}"`);
531
+ }
532
+ await copyAssetsIfPresent(out);
533
+ await writeCursorCommands(out, commands);
534
+ // Cursor's internal skills tree is the siblings root; depth-aware rewrite
535
+ // turns `${CLAUDE_PLUGIN_ROOT}/skills/<name>/...` into the right number of
536
+ // `..` segments per file location.
537
+ await copyMarkdownTreeRewrittenForPublicEntry(
538
+ join(pkgRoot, "plugin/skills"),
539
+ join(out, "skills"),
540
+ join(out, "skills"),
541
+ );
542
+ await writeGeneratedGuards(out, "AGENTS.md", "Cursor plugin build");
543
+ await writeFile(
544
+ join(out, "README.md"),
545
+ [
546
+ generatedNotice("Cursor plugin build").trimEnd(),
547
+ "",
548
+ "Install shape:",
549
+ "",
550
+ "- Marketplace/GitHub plugin shape: this directory is a plugin root with `.cursor-plugin/plugin.json`, `commands/`, and `skills/`.",
551
+ "- User entries are under `commands/`; Cursor command files are prefixed as `context-*` to avoid global slash-command collisions.",
552
+ "- Local plugin fallback: symlink or copy this directory to `~/.cursor/plugins/local/context/` only when Marketplace import is unavailable.",
553
+ "",
554
+ "Do not copy these files into `.cursor/skills/` as the main install path. Cursor Marketplace installs this plugin root directly.",
555
+ "",
556
+ ].join("\n"),
557
+ "utf8",
558
+ );
559
+ await rewriteMarkdownFiles(out, rewriteClaudeSlashCommandsForCursor);
560
+ await verifyNoPackagedSkillLeak(out, "cursor");
561
+ await verifyNoClaudePluginRootLeak(out, "cursor");
562
+ }
563
+
564
+ async function ensurePluginsRoot(): Promise<void> {
565
+ try {
566
+ const info = await stat(PLUGINS_ROOT);
567
+ if (!info.isDirectory()) {
568
+ throw new Error(`c4a-plugins exists but is not a directory: ${PLUGINS_ROOT}`);
569
+ }
570
+ } catch (err) {
571
+ const code = (err as NodeJS.ErrnoException).code;
572
+ if (code !== "ENOENT") throw err;
573
+ throw new Error(
574
+ `c4a-plugins/ not found at ${PLUGINS_ROOT}. ` +
575
+ `Run: git clone https://github.com/context4ai/context.git c4a-plugins ` +
576
+ `(from the c4a repo root) before building plugins.`
577
+ );
578
+ }
579
+ try {
580
+ await stat(join(PLUGINS_ROOT, ".git"));
581
+ } catch {
582
+ throw new Error(
583
+ `c4a-plugins/ exists but is not a git repo. Expected remote: ` +
584
+ `https://github.com/context4ai/context.git`
585
+ );
586
+ }
587
+ }
588
+
589
+ async function writeMarketplaceManifests(version: string): Promise<void> {
590
+ const claudeMarketplace = {
591
+ name: "context",
592
+ owner: { name: "c4a" },
593
+ plugins: [{
594
+ name: "context",
595
+ source: "./claude",
596
+ description: "Context For AI — local knowledge workspace. Capture docs, extract code structure, compile into an interlinked wiki with source-traced facts.",
597
+ }],
598
+ };
599
+ const cursorMarketplace = {
600
+ name: "context",
601
+ owner: { name: "Context4AI", email: "support@context4ai.dev" },
602
+ metadata: { description: "C4A Context plugin marketplace" },
603
+ plugins: [{
604
+ name: "context",
605
+ source: "./cursor",
606
+ description: "Turn project sources into a local, source-linked knowledge workspace your agent can maintain and query.",
607
+ }],
608
+ };
609
+ const codexMarketplace = {
610
+ name: "context",
611
+ interface: { displayName: "C4A Marketplace" },
612
+ plugins: [{
613
+ name: "context",
614
+ source: { source: "local", path: "./codex" },
615
+ policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
616
+ category: "Productivity",
617
+ }],
618
+ };
619
+ await mkdir(join(PLUGINS_ROOT, ".claude-plugin"), { recursive: true });
620
+ await mkdir(join(PLUGINS_ROOT, ".cursor-plugin"), { recursive: true });
621
+ await mkdir(join(PLUGINS_ROOT, ".agents", "plugins"), { recursive: true });
622
+ await writeFile(
623
+ join(PLUGINS_ROOT, ".claude-plugin", "marketplace.json"),
624
+ `${JSON.stringify(claudeMarketplace, null, 2)}\n`,
625
+ "utf8",
626
+ );
627
+ await writeFile(
628
+ join(PLUGINS_ROOT, ".cursor-plugin", "marketplace.json"),
629
+ `${JSON.stringify(cursorMarketplace, null, 2)}\n`,
630
+ "utf8",
631
+ );
632
+ await writeFile(
633
+ join(PLUGINS_ROOT, ".agents", "plugins", "marketplace.json"),
634
+ `${JSON.stringify(codexMarketplace, null, 2)}\n`,
635
+ "utf8",
636
+ );
637
+ await writeFile(
638
+ join(PLUGINS_ROOT, "VERSION"),
639
+ `${version}\n`,
640
+ "utf8",
641
+ );
642
+ }
643
+
644
+ async function writePluginsTopLevelDocs(): Promise<void> {
645
+ for (const name of ["README.md", "README_CN.md"]) {
646
+ const body = await readFile(join(pkgRoot, "plugin", name), "utf8");
647
+ await writeFile(join(PLUGINS_ROOT, name), body, "utf8");
648
+ }
649
+
650
+ const assetsSrc = join(pkgRoot, "plugin/assets");
651
+ if (await pathExists(assetsSrc)) {
652
+ const assetsDst = join(PLUGINS_ROOT, "assets");
653
+ await rm(assetsDst, { recursive: true, force: true });
654
+ await copyDir(assetsSrc, assetsDst);
655
+ }
656
+
657
+ const gitignorePath = join(PLUGINS_ROOT, ".gitignore");
658
+ if (!(await pathExists(gitignorePath))) {
659
+ await writeFile(
660
+ gitignorePath,
661
+ [
662
+ ".DS_Store",
663
+ "node_modules/",
664
+ ".idea/",
665
+ ".vscode/",
666
+ "",
667
+ ].join("\n"),
668
+ "utf8",
669
+ );
670
+ }
671
+ }
672
+
673
+ async function main(): Promise<void> {
674
+ const pkgRaw = await readFile(resolve(pkgRoot, "package.json"), "utf-8");
675
+ const pkg = JSON.parse(pkgRaw) as { version?: string };
676
+ const version = pkg.version;
677
+ if (!version) throw new Error("package.json is missing `version`");
678
+
679
+ await ensurePluginsRoot();
680
+
681
+ const commands = await readCommands();
682
+ await buildClaude(version);
683
+ await buildCodex(version, commands);
684
+ await buildCursor(version, commands);
685
+ await buildVercel(commands);
686
+ await writeMarketplaceManifests(version);
687
+ await writePluginsTopLevelDocs();
688
+
689
+ process.stdout.write(`c4a-plugins/claude generated (version=${version})\n`);
690
+ process.stdout.write(`c4a-plugins/codex generated (version=${version}, publicSkills=${commands.length})\n`);
691
+ process.stdout.write(`c4a-plugins/cursor generated (version=${version}, publicSkills=${commands.length})\n`);
692
+ process.stdout.write(`c4a-plugins/skills generated (publicSkills=${commands.length})\n`);
693
+ process.stdout.write(`c4a-plugins/{marketplace.json,README.md,README_CN.md,VERSION} written\n`);
694
+ }
695
+
67
696
  main().catch((err) => {
68
697
  process.stderr.write(`build-plugin failed: ${err instanceof Error ? err.message : String(err)}\n`);
69
698
  process.exit(1);