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,360 @@
1
+ /**
2
+ * The one writer and the one differ. A plan is a list of files; applying it writes
3
+ * generated files, keeps `manual` (adopted) files untouched, refreshes managed regions
4
+ * inside author-owned files, merges owned keys into shared structured files, deletes
5
+ * orphans, and records SHAs in the lock file. Every kind carries its own inverse: a region file
6
+ * its markers, a full file its path, a merge file the key paths recorded in the lock.
7
+ */
8
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
11
+ import { LOCK_PATH, readLock, serializeLock, sha256 } from "./lock.js";
12
+ function locate(text, region) {
13
+ const start = text.indexOf(region.begin);
14
+ if (start < 0)
15
+ return undefined;
16
+ const end = text.indexOf(region.end, start + region.begin.length);
17
+ if (end < 0)
18
+ return undefined;
19
+ return { start: start + region.begin.length, end };
20
+ }
21
+ export function extractRegions(text, file) {
22
+ const parts = [];
23
+ for (const region of file.regions) {
24
+ const at = locate(text, region);
25
+ if (!at)
26
+ return undefined;
27
+ parts.push(text.slice(at.start, at.end));
28
+ }
29
+ return parts;
30
+ }
31
+ export function replaceRegions(text, file) {
32
+ let next = text;
33
+ for (const region of file.regions) {
34
+ const at = locate(next, region);
35
+ if (!at)
36
+ return undefined;
37
+ next = next.slice(0, at.start) + region.content + next.slice(at.end);
38
+ }
39
+ return next;
40
+ }
41
+ /** A region file's inverse: the named regions blanked, their markers and the author's bytes kept. */
42
+ function emptyRegions(text, markers) {
43
+ return replaceRegions(text, { regions: markers.map((region) => ({ ...region, content: "" })) });
44
+ }
45
+ function isRecord(value) {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+ /** Objects merge key by key, except the paths a merge file owns whole, which are replaced. */
49
+ export function deepMerge(base, patch, owned = [], prefix = "") {
50
+ const out = { ...base };
51
+ for (const [key, value] of Object.entries(patch)) {
52
+ const path = prefix ? `${prefix}.${key}` : key;
53
+ out[key] =
54
+ isRecord(value) && isRecord(out[key]) && !owned.includes(path)
55
+ ? deepMerge(out[key], value, owned, path)
56
+ : value;
57
+ }
58
+ return out;
59
+ }
60
+ /** The values a merge file owns, read back out of a document; an owned object comes back whole. */
61
+ export function pickPatch(document, patch, owned = [], prefix = "") {
62
+ const out = {};
63
+ for (const [key, value] of Object.entries(patch)) {
64
+ const current = document[key];
65
+ const path = prefix ? `${prefix}.${key}` : key;
66
+ out[key] =
67
+ isRecord(value) && isRecord(current) && !owned.includes(path)
68
+ ? pickPatch(current, value, owned, path)
69
+ : current;
70
+ }
71
+ return out;
72
+ }
73
+ /** The dotted paths a patch writes: one per leaf value, and one per object it owns whole. */
74
+ export function patchKeys(patch, owned = [], prefix = "") {
75
+ const keys = [];
76
+ for (const [key, value] of Object.entries(patch)) {
77
+ const path = prefix ? `${prefix}.${key}` : key;
78
+ if (isRecord(value) && !owned.includes(path) && Object.keys(value).length > 0) {
79
+ keys.push(...patchKeys(value, owned, path));
80
+ }
81
+ else {
82
+ keys.push(path);
83
+ }
84
+ }
85
+ return keys;
86
+ }
87
+ function walk(document, key) {
88
+ const chain = [document];
89
+ let node = document;
90
+ for (const part of key.split(".").slice(0, -1)) {
91
+ node = node[part];
92
+ if (!isRecord(node))
93
+ return chain;
94
+ chain.push(node);
95
+ }
96
+ return chain;
97
+ }
98
+ function hasKey(document, key) {
99
+ const parts = key.split(".");
100
+ const chain = walk(document, key);
101
+ return chain.length === parts.length && parts.at(-1) in chain.at(-1);
102
+ }
103
+ /** Uninstall dotted paths from a document, pruning the objects the removal empties. */
104
+ export function removeKeys(document, keys) {
105
+ let removed = false;
106
+ for (const key of keys) {
107
+ if (!hasKey(document, key))
108
+ continue;
109
+ const parts = key.split(".");
110
+ const chain = walk(document, key);
111
+ delete chain.at(-1)[parts.at(-1)];
112
+ removed = true;
113
+ for (let depth = chain.length - 1; depth > 0; depth--) {
114
+ if (Object.keys(chain[depth]).length > 0)
115
+ break;
116
+ delete chain[depth - 1][parts[depth - 1]];
117
+ }
118
+ }
119
+ return removed;
120
+ }
121
+ function parseDocument(text, format) {
122
+ return (format === "toml" ? parseToml(text) : JSON.parse(text));
123
+ }
124
+ function serializeDocument(document, format) {
125
+ return format === "toml"
126
+ ? `${stringifyToml(document)}\n`
127
+ : `${JSON.stringify(document, null, 2)}\n`;
128
+ }
129
+ /** The content toolfactory manages, as one string for hashing and comparison. */
130
+ export function managedContent(file) {
131
+ if (file.kind === "file")
132
+ return file.content;
133
+ if (file.kind === "region")
134
+ return file.regions.map((r) => r.content).join(" ");
135
+ return JSON.stringify(file.patch);
136
+ }
137
+ /** A symbolic link's managed content is its target; every other kind's is what it holds. */
138
+ function isLink(file) {
139
+ return file.kind === "file" && file.symlink === true;
140
+ }
141
+ function currentManagedContent(root, file) {
142
+ const path = join(root, file.path);
143
+ if (isLink(file)) {
144
+ const entry = lstatSync(path, { throwIfNoEntry: false });
145
+ // A regular file or directory standing where the link belongs matches no target: it is drift.
146
+ return entry && (entry.isSymbolicLink() ? readlinkSync(path) : "");
147
+ }
148
+ if (!existsSync(path))
149
+ return undefined;
150
+ const text = readFileSync(path, "utf8");
151
+ if (file.kind === "file")
152
+ return text;
153
+ if (file.kind === "region")
154
+ return extractRegions(text, file)?.join(" ");
155
+ return JSON.stringify(pickPatch(parseDocument(text, file.format), file.patch, file.owned));
156
+ }
157
+ /** The two structured formats merge files use; the extension is the only discriminator needed. */
158
+ function documentFormat(path) {
159
+ return path.endsWith(".toml") ? "toml" : "json";
160
+ }
161
+ /** The keys the lock recorded for a path that the current patch no longer writes. */
162
+ function staleKeys(lock, path, current = []) {
163
+ return (lock.files[path]?.keys ?? []).filter((key) => !current.includes(key));
164
+ }
165
+ /** Whether any uninstalled key is still in the document — the only reason to touch a merge file. */
166
+ function stranded(root, path, keys) {
167
+ if (keys.length === 0 || !existsSync(join(root, path)))
168
+ return false;
169
+ const document = parseDocument(readFileSync(join(root, path), "utf8"), documentFormat(path));
170
+ return keys.some((key) => hasKey(document, key));
171
+ }
172
+ /** The marker pairs the lock recorded for a path that the current plan no longer writes. */
173
+ function staleRegions(lock, path, current = []) {
174
+ return (lock.files[path]?.regions ?? []).filter((region) => !current.some((planned) => planned.begin === region.begin));
175
+ }
176
+ /** Whether any uninstalled region still carries content — the only reason to touch a region file. */
177
+ function filled(root, path, markers) {
178
+ if (markers.length === 0 || !existsSync(join(root, path)))
179
+ return false;
180
+ const parts = extractRegions(readFileSync(join(root, path), "utf8"), { regions: markers });
181
+ return parts?.some((part) => part.trim() !== "") === true;
182
+ }
183
+ /** lstat, not stat: a dangling symlink (its target just deleted) is still present and still ours. */
184
+ function present(root, path) {
185
+ return lstatSync(join(root, path), { throwIfNoEntry: false }) !== undefined;
186
+ }
187
+ /** Compare a plan to the tree without writing. */
188
+ export function check(root, plan, toolfactoryVersion) {
189
+ const lock = readLock(root) ?? { toolfactoryVersion, files: {} };
190
+ const drift = [];
191
+ const planned = new Set(plan.map((file) => file.path));
192
+ for (const file of plan) {
193
+ if (lock.files[file.path]?.state === "manual")
194
+ continue;
195
+ const current = currentManagedContent(root, file);
196
+ if (current === undefined) {
197
+ // An output file is rebuilt, not tracked: absent is not drift, only stale is.
198
+ if (file.kind === "file" && file.output)
199
+ continue;
200
+ drift.push({
201
+ path: file.path,
202
+ kind: existsSync(join(root, file.path)) ? "unmarked" : "missing",
203
+ });
204
+ }
205
+ else if (current !== managedContent(file)) {
206
+ drift.push({ path: file.path, kind: "changed" });
207
+ }
208
+ else if (file.kind === "merge" &&
209
+ stranded(root, file.path, staleKeys(lock, file.path, patchKeys(file.patch, file.owned)))) {
210
+ drift.push({ path: file.path, kind: "changed" });
211
+ }
212
+ else if (file.kind === "region" &&
213
+ filled(root, file.path, staleRegions(lock, file.path, file.regions))) {
214
+ drift.push({ path: file.path, kind: "changed" });
215
+ }
216
+ }
217
+ for (const path of Object.keys(lock.files)) {
218
+ const entry = lock.files[path];
219
+ if (planned.has(path) || entry?.state !== "generated" || !present(root, path))
220
+ continue;
221
+ // A merge file toolfactory stops writing loses its keys, not its existence.
222
+ if (entry.keys && !stranded(root, path, entry.keys))
223
+ continue;
224
+ // A region file likewise loses its regions: once they are empty, nothing of ours is stranded.
225
+ if (entry.regions && !filled(root, path, entry.regions))
226
+ continue;
227
+ drift.push({ path, kind: "orphan" });
228
+ }
229
+ return drift;
230
+ }
231
+ function render(root, file, previous) {
232
+ const path = join(root, file.path);
233
+ if (file.kind === "file")
234
+ return file.content;
235
+ if (file.kind === "region") {
236
+ if (existsSync(path)) {
237
+ const text = readFileSync(path, "utf8");
238
+ // The region inverse first: a marker pair a previous plan wrote and this one dropped — a
239
+ // shared file that lost one of its owners — is emptied before the current regions are filled.
240
+ const stale = staleRegions(previous, file.path, file.regions);
241
+ const replaced = replaceRegions(emptyRegions(text, stale) ?? text, file);
242
+ if (replaced === undefined) {
243
+ throw new Error(`${file.path} exists but is missing a toolfactory region marker; restore the markers or run \`toolfactory adopt ${file.path}\`.`);
244
+ }
245
+ return replaced;
246
+ }
247
+ return replaceRegions(file.template, file) ?? file.template;
248
+ }
249
+ if (!existsSync(path))
250
+ return serializeDocument(deepMerge({}, file.patch), file.format);
251
+ const text = readFileSync(path, "utf8");
252
+ const document = parseDocument(text, file.format);
253
+ // The patch's inverse first: keys a previous patch wrote and this one dropped are uninstalled.
254
+ const uninstalled = removeKeys(document, staleKeys(previous, file.path, patchKeys(file.patch, file.owned)));
255
+ // A document that already carries the patch is left byte-for-byte alone, so a rebuild never
256
+ // reserializes the author's file (and, for TOML, never drops their comments).
257
+ if (!uninstalled &&
258
+ JSON.stringify(pickPatch(document, file.patch, file.owned)) === JSON.stringify(file.patch)) {
259
+ return text;
260
+ }
261
+ return serializeDocument(deepMerge(document, file.patch, file.owned), file.format);
262
+ }
263
+ /** Write a plan to the tree and refresh the lock. */
264
+ export function apply(root, plan, toolfactoryVersion) {
265
+ const previous = readLock(root) ?? { toolfactoryVersion, files: {} };
266
+ const lock = { toolfactoryVersion, files: {} };
267
+ const result = {
268
+ written: [],
269
+ deleted: [],
270
+ unchanged: [],
271
+ manual: [],
272
+ stripped: [],
273
+ };
274
+ for (const file of plan) {
275
+ const state = previous.files[file.path]?.state ?? "generated";
276
+ const path = join(root, file.path);
277
+ if (state === "manual") {
278
+ lock.files[file.path] = { sha256: previous.files[file.path]?.sha256 ?? "", state };
279
+ result.manual.push(file.path);
280
+ continue;
281
+ }
282
+ const keys = file.kind === "merge" ? patchKeys(file.patch, file.owned) : undefined;
283
+ const regions = file.kind === "region" ? file.regions.map(({ begin, end }) => ({ begin, end })) : undefined;
284
+ if (isLink(file)) {
285
+ if (currentManagedContent(root, file) === managedContent(file)) {
286
+ result.unchanged.push(file.path);
287
+ }
288
+ else {
289
+ mkdirSync(dirname(path), { recursive: true });
290
+ // Whatever stands there — a stale link, or a real file or directory — makes way for the link.
291
+ rmSync(path, { recursive: true, force: true });
292
+ symlinkSync(managedContent(file), path);
293
+ result.written.push(file.path);
294
+ }
295
+ }
296
+ else {
297
+ const next = render(root, file, previous);
298
+ if (existsSync(path) && readFileSync(path, "utf8") === next) {
299
+ result.unchanged.push(file.path);
300
+ }
301
+ else {
302
+ mkdirSync(dirname(path), { recursive: true });
303
+ writeFileSync(path, next, { mode: file.kind === "file" ? file.mode : undefined });
304
+ result.written.push(file.path);
305
+ }
306
+ }
307
+ lock.files[file.path] = {
308
+ sha256: sha256(managedContent(file)),
309
+ state: "generated",
310
+ keys,
311
+ regions,
312
+ };
313
+ }
314
+ for (const [path, entry] of Object.entries(previous.files)) {
315
+ if (lock.files[path])
316
+ continue;
317
+ if (entry.state === "manual") {
318
+ lock.files[path] = entry;
319
+ continue;
320
+ }
321
+ if (!present(root, path))
322
+ continue;
323
+ if (entry.keys) {
324
+ // The inverse of a merge file is its keys: the author keeps the file and everything else in it.
325
+ const format = documentFormat(path);
326
+ const document = parseDocument(readFileSync(join(root, path), "utf8"), format);
327
+ if (removeKeys(document, entry.keys)) {
328
+ writeFileSync(join(root, path), serializeDocument(document, format));
329
+ result.written.push(path);
330
+ }
331
+ continue;
332
+ }
333
+ if (entry.regions) {
334
+ // The inverse of a region file is its markers: the author keeps the file, the markers, and
335
+ // every byte outside them, so re-selecting the surface refills them with no further code.
336
+ const text = readFileSync(join(root, path), "utf8");
337
+ const emptied = emptyRegions(text, entry.regions);
338
+ // Markers already gone: nothing of ours is left to uninstall.
339
+ if (emptied === undefined || emptied === text)
340
+ continue;
341
+ writeFileSync(join(root, path), emptied);
342
+ result.stripped.push(path);
343
+ continue;
344
+ }
345
+ rmSync(join(root, path));
346
+ result.deleted.push(path);
347
+ }
348
+ const lockPath = join(root, LOCK_PATH);
349
+ mkdirSync(dirname(lockPath), { recursive: true });
350
+ writeFileSync(lockPath, serializeLock(lock));
351
+ return result;
352
+ }
353
+ export function setState(root, path, state, toolfactoryVersion) {
354
+ const lock = readLock(root) ?? { toolfactoryVersion, files: {} };
355
+ const entry = lock.files[path];
356
+ if (!entry)
357
+ throw new Error(`${path} is not a toolfactory-managed file.`);
358
+ lock.files[path] = { ...entry, state };
359
+ writeFileSync(join(root, LOCK_PATH), serializeLock(lock));
360
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,285 @@
1
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { apply, check, setState } from "./apply.js";
6
+ import { buildPlan } from "./plan.js";
7
+ function tmp() {
8
+ return mkdtempSync(join(tmpdir(), "tf-apply-"));
9
+ }
10
+ function regionPlan(content) {
11
+ return {
12
+ kind: "region",
13
+ path: "SKILL.md",
14
+ regions: [{ begin: "<!-- tf:ops -->", end: "<!-- /tf:ops -->", content }],
15
+ template: ["# Title", "", "Author prose.", "", "<!-- tf:ops -->", "<!-- /tf:ops -->", ""].join("\n"),
16
+ };
17
+ }
18
+ describe("apply / check — full files", () => {
19
+ it("applies a plan and check then reports it clean", () => {
20
+ const root = tmp();
21
+ const plan = [{ kind: "file", path: "README.md", content: "hello\n" }];
22
+ const result = apply(root, plan, "0.1.0");
23
+ expect(result.written).toEqual(["README.md"]);
24
+ expect(readFileSync(join(root, "README.md"), "utf8")).toBe("hello\n");
25
+ expect(check(root, plan, "0.1.0")).toEqual([]);
26
+ });
27
+ it("reports a hand-edited generated file as changed", () => {
28
+ const root = tmp();
29
+ const plan = [{ kind: "file", path: "README.md", content: "hello\n" }];
30
+ apply(root, plan, "0.1.0");
31
+ writeFileSync(join(root, "README.md"), "tampered\n");
32
+ expect(check(root, plan, "0.1.0")).toEqual([{ path: "README.md", kind: "changed" }]);
33
+ });
34
+ it("a manual file is skipped by both drift and rewriting", () => {
35
+ const root = tmp();
36
+ const planA = [{ kind: "file", path: "README.md", content: "hello\n" }];
37
+ apply(root, planA, "0.1.0");
38
+ setState(root, "README.md", "manual", "0.1.0");
39
+ writeFileSync(join(root, "README.md"), "author-owned\n");
40
+ const planB = [{ kind: "file", path: "README.md", content: "regenerated\n" }];
41
+ expect(check(root, planB, "0.1.0")).toEqual([]);
42
+ const result = apply(root, planB, "0.1.0");
43
+ expect(result.manual).toEqual(["README.md"]);
44
+ expect(readFileSync(join(root, "README.md"), "utf8")).toBe("author-owned\n");
45
+ });
46
+ it("reports an orphaned generated file via check and removes it on apply", () => {
47
+ const root = tmp();
48
+ const planA = [
49
+ { kind: "file", path: "a.txt", content: "a\n" },
50
+ { kind: "file", path: "b.txt", content: "b\n" },
51
+ ];
52
+ apply(root, planA, "0.1.0");
53
+ const planB = [{ kind: "file", path: "b.txt", content: "b\n" }];
54
+ expect(check(root, planB, "0.1.0")).toEqual([{ path: "a.txt", kind: "orphan" }]);
55
+ const result = apply(root, planB, "0.1.0");
56
+ expect(result.deleted).toEqual(["a.txt"]);
57
+ expect(existsSync(join(root, "a.txt"))).toBe(false);
58
+ });
59
+ it("writes a symbolic link, restores one a real file replaced, and unlinks it as an orphan", () => {
60
+ const root = tmp();
61
+ mkdirSync(join(root, "skills/hello"), { recursive: true });
62
+ writeFileSync(join(root, "skills/hello/SKILL.md"), "---\n---\n");
63
+ const link = {
64
+ kind: "file",
65
+ path: ".agents/skills/hello",
66
+ content: "../../skills/hello",
67
+ symlink: true,
68
+ };
69
+ apply(root, [link], "0.1.0");
70
+ expect(readlinkSync(join(root, ".agents/skills/hello"))).toBe("../../skills/hello");
71
+ expect(check(root, [link], "0.1.0")).toEqual([]);
72
+ rmSync(join(root, ".agents/skills/hello"));
73
+ writeFileSync(join(root, ".agents/skills/hello"), "not a link\n");
74
+ expect(check(root, [link], "0.1.0")).toEqual([
75
+ { path: ".agents/skills/hello", kind: "changed" },
76
+ ]);
77
+ apply(root, [link], "0.1.0");
78
+ expect(lstatSync(join(root, ".agents/skills/hello")).isSymbolicLink()).toBe(true);
79
+ expect(apply(root, [], "0.1.0").deleted).toEqual([".agents/skills/hello"]);
80
+ expect(existsSync(join(root, ".agents/skills/hello"))).toBe(false);
81
+ expect(existsSync(join(root, "skills/hello/SKILL.md"))).toBe(true);
82
+ // lstat, not stat: with its target gone the link is dangling — still present, still ours.
83
+ apply(root, [link], "0.1.0");
84
+ rmSync(join(root, "skills"), { recursive: true });
85
+ expect(check(root, [], "0.1.0")).toEqual([{ path: ".agents/skills/hello", kind: "orphan" }]);
86
+ expect(apply(root, [], "0.1.0").deleted).toEqual([".agents/skills/hello"]);
87
+ expect(lstatSync(join(root, ".agents/skills/hello"), { throwIfNoEntry: false })).toBeUndefined();
88
+ });
89
+ });
90
+ describe("apply / check — region files", () => {
91
+ it("writes the template with the region filled when the file is absent", () => {
92
+ const root = tmp();
93
+ apply(root, [regionPlan("- op-a\n")], "0.1.0");
94
+ const text = readFileSync(join(root, "SKILL.md"), "utf8");
95
+ expect(text).toContain("Author prose.");
96
+ expect(text).toContain("- op-a\n");
97
+ });
98
+ it("preserves the author's body outside the markers when the region is rebuilt", () => {
99
+ const root = tmp();
100
+ apply(root, [regionPlan("- op-a\n")], "0.1.0");
101
+ const path = join(root, "SKILL.md");
102
+ writeFileSync(path, readFileSync(path, "utf8").replace("Author prose.", "Author prose.\n\nMore notes the author wrote by hand."));
103
+ apply(root, [regionPlan("- op-a\n- op-b\n")], "0.1.0");
104
+ const text = readFileSync(path, "utf8");
105
+ expect(text).toContain("More notes the author wrote by hand.");
106
+ expect(text).toContain("- op-b\n");
107
+ });
108
+ it("a region file toolfactory stops writing loses its regions, not its existence", () => {
109
+ const root = tmp();
110
+ apply(root, [regionPlan("- op-a\n")], "0.1.0");
111
+ const path = join(root, "SKILL.md");
112
+ const authored = readFileSync(path, "utf8").replace("Author prose.", "Author prose.\n\nA paragraph only the author can write.");
113
+ writeFileSync(path, authored);
114
+ expect(check(root, [], "0.1.0")).toEqual([{ path: "SKILL.md", kind: "orphan" }]);
115
+ const result = apply(root, [], "0.1.0");
116
+ expect(result).toMatchObject({ stripped: ["SKILL.md"], deleted: [] });
117
+ const emptied = readFileSync(path, "utf8");
118
+ expect(emptied).toContain("A paragraph only the author can write.");
119
+ expect(emptied).toContain("<!-- tf:ops --><!-- /tf:ops -->");
120
+ expect(emptied).not.toContain("- op-a");
121
+ // Silent once emptied: nothing of toolfactory's is stranded in the file any more.
122
+ expect(check(root, [], "0.1.0")).toEqual([]);
123
+ // Re-selection needs no code of its own: the preserved markers are refilled in place.
124
+ apply(root, [regionPlan("- op-a\n")], "0.1.0");
125
+ expect(readFileSync(path, "utf8")).toBe(authored);
126
+ });
127
+ it("empties the region a surface stopped owning while another surface keeps the file", () => {
128
+ const root = tmp();
129
+ const install = { begin: "<!-- tf:install -->", end: "<!-- /tf:install -->" };
130
+ const mcpName = { begin: "<!-- tf:mcp-name -->", end: "<!-- /tf:mcp-name -->" };
131
+ const plan = (registry) => [
132
+ {
133
+ kind: "region",
134
+ path: "README.md",
135
+ regions: [
136
+ { ...install, content: "\n`npx -y hello mcp`\n" },
137
+ ...(registry
138
+ ? [{ ...mcpName, content: "\n<!-- mcp-name: io.github.o/hello -->\n" }]
139
+ : []),
140
+ ],
141
+ template: [
142
+ "# hello",
143
+ "",
144
+ install.begin,
145
+ install.end,
146
+ "",
147
+ mcpName.begin,
148
+ mcpName.end,
149
+ "",
150
+ ].join("\n"),
151
+ },
152
+ ];
153
+ apply(root, plan(true), "0.1.0");
154
+ expect(check(root, plan(false), "0.1.0")).toEqual([{ path: "README.md", kind: "changed" }]);
155
+ apply(root, plan(false), "0.1.0");
156
+ const text = readFileSync(join(root, "README.md"), "utf8");
157
+ expect(text).not.toContain("mcp-name: io.github.o/hello");
158
+ expect(text).toContain("<!-- tf:mcp-name --><!-- /tf:mcp-name -->");
159
+ expect(text).toContain("`npx -y hello mcp`");
160
+ expect(check(root, plan(false), "0.1.0")).toEqual([]);
161
+ });
162
+ it("check reports a missing region marker as unmarked", () => {
163
+ const root = tmp();
164
+ mkdirSync(root, { recursive: true });
165
+ writeFileSync(join(root, "SKILL.md"), "# Title\n\nNo markers in this file at all.\n");
166
+ expect(check(root, [regionPlan("- op-a\n")], "0.1.0")).toEqual([
167
+ { path: "SKILL.md", kind: "unmarked" },
168
+ ]);
169
+ });
170
+ });
171
+ describe("apply / check — merge files", () => {
172
+ it("merges a JSON patch, keeping keys it does not own", () => {
173
+ const root = tmp();
174
+ mkdirSync(root, { recursive: true });
175
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "old", scripts: { test: "vitest" } }, null, 2));
176
+ const plan = [
177
+ {
178
+ kind: "merge",
179
+ path: "package.json",
180
+ format: "json",
181
+ patch: { name: "hello", version: "0.1.0" },
182
+ },
183
+ ];
184
+ apply(root, plan, "0.1.0");
185
+ const document = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
186
+ expect(document).toEqual({ name: "hello", scripts: { test: "vitest" }, version: "0.1.0" });
187
+ expect(check(root, plan, "0.1.0")).toEqual([]);
188
+ });
189
+ it("replaces an owned object whole, so a renamed key does not leave its old name behind", () => {
190
+ const root = tmp();
191
+ mkdirSync(root, { recursive: true });
192
+ const bin = (name) => [
193
+ {
194
+ kind: "merge",
195
+ path: "package.json",
196
+ format: "json",
197
+ patch: { name, bin: { [name]: "./dist/cli.js" } },
198
+ owned: ["bin"],
199
+ },
200
+ ];
201
+ apply(root, bin("hello"), "0.1.0");
202
+ apply(root, bin("renamed"), "0.1.0");
203
+ const document = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
204
+ expect(document.bin).toEqual({ renamed: "./dist/cli.js" });
205
+ writeFileSync(join(root, "package.json"), JSON.stringify({ ...document, bin: { ...document.bin, stale: "./x" } }));
206
+ expect(check(root, bin("renamed"), "0.1.0")).toEqual([
207
+ { path: "package.json", kind: "changed" },
208
+ ]);
209
+ });
210
+ it("round-trips a TOML patch, merging into and preserving existing content", () => {
211
+ const root = tmp();
212
+ mkdirSync(root, { recursive: true });
213
+ writeFileSync(join(root, "pyproject.toml"), '[project]\nname = "old"\ndependencies = ["zod"]\n');
214
+ const plan = [
215
+ {
216
+ kind: "merge",
217
+ path: "pyproject.toml",
218
+ format: "toml",
219
+ patch: { project: { name: "hello", version: "0.1.0" } },
220
+ },
221
+ ];
222
+ apply(root, plan, "0.1.0");
223
+ const text = readFileSync(join(root, "pyproject.toml"), "utf8");
224
+ expect(text).toContain('name = "hello"');
225
+ expect(text).toContain('version = "0.1.0"');
226
+ expect(text).toContain('dependencies = [ "zod" ]');
227
+ expect(check(root, plan, "0.1.0")).toEqual([]);
228
+ });
229
+ });
230
+ describe("apply / check — inverses and outputs", () => {
231
+ it("uninstalls the keys a patch stops writing, keeping every other key", () => {
232
+ const root = tmp();
233
+ mkdirSync(root, { recursive: true });
234
+ writeFileSync(join(root, "package.json"), JSON.stringify({ scripts: { test: "vitest" } }, null, 2));
235
+ const plan = (registry) => [
236
+ {
237
+ kind: "merge",
238
+ path: "package.json",
239
+ format: "json",
240
+ patch: {
241
+ name: "hello",
242
+ ...(registry
243
+ ? { scripts: { smoke: "node smoke.js" }, mcpName: "io.github.o/hello", tf: { mark: 1 } }
244
+ : {}),
245
+ },
246
+ },
247
+ ];
248
+ apply(root, plan(true), "0.1.0");
249
+ expect(check(root, plan(false), "0.1.0")).toEqual([{ path: "package.json", kind: "changed" }]);
250
+ apply(root, plan(false), "0.1.0");
251
+ expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8"))).toEqual({
252
+ name: "hello",
253
+ scripts: { test: "vitest" },
254
+ });
255
+ expect(check(root, plan(false), "0.1.0")).toEqual([]);
256
+ });
257
+ it("a merge file toolfactory stops writing loses its keys, not its existence", () => {
258
+ const root = tmp();
259
+ mkdirSync(root, { recursive: true });
260
+ writeFileSync(join(root, "package.json"), JSON.stringify({ scripts: { test: "vitest" } }, null, 2));
261
+ const plan = [
262
+ { kind: "merge", path: "package.json", format: "json", patch: { name: "hello" } },
263
+ ];
264
+ apply(root, plan, "0.1.0");
265
+ expect(check(root, [], "0.1.0")).toEqual([{ path: "package.json", kind: "orphan" }]);
266
+ expect(apply(root, [], "0.1.0").deleted).toEqual([]);
267
+ expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8"))).toEqual({
268
+ scripts: { test: "vitest" },
269
+ });
270
+ });
271
+ it("an output file is drift only when it is present and stale", () => {
272
+ const root = tmp();
273
+ const path = "dev.toolfactory/coverage.json";
274
+ const plan = [{ kind: "file", path, content: "{}\n", output: true }];
275
+ apply(root, plan, "0.1.0");
276
+ rmSync(join(root, path));
277
+ expect(check(root, plan, "0.1.0")).toEqual([]);
278
+ writeFileSync(join(root, path), "stale\n");
279
+ expect(check(root, plan, "0.1.0")).toEqual([{ path, kind: "changed" }]);
280
+ });
281
+ it("refuses a selection that omits a surface another one declares it requires", () => {
282
+ const project = { root: tmp(), tool: { surfaces: ["cli", "web"] } };
283
+ expect(() => buildPlan(project)).toThrow(/Surface "web" requires surface "mcp"/);
284
+ });
285
+ });