toolfactory 0.1.2 → 0.2.0

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 (98) hide show
  1. package/README.md +24 -1
  2. package/dist/commands.d.ts +1 -1
  3. package/dist/distribution/native.d.ts +50 -0
  4. package/dist/distribution/native.js +414 -0
  5. package/dist/distribution/native.test.d.ts +1 -0
  6. package/dist/distribution/native.test.js +127 -0
  7. package/dist/distribution/system.d.ts +57 -0
  8. package/dist/distribution/system.js +637 -0
  9. package/dist/distribution/system.test.d.ts +1 -0
  10. package/dist/distribution/system.test.js +116 -0
  11. package/dist/e2e.test.js +7 -1
  12. package/dist/hosts/openclaw.js +6 -2
  13. package/dist/hosts/vscode.d.ts +1 -0
  14. package/dist/hosts/vscode.js +67 -0
  15. package/dist/model.d.ts +62 -2
  16. package/dist/model.js +9 -0
  17. package/dist/ops.d.ts +52 -0
  18. package/dist/project/apply.d.ts +1 -1
  19. package/dist/project/apply.js +194 -17
  20. package/dist/project/apply.test.js +103 -0
  21. package/dist/project/gate.d.ts +17 -2
  22. package/dist/project/gate.js +68 -3
  23. package/dist/project/history.d.ts +29 -1
  24. package/dist/project/lock.d.ts +5 -0
  25. package/dist/project/lock.js +9 -0
  26. package/dist/surfaces/agent-plugins.js +5 -4
  27. package/dist/surfaces/agents.js +21 -3
  28. package/dist/surfaces/agents.test.js +12 -0
  29. package/dist/surfaces/claude.js +8 -3
  30. package/dist/surfaces/codex.js +8 -3
  31. package/dist/surfaces/codex.test.js +8 -8
  32. package/dist/surfaces/cursor.js +2 -2
  33. package/dist/surfaces/distribution.test.js +3 -3
  34. package/dist/surfaces/dsh.js +25 -6
  35. package/dist/surfaces/dsh.test.js +8 -3
  36. package/dist/surfaces/gemini.js +2 -2
  37. package/dist/surfaces/gemini.test.js +4 -4
  38. package/dist/surfaces/hermes-native.js +38 -9
  39. package/dist/surfaces/hermes-native.test.js +32 -11
  40. package/dist/surfaces/mcp-registry.js +43 -5
  41. package/dist/surfaces/mcp-registry.test.js +18 -0
  42. package/dist/surfaces/openclaw-native.js +10 -8
  43. package/dist/surfaces/openclaw-native.test.js +30 -0
  44. package/dist/surfaces/readme.d.ts +16 -0
  45. package/dist/surfaces/readme.js +7 -0
  46. package/dist/surfaces/registry.js +6 -0
  47. package/dist/surfaces/vscode-extension.d.ts +14 -0
  48. package/dist/surfaces/vscode-extension.js +312 -0
  49. package/dist/surfaces/vscode-extension.test.d.ts +1 -0
  50. package/dist/surfaces/vscode-extension.test.js +81 -0
  51. package/dist/surfaces/vscode-scaffold.d.ts +43 -0
  52. package/dist/surfaces/vscode-scaffold.js +43 -0
  53. package/dist/surfaces/workflows.js +49 -10
  54. package/dist/surfaces/workflows.test.js +3 -2
  55. package/dist/toolfactory/cli.js +1 -1
  56. package/dist/toolfactory/mcp.js +1 -1
  57. package/package.json +2 -1
  58. package/src/distribution/native.test.ts +161 -0
  59. package/src/distribution/native.ts +460 -0
  60. package/src/distribution/system.test.ts +136 -0
  61. package/src/distribution/system.ts +688 -0
  62. package/src/e2e.test.ts +7 -1
  63. package/src/hosts/openclaw.ts +4 -2
  64. package/src/hosts/vscode.ts +79 -0
  65. package/src/model.ts +15 -1
  66. package/src/project/apply.test.ts +120 -0
  67. package/src/project/apply.ts +257 -15
  68. package/src/project/gate.ts +96 -4
  69. package/src/project/lock.ts +12 -0
  70. package/src/surfaces/agent-plugins.ts +5 -4
  71. package/src/surfaces/agents.test.ts +16 -0
  72. package/src/surfaces/agents.ts +31 -3
  73. package/src/surfaces/claude.ts +7 -3
  74. package/src/surfaces/codex.test.ts +7 -7
  75. package/src/surfaces/codex.ts +8 -3
  76. package/src/surfaces/cursor.ts +2 -10
  77. package/src/surfaces/distribution.test.ts +2 -2
  78. package/src/surfaces/dsh.test.ts +8 -2
  79. package/src/surfaces/dsh.ts +26 -6
  80. package/src/surfaces/gemini.test.ts +3 -3
  81. package/src/surfaces/gemini.ts +1 -2
  82. package/src/surfaces/hermes-native.test.ts +39 -10
  83. package/src/surfaces/hermes-native.ts +41 -11
  84. package/src/surfaces/mcp-registry.test.ts +20 -0
  85. package/src/surfaces/mcp-registry.ts +46 -6
  86. package/src/surfaces/openclaw-native.test.ts +37 -0
  87. package/src/surfaces/openclaw-native.ts +49 -47
  88. package/src/surfaces/readme.ts +10 -0
  89. package/src/surfaces/registry.ts +6 -0
  90. package/src/surfaces/vscode-extension.test.ts +89 -0
  91. package/src/surfaces/vscode-extension.ts +345 -0
  92. package/src/surfaces/vscode-scaffold.ts +55 -0
  93. package/src/surfaces/workflows.test.ts +3 -4
  94. package/src/surfaces/workflows.ts +49 -10
  95. package/src/toolfactory/cli.ts +1 -1
  96. package/src/toolfactory/mcp.ts +1 -1
  97. package/web/dist/assets/{index-Spapkkvh.js → index-CVm6m4s0.js} +1 -1
  98. package/web/dist/index.html +1 -1
