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,110 @@
1
+ /**
2
+ * The I/O half of the OpenClaw surface: §8 C2, "mirror scaffolds by execution, not
3
+ * transcription". Runs the real `openclaw plugins init --type tool` into a temp directory and
4
+ * checks that everything it writes still agrees with what `plan()` emits, so an upstream default
5
+ * change is inherited on the next toolfactory release instead of silently rotting in a constant.
6
+ *
7
+ * Runnable as a script (`node dist/hosts/openclaw.js <root>`) because that is how the surface's
8
+ * `validate()` step reaches it: one Command like every other validator.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { argv, exit, stderr, stdout } from "node:process";
15
+ import { fileURLToPath } from "node:url";
16
+ import type { PlannedFile, Project } from "../model.js";
17
+ import { loadProject } from "../project/load.js";
18
+ import { HOST_DIR, surface } from "../surfaces/openclaw-native.js";
19
+
20
+ export const DRIFT_ENTRY = fileURLToPath(import.meta.url);
21
+
22
+ /** Per-tool values: the scaffold writes its own, and the projection is expected to differ. */
23
+ const PER_TOOL_KEYS = new Set(["name", "version", "description"]);
24
+
25
+ /**
26
+ * Keys `tool.json` deliberately overrides (`openclaw.pluginApi` and the extra dependency maps):
27
+ * the scaffold's value is not law where the author has replaced it on purpose.
28
+ */
29
+ function overridden(project: Project): Set<string> {
30
+ const openclaw = project.tool.openclaw;
31
+ const paths = new Set<string>();
32
+ for (const section of ["dependencies", "peerDependencies", "devDependencies"] as const) {
33
+ for (const key of Object.keys(openclaw?.[section] ?? {})) paths.add(`.${section}.${key}`);
34
+ }
35
+ if (openclaw?.pluginApi) {
36
+ paths.add(".peerDependencies.openclaw");
37
+ paths.add(".openclaw.compat.pluginApi");
38
+ }
39
+ return paths;
40
+ }
41
+
42
+ /**
43
+ * Every scaffold key must survive into the projection with the same value; extra keys are the
44
+ * generator's own additions (the core dependency, the projected tools, the inspector fixture)
45
+ * and are allowed, as are the keys `tool.json` overrides on purpose.
46
+ */
47
+ function missing(scaffold: unknown, ours: unknown, path: string, skip: Set<string>): string[] {
48
+ if (skip.has(path)) return [];
49
+ if (scaffold === null || typeof scaffold !== "object" || Array.isArray(scaffold)) {
50
+ return JSON.stringify(scaffold) === JSON.stringify(ours)
51
+ ? []
52
+ : [`${path}: upstream ${JSON.stringify(scaffold)}, generated ${JSON.stringify(ours)}`];
53
+ }
54
+ if (ours === null || typeof ours !== "object" || Array.isArray(ours)) {
55
+ return [`${path}: upstream object, generated ${JSON.stringify(ours)}`];
56
+ }
57
+ const right = ours as Record<string, unknown>;
58
+ return Object.entries(scaffold as Record<string, unknown>).flatMap(([key, value]) =>
59
+ path === "" && PER_TOOL_KEYS.has(key) ? [] : missing(value, right[key], `${path}.${key}`, skip),
60
+ );
61
+ }
62
+
63
+ function planned(files: PlannedFile[], path: string): string {
64
+ const file = files.find((candidate) => candidate.path === `${HOST_DIR}/${path}`);
65
+ if (file?.kind !== "file") throw new Error(`the openclaw surface no longer plans ${path}`);
66
+ return file.content;
67
+ }
68
+
69
+ /** Human-readable drift lines; empty means the generator still mirrors upstream. */
70
+ export function scaffoldDrift(project: Project): string[] {
71
+ const files = surface.plan(project);
72
+ const directory = mkdtempSync(join(tmpdir(), "toolfactory-openclaw-"));
73
+ // Prefer the openclaw the plugin pins as a devDependency over whatever is on PATH.
74
+ const pinned = join(project.root, HOST_DIR, "node_modules/.bin/openclaw");
75
+ try {
76
+ execFileSync(
77
+ existsSync(pinned) ? pinned : "openclaw",
78
+ ["plugins", "init", "tf-probe", "--type", "tool", "--directory", join(directory, "probe")],
79
+ { stdio: "pipe" },
80
+ );
81
+ const scaffold = (path: string) => readFileSync(join(directory, "probe", path), "utf8");
82
+ const skip = overridden(project);
83
+ const drift = [
84
+ ...["package.json", "tsconfig.json"].flatMap((path) =>
85
+ missing(JSON.parse(scaffold(path)), JSON.parse(planned(files, path)), "", skip).map(
86
+ (line) => `${path}${line}`,
87
+ ),
88
+ ),
89
+ ...(scaffold("vitest.config.ts") === planned(files, "vitest.config.ts")
90
+ ? []
91
+ : ["vitest.config.ts: upstream template changed"]),
92
+ ];
93
+ return drift;
94
+ } finally {
95
+ rmSync(directory, { recursive: true, force: true });
96
+ }
97
+ }
98
+
99
+ if (argv[1] === DRIFT_ENTRY) {
100
+ const drift = scaffoldDrift(loadProject(argv[2] ?? "."));
101
+ if (drift.length) {
102
+ stderr.write(
103
+ `openclaw scaffold drifted upstream; update src/surfaces/openclaw-native.ts:\n${drift
104
+ .map((line) => ` ${line}`)
105
+ .join("\n")}\n`,
106
+ );
107
+ exit(1);
108
+ }
109
+ stdout.write("openclaw plugins init still matches src/surfaces/openclaw-native.ts\n");
110
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The I/O half of the `agents` surface: §8 C2, "mirror scaffolds by execution, not
3
+ * transcription", applied to a repository instead of a generator. The agent-config machinery
4
+ * every generated project carries — `.agents/sync.py`, the hook carriers, the agent-config
5
+ * workflow — is one shared contract owned by github.com/GoatInAHat/template, and a copy of a
6
+ * moving contract rots silently. So the bytes are vendored into `src/surfaces/agents.template.ts`
7
+ * by this module's `--write` mode, and the copy is proved by cloning the real repository and
8
+ * diffing it file by file.
9
+ *
10
+ * Runnable as a script (`node dist/hosts/template.js [<url or checkout>] [--write]`) because that
11
+ * is how the surface's `validate()` step reaches it: one Command like every other validator.
12
+ */
13
+ import { execFileSync } from "node:child_process";
14
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { argv, exit, stderr, stdout } from "node:process";
18
+ import { fileURLToPath } from "node:url";
19
+ import { TEMPLATE_COMMIT, TEMPLATE_FILES } from "../surfaces/agents.template.js";
20
+
21
+ export const DRIFT_ENTRY = fileURLToPath(import.meta.url);
22
+ export const TEMPLATE_URL = "https://github.com/GoatInAHat/template";
23
+
24
+ /**
25
+ * The template files toolfactory projects into every generated repository. The two it does not
26
+ * vendor are the two it writes itself: `.agents/mcp/servers.json` (a merge file, so a teammate's
27
+ * absorbed server survives a rebuild) and `AGENTS.md` (a region file with the tool's own prose).
28
+ */
29
+ export const VENDORED_PATHS = [
30
+ ".agents/README.md",
31
+ ".agents/setup",
32
+ ".agents/skills/.gitkeep",
33
+ ".agents/sync.py",
34
+ ".claude/settings.json",
35
+ ".cursor/environment.json",
36
+ ".devcontainer/Dockerfile",
37
+ ".devcontainer/devcontainer.json",
38
+ ".gitattributes",
39
+ ".github/workflows/agent-config.yml",
40
+ ".github/workflows/copilot-setup-steps.yml",
41
+ ".gitignore",
42
+ ];
43
+
44
+ /** Where `--write` puts the vendored bytes; a source-tree path, since re-vendoring is a dev step. */
45
+ const VENDORED_MODULE = join(dirname(DRIFT_ENTRY), "..", "surfaces", "agents.template.ts");
46
+
47
+ function head(root: string): string {
48
+ return execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
49
+ }
50
+
51
+ /** A local checkout is used where it is; anything else is cloned shallow into a temp directory. */
52
+ function checkout(source: string): { root: string; commit: string; dispose: () => void } {
53
+ const local = (() => {
54
+ try {
55
+ return statSync(source).isDirectory();
56
+ } catch {
57
+ return false;
58
+ }
59
+ })();
60
+ if (local) return { root: source, commit: head(source), dispose: () => {} };
61
+ const directory = mkdtempSync(join(tmpdir(), "toolfactory-template-"));
62
+ execFileSync("git", ["clone", "--depth", "1", source, directory], { stdio: "pipe" });
63
+ return {
64
+ root: directory,
65
+ commit: head(directory),
66
+ dispose: () => rmSync(directory, { recursive: true, force: true }),
67
+ };
68
+ }
69
+
70
+ function read(root: string, path: string): string | undefined {
71
+ try {
72
+ return readFileSync(join(root, path), "utf8");
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ /** Human-readable drift lines; empty means the vendored copy is still the template's own bytes. */
79
+ export function scaffoldDrift(source: string = TEMPLATE_URL): string[] {
80
+ const at = checkout(source);
81
+ try {
82
+ return VENDORED_PATHS.flatMap((path) => {
83
+ const upstream = read(at.root, path);
84
+ if (upstream === undefined) return [`${path}: no longer in the template`];
85
+ if (TEMPLATE_FILES[path] === undefined) return [`${path}: not vendored`];
86
+ return upstream === TEMPLATE_FILES[path] ? [] : [`${path}: the template moved`];
87
+ });
88
+ } finally {
89
+ at.dispose();
90
+ }
91
+ }
92
+
93
+ function moduleSource(files: Record<string, string>, commit: string): string {
94
+ return [
95
+ "/**",
96
+ ` * Vendored from ${TEMPLATE_URL} at ${commit}.`,
97
+ " *",
98
+ " * Do not edit: written by `pnpm vendor:template`, proved by `src/hosts/template.ts`'s",
99
+ " * scaffoldDrift, which the `agents` surface's validate() runs against the real repository.",
100
+ " */",
101
+ `export const TEMPLATE_COMMIT = ${JSON.stringify(commit)};`,
102
+ "",
103
+ "export const TEMPLATE_FILES: Record<string, string> = {",
104
+ ...VENDORED_PATHS.map((path) => ` ${JSON.stringify(path)}: ${JSON.stringify(files[path])},`),
105
+ "};",
106
+ "",
107
+ ].join("\n");
108
+ }
109
+
110
+ /** Re-vendor: read every projected file out of a checkout and rewrite the constants module. */
111
+ export function vendor(source: string = TEMPLATE_URL): { commit: string; path: string } {
112
+ const at = checkout(source);
113
+ try {
114
+ const files = Object.fromEntries(
115
+ VENDORED_PATHS.map((path) => {
116
+ const content = read(at.root, path);
117
+ if (content === undefined) throw new Error(`${path} is not in ${source}`);
118
+ return [path, content];
119
+ }),
120
+ );
121
+ writeFileSync(VENDORED_MODULE, moduleSource(files, at.commit));
122
+ return { commit: at.commit, path: VENDORED_MODULE };
123
+ } finally {
124
+ at.dispose();
125
+ }
126
+ }
127
+
128
+ if (argv[1] === DRIFT_ENTRY) {
129
+ const args = argv.slice(2);
130
+ const write = args.includes("--write");
131
+ const source = args.find((argument) => !argument.startsWith("--")) ?? TEMPLATE_URL;
132
+ if (write) {
133
+ const { commit, path } = vendor(source);
134
+ stdout.write(`vendored ${VENDORED_PATHS.length} template files at ${commit} into ${path}\n`);
135
+ } else {
136
+ const drift = scaffoldDrift(source);
137
+ if (drift.length) {
138
+ stderr.write(
139
+ `the agent-config template drifted; re-run \`pnpm vendor:template\`:\n${drift
140
+ .map((line) => ` ${line}`)
141
+ .join("\n")}\n`,
142
+ );
143
+ exit(1);
144
+ }
145
+ stdout.write(
146
+ `the template still matches src/surfaces/agents.template.ts (vendored at ${TEMPLATE_COMMIT})\n`,
147
+ );
148
+ }
149
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The I/O half of the web surface: §8 C2, "mirror scaffolds by execution, not transcription".
3
+ * Runs the real generators — `npm create vite`, the Tailwind and alias steps from
4
+ * ui.shadcn.com/docs/installation/vite, `shadcn init` and `shadcn add` — into a temp directory,
5
+ * seeding it with the very files `plan()` emits, then checks that everything they write still
6
+ * agrees with WEB_SCAFFOLD, so an upstream default change is inherited on the next toolfactory
7
+ * release instead of silently rotting in a constant.
8
+ *
9
+ * Runnable as a script (`node dist/hosts/web.js <root>`) because that is how the surface's
10
+ * `validate()` step reaches it: one Command like every other validator.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { argv, exit, stderr, stdout } from "node:process";
17
+ import { fileURLToPath } from "node:url";
18
+ import type { PlannedFile, Project } from "../model.js";
19
+ import { loadProject } from "../project/load.js";
20
+ import { surface, TAILWIND_CSS, WEB_DIR, WEB_SCAFFOLD } from "../surfaces/web.js";
21
+
22
+ export const DRIFT_ENTRY = fileURLToPath(import.meta.url);
23
+
24
+ /** The scaffold's own config, which the generators read before writing anything else. */
25
+ const SEEDED = ["vite.config.ts", "tsconfig.json", "tsconfig.app.json"];
26
+
27
+ function planned(files: PlannedFile[], path: string): string {
28
+ const file = files.find((candidate) => candidate.path === `${WEB_DIR}/${path}`);
29
+ if (file?.kind !== "file") throw new Error(`the web surface no longer plans ${path}`);
30
+ return file.content;
31
+ }
32
+
33
+ /** JSON with the comments a tsconfig is allowed to carry. */
34
+ function parse(text: string): unknown {
35
+ return JSON.parse(text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""));
36
+ }
37
+
38
+ function differs(label: string, upstream: unknown, pinned: unknown): string[] {
39
+ return JSON.stringify(upstream) === JSON.stringify(pinned)
40
+ ? []
41
+ : [`${label}: upstream and WEB_SCAFFOLD no longer agree`];
42
+ }
43
+
44
+ /** Human-readable drift lines; empty means the generators still write what WEB_SCAFFOLD says. */
45
+ export function scaffoldDrift(project: Project): string[] {
46
+ const files = surface.plan(project);
47
+ const directory = mkdtempSync(join(tmpdir(), "toolfactory-web-"));
48
+ const probe = join(directory, WEB_DIR);
49
+ // Prefer the shadcn the page pins as a dependency over whatever is on PATH.
50
+ const pinned = join(project.root, WEB_DIR, "node_modules/.bin/shadcn");
51
+ const shadcn = (args: string[]) =>
52
+ existsSync(pinned)
53
+ ? execFileSync(pinned, [...args, "--cwd", probe], { stdio: "pipe" })
54
+ : execFileSync("npx", ["--yes", "shadcn@latest", ...args, "--cwd", probe], { stdio: "pipe" });
55
+ try {
56
+ execFileSync(
57
+ "npm",
58
+ ["create", "vite@latest", WEB_DIR, "--", "--template", WEB_SCAFFOLD.viteTemplate],
59
+ { cwd: directory, stdio: "pipe" },
60
+ );
61
+ const scaffold = (path: string) => readFileSync(join(probe, path), "utf8");
62
+ const drift = Object.keys(WEB_SCAFFOLD.vite).flatMap((path) =>
63
+ scaffold(path) === WEB_SCAFFOLD.vite[path as keyof typeof WEB_SCAFFOLD.vite]
64
+ ? []
65
+ : [`${path}: \`npm create vite\` no longer writes what WEB_SCAFFOLD.vite pins`],
66
+ );
67
+
68
+ // The documentation's Tailwind and alias steps, as the generated project carries them.
69
+ writeFileSync(join(probe, "src/index.css"), TAILWIND_CSS);
70
+ for (const path of SEEDED) writeFileSync(join(probe, path), planned(files, path));
71
+ execFileSync("npm", ["--prefix", probe, "install", "tailwindcss", "@tailwindcss/vite"], {
72
+ stdio: "pipe",
73
+ });
74
+ shadcn(["init", "--yes", "--base", WEB_SCAFFOLD.base, "--preset", WEB_SCAFFOLD.preset]);
75
+ shadcn(["add", "--yes", ...WEB_SCAFFOLD.components]);
76
+
77
+ const packageJson = JSON.parse(scaffold("package.json")) as Record<string, unknown>;
78
+ return [
79
+ ...drift,
80
+ ...(["scripts", "dependencies", "devDependencies"] as const).flatMap((key) =>
81
+ differs(`package.json ${key}`, packageJson[key], WEB_SCAFFOLD.packageJson[key]),
82
+ ),
83
+ ...differs(
84
+ "components.json",
85
+ parse(scaffold("components.json")),
86
+ WEB_SCAFFOLD.componentsJson,
87
+ ),
88
+ ...(scaffold("src/index.css") === WEB_SCAFFOLD.indexCss
89
+ ? []
90
+ : ["src/index.css: `shadcn init` no longer writes what WEB_SCAFFOLD.indexCss pins"]),
91
+ ...(scaffold("src/lib/utils.ts") === WEB_SCAFFOLD.utils
92
+ ? []
93
+ : ["src/lib/utils.ts: `shadcn init` no longer writes what WEB_SCAFFOLD.utils pins"]),
94
+ ];
95
+ } finally {
96
+ rmSync(directory, { recursive: true, force: true });
97
+ }
98
+ }
99
+
100
+ if (argv[1] === DRIFT_ENTRY) {
101
+ const drift = scaffoldDrift(loadProject(argv[2] ?? "."));
102
+ if (drift.length) {
103
+ stderr.write(
104
+ `web scaffold drifted upstream; update WEB_SCAFFOLD in src/surfaces/web.ts:\n${drift
105
+ .map((line) => ` ${line}`)
106
+ .join("\n")}\n`,
107
+ );
108
+ exit(1);
109
+ }
110
+ stdout.write("the vite + shadcn scaffold still matches src/surfaces/web.ts\n");
111
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { assertValidName, githubOwner, projectName } from "./name.js";
3
+
4
+ describe("projectName", () => {
5
+ it("projects the canonical name N onto every wire-visible identifier", () => {
6
+ expect(projectName.npm("hello")).toBe("hello");
7
+ expect(projectName.npm("hello", "acme")).toBe("@acme/hello");
8
+ expect(projectName.npm("hello", "@acme")).toBe("@acme/hello");
9
+ expect(projectName.pypi("hello.tool")).toBe("hello-tool");
10
+ expect(projectName.pythonPackage("hello.tool-kit")).toBe("hello_tool_kit");
11
+ expect(projectName.cursor("hello.tool")).toBe("hello-tool");
12
+ expect(projectName.openclawPackage("hello")).toBe("openclaw-plugin-hello");
13
+ expect(projectName.mcpRegistry("hello", "acme")).toBe("io.github.acme/hello");
14
+ expect(projectName.hermesToolset("hello.tool-kit")).toBe("hello_tool_kit");
15
+ });
16
+ });
17
+
18
+ describe("githubOwner", () => {
19
+ it("reads the owner out of a github repository URL", () => {
20
+ expect(githubOwner("https://github.com/acme/hello")).toBe("acme");
21
+ expect(githubOwner("git@github.com:acme/hello.git")).toBe("acme");
22
+ expect(githubOwner(undefined)).toBeUndefined();
23
+ expect(githubOwner("https://example.com/acme/hello")).toBeUndefined();
24
+ });
25
+ });
26
+
27
+ describe("assertValidName", () => {
28
+ it("accepts a lowercase, hyphen/dot-separated name", () => {
29
+ expect(() => assertValidName("hello-tool.kit")).not.toThrow();
30
+ });
31
+
32
+ it("rejects a double hyphen", () => {
33
+ expect(() => assertValidName("Bad--Name")).toThrow(/Invalid tool name/);
34
+ });
35
+
36
+ it("rejects a leading hyphen", () => {
37
+ expect(() => assertValidName("-abc")).toThrow(/Invalid tool name/);
38
+ });
39
+
40
+ it("rejects uppercase", () => {
41
+ expect(() => assertValidName("ABC")).toThrow(/Invalid tool name/);
42
+ });
43
+ });
@@ -0,0 +1,39 @@
1
+ import { NAME_PATTERN } from "../model.js";
2
+
3
+ export function assertValidName(name: string): void {
4
+ if (!NAME_PATTERN.test(name) || name.length > 64) {
5
+ throw new Error(
6
+ `Invalid tool name ${JSON.stringify(name)}: must match Agent Plugins ${NAME_PATTERN} and be at most 64 characters.`,
7
+ );
8
+ }
9
+ }
10
+
11
+ /** The total projection of the canonical name N onto every wire-visible identifier. */
12
+ export const projectName = {
13
+ npm: (name: string, scope?: string): string =>
14
+ scope ? `@${scope.replace(/^@/, "")}/${name}` : name,
15
+ pypi: (name: string): string => name.replace(/\./g, "-"),
16
+ pythonPackage: (name: string): string => name.replace(/[.-]/g, "_").replace(/^(?=\d)/, "_"),
17
+ /** Human-facing name for manifests: words from the hyphens and dots, capitalised. */
18
+ display: (name: string): string =>
19
+ name
20
+ .split(/[-.]/)
21
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
22
+ .join(" "),
23
+ cursor: (name: string): string => name.replace(/\./g, "-"),
24
+ /** Gemini CLI extension name: the directory name rule, the same shape as Cursor's. */
25
+ gemini: (name: string): string => name.replace(/\./g, "-"),
26
+ openclawPackage: (name: string): string => `openclaw-plugin-${name}`,
27
+ mcpRegistry: (name: string, owner: string): string => `io.github.${owner}/${name}`,
28
+ hermesToolset: (name: string): string => name.replace(/[.-]/g, "_"),
29
+ dshPackage: (name: string): string => `${name}-dsh`,
30
+ /** DSH `serverName`: `[A-Za-z0-9_-]{1,32}`, validated at boot, and wire-visible as `mcp__<it>__<tool>`. */
31
+ dshServer: (name: string): string => name.replace(/\./g, "-").slice(0, 32),
32
+ };
33
+
34
+ /** `owner` from a GitHub repository URL, for the MCP Registry namespace. */
35
+ export function githubOwner(repository: string | undefined): string | undefined {
36
+ if (!repository) return undefined;
37
+ const match = repository.match(/github\.com[/:]([^/]+)\/[^/#?]+/);
38
+ return match?.[1];
39
+ }
@@ -0,0 +1,92 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { readIdentityFile } from "./read.js";
6
+
7
+ let dir: string;
8
+
9
+ function file(name: string, content: string): string {
10
+ const path = join(dir, name);
11
+ writeFileSync(path, content);
12
+ return path;
13
+ }
14
+
15
+ describe("readIdentityFile", () => {
16
+ afterEach(() => {
17
+ dir = "";
18
+ });
19
+
20
+ it("reads plugin.json, preserving unknown top-level keys as extra", () => {
21
+ dir = mkdtempSync(join(tmpdir(), "tf-identity-"));
22
+ const path = file(
23
+ "plugin.json",
24
+ JSON.stringify({
25
+ name: "hello",
26
+ version: "0.1.0",
27
+ description: "Say hello.",
28
+ author: { name: "Ada", email: "ada@example.com" },
29
+ license: "MIT",
30
+ extensions: { toolfactory: {} },
31
+ }),
32
+ );
33
+ const result = readIdentityFile(path);
34
+ expect(result.format).toBe("json");
35
+ expect(result.identity).toMatchObject({
36
+ name: "hello",
37
+ version: "0.1.0",
38
+ description: "Say hello.",
39
+ author: { name: "Ada", email: "ada@example.com" },
40
+ license: "MIT",
41
+ });
42
+ expect(result.extra).toEqual({ extensions: { toolfactory: {} } });
43
+ });
44
+
45
+ it("reads package.json, stripping the npm scope off a scoped name", () => {
46
+ dir = mkdtempSync(join(tmpdir(), "tf-identity-"));
47
+ const path = file("package.json", JSON.stringify({ name: "@acme/hello", version: "0.2.0" }));
48
+ const result = readIdentityFile(path);
49
+ expect(result.identity.name).toBe("hello");
50
+ expect(result.identity.version).toBe("0.2.0");
51
+ });
52
+
53
+ it("reads pyproject.toml", () => {
54
+ dir = mkdtempSync(join(tmpdir(), "tf-identity-"));
55
+ const path = file(
56
+ "pyproject.toml",
57
+ [
58
+ "[project]",
59
+ 'name = "hello-py"',
60
+ 'version = "0.3.0"',
61
+ 'description = "Say hello."',
62
+ 'authors = [{ name = "Ada", email = "ada@example.com" }]',
63
+ "",
64
+ "[project.urls]",
65
+ 'Repository = "https://github.com/acme/hello-py"',
66
+ "",
67
+ ].join("\n"),
68
+ );
69
+ const result = readIdentityFile(path);
70
+ expect(result.format).toBe("toml");
71
+ expect(result.identity).toMatchObject({
72
+ name: "hello-py",
73
+ version: "0.3.0",
74
+ description: "Say hello.",
75
+ author: { name: "Ada", email: "ada@example.com" },
76
+ repository: "https://github.com/acme/hello-py",
77
+ });
78
+ });
79
+
80
+ it("reads openclaw.plugin.json off the `id` field", () => {
81
+ dir = mkdtempSync(join(tmpdir(), "tf-identity-"));
82
+ const path = file(
83
+ "openclaw.plugin.json",
84
+ JSON.stringify({ id: "hello", version: "0.1.0", commands: ["run"] }),
85
+ );
86
+ const result = readIdentityFile(path);
87
+ expect(result.identity.name).toBe("hello");
88
+ expect(result.identity.version).toBe("0.1.0");
89
+ // Unknown keys other than the generic identity set round-trip as extra.
90
+ expect(result.extra).toEqual({ commands: ["run"] });
91
+ });
92
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Identity readers. These are shallow structural reads that pull the eight identity
3
+ * fields out of a file the ecosystem defines and keep every other top-level key as an
4
+ * opaque bag, so a rewrite never deletes what toolfactory does not understand.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { basename } from "node:path";
8
+ import { parse as parseToml } from "smol-toml";
9
+ import { parse as parseYaml } from "yaml";
10
+ import type { Author, Identity } from "../model.js";
11
+ import { assertValidName } from "./name.js";
12
+
13
+ export interface IdentityFile {
14
+ identity: Identity;
15
+ extra: Record<string, unknown>;
16
+ /** Object → text serializer matching the source format, used when projecting back. */
17
+ format: "json" | "toml" | "yaml";
18
+ }
19
+
20
+ const IDENTITY_KEYS = [
21
+ "name",
22
+ "version",
23
+ "description",
24
+ "author",
25
+ "homepage",
26
+ "repository",
27
+ "license",
28
+ "keywords",
29
+ ];
30
+
31
+ function asString(value: unknown): string | undefined {
32
+ return typeof value === "string" ? value : undefined;
33
+ }
34
+
35
+ function asAuthor(value: unknown): Author | undefined {
36
+ if (typeof value === "string") {
37
+ const match = value.match(/^([^<(]+?)\s*(?:<([^>]+)>)?\s*(?:\(([^)]+)\))?\s*$/);
38
+ return match ? { name: match[1]?.trim(), email: match[2], url: match[3] } : { name: value };
39
+ }
40
+ if (value && typeof value === "object") {
41
+ const record = value as Record<string, unknown>;
42
+ return {
43
+ name: asString(record.name),
44
+ email: asString(record.email),
45
+ url: asString(record.url),
46
+ };
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ function asRepository(value: unknown): string | undefined {
52
+ if (typeof value === "string") return value;
53
+ if (value && typeof value === "object") return asString((value as Record<string, unknown>).url);
54
+ return undefined;
55
+ }
56
+
57
+ function split(record: Record<string, unknown>, keys: string[]): Record<string, unknown> {
58
+ return Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key)));
59
+ }
60
+
61
+ function fromGeneric(record: Record<string, unknown>, nameKey = "name"): IdentityFile {
62
+ const rawName = asString(record[nameKey]) ?? "";
63
+ const name = rawName.replace(/^@[^/]+\//, "");
64
+ assertValidName(name);
65
+ const identity: Identity = {
66
+ name,
67
+ version: asString(record.version),
68
+ description: asString(record.description),
69
+ author: asAuthor(record.author),
70
+ homepage: asString(record.homepage),
71
+ repository: asRepository(record.repository),
72
+ license: asString(record.license),
73
+ keywords: Array.isArray(record.keywords)
74
+ ? record.keywords.filter((k): k is string => typeof k === "string")
75
+ : undefined,
76
+ };
77
+ const owned = [...IDENTITY_KEYS.filter((key) => key !== "name"), nameKey];
78
+ return { identity, extra: split(record, owned), format: "json" };
79
+ }
80
+
81
+ function fromPyproject(text: string): IdentityFile {
82
+ const document = parseToml(text) as Record<string, unknown>;
83
+ const project = (document.project ?? {}) as Record<string, unknown>;
84
+ const urls = (project.urls ?? {}) as Record<string, unknown>;
85
+ const authors = Array.isArray(project.authors)
86
+ ? (project.authors as Record<string, unknown>[])
87
+ : [];
88
+ const name = asString(project.name) ?? "";
89
+ assertValidName(name);
90
+ const license = project.license;
91
+ const identity: Identity = {
92
+ name,
93
+ version: asString(project.version),
94
+ description: asString(project.description),
95
+ author: authors[0] ? asAuthor(authors[0]) : undefined,
96
+ homepage: asString(urls.Homepage ?? urls.homepage),
97
+ repository: asString(urls.Repository ?? urls.repository ?? urls.Source),
98
+ license:
99
+ typeof license === "string"
100
+ ? license
101
+ : asString((license as Record<string, unknown> | undefined)?.text),
102
+ keywords: Array.isArray(project.keywords) ? (project.keywords as string[]) : undefined,
103
+ };
104
+ return { identity, extra: document, format: "toml" };
105
+ }
106
+
107
+ /** Read the identity file named by `tool.json.identity`. */
108
+ export function readIdentityFile(path: string): IdentityFile {
109
+ const text = readFileSync(path, "utf8");
110
+ const file = basename(path);
111
+ if (file === "pyproject.toml") return fromPyproject(text);
112
+ if (file === "plugin.yaml" || file === "plugin.yml") {
113
+ const document = (parseYaml(text) ?? {}) as Record<string, unknown>;
114
+ return { ...fromGeneric(document), format: "yaml" };
115
+ }
116
+ const document = JSON.parse(text) as Record<string, unknown>;
117
+ if (file === "openclaw.plugin.json") return fromGeneric(document, "id");
118
+ return fromGeneric(document);
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export * as commands from "./commands.js";
2
+ export * from "./model.js";
3
+ export { loadProject, TOOLFACTORY_VERSION } from "./project/load.js";
4
+ export { buildPlan } from "./project/plan.js";
5
+ export { computeCoverage } from "./report/coverage.js";
6
+ export {
7
+ assertSurfaceRequirements,
8
+ getSurface,
9
+ registerSurface,
10
+ selectedSurfaces,
11
+ } from "./surfaces/registry.js";