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,136 @@
1
+ /**
2
+ * Agent Skills: one `skills/<N>/SKILL.md` per tool. toolfactory owns the frontmatter and
3
+ * the `<!-- tf:operations -->` block; the body is the author's prose and is never generated.
4
+ * The bundle formats (Agent Plugins, Claude, Codex, Cursor) fix `skills/` at the bundle root,
5
+ * while Copilot, Codex, Hermes and DSH read a project's skills only from `.agents/skills/`
6
+ * (and `npx skills add` reads both): a symbolic link there is the one directory that serves all.
7
+ */
8
+ import { stringify as yaml } from "yaml";
9
+ import {
10
+ type Capability,
11
+ isInstructionOnly,
12
+ type Operation,
13
+ type Project,
14
+ type Surface,
15
+ type Verdict,
16
+ } from "../model.js";
17
+ import { compact, has, skillVerdict } from "./shared.js";
18
+
19
+ export const OPERATIONS_BEGIN = "<!-- tf:operations -->";
20
+ export const OPERATIONS_END = "<!-- /tf:operations -->";
21
+
22
+ export function skillPath(project: Project): string {
23
+ return `skills/${project.identity.name}/SKILL.md`;
24
+ }
25
+
26
+ /** The `.agents/` canon's view of the same skill, which `.agents/sync.py` fans out to every harness. */
27
+ function skillLinkPath(project: Project): string {
28
+ return `.agents/skills/${project.identity.name}`;
29
+ }
30
+
31
+ function invocation(project: Project, operation: Operation): string {
32
+ const name = project.identity.name;
33
+ const lines: string[] = [];
34
+ if (has(project, "cli"))
35
+ lines.push(`\`${name} ${operation.name} --json '<arguments>'\` prints a JSON result.`);
36
+ if (has(project, "mcp") || has(project, "agent-plugins")) {
37
+ lines.push(
38
+ `MCP tool \`${operation.name}\` on server \`${name}\` returns the same result as \`structuredContent\`.`,
39
+ );
40
+ }
41
+ return lines.join(" ");
42
+ }
43
+
44
+ /** How an agent bridges each non-portable capability before calling the operation. */
45
+ const GUIDANCE: Partial<Record<Capability, string>> = {
46
+ browser: "drive this host's own browser tools and pass what they return as arguments",
47
+ model: "do that reasoning yourself and pass the result as an argument",
48
+ "user-input": "ask the user for what it needs before calling it",
49
+ };
50
+
51
+ export function renderOperations(project: Project): string {
52
+ if (isInstructionOnly(project.tool)) return "";
53
+ const lines = ["", "## Operations", ""];
54
+ if (project.operations.length === 0) {
55
+ lines.push("_No operations yet: run `toolfactory introspect` after adding one to the kernel._");
56
+ }
57
+ for (const operation of project.operations) {
58
+ const verdict: Verdict = skillVerdict(operation);
59
+ if (verdict.kind === "excluded") continue;
60
+ const required = Object.keys(
61
+ (operation.inputSchema.properties as Record<string, unknown> | undefined) ?? {},
62
+ );
63
+ lines.push(`### ${operation.name}`, "");
64
+ if (operation.description) lines.push(operation.description, "");
65
+ if (required.length)
66
+ lines.push(`Arguments: ${required.map((k) => `\`${k}\``).join(", ")}.`, "");
67
+ const how = invocation(project, operation);
68
+ if (how) lines.push(how, "");
69
+ const needs = operation.requires.filter((capability) => GUIDANCE[capability]);
70
+ if (verdict.kind === "bridged" && needs.length) {
71
+ lines.push(
72
+ `This operation needs ${needs.join(", ")}: ${needs.map((capability) => GUIDANCE[capability]).join("; ")}.`,
73
+ "",
74
+ );
75
+ }
76
+ }
77
+ return `${lines.join("\n")}\n`;
78
+ }
79
+
80
+ export function frontmatter(project: Project): string {
81
+ const document = compact({
82
+ name: project.identity.name,
83
+ description: project.identity.description ?? `Use the ${project.identity.name} tool.`,
84
+ license: project.identity.license,
85
+ });
86
+ return `\n${yaml(document).trimEnd()}\n`;
87
+ }
88
+
89
+ export const surface: Surface = {
90
+ id: "skill",
91
+ plan(project) {
92
+ const path = skillPath(project);
93
+ const regions = [
94
+ { begin: "---", end: "---", content: frontmatter(project) },
95
+ { begin: OPERATIONS_BEGIN, end: OPERATIONS_END, content: renderOperations(project) },
96
+ ];
97
+ const template = [
98
+ "---",
99
+ "---",
100
+ "",
101
+ `# ${project.identity.name}`,
102
+ "",
103
+ "Explain here when an agent should reach for this tool and how to combine its operations.",
104
+ "",
105
+ OPERATIONS_BEGIN,
106
+ OPERATIONS_END,
107
+ "",
108
+ ].join("\n");
109
+ return [
110
+ { kind: "region", path, regions, template },
111
+ {
112
+ kind: "file",
113
+ path: skillLinkPath(project),
114
+ content: `../../skills/${project.identity.name}`,
115
+ symlink: true,
116
+ },
117
+ ];
118
+ },
119
+ validate(project) {
120
+ return [
121
+ {
122
+ label: "agentskills validate",
123
+ command: "uvx",
124
+ args: [
125
+ "--from",
126
+ "skills-ref",
127
+ "agentskills",
128
+ "validate",
129
+ `skills/${project.identity.name}`,
130
+ ],
131
+ cwd: project.root,
132
+ },
133
+ ];
134
+ },
135
+ verdict: skillVerdict,
136
+ };
@@ -0,0 +1,303 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ symlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { homedir, tmpdir } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+ import { describe, expect, it } from "vitest";
13
+ import { kernel, scaffold } from "../bindings/typescript.js";
14
+ import { run } from "../commands.js";
15
+ import { DRIFT_ENTRY } from "../hosts/web.js";
16
+ import type { Operation, Project } from "../model.js";
17
+ import { apply, deepMerge, replaceRegions } from "../project/apply.js";
18
+ import { json } from "./shared.js";
19
+ import { APP_BEGIN, APP_END, surface, WEB_DIR, WEB_SCAFFOLD } from "./web.js";
20
+
21
+ const echo: Operation = {
22
+ name: "echo",
23
+ description: "Return the text you pass in.",
24
+ inputSchema: {
25
+ $schema: "https://json-schema.org/draft/2020-12/schema",
26
+ type: "object",
27
+ properties: { text: { type: "string", description: "Text to echo back" } },
28
+ required: ["text"],
29
+ },
30
+ annotations: { readOnlyHint: true },
31
+ requires: [],
32
+ };
33
+ const shoot: Operation = {
34
+ name: "shoot",
35
+ description: "Take a screenshot of the current tab.",
36
+ inputSchema: { type: "object" },
37
+ requires: ["browser"],
38
+ };
39
+
40
+ function project(overrides: Partial<Project> = {}): Project {
41
+ return {
42
+ root: "/repo",
43
+ tool: {
44
+ schemaVersion: 1,
45
+ identity: "package.json",
46
+ binding: "typescript",
47
+ surfaces: ["web", "mcp", "cli"],
48
+ bundle: { runtime: "package" },
49
+ tests: { examples: {} },
50
+ },
51
+ identity: { name: "hello", version: "0.1.0", description: "Say hello" },
52
+ identityExtra: {},
53
+ operations: [echo, shoot],
54
+ toolfactoryVersion: "0.1.0",
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ /** Every planned file as text, region files rendered fresh (as `apply()` writes them the first time). */
60
+ function planned(from: Project = project()): Map<string, string> {
61
+ return new Map(
62
+ surface.plan(from).map((file) => {
63
+ if (file.kind === "file") return [file.path, file.content];
64
+ if (file.kind === "region")
65
+ return [file.path, replaceRegions(file.template, file) ?? file.template];
66
+ return [file.path, json(deepMerge({}, file.patch))];
67
+ }),
68
+ );
69
+ }
70
+
71
+ function opsJson(from: Project = project()): Record<string, unknown> {
72
+ return JSON.parse(planned(from).get(`${WEB_DIR}/src/ops.json`) ?? "");
73
+ }
74
+
75
+ describe("web", () => {
76
+ it("plans the vite + shadcn project and leaves src/components/ui to the shadcn CLI", () => {
77
+ expect([...planned().keys()]).toEqual([
78
+ "web/.oxlintrc.json",
79
+ "web/components.json",
80
+ "web/index.html",
81
+ "web/package.json",
82
+ "web/smoke.mjs",
83
+ "web/src/App.tsx",
84
+ "web/src/index.css",
85
+ "web/src/lib/utils.ts",
86
+ "web/src/main.tsx",
87
+ "web/src/ops.json",
88
+ "web/src/schema-form.tsx",
89
+ "web/tsconfig.app.json",
90
+ "web/tsconfig.json",
91
+ "web/tsconfig.node.json",
92
+ "web/vite.config.ts",
93
+ ]);
94
+ });
95
+
96
+ it("App.tsx is a region file: the operations page is generated, the pages array and shell are the author's and survive a rebuild", () => {
97
+ const target = project();
98
+ const file = surface
99
+ .plan(target)
100
+ .find((candidate) => candidate.path === `${WEB_DIR}/src/App.tsx`);
101
+ if (file?.kind !== "region") throw new Error("web/src/App.tsx is no longer a region file");
102
+ expect(file.regions).toHaveLength(1);
103
+ expect(file.regions[0].begin).toBe(APP_BEGIN);
104
+ expect(file.regions[0].end).toBe(APP_END);
105
+ expect(file.regions[0].content).toContain("export function OperationsPage()");
106
+ expect(file.template).toContain(
107
+ "export const pages: { id: string; title: string; element: ReactNode }[]",
108
+ );
109
+ expect(file.template).toContain(
110
+ '{ id: "operations", title: "Operations", element: <OperationsPage /> }',
111
+ );
112
+ expect(file.template).toContain("export default function App()");
113
+
114
+ // Round-trip through the real writer: scaffold, let the author add a page below the
115
+ // marker (as vutoolkit adds its d3 degree-planner graph), then run a second `toolfactory
116
+ // build` (a changed operation set, same as a real `introspect` picking up new tools) and
117
+ // check the added page is still there.
118
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-web-app-"));
119
+ apply(root, surface.plan(target), "0.1.0");
120
+ const appPath = join(root, WEB_DIR, "src/App.tsx");
121
+ const authored = readFileSync(appPath, "utf8").replace(
122
+ '{ id: "operations", title: "Operations", element: <OperationsPage /> },',
123
+ '{ id: "operations", title: "Operations", element: <OperationsPage /> },\n { id: "planner", title: "Degree planner", element: <div>planner</div> },',
124
+ );
125
+ writeFileSync(appPath, authored);
126
+
127
+ const rebuilt = project({ operations: [echo] });
128
+ apply(root, surface.plan(rebuilt), "0.1.0");
129
+ const after = readFileSync(appPath, "utf8");
130
+ expect(after).toContain(
131
+ '{ id: "planner", title: "Degree planner", element: <div>planner</div> },',
132
+ );
133
+ expect(after).toContain("export function OperationsPage()");
134
+ });
135
+
136
+ it("web/package.json is a merge file, so an author-added dependency (e.g. `npm --prefix web install d3` for a page) survives a rebuild", () => {
137
+ const target = project();
138
+ const file = surface
139
+ .plan(target)
140
+ .find((candidate) => candidate.path === `${WEB_DIR}/package.json`);
141
+ if (file?.kind !== "merge") throw new Error("web/package.json is no longer a merge file");
142
+
143
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-web-pkg-"));
144
+ apply(root, surface.plan(target), "0.1.0");
145
+ const pkgPath = join(root, WEB_DIR, "package.json");
146
+ const authored = JSON.parse(readFileSync(pkgPath, "utf8"));
147
+ authored.dependencies.d3 = "^7.9.0";
148
+ writeFileSync(pkgPath, json(authored));
149
+
150
+ apply(root, surface.plan(project({ operations: [echo] })), "0.1.0");
151
+ const after = JSON.parse(readFileSync(pkgPath, "utf8"));
152
+ expect(after.dependencies.d3).toBe("^7.9.0");
153
+ expect(after.dependencies.react).toBe(WEB_SCAFFOLD.packageJson.dependencies.react);
154
+ });
155
+
156
+ it("snapshots the operations this surface carries and says why the others are missing", () => {
157
+ const data = opsJson();
158
+ expect(data.tool).toEqual({ name: "hello", version: "0.1.0", description: "Say hello" });
159
+ expect(data.mcpProtocolVersion).toBe("2026-07-28");
160
+ // Relative to the document: one build serves under `/`, under a plugin prefix, and on Pages.
161
+ expect(data.defaultEndpoint).toBe("mcp");
162
+
163
+ const operations = data.operations as {
164
+ name: string;
165
+ inputSchema: unknown;
166
+ annotations?: unknown;
167
+ }[];
168
+ expect(operations.map((operation) => operation.name)).toEqual(["echo"]);
169
+ expect(operations[0]?.inputSchema).toEqual(echo.inputSchema);
170
+ // `smoke.mjs` runs the first read-only operation for real, so the hints have to travel.
171
+ expect(operations[0]?.annotations).toEqual({ readOnlyHint: true });
172
+ expect(data.excluded).toEqual([{ name: "shoot", reason: "excluded:mcp-no-host-capabilities" }]);
173
+ expect(surface.verdict?.(shoot, project())).toEqual({
174
+ kind: "excluded",
175
+ reason: "excluded:mcp-no-host-capabilities",
176
+ });
177
+ });
178
+
179
+ it("src/ops.json is an output file: a lossy duplicate rebuilt by `npm run build`, not tracked (D8)", () => {
180
+ const file = surface
181
+ .plan(project())
182
+ .find((candidate) => candidate.path === `${WEB_DIR}/src/ops.json`);
183
+ if (file?.kind !== "file") throw new Error("web/src/ops.json is no longer a plain file");
184
+ expect(file.output).toBe(true);
185
+ });
186
+
187
+ it("the page and the smoke both talk to the kernel that served them: relative endpoint, bearer from the fragment, one listener", () => {
188
+ const files = planned();
189
+ const app = files.get(`${WEB_DIR}/src/App.tsx`) ?? "";
190
+ expect(app).toContain("new URL(DATA.defaultEndpoint, document.baseURI).href");
191
+ expect(app).toContain("window.location.hash");
192
+ expect(app).toContain("Authorization: `Bearer ${TOKEN}`");
193
+ // The Secrets panel is the developer's `.env`, and it is the kernel's `/env` that answers.
194
+ expect(app).toContain('new URL("env", document.baseURI).href');
195
+ expect(app).toContain("toolfactory secrets check");
196
+ expect(app).toContain('type="password"');
197
+
198
+ // The smoke drives the real kernel over --http instead of a static server of its own.
199
+ const smoke = files.get(`${WEB_DIR}/smoke.mjs`) ?? "";
200
+ expect(smoke).toContain('const KERNEL = ["node","--import","tsx","src/toolfactory/mcp.ts"]');
201
+ expect(smoke).toContain('"--http", "0"');
202
+ expect(smoke).toContain("HELLO_MCP_TOKEN");
203
+ expect(smoke).toContain("candidate.annotations?.readOnlyHint");
204
+ expect(smoke).not.toContain("createServer");
205
+ });
206
+
207
+ it("builds with a relative base, so one build is served the same at /, under a plugin prefix and on Pages", () => {
208
+ const content = planned().get(`${WEB_DIR}/vite.config.ts`) ?? "";
209
+ expect(content).toContain('base: process.env.PAGES_BASE ?? "./"');
210
+ });
211
+
212
+ it("previews the CLI only when the cli surface is also selected", () => {
213
+ expect(opsJson().cliAvailable).toBe(true);
214
+ const without = project({ tool: { ...project().tool, surfaces: ["web", "mcp"] } });
215
+ expect(opsJson(without).cliAvailable).toBe(false);
216
+ });
217
+
218
+ it("emits the pinned scaffold verbatim, so the drift check speaks for plan()", () => {
219
+ const files = planned();
220
+ expect(files.get("web/src/index.css")).toBe(WEB_SCAFFOLD.indexCss);
221
+ expect(files.get("web/src/lib/utils.ts")).toBe(WEB_SCAFFOLD.utils);
222
+ for (const [path, content] of Object.entries(WEB_SCAFFOLD.vite)) {
223
+ // The two tsconfigs gain the `@/*` alias; the rest travel unchanged.
224
+ const emitted = files.get(`web/${path}`) ?? "";
225
+ if (path === "tsconfig.json" || path === "tsconfig.app.json") {
226
+ expect(emitted).toContain('"@/*": ["./src/*"]');
227
+ } else if (path !== "vite.config.ts") {
228
+ expect(emitted).toBe(content);
229
+ }
230
+ }
231
+ expect(JSON.parse(files.get("web/components.json") ?? "")).toEqual(WEB_SCAFFOLD.componentsJson);
232
+
233
+ const packageJson = JSON.parse(files.get("web/package.json") ?? "");
234
+ expect(packageJson.dependencies).toEqual(WEB_SCAFFOLD.packageJson.dependencies);
235
+ expect(packageJson.devDependencies).toMatchObject(WEB_SCAFFOLD.packageJson.devDependencies);
236
+ expect(packageJson.devDependencies.playwright).toBe(WEB_SCAFFOLD.playwright);
237
+
238
+ // Every component the page imports is one `validate()` asks the shadcn CLI for.
239
+ const sources = `${files.get("web/src/App.tsx")}${files.get("web/src/schema-form.tsx")}`;
240
+ const imported = [...sources.matchAll(/@\/components\/ui\/([a-z-]+)/g)].map(
241
+ (match) => match[1],
242
+ );
243
+ expect(imported.length).toBeGreaterThan(0);
244
+ expect(WEB_SCAFFOLD.components).toEqual(expect.arrayContaining(imported));
245
+ });
246
+ });
247
+
248
+ /**
249
+ * The whole validator chain, run the way `toolfactory validate --surface web` runs it. Needs
250
+ * npm (every step installs from the registry) and a Playwright browser download for the smoke,
251
+ * so it self-skips where neither the author nor CI has one.
252
+ */
253
+ function browserAvailable(): boolean {
254
+ const browsers = process.env.PLAYWRIGHT_BROWSERS_PATH ?? join(homedir(), ".cache/ms-playwright");
255
+ return existsSync(browsers) && spawnSync("npm", ["--version"]).status === 0;
256
+ }
257
+
258
+ describe.skipIf(!browserAvailable())("validated the way an author runs it", () => {
259
+ it("installs, adds the components, proves the scaffold, builds and smokes the page", () => {
260
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-web-"));
261
+ const fixture = project({ root });
262
+ for (const [path, content] of planned(fixture)) {
263
+ mkdirSync(dirname(join(root, path)), { recursive: true });
264
+ writeFileSync(join(root, path), content);
265
+ }
266
+ // What `loadProject` needs, so the drift script can re-plan from the same project.
267
+ mkdirSync(join(root, "dev.toolfactory"), { recursive: true });
268
+ writeFileSync(join(root, "dev.toolfactory/tool.json"), json(fixture.tool));
269
+ writeFileSync(
270
+ join(root, "dev.toolfactory/ops.json"),
271
+ json({
272
+ tools: [echo, shoot].map(({ requires, ...tool }) => ({
273
+ ...tool,
274
+ _meta: { "dev.toolfactory": { requires } },
275
+ })),
276
+ }),
277
+ );
278
+ // The smoke drives the real kernel, so the fixture is a real project: the binding's kernel
279
+ // and scaffold, and this checkout's `node_modules` (the SDK, zod and tsx the kernel imports).
280
+ for (const file of [...kernel(fixture), ...scaffold(fixture)]) {
281
+ if (file.kind !== "file") continue;
282
+ mkdirSync(dirname(join(root, file.path)), { recursive: true });
283
+ writeFileSync(join(root, file.path), file.content);
284
+ }
285
+ symlinkSync(join(process.cwd(), "node_modules"), join(root, "node_modules"));
286
+
287
+ for (const command of surface.validate?.(fixture) ?? []) {
288
+ // Vitest runs this file from source, so the drift entry is still TypeScript: give node
289
+ // the loader from this checkout, where `tsx` is installed.
290
+ const outcome = run(
291
+ command.args.includes(DRIFT_ENTRY)
292
+ ? { ...command, args: ["--import", "tsx", DRIFT_ENTRY, root], cwd: process.cwd() }
293
+ : command,
294
+ );
295
+ expect(outcome.ok, `${outcome.label}: ${outcome.output}`).toBe(true);
296
+ if (outcome.label.includes("smoke")) {
297
+ expect(outcome.output).toContain("PASS cli preview");
298
+ // The whole point of driving the kernel: the page's own call came back from it.
299
+ expect(outcome.output).toContain('PASS echo round-tripped: {\n "text": "smoke"\n}');
300
+ }
301
+ }
302
+ }, 900_000);
303
+ });