@@ -9,6 +9,18 @@
9
9
  * A step here is a command and nothing else. GitHub Actions `uses:` steps are runner provisioning
10
10
  * (checkout, toolchains, caches) and stay in `src/surfaces/workflows.ts`, which wraps this list.
11
11
  */
12
+
13
+ import {
14
+ type NativePackageId,
15
+ nativePackageSteps,
16
+ nativeRegistryRows,
17
+ nativeToolchainSteps,
18
+ } from "../distribution/native.js";
19
+ import {
20
+ type SystemPackageId,
21
+ systemPackageSteps,
22
+ systemRegistryRows,
23
+ } from "../distribution/system.js";
12
24
  import { githubSlug } from "../hosts/github.js";
13
25
  import { githubOwner, projectName } from "../identity/name.js";
14
26
  import { isInstructionOnly, type PackageManager, type Project, type SurfaceId } from "../model.js";
@@ -28,6 +40,14 @@ import {
28
40
  npmName,
29
41
  pypiName,
30
42
  } from "../surfaces/shared.js";
43
+ import {
44
+ extensionName,
45
+ OVSX_VERSION,
46
+ publisher,
47
+ VSCE_VERSION,
48
+ HOST_DIR as VSCODE_HOST_DIR,
49
+ vsixName,
50
+ } from "../surfaces/vscode-extension.js";
31
51
 
32
52
  export interface GateStep {
33
53
  /** Step label: the CI step name, and the banner the shell script echoes. */
@@ -140,7 +160,11 @@ export function gateSteps(project: Project): GateStep[] {
140
160
  when: "ci",
141
161
  });
142
162
  }
