fluidcad 0.0.32 → 0.0.34

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 (279) hide show
  1. package/README.md +3 -2
  2. package/bin/commands/init.js +55 -0
  3. package/bin/commands/mcp.js +33 -0
  4. package/bin/commands/serve.js +77 -0
  5. package/bin/fluidcad.js +15 -107
  6. package/lib/dist/common/scene-object.d.ts +4 -1
  7. package/lib/dist/common/scene-object.js +9 -2
  8. package/lib/dist/common/solid.d.ts +4 -1
  9. package/lib/dist/common/solid.js +13 -0
  10. package/lib/dist/core/2d/tarc.d.ts +20 -2
  11. package/lib/dist/core/2d/tarc.js +24 -0
  12. package/lib/dist/core/index.d.ts +2 -1
  13. package/lib/dist/core/index.js +1 -0
  14. package/lib/dist/core/interfaces.d.ts +107 -2
  15. package/lib/dist/core/load.d.ts +2 -2
  16. package/lib/dist/core/repeat.js +62 -46
  17. package/lib/dist/core/rib.d.ts +18 -0
  18. package/lib/dist/core/rib.js +37 -0
  19. package/lib/dist/features/2d/arc.d.ts +8 -2
  20. package/lib/dist/features/2d/arc.js +94 -17
  21. package/lib/dist/features/2d/back.js +3 -2
  22. package/lib/dist/features/2d/sketch.d.ts +4 -0
  23. package/lib/dist/features/2d/sketch.js +21 -0
  24. package/lib/dist/features/2d/tarc-constrained.d.ts +2 -0
  25. package/lib/dist/features/2d/tarc-constrained.js +8 -0
  26. package/lib/dist/features/2d/tarc-radius-to-object.d.ts +16 -0
  27. package/lib/dist/features/2d/tarc-radius-to-object.js +58 -0
  28. package/lib/dist/features/2d/tarc-to-object.d.ts +18 -0
  29. package/lib/dist/features/2d/tarc-to-object.js +66 -0
  30. package/lib/dist/features/2d/tarc-to-point-tangent.d.ts +2 -0
  31. package/lib/dist/features/2d/tarc-to-point-tangent.js +3 -0
  32. package/lib/dist/features/2d/tarc-to-point.d.ts +2 -0
  33. package/lib/dist/features/2d/tarc-to-point.js +3 -0
  34. package/lib/dist/features/2d/tarc-with-tangent.d.ts +2 -0
  35. package/lib/dist/features/2d/tarc-with-tangent.js +3 -0
  36. package/lib/dist/features/2d/tarc.d.ts +2 -0
  37. package/lib/dist/features/2d/tarc.js +3 -0
  38. package/lib/dist/features/extrude-base.d.ts +9 -0
  39. package/lib/dist/features/extrude-base.js +22 -0
  40. package/lib/dist/features/extrude-to-face.js +1 -5
  41. package/lib/dist/features/extrude-two-distances.js +1 -2
  42. package/lib/dist/features/extrude.js +1 -2
  43. package/lib/dist/features/load.d.ts +6 -0
  44. package/lib/dist/features/load.js +53 -1
  45. package/lib/dist/features/mirror-feature.d.ts +3 -2
  46. package/lib/dist/features/mirror-feature.js +1 -1
  47. package/lib/dist/features/repeat-circular.d.ts +3 -3
  48. package/lib/dist/features/repeat-circular.js +8 -1
  49. package/lib/dist/features/repeat-linear.d.ts +4 -2
  50. package/lib/dist/features/repeat-linear.js +10 -1
  51. package/lib/dist/features/repeat-matrix.d.ts +3 -1
  52. package/lib/dist/features/repeat-matrix.js +7 -2
  53. package/lib/dist/features/rib.d.ts +31 -0
  54. package/lib/dist/features/rib.js +321 -0
  55. package/lib/dist/features/select.d.ts +1 -0
  56. package/lib/dist/features/select.js +81 -10
  57. package/lib/dist/features/shell.d.ts +4 -1
  58. package/lib/dist/features/shell.js +14 -3
  59. package/lib/dist/filters/edge/belongs-to-face.d.ts +12 -9
  60. package/lib/dist/filters/edge/belongs-to-face.js +64 -15
  61. package/lib/dist/filters/filter-builder-base.d.ts +25 -0
  62. package/lib/dist/filters/filter-builder-base.js +47 -0
  63. package/lib/dist/filters/filter.js +39 -14
  64. package/lib/dist/filters/from-object.d.ts +4 -0
  65. package/lib/dist/filters/from-object.js +10 -0
  66. package/lib/dist/helpers/clone-transform.d.ts +2 -1
  67. package/lib/dist/helpers/scene-helpers.d.ts +1 -1
  68. package/lib/dist/helpers/scene-helpers.js +146 -12
  69. package/lib/dist/index.d.ts +7 -1
  70. package/lib/dist/index.js +3 -3
  71. package/lib/dist/io/file-import.d.ts +5 -1
  72. package/lib/dist/io/file-import.js +29 -18
  73. package/lib/dist/math/lazy-matrix.d.ts +31 -0
  74. package/lib/dist/math/lazy-matrix.js +66 -0
  75. package/lib/dist/oc/color-transfer.d.ts +19 -8
  76. package/lib/dist/oc/color-transfer.js +70 -12
  77. package/lib/dist/oc/constraints/constraint-solver-adaptor.d.ts +5 -0
  78. package/lib/dist/oc/constraints/constraint-solver-adaptor.js +16 -0
  79. package/lib/dist/oc/constraints/constraint-solver.d.ts +4 -0
  80. package/lib/dist/oc/constraints/curve/curve-constraint-solver.d.ts +4 -0
  81. package/lib/dist/oc/constraints/curve/curve-constraint-solver.js +3 -0
  82. package/lib/dist/oc/constraints/geometric/geometric-constraint-solver.d.ts +6 -1
  83. package/lib/dist/oc/constraints/geometric/geometric-constraint-solver.js +4 -0
  84. package/lib/dist/oc/constraints/geometric/tangent-arc-from-point-tangent.d.ts +8 -0
  85. package/lib/dist/oc/constraints/geometric/tangent-arc-from-point-tangent.js +111 -0
  86. package/lib/dist/oc/constraints/geometric/tangent-arc-radius-to-object.d.ts +8 -0
  87. package/lib/dist/oc/constraints/geometric/tangent-arc-radius-to-object.js +161 -0
  88. package/lib/dist/oc/extrude-ops.d.ts +2 -1
  89. package/lib/dist/oc/extrude-ops.js +51 -2
  90. package/lib/dist/oc/mesh.d.ts +9 -4
  91. package/lib/dist/oc/mesh.js +14 -13
  92. package/lib/dist/oc/rib-ops.d.ts +35 -0
  93. package/lib/dist/oc/rib-ops.js +619 -0
  94. package/lib/dist/oc/shell-ops.d.ts +2 -1
  95. package/lib/dist/oc/shell-ops.js +5 -2
  96. package/lib/dist/oc/topology-index.d.ts +6 -0
  97. package/lib/dist/oc/topology-index.js +36 -0
  98. package/lib/dist/rendering/mesh-builder.d.ts +3 -0
  99. package/lib/dist/rendering/mesh-builder.js +8 -4
  100. package/lib/dist/rendering/render-edge.d.ts +2 -1
  101. package/lib/dist/rendering/render-edge.js +2 -2
  102. package/lib/dist/rendering/render-face.d.ts +2 -1
  103. package/lib/dist/rendering/render-face.js +2 -2
  104. package/lib/dist/rendering/render-solid.d.ts +2 -1
  105. package/lib/dist/rendering/render-solid.js +3 -5
  106. package/lib/dist/rendering/render-wire.d.ts +2 -1
  107. package/lib/dist/rendering/render-wire.js +2 -2
  108. package/lib/dist/rendering/render.d.ts +4 -0
  109. package/lib/dist/rendering/render.js +50 -2
  110. package/lib/dist/rendering/scene-compare.js +3 -0
  111. package/lib/dist/rendering/scene.d.ts +1 -0
  112. package/lib/dist/rendering/scene.js +4 -0
  113. package/lib/dist/scene-manager.d.ts +4 -2
  114. package/lib/dist/scene-manager.js +12 -4
  115. package/lib/dist/tests/features/2d/arc.test.js +64 -0
  116. package/lib/dist/tests/features/2d/back.test.js +17 -1
  117. package/lib/dist/tests/features/2d/tarc.test.js +157 -0
  118. package/lib/dist/tests/features/color-lineage.test.js +18 -0
  119. package/lib/dist/tests/features/filter-positional.test.d.ts +1 -0
  120. package/lib/dist/tests/features/filter-positional.test.js +129 -0
  121. package/lib/dist/tests/features/repeat-user-repro.test.d.ts +1 -0
  122. package/lib/dist/tests/features/repeat-user-repro.test.js +60 -0
  123. package/lib/dist/tests/features/rib.test.d.ts +1 -0
  124. package/lib/dist/tests/features/rib.test.js +598 -0
  125. package/lib/dist/tests/features/shell.test.js +36 -0
  126. package/lib/dist/tests/global-setup.js +2 -1
  127. package/lib/dist/tests/helpers/extract-blocks.d.ts +9 -0
  128. package/lib/dist/tests/helpers/extract-blocks.js +56 -0
  129. package/lib/dist/tests/llm-docs-examples.test.d.ts +1 -0
  130. package/lib/dist/tests/llm-docs-examples.test.js +62 -0
  131. package/lib/dist/tests/scene-compare.test.d.ts +1 -0
  132. package/lib/dist/tests/scene-compare.test.js +77 -0
  133. package/lib/dist/tests/setup.js +2 -1
  134. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  135. package/llm-docs/.coverage-allowlist.txt +9 -0
  136. package/llm-docs/api/arc.md +48 -0
  137. package/llm-docs/api/axis.md +42 -0
  138. package/llm-docs/api/bezier.md +42 -0
  139. package/llm-docs/api/booleans.md +44 -0
  140. package/llm-docs/api/chamfer.md +40 -0
  141. package/llm-docs/api/circle.md +36 -0
  142. package/llm-docs/api/color.md +34 -0
  143. package/llm-docs/api/connect.md +41 -0
  144. package/llm-docs/api/constraint-qualifiers.md +48 -0
  145. package/llm-docs/api/copy.md +63 -0
  146. package/llm-docs/api/cursor-lines.md +50 -0
  147. package/llm-docs/api/cursor-move.md +61 -0
  148. package/llm-docs/api/cut.md +55 -0
  149. package/llm-docs/api/draft.md +36 -0
  150. package/llm-docs/api/edge-filter.md +57 -0
  151. package/llm-docs/api/ellipse.md +34 -0
  152. package/llm-docs/api/extrude.md +74 -0
  153. package/llm-docs/api/face-filter.md +61 -0
  154. package/llm-docs/api/fillet.md +51 -0
  155. package/llm-docs/api/index.json +139 -0
  156. package/llm-docs/api/line.md +42 -0
  157. package/llm-docs/api/load.md +37 -0
  158. package/llm-docs/api/local.md +38 -0
  159. package/llm-docs/api/loft.md +37 -0
  160. package/llm-docs/api/mirror.md +44 -0
  161. package/llm-docs/api/offset.md +36 -0
  162. package/llm-docs/api/part.md +40 -0
  163. package/llm-docs/api/plane.md +44 -0
  164. package/llm-docs/api/polygon.md +37 -0
  165. package/llm-docs/api/primitive-solids.md +39 -0
  166. package/llm-docs/api/project-intersect.md +48 -0
  167. package/llm-docs/api/rect.md +48 -0
  168. package/llm-docs/api/remove.md +32 -0
  169. package/llm-docs/api/repeat.md +79 -0
  170. package/llm-docs/api/revolve.md +38 -0
  171. package/llm-docs/api/rib.md +40 -0
  172. package/llm-docs/api/rotate.md +37 -0
  173. package/llm-docs/api/select.md +41 -0
  174. package/llm-docs/api/shell.md +41 -0
  175. package/llm-docs/api/sketch.md +76 -0
  176. package/llm-docs/api/slot.md +36 -0
  177. package/llm-docs/api/split-trim.md +42 -0
  178. package/llm-docs/api/sweep.md +43 -0
  179. package/llm-docs/api/tarc.md +45 -0
  180. package/llm-docs/api/tcircle.md +38 -0
  181. package/llm-docs/api/tline.md +42 -0
  182. package/llm-docs/api/translate.md +40 -0
  183. package/llm-docs/api/types/aline.md +35 -0
  184. package/llm-docs/api/types/arc-angles.md +29 -0
  185. package/llm-docs/api/types/arc-points.md +48 -0
  186. package/llm-docs/api/types/axis-like.md +38 -0
  187. package/llm-docs/api/types/axis.md +21 -0
  188. package/llm-docs/api/types/boolean-operation.md +50 -0
  189. package/llm-docs/api/types/circular-repeat-options.md +31 -0
  190. package/llm-docs/api/types/common.md +32 -0
  191. package/llm-docs/api/types/cut.md +125 -0
  192. package/llm-docs/api/types/draft.md +21 -0
  193. package/llm-docs/api/types/extrudable-geometry.md +23 -0
  194. package/llm-docs/api/types/extrude.md +194 -0
  195. package/llm-docs/api/types/geometry.md +51 -0
  196. package/llm-docs/api/types/hline.md +35 -0
  197. package/llm-docs/api/types/linear-repeat-options.md +31 -0
  198. package/llm-docs/api/types/loft.md +154 -0
  199. package/llm-docs/api/types/mirror.md +35 -0
  200. package/llm-docs/api/types/offset.md +31 -0
  201. package/llm-docs/api/types/plane-like.md +35 -0
  202. package/llm-docs/api/types/plane-transform-options.md +29 -0
  203. package/llm-docs/api/types/plane.md +21 -0
  204. package/llm-docs/api/types/point-like.md +22 -0
  205. package/llm-docs/api/types/point2dlike.md +26 -0
  206. package/llm-docs/api/types/polygon.md +46 -0
  207. package/llm-docs/api/types/rect.md +128 -0
  208. package/llm-docs/api/types/revolve.md +102 -0
  209. package/llm-docs/api/types/rib.md +133 -0
  210. package/llm-docs/api/types/scene-object.md +33 -0
  211. package/llm-docs/api/types/select.md +21 -0
  212. package/llm-docs/api/types/shell.md +54 -0
  213. package/llm-docs/api/types/slot.md +43 -0
  214. package/llm-docs/api/types/sweep.md +189 -0
  215. package/llm-docs/api/types/tangent-arc-two-objects.md +46 -0
  216. package/llm-docs/api/types/transformable.md +93 -0
  217. package/llm-docs/api/types/trim.md +27 -0
  218. package/llm-docs/api/types/two-objects-tangent-line.md +46 -0
  219. package/llm-docs/api/types/vertex.md +17 -0
  220. package/llm-docs/api/types/vline.md +35 -0
  221. package/llm-docs/concepts/coordinate-system.md +45 -0
  222. package/llm-docs/concepts/history-and-rollback.md +40 -0
  223. package/llm-docs/concepts/last-selection.md +49 -0
  224. package/llm-docs/concepts/scene-graph.md +37 -0
  225. package/llm-docs/index.json +1750 -0
  226. package/mcp/dist/client.d.ts +64 -0
  227. package/mcp/dist/client.js +248 -0
  228. package/mcp/dist/discovery.d.ts +11 -0
  229. package/mcp/dist/discovery.js +78 -0
  230. package/mcp/dist/docs-index.d.ts +81 -0
  231. package/mcp/dist/docs-index.js +261 -0
  232. package/mcp/dist/resources.d.ts +4 -0
  233. package/mcp/dist/resources.js +115 -0
  234. package/mcp/dist/server.d.ts +12 -0
  235. package/mcp/dist/server.js +489 -0
  236. package/mcp/dist/tools/coordination.d.ts +9 -0
  237. package/mcp/dist/tools/coordination.js +46 -0
  238. package/mcp/dist/tools/docs.d.ts +66 -0
  239. package/mcp/dist/tools/docs.js +122 -0
  240. package/mcp/dist/tools/engine.d.ts +56 -0
  241. package/mcp/dist/tools/engine.js +145 -0
  242. package/mcp/dist/tools/inspection.d.ts +75 -0
  243. package/mcp/dist/tools/inspection.js +121 -0
  244. package/mcp/dist/tools/screenshot.d.ts +63 -0
  245. package/mcp/dist/tools/screenshot.js +263 -0
  246. package/mcp/dist/tools/source.d.ts +84 -0
  247. package/mcp/dist/tools/source.js +434 -0
  248. package/mcp/dist/tools/workspaces.d.ts +13 -0
  249. package/mcp/dist/tools/workspaces.js +33 -0
  250. package/mcp/dist/types.d.ts +18 -0
  251. package/mcp/dist/types.js +11 -0
  252. package/package.json +19 -5
  253. package/server/dist/code-editor.d.ts +36 -0
  254. package/server/dist/code-editor.js +8 -0
  255. package/server/dist/fluidcad-server.d.ts +50 -0
  256. package/server/dist/fluidcad-server.js +153 -1
  257. package/server/dist/global-registry.d.ts +30 -0
  258. package/server/dist/global-registry.js +126 -0
  259. package/server/dist/index.js +171 -26
  260. package/server/dist/instance-file.d.ts +31 -0
  261. package/server/dist/instance-file.js +73 -0
  262. package/server/dist/lint-fluid-js.d.ts +15 -0
  263. package/server/dist/lint-fluid-js.js +271 -0
  264. package/server/dist/routes/editor.d.ts +24 -0
  265. package/server/dist/routes/editor.js +44 -0
  266. package/server/dist/routes/export.d.ts +1 -1
  267. package/server/dist/routes/export.js +45 -8
  268. package/server/dist/routes/health.d.ts +7 -0
  269. package/server/dist/routes/health.js +14 -0
  270. package/server/dist/routes/lint.d.ts +10 -0
  271. package/server/dist/routes/lint.js +28 -0
  272. package/server/dist/routes/render.d.ts +33 -0
  273. package/server/dist/routes/render.js +34 -0
  274. package/server/dist/routes/scene.d.ts +5 -0
  275. package/server/dist/routes/scene.js +48 -0
  276. package/server/dist/routes/screenshot.js +68 -1
  277. package/server/dist/ws-protocol.d.ts +56 -2
  278. package/ui/dist/assets/{index-DMw0OYCF.js → index-BdqrMDRu.js} +30 -30
  279. package/ui/dist/index.html +1 -1
