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,8 +1,12 @@
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 { buildPlan } from "../project/plan.js";
3
7
  import { surface as claude } from "./claude.js";
4
8
  import { surface as npm } from "./npm.js";
5
- import { MCP_NAME_BEGIN, MCP_NAME_END } from "./pypi.js";
9
+ import { MCP_NAME_BEGIN, MCP_NAME_END, surface as pypi } from "./pypi.js";
6
10
  import { INSTALL_BEGIN, INSTALL_END, surface as readme } from "./readme.js";
7
11
  const echo = { name: "echo", inputSchema: { type: "object" }, requires: [] };
8
12
  function project(surfaces, overrides = {}) {
@@ -47,14 +51,123 @@ describe("npm", () => {
47
51
  });
48
52
  expect(npm.validate?.(project(["npm"])).map((command) => command.args.join(" "))).toContain("--yes publint --strict");
49
53
  });
54
+ it("makes a Python npm package a thin uvx launcher for its canonical PyPI distribution", () => {
55
+ const python = project(["npm", "pypi", "cli", "mcp"], {
56
+ tool: { ...project([]).tool, binding: "python", surfaces: ["npm", "pypi", "cli", "mcp"] },
57
+ });
58
+ const files = npm.plan(python);
59
+ const launcher = files.find((file) => file.path === "bin/hello.mjs");
60
+ const packageJson = files.find((file) => file.path === "package.json");
61
+ if (launcher?.kind !== "file" || packageJson?.kind !== "merge") {
62
+ throw new Error("expected a launcher and package metadata");
63
+ }
64
+ expect(launcher.content).toContain('spawnSync("uvx", ["--from", "hello==0.1.0", "hello"');
65
+ expect(launcher.content).toContain("...process.argv.slice(2)");
66
+ expect(packageJson.patch).toMatchObject({
67
+ name: "hello",
68
+ version: "0.1.0",
69
+ bin: { hello: "./bin/hello.mjs" },
70
+ files: ["bin", "README.md", "LICENSE"],
71
+ });
72
+ expect(region(python)).toContain("requires `uv`, and `hello` delegates to `uvx --from hello==0.1.0 hello`");
73
+ });
74
+ it("ships the built web page in Python's prebuilt wheel before release publication", () => {
75
+ const python = project(["pypi", "web"], {
76
+ tool: { ...project([]).tool, binding: "python", surfaces: ["pypi", "web"] },
77
+ });
78
+ const [file] = pypi.plan(python);
79
+ if (file?.kind !== "merge")
80
+ throw new Error("expected Python package metadata");
81
+ expect(file.patch).toMatchObject({
82
+ tool: {
83
+ hatch: {
84
+ build: {
85
+ artifacts: ["web/dist/**"],
86
+ targets: {
87
+ wheel: {
88
+ "only-include": ["src/hello", "web/dist"],
89
+ sources: { src: "", "web/dist": "hello/web" },
90
+ },
91
+ },
92
+ },
93
+ },
94
+ },
95
+ });
96
+ expect(JSON.stringify(file.patch)).not.toContain("force-include");
97
+ });
98
+ });
99
+ const uv = spawnSync("uv", ["--version"], { encoding: "utf8" }).status === 0;
100
+ describe.skipIf(!uv)("Python release source", () => {
101
+ it("stages the built page and metadata from the canonical sdist for a uv bundle", () => {
102
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-python-sdist-"));
103
+ mkdirSync(join(root, "src", "hello"), { recursive: true });
104
+ mkdirSync(join(root, "web", "dist"), { recursive: true });
105
+ writeFileSync(join(root, "pyproject.toml"), `[project]
106
+ name = "hello"
107
+ version = "0.1.0"
108
+ readme = "README.md"
109
+
110
+ [build-system]
111
+ requires = ["hatchling"]
112
+ build-backend = "hatchling.build"
113
+
114
+ [tool.hatch.build]
115
+ artifacts = ["web/dist/**"]
116
+
117
+ [tool.hatch.build.targets.wheel]
118
+ packages = ["src/hello"]
119
+ only-include = ["src/hello", "web/dist"]
120
+
121
+ [tool.hatch.build.targets.wheel.sources]
122
+ src = ""
123
+ "web/dist" = "hello/web"
124
+
125
+ [tool.hatch.build.targets.sdist]
126
+ only-include = ["pyproject.toml", "README.md", "LICENSE", "src/hello", "web/dist"]
127
+ `);
128
+ writeFileSync(join(root, "README.md"), "# hello\n");
129
+ writeFileSync(join(root, "LICENSE"), "MIT\n");
130
+ writeFileSync(join(root, ".gitignore"), "dist/\n");
131
+ writeFileSync(join(root, "src", "hello", "__init__.py"), "");
132
+ mkdirSync(join(root, "web", "dist", "assets"), { recursive: true });
133
+ writeFileSync(join(root, "web", "dist", "index.html"), '<link rel="stylesheet" href="assets/app.css"><script src="assets/app.js"></script>\n');
134
+ writeFileSync(join(root, "web", "dist", "assets", "app.css"), "main{color:#000}\n");
135
+ writeFileSync(join(root, "web", "dist", "assets", "app.js"), "console.log('hello')\n");
136
+ const build = spawnSync("uv", ["build", "--out-dir", "dist/release/pypi"], {
137
+ cwd: root,
138
+ encoding: "utf8",
139
+ });
140
+ expect(build.status, build.stderr).toBe(0);
141
+ const wheel = spawnSync("unzip", ["-l", join(root, "dist", "release", "pypi", "hello-0.1.0-py2.py3-none-any.whl")], { encoding: "utf8" });
142
+ expect(wheel.status, wheel.stderr).toBe(0);
143
+ expect(wheel.stdout).toContain("hello/web/assets/app.css");
144
+ expect(wheel.stdout).toContain("hello/web/assets/app.js");
145
+ const stage = join(root, "dist", "mcpb");
146
+ mkdirSync(stage, { recursive: true });
147
+ const extract = spawnSync("tar", [
148
+ "-xzf",
149
+ join(root, "dist", "release", "pypi", "hello-0.1.0.tar.gz"),
150
+ "-C",
151
+ stage,
152
+ "--strip-components=1",
153
+ ], { encoding: "utf8" });
154
+ expect(extract.status, extract.stderr).toBe(0);
155
+ const sync = spawnSync("uv", ["sync", "--quiet", "--no-dev"], { cwd: stage, encoding: "utf8" });
156
+ expect(sync.status, sync.stderr).toBe(0);
157
+ expect(existsSync(join(stage, "README.md"))).toBe(true);
158
+ expect(existsSync(join(stage, "LICENSE"))).toBe(true);
159
+ expect(existsSync(join(stage, "web", "dist", "index.html"))).toBe(true);
160
+ expect(existsSync(join(stage, "web", "dist", "assets", "app.css"))).toBe(true);
161
+ expect(existsSync(join(stage, "web", "dist", "assets", "app.js"))).toBe(true);
162
+ });
50
163
  });