143
- if (has(project, "web") || has(project, "browser-extension")) {
163
+ if (
164
+ has(project, "web") ||
165
+ has(project, "browser-extension") ||
166
+ has(project, "vscode-extension")
167
+ ) {
144
168
  // The web smoke and the browser extension's own Playwright smoke both drive Chromium;
145
169
  // their own npm installs fetch the browser, the runner needs its system libraries.
146
170
  steps.push({
@@ -149,6 +173,7 @@ export function gateSteps(project: Project): GateStep[] {
149
173
  when: "ci",
150
174
  });
151
175
  }
176
+ steps.push(...nativeToolchainSteps(project));
152
177
  steps.push({
153
178
  name: "toolfactory validate",
154
179
  run: `${cli} validate`,
@@ -160,6 +185,7 @@ export function gateSteps(project: Project): GateStep[] {
160
185
  name: "author tests",
161
186
  run: typescript ? pm.test : "uv run --with pytest pytest -q",
162
187
  });
188
+ if (typescript) steps.push({ name: "author integration tests", run: pm.run("test:integration") });
163
189
  if (openclaw) {
164
190
  // T3, credential-free: one real OpenClaw agent turn against a scripted model. The suite only
165
191
  // exists when `tool.json` `tests.examples` names an operation the plugin carries, which is
@@ -220,13 +246,16 @@ const LEGAL_BUNDLE_PATHS = [
220
246
  * the release workflow's `package` job, which uploads the directory as one artifact. Publishing
221
247
  * to a registry stays a CI concern; producing the artifacts never is.
222
248
  */
223
- export function packageSteps(project: Project): GateStep[] {
249
+ export function packageSteps(project: Project, options: { system?: boolean } = {}): GateStep[] {
224
250
  const cli = toolfactoryCli(project);
225
251
  const name = project.identity.name;
226
252
  const steps: GateStep[] = [
227
253
  { name: "release directory", run: `rm -rf ${RELEASE_DIR} && mkdir -p ${RELEASE_DIR}` },
228
254
  ...bootstrapSteps(project),
229
255
  outputsStep(project),
256
+ ...nativeToolchainSteps(project),
257
+ ...nativePackageSteps(project),
258
+ ...systemPackageSteps(project, { include: options.system !== false }),
230
259
  ];
231
260
  if (has(project, "web")) {
232
261
  steps.push({
@@ -329,6 +358,12 @@ export function packageSteps(project: Project): GateStep[] {
329
358
  });
330
359
  }
331
360
  // COVERAGE.md is tracked; coverage.json is a build output that need not be, so recompute it.
361
+ if (has(project, "vscode-extension")) {
362
+ steps.push({
363
+ name: "VS Code extension VSIX",
364
+ run: `npm --prefix ${VSCODE_HOST_DIR} install && npm --prefix ${VSCODE_HOST_DIR} run vsix && cp ${VSCODE_HOST_DIR}/extension.vsix ${RELEASE_DIR}/${vsixName(project)}`,
365
+ });
366
+ }
332
367
  steps.push({
333
368
  name: "coverage report",
334
369
  run: `cp COVERAGE.md ${RELEASE_DIR}/ && ${cli} coverage > ${RELEASE_DIR}/coverage.json`,
@@ -355,6 +390,8 @@ export function openclawTarball(project: Project): string {
355
390
  export interface Registry {
356
391
  /** Stable id: the release job suffix, the `secrets status` row, the unpublish step name. */
357
392
  id:
393
+ | NativePackageId
394
+ | SystemPackageId
358
395
  | "npm"
359
396
  | "pypi"
360
397
  | "mcp-registry"
@@ -365,7 +402,9 @@ export interface Registry {
365
402
  | "chrome"
366
403
  | "firefox"
367
404
  | "edge"
368
- | "safari";
405
+ | "safari"
406
+ | "vscode-marketplace"
407
+ | "open-vsx";
369
408
  /** The surfaces whose selection publishes here; all must be selected. */
370
409
  surfaces: SurfaceId[];
371
410
  /** Environment names the publish leg consumes (repository scope); [] for OIDC/GITHUB_TOKEN legs. */
@@ -447,6 +486,22 @@ const ASC_SECRETS = ["ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_PRIVATE_KEY"];
447
486
  export const RELEASE_SECRET_NAMES = [
448
487
  "NPM_TOKEN",
449
488
  "CLAWHUB_TOKEN",
489
+ "VSCE_PAT",
490
+ "OVSX_PAT",
491
+ "CARGO_REGISTRY_TOKEN",
492
+ "NUGET_API_KEY",
493
+ "MAVEN_CENTRAL_USERNAME",
494
+ "MAVEN_CENTRAL_PASSWORD",
495
+ "MAVEN_GPG_PRIVATE_KEY",
496
+ "MAVEN_GPG_PASSPHRASE",
497
+ "RUBYGEMS_API_KEY",
498
+ "HOMEBREW_TAP_TOKEN",
499
+ "SCOOP_BUCKET_TOKEN",
500
+ "CHOCOLATEY_API_KEY",
501
+ "APT_GPG_PRIVATE_KEY",
502
+ "APT_GPG_KEY_ID",
503
+ "COPR_LOGIN",
504
+ "COPR_TOKEN",
450
505
  ...CHROME_SECRETS,
451
506
  ...FIREFOX_SECRETS,
452
507
  ...EDGE_SECRETS,
@@ -474,6 +529,29 @@ export function registries(project: Project): Registry[] {
474
529
  const wxtSubmit = `npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt submit --dry-run`;
475
530
 
476
531
  const rows: Registry[] = [
532
+ ...nativeRegistryRows(project),
533
+ ...systemRegistryRows(project),
534
+ {
535
+ id: "vscode-marketplace",
536
+ surfaces: ["vscode-extension"],
537
+ secrets: ["VSCE_PAT"],
538
+ url: "https://marketplace.visualstudio.com/manage",
539
+ probe: `npx --yes @vscode/vsce@${VSCE_VERSION} verify-pat ${publisher(project)}`,
540
+ exists: `npx --yes @vscode/vsce@${VSCE_VERSION} show ${publisher(project)}.${extensionName(project)} --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.exit(JSON.parse(s).versions.some(v=>v.version==="${version}")?0:1))'`,
541
+ gate: allSet(["VSCE_PAT"]),
542
+ retractUrl: "https://marketplace.visualstudio.com/manage",
543
+ retractHard: `npx --yes @vscode/vsce@${VSCE_VERSION} unpublish ${publisher(project)}.${extensionName(project)} --force`,
544
+ },
545
+ {
546
+ id: "open-vsx",
547
+ surfaces: ["vscode-extension"],
548
+ secrets: ["OVSX_PAT"],
549
+ url: "https://open-vsx.org/user-settings/tokens",
550
+ probe: `npx --yes ovsx@${OVSX_VERSION} verify-pat ${publisher(project)}`,
551
+ exists: `curl -fsS -o /dev/null https://open-vsx.org/api/${publisher(project)}/${extensionName(project)}/${version}`,
552
+ gate: allSet(["OVSX_PAT"]),
553
+ retractUrl: "https://open-vsx.org/user-settings/extensions",
554
+ },
477
555
  {
478
556
  id: "npm",
479
557
  surfaces: ["npm"],
@@ -627,7 +705,9 @@ export function registries(project: Project): Registry[] {
627
705
  const selected = rows.filter(
628
706
  (row) =>
629
707
  row.surfaces.every((surface) => has(project, surface)) &&
630
- (row.id !== "safari" || project.tool.browserExtension?.safari === true),
708
+ (row.id !== "safari" || project.tool.browserExtension?.safari === true) &&
709
+ (row.id !== "vscode-marketplace" || project.tool.vscode?.marketplace !== false) &&
710
+ (row.id !== "open-vsx" || project.tool.vscode?.openvsx !== false),
631
711
  );
632
712
  const mcpRegistry = selected.find((row) => row.id === "mcp-registry");
633
713
  if (mcpRegistry) {
@@ -638,6 +718,14 @@ export function registries(project: Project): Registry[] {
638
718
  ? packages.map((row) => `[ "$${gateVariable(row)}" = true ]`).join(" && ")
639
719
  : "true";
640
720
  }
721
+ const runtime = selected.find(
722
+ (row) => row.id === (project.tool.binding === "python" ? "pypi" : "npm"),
723
+ );
724
+ for (const row of selected.filter(
725
+ (row) => row.id === "vscode-marketplace" || row.id === "open-vsx",
726
+ )) {
727
+ if (runtime) row.gate = `(${row.gate}) && { ${runtime.exists} || { ${runtime.gate}; }; }`;
728
+ }
641
729
  return selected;
642
730
  }
643
731
 
@@ -664,6 +752,10 @@ export function manualSteps(project: Project): string[] {
664
752
  const has_ = (id: Registry["id"]) => rows.some((row) => row.id === id);
665
753
  const slug = githubSlug(project.identity.repository);
666
754
  const steps: string[] = [];
755
+ if (has_("vscode-marketplace") || has_("open-vsx"))
756
+ steps.push(
757
+ "VS Code: register the publisher in Visual Studio Marketplace; create the Open VSX namespace and accept its publisher agreement before setting VSCE_PAT / OVSX_PAT. tool.vscode.publisher selects that identity.",
758
+ );
667
759
  if (has_("oci")) {
668
760
  steps.push(
669
761
  `ghcr.io: the first push publishes a private image whatever the repository's visibility, and GitHub has no visibility API — flip it once at ${rows.find((row) => row.id === "oci")?.url}.`,
@@ -23,6 +23,18 @@ export const LockSchema = z.object({
23
23
  * patch — or deselecting the surface that added it — uninstalls it instead of stranding it.
24
24
  */
25
25
  keys: z.array(z.string()).optional(),
26
+ /** A keyed array's inverse: the entries Toolfactory owns at each dotted path. */
27
+ keyedArrays: z
28
+ .record(
29
+ z.string(),
30
+ z.object({
31
+ id: z.string(),
32
+ values: z.array(z.string()),
33
+ /** The last projected entry shape lets a later plan remove only stale generated leaves. */
34
+ entries: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
35
+ }),
36
+ )
37
+ .optional(),
26
38
  /**
27
39
  * A region file's inverse, exactly as `keys` is a merge file's: the marker pairs its
28
40
  * projector wrote. Regions the current plan no longer writes are emptied before the current
@@ -4,7 +4,7 @@
4
4
  * Validation is Ajv against the cached official schemas; the spec forbids fetching at load.
5
5
  */
6
6
  import type { Project, Surface } from "../model.js";
7
- import { json, kernelLaunch, mcpVerdict } from "./shared.js";
7
+ import { kernelLaunch, mcpVerdict } from "./shared.js";
8
8
 
9
9
  export const MCP_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
10
10
  export const PLUGIN_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
@@ -24,12 +24,13 @@ export const surface: Surface = {
24
24
  const launch = kernelLaunch(project, "${PLUGIN_ROOT}");
25
25
  return [
26
26
  {
27
- kind: "file",
27
+ kind: "merge",
28
28
  path: "mcp.json",
29
- content: json({
29
+ format: "json",
30
+ patch: {
30
31
  $schema: MCP_SCHEMA_ID,
31
32
  mcpServers: { [project.identity.name]: { type: "stdio", ...launch } },
32
- }),
33
+ },
33
34
  },
34
35
  ];
35
36
  },
@@ -129,6 +129,22 @@ describe("agents", () => {
129
129
  expect(setup).toContain("npm install --no-audit --no-fund");
130
130
  });
131
131
 
132
+ it("installs uv before Python projects run the vendored setup workflow", () => {
133
+ const workflow = planned(
134
+ project(["cli"], { binding: "python" }),
135
+ ".github/workflows/agent-config.yml",
136
+ );
137
+ if (workflow.kind !== "file") throw new Error("expected an agent-config workflow file");
138
+ const install = "uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0";
139
+ expect(workflow.content).toContain(install);
140
+ expect(workflow.content.indexOf(install)).toBeLessThan(
141
+ workflow.content.indexOf("run: bash .agents/setup --all"),
142
+ );
143
+ const typescript = planned(project(["cli"]), ".github/workflows/agent-config.yml");
144
+ if (typescript.kind !== "file") throw new Error("expected an agent-config workflow file");
145
+ expect(typescript.content).toBe(TEMPLATE_FILES[".github/workflows/agent-config.yml"]);
146
+ });
147
+
132
148
  it("owns the agent adapters, the binding's ignores and the build outputs in .gitignore", () => {
133
149
  const bare = region(project(["cli"]), IGNORE_PATH);
134
150
  const agentBlocks = (TEMPLATE_FILES[IGNORE_PATH] ?? "").split("# ─── Secrets")[0] ?? "";
@@ -198,7 +198,8 @@ function layoutSection(project: Project): string[] {
198
198
  has(project, "openclaw-native") ||
199
199
  has(project, "hermes-native") ||
200
200
  has(project, "dsh") ||
201
- has(project, "browser-extension")
201
+ has(project, "browser-extension") ||
202
+ has(project, "vscode-extension")
202
203
  ) {
203
204
  lines.push(
204
205
  "- `hosts/<id>/` — the host-native escape hatch for a selected host; nothing else creates it.",
@@ -281,6 +282,15 @@ function installSection(project: Project): string[] {
281
282
  " KEY/PASSWORD/SECRET/TOKEN names before spawning an MCP server.",
282
283
  );
283
284
  }
285
+ if (has(project, "vscode-extension")) {
286
+ lines.push(
287
+ "- **VS Code extension**: `npm --prefix hosts/vscode install`, then open `hosts/vscode` in VS Code",
288
+ " and press F5. Development and `npm --prefix hosts/vscode test` use the checkout's kernel.",
289
+ " `npm --prefix hosts/vscode run vsix` packages it; install with `code --install-extension hosts/vscode/extension.vsix`.",
290
+ " Use Developer: Reload Window after rebuilding an installed extension. Native activation, contributions",
291
+ " and every VS Code API are available in your own files; include your license in the extension directory.",
292
+ );
293
+ }
284
294
  if (has(project, "browser-extension")) {
285
295
  const { command, args } = getBinding(project.tool.binding).cliCommand(project);
286
296
  lines.push(
@@ -299,7 +309,8 @@ function installSection(project: Project): string[] {
299
309
  !openclaw &&
300
310
  !hermes &&
301
311
  !has(project, "dsh") &&
302
- !has(project, "browser-extension")
312
+ !has(project, "browser-extension") &&
313
+ !has(project, "vscode-extension")
303
314
  ) {
304
315
  lines.push(
305
316
  "_No host-native surface (`openclaw-native`, `hermes-native`, `dsh`, `browser-extension`) or",
@@ -688,6 +699,22 @@ function devcontainer(project: Project): string {
688
699
  return `${JSON.stringify({ ...document, features }, null, 2)}\n`;
689
700
  }
690
701
 
702
+ /** The vendor-owned workflow gains the Python prerequisite it cannot know. */
703
+ function agentConfigWorkflow(project: Project): string {
704
+ const path = ".github/workflows/agent-config.yml";
705
+ const workflow = TEMPLATE_FILES[path] ?? "";
706
+ if (isInstructionOnly(project.tool) || project.tool.binding !== "python") return workflow;
707
+ const setup = " - name: Run the shared setup entry point\n";
708
+ if (!workflow.includes(setup))
709
+ throw new Error(`${path} no longer has the setup step; re-vendor before adding uv.`);
710
+ return workflow.replace(
711
+ setup,
712
+ " - name: Install uv\n" +
713
+ " uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0\n\n" +
714
+ setup,
715
+ );
716
+ }
717
+
691
718
  /** Whole-file carriers: no per-project content, `adopt` is the escape hatch. */
692
719
  function vendored(project: Project): PlannedFile[] {
693
720
  const skip = new Set([SETUP_PATH, IGNORE_PATH, ".devcontainer/devcontainer.json"]);
@@ -698,7 +725,8 @@ function vendored(project: Project): PlannedFile[] {
698
725
  ([path, content]): PlannedFile => ({
699
726
  kind: "file",
700
727
  path,
701
- content,
728
+ content:
729
+ path === ".github/workflows/agent-config.yml" ? agentConfigWorkflow(project) : content,
702
730
  ...(path.endsWith("sync.py") ? { mode: 0o755 } : {}),
703
731
  }),
704
732
  ),
@@ -12,7 +12,6 @@ import {
12
12
  envName,
13
13
  has,
14
14
  isSensitive,
15
- json,
16
15
  kernelLaunch,
17
16
  mcpVerdict,
18
17
  requiredConfig,
@@ -79,8 +78,13 @@ export const surface: Surface = {
79
78
  plugins: [compact({ name: identity.name, source: "./", description: identity.description })],
80
79
  });
81
80
  return [
82
- { kind: "file", path: ".claude-plugin/plugin.json", content: json(manifest) },
83
- { kind: "file", path: ".claude-plugin/marketplace.json", content: json(marketplace) },
81
+ { kind: "merge", path: ".claude-plugin/plugin.json", format: "json", patch: manifest },
82
+ {
83
+ kind: "merge",
84
+ path: ".claude-plugin/marketplace.json",
85
+ format: "json",
86
+ patch: marketplace,
87
+ },
84
88
  ];
85
89
  },
86
90
  validate(project) {
@@ -33,8 +33,8 @@ describe("codex", () => {
33
33
  it("makes the repository its own single-plugin marketplace, and validates with the real codex CLI", () => {
34
34
  const files = codex.plan(project(["codex", "mcp"]));
35
35
  const marketplace = files.find((entry) => entry.path === ".agents/plugins/marketplace.json");
36
- if (marketplace?.kind !== "file") throw new Error("expected a whole file");
37
- expect(JSON.parse(marketplace.content)).toMatchObject({
36
+ if (marketplace?.kind !== "merge") throw new Error("expected a merge manifest");
37
+ expect(marketplace.patch).toMatchObject({
38
38
  name: "hello",
39
39
  interface: { displayName: "Hello" },
40
40
  plugins: [
@@ -64,17 +64,17 @@ describe("codex", () => {
64
64
  const target = project(["skill", "codex"]);
65
65
  target.tool.runtime = "none";
66
66
  const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
67
- if (manifest?.kind !== "file") throw new Error("expected a manifest");
68
- expect(JSON.parse(manifest.content)).toMatchObject({ skills: "./skills/" });
69
- expect(JSON.parse(manifest.content)).not.toHaveProperty("mcpServers");
67
+ if (manifest?.kind !== "merge") throw new Error("expected a manifest");
68
+ expect(manifest.patch).toMatchObject({ skills: "./skills/" });
69
+ expect(manifest.patch).not.toHaveProperty("mcpServers");
70
70
  });
71
71
 
72
72
  it("can omit the eager MCP server while retaining the plugin skill", () => {
73
73
  const target = project(["skill", "codex", "mcp"]);
74
74
  target.tool.codex = { mcp: false };
75
75
  const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
76
- if (manifest?.kind !== "file") throw new Error("expected a manifest");
77
- const parsed = JSON.parse(manifest.content);
76
+ if (manifest?.kind !== "merge") throw new Error("expected a manifest");
77
+ const parsed = manifest.patch;
78
78
  expect(parsed.skills).toBe("./skills/");
79
79
  expect(parsed).not.toHaveProperty("mcpServers");
80
80
  });
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { projectName } from "../identity/name.js";
12
12
  import type { Surface } from "../model.js";
13
- import { compact, has, json, kernelLaunch, mcpVerdict } from "./shared.js";
13
+ import { compact, has, kernelLaunch, mcpVerdict } from "./shared.js";
14
14
 
15
15
  /** The Codex CLI (`@openai/codex`) `validate()` is proven against — pin for a deterministic gate. */
16
16
  export const CODEX_PIN = "0.152.1";
@@ -65,8 +65,13 @@ export const surface: Surface = {
65
65
  ],
66
66
  });
67
67
  return [
68
- { kind: "file", path: ".codex-plugin/plugin.json", content: json(manifest) },
69
- { kind: "file", path: ".agents/plugins/marketplace.json", content: json(marketplace) },
68
+ { kind: "merge", path: ".codex-plugin/plugin.json", format: "json", patch: manifest },
69
+ {
70
+ kind: "merge",
71
+ path: ".agents/plugins/marketplace.json",
72
+ format: "json",
73
+ patch: marketplace,
74
+ },
70
75
  ];
71
76
  },
72
77
  validate(project) {
@@ -4,15 +4,7 @@
4
4
  */
5
5
  import { projectName } from "../identity/name.js";
6
6
  import type { Surface } from "../model.js";
7
- import {
8
- compact,
9
- configProperties,
10
- envName,
11
- has,
12
- json,
13
- mcpVerdict,
14
- requiredConfig,
15
- } from "./shared.js";
7
+ import { compact, configProperties, envName, has, mcpVerdict, requiredConfig } from "./shared.js";
16
8
 
17
9
  export const surface: Surface = {
18
10
  id: "cursor",
@@ -51,7 +43,7 @@ export const surface: Surface = {
51
43
  mcpServers: has(project, "agent-plugins") ? "./mcp.json" : undefined,
52
44
  variables,
53
45
  });
54
- return [{ kind: "file", path: ".cursor-plugin/plugin.json", content: json(manifest) }];
46
+ return [{ kind: "merge", path: ".cursor-plugin/plugin.json", format: "json", patch: manifest }];
55
47
  },
56
48
  verdict: (operation) => {
57
49
  const verdict = mcpVerdict(operation);
@@ -190,8 +190,8 @@ describe("claude", () => {
190
190
  it("makes the repository its own single-plugin marketplace", () => {
191
191
  const files = claude.plan(project(["claude", "mcp"]));
192
192
  const file = files.find((entry) => entry.path === ".claude-plugin/marketplace.json");
193
- if (file?.kind !== "file") throw new Error("expected a whole file");
194
- expect(JSON.parse(file.content)).toMatchObject({
193
+ if (file?.kind !== "merge") throw new Error("expected a merge file");
194
+ expect(file.patch).toMatchObject({
195
195
  name: "hello",
196
196
  owner: { name: "GoatInAHat" },
197
197
  plugins: [{ name: "hello", source: "./", description: "Say hello" }],
@@ -36,8 +36,14 @@ const project: Project = {
36
36
 
37
37
  const files = Object.fromEntries(
38
38
  dsh.plan(project).map((file) => {
39
- if (file.kind !== "file") throw new Error("expected whole files only");
40
- return [file.path, file.content];
39
+ return [
40
+ file.path,
41
+ file.kind === "file"
42
+ ? file.content
43
+ : file.kind === "merge"
44
+ ? JSON.stringify(file.patch)
45
+ : file.regions.map((region) => region.content).join(""),
46
+ ];
41
47
  }),
42
48
  );
43
49
 
@@ -22,7 +22,7 @@ import { Scalar, stringify as yamlStringify } from "yaml";
22
22
  import { getBinding } from "../bindings/index.js";
23
23
  import { projectName } from "../identity/name.js";
24
24
  import type { Project, Surface } from "../model.js";
25
- import { compact, configProperties, envName, json, kernelLaunch, mcpVerdict } from "./shared.js";
25
+ import { compact, configProperties, envName, kernelLaunch, mcpVerdict } from "./shared.js";
26
26
 
27
27
  /**
28
28
  * The `@deepseek-ai/dsh` release `validate()` boots. Pinned, not `@alpha`: npm's `latest` tag
@@ -40,6 +40,8 @@ export const PATCH_FILE = "cordis.patch.yml";
40
40
  * passes to `dsh --patch` to drive the working tree.
41
41
  */
42
42
  export const LOCAL_PATCH_FILE = "cordis.local.patch.yml";
43
+ const PATCH_BEGIN = "# tf:mcp";
44
+ const PATCH_END = "# /tf:mcp";
43
45
 
44
46
  /** DSH's first-party MCP bridge; the only plugin the bundle names. */
45
47
  const MCP_CLIENT = "@deepseek-ai/dsh-mcp-client";
@@ -91,6 +93,10 @@ function patch(project: Project, launch: { command: string; args: string[] }): s
91
93
  return yamlStringify([{ insert: [row] }], { lineWidth: 0 });
92
94
  }
93
95
 
96
+ function patchTemplate(): string {
97
+ return `${PATCH_BEGIN}\n${PATCH_END}\n\n# Yours: add Cordis rows or local code configuration below this marker.\n`;
98
+ }
99
+
94
100
  /** The public name a bridged MCP tool takes in DSH; `mcp__<serverName>__<rawName>` is its contract. */
95
101
  export function toolName(project: Project, operation: string): string {
96
102
  return `mcp__${projectName.dshServer(project.identity.name)}__${operation}`;
@@ -120,20 +126,34 @@ export const surface: Surface = {
120
126
  dsh: { bundle: { patch: `./${PATCH_FILE}` } },
121
127
  });
122
128
  return [
123
- { kind: "file", path: `${HOST_DIR}/package.json`, content: json(manifest) },
129
+ { kind: "merge", path: `${HOST_DIR}/package.json`, format: "json", patch: manifest },
124
130
  {
125
- kind: "file",
131
+ kind: "region",
126
132
  path: `${HOST_DIR}/${PATCH_FILE}`,
127
133
  // `cwd` is deliberately absent: an installed bundle has no path to point at, and the
128
134
  // published launch resolves through the registry from wherever DSH is running.
129
- content: patch(project, kernelLaunch(project, ".")),
135
+ regions: [
136
+ {
137
+ begin: PATCH_BEGIN,
138
+ end: PATCH_END,
139
+ content: patch(project, kernelLaunch(project, ".")),
140
+ },
141
+ ],
142
+ template: patchTemplate(),
130
143
  },
131
144
  {
132
- kind: "file",
145
+ kind: "region",
133
146
  path: `${HOST_DIR}/${LOCAL_PATCH_FILE}`,
134
147
  // The kernel as `introspect` and `inspector.json` spawn it: repo-relative, so it resolves
135
148
  // against the directory DSH itself was started in.
136
- content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
149
+ regions: [
150
+ {
151
+ begin: PATCH_BEGIN,
152
+ end: PATCH_END,
153
+ content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
154
+ },
155
+ ],
156
+ template: patchTemplate(),
137
157
  },
138
158
  ];
139
159
  },
@@ -34,8 +34,8 @@ const project: Project = {
34
34
  };
35
35
 
36
36
  const [manifestFile, ...rest] = gemini.plan(project);
37
- if (manifestFile?.kind !== "file") throw new Error("expected one whole file");
38
- const manifest = JSON.parse(manifestFile.content) as Record<string, unknown>;
37
+ if (manifestFile?.kind !== "merge") throw new Error("expected one merge file");
38
+ const manifest = manifestFile.patch as Record<string, unknown>;
39
39
 
40
40
  describe("gemini", () => {
41
41
  it("is one root manifest: a dashed name, the kernel launch, AGENTS.md as the context file", () => {
@@ -58,7 +58,7 @@ describe("gemini", () => {
58
58
  ],
59
59
  });
60
60
  // `skills/` and `commands/` are auto-discovered from the extension root; no key names them.
61
- expect(manifestFile.content).not.toContain('"skills"');
61
+ expect(JSON.stringify(manifestFile.patch)).not.toContain('"skills"');
62
62
  });
63
63
 
64
64
  it("validates through Gemini's own manifest validator, and carries only MCP tool calls", () => {
@@ -26,7 +26,6 @@ import {
26
26
  configProperties,
27
27
  envName,
28
28
  isSensitive,
29
- json,
30
29
  kernelLaunch,
31
30
  mcpVerdict,
32
31
  } from "./shared.js";
@@ -64,7 +63,7 @@ export const surface: Surface = {
64
63
  contextFileName: AGENTS_PATH,
65
64
  settings: settings.length ? settings : undefined,
66
65
  });
67
- return [{ kind: "file", path: MANIFEST_PATH, content: json(manifest) }];
66
+ return [{ kind: "merge", path: MANIFEST_PATH, format: "json", patch: manifest }];
68
67
  },
69
68
  validate(project) {
70
69
  // Gemini's own manifest validator: it parses the manifest, enforces the name and version
@@ -1,11 +1,12 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import { parse as parseToml } from "smol-toml";
4
+ import { join } from "node:path";
5
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
6
6
  import { describe, expect, it } from "vitest";
7
- import { parse as parseYaml } from "yaml";
7
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
8
8
  import type { Operation, Project } from "../model.js";
9
+ import { apply } from "../project/apply.js";
9
10
  import { HOST_DIR, pluginDir, surface } from "./hermes-native.js";
10
11
 
11
12
  const echo: Operation = {
@@ -61,13 +62,45 @@ function project(overrides: Partial<Project> = {}): Project {
61
62
 
62
63
  function emitted(target: Project): Record<string, string> {
63
64
  return Object.fromEntries(
64
- surface.plan(target).map((file) => [file.path, file.kind === "file" ? file.content : ""]),
65
+ surface
66
+ .plan(target)
67
+ .map((file) => [
68
+ file.path,
69
+ file.kind === "file"
70
+ ? file.content
71
+ : file.kind === "merge"
72
+ ? file.format === "yaml"
73
+ ? stringifyYaml(file.patch)
74
+ : file.format === "toml"
75
+ ? stringifyToml(file.patch)
76
+ : JSON.stringify(file.patch)
77
+ : file.regions.map((region) => region.content).join(""),
78
+ ]),
65
79
  );
66
80
  }
67
81
 
68
82
  const python = project({ tool: { ...project().tool, binding: "python" } });
69
83
 
70
84
  describe("hermes-native", () => {
85
+ it("keeps native registrations in the author hook while refreshing generated registrations", () => {
86
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-region-"));
87
+ const target = { ...project(), root };
88
+ apply(root, surface.plan(target), "0.1.0");
89
+ const path = join(root, pluginDir(target), "__init__.py");
90
+ writeFileSync(
91
+ path,
92
+ readFileSync(path, "utf8").replace(
93
+ " pass\n",
94
+ ' ctx.register_tool(name="native", toolset="native", schema={}, handler=lambda _: "{}")\n',
95
+ ),
96
+ );
97
+ apply(root, surface.plan({ ...target, operations: [echo] }), "0.1.0");
98
+ const text = readFileSync(path, "utf8");
99
+ expect(text).toContain('name="native"');
100
+ expect(text).toContain("def _register_generated(ctx: Any)");
101
+ expect(text).toContain("_register_generated(ctx)\n register_native(ctx)");
102
+ });
103
+
71
104
  it("projects the manifest Hermes parses and the credentials it prompts for", () => {
72
105
  const files = emitted(project());
73
106
  const manifest = parseYaml(files[`${pluginDir(project())}/plugin.yaml`] ?? "") as Record<
@@ -184,11 +217,7 @@ describe.skipIf(!hasHermes())("hermes plugins doctor", () => {
184
217
  it("loads and registers the generated plugin", () => {
185
218
  const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-"));
186
219
  try {
187
- for (const file of surface.plan(project())) {
188
- if (file.kind !== "file") continue;
189
- mkdirSync(dirname(join(root, file.path)), { recursive: true });
190
- writeFileSync(join(root, file.path), file.content);
191
- }
220
+ apply(root, surface.plan(project()), "0.1.0");
192
221
  const commands = surface.validate?.({ ...project(), root }) ?? [];
193
222
  const command = commands.find((c) => c.label === "hermes plugins doctor");
194
223
  if (!command) throw new Error("the hermes surface no longer declares a doctor validator");