toolfactory 0.1.1 → 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 (110) hide show
  1. package/README.md +31 -3
  2. package/dist/bindings/python.js +1 -1
  3. package/dist/commands.d.ts +1 -1
  4. package/dist/distribution/native.d.ts +50 -0
  5. package/dist/distribution/native.js +414 -0
  6. package/dist/distribution/native.test.d.ts +1 -0
  7. package/dist/distribution/native.test.js +127 -0
  8. package/dist/distribution/system.d.ts +57 -0
  9. package/dist/distribution/system.js +637 -0
  10. package/dist/distribution/system.test.d.ts +1 -0
  11. package/dist/distribution/system.test.js +116 -0
  12. package/dist/e2e.test.js +7 -1
  13. package/dist/hosts/openclaw.js +6 -2
  14. package/dist/hosts/vscode.d.ts +1 -0
  15. package/dist/hosts/vscode.js +67 -0
  16. package/dist/model.d.ts +63 -2
  17. package/dist/model.js +18 -1
  18. package/dist/ops.d.ts +52 -0
  19. package/dist/project/apply.d.ts +1 -1
  20. package/dist/project/apply.js +194 -17
  21. package/dist/project/apply.test.js +103 -0
  22. package/dist/project/gate.d.ts +17 -2
  23. package/dist/project/gate.js +102 -23
  24. package/dist/project/history.d.ts +30 -1
  25. package/dist/project/lock.d.ts +5 -0
  26. package/dist/project/lock.js +9 -0
  27. package/dist/surfaces/agent-plugins.js +5 -4
  28. package/dist/surfaces/agents.js +27 -3
  29. package/dist/surfaces/agents.test.js +12 -0
  30. package/dist/surfaces/claude.js +8 -3
  31. package/dist/surfaces/codex.js +9 -4
  32. package/dist/surfaces/codex.test.js +16 -6
  33. package/dist/surfaces/cursor.js +2 -2
  34. package/dist/surfaces/distribution.test.js +121 -7
  35. package/dist/surfaces/dsh.js +29 -7
  36. package/dist/surfaces/dsh.test.js +11 -6
  37. package/dist/surfaces/gemini.js +2 -2
  38. package/dist/surfaces/gemini.test.js +4 -4
  39. package/dist/surfaces/hermes-native.js +43 -12
  40. package/dist/surfaces/hermes-native.test.js +53 -11
  41. package/dist/surfaces/mcp-registry.js +59 -7
  42. package/dist/surfaces/mcp-registry.test.js +23 -0
  43. package/dist/surfaces/mcpb.d.ts +1 -0
  44. package/dist/surfaces/mcpb.js +36 -23
  45. package/dist/surfaces/mcpb.test.js +31 -3
  46. package/dist/surfaces/npm.js +45 -14
  47. package/dist/surfaces/openclaw-native.js +10 -8
  48. package/dist/surfaces/openclaw-native.test.js +30 -0
  49. package/dist/surfaces/pypi.js +27 -3
  50. package/dist/surfaces/readme.d.ts +16 -0
  51. package/dist/surfaces/readme.js +17 -7
  52. package/dist/surfaces/registry.js +9 -3
  53. package/dist/surfaces/vscode-extension.d.ts +14 -0
  54. package/dist/surfaces/vscode-extension.js +312 -0
  55. package/dist/surfaces/vscode-extension.test.d.ts +1 -0
  56. package/dist/surfaces/vscode-extension.test.js +81 -0
  57. package/dist/surfaces/vscode-scaffold.d.ts +43 -0
  58. package/dist/surfaces/vscode-scaffold.js +43 -0
  59. package/dist/surfaces/workflows.js +57 -11
  60. package/dist/surfaces/workflows.test.js +71 -3
  61. package/dist/toolfactory/cli.js +1 -1
  62. package/dist/toolfactory/mcp.js +1 -1
  63. package/package.json +2 -1
  64. package/src/bindings/python.ts +1 -1
  65. package/src/distribution/native.test.ts +161 -0
  66. package/src/distribution/native.ts +460 -0
  67. package/src/distribution/system.test.ts +136 -0
  68. package/src/distribution/system.ts +688 -0
  69. package/src/e2e.test.ts +7 -1
  70. package/src/hosts/openclaw.ts +4 -2
  71. package/src/hosts/vscode.ts +79 -0
  72. package/src/model.ts +26 -2
  73. package/src/project/apply.test.ts +120 -0
  74. package/src/project/apply.ts +257 -15
  75. package/src/project/gate.ts +131 -24
  76. package/src/project/lock.ts +12 -0
  77. package/src/surfaces/agent-plugins.ts +5 -4
  78. package/src/surfaces/agents.test.ts +16 -0
  79. package/src/surfaces/agents.ts +38 -3
  80. package/src/surfaces/claude.ts +7 -3
  81. package/src/surfaces/codex.test.ts +15 -5
  82. package/src/surfaces/codex.ts +12 -6
  83. package/src/surfaces/cursor.ts +2 -10
  84. package/src/surfaces/distribution.test.ts +141 -6
  85. package/src/surfaces/dsh.test.ts +11 -5
  86. package/src/surfaces/dsh.ts +30 -7
  87. package/src/surfaces/gemini.test.ts +3 -3
  88. package/src/surfaces/gemini.ts +1 -2
  89. package/src/surfaces/hermes-native.test.ts +61 -10
  90. package/src/surfaces/hermes-native.ts +46 -13
  91. package/src/surfaces/mcp-registry.test.ts +28 -0
  92. package/src/surfaces/mcp-registry.ts +66 -8
  93. package/src/surfaces/mcpb.test.ts +32 -5
  94. package/src/surfaces/mcpb.ts +44 -23
  95. package/src/surfaces/npm.ts +47 -15
  96. package/src/surfaces/openclaw-native.test.ts +37 -0
  97. package/src/surfaces/openclaw-native.ts +49 -47
  98. package/src/surfaces/pypi.ts +27 -3
  99. package/src/surfaces/readme.ts +21 -6
  100. package/src/surfaces/registry.ts +9 -3
  101. package/src/surfaces/vscode-extension.test.ts +89 -0
  102. package/src/surfaces/vscode-extension.ts +345 -0
  103. package/src/surfaces/vscode-scaffold.ts +55 -0
  104. package/src/surfaces/workflows.test.ts +86 -3
  105. package/src/surfaces/workflows.ts +57 -11
  106. package/src/toolfactory/cli.ts +1 -1
  107. package/src/toolfactory/mcp.ts +1 -1
  108. package/web/dist/assets/{index-CCXhQKK3.css → index-BuT2czb2.css} +1 -1
  109. package/web/dist/assets/{index-D1PwZ3qS.js → index-CVm6m4s0.js} +1 -1
  110. package/web/dist/index.html +2 -2