@@ -0,0 +1,434 @@
1
+ // Source-editing tools — let the agent read, write, and walk `.fluid.js`
2
+ // sources inside a workspace. Writes are gated by a dirty-buffer check
3
+ // against the editor extension, atomic via tmp+rename, and confined to the
4
+ // workspace root (symlinks that escape are rejected).
5
+ import fs from 'node:fs';
6
+ import fsp from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { findByWorkspace, listLiveInstances } from "../discovery.js";
9
+ import { FluidCadClient, HttpError } from "../client.js";
10
+ import { err, ok } from "../types.js";
11
+ /**
12
+ * Resolve a workspace argument to a `RegistryEntry`. Distinct from the
13
+ * inspection-tools variant: source tools rarely need an HTTP client (only
14
+ * `write_file`/`edit_range` do, and only to probe dirty buffers), so we
15
+ * return the entry directly and let callers open a client when required.
16
+ */
17
+ function resolveEntry(input) {
18
+ if (input?.workspace) {
19
+ const entry = findByWorkspace(input.workspace);
20
+ if (!entry) {
21
+ return err('workspace-not-found', `No running FluidCAD workspace at "${input.workspace}". Call list_workspaces to see what's available.`);
22
+ }
23
+ return ok(entry);
24
+ }
25
+ const instances = listLiveInstances();
26
+ if (instances.length === 0) {
27
+ return err('no-server', 'No running FluidCAD workspaces. Start one with `fluidcad serve`.');
28
+ }
29
+ if (instances.length > 1) {
30
+ return err('no-workspace', `Multiple FluidCAD workspaces are running (${instances.length}). Pass \`workspace\` to disambiguate.`, { workspaces: instances.map((e) => e.workspacePath) });
31
+ }
32
+ return ok(instances[0]);
33
+ }
34
+ /**
35
+ * Resolve a user-supplied path against the workspace root, then verify the
36
+ * result still lives under the workspace (symlinks included). Returns the
37
+ * canonical absolute path on success.
38
+ */
39
+ function resolveWithinWorkspace(workspaceRoot, userPath, { mustExist }) {
40
+ if (typeof userPath !== 'string' || userPath.length === 0) {
41
+ return err('invalid-input', '`path` is required and must be a non-empty string.');
42
+ }
43
+ const rootReal = (() => {
44
+ try {
45
+ return fs.realpathSync(workspaceRoot);
46
+ }
47
+ catch {
48
+ return path.resolve(workspaceRoot);
49
+ }
50
+ })();
51
+ const candidate = path.resolve(rootReal, userPath);
52
+ let canonical = candidate;
53
+ try {
54
+ canonical = fs.realpathSync(candidate);
55
+ }
56
+ catch (e) {
57
+ if (mustExist || e?.code !== 'ENOENT') {
58
+ if (mustExist) {
59
+ return err('invalid-input', `File not found: ${userPath}`);
60
+ }
61
+ return err('internal', e?.message ?? String(e));
62
+ }
63
+ // File doesn't exist yet — for writes, fall back to the parent dir's
64
+ // realpath so we still catch symlink escape via the parent.
65
+ const parent = path.dirname(candidate);
66
+ try {
67
+ const parentReal = fs.realpathSync(parent);
68
+ canonical = path.join(parentReal, path.basename(candidate));
69
+ }
70
+ catch {
71
+ // Parent doesn't exist either — keep the resolved candidate; the
72
+ // boundary check below still catches `..` escapes.
73
+ }
74
+ }
75
+ const rel = path.relative(rootReal, canonical);
76
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
77
+ return err('invalid-input', `Path escapes workspace root: ${userPath}`);
78
+ }
79
+ return ok({ absPath: canonical, rootReal });
80
+ }
81
+ /**
82
+ * Static import lint for a `.fluid.js` payload via the server's
83
+ * `POST /api/lint-fluid-js` endpoint. The server owns the tree-sitter parser
84
+ * and the FluidCAD symbol table, so MCP stays a thin proxy.
85
+ *
86
+ * Failure is non-fatal: if the server endpoint is missing (older release) or
87
+ * the request errors, the lint is treated as "no missing imports" so writes
88
+ * still succeed. The render step downstream will catch any resulting
89
+ * `ReferenceError` if the lint was bypassed.
90
+ */
91
+ async function lintFluidJsCode(entry, code) {
92
+ const client = new FluidCadClient(entry);
93
+ try {
94
+ const result = await client.postJson('/api/lint-fluid-js', { code });
95
+ return result;
96
+ }
97
+ catch (e) {
98
+ if (e instanceof HttpError && e.statusCode === 404) {
99
+ return null;
100
+ }
101
+ return null;
102
+ }
103
+ finally {
104
+ await client.close().catch(() => { });
105
+ }
106
+ }
107
+ function isFluidJsPath(absPath) {
108
+ return absPath.toLowerCase().endsWith('.fluid.js');
109
+ }
110
+ /**
111
+ * Refuse a write if the post-edit content uses FluidCAD APIs without the
112
+ * matching `import { … } from "fluidcad/…"` line. Bypassable with
113
+ * `force: true`, identical to the dirty-buffer guard, since both protect
114
+ * against the most common "first draft" mistakes LLMs make.
115
+ */
116
+ async function assertImportsPresent(entry, absPath, code, force) {
117
+ if (force === true) {
118
+ return ok(undefined);
119
+ }
120
+ if (!isFluidJsPath(absPath)) {
121
+ return ok(undefined);
122
+ }
123
+ const lint = await lintFluidJsCode(entry, code);
124
+ if (!lint || lint.missing.length === 0) {
125
+ return ok(undefined);
126
+ }
127
+ const symbolList = lint.missing.map((m) => m.symbol).join(', ');
128
+ return err('missing-imports', [
129
+ `Refusing to write "${absPath}" — uses ${lint.missing.length} FluidCAD ` +
130
+ `symbol(s) without an import: ${symbolList}.`,
131
+ 'Add this to the top of the file (pass `force: true` to override):',
132
+ lint.suggestion,
133
+ ].join('\n'), { missing: lint.missing, suggestion: lint.suggestion });
134
+ }
135
+ /**
136
+ * Ask the running FluidCAD server to render `code` for `filePath`. Used by
137
+ * `write_file` / `edit_range` to make the agent's edit synchronous: the
138
+ * disk write returns once the render settles, so the caller doesn't need a
139
+ * separate round-trip to observe completion.
140
+ *
141
+ * Non-fatal: any transport error is folded into the outcome as
142
+ * `render-failed` so the agent still sees the write succeeded.
143
+ */
144
+ async function triggerRender(entry, filePath, code) {
145
+ const client = new FluidCadClient(entry);
146
+ try {
147
+ const outcome = await client.postJson('/api/render', { filePath, code });
148
+ return outcome;
149
+ }
150
+ catch (e) {
151
+ if (e instanceof HttpError && e.statusCode === 404) {
152
+ // Older server without /api/render — silently degrade. The agent still
153
+ // gets `written: true`; the file watcher (under `fluidcad serve`) or
154
+ // the next editor save will eventually trigger the render.
155
+ return { state: 'render-failed', error: 'Server has no /api/render endpoint (upgrade fluidcad).' };
156
+ }
157
+ return { state: 'render-failed', error: e?.message ?? String(e) };
158
+ }
159
+ finally {
160
+ await client.close().catch(() => { });
161
+ }
162
+ }
163
+ /**
164
+ * Fetch the editor's dirty-buffer set. Failure to reach the server is
165
+ * non-fatal — the tool will treat the set as empty so the agent can still
166
+ * write when the editor extension is not connected. The MCP description
167
+ * surfaces this caveat.
168
+ */
169
+ async function fetchDirtyFiles(entry) {
170
+ const client = new FluidCadClient(entry);
171
+ try {
172
+ const dirty = await client.getJson('/api/editor/dirty-files');
173
+ return Array.isArray(dirty) ? dirty : [];
174
+ }
175
+ catch (e) {
176
+ if (e instanceof HttpError && e.statusCode === 404) {
177
+ // Older server with no dirty-files endpoint — treat as none dirty.
178
+ return [];
179
+ }
180
+ throw e;
181
+ }
182
+ finally {
183
+ await client.close().catch(() => { });
184
+ }
185
+ }
186
+ function pathsEqual(a, b) {
187
+ // Best-effort case-insensitive compare on Windows. On Linux/macOS file
188
+ // systems we still mostly want exact match, but realpath canonicalizes
189
+ // case on macOS HFS+, so direct `===` is usually enough.
190
+ if (process.platform === 'win32') {
191
+ return a.toLowerCase() === b.toLowerCase();
192
+ }
193
+ return a === b;
194
+ }
195
+ async function assertNotDirty(entry, absPath, force) {
196
+ if (force === true) {
197
+ return ok(undefined);
198
+ }
199
+ let dirty;
200
+ try {
201
+ dirty = await fetchDirtyFiles(entry);
202
+ }
203
+ catch (e) {
204
+ return err('http-error', `Could not check dirty buffers: ${e?.message ?? String(e)}`);
205
+ }
206
+ const conflict = dirty.find((d) => pathsEqual(d.path, absPath));
207
+ if (conflict) {
208
+ return err('dirty-buffer', `Refusing to write "${absPath}" — the editor has unsaved changes. Save in the editor, or pass \`force: true\` to overwrite.`, { dirtyFiles: dirty.map((d) => d.path) });
209
+ }
210
+ return ok(undefined);
211
+ }
212
+ /**
213
+ * Atomic file write: write to a sibling tmp file, fsync it, then rename
214
+ * over the destination. Mirrors `server/src/instance-file.ts` so a crash
215
+ * mid-write never leaves a half-written `.fluid.js` on disk.
216
+ */
217
+ async function atomicWrite(absPath, content) {
218
+ const dir = path.dirname(absPath);
219
+ await fsp.mkdir(dir, { recursive: true });
220
+ const tmp = path.join(dir, `.${path.basename(absPath)}.${process.pid}.tmp`);
221
+ const fh = await fsp.open(tmp, 'w', 0o644);
222
+ try {
223
+ await fh.writeFile(content, { encoding: 'utf8' });
224
+ await fh.sync();
225
+ }
226
+ finally {
227
+ await fh.close();
228
+ }
229
+ await fsp.rename(tmp, absPath);
230
+ }
231
+ export async function readFile(input) {
232
+ const entry = resolveEntry(input);
233
+ if (entry.ok === false) {
234
+ return entry;
235
+ }
236
+ const resolved = resolveWithinWorkspace(entry.data.workspacePath, input?.path, { mustExist: true });
237
+ if (resolved.ok === false) {
238
+ return resolved;
239
+ }
240
+ try {
241
+ const content = await fsp.readFile(resolved.data.absPath, 'utf8');
242
+ return ok({ path: resolved.data.absPath, content });
243
+ }
244
+ catch (e) {
245
+ return err('internal', e?.message ?? String(e));
246
+ }
247
+ }
248
+ export async function writeFile(input) {
249
+ if (typeof input?.content !== 'string') {
250
+ return err('invalid-input', '`content` is required and must be a string.');
251
+ }
252
+ const entry = resolveEntry(input);
253
+ if (entry.ok === false) {
254
+ return entry;
255
+ }
256
+ const resolved = resolveWithinWorkspace(entry.data.workspacePath, input?.path, { mustExist: false });
257
+ if (resolved.ok === false) {
258
+ return resolved;
259
+ }
260
+ const guard = await assertNotDirty(entry.data, resolved.data.absPath, input.force);
261
+ if (guard.ok === false) {
262
+ return guard;
263
+ }
264
+ const importGuard = await assertImportsPresent(entry.data, resolved.data.absPath, input.content, input.force);
265
+ if (importGuard.ok === false) {
266
+ return importGuard;
267
+ }
268
+ try {
269
+ await atomicWrite(resolved.data.absPath, input.content);
270
+ }
271
+ catch (e) {
272
+ return err('internal', e?.message ?? String(e));
273
+ }
274
+ const render = await triggerRender(entry.data, resolved.data.absPath, input.content);
275
+ return ok({
276
+ path: resolved.data.absPath,
277
+ bytesWritten: Buffer.byteLength(input.content, 'utf8'),
278
+ render,
279
+ });
280
+ }
281
+ function isValidPosition(value) {
282
+ if (typeof value !== 'object' || value === null) {
283
+ return false;
284
+ }
285
+ const v = value;
286
+ return (typeof v.line === 'number' &&
287
+ Number.isInteger(v.line) &&
288
+ v.line >= 0 &&
289
+ typeof v.column === 'number' &&
290
+ Number.isInteger(v.column) &&
291
+ v.column >= 0);
292
+ }
293
+ function comparePositions(a, b) {
294
+ if (a.line !== b.line) {
295
+ return a.line - b.line;
296
+ }
297
+ return a.column - b.column;
298
+ }
299
+ /**
300
+ * Convert a `{ line, column }` position to a UTF-16 character offset into
301
+ * `text`. Columns past end-of-line clamp to the line's length; lines past
302
+ * end-of-file clamp to the file's length. Both behaviours match LSP's
303
+ * forgiving range semantics and match VSCode's `TextDocument.offsetAt`.
304
+ */
305
+ function offsetAt(text, pos) {
306
+ let lineStart = 0;
307
+ let line = 0;
308
+ while (line < pos.line) {
309
+ const nl = text.indexOf('\n', lineStart);
310
+ if (nl === -1) {
311
+ return text.length;
312
+ }
313
+ lineStart = nl + 1;
314
+ line++;
315
+ }
316
+ const lineEnd = (() => {
317
+ const nl = text.indexOf('\n', lineStart);
318
+ return nl === -1 ? text.length : nl;
319
+ })();
320
+ return Math.min(lineStart + pos.column, lineEnd);
321
+ }
322
+ export async function editRange(input) {
323
+ if (typeof input?.newText !== 'string') {
324
+ return err('invalid-input', '`newText` is required and must be a string.');
325
+ }
326
+ if (!isValidPosition(input?.start)) {
327
+ return err('invalid-input', '`start` must be `{ line: number >= 0, column: number >= 0 }`.');
328
+ }
329
+ if (!isValidPosition(input?.end)) {
330
+ return err('invalid-input', '`end` must be `{ line: number >= 0, column: number >= 0 }`.');
331
+ }
332
+ if (comparePositions(input.start, input.end) > 0) {
333
+ return err('invalid-input', '`start` must be at or before `end`.');
334
+ }
335
+ const entry = resolveEntry(input);
336
+ if (entry.ok === false) {
337
+ return entry;
338
+ }
339
+ const resolved = resolveWithinWorkspace(entry.data.workspacePath, input?.path, { mustExist: true });
340
+ if (resolved.ok === false) {
341
+ return resolved;
342
+ }
343
+ const guard = await assertNotDirty(entry.data, resolved.data.absPath, input.force);
344
+ if (guard.ok === false) {
345
+ return guard;
346
+ }
347
+ let original;
348
+ try {
349
+ original = await fsp.readFile(resolved.data.absPath, 'utf8');
350
+ }
351
+ catch (e) {
352
+ return err('internal', e?.message ?? String(e));
353
+ }
354
+ const startOffset = offsetAt(original, input.start);
355
+ const endOffset = offsetAt(original, input.end);
356
+ const next = original.slice(0, startOffset) + input.newText + original.slice(endOffset);
357
+ const importGuard = await assertImportsPresent(entry.data, resolved.data.absPath, next, input.force);
358
+ if (importGuard.ok === false) {
359
+ return importGuard;
360
+ }
361
+ try {
362
+ await atomicWrite(resolved.data.absPath, next);
363
+ }
364
+ catch (e) {
365
+ return err('internal', e?.message ?? String(e));
366
+ }
367
+ const render = await triggerRender(entry.data, resolved.data.absPath, next);
368
+ return ok({
369
+ path: resolved.data.absPath,
370
+ bytesWritten: Buffer.byteLength(next, 'utf8'),
371
+ replacedRange: { start: input.start, end: input.end },
372
+ render,
373
+ });
374
+ }
375
+ // ---------------------------------------------------------------------------
376
+ // list_fluid_files
377
+ // ---------------------------------------------------------------------------
378
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.fluidcad', 'dist', 'build']);
379
+ const FLUID_SUFFIX = '.fluid.js';
380
+ const WALK_FILE_LIMIT = 5000;
381
+ export async function listFluidFiles(input) {
382
+ const entry = resolveEntry(input);
383
+ if (entry.ok === false) {
384
+ return entry;
385
+ }
386
+ const root = (() => {
387
+ try {
388
+ return fs.realpathSync(entry.data.workspacePath);
389
+ }
390
+ catch {
391
+ return path.resolve(entry.data.workspacePath);
392
+ }
393
+ })();
394
+ const files = [];
395
+ try {
396
+ await walk(root, root, files);
397
+ }
398
+ catch (e) {
399
+ return err('internal', e?.message ?? String(e));
400
+ }
401
+ files.sort();
402
+ return ok({ files });
403
+ }
404
+ async function walk(root, dir, out) {
405
+ if (out.length >= WALK_FILE_LIMIT) {
406
+ return;
407
+ }
408
+ let entries;
409
+ try {
410
+ entries = await fsp.readdir(dir, { withFileTypes: true });
411
+ }
412
+ catch {
413
+ return;
414
+ }
415
+ for (const e of entries) {
416
+ if (out.length >= WALK_FILE_LIMIT) {
417
+ return;
418
+ }
419
+ if (e.name.startsWith('.') && e.isDirectory()) {
420
+ // Hidden dirs (`.git`, `.fluidcad`, …) — always skip.
421
+ continue;
422
+ }
423
+ if (e.isDirectory() && SKIP_DIRS.has(e.name)) {
424
+ continue;
425
+ }
426
+ const full = path.join(dir, e.name);
427
+ if (e.isDirectory()) {
428
+ await walk(root, full, out);
429
+ }
430
+ else if (e.isFile() && e.name.endsWith(FLUID_SUFFIX)) {
431
+ out.push(path.relative(root, full));
432
+ }
433
+ }
434
+ }
@@ -0,0 +1,13 @@
1
+ import { type ToolResult } from '../types.ts';
2
+ export type WorkspaceInfo = {
3
+ workspacePath: string;
4
+ port: number;
5
+ pid: number;
6
+ version: string;
7
+ startedAt: string;
8
+ reachable: boolean;
9
+ };
10
+ export type ListWorkspacesOutput = {
11
+ workspaces: WorkspaceInfo[];
12
+ };
13
+ export declare function listWorkspaces(): Promise<ToolResult<ListWorkspacesOutput>>;
@@ -0,0 +1,33 @@
1
+ // Workspace enumeration tool — the agent's entry point for discovery.
2
+ //
3
+ // Returns every running FluidCAD workspace on this machine, with a quick
4
+ // health probe so dead-but-not-yet-pruned entries surface as `reachable: false`
5
+ // instead of being silently dropped.
6
+ import { listLiveInstances } from "../discovery.js";
7
+ import { FluidCadClient } from "../client.js";
8
+ import { ok } from "../types.js";
9
+ export async function listWorkspaces() {
10
+ const entries = listLiveInstances();
11
+ const probes = await Promise.all(entries.map(async (entry) => {
12
+ const client = new FluidCadClient(entry);
13
+ try {
14
+ const health = await client.health();
15
+ return { entry, reachable: health !== null };
16
+ }
17
+ finally {
18
+ // We don't keep clients around past the probe — each tool invocation
19
+ // re-creates them when needed. Pools are cheap; lingering connections
20
+ // are not.
21
+ await client.close().catch(() => { });
22
+ }
23
+ }));
24
+ const workspaces = probes.map(({ entry, reachable }) => ({
25
+ workspacePath: entry.workspacePath,
26
+ port: entry.port,
27
+ pid: entry.pid,
28
+ version: entry.version,
29
+ startedAt: entry.startedAt,
30
+ reachable,
31
+ }));
32
+ return ok({ workspaces });
33
+ }
@@ -0,0 +1,18 @@
1
+ export type { InstanceFile } from '../../server/dist/instance-file.js';
2
+ export type { RegistryEntry } from '../../server/dist/global-registry.js';
3
+ /**
4
+ * Discriminated result type used by every tool handler so the MCP layer can
5
+ * render success/failure consistently and the agent can branch on `code`.
6
+ */
7
+ export type ToolResult<T> = {
8
+ ok: true;
9
+ data: T;
10
+ } | {
11
+ ok: false;
12
+ code: ToolErrorCode;
13
+ message: string;
14
+ details?: unknown;
15
+ };
16
+ export type ToolErrorCode = 'no-server' | 'no-workspace' | 'workspace-not-found' | 'http-error' | 'ws-error' | 'invalid-input' | 'timeout' | 'compile-error' | 'dirty-buffer' | 'missing-imports' | 'internal';
17
+ export declare function ok<T>(data: T): ToolResult<T>;
18
+ export declare function err(code: ToolErrorCode, message: string, details?: unknown): ToolResult<never>;
@@ -0,0 +1,11 @@
1
+ // Types shared across the MCP server.
2
+ //
3
+ // We deliberately re-export the discovery types from the server package
4
+ // rather than redefining them — the contract is owned by `server/src/` and
5
+ // imported via the project reference to the built `.d.ts` output.
6
+ export function ok(data) {
7
+ return { ok: true, data };
8
+ }
9
+ export function err(code, message, details) {
10
+ return { ok: false, code, message, details };
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluidcad",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "description": "Parametric CAD modeling library using javascript",
5
5
  "author": "Marwan Aouida <contact@marwan.dev>",
6
6
  "homepage": "https://fluidcad.io",
@@ -29,7 +29,9 @@
29
29
  "lib/dist/",
30
30
  "server/dist/",
31
31
  "ui/dist/",
32
- "bin/"
32
+ "mcp/dist/",
33
+ "bin/",
34
+ "llm-docs/"
33
35
  ],
