lecodes-cli 0.18.2 → 0.19.2

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 (224) hide show
  1. package/dist/index.js +1866 -301
  2. package/package.json +14 -12
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/core/Aspect.ts +0 -512
  86. package/runtime/sdk/core/InspectorUI.ts +0 -212
  87. package/runtime/sdk/core/color.ts +0 -66
  88. package/runtime/sdk/core/compWrite.ts +0 -42
  89. package/runtime/sdk/core/events.ts +0 -38
  90. package/runtime/sdk/core/fields.ts +0 -120
  91. package/runtime/sdk/core/registry.ts +0 -23
  92. package/runtime/sdk/core/signals.ts +0 -277
  93. package/runtime/sdk/core/time.ts +0 -81
  94. package/runtime/sdk/g2/Camera2D.ts +0 -40
  95. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  96. package/runtime/sdk/g2/Node2D.ts +0 -267
  97. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  98. package/runtime/sdk/g2/Physics2D.ts +0 -346
  99. package/runtime/sdk/g2/Scene2D.ts +0 -209
  100. package/runtime/sdk/g2/Shape2D.ts +0 -259
  101. package/runtime/sdk/g2/Sprite.ts +0 -89
  102. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  103. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  104. package/runtime/sdk/g2/Texture2D.ts +0 -47
  105. package/runtime/sdk/g2/Tilemap.ts +0 -41
  106. package/runtime/sdk/g2/Tileset.ts +0 -71
  107. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  108. package/runtime/sdk/g2/autotile.ts +0 -433
  109. package/runtime/sdk/g2/cells.ts +0 -91
  110. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  111. package/runtime/sdk/g2/groups2d.ts +0 -106
  112. package/runtime/sdk/g2/loop.ts +0 -50
  113. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  114. package/runtime/sdk/g2/touch.ts +0 -83
  115. package/runtime/sdk/gl/Camera.ts +0 -160
  116. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  117. package/runtime/sdk/gl/CharacterController.ts +0 -238
  118. package/runtime/sdk/gl/Gearbox.ts +0 -212
  119. package/runtime/sdk/gl/Geometry.ts +0 -279
  120. package/runtime/sdk/gl/IK.ts +0 -193
  121. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  122. package/runtime/sdk/gl/Light.ts +0 -99
  123. package/runtime/sdk/gl/Lightmap.ts +0 -179
  124. package/runtime/sdk/gl/Material.ts +0 -245
  125. package/runtime/sdk/gl/Mesh.ts +0 -83
  126. package/runtime/sdk/gl/Model.ts +0 -64
  127. package/runtime/sdk/gl/Node.ts +0 -350
  128. package/runtime/sdk/gl/Noise.ts +0 -30
  129. package/runtime/sdk/gl/Particles.ts +0 -676
  130. package/runtime/sdk/gl/Physics.ts +0 -222
  131. package/runtime/sdk/gl/Plane.ts +0 -53
  132. package/runtime/sdk/gl/Ray.ts +0 -16
  133. package/runtime/sdk/gl/Scene.ts +0 -479
  134. package/runtime/sdk/gl/Shape.ts +0 -377
  135. package/runtime/sdk/gl/Texture.ts +0 -46
  136. package/runtime/sdk/gl/Trigger.ts +0 -45
  137. package/runtime/sdk/gl/Vehicle.ts +0 -473
  138. package/runtime/sdk/gl/Wheel.ts +0 -240
  139. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  140. package/runtime/sdk/gl/animation/Animator.ts +0 -87
  141. package/runtime/sdk/gl/animation/Layer.ts +0 -29
  142. package/runtime/sdk/gl/animation/Loop.ts +0 -25
  143. package/runtime/sdk/gl/animation/Playback.ts +0 -43
  144. package/runtime/sdk/gl/animation/core.ts +0 -294
  145. package/runtime/sdk/gl/controls.ts +0 -95
  146. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  147. package/runtime/sdk/gl/scenarios.ts +0 -291
  148. package/runtime/sdk/gl/state.ts +0 -6
  149. package/runtime/sdk/gl/touch.ts +0 -68
  150. package/runtime/sdk/inject.ts +0 -186
  151. package/runtime/sdk/math/Mathf.ts +0 -118
  152. package/runtime/sdk/math/mat4.ts +0 -278
  153. package/runtime/sdk/math/quat.ts +0 -232
  154. package/runtime/sdk/math/vec.ts +0 -255
  155. package/runtime/sdk/plugins/camera.ts +0 -81
  156. package/runtime/sdk/plugins/geolocation.ts +0 -123
  157. package/runtime/sdk/plugins/oauth.ts +0 -61
  158. package/runtime/sdk/plugins/permission.ts +0 -7
  159. package/runtime/sdk/plugins/push.ts +0 -132
  160. package/runtime/sdk/plugins/qr.ts +0 -73
  161. package/runtime/sdk/plugins/service.ts +0 -47
  162. package/runtime/sdk/runtime/app.ts +0 -101
  163. package/runtime/sdk/runtime/appEvents.ts +0 -54
  164. package/runtime/sdk/runtime/channel.ts +0 -61
  165. package/runtime/sdk/runtime/clipboard.ts +0 -20
  166. package/runtime/sdk/runtime/datetime.ts +0 -329
  167. package/runtime/sdk/runtime/device.ts +0 -293
  168. package/runtime/sdk/runtime/fetch.ts +0 -77
  169. package/runtime/sdk/runtime/files.ts +0 -23
  170. package/runtime/sdk/runtime/input.ts +0 -175
  171. package/runtime/sdk/runtime/media.ts +0 -111
  172. package/runtime/sdk/runtime/misc.ts +0 -16
  173. package/runtime/sdk/runtime/net.ts +0 -36
  174. package/runtime/sdk/runtime/rpc.ts +0 -218
  175. package/runtime/sdk/runtime/service.ts +0 -83
  176. package/runtime/sdk/runtime/share.ts +0 -9
  177. package/runtime/sdk/runtime/storage.ts +0 -13
  178. package/runtime/sdk/runtime/touch.ts +0 -76
  179. package/runtime/sdk/scene/defineScene.ts +0 -1227
  180. package/runtime/sdk/scene/editorPlugins.ts +0 -92
  181. package/runtime/sdk/scene/gizmos.ts +0 -148
  182. package/runtime/sdk/scene/grammar.ts +0 -120
  183. package/runtime/sdk/scene/material.ts +0 -188
  184. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  185. package/runtime/sdk/server/auth/global.ts +0 -80
  186. package/runtime/sdk/server/auth/host.ts +0 -318
  187. package/runtime/sdk/server/auth/models.ts +0 -83
  188. package/runtime/sdk/server/auth/types.ts +0 -50
  189. package/runtime/sdk/server/channel.ts +0 -56
  190. package/runtime/sdk/server/context.ts +0 -36
  191. package/runtime/sdk/server/db/defineDb.ts +0 -237
  192. package/runtime/sdk/server/db/fields.ts +0 -132
  193. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  194. package/runtime/sdk/server/db/index.ts +0 -7
  195. package/runtime/sdk/server/db/marci/query.ts +0 -412
  196. package/runtime/sdk/server/db/types.ts +0 -202
  197. package/runtime/sdk/server/errors.ts +0 -12
  198. package/runtime/sdk/server/host.ts +0 -74
  199. package/runtime/sdk/server/inject.ts +0 -13
  200. package/runtime/sdk/server/runtime.ts +0 -133
  201. package/runtime/sdk/server/validate.ts +0 -87
  202. package/runtime/sdk/ui/NativeView.ts +0 -142
  203. package/runtime/sdk/ui/UI.ts +0 -39
  204. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  205. package/runtime/sdk/ui/UIButton.ts +0 -101
  206. package/runtime/sdk/ui/UIContainer.ts +0 -60
  207. package/runtime/sdk/ui/UIImage.ts +0 -83
  208. package/runtime/sdk/ui/UIInput.ts +0 -185
  209. package/runtime/sdk/ui/UIModal.ts +0 -139
  210. package/runtime/sdk/ui/UINode.ts +0 -826
  211. package/runtime/sdk/ui/UIPager.ts +0 -362
  212. package/runtime/sdk/ui/UIPopover.ts +0 -100
  213. package/runtime/sdk/ui/UIScreen.ts +0 -123
  214. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  215. package/runtime/sdk/ui/UISpacer.ts +0 -14
  216. package/runtime/sdk/ui/UITabs.ts +0 -236
  217. package/runtime/sdk/ui/UIText.ts +0 -51
  218. package/runtime/sdk/ui/UIVideo.ts +0 -88
  219. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  220. package/runtime/sdk/ui/UIWidget.ts +0 -127
  221. package/runtime/sdk/ui/fonts.ts +0 -13
  222. package/runtime/sdk/ui/presentable.ts +0 -117
  223. package/runtime/sdk/ui/router.ts +0 -132
  224. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,863 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from "node:fs"
