toolfactory 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +166 -0
  2. package/dist/bindings/index.d.ts +23 -0
  3. package/dist/bindings/index.js +29 -0
  4. package/dist/bindings/python.d.ts +33 -0
  5. package/dist/bindings/python.js +805 -0
  6. package/dist/bindings/python.test.d.ts +1 -0
  7. package/dist/bindings/python.test.js +316 -0
  8. package/dist/bindings/typescript.d.ts +39 -0
  9. package/dist/bindings/typescript.js +758 -0
  10. package/dist/bindings/typescript.test.d.ts +1 -0
  11. package/dist/bindings/typescript.test.js +264 -0
  12. package/dist/commands.d.ts +188 -0
  13. package/dist/commands.js +662 -0
  14. package/dist/commands.test.d.ts +1 -0
  15. package/dist/commands.test.js +215 -0
  16. package/dist/e2e.test.d.ts +1 -0
  17. package/dist/e2e.test.js +124 -0
  18. package/dist/hosts/browser.d.ts +10 -0
  19. package/dist/hosts/browser.js +94 -0
  20. package/dist/hosts/github.d.ts +66 -0
  21. package/dist/hosts/github.js +247 -0
  22. package/dist/hosts/openclaw.d.ts +4 -0
  23. package/dist/hosts/openclaw.js +94 -0
  24. package/dist/hosts/template.d.ts +15 -0
  25. package/dist/hosts/template.js +142 -0
  26. package/dist/hosts/web.d.ts +4 -0
  27. package/dist/hosts/web.js +89 -0
  28. package/dist/identity/name.d.ts +20 -0
  29. package/dist/identity/name.js +33 -0
  30. package/dist/identity/name.test.d.ts +1 -0
  31. package/dist/identity/name.test.js +37 -0
  32. package/dist/identity/read.d.ts +9 -0
  33. package/dist/identity/read.js +106 -0
  34. package/dist/identity/read.test.d.ts +1 -0
  35. package/dist/identity/read.test.js +76 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.js +6 -0
  38. package/dist/introspect/index.d.ts +15 -0
  39. package/dist/introspect/index.js +63 -0
  40. package/dist/model.d.ts +236 -0
  41. package/dist/model.js +214 -0
  42. package/dist/ops.d.ts +255 -0
  43. package/dist/ops.js +297 -0
  44. package/dist/project/apply.d.ts +35 -0
  45. package/dist/project/apply.js +360 -0
  46. package/dist/project/apply.test.d.ts +1 -0
  47. package/dist/project/apply.test.js +285 -0
  48. package/dist/project/gate.d.ts +129 -0
  49. package/dist/project/gate.js +597 -0
  50. package/dist/project/history.d.ts +62 -0
  51. package/dist/project/history.js +41 -0
  52. package/dist/project/load.d.ts +21 -0
  53. package/dist/project/load.js +82 -0
  54. package/dist/project/lock.d.ts +28 -0
  55. package/dist/project/lock.js +42 -0
  56. package/dist/project/plan.d.ts +5 -0
  57. package/dist/project/plan.js +96 -0
  58. package/dist/report/coverage.d.ts +22 -0
  59. package/dist/report/coverage.js +72 -0
  60. package/dist/report/coverage.test.d.ts +1 -0
  61. package/dist/report/coverage.test.js +93 -0
  62. package/dist/surfaces/agent-plugins.d.ts +10 -0
  63. package/dist/surfaces/agent-plugins.js +36 -0
  64. package/dist/surfaces/agents.d.ts +35 -0
  65. package/dist/surfaces/agents.js +609 -0
  66. package/dist/surfaces/agents.template.d.ts +8 -0
  67. package/dist/surfaces/agents.template.js +21 -0
  68. package/dist/surfaces/agents.test.d.ts +1 -0
  69. package/dist/surfaces/agents.test.js +181 -0
  70. package/dist/surfaces/browser-extension.d.ts +101 -0
  71. package/dist/surfaces/browser-extension.js +1034 -0
  72. package/dist/surfaces/browser-extension.test.d.ts +1 -0
  73. package/dist/surfaces/browser-extension.test.js +145 -0
  74. package/dist/surfaces/claude.d.ts +2 -0
  75. package/dist/surfaces/claude.js +81 -0
  76. package/dist/surfaces/codex.d.ts +13 -0
  77. package/dist/surfaces/codex.js +101 -0
  78. package/dist/surfaces/codex.test.d.ts +1 -0
  79. package/dist/surfaces/codex.test.js +67 -0
  80. package/dist/surfaces/cursor.d.ts +2 -0
  81. package/dist/surfaces/cursor.js +50 -0
  82. package/dist/surfaces/distribution.test.d.ts +1 -0
  83. package/dist/surfaces/distribution.test.js +146 -0
  84. package/dist/surfaces/dsh.d.ts +25 -0
  85. package/dist/surfaces/dsh.js +147 -0
  86. package/dist/surfaces/dsh.test.d.ts +1 -0
  87. package/dist/surfaces/dsh.test.js +100 -0
  88. package/dist/surfaces/external.d.ts +9 -0
  89. package/dist/surfaces/external.js +4 -0
  90. package/dist/surfaces/gemini.d.ts +29 -0
  91. package/dist/surfaces/gemini.js +68 -0
  92. package/dist/surfaces/gemini.test.d.ts +1 -0
  93. package/dist/surfaces/gemini.test.js +74 -0
  94. package/dist/surfaces/hermes-native.d.ts +53 -0
  95. package/dist/surfaces/hermes-native.js +507 -0
  96. package/dist/surfaces/hermes-native.test.d.ts +1 -0
  97. package/dist/surfaces/hermes-native.test.js +156 -0
  98. package/dist/surfaces/kernel.d.ts +5 -0
  99. package/dist/surfaces/kernel.js +63 -0
  100. package/dist/surfaces/mcp-registry.d.ts +19 -0
  101. package/dist/surfaces/mcp-registry.js +160 -0
  102. package/dist/surfaces/mcp-registry.test.d.ts +1 -0
  103. package/dist/surfaces/mcp-registry.test.js +79 -0
  104. package/dist/surfaces/mcpb.d.ts +11 -0
  105. package/dist/surfaces/mcpb.js +118 -0
  106. package/dist/surfaces/mcpb.test.d.ts +1 -0
  107. package/dist/surfaces/mcpb.test.js +104 -0
  108. package/dist/surfaces/npm.d.ts +6 -0
  109. package/dist/surfaces/npm.js +72 -0
  110. package/dist/surfaces/openclaw-native.d.ts +93 -0
  111. package/dist/surfaces/openclaw-native.js +956 -0
  112. package/dist/surfaces/openclaw-native.test.d.ts +1 -0
  113. package/dist/surfaces/openclaw-native.test.js +246 -0
  114. package/dist/surfaces/pypi.d.ts +5 -0
  115. package/dist/surfaces/pypi.js +82 -0
  116. package/dist/surfaces/readme.d.ts +7 -0
  117. package/dist/surfaces/readme.js +181 -0
  118. package/dist/surfaces/registry.d.ts +6 -0
  119. package/dist/surfaces/registry.js +73 -0
  120. package/dist/surfaces/shared.d.ts +49 -0
  121. package/dist/surfaces/shared.js +112 -0
  122. package/dist/surfaces/skill.d.ts +7 -0
  123. package/dist/surfaces/skill.js +118 -0
  124. package/dist/surfaces/skill.test.d.ts +1 -0
  125. package/dist/surfaces/skill.test.js +87 -0
  126. package/dist/surfaces/web.d.ts +97 -0
  127. package/dist/surfaces/web.js +1285 -0
  128. package/dist/surfaces/web.test.d.ts +1 -0
  129. package/dist/surfaces/web.test.js +255 -0
  130. package/dist/surfaces/workflows.d.ts +2 -0
  131. package/dist/surfaces/workflows.js +809 -0
  132. package/dist/surfaces/workflows.test.d.ts +1 -0
  133. package/dist/surfaces/workflows.test.js +563 -0
  134. package/dist/toolfactory/cli.d.ts +2 -0
  135. package/dist/toolfactory/cli.js +65 -0
  136. package/dist/toolfactory/config.d.ts +3 -0
  137. package/dist/toolfactory/config.js +27 -0
  138. package/dist/toolfactory/mcp.d.ts +37 -0
  139. package/dist/toolfactory/mcp.js +269 -0
  140. package/dist/toolfactory/types.d.ts +31 -0
  141. package/dist/toolfactory/types.js +10 -0
  142. package/dist/toolfactory/web.d.ts +4 -0
  143. package/dist/toolfactory/web.js +40 -0
  144. package/dist/validate/agent-plugins.d.ts +5 -0
  145. package/dist/validate/agent-plugins.js +46 -0
  146. package/package.json +68 -0
  147. package/schemas/agent-plugins/mcp.schema.json +120 -0
  148. package/schemas/agent-plugins/plugin.schema.json +65 -0
  149. package/src/bindings/index.ts +47 -0
  150. package/src/bindings/python.test.ts +374 -0
  151. package/src/bindings/python.ts +862 -0
  152. package/src/bindings/typescript.test.ts +329 -0
  153. package/src/bindings/typescript.ts +818 -0
  154. package/src/commands.test.ts +253 -0
  155. package/src/commands.ts +886 -0
  156. package/src/e2e.test.ts +169 -0
  157. package/src/hosts/browser.ts +106 -0
  158. package/src/hosts/github.ts +321 -0
  159. package/src/hosts/openclaw.ts +110 -0
  160. package/src/hosts/template.ts +149 -0
  161. package/src/hosts/web.ts +111 -0
  162. package/src/identity/name.test.ts +43 -0
  163. package/src/identity/name.ts +39 -0
  164. package/src/identity/read.test.ts +92 -0
  165. package/src/identity/read.ts +119 -0
  166. package/src/index.ts +11 -0
  167. package/src/introspect/index.ts +72 -0
  168. package/src/model.ts +386 -0
  169. package/src/ops.ts +333 -0
  170. package/src/project/apply.test.ts +348 -0
  171. package/src/project/apply.ts +417 -0
  172. package/src/project/gate.ts +717 -0
  173. package/src/project/history.ts +46 -0
  174. package/src/project/load.ts +104 -0
  175. package/src/project/lock.ts +54 -0
  176. package/src/project/plan.ts +101 -0
  177. package/src/report/coverage.test.ts +108 -0
  178. package/src/report/coverage.ts +113 -0
  179. package/src/surfaces/agent-plugins.ts +47 -0
  180. package/src/surfaces/agents.template.ts +32 -0
  181. package/src/surfaces/agents.test.ts +206 -0
  182. package/src/surfaces/agents.ts +729 -0
  183. package/src/surfaces/browser-extension.test.ts +175 -0
  184. package/src/surfaces/browser-extension.ts +1095 -0
  185. package/src/surfaces/claude.ts +99 -0
  186. package/src/surfaces/codex.test.ts +71 -0
  187. package/src/surfaces/codex.ts +105 -0
  188. package/src/surfaces/cursor.ts +62 -0
  189. package/src/surfaces/distribution.test.ts +166 -0
  190. package/src/surfaces/dsh.test.ts +110 -0
  191. package/src/surfaces/dsh.ts +159 -0
  192. package/src/surfaces/external.ts +13 -0
  193. package/src/surfaces/gemini.test.ts +80 -0
  194. package/src/surfaces/gemini.ts +84 -0
  195. package/src/surfaces/hermes-native.test.ts +179 -0
  196. package/src/surfaces/hermes-native.ts +584 -0
  197. package/src/surfaces/kernel.ts +68 -0
  198. package/src/surfaces/mcp-registry.test.ts +99 -0
  199. package/src/surfaces/mcp-registry.ts +190 -0
  200. package/src/surfaces/mcpb.test.ts +112 -0
  201. package/src/surfaces/mcpb.ts +130 -0
  202. package/src/surfaces/npm.ts +78 -0
  203. package/src/surfaces/openclaw-native.test.ts +283 -0
  204. package/src/surfaces/openclaw-native.ts +1070 -0
  205. package/src/surfaces/pypi.ts +84 -0
  206. package/src/surfaces/readme.ts +218 -0
  207. package/src/surfaces/registry.ts +79 -0
  208. package/src/surfaces/shared.ts +140 -0
  209. package/src/surfaces/skill.test.ts +103 -0
  210. package/src/surfaces/skill.ts +136 -0
  211. package/src/surfaces/web.test.ts +303 -0
  212. package/src/surfaces/web.ts +1310 -0
  213. package/src/surfaces/workflows.test.ts +677 -0
  214. package/src/surfaces/workflows.ts +936 -0
  215. package/src/toolfactory/cli.ts +74 -0
  216. package/src/toolfactory/config.ts +33 -0
  217. package/src/toolfactory/mcp.ts +311 -0
  218. package/src/toolfactory/types.ts +52 -0
  219. package/src/toolfactory/web.ts +45 -0
  220. package/src/validate/agent-plugins.ts +53 -0
  221. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  222. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  223. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  224. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  225. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  226. package/web/dist/assets/index-CCXhQKK3.css +2 -0
  227. package/web/dist/assets/index-D1PwZ3qS.js +10 -0
  228. package/web/dist/index.html +14 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,563 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import { parse as yamlParse } from "yaml";
