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
@@ -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. */
@@ -82,6 +84,9 @@ function patch(project, launch) {
82
84
  };
83
85
  return yamlStringify([{ insert: [row] }], { lineWidth: 0 });
84
86
  }
87
+ function patchTemplate() {
88
+ return `${PATCH_BEGIN}\n${PATCH_END}\n\n# Yours: add Cordis rows or local code configuration below this marker.\n`;
89
+ }
85
90
  /** The public name a bridged MCP tool takes in DSH; `mcp__<serverName>__<rawName>` is its contract. */
86
91
  export function toolName(project, operation) {
87
92
  return `mcp__${projectName.dshServer(project.identity.name)}__${operation}`;
@@ -108,20 +113,34 @@ export const surface = {
108
113
  dsh: { bundle: { patch: `./${PATCH_FILE}` } },
109
114
  });
110
115
  return [
111
- { kind: "file", path: `${HOST_DIR}/package.json`, content: json(manifest) },
116
+ { kind: "merge", path: `${HOST_DIR}/package.json`, format: "json", patch: manifest },
112
117
  {
113
- kind: "file",
118
+ kind: "region",
114
119
  path: `${HOST_DIR}/${PATCH_FILE}`,
115
120
  // `cwd` is deliberately absent: an installed bundle has no path to point at, and the
116
121
  // published launch resolves through the registry from wherever DSH is running.
117
- content: patch(project, kernelLaunch(project, ".")),
122
+ regions: [
123
+ {
124
+ begin: PATCH_BEGIN,
125
+ end: PATCH_END,
126
+ content: patch(project, kernelLaunch(project, ".")),
127
+ },
128
+ ],
129
+ template: patchTemplate(),
118
130
  },
119
131
  {
120
- kind: "file",
132
+ kind: "region",
121
133
  path: `${HOST_DIR}/${LOCAL_PATCH_FILE}`,
122
134
  // The kernel as `introspect` and `inspector.json` spawn it: repo-relative, so it resolves
123
135
  // against the directory DSH itself was started in.
124
- content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
136
+ regions: [
137
+ {
138
+ begin: PATCH_BEGIN,
139
+ end: PATCH_END,
140
+ content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
141
+ },
142
+ ],
143
+ template: patchTemplate(),
125
144
  },
126
145
  ];
127
146
  },
@@ -31,9 +31,14 @@ const project = {
31
31
  packageManager: "npm",
32
32
  };
33
33
  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];
34
+ return [
35
+ file.path,
36
+ file.kind === "file"
37
+ ? file.content
38
+ : file.kind === "merge"
39
+ ? JSON.stringify(file.patch)
40
+ : file.regions.map((region) => region.content).join(""),
41
+ ];
37
42
  }));
38
43
  describe("dsh", () => {
39
44
  it("is two shipped files and no code: the bundle manifest and one mcp-client insert row", () => {
@@ -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)
@@ -63,6 +63,42 @@ const DOCKER_PACKAGE_MANAGERS = {
63
63
  prune: "pnpm prune --prod",
64
64
  },
65
65
  };
66
+ const DOCKERIGNORE_BEGIN = "# tf:dockerignore";
67
+ const DOCKERIGNORE_END = "# /tf:dockerignore";
68
+ /** Build context hygiene is generated, while the rest of `.dockerignore` stays author-owned. */
69
+ function dockerignore() {
70
+ return {
71
+ kind: "region",
72
+ path: ".dockerignore",
73
+ regions: [
74
+ {
75
+ begin: DOCKERIGNORE_BEGIN,
76
+ end: DOCKERIGNORE_END,
77
+ content: `
78
+ .git/
79
+ .env
80
+ .env.*
81
+ !.env.example
82
+ node_modules/
83
+ **/node_modules/
84
+ .venv/
85
+ **/.venv/
86
+ dist/
87
+ coverage/
88
+ test-results/
89
+ playwright-report/
90
+ .pytest_cache/
91
+ .mypy_cache/
92
+ .ruff_cache/
93
+ __pycache__/
94
+ **/__pycache__/
95
+ .vscode-test/
96
+ `,
97
+ },
98
+ ],
99
+ template: `${DOCKERIGNORE_BEGIN}\n${DOCKERIGNORE_END}\n\n# Add local build inputs to exclude below.\n`,
100
+ };
101
+ }
66
102
  /**
67
103
  * The root `Dockerfile`, stdio only (§4.1: no `--host` flag exists to bind past loopback), one
68
104
  * multi-stage build per binding. `label` is `server.json`'s own `name`, byte for byte — the MCP
@@ -83,16 +119,17 @@ RUN npm -C web run build
83
119
  `
84
120
  : ""}FROM ${UV_IMAGE} AS build
85
121
  WORKDIR /app
86
- COPY pyproject.toml ./
87
- COPY src ./src
88
- RUN uv sync --no-dev
122
+ # pyproject metadata can name README, license, and other package assets; copy the project without
123
+ # assuming optional metadata files exist.
124
+ COPY . .
125
+ ${web ? "COPY --from=web /app/web/dist ./web/dist\n" : ""}RUN uv sync --no-dev
126
+ RUN mkdir /app/runtime && cp pyproject.toml /app/runtime/ && cp -R src /app/runtime/src && for path in README README.md README.rst LICENSE LICENSE.md LICENSE.txt NOTICE NOTICE.md NOTICE.txt; do if [ -f "$path" ]; then cp "$path" /app/runtime/; fi; done
89
127
 
90
128
  FROM ${PYTHON_IMAGE}
91
129
  LABEL io.modelcontextprotocol.server.name="${label}"
92
130
  WORKDIR /app
93
131
  COPY --from=build /app/.venv ./.venv
94
- COPY --from=build /app/src ./src
95
- COPY --from=build /app/pyproject.toml ./
132
+ COPY --from=build /app/runtime/ ./
96
133
  ${web
97
134
  ? `COPY --from=web /app/web/dist ./src/${pythonPackage(project)}/${WEB_DIR}
98
135
  `
@@ -169,6 +206,7 @@ export const surface = {
169
206
  return [
170
207
  { kind: "file", path: "server.json", content: json(server) },
171
208
  { kind: "file", path: "Dockerfile", content: dockerfileTemplate(project, name) },
209
+ dockerignore(),
172
210
  ];
173
211
  },
174
212
  };