toolfactory 0.1.1 → 0.1.2

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 (54) hide show
  1. package/README.md +8 -3
  2. package/dist/bindings/python.js +1 -1
  3. package/dist/model.d.ts +1 -0
  4. package/dist/model.js +9 -1
  5. package/dist/project/gate.js +34 -20
  6. package/dist/project/history.d.ts +1 -0
  7. package/dist/surfaces/agents.js +6 -0
  8. package/dist/surfaces/codex.js +1 -1
  9. package/dist/surfaces/codex.test.js +10 -0
  10. package/dist/surfaces/distribution.test.js +118 -4
  11. package/dist/surfaces/dsh.js +4 -1
  12. package/dist/surfaces/dsh.test.js +3 -3
  13. package/dist/surfaces/hermes-native.js +5 -3
  14. package/dist/surfaces/hermes-native.test.js +21 -0
  15. package/dist/surfaces/mcp-registry.js +16 -2
  16. package/dist/surfaces/mcp-registry.test.js +5 -0
  17. package/dist/surfaces/mcpb.d.ts +1 -0
  18. package/dist/surfaces/mcpb.js +36 -23
  19. package/dist/surfaces/mcpb.test.js +31 -3
  20. package/dist/surfaces/npm.js +45 -14
  21. package/dist/surfaces/pypi.js +27 -3
  22. package/dist/surfaces/readme.js +10 -7
  23. package/dist/surfaces/registry.js +3 -3
  24. package/dist/surfaces/workflows.js +13 -6
  25. package/dist/surfaces/workflows.test.js +70 -3
  26. package/dist/toolfactory/cli.js +1 -1
  27. package/dist/toolfactory/mcp.js +1 -1
  28. package/package.json +1 -1
  29. package/src/bindings/python.ts +1 -1
  30. package/src/model.ts +11 -1
  31. package/src/project/gate.ts +35 -20
  32. package/src/surfaces/agents.ts +7 -0
  33. package/src/surfaces/codex.test.ts +10 -0
  34. package/src/surfaces/codex.ts +4 -3
  35. package/src/surfaces/distribution.test.ts +139 -4
  36. package/src/surfaces/dsh.test.ts +3 -3
  37. package/src/surfaces/dsh.ts +4 -1
  38. package/src/surfaces/hermes-native.test.ts +22 -0
  39. package/src/surfaces/hermes-native.ts +5 -2
  40. package/src/surfaces/mcp-registry.test.ts +8 -0
  41. package/src/surfaces/mcp-registry.ts +20 -2
  42. package/src/surfaces/mcpb.test.ts +32 -5
  43. package/src/surfaces/mcpb.ts +44 -23
  44. package/src/surfaces/npm.ts +47 -15
  45. package/src/surfaces/pypi.ts +27 -3
  46. package/src/surfaces/readme.ts +11 -6
  47. package/src/surfaces/registry.ts +3 -3
  48. package/src/surfaces/workflows.test.ts +87 -3
  49. package/src/surfaces/workflows.ts +13 -6
  50. package/src/toolfactory/cli.ts +1 -1
  51. package/src/toolfactory/mcp.ts +1 -1
  52. package/web/dist/assets/{index-CCXhQKK3.css → index-BuT2czb2.css} +1 -1
  53. package/web/dist/assets/{index-D1PwZ3qS.js → index-Spapkkvh.js} +1 -1
  54. package/web/dist/index.html +2 -2
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { NODE_ENGINES } from "../bindings/typescript.js";
3
- import { MCPB_PIN, surface as mcpb } from "./mcpb.js";
3
+ import { MCPB_PIN, surface as mcpb, UV_MANIFEST_VERSION } from "./mcpb.js";
4
4
  const echo = {
5
5
  name: "echo",
6
6
  description: "Echoes.",
@@ -90,8 +90,36 @@ describe("mcpb", () => {
90
90
  // No privacy_policies unless tool.json names them (see the module docs).
91
91
  expect(manifestFile.content).not.toContain("privacy_policies");
92
92
  });
93
- it("declares the kernel's own Node floor, and refuses a binding whose package is not the bundle", () => {
94
- expect(() => mcpb.plan({ ...project, tool: { ...project.tool, binding: "python" } })).toThrow(/requires the typescript binding/);
93
+ it("uses the official v0.4 uv bundle contract for Python, with its canonical PyPI surface", () => {
94
+ const pythonProject = {
95
+ ...project,
96
+ tool: { ...project.tool, binding: "python", surfaces: ["mcp", "pypi", "mcpb"] },
97
+ };
98
+ const [file] = mcpb.plan(pythonProject);
99
+ if (file?.kind !== "file")
100
+ throw new Error("expected one whole file");
101
+ expect(JSON.parse(file.content)).toMatchObject({
102
+ manifest_version: UV_MANIFEST_VERSION,
103
+ server: {
104
+ type: "uv",
105
+ entry_point: "src/hello_tool/toolfactory/mcp.py",
106
+ mcp_config: {
107
+ command: "uv",
108
+ args: [
109
+ "run",
110
+ "--directory",
111
+ "${__dirname}",
112
+ "python",
113
+ "-m",
114
+ "hello_tool.toolfactory.mcp",
115
+ ],
116
+ },
117
+ },
118
+ compatibility: { runtimes: { python: ">=3.11" } },
119
+ });
120
+ expect(() => mcpb.plan({ ...pythonProject, tool: { ...pythonProject.tool, surfaces: ["mcp", "mcpb"] } })).toThrow(/requires the "pypi" surface/);
121
+ });
122
+ it("declares the kernel's own Node floor and validates with the pinned official CLI", () => {
95
123
  expect(mcpb.validate?.(project)).toEqual([
96
124
  {
97
125
  label: "mcpb validate",
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { githubSlug } from "../hosts/github.js";
6
6
  import { registryName } from "./mcp-registry.js";
7
- import { compact, has, npmName } from "./shared.js";
7
+ import { compact, has, npmName, pypiName } from "./shared.js";
8
8
  import { WEB_DIR } from "./web.js";
9
9
  /**
10
10
  * npm's object form. A bare URL string is only valid as one of npm's shorthands, so `publint`
@@ -17,14 +17,30 @@ function repositoryField(repository) {
17
17
  const slug = githubSlug(repository);
18
18
  return { type: "git", url: slug ? `git+https://github.com/${slug}.git` : repository };
19
19
  }
20
+ /**
21
+ * A deliberately thin cross-registry bridge: npm supplies discovery and a familiar `npx` entry,
22
+ * while uvx resolves the canonical Python distribution. `uvx` owns Python acquisition, caching,
23
+ * and the package install; the npm package never downloads an interpreter itself.
24
+ */
25
+ function pythonLauncher(project) {
26
+ const { identity } = project;
27
+ return `#!/usr/bin/env node
28
+ import { spawnSync } from "node:child_process";
29
+
30
+ const child = spawnSync("uvx", ["--from", ${JSON.stringify(`${pypiName(project)}==${identity.version}`)}, ${JSON.stringify(identity.name)}, ...process.argv.slice(2)], {
31
+ stdio: "inherit",
32
+ });
33
+ if (child.error) throw child.error;
34
+ process.exit(child.status ?? 1);
35
+ `;
36
+ }
20
37
  export const surface = {
21
38
  id: "npm",
22
39
  plan(project) {
23
- if (project.tool.binding !== "typescript") {
24
- throw new Error('Surface "npm" requires the typescript binding.');
25
- }
26
40
  const { identity } = project;
27
41
  const authored = project.tool.identity === "package.json";
42
+ const python = project.tool.binding === "python";
43
+ const launcher = `bin/${identity.name}.mjs`;
28
44
  const patch = compact({
29
45
  name: authored ? undefined : npmName(project),
30
46
  version: authored ? undefined : identity.version,
@@ -35,21 +51,36 @@ export const surface = {
35
51
  keywords: authored ? undefined : identity.keywords,
36
52
  type: "module",
37
53
  bin: has(project, "cli") || has(project, "mcp")
38
- ? { [identity.name]: "./dist/toolfactory/cli.js" }
54
+ ? {
55
+ [identity.name]: python ? `./${launcher}` : "./dist/toolfactory/cli.js",
56
+ }
39
57
  : undefined,
40
58
  // `web/dist` only with the `web` surface: the kernel serves the built page from inside
41
59
  // the installed package, so `npx <tool> mcp --http --open` works without a checkout.
42
- files: [
43
- "dist",
44
- "src",
45
- "schemas",
46
- ...(has(project, "web") ? [`${WEB_DIR}/dist`] : []),
47
- "README.md",
48
- "LICENSE",
49
- ],
60
+ files: python
61
+ ? ["bin", "README.md", "LICENSE"]
62
+ : [
63
+ "dist",
64
+ "src",
65
+ "schemas",
66
+ ...(has(project, "web") ? [`${WEB_DIR}/dist`] : []),
67
+ "README.md",
68
+ "LICENSE",
69
+ ],
50
70
  mcpName: has(project, "mcp-registry") ? registryName(project) : undefined,
51
71
  });
52
- return [{ kind: "merge", path: "package.json", format: "json", patch, owned: ["bin"] }];
72
+ return [
73
+ ...(python && (has(project, "cli") || has(project, "mcp"))
74
+ ? [{ kind: "file", path: launcher, content: pythonLauncher(project) }]
75
+ : []),
76
+ {
77
+ kind: "merge",
78
+ path: "package.json",
79
+ format: "json",
80
+ patch,
81
+ owned: ["bin", "files"],
82
+ },
83
+ ];
53
84
  },
54
85
  validate(project) {
55
86
  return [
@@ -26,19 +26,43 @@ export const surface = {
26
26
  ? { [project.identity.name]: cliEntryPoint(project) }
27
27
  : undefined,
28
28
  }),
29
- // The kernel serves the built page from inside its own package, so the wheel has to
30
- // carry it: `packages` only copies the package tree, `force-include` maps anything else.
29
+ // Hatch ignores a missing `only-include` path, unlike `force-include`, which makes
30
+ // `uv sync` work in a fresh checkout before the optional page is built. At package time,
31
+ // the gate builds `web/dist` first and Hatch maps it into the installed package.
31
32
  ...(has(project, "web")
32
33
  ? {
33
34
  tool: {
34
35
  hatch: {
35
36
  build: {
37
+ // Hatch's normal file selection honors .gitignore; `web/dist` is ignored in
38
+ // every generated project, so artifacts is the standard explicit opt-in for
39
+ // the release build's compiled page.
40
+ artifacts: [`${WEB_DIR}/dist/**`],
36
41
  targets: {
37
42
  wheel: {
38
- "force-include": {
43
+ "only-include": [`src/${pythonPackage(project)}`, `${WEB_DIR}/dist`],
44
+ sources: {
45
+ src: "",
39
46
  [`${WEB_DIR}/dist`]: `${pythonPackage(project)}/${WEB_DIR}`,
40
47
  },
41
48
  },
49
+ // This is the source MCPB stages, so include every declared project metadata
50
+ // file alongside the package and built page. `only-include` ignores absent
51
+ // legal files without making fresh checkouts fail.
52
+ sdist: {
53
+ "only-include": [
54
+ "pyproject.toml",
55
+ "README.md",
56
+ "LICENSE",
57
+ "LICENSE.md",
58
+ "LICENSE.txt",
59
+ "NOTICE",
60
+ "NOTICE.md",
61
+ "NOTICE.txt",
62
+ `src/${pythonPackage(project)}`,
63
+ `${WEB_DIR}/dist`,
64
+ ],
65
+ },
42
66
  },
43
67
  },
44
68
  },
@@ -114,16 +114,15 @@ function installLines(project) {
114
114
  }
115
115
  if (has(project, "browser-extension")) {
116
116
  // Three channels, in the order a reader needs them: build-and-load from a checkout, the
117
- // release assets (only Mozilla's signed xpi is a real self-hosted install — Chrome drops
118
- // side-loaded unpacked extensions), the store listings. Pairing is the last step of each.
117
+ // release assets (the signed Firefox XPI depends on configured signing credentials), and
118
+ // store listings. Pairing is the last step of each.
119
119
  const zips = ["chrome", "firefox", "edge"].map((browser) => `\`${zipName(project, browser)}\``);
120
120
  lines.push([
121
121
  `- **Browser extension** — from a checkout: \`npm --prefix ${BROWSER_HOST_DIR} install && npm --prefix ${BROWSER_HOST_DIR} exec --no -- wxt build\`,`,
122
122
  ` then \`chrome://extensions\` → developer mode → Load unpacked → \`${BROWSER_HOST_DIR}/.output/chrome-mv3\``,
123
123
  ` (Firefox: \`npm --prefix ${BROWSER_HOST_DIR} exec --no -- web-ext run\`). Each GitHub Release attaches the`,
124
- ` store uploads ${zips.join(", ")}, and the Mozilla-signed \`.xpi\`,`,
125
- " which is the only download-and-install channel now that Chrome no longer keeps side-loaded unpacked",
126
- " extensions; the Chrome Web Store, Firefox Add-ons and Edge Add-ons listings appear once the release's",
124
+ ` store uploads ${zips.join(", ")}. When Firefox signing credentials are configured, it also attaches a`,
125
+ " Mozilla-signed `.xpi`; the Chrome Web Store, Firefox Add-ons and Edge Add-ons listings appear once the release's",
127
126
  ` submit step has each store's credentials. Then pair it: \`${mcpCommand(project)} --http --pair\``,
128
127
  " prints the `<url>#<token>` the extension's options page accepts.",
129
128
  ].join("\n"));
@@ -133,8 +132,12 @@ function installLines(project) {
133
132
  // tool already is. The same listener answers `/mcp`, so the page needs nothing else running.
134
133
  lines.push(`- **Web app** — \`${mcpCommand(project)} --http --open\` serves the operations page beside the`, " MCP endpoint on one port and opens it; over MCP or a skill, the `web` operation does the same and", " returns the URL.");
135
134
  }
136
- if (has(project, "npm"))
137
- lines.push(`- **npm package** — \`npm install ${npmName(project)}\``);
135
+ if (has(project, "npm")) {
136
+ const pythonBridge = project.tool.binding === "python"
137
+ ? `; requires \`uv\`, and \`${identity.name}\` delegates to \`uvx --from ${pypiName(project)}==${identity.version} ${identity.name}\``
138
+ : "";
139
+ lines.push(`- **npm package** — \`npm install ${npmName(project)}\`${pythonBridge}`);
140
+ }
138
141
  if (has(project, "pypi"))
139
142
  lines.push(`- **PyPI package** — \`uv add ${pypiName(project)}\``);
140
143
  return lines;
@@ -36,9 +36,9 @@ const registry = {
36
36
  cli,
37
37
  mcp,
38
38
  "mcp-registry": mcpRegistry,
39
- // The bundle root is the npm tarball with its production dependencies installed into it, and
40
- // its one row launches the kernel MCP server: without either surface there is nothing to pack.
41
- mcpb: { ...mcpb, requires: ["mcp", "npm"] },
39
+ // TypeScript bundles stage their npm tarball; Python `uv` bundles stage the PyPI project's
40
+ // source. The projector enforces that binding-specific distribution dependency.
41
+ mcpb: { ...mcpb, requires: ["mcp"] },
42
42
  npm,
43
43
  skill,
44
44
  "hermes-native": hermesNative,
@@ -408,7 +408,9 @@ function releaseDocument(project) {
408
408
  };
409
409
  const priorLegs = [];
410
410
  if (npmSelected) {
411
- const tarball = `${RELEASE_ARTIFACT}/${npmTarball(project)}`;
411
+ // npm treats a slash-containing bare operand as a Git spec; make the downloaded archive a
412
+ // filesystem path explicitly.
413
+ const tarball = `./${RELEASE_ARTIFACT}/${npmTarball(project)}`;
412
414
  jobs["publish-npm"] = compact({
413
415
  needs: ["gate", "package"],
414
416
  if: gated("npm"),
@@ -442,18 +444,20 @@ function releaseDocument(project) {
442
444
  }
443
445
  if (pypiSelected) {
444
446
  jobs["publish-pypi"] = compact({
445
- needs: "gate",
447
+ needs: ["gate", "package"],
446
448
  if: gated("pypi"),
447
449
  "runs-on": "ubuntu-latest",
448
450
  environment: "pypi",
449
451
  permissions: { "id-token": "write" },
450
452
  steps: [
451
- checkoutStep(RELEASE_SHA),
452
- { uses: "astral-sh/setup-uv@v6" },
453
- { run: "uv build" },
453
+ {
454
+ uses: "actions/download-artifact@v8",
455
+ with: { name: RELEASE_ARTIFACT, path: RELEASE_ARTIFACT },
456
+ },
454
457
  {
455
458
  name: "Publish package distributions to PyPI",
456
459
  uses: "pypa/gh-action-pypi-publish@release/v1",
460
+ with: { "packages-dir": `${RELEASE_ARTIFACT}/pypi/` },
457
461
  },
458
462
  ],
459
463
  });
@@ -616,7 +620,10 @@ function releaseDocument(project) {
616
620
  // A tag the dispatch is cutting does not exist yet: the Release creates it here.
617
621
  target_commitish: RELEASE_SHA,
618
622
  generate_release_notes: true,
619
- files: `${RELEASE_ARTIFACT}/*`,
623
+ // PyPI distributions live one level below the other release assets.
624
+ files: has(project, "pypi")
625
+ ? `${RELEASE_ARTIFACT}/*\n${RELEASE_ARTIFACT}/pypi/*`
626
+ : `${RELEASE_ARTIFACT}/*`,
620
627
  fail_on_unmatched_files: true,
621
628
  },
622
629
  },
@@ -120,6 +120,73 @@ describe("workflows", () => {
120
120
  });
121
121
  expect(steps.some((s) => s.run === "uv run --with pytest pytest -q")).toBe(true);
122
122
  });
123
+ it("installs a native Python core into Hermes only in validation jobs", () => {
124
+ const python = project(["cli", "mcp", "hermes-native"], {
125
+ tool: {
126
+ ...project(["cli"]).tool,
127
+ binding: "python",
128
+ surfaces: ["cli", "mcp", "hermes-native"],
129
+ },
130
+ });
131
+ const files = emitted(python);
132
+ const ci = yamlParse(files[".github/workflows/ci.yml"]);
133
+ const release = yamlParse(files[".github/workflows/release.yml"]);
134
+ const command = 'uv pip install --python "$HOME/.hermes/hermes-agent/venv/bin/python" --editable .';
135
+ const ciRuns = ci.jobs.test.steps.map((step) => step.run);
136
+ const gateRuns = release.jobs.gate.steps.map((step) => step.run);
137
+ const packageRuns = release.jobs.package.steps.map((step) => step.run);
138
+ const ciValidate = ciRuns.findIndex((run) => run?.endsWith(" validate"));
139
+ const gateValidate = gateRuns.findIndex((run) => run?.endsWith(" validate"));
140
+ expect(ciRuns.indexOf(command)).toBeGreaterThan(ciRuns.indexOf("uv sync"));
141
+ expect(ciRuns.indexOf(command)).toBeLessThan(ciValidate);
142
+ expect(gateRuns.indexOf(command)).toBeGreaterThan(gateRuns.indexOf("uv sync"));
143
+ expect(gateRuns.indexOf(command)).toBeLessThan(gateValidate);
144
+ expect(packageRuns).not.toContain(command);
145
+ const withoutHermes = project(["cli", "mcp"], {
146
+ tool: { ...project(["cli"]).tool, binding: "python", surfaces: ["cli", "mcp"] },
147
+ });
148
+ const typescriptHermes = project(["cli", "mcp", "hermes-native"]);
149
+ expect(emitted(withoutHermes)[".github/workflows/ci.yml"]).not.toContain(command);
150
+ expect(emitted(typescriptHermes)[".github/workflows/ci.yml"]).not.toContain(command);
151
+ });
152
+ it("publishes the package job's prebuilt Python distributions", () => {
153
+ const python = project(["pypi", "web"], {
154
+ tool: { ...project([]).tool, binding: "python", surfaces: ["pypi", "web"] },
155
+ });
156
+ const release = yamlParse(emitted(python)[".github/workflows/release.yml"] ?? "");
157
+ const publish = release.jobs["publish-pypi"];
158
+ expect(publish.needs).toEqual(["gate", "package"]);
159
+ expect(publish.steps).toEqual([
160
+ {
161
+ uses: "actions/download-artifact@v8",
162
+ with: { name: "release-assets", path: "release-assets" },
163
+ },
164
+ {
165
+ name: "Publish package distributions to PyPI",
166
+ uses: "pypa/gh-action-pypi-publish@release/v1",
167
+ with: { "packages-dir": "release-assets/pypi/" },
168
+ },
169
+ ]);
170
+ expect(release.jobs.release.steps.at(-1)?.with.files).toBe("release-assets/*\nrelease-assets/pypi/*");
171
+ expect(packageSteps(python).find((step) => step.name === "web build")).toBeDefined();
172
+ expect(packageSteps(python).find((step) => step.name === "python distributions")?.run).toBe("uv build --out-dir dist/release/pypi");
173
+ const mcpbPython = project(["pypi", "mcp", "mcpb", "web"], {
174
+ tool: {
175
+ ...project([]).tool,
176
+ binding: "python",
177
+ surfaces: ["pypi", "mcp", "mcpb", "web"],
178
+ },
179
+ });
180
+ const mcpb = packageSteps(mcpbPython).find((step) => step.name === "MCPB bundle")?.run;
181
+ expect(packageSteps(mcpbPython).map((step) => step.name)).toEqual(expect.arrayContaining(["python distributions", "MCPB bundle"]));
182
+ expect(packageSteps(mcpbPython)
183
+ .map((step) => step.name)
184
+ .indexOf("python distributions")).toBeLessThan(packageSteps(mcpbPython)
185
+ .map((step) => step.name)
186
+ .indexOf("MCPB bundle"));
187
+ expect(mcpb).toContain("tar -xzf dist/release/pypi/*.tar.gz -C dist/mcpb --strip-components=1");
188
+ expect(mcpb).not.toContain("cp -R src dist/mcpb/src");
189
+ });
123
190
  it("release.yml runs the legs in the forced order npm -> oci -> mcp-registry -> clawhub, each gated on what the gate decided", () => {
124
191
  const files = emitted(project(["npm", "mcp-registry", "clawhub", "openclaw-native", "skill", "claude", "web", "mcp"], {
125
192
  identity: {
@@ -265,8 +332,8 @@ describe("workflows", () => {
265
332
  // configured publisher selects OIDC; otherwise the stored token is a fallback. A re-dispatch
266
333
  // after adding either credential must not attempt to republish an immutable version.
267
334
  expect(release.jobs["publish-npm"].steps.some((s) => s.run?.includes("npm view hello@0.1.0 version") &&
268
- s.run?.includes('elif [ "$NPM_TRUSTED_PUBLISHER" = true ]; then npm publish release-assets/hello-0.1.0.tgz --access public') &&
269
- s.run?.includes('NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish release-assets/hello-0.1.0.tgz --access public'))).toBe(true);
335
+ s.run?.includes('elif [ "$NPM_TRUSTED_PUBLISHER" = true ]; then npm publish ./release-assets/hello-0.1.0.tgz --access public') &&
336
+ s.run?.includes('NODE_AUTH_TOKEN="$NPM_TOKEN" npm publish ./release-assets/hello-0.1.0.tgz --access public'))).toBe(true);
270
337
  // The oci leg pushes the very image server.json's oci entry names.
271
338
  const meta = release.jobs["publish-oci"].steps.find((s) => s.id === "meta");
272
339
  expect(meta?.with?.images).toBe("ghcr.io/acme/hello");
@@ -491,7 +558,7 @@ describe("gate", () => {
491
558
  ]);
492
559
  const runs = packageSteps(target).map((step) => step.run);
493
560
  expect(runs).toContain("npm pack --pack-destination dist/release");
494
- expect(runs).toContain("uv build --out-dir dist/release");
561
+ expect(runs).toContain("uv build --out-dir dist/release/pypi");
495
562
  expect(runs.some((run) => run.includes("npm pack ./hosts/openclaw"))).toBe(true);
496
563
  expect(runs).toContain('set -- skills .claude-plugin; for path in LICENSE LICENSE.md LICENSE.txt NOTICE NOTICE.md NOTICE.txt; do if [ -f "$path" ]; then set -- "$@" "$path"; fi; done; zip -qr dist/release/hello-plugin.zip "$@"');
497
564
  const webBuild = runs.findIndex((run) => run.includes("npm -C web run build"));
@@ -11,7 +11,7 @@ const operations = [...authored, web];
11
11
  const program = new Command()
12
12
  .name("toolfactory")
13
13
  .description("Build an agent tool once; ship it as Agent Skills, MCP servers, Agent Plugins / Claude / Codex / Cursor bundles, OpenClaw and Hermes plugins, CLIs, and packages, with the tests and releases to match.")
14
- .version("0.1.1");
14
+ .version("0.1.2");
15
15
  for (const op of operations) {
16
16
  if (!serves(op, "cli"))
17
17
  continue;
@@ -30,7 +30,7 @@ const operations = [...authored, web];
30
30
  const introspecting = Boolean(process.env.TOOLFACTORY_INTROSPECT);
31
31
  /** Builds one server instance with every servable operation registered; called once for stdio, once per HTTP request. */
32
32
  function createServer() {
33
- const server = new McpServer({ name: "toolfactory", version: "0.1.1" });
33
+ const server = new McpServer({ name: "toolfactory", version: "0.1.2" });
34
34
  for (const op of operations) {
35
35
  if (!introspecting && !serves(op, "mcp"))
36
36
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolfactory",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Build an agent tool once; ship it as Agent Skills, MCP servers, Agent Plugins / Claude / Codex / Cursor bundles, OpenClaw and Hermes plugins, CLIs, and packages, with the tests and releases to match.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -236,7 +236,7 @@ def env_route(token: "str | None") -> Route:
236
236
  return `
237
237
 
238
238
  def web_dist() -> "Path | None":
239
- """The built page: the wheel force-includes \`${WEB_DIR}/dist\` here, a checkout has it in place."""
239
+ """The wheel maps \`${WEB_DIR}/dist\` here at package time; a checkout has it in place."""
240
240
  package = Path(__file__).resolve().parent.parent
241
241
  for candidate in (package / "${WEB_DIR}", package.parent.parent / "${WEB_DIR}" / "dist"):
242
242
  if candidate.is_dir():
package/src/model.ts CHANGED
@@ -129,7 +129,17 @@ export const ToolConfigSchema = z
129
129
  .default({ examples: {} })
130
130
  .describe("Example arguments per operation, used by the generated surface-smoke tests."),
131
131
  npm: z.object({ scope: z.string().optional() }).optional(),
132
- codex: z.object({ interface: jsonObject.optional() }).optional(),
132
+ codex: z
133
+ .object({
134
+ interface: jsonObject.optional(),
135
+ mcp: z
136
+ .boolean()
137
+ .default(true)
138
+ .describe(
139
+ "Whether the Codex plugin manifest eagerly configures the generated MCP server.",
140
+ ),
141
+ })
142
+ .optional(),
133
143
  browserExtension: z
134
144
  .object({
135
145
  endpoint: z
@@ -121,11 +121,18 @@ export function gateSteps(project: Project): GateStep[] {
121
121
  const pm = commands(project);
122
122
  const cli = toolfactoryCli(project);
123
123
  const openclaw = has(project, "openclaw-native");
124
- const steps: GateStep[] = [
125
- ...bootstrapSteps(project),
126
- { name: "toolfactory check", run: `${cli} check` },
127
- outputsStep(project),
128
- ];
124
+ const steps: GateStep[] = [...bootstrapSteps(project)];
125
+ if (!typescript && has(project, "hermes-native")) {
126
+ // Hermes' launcher clears PYTHONPATH and runs its own venv. Its plugin dependency
127
+ // declaration is advisory, so Doctor can import a native Python core only after the
128
+ // project is installed into that interpreter. Run after bootstrap's `uv sync` on CI.
129
+ steps.push({
130
+ name: "Install core into Hermes",
131
+ run: 'uv pip install --python "$HOME/.hermes/hermes-agent/venv/bin/python" --editable .',
132
+ when: "ci",
133
+ });
134
+ }
135
+ steps.push({ name: "toolfactory check", run: `${cli} check` }, outputsStep(project));
129
136
  if (has(project, "claude")) {
130
137
  steps.push({
131
138
  name: "Install Claude Code CLI",
@@ -232,27 +239,35 @@ export function packageSteps(project: Project): GateStep[] {
232
239
  if (has(project, "npm")) {
233
240
  steps.push({ name: "npm tarball", run: `npm pack --pack-destination ${RELEASE_DIR}` });
234
241
  }
242
+ if (has(project, "pypi")) {
243
+ // Keep the wheel and sdist apart from the release's npm tarballs, plugin bundles and web
244
+ // archive: PyPA's publisher accepts only Python distribution files. The MCPB stage below
245
+ // unpacks this same canonical sdist rather than reproducing its source-file selection.
246
+ steps.push({ name: "python distributions", run: `uv build --out-dir ${RELEASE_DIR}/pypi` });
247
+ }
235
248
  if (has(project, "mcpb")) {
236
- // A bundle root is the published package with its production dependencies installed into
237
- // it, so it is built from the tarball `npm pack` just wrote rather than from the checkout:
238
- // whatever ships to npm is exactly what ships to Claude Desktop. `--ignore-scripts` because
239
- // nothing in a bundle root may run a build; the tarball already carries `dist/`.
240
249
  const stage = "dist/mcpb";
241
- const tarball = npmTarball(project);
242
250
  steps.push({
243
251
  name: "MCPB bundle",
244
- run: [
245
- `rm -rf ${stage} && mkdir -p ${stage}`,
246
- `tar -xzf ${RELEASE_DIR}/${tarball} -C ${stage} --strip-components=1`,
247
- `npm --prefix ${stage} install --omit=dev --ignore-scripts`,
248
- `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
249
- `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
250
- ].join(" && "),
252
+ run:
253
+ project.tool.binding === "python"
254
+ ? [
255
+ `rm -rf ${stage} && mkdir -p ${stage}`,
256
+ `tar -xzf ${RELEASE_DIR}/pypi/*.tar.gz -C ${stage} --strip-components=1`,
257
+ `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
258
+ `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
259
+ ].join(" && ")
260
+ : [
261
+ // The Node bundle root is the published package with production dependencies
262
+ // installed, so whatever ships to npm is exactly what ships to Claude Desktop.
263
+ `rm -rf ${stage} && mkdir -p ${stage}`,
264
+ `tar -xzf ${RELEASE_DIR}/${npmTarball(project)} -C ${stage} --strip-components=1`,
265
+ `npm --prefix ${stage} install --omit=dev --ignore-scripts`,
266
+ `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
267
+ `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
268
+ ].join(" && "),
251
269
  });
252
270
  }
253
- if (has(project, "pypi")) {
254
- steps.push({ name: "python distributions", run: `uv build --out-dir ${RELEASE_DIR}` });
255
- }
256
271
  if (has(project, "openclaw-native")) {
257
272
  // ClawHub's reusable workflow has no build step of its own, so the tarball it publishes has
258
273
  // to arrive already built (`package_artifact_name`).
@@ -89,6 +89,13 @@ export const RELOAD: Reload[] = [
89
89
  "`hermes skills trust` once in this repo, then a new conversation: the resolved skill directories are stable for a conversation.",
90
90
  line: "Run `hermes skills trust` once in this repo, then start a new Hermes conversation — every invocation re-scans plugins, skills and AGENTS.md. `/reload-mcp` only affects an already-open session; `hermes gateway restart` is only for the messaging gateway.",
91
91
  },
92
+ {
93
+ harness: "GSD",
94
+ env: [],
95
+ mcp: "`python3 .agents/sync.py --all` renders the shared `.mcp.json` even on a GSD-only machine. Refresh with `mcp_servers(refresh=true)`; start a new session after changing a connected server. Approve `mcp_discover` interactively once before unattended stdio use.",
96
+ instructions:
97
+ "Reads `AGENTS.md` and `.agents/skills/` natively; `/reload` refreshes resources. `--bare` omits them.",
98
+ },
92
99
  {
93
100
  harness: "Gemini CLI",
94
101
  env: [],
@@ -68,4 +68,14 @@ describe("codex", () => {
68
68
  expect(JSON.parse(manifest.content)).toMatchObject({ skills: "./skills/" });
69
69
  expect(JSON.parse(manifest.content)).not.toHaveProperty("mcpServers");
70
70
  });
71
+
72
+ it("can omit the eager MCP server while retaining the plugin skill", () => {
73
+ const target = project(["skill", "codex", "mcp"]);
74
+ target.tool.codex = { mcp: false };
75
+ const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
76
+ if (manifest?.kind !== "file") throw new Error("expected a manifest");
77
+ const parsed = JSON.parse(manifest.content);
78
+ expect(parsed.skills).toBe("./skills/");
79
+ expect(parsed).not.toHaveProperty("mcpServers");
80
+ });
71
81
  });
@@ -43,9 +43,10 @@ export const surface: Surface = {
43
43
  license: identity.license,
44
44
  keywords: identity.keywords,
45
45
  skills: has(project, "skill") ? "./skills/" : undefined,
46
- mcpServers: has(project, "mcp")
47
- ? { [identity.name]: kernelLaunch(project, "${PLUGIN_ROOT}") }
48
- : undefined,
46
+ mcpServers:
47
+ has(project, "mcp") && project.tool.codex?.mcp !== false
48
+ ? { [identity.name]: kernelLaunch(project, "${PLUGIN_ROOT}") }
49
+ : undefined,
49
50
  interface: codexInterface,
50
51
  });
51
52
  // `source: "local"` + `path: "."` is the marketplace root itself: one repository, one