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
@@ -16,6 +16,11 @@ export declare const LockSchema: z.ZodObject<{
16
16
  manual: "manual";
17
17
  }>;
18
18
  keys: z.ZodOptional<z.ZodArray<z.ZodString>>;
19
+ keyedArrays: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
20
+ id: z.ZodString;
21
+ values: z.ZodArray<z.ZodString>;
22
+ entries: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
23
+ }, z.core.$strip>>>;
19
24
  regions: z.ZodOptional<z.ZodArray<z.ZodObject<{
20
25
  begin: z.ZodString;
21
26
  end: z.ZodString;
@@ -17,6 +17,15 @@ export const LockSchema = z.object({
17
17
  * patch — or deselecting the surface that added it — uninstalls it instead of stranding it.
18
18
  */
19
19
  keys: z.array(z.string()).optional(),
20
+ /** A keyed array's inverse: the entries Toolfactory owns at each dotted path. */
21
+ keyedArrays: z
22
+ .record(z.string(), z.object({
23
+ id: z.string(),
24
+ values: z.array(z.string()),
25
+ /** The last projected entry shape lets a later plan remove only stale generated leaves. */
26
+ entries: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
27
+ }))
28
+ .optional(),
20
29
  /**
21
30
  * A region file's inverse, exactly as `keys` is a merge file's: the marker pairs its
22
31
  * projector wrote. Regions the current plan no longer writes are emptied before the current
@@ -1,4 +1,4 @@
1
- import { json, kernelLaunch, mcpVerdict } from "./shared.js";
1
+ import { kernelLaunch, mcpVerdict } from "./shared.js";
2
2
  export const MCP_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
3
3
  export const PLUGIN_SCHEMA_ID = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
4
4
  export function assertBundleIdentity(project) {
@@ -13,12 +13,13 @@ export const surface = {
13
13
  const launch = kernelLaunch(project, "${PLUGIN_ROOT}");
14
14
  return [
15
15
  {
16
- kind: "file",
16
+ kind: "merge",
17
17
  path: "mcp.json",
18
- content: json({
18
+ format: "json",
19
+ patch: {
19
20
  $schema: MCP_SCHEMA_ID,
20
21
  mcpServers: { [project.identity.name]: { type: "stdio", ...launch } },
21
- }),
22
+ },
22
23
  },
23
24
  ];
24
25
  },
@@ -160,7 +160,8 @@ function layoutSection(project) {
160
160
  if (has(project, "openclaw-native") ||
161
161
  has(project, "hermes-native") ||
162
162
  has(project, "dsh") ||
163
- has(project, "browser-extension")) {
163
+ has(project, "browser-extension") ||
164
+ has(project, "vscode-extension")) {
164
165
  lines.push("- `hosts/<id>/` — the host-native escape hatch for a selected host; nothing else creates it.");
165
166
  }
166
167
  if (has(project, "web"))
@@ -216,6 +217,9 @@ function installSection(project) {
216
217
  if (has(project, "dsh")) {
217
218
  lines.push(`- **DSH** (experimental): \`dsh plugin --profile ${DSH_PROFILE} add ./${DSH_HOST_DIR}\` installs the`, " bundle (`dsh plugin` forwards to pnpm, so pnpm must be on `PATH`); the operations reach the", ` model as \`${dshToolName(project, "<operation>")}\`, and \`dsh --profile ${DSH_PROFILE} --dump-config\` shows`, ` the composed \`id: ${dshRowId(project)}\` row. Bundle patches are read once per boot and never watched,`, ` so restart DSH after editing \`${DSH_HOST_DIR}/${PATCH_FILE}\` or reinstalling. Boot with`, ` \`--patch ${DSH_HOST_DIR}/${LOCAL_PATCH_FILE}\` and no bundle installed to drive this checkout`, " instead. Every config variable is restated in the row's `env:` because DSH scrubs", " KEY/PASSWORD/SECRET/TOKEN names before spawning an MCP server.");
218
219
  }
220
+ if (has(project, "vscode-extension")) {
221
+ lines.push("- **VS Code extension**: `npm --prefix hosts/vscode install`, then open `hosts/vscode` in VS Code", " and press F5. Development and `npm --prefix hosts/vscode test` use the checkout's kernel.", " `npm --prefix hosts/vscode run vsix` packages it; install with `code --install-extension hosts/vscode/extension.vsix`.", " Use Developer: Reload Window after rebuilding an installed extension. Native activation, contributions", " and every VS Code API are available in your own files; include your license in the extension directory.");
222
+ }
219
223
  if (has(project, "browser-extension")) {
220
224
  const { command, args } = getBinding(project.tool.binding).cliCommand(project);
221
225
  lines.push(`- **Browser extension**: \`npm --prefix ${BROWSER_HOST_DIR} install && npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt build\`,`, ` then \`chrome://extensions\` → developer mode → Load unpacked → \`${BROWSER_HOST_DIR}/.output/chrome-mv3\`;`, ` \`npm --prefix ${BROWSER_HOST_DIR} exec --no -- web-ext run\` does the same for Firefox. Pair it with this`, ` checkout's kernel: \`${[command, ...args].join(" ")} mcp --http --pair\` mints a token under the data`, ` directory and prints the \`<url>#<token>\` the extension's options page accepts (with no token minted`, ` and no \`${envName(project.identity.name)}_MCP_TOKEN\` in the environment the endpoint stays open on loopback). The content`, " script's selectors are yours to maintain: it is authored escape-hatch code against a page that", " changes on its own schedule, and nothing here can version-proof it.");
@@ -224,7 +228,8 @@ function installSection(project) {
224
228
  !openclaw &&
225
229
  !hermes &&
226
230
  !has(project, "dsh") &&
227
- !has(project, "browser-extension")) {
231
+ !has(project, "browser-extension") &&
232
+ !has(project, "vscode-extension")) {
228
233
  lines.push("_No host-native surface (`openclaw-native`, `hermes-native`, `dsh`, `browser-extension`) or", "`gemini` extension is selected; nothing installs into a running host._");
229
234
  }
230
235
  lines.push("", "Codex and Cursor read this file as is; Claude Code and Gemini read `CLAUDE.md` / `GEMINI.md`,", "each of which `.agents/sync.py` renders as the one line `@AGENTS.md`.", "");
@@ -570,6 +575,19 @@ function devcontainer(project) {
570
575
  : document.features;
571
576
  return `${JSON.stringify({ ...document, features }, null, 2)}\n`;
572
577
  }
578
+ /** The vendor-owned workflow gains the Python prerequisite it cannot know. */
579
+ function agentConfigWorkflow(project) {
580
+ const path = ".github/workflows/agent-config.yml";
581
+ const workflow = TEMPLATE_FILES[path] ?? "";
582
+ if (isInstructionOnly(project.tool) || project.tool.binding !== "python")
583
+ return workflow;
584
+ const setup = " - name: Run the shared setup entry point\n";
585
+ if (!workflow.includes(setup))
586
+ throw new Error(`${path} no longer has the setup step; re-vendor before adding uv.`);
587
+ return workflow.replace(setup, " - name: Install uv\n" +
588
+ " uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0\n\n" +
589
+ setup);
590
+ }
573
591
  /** Whole-file carriers: no per-project content, `adopt` is the escape hatch. */
574
592
  function vendored(project) {
575
593
  const skip = new Set([SETUP_PATH, IGNORE_PATH, ".devcontainer/devcontainer.json"]);
@@ -579,7 +597,7 @@ function vendored(project) {
579
597
  .map(([path, content]) => ({
580
598
  kind: "file",
581
599
  path,
582
- content,
600
+ content: path === ".github/workflows/agent-config.yml" ? agentConfigWorkflow(project) : content,
583
601
  ...(path.endsWith("sync.py") ? { mode: 0o755 } : {}),
584
602
  })),
585
603
  { kind: "file", path: ".devcontainer/devcontainer.json", content: devcontainer(project) },
@@ -108,6 +108,18 @@ describe("agents", () => {
108
108
  expect(setup).toContain("npx toolfactory check >/dev/null");
109
109
  expect(setup).toContain("npm install --no-audit --no-fund");
110
110
  });
111
+ it("installs uv before Python projects run the vendored setup workflow", () => {
112
+ const workflow = planned(project(["cli"], { binding: "python" }), ".github/workflows/agent-config.yml");
113
+ if (workflow.kind !== "file")
114
+ throw new Error("expected an agent-config workflow file");
115
+ const install = "uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0";
116
+ expect(workflow.content).toContain(install);
117
+ expect(workflow.content.indexOf(install)).toBeLessThan(workflow.content.indexOf("run: bash .agents/setup --all"));
118
+ const typescript = planned(project(["cli"]), ".github/workflows/agent-config.yml");
119
+ if (typescript.kind !== "file")
120
+ throw new Error("expected an agent-config workflow file");
121
+ expect(typescript.content).toBe(TEMPLATE_FILES[".github/workflows/agent-config.yml"]);
122
+ });
111
123
  it("owns the agent adapters, the binding's ignores and the build outputs in .gitignore", () => {
112
124
  const bare = region(project(["cli"]), IGNORE_PATH);
113
125
  const agentBlocks = (TEMPLATE_FILES[IGNORE_PATH] ?? "").split("# ─── Secrets")[0] ?? "";
@@ -5,7 +5,7 @@
5
5
  * `claude plugin marketplace add <owner>/<repo>` (and Copilot CLI's identical command) installs.
6
6
  */
7
7
  import { githubSlug } from "../hosts/github.js";
8
- import { compact, configProperties, envName, has, isSensitive, json, kernelLaunch, mcpVerdict, requiredConfig, } from "./shared.js";
8
+ import { compact, configProperties, envName, has, isSensitive, kernelLaunch, mcpVerdict, requiredConfig, } from "./shared.js";
9
9
  export const surface = {
10
10
  id: "claude",
11
11
  plan(project) {
@@ -61,8 +61,13 @@ export const surface = {
61
61
  plugins: [compact({ name: identity.name, source: "./", description: identity.description })],
62
62
  });
63
63
  return [
64
- { kind: "file", path: ".claude-plugin/plugin.json", content: json(manifest) },
65
- { kind: "file", path: ".claude-plugin/marketplace.json", content: json(marketplace) },
64
+ { kind: "merge", path: ".claude-plugin/plugin.json", format: "json", patch: manifest },
65
+ {
66
+ kind: "merge",
67
+ path: ".claude-plugin/marketplace.json",
68
+ format: "json",
69
+ patch: marketplace,
70
+ },
66
71
  ];
67
72
  },
68
73
  validate(project) {
@@ -8,7 +8,7 @@
8
8
  * this surface does not reach.
9
9
  */
10
10
  import { projectName } from "../identity/name.js";
11
- import { compact, has, json, kernelLaunch, mcpVerdict } from "./shared.js";
11
+ import { compact, has, kernelLaunch, mcpVerdict } from "./shared.js";
12
12
  /** The Codex CLI (`@openai/codex`) `validate()` is proven against — pin for a deterministic gate. */
13
13
  export const CODEX_PIN = "0.152.1";
14
14
  export const surface = {
@@ -60,8 +60,13 @@ export const surface = {
60
60
  ],
61
61
  });
62
62
  return [
63
- { kind: "file", path: ".codex-plugin/plugin.json", content: json(manifest) },
64
- { kind: "file", path: ".agents/plugins/marketplace.json", content: json(marketplace) },
63
+ { kind: "merge", path: ".codex-plugin/plugin.json", format: "json", patch: manifest },
64
+ {
65
+ kind: "merge",
66
+ path: ".agents/plugins/marketplace.json",
67
+ format: "json",
68
+ patch: marketplace,
69
+ },
65
70
  ];
66
71
  },
67
72
  validate(project) {
@@ -29,9 +29,9 @@ describe("codex", () => {
29
29
  it("makes the repository its own single-plugin marketplace, and validates with the real codex CLI", () => {
30
30
  const files = codex.plan(project(["codex", "mcp"]));
31
31
  const marketplace = files.find((entry) => entry.path === ".agents/plugins/marketplace.json");
32
- if (marketplace?.kind !== "file")
33
- throw new Error("expected a whole file");
34
- expect(JSON.parse(marketplace.content)).toMatchObject({
32
+ if (marketplace?.kind !== "merge")
33
+ throw new Error("expected a merge manifest");
34
+ expect(marketplace.patch).toMatchObject({
35
35
  name: "hello",
36
36
  interface: { displayName: "Hello" },
37
37
  plugins: [
@@ -59,18 +59,18 @@ describe("codex", () => {
59
59
  const target = project(["skill", "codex"]);
60
60
  target.tool.runtime = "none";
61
61
  const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
62
- if (manifest?.kind !== "file")
62
+ if (manifest?.kind !== "merge")
63
63
  throw new Error("expected a manifest");
64
- expect(JSON.parse(manifest.content)).toMatchObject({ skills: "./skills/" });
65
- expect(JSON.parse(manifest.content)).not.toHaveProperty("mcpServers");
64
+ expect(manifest.patch).toMatchObject({ skills: "./skills/" });
65
+ expect(manifest.patch).not.toHaveProperty("mcpServers");
66
66
  });
67
67
  it("can omit the eager MCP server while retaining the plugin skill", () => {
68
68
  const target = project(["skill", "codex", "mcp"]);
69
69
  target.tool.codex = { mcp: false };
70
70
  const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
71
- if (manifest?.kind !== "file")
71
+ if (manifest?.kind !== "merge")
72
72
  throw new Error("expected a manifest");
73
- const parsed = JSON.parse(manifest.content);
73
+ const parsed = manifest.patch;
74
74
  expect(parsed.skills).toBe("./skills/");
75
75
  expect(parsed).not.toHaveProperty("mcpServers");
76
76
  });
@@ -3,7 +3,7 @@
3
3
  * and the bundle's `mcp.json` is shared. Loader unverified, like Codex.
4
4
  */
5
5
  import { projectName } from "../identity/name.js";
6
- import { compact, configProperties, envName, has, json, mcpVerdict, requiredConfig, } from "./shared.js";
6
+ import { compact, configProperties, envName, has, mcpVerdict, requiredConfig } from "./shared.js";
7
7
  export const surface = {
8
8
  id: "cursor",
9
9
  plan(project) {
@@ -39,7 +39,7 @@ export const surface = {
39
39
  mcpServers: has(project, "agent-plugins") ? "./mcp.json" : undefined,
40
40
  variables,
41
41
  });
42
- return [{ kind: "file", path: ".cursor-plugin/plugin.json", content: json(manifest) }];
42
+ return [{ kind: "merge", path: ".cursor-plugin/plugin.json", format: "json", patch: manifest }];
43
43
  },
44
44
  verdict: (operation) => {
45
45
  const verdict = mcpVerdict(operation);
@@ -165,9 +165,9 @@ describe("claude", () => {
165
165
  it("makes the repository its own single-plugin marketplace", () => {
166
166
  const files = claude.plan(project(["claude", "mcp"]));
167
167
  const file = files.find((entry) => entry.path === ".claude-plugin/marketplace.json");
168
- if (file?.kind !== "file")
169
- throw new Error("expected a whole file");
170
- expect(JSON.parse(file.content)).toMatchObject({
168
+ if (file?.kind !== "merge")
169
+ throw new Error("expected a merge file");
170
+ expect(file.patch).toMatchObject({
171
171
  name: "hello",
172
172
  owner: { name: "GoatInAHat" },
173
173
  plugins: [{ name: "hello", source: "./", description: "Say hello" }],
@@ -21,7 +21,7 @@
21
21
  import { Scalar, stringify as yamlStringify } from "yaml";
22
22
  import { getBinding } from "../bindings/index.js";
23
23
  import { projectName } from "../identity/name.js";
24
- import { compact, configProperties, envName, json, kernelLaunch, mcpVerdict } from "./shared.js";
24
+ import { compact, configProperties, envName, kernelLaunch, mcpVerdict } from "./shared.js";
25
25
  /**
26
26
  * The `@deepseek-ai/dsh` release `validate()` boots. Pinned, not `@alpha`: npm's `latest` tag
27
27
  * lags the alpha line by weeks, so an unpinned install is neither current nor reproducible.
@@ -37,6 +37,8 @@ export const PATCH_FILE = "cordis.patch.yml";
37
37
  * passes to `dsh --patch` to drive the working tree.
38
38
  */
39
39
  export const LOCAL_PATCH_FILE = "cordis.local.patch.yml";
40
+ const PATCH_BEGIN = "# tf:mcp";
41
+ const PATCH_END = "# /tf:mcp";
40
42
  /** DSH's first-party MCP bridge; the only plugin the bundle names. */
41
43
  const MCP_CLIENT = "@deepseek-ai/dsh-mcp-client";
42
44
  /** `headless` is one-shot and ships an app bundle; a profile without one blocks forever. */
@@ -80,7 +82,11 @@ function patch(project, launch) {
80
82
  failOnStartupError: true,
81
83
  }),
82
84
  };
83
- return yamlStringify([{ insert: [row] }], { lineWidth: 0 });
85
+ // Region replacement starts immediately after the comment marker.
86
+ return `\n${yamlStringify([{ insert: [row] }], { lineWidth: 0 })}`;
87
+ }
88
+ function patchTemplate() {
89
+ return `${PATCH_BEGIN}\n${PATCH_END}\n\n# Yours: add Cordis rows or local code configuration below this marker.\n`;
84
90
  }
85
91
  /** The public name a bridged MCP tool takes in DSH; `mcp__<serverName>__<rawName>` is its contract. */
86
92
  export function toolName(project, operation) {
@@ -108,20 +114,34 @@ export const surface = {
108
114
  dsh: { bundle: { patch: `./${PATCH_FILE}` } },
109
115
  });
110
116
  return [
111
- { kind: "file", path: `${HOST_DIR}/package.json`, content: json(manifest) },
117
+ { kind: "merge", path: `${HOST_DIR}/package.json`, format: "json", patch: manifest },
112
118
  {
113
- kind: "file",
119
+ kind: "region",
114
120
  path: `${HOST_DIR}/${PATCH_FILE}`,
115
121
  // `cwd` is deliberately absent: an installed bundle has no path to point at, and the
116
122
  // published launch resolves through the registry from wherever DSH is running.
117
- content: patch(project, kernelLaunch(project, ".")),
123
+ regions: [
124
+ {
125
+ begin: PATCH_BEGIN,
126
+ end: PATCH_END,
127
+ content: patch(project, kernelLaunch(project, ".")),
128
+ },
129
+ ],
130
+ template: patchTemplate(),
118
131
  },
119
132
  {
120
- kind: "file",
133
+ kind: "region",
121
134
  path: `${HOST_DIR}/${LOCAL_PATCH_FILE}`,
122
135
  // The kernel as `introspect` and `inspector.json` spawn it: repo-relative, so it resolves
123
136
  // against the directory DSH itself was started in.
124
- content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
137
+ regions: [
138
+ {
139
+ begin: PATCH_BEGIN,
140
+ end: PATCH_END,
141
+ content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
142
+ },
143
+ ],
144
+ template: patchTemplate(),
125
145
  },
126
146
  ];
127
147
  },
@@ -1,5 +1,9 @@
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";
6
+ import { apply, replaceRegions } from "../project/apply.js";
3
7
  import { DSH_PIN, surface as dsh } from "./dsh.js";
4
8
  const echo = { name: "echo", inputSchema: { type: "object" }, requires: [] };
5
9
  const browse = {
@@ -31,11 +35,41 @@ const project = {
31
35
  packageManager: "npm",
32
36
  };
33
37
  const files = Object.fromEntries(dsh.plan(project).map((file) => {
34
- if (file.kind !== "file")
35
- throw new Error("expected whole files only");
36
- return [file.path, file.content];
38
+ return [
39
+ file.path,
40
+ file.kind === "file"
41
+ ? file.content
42
+ : file.kind === "merge"
43
+ ? JSON.stringify(file.patch)
44
+ : (replaceRegions(file.template, file) ?? file.template),
45
+ ];
37
46
  }));
38
47
  describe("dsh", () => {
48
+ it("rebuilds legacy patch regions without commenting out insert or losing authored rows", () => {
49
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-dsh-"));
50
+ try {
51
+ const plan = dsh.plan({ ...project, root });
52
+ const patches = plan.filter((file) => file.kind === "region");
53
+ apply(root, plan, project.toolfactoryVersion);
54
+ for (const file of patches) {
55
+ const path = join(root, file.path);
56
+ const legacy = readFileSync(path, "utf8").replace("# tf:mcp\n", "# tf:mcp");
57
+ writeFileSync(path, `${legacy}\n- insert:\n - id: authored\n`);
58
+ }
59
+ apply(root, plan, project.toolfactoryVersion);
60
+ for (const file of patches) {
61
+ const rendered = readFileSync(join(root, file.path), "utf8");
62
+ expect(rendered).toMatch(/^# tf:mcp\n- insert:/);
63
+ expect(parseYaml(rendered, { logLevel: "silent" })).toMatchObject([
64
+ { insert: [{ id: "mcp-hello-tool" }] },
65
+ { insert: [{ id: "authored" }] },
66
+ ]);
67
+ }
68
+ }
69
+ finally {
70
+ rmSync(root, { recursive: true, force: true });
71
+ }
72
+ });
39
73
  it("is two shipped files and no code: the bundle manifest and one mcp-client insert row", () => {
40
74
  // `dsh.bundle.patch` is DSH's whole acceptance test for a bundle; a dependency would be wrong,
41
75
  // because @deepseek-ai/dsh-mcp-client is @deepseek-ai/dsh's own.
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { projectName } from "../identity/name.js";
21
21
  import { AGENTS_PATH } from "./agents.js";
22
- import { compact, configProperties, envName, isSensitive, json, kernelLaunch, mcpVerdict, } from "./shared.js";
22
+ import { compact, configProperties, envName, isSensitive, kernelLaunch, mcpVerdict, } from "./shared.js";
23
23
  export const MANIFEST_PATH = "gemini-extension.json";
24
24
  /**
25
25
  * The `@google/gemini-cli` release `validate()` runs. Pinned so the gate is reproducible;
@@ -48,7 +48,7 @@ export const surface = {
48
48
  contextFileName: AGENTS_PATH,
49
49
  settings: settings.length ? settings : undefined,
50
50
  });
51
- return [{ kind: "file", path: MANIFEST_PATH, content: json(manifest) }];
51
+ return [{ kind: "merge", path: MANIFEST_PATH, format: "json", patch: manifest }];
52
52
  },
53
53
  validate(project) {
54
54
  // Gemini's own manifest validator: it parses the manifest, enforces the name and version
@@ -30,9 +30,9 @@ const project = {
30
30
  packageManager: "npm",
31
31
  };
32
32
  const [manifestFile, ...rest] = gemini.plan(project);
33
- if (manifestFile?.kind !== "file")
34
- throw new Error("expected one whole file");
35
- const manifest = JSON.parse(manifestFile.content);
33
+ if (manifestFile?.kind !== "merge")
34
+ throw new Error("expected one merge file");
35
+ const manifest = manifestFile.patch;
36
36
  describe("gemini", () => {
37
37
  it("is one root manifest: a dashed name, the kernel launch, AGENTS.md as the context file", () => {
38
38
  expect(rest).toEqual([]);
@@ -54,7 +54,7 @@ describe("gemini", () => {
54
54
  ],
55
55
  });
56
56
  // `skills/` and `commands/` are auto-discovered from the extension root; no key names them.
57
- expect(manifestFile.content).not.toContain('"skills"');
57
+ expect(JSON.stringify(manifestFile.patch)).not.toContain('"skills"');
58
58
  });
59
59
  it("validates through Gemini's own manifest validator, and carries only MCP tool calls", () => {
60
60
  expect(gemini.validate?.(project)).toEqual([
@@ -25,8 +25,6 @@
25
25
  * support. The fake-ctx test gets the same behavioural coverage with no Hermes import at all.
26
26
  */
27
27
  import { join } from "node:path";
28
- import { stringify as toml } from "smol-toml";
29
- import { stringify as yaml } from "yaml";
30
28
  import { KERNEL_DIR } from "../bindings/typescript.js";
31
29
  import { projectName } from "../identity/name.js";
32
30
  import { isPortable } from "../model.js";
@@ -57,6 +55,8 @@ export function hermesInstall() {
57
55
  return `curl -fsSL ${HERMES_PIN.installer} | bash -s -- --commit ${HERMES_PIN.commit} --skip-setup --skip-browser --skip-computer-use --non-interactive --no-skills`;
58
56
  }
59
57
  const HEADER = "# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.\n";
58
+ const INIT_BEGIN = "# tf:init";
59
+ const INIT_END = "# /tf:init";
60
60
  /**
61
61
  * The plugin package. It is a sibling of the author's own package rather than part of it: a
62
62
  * Hermes directory plugin *is* a Python package, and the two must be importable side by side.
@@ -243,7 +243,7 @@ function manifest(project, operations) {
243
243
  optional_env: optional.length ? optional : undefined,
244
244
  python_dependencies: core ? [core] : undefined,
245
245
  });
246
- return `${HEADER}${yaml(document)}`;
246
+ return document;
247
247
  }
248
248
  function pyproject(project) {
249
249
  const core = pinnedCore(project) ?? pypiName(project);
@@ -270,7 +270,7 @@ function pyproject(project) {
270
270
  : undefined,
271
271
  }),
272
272
  });
273
- return `${toml(document)}\n`;
273
+ return document;
274
274
  }
275
275
  /** isort order: plain `import` before `from`, alphabetical within each. */
276
276
  function sortImports(lines) {
@@ -357,7 +357,7 @@ def _handler(name: str) -> Callable[..., str]:
357
357
  return call`,
358
358
  };
359
359
  }
360
- function initPy(project, operations) {
360
+ function initRegion(project, operations) {
361
361
  const core = project.tool.binding === "python" ? pythonCore(project) : typescriptCore(project);
362
362
  const names = envDeclarations(project).required.map((entry) => entry.name);
363
363
  return `${HEADER}"""Hermes plugin for ${project.identity.name}, registered through PluginContext.
@@ -385,7 +385,7 @@ def _ready() -> bool:
385
385
  ${core.body}
386
386
 
387
387
 
388
- def register(ctx: Any) -> None:
388
+ def _register_generated(ctx: Any) -> None:
389
389
  # Hermes' own per-plugin data directory, through the variable every host uses.
390
390
  os.environ.setdefault(${JSON.stringify(dataDirEnvName(project))}, str(ctx.state.data_dir))
391
391
  for schema in TOOLS:
@@ -398,6 +398,20 @@ def register(ctx: Any) -> None:
398
398
  requires_env=list(REQUIRES_ENV),
399
399
  description=schema["description"],
400
400
  )
401
+
402
+
403
+ def register(ctx: Any) -> None:
404
+ _register_generated(ctx)
405
+ register_native(ctx)
406
+ `;
407
+ }
408
+ function initTemplate() {
409
+ return `${INIT_BEGIN}${INIT_END}
410
+
411
+
412
+ # Yours: use the full PluginContext here. This hook runs after Toolfactory's generated tools.
413
+ def register_native(ctx: Any) -> None:
414
+ pass
401
415
  `;
402
416
  }
403
417
  function readme(project, operations) {
@@ -474,9 +488,24 @@ export const surface = {
474
488
  const operations = includedOperations(project, surface);
475
489
  const dir = pluginDir(project);
476
490
  return [
477
- { kind: "file", path: `${dir}/plugin.yaml`, content: manifest(project, operations) },
478
- { kind: "file", path: `${dir}/__init__.py`, content: initPy(project, operations) },
479
- { kind: "file", path: `${HOST_DIR}/pyproject.toml`, content: pyproject(project) },
491
+ {
492
+ kind: "merge",
493
+ path: `${dir}/plugin.yaml`,
494
+ format: "yaml",
495
+ patch: manifest(project, operations),
496
+ },
497
+ {
498
+ kind: "region",
499
+ path: `${dir}/__init__.py`,
500
+ regions: [{ begin: INIT_BEGIN, end: INIT_END, content: initRegion(project, operations) }],
501
+ template: initTemplate(),
502
+ },
503
+ {
504
+ kind: "merge",
505
+ path: `${HOST_DIR}/pyproject.toml`,
506
+ format: "toml",
507
+ patch: pyproject(project),
508
+ },
480
509
  { kind: "file", path: `${HOST_DIR}/README.md`, content: readme(project, operations) },
481
510
  {
482
511
  kind: "file",
@@ -1,10 +1,11 @@
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
+ import { apply } from "../project/apply.js";
8
9
  import { HOST_DIR, pluginDir, surface } from "./hermes-native.js";
9
10
  const echo = {
10
11
  name: "echo",
@@ -56,10 +57,35 @@ function project(overrides = {}) {
56
57
  };
57
58
  }
58
59
  function emitted(target) {
59
- return Object.fromEntries(surface.plan(target).map((file) => [file.path, file.kind === "file" ? file.content : ""]));
60
+ return Object.fromEntries(surface
61
+ .plan(target)
62
+ .map((file) => [
63
+ file.path,
64
+ file.kind === "file"
65
+ ? file.content
66
+ : file.kind === "merge"
67
+ ? file.format === "yaml"
68
+ ? stringifyYaml(file.patch)
69
+ : file.format === "toml"
70
+ ? stringifyToml(file.patch)
71
+ : JSON.stringify(file.patch)
72
+ : file.regions.map((region) => region.content).join(""),
73
+ ]));
60
74
  }
61
75
  const python = project({ tool: { ...project().tool, binding: "python" } });
62
76
  describe("hermes-native", () => {
77
+ it("keeps native registrations in the author hook while refreshing generated registrations", () => {
78
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-region-"));
79
+ const target = { ...project(), root };
80
+ apply(root, surface.plan(target), "0.1.0");
81
+ const path = join(root, pluginDir(target), "__init__.py");
82
+ writeFileSync(path, readFileSync(path, "utf8").replace(" pass\n", ' ctx.register_tool(name="native", toolset="native", schema={}, handler=lambda _: "{}")\n'));
83
+ apply(root, surface.plan({ ...target, operations: [echo] }), "0.1.0");
84
+ const text = readFileSync(path, "utf8");
85
+ expect(text).toContain('name="native"');
86
+ expect(text).toContain("def _register_generated(ctx: Any)");
87
+ expect(text).toContain("_register_generated(ctx)\n register_native(ctx)");
88
+ });
63
89
  it("projects the manifest Hermes parses and the credentials it prompts for", () => {
64
90
  const files = emitted(project());
65
91
  const manifest = parseYaml(files[`${pluginDir(project())}/plugin.yaml`] ?? "");
@@ -157,12 +183,7 @@ describe.skipIf(!hasHermes())("hermes plugins doctor", () => {
157
183
  it("loads and registers the generated plugin", () => {
158
184
  const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-"));
159
185
  try {
160
- for (const file of surface.plan(project())) {
161
- if (file.kind !== "file")
162
- continue;
163
- mkdirSync(dirname(join(root, file.path)), { recursive: true });
164
- writeFileSync(join(root, file.path), file.content);
165
- }
186
+ apply(root, surface.plan(project()), "0.1.0");
166
187
  const commands = surface.validate?.({ ...project(), root }) ?? [];
167
188
  const command = commands.find((c) => c.label === "hermes plugins doctor");
168
189
  if (!command)