toolfactory 0.1.2 → 0.2.1

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 (101) 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 +27 -7
  35. package/dist/surfaces/dsh.test.js +37 -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/web.d.ts +1 -1
  54. package/dist/surfaces/web.js +1 -1
  55. package/dist/surfaces/workflows.js +49 -10
  56. package/dist/surfaces/workflows.test.js +3 -2
  57. package/dist/toolfactory/cli.js +1 -1
  58. package/dist/toolfactory/mcp.js +1 -1
  59. package/package.json +2 -1
  60. package/src/distribution/native.test.ts +161 -0
  61. package/src/distribution/native.ts +460 -0
  62. package/src/distribution/system.test.ts +136 -0
  63. package/src/distribution/system.ts +688 -0
  64. package/src/e2e.test.ts +7 -1
  65. package/src/hosts/openclaw.ts +4 -2
  66. package/src/hosts/vscode.ts +79 -0
  67. package/src/model.ts +15 -1
  68. package/src/project/apply.test.ts +120 -0
  69. package/src/project/apply.ts +257 -15
  70. package/src/project/gate.ts +96 -4
  71. package/src/project/lock.ts +12 -0
  72. package/src/surfaces/agent-plugins.ts +5 -4
  73. package/src/surfaces/agents.test.ts +16 -0
  74. package/src/surfaces/agents.ts +31 -3
  75. package/src/surfaces/claude.ts +7 -3
  76. package/src/surfaces/codex.test.ts +7 -7
  77. package/src/surfaces/codex.ts +8 -3
  78. package/src/surfaces/cursor.ts +2 -10
  79. package/src/surfaces/distribution.test.ts +2 -2
  80. package/src/surfaces/dsh.test.ts +38 -2
  81. package/src/surfaces/dsh.ts +28 -7
  82. package/src/surfaces/gemini.test.ts +3 -3
  83. package/src/surfaces/gemini.ts +1 -2
  84. package/src/surfaces/hermes-native.test.ts +39 -10
  85. package/src/surfaces/hermes-native.ts +41 -11
  86. package/src/surfaces/mcp-registry.test.ts +20 -0
  87. package/src/surfaces/mcp-registry.ts +46 -6
  88. package/src/surfaces/openclaw-native.test.ts +37 -0
  89. package/src/surfaces/openclaw-native.ts +49 -47
  90. package/src/surfaces/readme.ts +10 -0
  91. package/src/surfaces/registry.ts +6 -0
  92. package/src/surfaces/vscode-extension.test.ts +89 -0
  93. package/src/surfaces/vscode-extension.ts +345 -0
  94. package/src/surfaces/vscode-scaffold.ts +55 -0
  95. package/src/surfaces/web.ts +1 -1
  96. package/src/surfaces/workflows.test.ts +3 -4
  97. package/src/surfaces/workflows.ts +49 -10
  98. package/src/toolfactory/cli.ts +1 -1
  99. package/src/toolfactory/mcp.ts +1 -1
  100. package/web/dist/assets/{index-Spapkkvh.js → index-BHR6gCfm.js} +1 -1
  101. 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" }],
@@ -1,6 +1,10 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import { describe, expect, it } from "vitest";
2
5
  import { parse as parseYaml } from "yaml";
3
6
  import type { Operation, Project } from "../model.js";
7
+ import { apply, replaceRegions } from "../project/apply.js";
4
8
  import { DSH_PIN, surface as dsh } from "./dsh.js";
5
9
 
6
10
  const echo: Operation = { name: "echo", inputSchema: { type: "object" }, requires: [] };