@@ -1,9 +1,13 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
1
5
  import { describe, expect, it } from "vitest";
2
6
  import type { Operation, Project, SurfaceId } from "../model.js";
3
7
  import { buildPlan } from "../project/plan.js";
4
8
  import { surface as claude } from "./claude.js";
5
9
  import { surface as npm } from "./npm.js";
6
- import { MCP_NAME_BEGIN, MCP_NAME_END } from "./pypi.js";
10
+ import { MCP_NAME_BEGIN, MCP_NAME_END, surface as pypi } from "./pypi.js";
7
11
  import { INSTALL_BEGIN, INSTALL_END, surface as readme } from "./readme.js";
8
12
 
9
13
  const echo: Operation = { name: "echo", inputSchema: { type: "object" }, requires: [] };
@@ -52,14 +56,142 @@ describe("npm", () => {
52
56
  "--yes publint --strict",
53
57
  );
54
58
  });
59
+
60
+ it("makes a Python npm package a thin uvx launcher for its canonical PyPI distribution", () => {
61
+ const python = project(["npm", "pypi", "cli", "mcp"], {
62
+ tool: { ...project([]).tool, binding: "python", surfaces: ["npm", "pypi", "cli", "mcp"] },
63
+ });
64
+ const files = npm.plan(python);
65
+ const launcher = files.find((file) => file.path === "bin/hello.mjs");
66
+ const packageJson = files.find((file) => file.path === "package.json");
67
+ if (launcher?.kind !== "file" || packageJson?.kind !== "merge") {
68
+ throw new Error("expected a launcher and package metadata");
69
+ }
70
+ expect(launcher.content).toContain('spawnSync("uvx", ["--from", "hello==0.1.0", "hello"');
71
+ expect(launcher.content).toContain("...process.argv.slice(2)");
72
+ expect(packageJson.patch).toMatchObject({
73
+ name: "hello",
74
+ version: "0.1.0",
75
+ bin: { hello: "./bin/hello.mjs" },
76
+ files: ["bin", "README.md", "LICENSE"],
77
+ });
78
+ expect(region(python)).toContain(
79
+ "requires `uv`, and `hello` delegates to `uvx --from hello==0.1.0 hello`",
80
+ );
81
+ });
82
+
83
+ it("ships the built web page in Python's prebuilt wheel before release publication", () => {
84
+ const python = project(["pypi", "web"], {
85
+ tool: { ...project([]).tool, binding: "python", surfaces: ["pypi", "web"] },
86
+ });
87
+ const [file] = pypi.plan(python);
88
+ if (file?.kind !== "merge") throw new Error("expected Python package metadata");
89
+ expect(file.patch).toMatchObject({
90
+ tool: {
91
+ hatch: {
92
+ build: {
93
+ artifacts: ["web/dist/**"],
94
+ targets: {
95
+ wheel: {
96
+ "only-include": ["src/hello", "web/dist"],
97
+ sources: { src: "", "web/dist": "hello/web" },
98
+ },
99
+ },
100
+ },
101
+ },
102
+ },
103
+ });
104
+ expect(JSON.stringify(file.patch)).not.toContain("force-include");
105
+ });
106
+ });
107
+
108
+ const uv = spawnSync("uv", ["--version"], { encoding: "utf8" }).status === 0;
109
+
110
+ describe.skipIf(!uv)("Python release source", () => {
111
+ it("stages the built page and metadata from the canonical sdist for a uv bundle", () => {
112
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-python-sdist-"));
113
+ mkdirSync(join(root, "src", "hello"), { recursive: true });
114
+ mkdirSync(join(root, "web", "dist"), { recursive: true });
115
+ writeFileSync(
116
+ join(root, "pyproject.toml"),
117
+ `[project]
118
+ name = "hello"
119
+ version = "0.1.0"
120
+ readme = "README.md"
121
+
122
+ [build-system]
123
+ requires = ["hatchling"]
124
+ build-backend = "hatchling.build"
125
+
126
+ [tool.hatch.build]
127
+ artifacts = ["web/dist/**"]
128
+
129
+ [tool.hatch.build.targets.wheel]
130
+ packages = ["src/hello"]
131
+ only-include = ["src/hello", "web/dist"]
132
+
133
+ [tool.hatch.build.targets.wheel.sources]
134
+ src = ""
135
+ "web/dist" = "hello/web"
136
+
137
+ [tool.hatch.build.targets.sdist]
138
+ only-include = ["pyproject.toml", "README.md", "LICENSE", "src/hello", "web/dist"]
139
+ `,
140
+ );
141
+ writeFileSync(join(root, "README.md"), "# hello\n");
142
+ writeFileSync(join(root, "LICENSE"), "MIT\n");
143
+ writeFileSync(join(root, ".gitignore"), "dist/\n");
144
+ writeFileSync(join(root, "src", "hello", "__init__.py"), "");
145
+ mkdirSync(join(root, "web", "dist", "assets"), { recursive: true });
146
+ writeFileSync(
147
+ join(root, "web", "dist", "index.html"),
148
+ '<link rel="stylesheet" href="assets/app.css"><script src="assets/app.js"></script>\n',
149
+ );
150
+ writeFileSync(join(root, "web", "dist", "assets", "app.css"), "main{color:#000}\n");
151
+ writeFileSync(join(root, "web", "dist", "assets", "app.js"), "console.log('hello')\n");
152
+ const build = spawnSync("uv", ["build", "--out-dir", "dist/release/pypi"], {
153
+ cwd: root,
154
+ encoding: "utf8",
155
+ });
156
+ expect(build.status, build.stderr).toBe(0);
157
+ const wheel = spawnSync(
158
+ "unzip",
159
+ ["-l", join(root, "dist", "release", "pypi", "hello-0.1.0-py2.py3-none-any.whl")],
160
+ { encoding: "utf8" },
161
+ );
162
+ expect(wheel.status, wheel.stderr).toBe(0);
163
+ expect(wheel.stdout).toContain("hello/web/assets/app.css");
164
+ expect(wheel.stdout).toContain("hello/web/assets/app.js");
165
+ const stage = join(root, "dist", "mcpb");
166
+ mkdirSync(stage, { recursive: true });
167
+ const extract = spawnSync(
168
+ "tar",
169
+ [
170
+ "-xzf",
171
+ join(root, "dist", "release", "pypi", "hello-0.1.0.tar.gz"),
172
+ "-C",
173
+ stage,
174
+ "--strip-components=1",
175
+ ],
176
+ { encoding: "utf8" },
177
+ );
178
+ expect(extract.status, extract.stderr).toBe(0);
179
+ const sync = spawnSync("uv", ["sync", "--quiet", "--no-dev"], { cwd: stage, encoding: "utf8" });
180
+ expect(sync.status, sync.stderr).toBe(0);
181
+ expect(existsSync(join(stage, "README.md"))).toBe(true);
182
+ expect(existsSync(join(stage, "LICENSE"))).toBe(true);
183
+ expect(existsSync(join(stage, "web", "dist", "index.html"))).toBe(true);
184
+ expect(existsSync(join(stage, "web", "dist", "assets", "app.css"))).toBe(true);
185
+ expect(existsSync(join(stage, "web", "dist", "assets", "app.js"))).toBe(true);
186
+ });
55
187
  });