7
+ import { bootstrapRepo } from "../hosts/github.js";
8
+ import { gateSteps, packageSteps, registries } from "../project/gate.js";
9
+ import { sourcesZipName, zipName } from "./browser-extension.js";
10
+ import { surface } from "./workflows.js";
11
+ const echo = { name: "echo", inputSchema: { type: "object" }, requires: [] };
12
+ const shoot = { name: "shoot", inputSchema: { type: "object" }, requires: ["browser"] };
13
+ function project(surfaces, overrides = {}) {
14
+ return {
15
+ root: "/repo",
16
+ tool: {
17
+ schemaVersion: 1,
18
+ identity: "package.json",
19
+ binding: "typescript",
20
+ surfaces,
21
+ bundle: { runtime: "package" },
22
+ tests: { examples: {} },
23
+ config: {
24
+ properties: {
25
+ api_key: {
26
+ type: "string",
27
+ description: "Provider API key",
28
+ "x-toolfactory": { sensitive: true, tier: 4, url: "https://example.com/keys" },
29
+ },
30
+ region: { type: "string", description: "Deployment region" },
31
+ },
32
+ required: ["api_key"],
33
+ },
34
+ },
35
+ identity: { name: "hello", version: "0.1.0", description: "Say hello" },
36
+ identityExtra: {},
37
+ operations: [echo],
38
+ toolfactoryVersion: "0.1.0",
39
+ ...overrides,
40
+ };
41
+ }
42
+ function emitted(target) {
43
+ return Object.fromEntries(surface
44
+ .plan(target)
45
+ .map((file) => [
46
+ file.path,
47
+ file.kind === "file"
48
+ ? file.content
49
+ : file.kind === "merge"
50
+ ? JSON.stringify(file.patch)
51
+ : "",
52
+ ]));
53
+ }
54
+ /** Every generated file is a parseable document in its format — the one invariant that must never break. */
55
+ function expectAllYamlAndJsonParse(files) {
56
+ for (const [path, content] of Object.entries(files)) {
57
+ if (path.endsWith(".yml") || path.endsWith(".yaml")) {
58
+ expect(() => yamlParse(content), path).not.toThrow();
59
+ }
60
+ else if (path.endsWith(".json")) {
61
+ expect(() => JSON.parse(content), path).not.toThrow();
62
+ }
63
+ }
64
+ }
65
+ describe("workflows", () => {
66
+ it("sets up Node without a package-manager cache for an instruction-only plugin", () => {
67
+ const target = project(["skill", "codex"]);
68
+ target.tool.runtime = "none";
69
+ const ci = yamlParse(emitted(target)[".github/workflows/ci.yml"] ?? "");
70
+ const setupNode = ci.jobs.test.steps.find((step) => step.uses === "actions/setup-node@v7");
71
+ expect(setupNode?.with).toEqual({ "node-version": "${{ matrix.node-version }}" });
72
+ expect(ci.jobs.test.steps.some((step) => step.uses === "pnpm/action-setup@v4")).toBe(false);
73
+ });
74
+ it("always emits ci.yml, release.yml, .env.example and renovate.json, never compose without a trigger", () => {
75
+ const files = emitted(project(["cli", "mcp"]));
76
+ expect(Object.keys(files).sort()).toEqual([
77
+ ".env.example",
78
+ ".github/workflows/ci.yml",
79
+ ".github/workflows/release.yml",
80
+ "renovate.json",
81
+ ]);
82
+ // No registry surface: the tag still gets its gate, its assets and its Release, no legs.
83
+ const bare = yamlParse(files[".github/workflows/release.yml"]);
84
+ expect(Object.keys(bare.jobs)).toEqual(["gate", "package", "release"]);
85
+ expect(bare.jobs.gate.outputs).toEqual({ sha: "${{ steps.tag.outputs.sha }}" });
86
+ expectAllYamlAndJsonParse(files);
87
+ const ci = yamlParse(files[".github/workflows/ci.yml"]);
88
+ expect(ci.jobs.test.strategy.matrix["node-version"]).toEqual(["22", "24"]);
89
+ // No skill/claude/openclaw-native selected: no extra validator installs, no matrix guard.
90
+ const steps = ci.jobs.test.steps;
91
+ expect(steps.some((s) => s.run?.includes("agentskills"))).toBe(false);
92
+ expect(steps.filter((s) => s.uses).map((s) => s.uses)).toEqual(["actions/checkout@v7", "actions/setup-node@v7"]);
93
+ expect(steps.some((s) => s.run?.includes("claude-code"))).toBe(false);
94
+ expect(steps.find((s) => s.run === "npx toolfactory validate")?.if).toBeUndefined();
95
+ const renovate = JSON.parse(files["renovate.json"]);
96
+ expect(renovate.extends).toEqual(["config:recommended", "group:all"]);
97
+ expect(renovate.minimumReleaseAge).toBe("7 days");
98
+ // The generated workflows are SHA-locked projections: a Renovate PR editing one would make
99
+ // `toolfactory check` fail and the next `toolfactory build` revert it.
100
+ expect(renovate.ignorePaths).toContain(".github/workflows/**");
101
+ });
102
+ it("ci.yml installs each selected surface's validator and guards the openclaw leg on the matrix", () => {
103
+ const files = emitted(project(["skill", "claude", "openclaw-native", "mcp"]));
104
+ const ci = yamlParse(files[".github/workflows/ci.yml"]);
105
+ const steps = ci.jobs.test.steps;
106
+ expect(steps.some((s) => s.uses === "astral-sh/setup-uv@v6")).toBe(true);
107
+ expect(steps.some((s) => s.run?.includes("npm i -g @anthropic-ai/claude-code"))).toBe(true);
108
+ // openclaw-native needs Node >=24; skip that one step on the 22 leg rather than fail it.
109
+ expect(steps.find((s) => s.run === "npx toolfactory validate")?.if).toBe("matrix.node-version == '24'");
110
+ });
111
+ it("python binding uses a single uv + Python 3.12 toolchain, no node matrix", () => {
112
+ const python = project(["cli", "mcp"], {
113
+ tool: { ...project(["cli"]).tool, binding: "python" },
114
+ });
115
+ const ci = yamlParse(emitted(python)[".github/workflows/ci.yml"]);
116
+ expect(ci.jobs.test.strategy).toBeUndefined();
117
+ const steps = ci.jobs.test.steps;
118
+ expect(steps.find((s) => s.uses === "astral-sh/setup-uv@v6")?.with).toEqual({
119
+ "python-version": "3.12",
120
+ });
121
+ expect(steps.some((s) => s.run === "uv run --with pytest pytest -q")).toBe(true);
122
+ });
123
+ it("release.yml runs the legs in the forced order npm -> oci -> mcp-registry -> clawhub, each gated on what the gate decided", () => {
124
+ const files = emitted(project(["npm", "mcp-registry", "clawhub", "openclaw-native", "skill", "claude", "web", "mcp"], {
125
+ identity: {
126
+ name: "hello",
127
+ version: "0.1.0",
128
+ repository: "https://github.com/acme/hello.git",
129
+ },
130
+ }));
131
+ expectAllYamlAndJsonParse(files);
132
+ const release = yamlParse(files[".github/workflows/release.yml"]);
133
+ expect(release.on.push).toEqual({ tags: ["v*"] });
134
+ // The re-run after adding a secret: a fresh run reads current secrets, `gh run rerun` does not.
135
+ expect(release.on.workflow_dispatch.inputs.tag).toMatchObject({
136
+ required: true,
137
+ type: "string",
138
+ });
139
+ // Presence is decided once, in the gate, and every leg obeys it at job level.
140
+ expect(Object.keys(release.jobs.gate.outputs)).toEqual([
141
+ "sha",
142
+ "npm",
143
+ "oci",
144
+ "mcp_registry",
145
+ "clawhub_package",
146
+ "clawhub_skill",
147
+ "pages",
148
+ ]);
149
+ expect(release.jobs["publish-npm"].if).toBe("needs.gate.outputs.npm == 'true'");
150
+ expect(release.jobs["publish-mcp-registry"].if).toBe("needs.gate.outputs.mcp_registry == 'true'");
151
+ expect(release.jobs["publish-clawhub"].if).toBe("${{ !cancelled() && needs.package.result == 'success' && !contains(needs.*.result, 'failure') && needs.gate.outputs.clawhub_package == 'true' }}");
152
+ expect(release.jobs["pages-build"].if).toBe("needs.gate.outputs.pages == 'true'");
153
+ const presence = release.jobs.gate.steps.find((s) => s.id === "presence");
154
+ expect(presence?.env?.NPM_TOKEN).toBe("${{ secrets.NPM_TOKEN }}");
155
+ expect(presence?.env?.NPM_TRUSTED_PUBLISHER).toBe("${{ vars.NPM_TRUSTED_PUBLISHER }}");
156
+ expect(presence?.run).toContain('echo "npm=$npm" >> "$GITHUB_OUTPUT"');
157
+ expect(presence?.run).toContain('[ "$npm" = true ] && [ "$oci" = true ]');
158
+ // Skipped legs never skip the Release; failed ones do. It retracts dropped registries first.
159
+ expect(release.jobs.release.if).toBe("${{ !cancelled() && needs.package.result == 'success' && !contains(needs.*.result, 'failure') }}");
160
+ expect(release.jobs.release.needs).toContain("gate");
161
+ expect(release.jobs.release.env).not.toHaveProperty("NPM_TOKEN");
162
+ const releaseSteps = release.jobs.release.steps;
163
+ expect(releaseSteps[0].with).toEqual({
164
+ ref: "${{ needs.gate.outputs.sha }}",
165
+ "fetch-depth": 0,
166
+ });
167
+ expect(releaseSteps.some((s) => s.run === "npx toolfactory unpublish")).toBe(true);
168
+ expect(releaseSteps.at(-1)?.with).toMatchObject({
169
+ tag_name: "${{ inputs.tag || github.ref_name }}",
170
+ target_commitish: "${{ needs.gate.outputs.sha }}",
171
+ });
172
+ // Cutting a tag from the Actions UI: gate resolves the commit, the Release creates the tag.
173
+ const gateSteps_ = release.jobs.gate.steps;
174
+ expect(gateSteps_[0].with).toEqual({ "fetch-depth": 0 });
175
+ expect(gateSteps_.find((s) => s.id === "tag")?.run).toContain('git rev-parse -q --verify "refs/tags/$RELEASE_TAG^{commit}"');
176
+ expect(Object.keys(release.jobs)).toEqual([
177
+ "gate",
178
+ "package",
179
+ "publish-npm",
180
+ "publish-oci",
181
+ "publish-mcp-registry",
182
+ "publish-clawhub",
183
+ "publish-clawhub-skill",
184
+ "release",
185
+ "pages-build",
186
+ "pages-deploy",
187
+ ]);
188
+ expect(release.jobs["publish-npm"].needs).toEqual(["gate", "package"]);
189
+ expect(release.jobs["publish-npm"].steps).toContainEqual({
190
+ uses: "actions/download-artifact@v8",
191
+ with: { name: "release-assets", path: "release-assets" },
192
+ });
193
+ expect(release.jobs["publish-npm"].env).not.toHaveProperty("NPM_TOKEN");
194
+ for (const step of release.jobs["publish-npm"].steps) {
195
+ if (step.name === "npm publish") {
196
+ expect(step.env.NPM_TOKEN).toBe("${{ vars.NPM_TRUSTED_PUBLISHER != 'true' && secrets.NPM_TOKEN || '' }}");
197
+ }
198
+ else {
199
+ expect(step.env ?? {}).not.toHaveProperty("NPM_TOKEN");
200
+ }
201
+ }
202
+ expect(release.jobs["publish-oci"].needs).toBe("gate");
203
+ expect(release.jobs["publish-mcp-registry"].needs).toEqual([
204
+ "gate",
205
+ "publish-npm",
206
+ "publish-oci",
207
+ ]);
208
+ // The ClawHub leg publishes the tarball `package` built, not the repository subdirectory:
209
+ // the reusable workflow has no build step of its own.
210
+ expect(release.jobs["publish-clawhub"].needs).toEqual([
211
+ "gate",
212
+ "package",
213
+ "publish-npm",
214
+ "publish-oci",
215
+ "publish-mcp-registry",
216
+ ]);
217
+ expect(release.jobs["publish-clawhub"].with).toMatchObject({
218
+ package_artifact_name: "release-assets",
219
+ package_artifact_path: "openclaw-plugin-hello-0.1.0.tgz",
220
+ });
221
+ expect(release.jobs["publish-clawhub"].secrets.clawhub_token).toContain("CLAWHUB_TOKEN");
222
+ // ClawHub's skill catalog is a separate track: it needs only `gate` (no built tarball), and
223
+ // reuses ClawHub's own skill-publish.yml, which derives slug/version from SKILL.md itself.
224
+ expect(release.jobs["publish-clawhub-skill"]).toMatchObject({
225
+ needs: "gate",
226
+ uses: "openclaw/clawhub/.github/workflows/skill-publish.yml@main",
227
+ with: { skill_path: "skills/hello", dry_run: false },
228
+ });
229
+ expect(release.jobs["publish-clawhub-skill"].secrets.clawhub_token).toContain("CLAWHUB_TOKEN");
230
+ expect(release.jobs.release.needs.at(-1)).toBe("publish-clawhub-skill");
231
+ // Every job that runs steps declares its own permissions; the workflow-call leg inherits the
232
+ // file's `contents: read`.
233
+ const withoutPermissions = Object.entries(release.jobs)
234
+ .filter(([, job]) => job.permissions === undefined)
235
+ .map(([name]) => name);
236
+ expect(withoutPermissions).toEqual(["publish-clawhub", "publish-clawhub-skill"]);
237
+ expect(release.permissions).toEqual({ contents: "read" });
238
+ expect(release.jobs["publish-oci"].permissions).toEqual({
239
+ contents: "read",
240
+ packages: "write",
241
+ attestations: "write",
242
+ "id-token": "write",
243
+ });
244
+ expect(release.jobs.release.permissions).toEqual({
245
+ contents: "write",
246
+ packages: "write",
247
+ pages: "write",
248
+ "id-token": "write",
249
+ });
250
+ expect(release.jobs["pages-deploy"].permissions).toEqual({
251
+ pages: "write",
252
+ "id-token": "write",
253
+ });
254
+ // The gate asserts the tag against the identity file's version and never writes it (§2.2).
255
+ const gateRuns = release.jobs.gate.steps
256
+ .map((s) => s.run)
257
+ .filter(Boolean);
258
+ // After the commit is resolved (a dispatched tag may not exist yet), before anything else.
259
+ expect(gateRuns[1]).toBe('test "v$(node -p "require(\'./package.json\').version")" = "$RELEASE_TAG"');
260
+ // The gate is the same check sequence as ci.yml: build precedes `toolfactory validate`,
261
+ // which runs every selected surface's own validator (no transcribed commands to drift).
262
+ expect(gateRuns.indexOf("npm run --if-present build")).toBeLessThan(gateRuns.indexOf("npx toolfactory validate"));
263
+ expect(gateRuns.some((r) => r?.includes("openclaw plugins build"))).toBe(false);
264
+ // npm generates provenance itself under trusted publishing; --provenance is redundant. A
265
+ // configured publisher selects OIDC; otherwise the stored token is a fallback. A re-dispatch
266
+ // after adding either credential must not attempt to republish an immutable version.
267
+ 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);
270
+ // The oci leg pushes the very image server.json's oci entry names.
271
+ const meta = release.jobs["publish-oci"].steps.find((s) => s.id === "meta");
272
+ expect(meta?.with?.images).toBe("ghcr.io/acme/hello");
273
+ expect(meta?.with?.labels).toBe("io.modelcontextprotocol.server.name=io.github.acme/hello");
274
+ expect(files[".github/workflows/release.yml"]).toContain("git is the ledger");
275
+ expect(files[".github/workflows/release.yml"]).toContain("CLAWHUB_TOKEN");
276
+ });
277
+ it("clawhub without openclaw-native publishes nothing (there is no host package to publish)", () => {
278
+ const release = emitted(project(["clawhub", "npm"]))[".github/workflows/release.yml"];
279
+ expect(yamlParse(release).jobs["publish-clawhub"]).toBeUndefined();
280
+ });
281
+ it("clawhub + skill without openclaw-native still publishes the skill catalog leg", () => {
282
+ const release = yamlParse(emitted(project(["clawhub", "skill"]))[".github/workflows/release.yml"]);
283
+ expect(release.jobs["publish-clawhub"]).toBeUndefined();
284
+ expect(release.jobs["publish-clawhub-skill"].needs).toBe("gate");
285
+ });
286
+ it("compose.toolfactory.yaml appears only for a host-native surface and picks the browser image variant", () => {
287
+ expect(emitted(project(["cli"]))["compose.toolfactory.yaml"]).toBeUndefined();
288
+ const withBrowser = project(["openclaw-native"], { operations: [echo, shoot] });
289
+ const compose = yamlParse(emitted(withBrowser)["compose.toolfactory.yaml"]);
290
+ expect(compose.services.openclaw.image).toBe("ghcr.io/openclaw/openclaw:latest-browser");
291
+ // `--link` (a copy trips the install-time symlink scan) and a non-interactive accept.
292
+ expect(compose.services.openclaw.command[2]).toContain("openclaw plugins install --link /work/hosts/openclaw --force --accept-capabilities");
293
+ expect(compose.services.openclaw.env_file).toEqual([".env"]);
294
+ expect(compose.services.hermes).toBeUndefined();
295
+ expect(compose.volumes.PLUGIN_DATA).toEqual({});
296
+ const noBrowser = project(["hermes-native"]);
297
+ const composeHermes = yamlParse(emitted(noBrowser)["compose.toolfactory.yaml"]);
298
+ expect(composeHermes.services.hermes.command[2]).toContain("hermes plugins doctor /work/hosts/hermes/hello_hermes --ci");
299
+ expect(composeHermes.services.openclaw).toBeUndefined();
300
+ });
301
+ it("adds the T4 live job, and workflow_dispatch, iff a config key is required and sensitive", () => {
302
+ const ci = yamlParse(emitted(project(["cli"]))[".github/workflows/ci.yml"]);
303
+ expect(ci.on.workflow_dispatch).toEqual({});
304
+ const live = ci.jobs.live;
305
+ expect(live.needs).toBe("test");
306
+ expect(live.environment).toBe("live-tests");
307
+ // A fork's pull request must never see the live-tests secrets.
308
+ expect(live.if).toBe("github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository");
309
+ // Every sensitive key, not only the required ones: an optional credential is still a secret.
310
+ expect(live.env).toEqual({ API_KEY: "${{ secrets.API_KEY }}" });
311
+ expect(live.steps.at(-1)?.run).toBe("npm run --if-present test:live");
312
+ const python = project(["cli"], { tool: { ...project(["cli"]).tool, binding: "python" } });
313
+ expect(yamlParse(emitted(python)[".github/workflows/ci.yml"]).jobs.live.steps.at(-1)?.run).toBe("uv run --with pytest pytest -q tests/test_live.py");
314
+ // api_key optional: nothing gates a live run, so no live job and no workflow_dispatch.
315
+ const noCredential = project(["cli"]);
316
+ noCredential.tool.config = { ...noCredential.tool.config, required: [] };
317
+ const plain = yamlParse(emitted(noCredential)[".github/workflows/ci.yml"]);
318
+ expect(plain.jobs.live).toBeUndefined();
319
+ expect(plain.on.workflow_dispatch).toBeUndefined();
320
+ });
321
+ it(".env.example lists every config key uppercased, secrets and required marked", () => {
322
+ const env = emitted(project(["cli"]))[".env.example"];
323
+ expect(env).toContain("API_KEY=");
324
+ expect(env).toContain("(required, secret) — get one: https://example.com/keys");
325
+ expect(env).toContain("REGION=");
326
+ expect(env).toContain("(optional)");
327
+ });
328
+ });
329
+ /** Real invocation: an offline GitHub Actions linter, when one happens to be on PATH. */
330
+ describe("npm release authentication", () => {
331
+ it("executes token, OIDC, disabled, and already-published branches without registry writes", () => {
332
+ const target = project(["npm"]);
333
+ const release = yamlParse(emitted(target)[".github/workflows/release.yml"]);
334
+ const publish = release.jobs["publish-npm"].steps.find((step) => step.name === "npm publish")?.run;
335
+ const gate = registries(target).find((registry) => registry.id === "npm")?.gate;
336
+ expect(publish).toBeDefined();
337
+ expect(gate).toBeDefined();
338
+ for (const scenario of [
339
+ { trust: "", token: "", exists: false, enabled: false, output: "" },
340
+ { trust: "false", token: "", exists: false, enabled: false, output: "" },
341
+ {
342
+ trust: "false",
343
+ token: "test-token",
344
+ exists: false,
345
+ enabled: true,
346
+ output: "publish:test-token",
347
+ },
348
+ { trust: "true", token: "test-token", exists: false, enabled: true, output: "publish:oidc" },
349
+ { trust: "true", token: "", exists: true, enabled: true, output: "already published" },
350
+ ]) {
351
+ const env = {
352
+ ...process.env,
353
+ NPM_TRUSTED_PUBLISHER: scenario.trust,
354
+ NPM_TOKEN: scenario.token,
355
+ NODE_AUTH_TOKEN: "",
356
+ };
357
+ const gated = spawnSync("bash", ["-c", gate], { env });
358
+ expect(gated.status === 0).toBe(scenario.enabled);
359
+ if (!scenario.enabled)
360
+ continue;
361
+ // Stub the upstream CLI at the shell boundary; never contact or write to npm.
362
+ const stub = `npm() { if [ "$1" = view ]; then return ${scenario.exists ? 0 : 1}; fi; printf 'publish:%s\\n' "\${NODE_AUTH_TOKEN:-oidc}"; };`;
363
+ const output = execFileSync("bash", ["-c", `${stub}\n${publish}`], {
364
+ env,
365
+ encoding: "utf8",
366
+ });
367
+ expect(output).toContain(scenario.output);
368
+ if (scenario.exists)
369
+ expect(output).not.toContain("publish:");
370
+ }
371
+ });
372
+ });
373
+ function actionlintAvailable() {
374
+ try {
375
+ execFileSync("actionlint", ["-version"], { stdio: "ignore" });
376
+ return true;
377
+ }
378
+ catch {
379
+ return false;
380
+ }
381
+ }
382
+ describe.skipIf(!actionlintAvailable())("actionlint", () => {
383
+ it("accepts the generated ci.yml and release.yml", () => {
384
+ const dir = mkdtempSync(join(tmpdir(), "tf-workflows-"));
385
+ const files = emitted(project([
386
+ "npm",
387
+ "mcp-registry",
388
+ "clawhub",
389
+ "openclaw-native",
390
+ "hermes-native",
391
+ "skill",
392
+ "claude",
393
+ "web",
394
+ "mcp",
395
+ ], {
396
+ identity: {
397
+ name: "hello",
398
+ version: "0.1.0",
399
+ repository: "https://github.com/acme/hello.git",
400
+ },
401
+ }));
402
+ const workflowPaths = [];
403
+ for (const [path, content] of Object.entries(files)) {
404
+ if (!path.startsWith(".github/workflows/"))
405
+ continue;
406
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
407
+ writeFileSync(join(dir, path), content);
408
+ workflowPaths.push(path);
409
+ }
410
+ // actionlint otherwise walks up looking for a git repo to find `.github/workflows`;
411
+ // point it straight at the files instead.
412
+ execFileSync("actionlint", workflowPaths, { cwd: dir });
413
+ });
414
+ });
415
+ /**
416
+ * `bootstrap-repo`: the one-time I/O the live job depends on. It shells to the official `gh`
417
+ * CLI, so the unit under test is the invocation list — which `--dry-run` returns without
418
+ * touching the network — and the fact that no secret value appears in it.
419
+ */
420
+ describe("bootstrap-repo", () => {
421
+ it("prepares the live-tests environment through gh, with values only ever on stdin", () => {
422
+ const target = project(["cli"], {
423
+ root: "/repo",
424
+ identity: { name: "hello", repository: "https://github.com/acme/hello.git" },
425
+ });
426
+ const result = bootstrapRepo(target, { reviewers: ["ada"], dryRun: true });
427
+ expect(result.repository).toBe("acme/hello");
428
+ expect(result.secrets).toEqual(["API_KEY"]);
429
+ expect(result.commands).toEqual([
430
+ "gh api users/ada --jq .id",
431
+ 'gh api --method PUT repos/acme/hello/environments/live-tests --input - <<< \'{"reviewers":[{"type":"User","id":"<id of ada>"}]}\'',
432
+ "gh secret set API_KEY --env live-tests --repo acme/hello # value from /repo/.env, on stdin",
433
+ ]);
434
+ const anonymous = project(["cli"], { identity: { name: "hello" } });
435
+ expect(() => bootstrapRepo(anonymous, { dryRun: true })).toThrow(/GitHub repository URL/);
436
+ // No live credential is not "nothing to do": the release tokens still land, at repository scope.
437
+ const noCredential = project(["cli"], { identity: target.identity });
438
+ noCredential.tool.config = { ...noCredential.tool.config, required: [] };
439
+ const release = bootstrapRepo(noCredential, {
440
+ dryRun: true,
441
+ releaseSecrets: ["NPM_TOKEN"],
442
+ manual: ["npm: generic instructions replaced by the bootstrap outcome"],
443
+ });
444
+ expect(release.secrets).toEqual(["NPM_TOKEN"]);
445
+ expect(release.commands).toContain("gh secret set NPM_TOKEN --repo acme/hello # value from /repo/.env, on stdin");
446
+ expect(release.manual.join(" ")).toContain("granular access tokens with bypass 2FA are unsupported");
447
+ expect(release.manual.join(" ")).toContain("NPM_TRUSTED_PUBLISHER");
448
+ expect(release.manual.filter((step) => step.startsWith("npm:"))).toHaveLength(1);
449
+ });
450
+ });
451
+ /**
452
+ * The gate is a command and the workflows are its projection: one list, two renderings. The
453
+ * local runner skips the runner-only provisioning, so what it skips and what it keeps is pinned.
454
+ */
455
+ describe("gate", () => {
456
+ it("lists the same steps CI runs, marking the runner-only ones", () => {
457
+ const target = project(["npm", "claude", "openclaw-native", "mcp"]);
458
+ const steps = gateSteps(target);
459
+ expect(steps.map((step) => step.name)).toEqual([
460
+ "install",
461
+ "build",
462
+ "toolfactory check",
463
+ "toolfactory build (output files)",
464
+ "Install Claude Code CLI",
465
+ "toolfactory validate",
466
+ "author checks",
467
+ "author tests",
468
+ "openclaw end-to-end (scripted model, no LLM key)",
469
+ ]);
470
+ // `npm ci` and a global CLI install are the runner's job, not a checkout's: `toolfactory gate`
471
+ // skips every `when: "ci"` step and runs the rest here, in order.
472
+ expect(steps.filter((step) => step.when === "ci").map((step) => step.name)).toEqual([
473
+ "install",
474
+ "Install Claude Code CLI",
475
+ ]);
476
+ expect(steps.filter((step) => step.when === "node24").map((step) => step.name)).toEqual([
477
+ "toolfactory validate",
478
+ "openclaw end-to-end (scripted model, no LLM key)",
479
+ ]);
480
+ });
481
+ it("packages one asset per selected distribution surface into dist/release", () => {
482
+ const target = project([
483
+ "npm",
484
+ "pypi",
485
+ "openclaw-native",
486
+ "skill",
487
+ "claude",
488
+ "web",
489
+ "browser-extension",
490
+ "mcp",
491
+ ]);
492
+ const runs = packageSteps(target).map((step) => step.run);
493
+ expect(runs).toContain("npm pack --pack-destination dist/release");
494
+ expect(runs).toContain("uv build --out-dir dist/release");
495
+ expect(runs.some((run) => run.includes("npm pack ./hosts/openclaw"))).toBe(true);
496
+ 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
+ const webBuild = runs.findIndex((run) => run.includes("npm -C web run build"));
498
+ const npmPack = runs.indexOf("npm pack --pack-destination dist/release");
499
+ const webArchive = runs.findIndex((run) => run.includes("hello-web.tar.gz"));
500
+ expect(webBuild).toBeGreaterThan(-1);
501
+ expect(webBuild).toBeLessThan(npmPack);
502
+ // Keep the release archive out of the npm tarball's dist directory.
503
+ expect(webArchive).toBeGreaterThan(npmPack);
504
+ // wxt zip -b <browser> per store, copied into dist/release under zipName()'s own names, plus
505
+ // the Firefox sources zip `wxt zip -b firefox` writes alongside it.
506
+ const zipStep = runs.find((run) => run.includes("wxt zip hosts/browser -b chrome"));
507
+ expect(zipStep).toContain("wxt zip hosts/browser -b firefox");
508
+ expect(zipStep).toContain("wxt zip hosts/browser -b edge");
509
+ for (const asset of [
510
+ zipName(target, "chrome"),
511
+ zipName(target, "firefox"),
512
+ zipName(target, "edge"),
513
+ sourcesZipName(target),
514
+ ]) {
515
+ expect(zipStep).toContain(asset);
516
+ }
517
+ // The signed Firefox xpi is opt-in on the JWT pair, never a hard failure when absent.
518
+ expect(runs.some((run) => run.includes("web-ext sign") && run.includes("FIREFOX_JWT_ISSUER"))).toBe(true);
519
+ expect(runs.at(-1)).toBe("cp COVERAGE.md dist/release/ && npx toolfactory coverage > dist/release/coverage.json");
520
+ });
521
+ });
522
+ /**
523
+ * The publish-browser-ext leg (store submission) and its opt-in Safari sibling: the leg appears
524
+ * only with the surface, Safari only with the surface *and* `tool.json` `browserExtension.safari`.
525
+ */
526
+ describe("browser-extension release legs", () => {
527
+ it("release.yml carries publish-browser-ext only with the surface, Safari only with the flag", () => {
528
+ const withIdentity = {
529
+ identity: {
530
+ name: "hello",
531
+ repository: "https://github.com/acme/hello.git",
532
+ version: "0.1.0",
533
+ },
534
+ };
535
+ const noExtension = project(["npm"], withIdentity);
536
+ expect(yamlParse(emitted(noExtension)[".github/workflows/release.yml"]).jobs["publish-browser-ext"]).toBeUndefined();
537
+ const withExtension = project(["browser-extension", "mcp"], withIdentity);
538
+ const release = yamlParse(emitted(withExtension)[".github/workflows/release.yml"]);
539
+ // browser-extension alone (no npm/pypi/mcp-registry/clawhub) still gets a release.yml.
540
+ expect(release.jobs["publish-browser-ext"]).toBeDefined();
541
+ expect(release.jobs["publish-browser-ext"].needs).toEqual(["gate", "package"]);
542
+ expect(release.jobs["publish-browser-ext"].permissions).toEqual({ contents: "read" });
543
+ const steps = release.jobs["publish-browser-ext"].steps;
544
+ expect(steps.some((s) => s.run?.includes("wxt submit --dry-run"))).toBe(true);
545
+ const realSubmit = steps.find((s) => s.run?.includes("wxt submit") && !s.run.includes("--dry-run"));
546
+ expect(realSubmit).toBeDefined();
547
+ // release.yml runs only on a tag or a dispatched tag, so the job-level store gate is the whole condition.
548
+ expect(release.jobs["publish-browser-ext"].if).toContain("needs.gate.outputs.chrome == 'true'");
549
+ expect(release.jobs.release.needs).toContain("publish-browser-ext");
550
+ expect(release.jobs["publish-browser-ext-safari"]).toBeUndefined();
551
+ const withSafari = project(["browser-extension", "mcp"], {
552
+ ...withIdentity,
553
+ tool: { ...project(["browser-extension"]).tool, browserExtension: { safari: true } },
554
+ });
555
+ const safariRelease = yamlParse(emitted(withSafari)[".github/workflows/release.yml"]);
556
+ expect(safariRelease.jobs["publish-browser-ext-safari"]).toMatchObject({
557
+ needs: "gate",
558
+ "runs-on": "macos-latest",
559
+ });
560
+ // The Safari leg submits to App Store Connect, not dist/release/: it never gates the release.
561
+ expect(safariRelease.jobs.release.needs).not.toContain("publish-browser-ext-safari");
562
+ });
563
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.
3
+ import { Command } from "commander";
4
+ import { z } from "zod";
5
+ import { operations as authored } from "../ops.js";
6
+ import { context } from "./config.js";
7
+ import { serves } from "./types.js";
8
+ import { web } from "./web.js";
9
+ /** The authored operations plus the generated `web` one, exactly as the MCP kernel lists them. */
10
+ const operations = [...authored, web];
11
+ const program = new Command()
12
+ .name("toolfactory")
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");
15
+ for (const op of operations) {
16
+ if (!serves(op, "cli"))
17
+ continue;
18
+ const command = program.command(op.name).description(op.description);
19
+ command.option("--json <arguments>", "all arguments as one JSON object");
20
+ const schema = z.toJSONSchema(op.input, { io: "input" });
21
+ for (const [key, property] of Object.entries(schema.properties ?? {})) {
22
+ const flag = property.type === "boolean" ? `--${key}` : `--${key} <value>`;
23
+ const hint = property.type === "array" ? " (comma-separated)" : property.type === "object" ? " (JSON)" : "";
24
+ command.option(flag, `${property.description ?? key}${hint}`);
25
+ }
26
+ command.action(async (options) => {
27
+ const fromJson = options.json ? JSON.parse(String(options.json)) : {};
28
+ const fromFlags = Object.fromEntries(Object.entries(options)
29
+ .filter(([key, value]) => key !== "json" && value !== undefined)
30
+ .map(([key, value]) => {
31
+ const type = schema.properties?.[key]?.type;
32
+ if (type === "number" || type === "integer")
33
+ return [key, Number(value)];
34
+ if (type === "boolean")
35
+ return [key, value === true || value === "true"];
36
+ if (type === "array")
37
+ return [key, String(value).split(",").map((item) => item.trim()).filter(Boolean)];
38
+ if (type === "object")
39
+ return [key, JSON.parse(String(value))];
40
+ return [key, value];
41
+ }));
42
+ const args = op.input.parse({ ...fromJson, ...fromFlags });
43
+ const result = await op.handler(args, context());
44
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
45
+ });
46
+ }
47
+ program
48
+ .command("mcp")
49
+ .description("Serve the operations as an MCP server over stdio, or over streamable HTTP with --http")
50
+ .option("--http [port]", "serve over MCP streamable HTTP instead of stdio (default port 3000, host 127.0.0.1, path /mcp)")
51
+ .option("--pair", "serve over HTTP with a fresh pairing token: prints <url>#<token> and requires it as a bearer token")
52
+ .option("--open", "serve over HTTP and open the web app in this machine's browser")
53
+ .action(async (options) => {
54
+ const { serve, serveHttp } = await import("./mcp.js");
55
+ if (options.http === undefined && !options.pair && !options.open)
56
+ await serve();
57
+ else {
58
+ const port = options.http === undefined || options.http === true ? 3000 : Number(options.http);
59
+ await serveHttp({ port, pair: options.pair, open: options.open });
60
+ }
61
+ });
62
+ program.parseAsync(process.argv).catch((error) => {
63
+ console.error(error instanceof Error ? error.message : error);
64
+ process.exit(1);
65
+ });
@@ -0,0 +1,3 @@
1
+ import type { Context } from "./types.js";
2
+ /** Configuration is read from the environment; hosts inject it from their own settings UI. */
3
+ export declare function context(): Context;