34
36
  "exports": {
35
37
  ".": "./lib/dist/index.js",
@@ -40,11 +42,15 @@
40
42
  "./math": "./lib/dist/math/index.js"
41
43
  },
42
44
  "scripts": {
43
- "clean": "rm -rf lib/dist server/dist ui/dist lib/tsconfig.tsbuildinfo server/tsconfig.tsbuildinfo",
45
+ "clean": "rm -rf lib/dist server/dist ui/dist mcp/dist lib/tsconfig.tsbuildinfo server/tsconfig.tsbuildinfo mcp/tsconfig.tsbuildinfo",
44
46
  "build:lib": "tsc -p lib/tsconfig.json",
45
47
  "build:server": "tsc -p server/tsconfig.json",
46
48
  "build:ui": "vite build --config ui/vite.config.ts",
47
- "build": "npm run clean && npm run build:lib && npm run build:server && npm run build:ui",
49
+ "build:mcp": "tsc -p mcp/tsconfig.json",
50
+ "build:llm-type-docs": "tsx scripts/build-llm-type-docs.ts",
51
+ "build:llm-docs": "npm run build:llm-type-docs && tsx scripts/build-llm-docs.ts",
52
+ "check:llm-docs": "npm run build:llm-docs && tsx scripts/check-llm-docs-coverage.ts && vitest run lib/tests/llm-docs-examples.test.ts",
53
+ "build": "npm run clean && npm run build:lib && npm run build:server && npm run build:ui && npm run build:mcp && npm run build:llm-docs",
48
54
  "prepublishOnly": "npm run build",
49
55
  "dev:lib": "tsc -p lib/tsconfig.json --watch",
50
56
  "dev:server": "node --experimental-transform-types --no-warnings server/src/index.ts",
@@ -55,32 +61,40 @@
55
61
  "release": "npx bumpp patch package.json extension/vscode/package.json"
56
62
  },