56
188
 
57
189
  describe("claude", () => {
58
190
  it("makes the repository its own single-plugin marketplace", () => {
59
191
  const files = claude.plan(project(["claude", "mcp"]));
60
192
  const file = files.find((entry) => entry.path === ".claude-plugin/marketplace.json");
61
- if (file?.kind !== "file") throw new Error("expected a whole file");
62
- expect(JSON.parse(file.content)).toMatchObject({
193
+ if (file?.kind !== "merge") throw new Error("expected a merge file");
194
+ expect(file.patch).toMatchObject({
63
195
  name: "hello",
64
196
  owner: { name: "GoatInAHat" },
65
197
  plugins: [{ name: "hello", source: "./", description: "Say hello" }],
@@ -103,14 +235,17 @@ describe("readme", () => {
103
235
  expect(body).toContain(
104
236
  "download `hello.mcpb` from the GitHub Release and double-click to install",
105
237
  );
106
- // The extension's three channels — unpacked from a checkout, the release assets (only the
107
- // signed xpi installs itself), the store listings — and the pairing step that ends each.
238
+ // The extension's three channels — unpacked from a checkout, release assets (including an
239
+ // optional signed XPI), and store listings — and the pairing step that ends each.
108
240
  expect(body).toContain("Load unpacked → `hosts/browser/.output/chrome-mv3`");
109
241
  expect(body).toContain("web-ext run");
110
242
  expect(body).toContain(
111
243
  "`hello-0.1.0-chrome.zip`, `hello-0.1.0-firefox.zip`, `hello-0.1.0-edge.zip`",
112
244
  );
113
- expect(body).toContain("Mozilla-signed `.xpi`");
245
+ expect(body).toContain(
246
+ "When Firefox signing credentials are configured, it also attaches a\n Mozilla-signed `.xpi`",
247
+ );
248
+ expect(body).not.toContain("Chrome no longer keeps side-loaded unpacked");
114
249
  expect(body).toContain("`npx -y hello mcp --http --pair`");
115
250
  });
116
251
 
@@ -36,8 +36,14 @@ const project: Project = {
36
36
 
37
37
  const files = Object.fromEntries(
38
38
  dsh.plan(project).map((file) => {
39
- if (file.kind !== "file") throw new Error("expected whole files only");
40
- return [file.path, file.content];
39
+ return [
40
+ file.path,
41
+ file.kind === "file"
42
+ ? file.content
43
+ : file.kind === "merge"
44
+ ? JSON.stringify(file.patch)
45
+ : file.regions.map((region) => region.content).join(""),
46
+ ];
41
47
  }),
42
48
  );
43
49
 
@@ -68,8 +74,8 @@ describe("dsh", () => {
68
74
  command: "npx",
69
75
  args: ["-y", "hello.tool@0.1.0", "mcp"],
70
76
  env: {
71
- APIKEY: "process.env.APIKEY",
72
- REGION: "process.env.REGION",
77
+ APIKEY: 'process.env.APIKEY ?? ""',
78
+ REGION: 'process.env.REGION ?? ""',
73
79
  },
74
80
  failOnStartupError: true,
75
81
  },
@@ -79,7 +85,7 @@ describe("dsh", () => {
79
85
  ]);
80
86
  // DSH scrubs KEY/PASSWORD/SECRET/TOKEN names before spawning, so every config variable is
81
87
  // restated as the `!!js` expression DSH evaluates when it loads the patch.
82
- expect(patch).toContain("APIKEY: !!js process.env.APIKEY");
88
+ expect(patch).toContain('APIKEY: !!js process.env.APIKEY ?? ""');
83
89
  });
84
90
 
85
91
  it("validates by booting the checkout keylessly, and carries only MCP tool calls", () => {
@@ -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";
@@ -71,7 +73,10 @@ function patch(project: Project, launch: { command: string; args: string[] }): s
71
73
  const env = Object.fromEntries(
72
74
  Object.keys(configProperties(project)).map((key) => [
73
75
  envName(key),
74
- jsExpression(`process.env.${envName(key)}`),
76
+ // Schemastery's dict rejects the empty object produced when every `!!js`
77
+ // value resolves to undefined. Keep every declared key string-valued;
78
+ // an absent optional credential remains the empty string for the child.
79
+ jsExpression(`process.env.${envName(key)} ?? ""`),
75
80
  ]),
76
81
  );
77
82
  const row = {
@@ -88,6 +93,10 @@ function patch(project: Project, launch: { command: string; args: string[] }): s
88
93
  return yamlStringify([{ insert: [row] }], { lineWidth: 0 });
89
94
  }
90
95
 
96
+ function patchTemplate(): string {
97
+ return `${PATCH_BEGIN}\n${PATCH_END}\n\n# Yours: add Cordis rows or local code configuration below this marker.\n`;
98
+ }
99
+
91
100
  /** The public name a bridged MCP tool takes in DSH; `mcp__<serverName>__<rawName>` is its contract. */
92
101
  export function toolName(project: Project, operation: string): string {
93
102
  return `mcp__${projectName.dshServer(project.identity.name)}__${operation}`;
@@ -117,20 +126,34 @@ export const surface: Surface = {
117
126
  dsh: { bundle: { patch: `./${PATCH_FILE}` } },
118
127
  });
119
128
  return [
120
- { kind: "file", path: `${HOST_DIR}/package.json`, content: json(manifest) },
129
+ { kind: "merge", path: `${HOST_DIR}/package.json`, format: "json", patch: manifest },
121
130
  {
122
- kind: "file",
131
+ kind: "region",
123
132
  path: `${HOST_DIR}/${PATCH_FILE}`,
124
133
  // `cwd` is deliberately absent: an installed bundle has no path to point at, and the
125
134
  // published launch resolves through the registry from wherever DSH is running.
126
- content: patch(project, kernelLaunch(project, ".")),
135
+ regions: [
136
+ {
137
+ begin: PATCH_BEGIN,
138
+ end: PATCH_END,
139
+ content: patch(project, kernelLaunch(project, ".")),
140
+ },
141
+ ],
142
+ template: patchTemplate(),
127
143
  },
128
144
  {
129
- kind: "file",
145
+ kind: "region",
130
146
  path: `${HOST_DIR}/${LOCAL_PATCH_FILE}`,
131
147
  // The kernel as `introspect` and `inspector.json` spawn it: repo-relative, so it resolves
132
148
  // against the directory DSH itself was started in.
133
- content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
149
+ regions: [
150
+ {
151
+ begin: PATCH_BEGIN,
152
+ end: PATCH_END,
153
+ content: patch(project, getBinding(project.tool.binding).kernelCommand(project)),
154
+ },
155
+ ],
156
+ template: patchTemplate(),
134
157
  },
135
158
  ];
136
159
  },
@@ -34,8 +34,8 @@ const project: Project = {
34
34
  };
35
35
 
36
36
  const [manifestFile, ...rest] = gemini.plan(project);
37
- if (manifestFile?.kind !== "file") throw new Error("expected one whole file");
38
- const manifest = JSON.parse(manifestFile.content) as Record<string, unknown>;
37
+ if (manifestFile?.kind !== "merge") throw new Error("expected one merge file");
38
+ const manifest = manifestFile.patch as Record<string, unknown>;
39
39
 
40
40
  describe("gemini", () => {
41
41
  it("is one root manifest: a dashed name, the kernel launch, AGENTS.md as the context file", () => {
@@ -58,7 +58,7 @@ describe("gemini", () => {
58
58
  ],
59
59
  });
60
60
  // `skills/` and `commands/` are auto-discovered from the extension root; no key names them.
61
- expect(manifestFile.content).not.toContain('"skills"');
61
+ expect(JSON.stringify(manifestFile.patch)).not.toContain('"skills"');
62
62
  });
63
63
 
64
64
  it("validates through Gemini's own manifest validator, and carries only MCP tool calls", () => {
@@ -26,7 +26,6 @@ import {
26
26
  configProperties,
27
27
  envName,
28
28
  isSensitive,
29
- json,
30
29
  kernelLaunch,
31
30
  mcpVerdict,
32
31
  } from "./shared.js";
@@ -64,7 +63,7 @@ export const surface: Surface = {
64
63
  contextFileName: AGENTS_PATH,
65
64
  settings: settings.length ? settings : undefined,
66
65
  });
67
- return [{ kind: "file", path: MANIFEST_PATH, content: json(manifest) }];
66
+ return [{ kind: "merge", path: MANIFEST_PATH, format: "json", patch: manifest }];
68
67
  },
69
68
  validate(project) {
70
69
  // Gemini's own manifest validator: it parses the manifest, enforces the name and version
@@ -1,11 +1,12 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import { parse as parseToml } from "smol-toml";
4
+ import { join } from "node:path";
5
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
6
6
  import { describe, expect, it } from "vitest";
7
- import { parse as parseYaml } from "yaml";
7
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
8
8
  import type { Operation, Project } from "../model.js";
9
+ import { apply } from "../project/apply.js";
9
10
  import { HOST_DIR, pluginDir, surface } from "./hermes-native.js";
10
11
 
11
12
  const echo: Operation = {
@@ -21,6 +22,12 @@ const echo: Operation = {
21
22
  requires: [],
22
23
  };
23
24
  const shoot: Operation = { name: "shoot", inputSchema: { type: "object" }, requires: ["browser"] };
25
+ const web: Operation = {
26
+ name: "web",
27
+ description: "Open the generated operations page.",
28
+ inputSchema: { type: "object" },
29
+ requires: [],
30
+ };
24
31
 
25
32
  function project(overrides: Partial<Project> = {}): Project {
26
33
  return {
@@ -55,13 +62,45 @@ function project(overrides: Partial<Project> = {}): Project {
55
62
 
56
63
  function emitted(target: Project): Record<string, string> {
57
64
  return Object.fromEntries(
58
- surface.plan(target).map((file) => [file.path, file.kind === "file" ? file.content : ""]),
65
+ surface
66
+ .plan(target)
67
+ .map((file) => [
68
+ file.path,
69
+ file.kind === "file"
70
+ ? file.content
71
+ : file.kind === "merge"
72
+ ? file.format === "yaml"
73
+ ? stringifyYaml(file.patch)
74
+ : file.format === "toml"
75
+ ? stringifyToml(file.patch)
76
+ : JSON.stringify(file.patch)
77
+ : file.regions.map((region) => region.content).join(""),
78
+ ]),
59
79
  );
60
80
  }
61
81
 
62
82
  const python = project({ tool: { ...project().tool, binding: "python" } });
63
83
 
64
84
  describe("hermes-native", () => {
85
+ it("keeps native registrations in the author hook while refreshing generated registrations", () => {
86
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-region-"));
87
+ const target = { ...project(), root };
88
+ apply(root, surface.plan(target), "0.1.0");
89
+ const path = join(root, pluginDir(target), "__init__.py");
90
+ writeFileSync(
91
+ path,
92
+ readFileSync(path, "utf8").replace(
93
+ " pass\n",
94
+ ' ctx.register_tool(name="native", toolset="native", schema={}, handler=lambda _: "{}")\n',
95
+ ),
96
+ );
97
+ apply(root, surface.plan({ ...target, operations: [echo] }), "0.1.0");
98
+ const text = readFileSync(path, "utf8");
99
+ expect(text).toContain('name="native"');
100
+ expect(text).toContain("def _register_generated(ctx: Any)");
101
+ expect(text).toContain("_register_generated(ctx)\n register_native(ctx)");
102
+ });
103
+
65
104
  it("projects the manifest Hermes parses and the credentials it prompts for", () => {
66
105
  const files = emitted(project());
67
106
  const manifest = parseYaml(files[`${pluginDir(project())}/plugin.yaml`] ?? "") as Record<
@@ -128,6 +167,22 @@ describe("hermes-native", () => {
128
167
  expect(pyproject.tool.uv.sources.hello).toEqual({ path: "../..", editable: true });
129
168
  });
130
169
 
170
+ it("gives a Python Hermes plugin the generated web operation used by its tool schema", () => {
171
+ const target = project({
172
+ tool: {
173
+ ...project().tool,
174
+ binding: "python",
175
+ surfaces: ["hermes-native", "cli", "mcp", "web"],
176
+ },
177
+ operations: [echo, web],
178
+ });
179
+ const native = emitted(target)[`${pluginDir(target)}/__init__.py`] ?? "";
180
+ expect(native).toContain("from hello.ops import OPERATIONS as AUTHORED_OPERATIONS");
181
+ expect(native).toContain("from hello.toolfactory.web import OPERATION as WEB_OPERATION");
182
+ expect(native).toContain("OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]");
183
+ expect(native).toContain('handler=_handler(schema["name"])');
184
+ });
185
+
131
186
  it("emits a fake-ctx test that registers and calls a real handler, no Hermes import", () => {
132
187
  const shim = emitted(project())[`${HOST_DIR}/tests/test_plugin.py`] ?? "";
133
188
  expect(shim).not.toMatch(/^\s*(import|from) hermes/m);
@@ -162,11 +217,7 @@ describe.skipIf(!hasHermes())("hermes plugins doctor", () => {
162
217
  it("loads and registers the generated plugin", () => {
163
218
  const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-"));
164
219
  try {
165
- for (const file of surface.plan(project())) {
166
- if (file.kind !== "file") continue;
167
- mkdirSync(dirname(join(root, file.path)), { recursive: true });
168
- writeFileSync(join(root, file.path), file.content);
169
- }
220
+ apply(root, surface.plan(project()), "0.1.0");
170
221
  const commands = surface.validate?.({ ...project(), root }) ?? [];
171
222
  const command = commands.find((c) => c.label === "hermes plugins doctor");
172
223
  if (!command) throw new Error("the hermes surface no longer declares a doctor validator");
@@ -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, type Operation, type Project, type Surface, type Verdict } from "../model.js";
@@ -36,6 +34,7 @@ import {
36
34
  configProperties,
37
35
  dataDirEnvName,
38
36
  envName,
37
+ has,
39
38
  isSensitive,
40
39
  pypiName,
41
40
  requiredConfig,
@@ -71,6 +70,8 @@ export function hermesInstall(): string {
71
70
  }
72
71
 
73
72
  const HEADER = "# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.\n";
73
+ const INIT_BEGIN = "# tf:init";
74
+ const INIT_END = "# /tf:init";
74
75
 
75
76
  /**
76
77
  * The plugin package. It is a sibling of the author's own package rather than part of it: a
@@ -250,7 +251,7 @@ def test_register_adds_every_declared_tool() -> None:
250
251
  `;
251
252
  }
252
253
 
253
- function manifest(project: Project, operations: Operation[]): string {
254
+ function manifest(project: Project, operations: Operation[]): Record<string, unknown> {
254
255
  const { identity } = project;
255
256
  const { required, optional } = envDeclarations(project);
256
257
  const shim = project.tool.binding !== "python";
@@ -277,10 +278,10 @@ function manifest(project: Project, operations: Operation[]): string {
277
278
  optional_env: optional.length ? optional : undefined,
278
279
  python_dependencies: core ? [core] : undefined,
279
280
  });
280
- return `${HEADER}${yaml(document)}`;
281
+ return document as Record<string, unknown>;
281
282
  }
282
283
 
283
- function pyproject(project: Project): string {
284
+ function pyproject(project: Project): Record<string, unknown> {
284
285
  const core = pinnedCore(project) ?? pypiName(project);
285
286
  const python = project.tool.binding === "python";
286
287
  const document = compact({
@@ -305,7 +306,7 @@ function pyproject(project: Project): string {
305
306
  : undefined,
306
307
  }),
307
308
  });
308
- return `${toml(document)}\n`;
309
+ return document as Record<string, unknown>;
309
310
  }
310
311
 
311
312
  /** isort order: plain `import` before `from`, alphabetical within each. */
@@ -333,14 +334,16 @@ interface PluginCore {
333
334
  /** Python core: the handler calls the author's own operation in-process. */
334
335
  function pythonCore(project: Project): PluginCore {
335
336
  const pkg = projectName.pythonPackage(project.identity.name);
337
+ const web = has(project, "web");
336
338
  return {
337
339
  stdlib: ["import asyncio", "import inspect"],
338
340
  external: [
339
341
  "from pydantic import BaseModel",
340
- `from ${pkg}.ops import OPERATIONS`,
342
+ `from ${pkg}.ops import OPERATIONS${web ? " as AUTHORED_OPERATIONS" : ""}`,
341
343
  `from ${pkg}.toolfactory.config import context`,
344
+ ...(web ? [`from ${pkg}.toolfactory.web import OPERATION as WEB_OPERATION`] : []),
342
345
  ],
343
- body: `def _operation(name: str) -> Any:
346
+ body: `${web ? "OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]\n\n\n" : ""}def _operation(name: str) -> Any:
344
347
  """Fail loudly if the core package and this plugin disagree on the operations."""
345
348
  for operation in OPERATIONS:
346
349
  if operation.name == name:
@@ -403,7 +406,7 @@ def _handler(name: str) -> Callable[..., str]:
403
406
  };
404
407
  }
405
408
 
406
- function initPy(project: Project, operations: Operation[]): string {
409
+ function initRegion(project: Project, operations: Operation[]): string {
407
410
  const core = project.tool.binding === "python" ? pythonCore(project) : typescriptCore(project);
408
411
  const names = envDeclarations(project).required.map((entry) => entry.name);
409
412
  return `${HEADER}"""Hermes plugin for ${project.identity.name}, registered through PluginContext.
@@ -431,7 +434,7 @@ def _ready() -> bool:
431
434
  ${core.body}
432
435
 
433
436
 
434
- def register(ctx: Any) -> None:
437
+ def _register_generated(ctx: Any) -> None:
435
438
  # Hermes' own per-plugin data directory, through the variable every host uses.
436
439
  os.environ.setdefault(${JSON.stringify(dataDirEnvName(project))}, str(ctx.state.data_dir))
437
440
  for schema in TOOLS:
@@ -444,6 +447,21 @@ def register(ctx: Any) -> None:
444
447
  requires_env=list(REQUIRES_ENV),
445
448
  description=schema["description"],
446
449
  )
450
+
451
+
452
+ def register(ctx: Any) -> None:
453
+ _register_generated(ctx)
454
+ register_native(ctx)
455
+ `;
456
+ }
457
+
458
+ function initTemplate(): string {
459
+ return `${INIT_BEGIN}${INIT_END}
460
+
461
+
462
+ # Yours: use the full PluginContext here. This hook runs after Toolfactory's generated tools.
463
+ def register_native(ctx: Any) -> None:
464
+ pass
447
465
  `;
448
466
  }
449
467
 
@@ -549,9 +567,24 @@ export const surface: Surface = {
549
567
  const operations = includedOperations(project, surface);
550
568
  const dir = pluginDir(project);
551
569
  return [
552
- { kind: "file", path: `${dir}/plugin.yaml`, content: manifest(project, operations) },
553
- { kind: "file", path: `${dir}/__init__.py`, content: initPy(project, operations) },
554
- { kind: "file", path: `${HOST_DIR}/pyproject.toml`, content: pyproject(project) },
570
+ {
571
+ kind: "merge",
572
+ path: `${dir}/plugin.yaml`,
573
+ format: "yaml",
574
+ patch: manifest(project, operations),
575
+ },
576
+ {
577
+ kind: "region",
578
+ path: `${dir}/__init__.py`,
579
+ regions: [{ begin: INIT_BEGIN, end: INIT_END, content: initRegion(project, operations) }],
580
+ template: initTemplate(),
581
+ },
582
+ {
583
+ kind: "merge",
584
+ path: `${HOST_DIR}/pyproject.toml`,
585
+ format: "toml",
586
+ patch: pyproject(project),
587
+ },
555
588
  { kind: "file", path: `${HOST_DIR}/README.md`, content: readme(project, operations) },
556
589
  {
557
590
  kind: "file",
@@ -65,6 +65,34 @@ describe("mcp-registry Dockerfile", () => {
65
65
 
66
66
  const py = files(project({ tool: { ...project().tool, binding: "python" } }));
67
67
  expect(py.Dockerfile).toContain('ENTRYPOINT ["python","-m","hello.toolfactory.mcp"]');
68
+ expect(py.Dockerfile).toContain("COPY . .\nRUN uv sync --no-dev");
69
+ expect(py.Dockerfile).not.toContain("COPY pyproject.toml ./");
70
+ expect(py.Dockerfile).toContain(
71
+ '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',
72
+ );
73
+ expect(py.Dockerfile).toContain("COPY --from=build /app/runtime/ ./");
74
+ expect(py.Dockerfile).not.toContain("COPY --from=build /app .");
75
+
76
+ const pythonWeb = files(
77
+ project({
78
+ tool: { ...project().tool, binding: "python", surfaces: ["mcp-registry", "web"] },
79
+ }),
80
+ );
81
+ expect(pythonWeb.Dockerfile).toContain("FROM node:24-alpine AS web");
82
+ expect(pythonWeb.Dockerfile).toContain("COPY --from=web /app/web/dist ./src/hello/web");
83
+ });
84
+
85
+ it("ships an author-extensible Docker ignore region without excluding source metadata", () => {
86
+ const ignore = surface.plan(project()).find((file) => file.path === ".dockerignore");
87
+ if (ignore?.kind !== "region") throw new Error("expected a Docker ignore region");
88
+ expect(ignore.template).toContain("# tf:dockerignore\n# /tf:dockerignore");
89
+ expect(ignore.regions[0]?.content).toContain(".git/");
90
+ expect(ignore.regions[0]?.content).toContain(".env.*");
91
+ expect(ignore.regions[0]?.content).toContain("!.env.example");
92
+ expect(ignore.regions[0]?.content).toContain("**/node_modules/");
93
+ expect(ignore.regions[0]?.content).toContain("**/.venv/");
94
+ expect(ignore.regions[0]?.content).not.toContain("README");
95
+ expect(ignore.regions[0]?.content).not.toContain("web/");
68
96
  });
69
97
  });
70
98