51
164
  describe("claude", () => {
52
165
  it("makes the repository its own single-plugin marketplace", () => {
53
166
  const files = claude.plan(project(["claude", "mcp"]));
54
167
  const file = files.find((entry) => entry.path === ".claude-plugin/marketplace.json");
55
- if (file?.kind !== "file")
56
- throw new Error("expected a whole file");
57
- expect(JSON.parse(file.content)).toMatchObject({
168
+ if (file?.kind !== "merge")
169
+ throw new Error("expected a merge file");
170
+ expect(file.patch).toMatchObject({
58
171
  name: "hello",
59
172
  owner: { name: "GoatInAHat" },
60
173
  plugins: [{ name: "hello", source: "./", description: "Say hello" }],
@@ -91,12 +204,13 @@ describe("readme", () => {
91
204
  expect(body).toContain("`codex plugin add hello@hello`");
92
205
  expect(body).toContain("`gemini extensions install https://github.com/GoatInAHat/Hello-Tool`");
93
206
  expect(body).toContain("download `hello.mcpb` from the GitHub Release and double-click to install");
94
- // The extension's three channels — unpacked from a checkout, the release assets (only the
95
- // signed xpi installs itself), the store listings — and the pairing step that ends each.
207
+ // The extension's three channels — unpacked from a checkout, release assets (including an
208
+ // optional signed XPI), and store listings — and the pairing step that ends each.
96
209
  expect(body).toContain("Load unpacked → `hosts/browser/.output/chrome-mv3`");
97
210
  expect(body).toContain("web-ext run");
98
211
  expect(body).toContain("`hello-0.1.0-chrome.zip`, `hello-0.1.0-firefox.zip`, `hello-0.1.0-edge.zip`");
99
- expect(body).toContain("Mozilla-signed `.xpi`");
212
+ expect(body).toContain("When Firefox signing credentials are configured, it also attaches a\n Mozilla-signed `.xpi`");
213
+ expect(body).not.toContain("Chrome no longer keeps side-loaded unpacked");
100
214
  expect(body).toContain("`npx -y hello mcp --http --pair`");
101
215
  });
102
216
  it("names the one command that opens the web app, and only when that surface is selected", () => {
@@ -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. */
@@ -64,7 +66,10 @@ function patch(project, launch) {
64
66
  const serverName = projectName.dshServer(project.identity.name);
65
67
  const env = Object.fromEntries(Object.keys(configProperties(project)).map((key) => [
66
68
  envName(key),
67
- jsExpression(`process.env.${envName(key)}`),
69
+ // Schemastery's dict rejects the empty object produced when every `!!js`
70
+ // value resolves to undefined. Keep every declared key string-valued;
71
+ // an absent optional credential remains the empty string for the child.
72
+ jsExpression(`process.env.${envName(key)} ?? ""`),
68
73
  ]));
69
74
  const row = {
70
75
  id: rowId(project),
@@ -79,6 +84,9 @@ function patch(project, launch) {
79
84
  };
80
85
  return yamlStringify([{ insert: [row] }], { lineWidth: 0 });
81
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
+ }
82
90
  /** The public name a bridged MCP tool takes in DSH; `mcp__<serverName>__<rawName>` is its contract. */
83
91
  export function toolName(project, operation) {
84
92
  return `mcp__${projectName.dshServer(project.identity.name)}__${operation}`;
@@ -105,20 +113,34 @@ export const surface = {
105
113
  dsh: { bundle: { patch: `./${PATCH_FILE}` } },
106
114
  });
107
115
  return [
108
- { kind: "file", path: `${HOST_DIR}/package.json`, content: json(manifest) },
116
+ { kind: "merge", path: `${HOST_DIR}/package.json`, format: "json", patch: manifest },
109
117
  {
110
- kind: "file",
118
+ kind: "region",
111
119
  path: `${HOST_DIR}/${PATCH_FILE}`,
112
120
  // `cwd` is deliberately absent: an installed bundle has no path to point at, and the
113
121
  // published launch resolves through the registry from wherever DSH is running.
114
- 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(),
115
130
  },
116
131
  {
117
- kind: "file",
132
+ kind: "region",
118
133
  path: `${HOST_DIR}/${LOCAL_PATCH_FILE}`,
119
134
  // The kernel as `introspect` and `inspector.json` spawn it: repo-relative, so it resolves
120
135
  // against the directory DSH itself was started in.
121
- 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(),
122
144
  },
123
145
  ];
124
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", () => {
@@ -61,8 +66,8 @@ describe("dsh", () => {
61
66
  command: "npx",
62
67
  args: ["-y", "hello.tool@0.1.0", "mcp"],
63
68
  env: {
64
- APIKEY: "process.env.APIKEY",
65
- REGION: "process.env.REGION",
69
+ APIKEY: 'process.env.APIKEY ?? ""',
70
+ REGION: 'process.env.REGION ?? ""',
66
71
  },
67
72
  failOnStartupError: true,
68
73
  },
@@ -72,7 +77,7 @@ describe("dsh", () => {
72
77
  ]);
73
78
  // DSH scrubs KEY/PASSWORD/SECRET/TOKEN names before spawning, so every config variable is
74
79
  // restated as the `!!js` expression DSH evaluates when it loads the patch.
75
- expect(patch).toContain("APIKEY: !!js process.env.APIKEY");
80
+ expect(patch).toContain('APIKEY: !!js process.env.APIKEY ?? ""');
76
81
  });
77
82
  it("validates by booting the checkout keylessly, and carries only MCP tool calls", () => {
78
83
  // The local row is the same shape against this working tree, and stays out of `files`.
@@ -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,13 +25,11 @@
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";
33
31
  import { includedOperations } from "../report/coverage.js";
34
- import { compact, configProperties, dataDirEnvName, envName, isSensitive, pypiName, requiredConfig, rootEnvName, } from "./shared.js";
32
+ import { compact, configProperties, dataDirEnvName, envName, has, isSensitive, pypiName, requiredConfig, rootEnvName, } from "./shared.js";
35
33
  export const HOST_DIR = "hosts/hermes";
36
34
  /**
37
35
  * `hermes_cli/plugins.py` `SUPPORTED_MANIFEST_VERSION` is 2 and the loader parses every field
@@ -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) {
@@ -287,14 +287,16 @@ function importBlock(core) {
287
287
  /** Python core: the handler calls the author's own operation in-process. */
288
288
  function pythonCore(project) {
289
289
  const pkg = projectName.pythonPackage(project.identity.name);
290
+ const web = has(project, "web");
290
291
  return {
291
292
  stdlib: ["import asyncio", "import inspect"],
292
293
  external: [
293
294
  "from pydantic import BaseModel",
294
- `from ${pkg}.ops import OPERATIONS`,
295
+ `from ${pkg}.ops import OPERATIONS${web ? " as AUTHORED_OPERATIONS" : ""}`,
295
296
  `from ${pkg}.toolfactory.config import context`,
297
+ ...(web ? [`from ${pkg}.toolfactory.web import OPERATION as WEB_OPERATION`] : []),
296
298
  ],
297
- body: `def _operation(name: str) -> Any:
299
+ body: `${web ? "OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]\n\n\n" : ""}def _operation(name: str) -> Any:
298
300
  """Fail loudly if the core package and this plugin disagree on the operations."""
299
301
  for operation in OPERATIONS:
300
302
  if operation.name == name:
@@ -355,7 +357,7 @@ def _handler(name: str) -> Callable[..., str]:
355
357
  return call`,
356
358
  };
357
359
  }
358
- function initPy(project, operations) {
360
+ function initRegion(project, operations) {
359
361
  const core = project.tool.binding === "python" ? pythonCore(project) : typescriptCore(project);
360
362
  const names = envDeclarations(project).required.map((entry) => entry.name);
361
363
  return `${HEADER}"""Hermes plugin for ${project.identity.name}, registered through PluginContext.
@@ -383,7 +385,7 @@ def _ready() -> bool:
383
385
  ${core.body}
384
386
 
385
387
 
386
- def register(ctx: Any) -> None:
388
+ def _register_generated(ctx: Any) -> None:
387
389
  # Hermes' own per-plugin data directory, through the variable every host uses.
388
390
  os.environ.setdefault(${JSON.stringify(dataDirEnvName(project))}, str(ctx.state.data_dir))
389
391
  for schema in TOOLS:
@@ -396,6 +398,20 @@ def register(ctx: Any) -> None:
396
398
  requires_env=list(REQUIRES_ENV),
397
399
  description=schema["description"],
398
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
399
415
  `;
400
416
  }
401
417
  function readme(project, operations) {
@@ -472,9 +488,24 @@ export const surface = {
472
488
  const operations = includedOperations(project, surface);
473
489
  const dir = pluginDir(project);
474
490
  return [
475
- { kind: "file", path: `${dir}/plugin.yaml`, content: manifest(project, operations) },
476
- { kind: "file", path: `${dir}/__init__.py`, content: initPy(project, operations) },
477
- { 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
+ },
478
509
  { kind: "file", path: `${HOST_DIR}/README.md`, content: readme(project, operations) },
479
510
  {
480
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",
@@ -19,6 +20,12 @@ const echo = {
19
20
  requires: [],
20
21
  };
21
22
  const shoot = { name: "shoot", inputSchema: { type: "object" }, requires: ["browser"] };
23
+ const web = {
24
+ name: "web",
25
+ description: "Open the generated operations page.",
26
+ inputSchema: { type: "object" },
27
+ requires: [],
28
+ };
22
29
  function project(overrides = {}) {
23
30
  return {
24
31
  root: "/repo",
@@ -50,10 +57,35 @@ function project(overrides = {}) {
50
57
  };
51
58
  }
52
59
  function emitted(target) {
53
- 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
+ ]));
54
74
  }
55
75
  const python = project({ tool: { ...project().tool, binding: "python" } });
56
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
+ });
57
89
  it("projects the manifest Hermes parses and the credentials it prompts for", () => {
58
90
  const files = emitted(project());
59
91
  const manifest = parseYaml(files[`${pluginDir(project())}/plugin.yaml`] ?? "");
@@ -104,6 +136,21 @@ describe("hermes-native", () => {
104
136
  expect(pyproject.project.dependencies).toEqual(["hello>=1.2.0,<2"]);
105
137
  expect(pyproject.tool.uv.sources.hello).toEqual({ path: "../..", editable: true });
106
138
  });
139
+ it("gives a Python Hermes plugin the generated web operation used by its tool schema", () => {
140
+ const target = project({
141
+ tool: {
142
+ ...project().tool,
143
+ binding: "python",
144
+ surfaces: ["hermes-native", "cli", "mcp", "web"],
145
+ },
146
+ operations: [echo, web],
147
+ });
148
+ const native = emitted(target)[`${pluginDir(target)}/__init__.py`] ?? "";
149
+ expect(native).toContain("from hello.ops import OPERATIONS as AUTHORED_OPERATIONS");
150
+ expect(native).toContain("from hello.toolfactory.web import OPERATION as WEB_OPERATION");
151
+ expect(native).toContain("OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]");
152
+ expect(native).toContain('handler=_handler(schema["name"])');
153
+ });
107
154
  it("emits a fake-ctx test that registers and calls a real handler, no Hermes import", () => {
108
155
  const shim = emitted(project())[`${HOST_DIR}/tests/test_plugin.py`] ?? "";
109
156
  expect(shim).not.toMatch(/^\s*(import|from) hermes/m);
@@ -136,12 +183,7 @@ describe.skipIf(!hasHermes())("hermes plugins doctor", () => {
136
183
  it("loads and registers the generated plugin", () => {
137
184
  const root = mkdtempSync(join(tmpdir(), "toolfactory-hermes-"));
138
185
  try {
139
- for (const file of surface.plan(project())) {
140
- if (file.kind !== "file")
141
- continue;
142
- mkdirSync(dirname(join(root, file.path)), { recursive: true });
143
- writeFileSync(join(root, file.path), file.content);
144
- }
186
+ apply(root, surface.plan(project()), "0.1.0");
145
187
  const commands = surface.validate?.({ ...project(), root }) ?? [];
146
188
  const command = commands.find((c) => c.label === "hermes plugins doctor");
147
189
  if (!command)
@@ -7,6 +7,7 @@ import { pythonPackage } from "../bindings/python.js";
7
7
  import { KERNEL_DIR } from "../bindings/typescript.js";
8
8
  import { githubOwner, projectName } from "../identity/name.js";
9
9
  import { compact, configProperties, envName, has, isSensitive, json, npmName, pypiName, requiredConfig, } from "./shared.js";
10
+ import { WEB_DIR } from "./web.js";
10
11
  export const SERVER_SCHEMA_ID = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
11
12
  /** `owner` from the identity file's GitHub repository, or the error every registry name needs it for. */
12
13
  function requireGithubOwner(identity) {
@@ -62,6 +63,42 @@ const DOCKER_PACKAGE_MANAGERS = {
62
63
  prune: "pnpm prune --prod",
63
64
  },
64
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
+ }
65
102
  /**
66
103
  * The root `Dockerfile`, stdio only (§4.1: no `--host` flag exists to bind past loopback), one
67
104
  * multi-stage build per binding. `label` is `server.json`'s own `name`, byte for byte — the MCP
@@ -69,20 +106,34 @@ const DOCKER_PACKAGE_MANAGERS = {
69
106
  */
70
107
  export function dockerfileTemplate(project, label) {
71
108
  if (project.tool.binding === "python") {
109
+ const web = has(project, "web");
72
110
  return `# syntax=docker/dockerfile:1
73
- FROM ${UV_IMAGE} AS build
111
+ ${web
112
+ ? `FROM ${NODE_IMAGE} AS web
113
+ WORKDIR /app
114
+ COPY web/package.json web/package-lock.json ./web/
115
+ RUN npm -C web ci
116
+ COPY web ./web
117
+ RUN npm -C web run build
118
+
119
+ `
120
+ : ""}FROM ${UV_IMAGE} AS build
74
121
  WORKDIR /app
75
- COPY pyproject.toml ./
76
- COPY src ./src
77
- 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
78
127
 
79
128
  FROM ${PYTHON_IMAGE}
80
129
  LABEL io.modelcontextprotocol.server.name="${label}"
81
130
  WORKDIR /app
82
131
  COPY --from=build /app/.venv ./.venv
83
- COPY --from=build /app/src ./src
84
- COPY --from=build /app/pyproject.toml ./
85
- ENV PATH="/app/.venv/bin:$PATH"
132
+ COPY --from=build /app/runtime/ ./
133
+ ${web
134
+ ? `COPY --from=web /app/web/dist ./src/${pythonPackage(project)}/${WEB_DIR}
135
+ `
136
+ : ""}ENV PATH="/app/.venv/bin:$PATH"
86
137
  ENTRYPOINT ${JSON.stringify(dockerEntrypoint(project))}
87
138
  `;
88
139
  }
@@ -155,6 +206,7 @@ export const surface = {
155
206
  return [
156
207
  { kind: "file", path: "server.json", content: json(server) },
157
208
  { kind: "file", path: "Dockerfile", content: dockerfileTemplate(project, name) },
209
+ dockerignore(),
158
210
  ];
159
211
  },
160
212
  };