@@ -36,12 +40,44 @@ const project: Project = {
36
40
 
37
41
  const files = Object.fromEntries(
38
42
  dsh.plan(project).map((file) => {
39
- if (file.kind !== "file") throw new Error("expected whole files only");
40
- return [file.path, file.content];
43
+ return [
44
+ file.path,
45
+ file.kind === "file"
46
+ ? file.content
47
+ : file.kind === "merge"
48
+ ? JSON.stringify(file.patch)
49
+ : (replaceRegions(file.template, file) ?? file.template),
50
+ ];
41
51
  }),
42
52
  );
43
53
 
44
54
  describe("dsh", () => {
55
+ it("rebuilds legacy patch regions without commenting out insert or losing authored rows", () => {
56
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-dsh-"));
57
+ try {
58
+ const plan = dsh.plan({ ...project, root });
59
+ const patches = plan.filter((file) => file.kind === "region");
60
+ apply(root, plan, project.toolfactoryVersion);
61
+ for (const file of patches) {
62
+ const path = join(root, file.path);
63
+ const legacy = readFileSync(path, "utf8").replace("# tf:mcp\n", "# tf:mcp");
64
+ writeFileSync(path, `${legacy}\n- insert:\n - id: authored\n`);
65
+ }
66
+
67
+ apply(root, plan, project.toolfactoryVersion);
68
+ for (const file of patches) {
69
+ const rendered = readFileSync(join(root, file.path), "utf8");
70
+ expect(rendered).toMatch(/^# tf:mcp\n- insert:/);
71
+ expect(parseYaml(rendered, { logLevel: "silent" })).toMatchObject([
72
+ { insert: [{ id: "mcp-hello-tool" }] },
73
+ { insert: [{ id: "authored" }] },
74
+ ]);
75
+ }
76
+ } finally {
77
+ rmSync(root, { recursive: true, force: true });
78
+ }
79
+ });
80
+
45
81
  it("is two shipped files and no code: the bundle manifest and one mcp-client insert row", () => {
46
82
  // `dsh.bundle.patch` is DSH's whole acceptance test for a bundle; a dependency would be wrong,
47
83
  // because @deepseek-ai/dsh-mcp-client is @deepseek-ai/dsh's own.
@@ -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";
@@ -88,7 +90,12 @@ function patch(project: Project, launch: { command: string; args: string[] }): s
88
90
  failOnStartupError: true,
89
91
  }),
90
92
  };
91
- return yamlStringify([{ insert: [row] }], { lineWidth: 0 });
93
+ // Region replacement starts immediately after the comment marker.
94
+ return `\n${yamlStringify([{ insert: [row] }], { lineWidth: 0 })}`;
95
+ }
96
+
97
+ function patchTemplate(): string {
98
+ return `${PATCH_BEGIN}\n${PATCH_END}\n\n# Yours: add Cordis rows or local code configuration below this marker.\n`;
92
99
  }
93
100
 
94
101
  /** The public name a bridged MCP tool takes in DSH; `mcp__<serverName>__<rawName>` is its contract. */
@@ -120,20 +127,34 @@ export const surface: Surface = {
120
127
  dsh: { bundle: { patch: `./${PATCH_FILE}` } },
121
128
  });
122
129
  return [
123
- { kind: "file", path: `${HOST_DIR}/package.json`, content: json(manifest) },
130
+ { kind: "merge", path: `${HOST_DIR}/package.json`, format: "json", patch: manifest },
124
131
  {
125
- kind: "file",
132
+ kind: "region",
126
133
  path: `${HOST_DIR}/${PATCH_FILE}`,
127
134
  // `cwd` is deliberately absent: an installed bundle has no path to point at, and the
128
135
  // published launch resolves through the registry from wherever DSH is running.
129
- content: patch(project, kernelLaunch(project, ".")),
136
+ regions: [
137
+ {
138
+ begin: PATCH_BEGIN,
139
+ end: PATCH_END,
140
+ content: patch(project, kernelLaunch(project, ".")),
141
+ },
142
+ ],
143
+ template: patchTemplate(),
130
144
  },
131
145
  {
132
- kind: "file",
146
+ kind: "region",
133
147
  path: `${HOST_DIR}/${LOCAL_PATCH_FILE}`,
134
148
  // The kernel as `introspect` and `inspector.json` spawn it: repo-relative, so it resolves
135
149
  // against the directory DSH itself was started in.
136
- content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
150
+ regions: [
151
+ {
152
+ begin: PATCH_BEGIN,
153
+ end: PATCH_END,
154
+ content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
155
+ },
156
+ ],
157
+ template: patchTemplate(),
137
158
  },
138
159
  ];
139
160
  },
@@ -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