57
63
  "dependencies": {
64
+ "@modelcontextprotocol/sdk": "^1.29.0",
58
65
  "chokidar": "^5.0.0",
59
66
  "color-name": "^2.1.0",
67
+ "commander": "^14.0.3",
60
68
  "express": "^5.2.1",
61
69
  "occjs-wrapper": "npm:occjs-fluidcad@8.0.0",
70
+ "open": "^11.0.0",
62
71
  "stacktrace-parser": "^0.1.11",
63
72
  "tree-sitter-wasms": "^0.1.13",
64
73
  "tsx": "^4.21.0",
74
+ "undici": "^8.3.0",
65
75
  "vite": "^8.0.8",
66
76
  "web-tree-sitter": "^0.24.7",
67
- "ws": "^8.18.0"
77
+ "ws": "^8.18.0",
78
+ "zod": "^4.4.3"
68
79
  },
69
80
  "devDependencies": {
70
81
  "@tabler/icons": "^3.40.0",
71
82
  "@tailwindcss/vite": "^4.2.2",
72
83
  "@types/express": "^5.0.6",
84
+ "@types/js-yaml": "^4.0.9",
73
85
  "@types/node": "^22.14.1",
74
86
  "@types/three": "^0.180.0",
75
87
  "@types/ws": "^8.18.0",
76
88
  "camera-controls": "^3.1.2",
77
89
  "daisyui": "^5.5.19",
78
90
  "eslint": "^9.24.0",
91
+ "js-yaml": "^4.1.1",
79
92
  "lodash": "^4.17.21",
80
93
  "prettier": "^3.5.3",
81
94
  "tailwindcss": "^4.2.2",
82
95
  "three": "^0.180.0",
83
96
  "three-viewport-gizmo": "^2.2.0",
97
+ "ts-morph": "^28.0.0",
84
98
  "typescript": "^5.9.2",
85
99
  "vitest": "^4.0.17"
86
100
  }