2
+ import { basename, join } from "node:path"
3
+ import { findProjectRoot, readManifest, type Manifest } from "../manifest"
4
+ import { compileDesignScreen } from "../compile/designCompile"
5
+ import { openBrowser } from "../browserAuth"
6
+ import { CLAUDE_MD, DESIGN_MACROS_DTS, HOME_SCREEN_TS, META_JSON, META_JSON_DESKTOP, README_HINT, SPEC_MD, TABS_TS, TOKENS_TS } from "./designTemplates"
7
+ import { CliError, c, flagBool, flagStr, log, logErr, note, warnErr, type Args } from "../util"
8
+ import { loadConfig, normalizeApiUrl, requireApiUrl, requireToken } from "../config"
9
+ import {
10
+ createDesignComment, deleteDesignComment, deleteDesignCommentMessage, disableDesignShare,
11
+ editDesignCommentMessage, enableDesignShare, listDesignComments, moveDesignComment,
12
+ replyDesignComment, resolveDesignComment, setDesignCommentsOpen, type DesignCommentsSnapshot,
13
+ } from "../api"
14
+ import type { CommentOrigin, CommentThread, MirrorSummary } from "lecodes-design/server"
15
+ import { materializeTypesLocal, writeTsconfig } from "../types"
16
+ import { readMetaFrames } from "../designMeta"
17
+
18
+ /*
19
+ * `lecodes design` — the LeCodes Design canvas: prototype an app as live screens + a flow graph.
20
+ *
21
+ * lecodes design [serve] start the dev server + browser canvas (needs `lecodes-design`)
22
+ * --no-sync don't reconcile comments with the platform while serving
23
+ * lecodes design init scaffold the design folder (screens/, shared/, meta.json, spec.md, CLAUDE.md)
24
+ * lecodes design snapshot [--state <s> | --all-states] render screens headless to JSON/PNG (needs `lecodes-renderer`)
25
+ * lecodes design arrange [--new] auto-layout the flow graph (--new: only place unplaced screens)
26
+ * lecodes design check static coherence: spec.md links, flow warnings, untagged screens
27
+ * lecodes design share [--off] toggle the public share link (design must be pushed first)
28
+ * --comments <on|off> let link visitors leave comments
29
+ * lecodes design comments [--all] the board's review comments, grouped by screen (open first)
30
+ * lecodes design comments add "<text>" [--screen <id>] [--state <s>] [--element <name>] [--ai]
31
+ * lecodes design comments reply <number> "<text>" [--ai]
32
+ * lecodes design comments resolve <number> [--reply "<text>"] [--reopen] [--ai]
33
+ * lecodes design comments pull mirror the platform's open threads into design/comments/
34
+ * (writes go the other way with `lecodes push`, never on their own)
35
+ *
36
+ * The design folder (default `design/`) holds one file per screen (screens/<id>.ts default-exports
37
+ * a UIScreen), shared tokens/components (shared/), and meta.json — the human-owned graph of
38
+ * positions, descriptions and edges. Screens compile per-file through the normal project pipeline
39
+ * and render via canvas-ui, live in the browser and headless for AI feedback.
40
+ */
41
+
42
+ const DEFAULT_DIR = "design"
43
+ const DEFAULT_PORT = 4477
44
+
45
+ type DesignContext = {
46
+ root: string
47
+ /** Null in standalone mode: the design folder lives outside any cloned lecodes project. */
48
+ manifest: Manifest | null
49
+ name: string
50
+ dirName: string
51
+ designDir: string
52
+ designKey: string
53
+ }
54
+
55
+ const getContext = (args: Args): DesignContext => {
56
+ // Inside a cloned project the design folder sits at its root; anywhere else the tool runs
57
+ // standalone against the current directory (no login, no server project needed).
58
+ let root: string
59
+ let manifest: Manifest | null
60
+ try {
61
+ root = findProjectRoot(process.cwd())
62
+ manifest = readManifest(root)
63
+ } catch {
64
+ root = process.cwd()
65
+ manifest = null
66
+ }
67
+ const dirName = (flagStr(args, "dir") ?? DEFAULT_DIR).replace(/[/\\]+$/, "")
68
+ return {
69
+ root, manifest,
70
+ name: manifest?.name ?? basename(root),
71
+ dirName,
72
+ designDir: join(root, dirName),
73
+ designKey: "/" + dirName.replace(/\\/g, "/"),
74
+ }
75
+ }
76
+
77
+ /** The design canvas package, lazily: it's an optional dev dependency of the project, never a
78
+ * dependency of the CLI. Also asserts the folder exists, since every caller needs both.
79
+ *
80
+ * `needs` names exports the caller will actually call. The two packages are versioned and installed
81
+ * independently, so a project can easily hold a CLI newer than its `lecodes-design` — without this
82
+ * check that surfaces as `undefined is not a function` halfway through a command. */
83
+ const loadDesignServer = async (
84
+ ctx: DesignContext,
85
+ needs: (keyof typeof import("lecodes-design/server"))[] = [],
86
+ ): Promise<typeof import("lecodes-design/server")> => {
87
+ if (!existsSync(ctx.designDir)) {
88
+ throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`)
89
+ }
90
+ let mod: typeof import("lecodes-design/server")
91
+ try {
92
+ mod = await import("lecodes-design/server")
93
+ } catch {
94
+ throw new CliError("The design canvas isn't installed. Run: npm install -g lecodes-design")
95
+ }
96
+ const missing = needs.filter((name) => typeof mod[name] !== "function")
97
+ if (missing.length) {
98
+ throw new CliError(
99
+ `This needs a newer lecodes-design — the installed one has no \`${missing[0]}\`. Run: npm install -g lecodes-design@latest`)
100
+ }
101
+ return mod
102
+ }
103
+
104
+ /** The platform link a comment sync needs. A standalone design folder has no database behind it —
105
+ * there `comments/` IS the store, so saying that beats prompting for a login. */
106
+ const requirePlatform = (ctx: DesignContext, verb: string) => {
107
+ if (!ctx.manifest) {
108
+ throw new CliError(
109
+ `\`lecodes design comments ${verb}\` needs a cloned project. This folder is standalone, so ` +
110
+ `${ctx.dirName}/comments/ is already the only store — there is nothing to sync with.`)
111
+ }
112
+ const config = loadConfig()
113
+ return { apiUrl: requireApiUrl(config), token: requireToken(config), uuid: ctx.manifest.uuid }
114
+ }
115
+
116
+ /** The same link, but optional — null when standalone or not logged in. Used where syncing is a
117
+ * bonus rather than the point (the dev server's mirror, prune). */
118
+ const optionalPlatform = (ctx: DesignContext) => {
119
+ if (!ctx.manifest) return null
120
+ const config = loadConfig()
121
+ if (!config.apiUrl || !config.token) return null
122
+ return { apiUrl: normalizeApiUrl(config.apiUrl), token: config.token, uuid: ctx.manifest.uuid }
123
+ }
124
+
125
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e))
126
+
127
+ /** First non-empty line, for one-line echoes of a comment body. */
128
+ const firstLine = (body: string, max = 64): string => {
129
+ const line = body.split("\n").find((l) => l.trim())?.trim() ?? ""
130
+ return line.length > max ? line.slice(0, max - 1) + "…" : line
131
+ }
132
+
133
+ /** Screen ids from <designDir>/screens/*.ts. */
134
+ const listScreens = (designDir: string): string[] => {
135
+ const dir = join(designDir, "screens")
136
+ if (!existsSync(dir)) return []
137
+ return readdirSync(dir)
138
+ .filter((f) => f.endsWith(".ts") && !f.startsWith("."))
139
+ .map((f) => f.slice(0, -3))
140
+ .sort()
141
+ }
142
+
143
+ // ---- init --------------------------------------------------------------------------------------
144
+
145
+ /** Register the design server as a project-scoped MCP server so Claude Code auto-discovers it.
146
+ * Merges into an existing .mcp.json (never clobbers a hand-broken one). */
147
+ const writeMcpConfig = (ctx: DesignContext, port: number) => {
148
+ const path = join(ctx.root, ".mcp.json")
149
+ const entry = { type: "http", url: `http://localhost:${port}/mcp` }
150
+ let config: { mcpServers?: Record<string, unknown> } = {}
151
+ if (existsSync(path)) {
152
+ try {
153
+ config = JSON.parse(readFileSync(path, "utf8"))
154
+ } catch {
155
+ warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-design server by hand.")
156
+ return
157
+ }
158
+ }
159
+ config.mcpServers ??= {}
160
+ const existing = config.mcpServers["lecodes-design"] as { url?: string } | undefined
161
+ if (existing?.url === entry.url) { note("kept .mcp.json (lecodes-design MCP server)"); return }
162
+ config.mcpServers["lecodes-design"] = entry
163
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
164
+ log(`${c.green(existing ? "updated" : "created")} .mcp.json (lecodes-design MCP → ${entry.url})`)
165
+ }
166
+
167
+ const init = async (ctx: DesignContext, args: Args) => {
168
+ // --desktop: scaffold a desktop-frame board (meta.defaultSize [1280, 800]; every consumer —
169
+ // board tiles, arrange, snapshots, render_screen — resolves frames from it).
170
+ const files: [string, string][] = [
171
+ ["meta.json", flagBool(args, "desktop") ? META_JSON_DESKTOP : META_JSON],
172
+ ["spec.md", SPEC_MD],
173
+ ["README.md", README_HINT],
174
+ ["CLAUDE.md", CLAUDE_MD],
175
+ [join("shared", "tokens.ts"), TOKENS_TS],
176
+ [join("shared", "tabs.ts"), TABS_TS],
177
+ [join("screens", "home.ts"), HOME_SCREEN_TS],
178
+ ]
179
+ mkdirSync(join(ctx.designDir, "screens"), { recursive: true })
180
+ mkdirSync(join(ctx.designDir, "shared"), { recursive: true })
181
+ let created = 0
182
+ for (const [rel, content] of files) {
183
+ const abs = join(ctx.designDir, rel)
184
+ if (existsSync(abs)) { note(`kept ${ctx.dirName}/${rel.replace(/\\/g, "/")}`); continue }
185
+ writeFileSync(abs, content)
186
+ log(`${c.green("created")} ${ctx.dirName}/${rel.replace(/\\/g, "/")}`)
187
+ created++
188
+ }
189
+
190
+ // Neither of these is a project file: snapshots are local render artifacts, and comments/
191
+ // mirrors rows the platform already owns (pushing it back would just duplicate the database).
192
+ const ignorePath = join(ctx.root, ".lecodesignore")
193
+ const ignoreLines = [ `${ctx.dirName}/.snapshots/`, `${ctx.dirName}/comments/` ]
194
+ const current = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
195
+ const present = new Set(current.split(/\r?\n/))
196
+ const missing = ignoreLines.filter((line) => !present.has(line))
197
+ if (missing.length) {
198
+ appendFileSync(ignorePath, (current && !current.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n")
199
+ for (const line of missing) log(`${c.green("ignored")} ${line} (in .lecodesignore)`)
200
+ }
201
+
202
+ // Let Claude Code reach the running board over MCP (render/check/state/activity tools).
203
+ writeMcpConfig(ctx, Number(flagStr(args, "port")) || DEFAULT_PORT)
204
+
205
+ // IDE types: materialize the SDK's import-free type surface + a design-scoped tsconfig so screens
206
+ // typecheck honestly (globals like UIScreen / UINode / UINodeChild resolve), standalone or in a
207
+ // project. .lecodes/ and tsconfig.json are skipped by the push scanner.
208
+ const wroteTypes = materializeTypesLocal(ctx.designDir)
209
+ writeTsconfig(ctx.designDir)
210
+ // Design-only compiler globals (assetIcon) that aren't in the shipped SDK type surface. Written
211
+ // alongside the bundle so the editor resolves them; rewritten every init to pick up upgrades.
212
+ mkdirSync(join(ctx.designDir, ".lecodes", "types"), { recursive: true })
213
+ writeFileSync(join(ctx.designDir, ".lecodes", "types", "design-macros.d.ts"), DESIGN_MACROS_DTS)
214
+ log(`${c.green("created")} ${ctx.dirName}/tsconfig.json${wroteTypes ? ` + ${ctx.dirName}/.lecodes/types/` : ""} (IDE types)`)
215
+ if (!wroteTypes) warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.")
216
+
217
+ log("")
218
+ log(created ? "Design canvas ready." : "Design canvas already initialized.")
219
+ log(` ${c.bold("lecodes design")} start the canvas (browser)`)
220
+ log(` ${c.bold("lecodes design snapshot")} render screens headless (for AI feedback)`)
221
+ log(` Point Claude Code at ${c.bold(ctx.dirName + "/CLAUDE.md")} — it documents the conventions.`)
222
+ }
223
+
224
+ // ---- snapshot ----------------------------------------------------------------------------------
225
+
226
+ const snapshot = async (ctx: DesignContext, args: Args) => {
227
+ const all = listScreens(ctx.designDir)
228
+ if (all.length === 0) throw new CliError(`No screens in ${ctx.dirName}/screens/ — run "lecodes design init" first.`)
229
+ const requested = args._.slice(1)
230
+ for (const id of requested) {
231
+ if (!all.includes(id)) throw new CliError(`No screen "${id}" (have: ${all.join(", ")}).`)
232
+ }
233
+ const targets = requested.length ? requested : all
234
+
235
+ const stateFlag = flagStr(args, "state")
236
+ const allStates = flagBool(args, "all-states")
237
+ if (stateFlag !== undefined && allStates) throw new CliError("Use either --state <name> or --all-states, not both.")
238
+
239
+ let renderer: typeof import("lecodes-renderer/headless")
240
+ try {
241
+ renderer = await import("lecodes-renderer/headless")
242
+ } catch {
243
+ throw new CliError("The renderer isn't installed. Run: npm install -g lecodes-renderer")
244
+ }
245
+
246
+ // Enumerating / validating states parses the screen source — that discovery lives in the design
247
+ // package. The canonical-only path (no flag) needs no discovery, so base snapshot stays lean.
248
+ let discover: ((source: string) => { states: string[], canonical: string }) | null = null
249
+ if (stateFlag !== undefined || allStates) {
250
+ try {
251
+ const ds = await import("lecodes-design/server")
252
+ discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
253
+ } catch {
254
+ throw new CliError("Rendering specific states needs the design canvas. Run: npm install -g lecodes-design")
255
+ }
256
+ }
257
+
258
+ // A stuck screen (runaway loop) shouldn't hang the CLI forever.
259
+ const killer = setTimeout(() => {
260
+ warnErr("Snapshot timed out after 120s.")
261
+ process.exit(1)
262
+ }, 120000)
263
+ killer.unref?.()
264
+
265
+ const png = flagBool(args, "png") || flagStr(args, "png") !== undefined
266
+ const frames = readMetaFrames(ctx.designDir)
267
+ const outDir = flagStr(args, "out-dir") ?? join(ctx.designDir, ".snapshots")
268
+ const single = targets.length === 1 && requested.length === 1 && !png && !allStates && !flagStr(args, "out-dir")
269
+ const onConsole = flagBool(args, "logs")
270
+ ? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
271
+ : undefined
272
+
273
+ if (!single) mkdirSync(outDir, { recursive: true })
274
+
275
+ for (const id of targets) {
276
+ const [ width, height ] = frames.screens[id]?.size ?? frames.defaultSize
277
+
278
+ // What to render, and the file base(s) for each. `undefined` state = a bare (canonical) render.
279
+ // The canonical state also writes the unsuffixed `<id>` for backward compatibility.
280
+ let plan: { state?: string, bases: string[] }[] = [{ state: undefined, bases: [id] }]
281
+ if (discover) {
282
+ const { states, canonical } = discover(readFileSync(join(ctx.designDir, "screens", `${id}.ts`), "utf8"))
283
+ if (stateFlag !== undefined) {
284
+ if (!states.includes(stateFlag)) {
285
+ if (requested.length === 1) throw new CliError(`No state "${stateFlag}" on ${id} (has: ${states.join(", ")}).`)
286
+ warnErr(`${id}: no state "${stateFlag}" (has: ${states.join(", ")}) — skipped.`)
287
+ continue
288
+ }
289
+ plan = [{ state: stateFlag, bases: [`${id}@${stateFlag}`] }]
290
+ } else {
291
+ plan = states.map((st) => ({ state: st, bases: st === canonical ? [`${id}@${st}`, id] : [`${id}@${st}`] }))
292
+ }
293
+ }
294
+
295
+ for (const { state, bases } of plan) {
296
+ const tag = state ? `${id}@${state}` : id
297
+ note(`Rendering ${tag}…`)
298
+ const js = await compileDesignScreen({
299
+ root: ctx.root, manifest: ctx.manifest, name: ctx.name, designDir: ctx.designDir, designKey: ctx.designKey, screenId: id, state, assets: "disk",
300
+ })
301
+ const result = await renderer.renderToJson(js, { width, height, localAssets: true, onConsole })
302
+ for (const w of result.warnings) warnErr(`${tag}: ${w}`)
303
+
304
+ if (single) {
305
+ const text = result.text.endsWith("\n") ? result.text : result.text + "\n"
306
+ process.stdout.write(text)
307
+ return
308
+ }
309
+ const pngResult = png ? await renderer.renderToPng(js, { width, height, localAssets: true, scale: 2, onConsole }) : null
310
+ if (pngResult) for (const w of pngResult.warnings) warnErr(`${tag}: ${w}`)
311
+
312
+ for (const base of bases) {
313
+ writeFileSync(join(outDir, `${base}.json`), result.text)
314
+ log(`${c.green("✓")} ${tag} → ${join(outDir, `${base}.json`)}`)
315
+ if (pngResult) {
316
+ writeFileSync(join(outDir, `${base}.png`), pngResult.png)
317
+ log(`${c.green("✓")} ${tag} → ${join(outDir, `${base}.png`)}`)
318
+ }
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ // ---- serve -------------------------------------------------------------------------------------
325
+
326
+ const serve = async (ctx: DesignContext, args: Args) => {
327
+ const designServer = await loadDesignServer(ctx)
328
+
329
+ const port = Number(flagStr(args, "port")) || DEFAULT_PORT
330
+
331
+ // The MCP render_screen tool renders headless through lecodes-renderer, injected here so the
332
+ // design server keeps no dependency on it. Absent renderer → the tool returns an install hint.
333
+ let renderer: typeof import("lecodes-renderer/headless") | null = null
334
+ try { renderer = await import("lecodes-renderer/headless") } catch { renderer = null }
335
+ const renderScreen = renderer
336
+ ? async (id: string, format: "json" | "png" | "both", state?: string) => {
337
+ const r = renderer!
338
+ const js = await compileDesignScreen({
339
+ root: ctx.root, manifest: ctx.manifest, name: ctx.name, designDir: ctx.designDir, designKey: ctx.designKey, screenId: id, state, assets: "disk",
340
+ })
341
+ const frames = readMetaFrames(ctx.designDir)
342
+ const [width, height] = frames.screens[id]?.size ?? frames.defaultSize
343
+ const warnings: string[] = []
344
+ let json: string | undefined
345
+ let png: Buffer | undefined
346
+ if (format === "json" || format === "both") {
347
+ const res = await r.renderToJson(js, { width, height, localAssets: true })
348
+ json = res.text
349
+ warnings.push(...res.warnings)
350
+ }
351
+ if (format === "png" || format === "both") {
352
+ const res = await r.renderToPng(js, { width, height, localAssets: true, scale: 2 })
353
+ png = res.png
354
+ warnings.push(...res.warnings)
355
+ }
356
+ return { json, png, warnings }
357
+ }
358
+ : undefined
359
+
360
+ const server = await designServer.startDesignServer({
361
+ root: ctx.root,
362
+ designDir: ctx.designDir,
363
+ name: ctx.name,
364
+ port,
365
+ log: note,
366
+ // Who owns the conversation: linked, the files mirror the platform's open threads and local
367
+ // writes queue for `lecodes push`; standalone, these files are the record.
368
+ linked: !!optionalPlatform(ctx),
369
+ compileScreen: async (id: string, state?: string) => ({
370
+ js: await compileDesignScreen({
371
+ root: ctx.root, manifest: ctx.manifest, name: ctx.name, designDir: ctx.designDir, designKey: ctx.designKey, screenId: id, state, assets: "server",
372
+ }),
373
+ warnings: [],
374
+ }),
375
+ renderScreen,
376
+ })
377
+
378
+ // Reconcile comments with the platform while we run, so review feedback from the share link shows
379
+ // up on the board (and in comments/) without a manual pull; answers go back with `lecodes push`.
380
+ // `--no-sync` opts out.
381
+ const sync = flagBool(args, "no-sync") ? null : startCommentSync(ctx, designServer)
382
+
383
+ log(`${c.bold("LeCodes Design")} — ${ctx.name}`)
384
+ log(`Canvas: ${c.bold(server.url)}`)
385
+ log(`MCP: ${c.bold(server.url + "/mcp")}${renderer ? "" : c.dim(" (render_screen off — npm install -g lecodes-renderer)")}`)
386
+ log(`Watching: ${ctx.dirName}/ (screens hot-reload on save; Ctrl+C to stop)`)
387
+ if (sync) log(`Comments: ${c.dim("in sync with the platform — replies and resolves go back automatically")}`)
388
+ if (!flagBool(args, "no-open")) openBrowser(server.url)
389
+ }
390
+
391
+ // ---- arrange -----------------------------------------------------------------------------------
392
+
393
+ /**
394
+ * Auto-layout the flow graph in meta.json. Full arrange re-lays-out every screen and always
395
+ * applies (the board offers Undo; the structure-aware score delta is printed); `--new` places only
396
+ * screens that have no explicit `pos` yet and never moves the rest — the mode Claude uses after
397
+ * adding screens. If the dev server is running it picks up the meta.json change and refreshes.
398
+ */
399
+ const arrange = async (ctx: DesignContext, args: Args) => {
400
+ const designServer = await loadDesignServer(ctx)
401
+ const onlyNew = flagBool(args, "new")
402
+ const summary = designServer.arrangeDesign(ctx.designDir, { onlyNew })
403
+ const score = summary.score ? ` (score ${summary.score.before.toFixed(0)} → ${summary.score.after.toFixed(0)}, lower is better)` : ""
404
+ if (!summary.changed) {
405
+ log(onlyNew ? "No unplaced screens — nothing to arrange." : `Already in its arranged shape — nothing moved${score}.`)
406
+ return
407
+ }
408
+ log(`${c.green("✓")} ${onlyNew ? "Placed" : "Arranged"} ${summary.moved} screen${summary.moved === 1 ? "" : "s"} in ${ctx.dirName}/meta.json${score}.`)
409
+ }
410
+
411
+ // ---- share -------------------------------------------------------------------------------------
412
+
413
+ /**
414
+ * Toggle the project's public design share link on the platform. The design must already be pushed
415
+ * (`lecodes push`) — this only flips the link, it doesn't upload anything. `--off` revokes it;
416
+ * `--comments on|off` flips whether link visitors may WRITE comments (reading is never affected).
417
+ */
418
+ const share = async (ctx: DesignContext, args: Args) => {
419
+ if (!ctx.manifest) {
420
+ throw new CliError("`lecodes design share` needs a cloned project. Run it inside one, and push the design first (lecodes push).")
421
+ }
422
+ const config = loadConfig()
423
+ const apiUrl = requireApiUrl(config)
424
+ const token = requireToken(config)
425
+ const uuid = ctx.manifest.uuid
426
+
427
+ const commentsFlag = flagStr(args, "comments")
428
+ if (commentsFlag !== undefined) {
429
+ const value = commentsFlag.trim().toLowerCase()
430
+ if (value !== "on" && value !== "off") {
431
+ throw new CliError(`--comments takes "on" or "off" (got "${commentsFlag}").`)
432
+ }
433
+ const { commentsOpen } = await setDesignCommentsOpen(apiUrl, token, uuid, value === "on")
434
+ log(`${c.green("✓")} Comments on the shared design are ${commentsOpen ? "ON" : "OFF"}.`)
435
+ if (!commentsOpen) note("Existing comments stay readable — this only stops new ones.")
436
+ return
437
+ }
438
+
439
+ if (flagBool(args, "off")) {
440
+ await disableDesignShare(apiUrl, token, uuid)
441
+ log(`${c.green("✓")} Design sharing is off.`)
442
+ return
443
+ }
444
+ const res = await enableDesignShare(apiUrl, token, uuid)
445
+ log(`${c.bold("Design shared")} — anyone with this link can view the mockups (your app code stays private):`)
446
+ log(` ${c.bold(res.url ?? "")}`)
447
+ log(` ${c.dim(`comments: ${res.commentsOpen === false ? "off" : "on"}`)}`)
448
+ note('Turn it off with: lecodes design share --off · comments: --comments off')
449
+ }
450
+
451
+ // ---- check -------------------------------------------------------------------------------------
452
+
453
+ /**
454
+ * Static coherence check for a design folder — the terminal/CI sibling of the `check_concept` MCP
455
+ * tool. Reports the flow warnings meta.json/screens/spec.md already carry (broken edges, missing
456
+ * screens, edges targeting an undeclared `@state`, duplicate state names, dangling `[[spec]]` links)
457
+ * plus concept completeness (spec.md present? screens with no role?) and discovered states. Hard
458
+ * warnings exit non-zero; soft hints never fail the check. No compile, no render — fast, and needs
459
+ * only lecodes-design (not the renderer).
460
+ */
461
+ const check = async (ctx: DesignContext, args: Args) => {
462
+ const designServer = await loadDesignServer(ctx)
463
+ const state = designServer.readDesignState(ctx.designDir)
464
+ const concept = designServer.conceptReport(ctx.designDir, state.screens)
465
+
466
+ if (state.warnings.length) {
467
+ log(c.bold(`Warnings (${state.warnings.length})`))
468
+ for (const w of state.warnings) log(` ${c.yellow("!")} ${w}`)
469
+ log("")
470
+ }
471
+
472
+ log(c.bold("Concept"))
473
+ log(` spec.md: ${concept.hasSpec ? c.green("present") : c.dim("missing (optional)")}`)
474
+ if (concept.screenRefs.length) {
475
+ const resolved = concept.screenRefs.filter((r) => r.exists).length
476
+ log(` [[links]]: ${resolved}/${concept.screenRefs.length} resolve`)
477
+ }
478
+ if (concept.screensWithoutRole.length) log(` ${c.dim("no role:")} ${concept.screensWithoutRole.join(", ")}`)
479
+ log("")
480
+
481
+ // States: list multi-state screens (canonical highlighted) + soft "not discoverable" hints.
482
+ const stateful = state.screens.filter((s) => s.states.length > 1)
483
+ const undiscoverable = state.screens.filter((s) => s.statesWarning)
484
+ if (stateful.length || undiscoverable.length) {
485
+ log(c.bold("States"))
486
+ for (const s of stateful) {
487
+ log(` ${s.id}: ${s.states.map((st) => st === s.canonical ? c.green(st) : st).join(", ")}`)
488
+ }
489
+ for (const s of undiscoverable) log(` ${c.yellow("!")} ${s.id}: ${s.statesWarning}`)
490
+ log("")
491
+ }
492
+
493
+ // Tab bars: the source-discovered hubs (defineTabs) — tabs in order + how many screens sit on
494
+ // each bar. Problems (missing roots, unknown tabs, bar-implied edges) surface in Warnings above.
495
+ if (state.hubs.length) {
496
+ log(c.bold("Tab bars"))
497
+ for (const h of state.hubs) {
498
+ const mounted = Object.keys(h.lanes).length
499
+ log(` ${h.name} (${h.file}): ${h.tabs.join(" · ")} — ${mounted} screen${mounted === 1 ? "" : "s"} on the bar`)
500
+ }
501
+ log("")
502
+ }
503
+
504
+ // Review comments: open threads are outstanding work on this design, so a coherence check that
505
+ // stayed silent about them would report "no problems" over a board full of unanswered feedback.
506
+ // They never fail the check — feedback isn't an error, and the file may be a stale mirror. An
507
+ // older lecodes-design without the comment store simply omits the section rather than failing.
508
+ const commentThreads = typeof designServer.listDesignComments === "function"
509
+ ? designServer.listDesignComments(ctx.designDir).threads
510
+ : []
511
+ if (commentThreads.length) {
512
+ const open = commentThreads.filter((t) => !t.resolved)
513
+ log(c.bold("Comments"))
514
+ log(` ${open.length} open · ${commentThreads.length} total`)
515
+ for (const thread of open.slice(0, 5)) {
516
+ const where = thread.anchor.screen ?? "(board)"
517
+ log(` ${c.dim(`#${thread.number}`)} ${where} ${c.dim(firstLine(thread.messages[0]?.body ?? ""))}`)
518
+ }
519
+ if (open.length > 5) log(` ${c.dim(`…and ${open.length - 5} more — lecodes design comments`)}`)
520
+ log("")
521
+ }
522
+
523
+ const hard = state.warnings.length
524
+ if (hard === 0) {
525
+ const n = state.screens.length
526
+ log(`${c.green("✓")} No coherence problems${n ? ` across ${n} screen${n === 1 ? "" : "s"}` : ""}.`)
527
+ return
528
+ }
529
+ log(`${c.red("✗")} ${hard} coherence warning${hard === 1 ? "" : "s"} — see above.`)
530
+ process.exitCode = 1
531
+ }
532
+
533
+ // ---- comments ----------------------------------------------------------------------------------
534
+
535
+ /**
536
+ * Read the board's review comments in the terminal — the same threads the canvas pins, grouped by
537
+ * screen. Open first, because the open ones are the work; `--all` includes resolved.
538
+ *
539
+ * Reads `design/comments/` directly (no server needed). On the platform those files mirror the
540
+ * database — `lecodes design comments pull` refreshes them — but for a standalone design folder they
541
+ * are the store, so this works with or without a project.
542
+ */
543
+ const commentsCmd = async (ctx: DesignContext, args: Args) => {
544
+ const sub = args._[1]
545
+ if (sub === "pull") return commentsPull(ctx)
546
+ if (sub === "add") return commentsAdd(ctx, args)
547
+ if (sub === "reply") return commentsReply(ctx, args)
548
+ if (sub === "resolve") return commentsResolve(ctx, args)
549
+ if (sub === "push") {
550
+ throw new CliError(
551
+ "Comments go up with the design, not on their own — run `lecodes push`.\n" +
552
+ " (A reply that arrives before the change it describes is worse than one that arrives late.)")
553
+ }
554
+ if (sub !== undefined) {
555
+ throw new CliError(`Unknown comments subcommand "${sub}" (add, reply, resolve, pull, or nothing to list).`)
556
+ }
557
+ const designServer = await loadDesignServer(ctx)
558
+
559
+ const all = flagBool(args, "all")
560
+ const { threads } = designServer.listDesignComments(ctx.designDir)
561
+ const shown = all ? threads : threads.filter((t) => !t.resolved)
562
+ const open = threads.filter((t) => !t.resolved).length
563
+
564
+ if (threads.length === 0) {
565
+ log("No comments on this design yet.")
566
+ note("Open the board (lecodes design) and use 💬 Comment, or share it for review.")
567
+ return
568
+ }
569
+ if (shown.length === 0) {
570
+ log(`${c.green("✓")} Nothing open — all ${threads.length} comment${threads.length === 1 ? "" : "s"} resolved.`)
571
+ note("See them with: lecodes design comments --all")
572
+ return
573
+ }
574
+
575
+ // Group by anchor so the list reads like a walk of the board, not a feed.
576
+ const byScreen = new Map<string, typeof shown>()
577
+ for (const thread of shown) {
578
+ const key = thread.anchor.screen ?? "(detached)"
579
+ byScreen.set(key, [ ...(byScreen.get(key) ?? []), thread ])
580
+ }
581
+ // Unpushed drafts have no number yet; they sort last rather than pretending to be #0.
582
+ const order = (t: { number?: number }) => t.number ?? Number.MAX_SAFE_INTEGER
583
+ for (const [ screen, list ] of [ ...byScreen ].sort((a, b) => a[0].localeCompare(b[0]))) {
584
+ log(c.bold(screen))
585
+ for (const thread of list.sort((a, b) => order(a) - order(b))) {
586
+ const target = thread.anchor.target
587
+ const where = [ thread.anchor.state && `@${thread.anchor.state}`, target?.name ?? target?.text ]
588
+ .filter(Boolean).join(" · ")
589
+ const head = ` ${c.bold(threadRef(thread))} ${thread.messages[0]?.author ?? "—"}`
590
+ log(`${head}${where ? c.dim(` ${where}`) : ""}${thread.resolved ? c.green(" ✓ resolved") : ""}`)
591
+ for (const message of thread.messages) {
592
+ for (const line of message.body.split("\n")) log(` ${c.dim("│")} ${line}`)
593
+ }
594
+ if (thread.messages.length > 1) log(` ${c.dim(`${thread.messages.length} messages`)}`)
595
+ }
596
+ log("")
597
+ }
598
+ log(`${open} open · ${threads.length} total`)
599
+ }
600
+
601
+ // ---- comments: write verbs (the server-down path — same store, same rules as the MCP tools) ----
602
+
603
+ /**
604
+ * Attribution for a CLI write. The developer's git name by default; Claude when `--ai` says so, or
605
+ * when the command is visibly running inside a Claude session (Claude Code's shell sets CLAUDECODE).
606
+ * The reviewer reading the answer is owed knowing a machine wrote it, so err toward the badge.
607
+ */
608
+ const cliAuthor = (args: Args): { author: string, origin: CommentOrigin } | undefined =>
609
+ flagBool(args, "ai") || process.env.CLAUDECODE ? { author: "Claude", origin: "ai" } : undefined
610
+
611
+ /** Where a write just went — said after every verb, because a finished-looking comment that hasn't
612
+ * reached the reviewer yet is the misunderstanding this whole queue exists to prevent. */
613
+ const commentDelivery = (ctx: DesignContext) =>
614
+ optionalPlatform(ctx)
615
+ ? "queued — it goes out with the next `lecodes push`, after the design it describes"
616
+ : "saved — this folder is the record"
617
+
618
+ const findThreadByNumber = (
619
+ designServer: typeof import("lecodes-design/server"),
620
+ ctx: DesignContext,
621
+ raw: unknown,
622
+ ): CommentThread => {
623
+ const number = Number(raw)
624
+ if (!Number.isInteger(number) || number <= 0) {
625
+ throw new CliError(`"${String(raw ?? "")}" is not a thread number — see: lecodes design comments`)
626
+ }
627
+ const thread = designServer.listDesignComments(ctx.designDir).threads.find((t) => t.number === number)
628
+ if (!thread) throw new CliError(`No comment #${number} on this design — see: lecodes design comments`)
629
+ return thread
630
+ }
631
+
632
+ /** Start a thread from the terminal — Claude asking the reviewer something with no server running,
633
+ * or the developer leaving themselves a note. Pin it to a real screen so it never lands detached. */
634
+ const commentsAdd = async (ctx: DesignContext, args: Args) => {
635
+ const body = String(args._[2] ?? "").trim()
636
+ if (!body) {
637
+ throw new CliError('Usage: lecodes design comments add "<text>" [--screen <id>] [--state <s>] [--element <name>] [--ai]')
638
+ }
639
+ const designServer = await loadDesignServer(ctx, [ "startThread", "readDesignState" ])
640
+ const screen = flagStr(args, "screen")?.trim() || undefined
641
+ if (screen) {
642
+ const ids = designServer.readDesignState(ctx.designDir).screens.map((s) => s.id)
643
+ if (!ids.includes(screen)) {
644
+ throw new CliError(`No screen "${screen}" in ${ctx.dirName}/ — the pin would point at nothing. Have: ${ids.join(", ") || "(none)"}.`)
645
+ }
646
+ }
647
+ const element = flagStr(args, "element")?.trim() || undefined
648
+ const thread = designServer.startThread(ctx.designDir, {
649
+ anchor: {
650
+ screen,
651
+ state: flagStr(args, "state")?.trim() || undefined,
652
+ ...(element ? { target: { name: element } } : {}),
653
+ },
654
+ body,
655
+ ...cliAuthor(args),
656
+ }, { linked: !!optionalPlatform(ctx) })
657
+ log(`${c.green("✓")} ${threadRef(thread)} added — ${commentDelivery(ctx)}`)
658
+ }
659
+
660
+ const commentsReply = async (ctx: DesignContext, args: Args) => {
661
+ const body = String(args._[3] ?? "").trim()
662
+ if (!body) throw new CliError('Usage: lecodes design comments reply <number> "<text>" [--ai]')
663
+ const designServer = await loadDesignServer(ctx, [ "replyToThread" ])
664
+ const thread = findThreadByNumber(designServer, ctx, args._[2])
665
+ designServer.replyToThread(ctx.designDir, thread.id, body, cliAuthor(args))
666
+ log(`${c.green("✓")} replied to #${thread.number} — ${commentDelivery(ctx)}`)
667
+ }
668
+
669
+ /** Close (or `--reopen`) a thread, optionally replying first — same order as the MCP tool, so the
670
+ * reviewer always sees the "what was done" before the checkmark that claims it. */
671
+ const commentsResolve = async (ctx: DesignContext, args: Args) => {
672
+ const designServer = await loadDesignServer(ctx, [ "resolveThread", "replyToThread" ])
673
+ const thread = findThreadByNumber(designServer, ctx, args._[2])
674
+ const reopen = flagBool(args, "reopen")
675
+ const reply = flagStr(args, "reply")?.trim()
676
+ if (reply) designServer.replyToThread(ctx.designDir, thread.id, reply, cliAuthor(args))
677
+ designServer.resolveThread(ctx.designDir, thread.id, !reopen)
678
+ log(`${c.green("✓")} #${thread.number} ${reopen ? "reopened" : "resolved"} — ${commentDelivery(ctx)}`)
679
+ }
680
+
681
+ // ---- comments: pull down, and the queue `lecodes push` drains ---------------------------------
682
+
683
+ /*
684
+ * Direction is fixed and it is what keeps this small: the platform owns the conversation, the design
685
+ * folder mirrors its OPEN threads, and **nothing leaves this machine except via `lecodes push`**.
686
+ *
687
+ * So there are exactly two moving parts here — a pull (safe any time, feedback arriving early is just
688
+ * feedback) and a flush that rides along with the design push. A reply cannot reach a reviewer before
689
+ * the work it describes, because they are the same command in that order.
690
+ */
691
+
692
+ type Platform = { apiUrl: string, token: string, uuid: string }
693
+
694
+ /** How a thread is referred to. Duplicated from the design package rather than imported: that package
695
+ * is an optional peer, so a value import here would break the CLI wherever it isn't installed. */
696
+ const threadRef = (thread: { number?: number }) => (thread.number === undefined ? "new" : `#${thread.number}`)
697
+
698
+ const reportMirror = (ctx: DesignContext, summary: MirrorSummary, open: number) => {
699
+ const parts = [
700
+ summary.added ? `+${summary.added} new` : null,
701
+ summary.updated ? `${summary.updated} updated` : null,
702
+ summary.closed ? `${summary.closed} closed` : null,
703
+ ].filter((p): p is string => p !== null)
704
+ if (parts.length === 0) {
705
+ log(`Already up to date — ${open} open.`)
706
+ return
707
+ }
708
+ log(`${c.green("✓")} ${ctx.dirName}/comments/ — ${parts.join(", ")} ${c.dim(`(${open} open)`)}`)
709
+ }
710
+
711
+ /** The platform's open threads. Resolved ones aren't mirrored: the mirror is the work queue, and the
712
+ * platform keeps the record. */
713
+ const fetchOpen = async (platform: Platform) => {
714
+ const snapshot = await listDesignComments(platform.apiUrl, platform.token, platform.uuid)
715
+ return snapshot.threads.filter((t) => !t.resolved)
716
+ }
717
+
718
+ const commentsPull = async (ctx: DesignContext) => {
719
+ const designServer = await loadDesignServer(ctx, [ "mirrorThreads" ])
720
+ const platform = requirePlatform(ctx, "pull")
721
+ const open = await fetchOpen(platform)
722
+ const summary = designServer.mirrorThreads(ctx.designDir, open)
723
+ reportMirror(ctx, summary, open.length)
724
+ if (open.length) note("Read them with: lecodes design comments")
725
+ }
726
+
727
+ export type CommentFlush = { sent: number, threads: number, failures: string[] }
728
+
729
+ /**
730
+ * Send everything the design folder has queued, and settle each thread against what the platform says
731
+ * it is afterwards. Called by `lecodes push` once the design itself has landed — never before, which
732
+ * is the whole reason a reply can't claim a fix nobody can see yet.
733
+ *
734
+ * Returns null when there is nothing to do (no design folder, no project, no queue), so the caller
735
+ * stays a single `if`.
736
+ */
737
+ export const flushDesignComments = async (args: Args): Promise<CommentFlush | null> => {
738
+ const ctx = getContext(args)
739
+ if (!existsSync(ctx.designDir)) return null
740
+ const platform = optionalPlatform(ctx)
741
+ if (!platform) return null
742
+
743
+ let designServer: typeof import("lecodes-design/server")
744
+ try {
745
+ designServer = await import("lecodes-design/server")
746
+ } catch {
747
+ return null
748
+ }
749
+ // `settleQueued` must take `delivered` (4 params): settling a half-failed push with the older
750
+ // signature would drop the writes that DIDN'T go through instead of retrying them.
751
+ if (typeof designServer.queuedThreads !== "function") return null
752
+ if (typeof designServer.settleQueued !== "function" || designServer.settleQueued.length < 4) return null
753
+
754
+ const queued = designServer.queuedThreads(ctx.designDir)
755
+ if (queued.length === 0) return null
756
+
757
+ const { apiUrl, token, uuid } = platform
758
+ const result: CommentFlush = { sent: 0, threads: 0, failures: [] }
759
+
760
+ for (const item of queued) {
761
+ const { thread, pending } = item
762
+ /** What the platform says the thread is after the last delivered write. */
763
+ let upstream: CommentThread | null = null
764
+ /** LOCAL ids of what actually landed — a push can die halfway, and only these get settled. */
765
+ const delivered = { messageIds: new Set<string>(), resolve: false }
766
+ try {
767
+ if (pending.newThread) {
768
+ const [ opening, ...rest ] = thread.messages
769
+ if (!opening) continue
770
+ upstream = await createDesignComment(apiUrl, token, uuid, {
771
+ anchor: thread.anchor, body: opening.body, origin: opening.origin,
772
+ })
773
+ result.sent++
774
+ delivered.messageIds.add(opening.id)
775
+ for (const message of rest) {
776
+ upstream = await replyDesignComment(apiUrl, token, uuid, upstream.id, message.body, message.origin)
777
+ result.sent++
778
+ delivered.messageIds.add(message.id)
779
+ }
780
+ } else {
781
+ for (const message of pending.messages) {
782
+ upstream = await replyDesignComment(apiUrl, token, uuid, thread.id, message.body, message.origin)
783
+ result.sent++
784
+ delivered.messageIds.add(message.id)
785
+ }
786
+ }
787
+
788
+ if (pending.resolve) {
789
+ upstream = await resolveDesignComment(apiUrl, token, uuid, upstream?.id ?? thread.id, thread.resolved)
790
+ result.sent++
791
+ delivered.resolve = true
792
+ }
793
+ } catch (e) {
794
+ // The undelivered remainder stays queued: the next push retries it. What must never happen is
795
+ // the file claiming something was sent when it wasn't.
796
+ result.failures.push(`${threadRef(thread)}: ${errText(e)}`)
797
+ }
798
+ // Settle whatever DID land, even after a mid-thread failure — adopting the platform's copy is
799
+ // what stops the next push from sending it again as a duplicate.
800
+ if (upstream) {
801
+ designServer.settleQueued(ctx.designDir, item, upstream, delivered)
802
+ result.threads++
803
+ }
804
+ }
805
+ return result
806
+ }
807
+
808
+ /** How often the running board re-reads the platform's comments. */
809
+ const COMMENT_SYNC_MS = 20_000
810
+
811
+ /**
812
+ * Keep `design/comments/` fed while the canvas runs, so feedback left on the share link reaches the
813
+ * board — and Claude — without anyone remembering to pull.
814
+ *
815
+ * Strictly inbound. Writing the files is what refreshes the board: the fs watcher sees the directory
816
+ * change and pushes the SSE `comments` flag, exactly as it does for a hand edit.
817
+ */
818
+ const startCommentSync = (ctx: DesignContext, designServer: typeof import("lecodes-design/server")) => {
819
+ const platform = optionalPlatform(ctx)
820
+ if (!platform) return null
821
+ // An older lecodes-design has no mirror to drive. The board still works off the files, so losing
822
+ // the sync is not a reason to refuse to serve.
823
+ if (typeof designServer.mirrorThreads !== "function") {
824
+ note("Comment sync needs a newer lecodes-design (npm install -g lecodes-design@latest) — serving without it.")
825
+ return null
826
+ }
827
+ let failures = 0
828
+ const timer: ReturnType<typeof setInterval> = setInterval(() => { void tick() }, COMMENT_SYNC_MS)
829
+ timer.unref?.()
830
+
831
+ async function tick() {
832
+ try {
833
+ const open = await fetchOpen(platform!)
834
+ failures = 0
835
+ const summary = designServer.mirrorThreads(ctx.designDir, open)
836
+ if (summary.added || summary.updated || summary.closed) {
837
+ note(`comments ← platform (${open.length} open)`)
838
+ }
839
+ } catch (e) {
840
+ // Offline, revoked token, project gone: say it once and give up rather than logging forever.
841
+ // The board keeps working off the files, and `comments pull` reports the real error on demand.
842
+ if (++failures === 1) warnErr(`Comment sync paused — ${errText(e)}`)
843
+ if (failures >= 3) clearInterval(timer)
844
+ }
845
+ }
846
+ void tick()
847
+ return () => clearInterval(timer)
848
+ }
849
+
850
+ // ---- dispatch ----------------------------------------------------------------------------------
851
+
852
+ export const design = async (args: Args) => {
853
+ const sub = args._[0] ?? "serve"
854
+ const ctx = getContext(args)
855
+ if (sub === "serve") return serve(ctx, args)
856
+ if (sub === "init") return init(ctx, args)
857
+ if (sub === "snapshot") return snapshot(ctx, args)
858
+ if (sub === "arrange") return arrange(ctx, args)
859
+ if (sub === "check") return check(ctx, args)
860
+ if (sub === "share") return share(ctx, args)
861
+ if (sub === "comments") return commentsCmd(ctx, args)
862
+ throw new CliError(`Unknown design subcommand "${sub}" (serve | init | snapshot | arrange | check | share | comments).`)
863
+ }