@@ -1,3 +1,38 @@
1
+ type TSNode = {
2
+ type: string;
3
+ text: string;
4
+ startPosition: {
5
+ row: number;
6
+ column: number;
7
+ };
8
+ endPosition: {
9
+ row: number;
10
+ column: number;
11
+ };
12
+ startIndex: number;
13
+ endIndex: number;
14
+ parent: TSNode | null;
15
+ namedChildren: TSNode[];
16
+ namedChild(i: number): TSNode | null;
17
+ childForFieldName(name: string): TSNode | null;
18
+ descendantForPosition(pos: {
19
+ row: number;
20
+ column: number;
21
+ }): TSNode | null;
22
+ };
23
+ type TSTree = {
24
+ rootNode: TSNode;
25
+ };
26
+ type TSParser = {
27
+ setLanguage(lang: any): void;
28
+ parse(code: string): TSTree;
29
+ };
30
+ /**
31
+ * Public alias for `getParser()` so other modules in this package (e.g.
32
+ * `lint-fluid-js.ts`) can reuse the same wasm-backed parser instance instead
33
+ * of loading the JavaScript grammar twice.
34
+ */
35
+ export declare function getJavaScriptParser(): Promise<TSParser>;
1
36
  export type BreakpointEditResult = {
2
37
  newCode: string;
3
38
  breakpointLine: number | null;
@@ -19,3 +54,4 @@ export declare function addPick(code: string, sourceLine: number): Promise<CodeE
19
54
  export declare function removePick(code: string, sourceLine: number): Promise<CodeEditResult>;
20
55
  export declare function removePoint(code: string, sourceLine: number, point: [number, number]): Promise<CodeEditResult>;
21
56
  export declare function setPickPoints(code: string, sourceLine: number, points: [number, number][]): Promise<CodeEditResult>;
57
+ export {};
@@ -5,6 +5,14 @@ async function loadTreeSitter() {
5
5
  return mod.default;
6
6
  }
7
7
  let parser = null;
8
+ /**
9
+ * Public alias for `getParser()` so other modules in this package (e.g.
10
+ * `lint-fluid-js.ts`) can reuse the same wasm-backed parser instance instead
11
+ * of loading the JavaScript grammar twice.
12
+ */
13
+ export async function getJavaScriptParser() {
14
+ return getParser();
15
+ }
8
16
  async function getParser() {
9
17
  if (parser) {
10
18
  return parser;