mockpit 0.0.0-stage → 1.0.0

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 (82) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +232 -2
  3. package/bin/demoData.js +132 -0
  4. package/bin/initDesign.js +846 -0
  5. package/bin/mockpit.js +1830 -0
  6. package/bin/serveUrl.d.ts +1 -0
  7. package/bin/serveUrl.js +7 -0
  8. package/dist/mcp/server.d.ts +2 -0
  9. package/dist/mcp/server.js +230 -0
  10. package/dist/server/apiViews.d.ts +329 -0
  11. package/dist/server/apiViews.js +135 -0
  12. package/dist/server/app.d.ts +53 -0
  13. package/dist/server/app.js +3112 -0
  14. package/dist/server/base64.d.ts +1 -0
  15. package/dist/server/base64.js +16 -0
  16. package/dist/server/cdn.d.ts +6 -0
  17. package/dist/server/cdn.js +33 -0
  18. package/dist/server/codeRunner.d.ts +5 -0
  19. package/dist/server/codeRunner.js +89 -0
  20. package/dist/server/codeWorker.d.ts +1 -0
  21. package/dist/server/codeWorker.js +196 -0
  22. package/dist/server/designGuide.d.ts +16 -0
  23. package/dist/server/designGuide.js +311 -0
  24. package/dist/server/events.d.ts +53 -0
  25. package/dist/server/events.js +14 -0
  26. package/dist/server/feedbackBatch.d.ts +43 -0
  27. package/dist/server/feedbackBatch.js +98 -0
  28. package/dist/server/icons.d.ts +50 -0
  29. package/dist/server/icons.js +225 -0
  30. package/dist/server/index.d.ts +1 -0
  31. package/dist/server/index.js +64 -0
  32. package/dist/server/kits.d.ts +36 -0
  33. package/dist/server/kits.js +279 -0
  34. package/dist/server/knobs.d.ts +15 -0
  35. package/dist/server/knobs.js +333 -0
  36. package/dist/server/mcpHttp.d.ts +53 -0
  37. package/dist/server/mcpHttp.js +143 -0
  38. package/dist/server/mcpSpec.d.ts +191 -0
  39. package/dist/server/mcpSpec.js +402 -0
  40. package/dist/server/parts.d.ts +31 -0
  41. package/dist/server/parts.js +338 -0
  42. package/dist/server/postMarkdown.d.ts +10 -0
  43. package/dist/server/postMarkdown.js +271 -0
  44. package/dist/server/postSurfaces.d.ts +40 -0
  45. package/dist/server/postSurfaces.js +407 -0
  46. package/dist/server/public.d.ts +5 -0
  47. package/dist/server/public.js +5 -0
  48. package/dist/server/push.d.ts +45 -0
  49. package/dist/server/push.js +234 -0
  50. package/dist/server/richRender.d.ts +15 -0
  51. package/dist/server/richRender.js +392 -0
  52. package/dist/server/run.d.ts +82 -0
  53. package/dist/server/run.js +290 -0
  54. package/dist/server/runApi.d.ts +6 -0
  55. package/dist/server/runApi.js +82 -0
  56. package/dist/server/slots.d.ts +14 -0
  57. package/dist/server/slots.js +71 -0
  58. package/dist/server/sqlStore.d.ts +119 -0
  59. package/dist/server/sqlStore.js +954 -0
  60. package/dist/server/sqliteStorage.d.ts +2 -0
  61. package/dist/server/sqliteStorage.js +90 -0
  62. package/dist/server/surfacePage.d.ts +48 -0
  63. package/dist/server/surfacePage.js +1099 -0
  64. package/dist/server/themes.d.ts +48 -0
  65. package/dist/server/themes.js +305 -0
  66. package/dist/server/types.d.ts +599 -0
  67. package/dist/server/types.js +299 -0
  68. package/dist/workers/public.d.ts +1 -0
  69. package/dist/workers/public.js +3 -0
  70. package/extensions/mockpit.js +703 -0
  71. package/guide/AGENT_SETUP.md +23 -0
  72. package/guide/topics/asks.md +87 -0
  73. package/guide/topics/feedback.md +127 -0
  74. package/guide/topics/html.md +175 -0
  75. package/guide/topics/http.md +76 -0
  76. package/guide/topics/knobs.md +72 -0
  77. package/guide/topics/scripts.md +129 -0
  78. package/guide/topics/surfaces.md +100 -0
  79. package/package.json +165 -4
  80. package/skills/launch-video/SKILL.md +159 -0
  81. package/skills/mockpit/SKILL.md +40 -0
  82. package/viewer/dist/index.html +1922 -0
package/bin/mockpit.js ADDED
@@ -0,0 +1,1830 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawn } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ writeFileSync,
10
+ writeSync,
11
+ } from "node:fs";
12
+ import { tmpdir, userInfo } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { parseArgs } from "node:util";
16
+ import { serveUrl } from "./serveUrl.js";
17
+
18
+ const BASE = (process.env.MOCKPIT_URL ?? "http://localhost:8228").replace(/\/$/, "");
19
+ const TOKEN = process.env.MOCKPIT_TOKEN;
20
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
21
+ const PKG_VERSION = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
22
+
23
+ const HELP = `mockpit — a live visual surface for terminal coding agents
24
+
25
+ vocabulary: project › mock › state › variant › version. A project is a repo; a
26
+ mock is the page or component on stage; a state is one moment of it ("Lab
27
+ open"); a variant is a parallel design of a state; a version is its history.
28
+ Mark the parts you want feedback on with data-part="name" in the html.
29
+
30
+ the loop (never blocks on the user):
31
+ 1. mockpit init once per repo; read the brief (mockpit guide).
32
+ 2. mockpit publish once per variant; mark parts with data-part.
33
+ 3. A choice is several variants plus one ask that binds them: mockpit ask
34
+ with options bound to variants. One render plus a control: --knobs.
35
+ 4. Tell the user in one line where to look, then end your turn. The question
36
+ itself lives in the mock (ask), never in chat. The user answers in the
37
+ browser at their own pace. Never poll.
38
+ 5. When the user says they answered (or a mockpit watch line wakes you), run
39
+ mockpit feedback. Every write also returns feedback; read it.
40
+ 6. mockpit publish the revision; mockpit export once a variant is accepted.
41
+ In Claude Code, arm mockpit watch under Monitor after asking, then end the
42
+ turn; elsewhere the user's next message is the wake-up.
43
+
44
+ verbs:
45
+ mockpit init [--project name] [--kit <id>] [--kit-url <url> --kit-doc <file|text>]
46
+ detect the repo's design system, store
47
+ palette/kit/icon sets, write .mockpit/starter.html
48
+ mockpit publish --mock <slug> --html <file> [options]
49
+ create or version one variant of one state
50
+ --state <label> state, in the user's words (omit for a single-state mock)
51
+ --variant <name> variant label (default "default")
52
+ --html <file|-> the html body (or a positional file)
53
+ --surfaces <json|file> instead: the full ordered surface list; {"id"}
54
+ alone keeps a surface, a missing id removes it
55
+ --parts <name=file|-> instead: replace just that data-part's element in
56
+ the latest version (repeatable; name#key targets one instance)
57
+ --title <t> mock title
58
+ --kind <k> component|page (default component)
59
+ --knobs <json|file> knobs in tunekit usePane shape, keyed by path
60
+ --from <N> branch from version N
61
+ --prompt <text> what prompted this version
62
+ --project <name> project (default: git remote, else directory name)
63
+ --kit <id> opt the html surface into a kit (repeatable; see "mockpit kits")
64
+ --md/--mermaid/--diff/--terminal/--data/--code/--image <file|->
65
+ add a surface of that kind after the html (repeatable;
66
+ --data is a JSON tree)
67
+ mockpit ask --mock <slug> "<question>" --option <label[=variant]> ...
68
+ ask the user; bind options to variants
69
+ --option <l[=v]> an option (repeatable); "=variant" binds it to a variant
70
+ --scope <s> mock|state|part (default mock)
71
+ --state <label> the state a state-scoped ask is about
72
+ --part <name> the part a part-scoped ask is about
73
+ --multi allow several answers
74
+ --id <id> stable id; reusing one replaces that ask
75
+ --asks <json|file> several asks at once, full shape
76
+ mockpit read [slug] [--body] [--history]
77
+ no slug: every mock plus what the user
78
+ is doing now; a slug: that mock
79
+ mockpit feedback what the user sent since you last heard,
80
+ plus pending; returns at once
81
+ mockpit say "<text>" --mock <slug> [--state s] [--variant v]
82
+ a plain-text message in a mock's thread
83
+ mockpit export --mock <slug> [--state s] [--variant v] [--out <dir>]
84
+ write the accepted html + history per state
85
+ to .mockpit/accepted/<mock>/<state>/
86
+ mockpit upload <file> [--kind image|file]
87
+ upload an asset, print its id and URL
88
+ mockpit guide [--topic <id>] the project-aware brief, or one reference topic
89
+ mockpit run <file|-> run a script against the mockpit API on the
90
+ server (mockpit guide --topic scripts)
91
+ mockpit watch [--session <id>] stream user feedback, one line per Send
92
+ (for a background monitor)
93
+
94
+ other commands:
95
+ mockpit serve [--port N] [--host H] [--open]
96
+ start the server (API + viewer)
97
+ mockpit demo seed the Writer mock to explore the viewer
98
+ mockpit icons [add|remove <set>...] list the icon sets html surfaces can name
99
+ (icon="prefix:name"); add or remove an
100
+ Iconify set for this project
101
+ mockpit asset-url <file> print the URL a file will have (no upload)
102
+ mockpit kits list the opt-in html kits (bundled + project)
103
+ mockpit kit add <id> --url <css> --doc <file|text> [--script <js>]
104
+ define a project kit from a CDN stylesheet
105
+ mockpit kit remove <id> remove a project kit
106
+ mockpit setup print the AGENTS.md integration block
107
+ mockpit version show version and check for updates
108
+ mockpit mcp run the stdio MCP server (for agent configs)
109
+
110
+ flags:
111
+ --version, -V print version and exit
112
+ --json print the raw server response
113
+ --quiet print nothing on success
114
+ --help, -h per-command help (mockpit publish --help)
115
+
116
+ environment:
117
+ MOCKPIT_PROJECT project name; overrides the git-remote/directory default
118
+ MOCKPIT_URL server base URL (default http://localhost:8228)
119
+ MOCKPIT_TOKEN bearer token for a deployed instance
120
+ MOCKPIT_HOST address serve binds to (default: every interface)
121
+ MOCKPIT_SESSION fixed session id (overrides auto-detection)
122
+ MOCKPIT_AGENT agent name used when creating sessions
123
+ `;
124
+
125
+ // Per-command help, so `mockpit publish --help` costs a few lines instead of
126
+ // the whole manual. Commands without an entry fall back to HELP.
127
+ const COMMAND_HELP = {
128
+ init: `mockpit init [--project <name>] [--kit <id>]
129
+ [--kit-url <https url> [--kit-script <url>] --kit-doc <file.md|text>]
130
+ Detect the repo's design system, store palette + kit for the project, add the
131
+ Iconify sets matching its icon packages, and write .mockpit/starter.html
132
+ (gitignored). --kit makes a bundled or project kit the default for every html
133
+ surface. --kit-url defines the project's own kit (id from --kit, else
134
+ "project") and makes it the default; the doc is the cheat sheet the brief prints.`,
135
+ kit: `mockpit kit add <id> --url <https url> --doc <file.md|text> [--script <url>]
136
+ mockpit kit remove <id>
137
+ A project kit is a stylesheet (and optional script) on the CDN allowlist plus a
138
+ class cheat sheet of at most 1,200 chars. Surfaces name it in kits like a
139
+ bundled kit.`,
140
+ icons: `mockpit icons [--project <name>]
141
+ mockpit icons add <set> [<set>...]
142
+ mockpit icons remove <set> [<set>...]
143
+ html surfaces write <i icon="lucide:check"></i>; the server inlines the svg.
144
+ lucide and mage are bundled. add finds @iconify-json/<set> in node_modules,
145
+ the global npm root or bun's cache, else fetches it from jsDelivr.`,
146
+ publish: `mockpit publish --mock <slug> --html <file> [options]
147
+ mockpit publish --mock <slug> --surfaces <json|file> [options]
148
+ mockpit publish --mock <slug> --parts <name=file|-> ... [options]
149
+ Creates the mock, state or variant, or the next version of an existing one.
150
+ --state <label> state in the user's words (omit for a single-state mock)
151
+ --variant <name> variant label (default "default")
152
+ --surfaces <json|file> the full ordered list: {"id"} alone keeps a surface,
153
+ a missing id removes it, order is the list order
154
+ --parts <name=file|-> replace only the element carrying data-part="name"
155
+ (name#key for one instance) in the latest version; repeatable
156
+ --title <t> mock title
157
+ --kind <k> component|page (default component)
158
+ --knobs <json|file> knobs in tunekit usePane shape
159
+ --from <N> branch from version N
160
+ --prompt <text> what prompted this version
161
+ --project <name> project (default: git remote, else directory name)
162
+ --md/--mermaid/--diff/--terminal/--data/--code/--image <file> more surfaces
163
+ --json / --quiet
164
+ Prints the parts found per state, parts that vanished or were renamed,
165
+ nudges, a ready-to-run "mockpit ask" when variants have no ask, and feedback.`,
166
+ ask: `mockpit ask --mock <slug> "<question>" --option <label[=variant]> ... [--scope mock|state|part]
167
+ [--state s] [--part p] [--multi] [--id id]
168
+ mockpit ask --mock <slug> --asks <json|file>
169
+ Ask the user. A choice is several variants plus one ask that binds them.
170
+ Then tell the user where to look and end your turn.`,
171
+ read: `mockpit read [slug] [--project <name>] [--body] [--history]
172
+ No slug: one line per mock (states, variants, open asks) plus pending, what
173
+ the user is doing now. A slug: the mock's states, variants, asks with
174
+ answers, parts, knobs and last tuned values, plus its pending, as JSON.
175
+ --body includes surfaces, --history version rows. Never consumes feedback.`,
176
+ feedback: `mockpit feedback [--project <name>] [--session <id>]
177
+ Returns at once with {feedback, pending} as JSON: one batch per mock the user
178
+ sent since you last heard ({mock, reply: {asks, mix, tuned, comments},
179
+ comments, accepted, archived}), delivered once across feedback, watch and
180
+ every write; and pending ({mock, viewerOpen, draft}) so you can tell the
181
+ user "take your time" or "2 of 3 answered". Run it when the user says they
182
+ answered.`,
183
+ say: `mockpit say "<text>" --mock <slug> [--state s] [--variant v]
184
+ A plain-text message in the mock's thread, as your session's agent.`,
185
+ run: `mockpit run <file|-> [--session <id>] [--project <name>] [--json]
186
+ Run a script (the body of an async function, plain JavaScript) in the server's
187
+ sandbox against the mockpit API; see mockpit guide --topic scripts. Prints each
188
+ host call in order, the script's prints, its value or error, and any user
189
+ feedback the run received. --json prints the whole result. Exits 2 when the
190
+ script failed.`,
191
+ watch: `mockpit watch [--session <id>] [--after <seq>]
192
+ Stream user feedback, one line per Send, for a background monitor (in
193
+ Claude Code, arm it under Monitor after asking, then end the turn).
194
+ Delivery is shared with feedback and every write: each Send arrives once.`,
195
+ export: `mockpit export --mock <slug> [--state s] [--variant v] [--out <dir>]
196
+ Write index.html + history.json per state to .mockpit/accepted/<mock>/<state>/.`,
197
+ upload: `mockpit upload <file> [--kind image|file] [--session <id>]
198
+ Upload an asset; prints its id and URL. Use the id as an image surface's assetId.`,
199
+ guide: `mockpit guide [--topic <id>] [--project <name>]
200
+ No flag prints the brief: the loop, parts, asks and knobs, feedback, and
201
+ this project's palette, kit and icons. --topic prints one reference topic
202
+ (knobs, asks, surfaces, html, feedback, http, scripts).`,
203
+ };
204
+
205
+ // `console.log(...)` on a pipe is asynchronous, so exiting on the next line
206
+ // truncates anything past the pipe buffer (~8 KB) — which is most of the help
207
+ // text. Write to fd 1 synchronously, retrying the non-blocking EAGAIN, then exit.
208
+ function printAndExit(text) {
209
+ let buf = Buffer.from(text.endsWith("\n") ? text : `${text}\n`);
210
+ while (buf.length > 0) {
211
+ try {
212
+ buf = buf.subarray(writeSync(1, buf));
213
+ } catch (err) {
214
+ if (err?.code !== "EAGAIN") throw err;
215
+ }
216
+ }
217
+ process.exit(0);
218
+ }
219
+
220
+ function fail(msg) {
221
+ console.error(`mockpit: ${msg}`);
222
+ process.exit(1);
223
+ }
224
+
225
+ // The agent-facing error format: name the thing, offer one fix, say nothing was
226
+ // written, exit 2. No stack traces, no prose.
227
+ function die(what, fix) {
228
+ console.error(`error ${what}`);
229
+ if (fix) console.error(` fix: ${fix}`);
230
+ process.exit(2);
231
+ }
232
+
233
+ // `report` decides how a failure reads: plumbing verbs keep the one-line
234
+ // `mockpit: …` (exit 1); the mock verbs pass `die` so the error / fix / exit 2
235
+ // format is what an agent parses.
236
+ async function api(path, init = {}, { report, fix } = {}) {
237
+ const bail = (what, hint) => (report === "die" ? die(what, hint ?? fix) : fail(what));
238
+ let res;
239
+ try {
240
+ res = await fetch(`${BASE}${path}`, {
241
+ ...init,
242
+ headers: {
243
+ "content-type": "application/json",
244
+ ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
245
+ ...init.headers,
246
+ },
247
+ });
248
+ } catch {
249
+ if (report === "die") die(`cannot reach mockpit at ${BASE}`, "mockpit serve");
250
+ fail(`server not reachable at ${BASE} — start it with: mockpit serve`);
251
+ }
252
+ const body = await res.json().catch(() => ({}));
253
+ if (!res.ok) {
254
+ // A surface validation failure carries typed issues; the first one is the
255
+ // actionable line ("error … : <message>") the agent needs.
256
+ const issue = Array.isArray(body.issues) ? body.issues[0] : null;
257
+ // The server names the valid choices when a call is ambiguous; say them.
258
+ const choices = body.variants ?? body.states ?? body.projects;
259
+ const main =
260
+ (body.error ?? `${res.status} ${res.statusText}`) +
261
+ (Array.isArray(choices) ? ` (${choices.join(", ")})` : "");
262
+ const extra = issue?.message ?? issue?.code;
263
+ bail(extra && !main.includes(extra) ? `${main}: ${extra}` : main);
264
+ }
265
+ return body;
266
+ }
267
+
268
+ // Session state is keyed by (agent process pid, cwd). Many agents spawn a
269
+ // fresh shell per command, so the immediate parent is unstable — walk up the
270
+ // process tree past shells to the agent process itself. Falls back to
271
+ // cwd-only keying where `ps` is unavailable.
272
+ const SHELLS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
273
+
274
+ function getParentPosix(pid) {
275
+ const out = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], {
276
+ encoding: "utf8",
277
+ stdio: ["ignore", "pipe", "ignore"],
278
+ }).trim();
279
+ const m = out.match(/^\s*(\d+)\s+(.*)$/);
280
+ if (!m) return { ppid: 0, isShell: false };
281
+ const ppid = Number(m[1]);
282
+ const comm = m[2].trim().split("/").pop() ?? "";
283
+ return { ppid, isShell: SHELLS.has(comm.replace(/^-/, "")) };
284
+ }
285
+
286
+ function agentPidWindows(startPid) {
287
+ // wmic is removed in Windows 11. Walk the process tree in a single
288
+ // PowerShell call to avoid repeated startup overhead (~300ms per spawn).
289
+ // $procId, not $pid: $PID is a PowerShell automatic variable holding the
290
+ // host process's own id, and reassigning it is confusing at best.
291
+ const script = `
292
+ $procId = ${startPid}
293
+ $shells = @('cmd.exe','powershell.exe','pwsh.exe')
294
+ for ($i = 0; $i -lt 10; $i++) {
295
+ $p = Get-CimInstance Win32_Process -Filter "ProcessId=$procId"
296
+ if (!$p) { break }
297
+ if ($shells -notcontains $p.Name.ToLower()) { break }
298
+ if ($p.ParentProcessId -le 1) { break }
299
+ $procId = $p.ParentProcessId
300
+ }
301
+ $procId
302
+ `;
303
+ const out = execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
304
+ encoding: "utf8",
305
+ stdio: ["ignore", "pipe", "ignore"],
306
+ }).trim();
307
+ return Number(out) || startPid;
308
+ }
309
+
310
+ function agentPid() {
311
+ try {
312
+ if (process.platform === "win32") return agentPidWindows(process.ppid);
313
+ let pid = process.ppid;
314
+ for (let hops = 0; hops < 10; hops++) {
315
+ const { ppid, isShell } = getParentPosix(pid);
316
+ if (!isShell || !ppid || ppid <= 1) return pid;
317
+ pid = ppid;
318
+ }
319
+ return pid;
320
+ } catch {
321
+ return 0;
322
+ }
323
+ }
324
+
325
+ function stateFile() {
326
+ const dir = join(tmpdir(), `mockpit-${userInfo().username}`);
327
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
328
+ const key = createHash("sha1")
329
+ .update(`${agentPid()}:${process.cwd()}`)
330
+ .digest("hex")
331
+ .slice(0, 12);
332
+ return join(dir, `${key}.json`);
333
+ }
334
+
335
+ function readState() {
336
+ try {
337
+ return JSON.parse(readFileSync(stateFile(), "utf8"));
338
+ } catch {
339
+ return {};
340
+ }
341
+ }
342
+
343
+ function writeState(patch) {
344
+ const next = { ...readState(), ...patch };
345
+ writeFileSync(stateFile(), JSON.stringify(next));
346
+ return next;
347
+ }
348
+
349
+ function agentName(flags) {
350
+ return flags.agent ?? process.env.MOCKPIT_AGENT ?? readState().agent ?? "agent";
351
+ }
352
+
353
+ // The state file is keyed by the agent's pid, which a resumed conversation
354
+ // doesn't keep; Claude Code's session id does, so the server can hand back the
355
+ // same mockpit session (and its feedback cursor).
356
+ function harnessKey() {
357
+ const id = process.env.CLAUDE_CODE_SESSION_ID;
358
+ return id ? `claude-code:${id}` : undefined;
359
+ }
360
+
361
+ async function resolveSession(flags, { create = false } = {}) {
362
+ if (flags.session) return flags.session;
363
+ if (process.env.MOCKPIT_SESSION) return process.env.MOCKPIT_SESSION;
364
+ const state = readState();
365
+ if (state.session && !flags["new-session"]) {
366
+ const ok = await fetch(`${BASE}/api/sessions/${state.session}`, {
367
+ headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
368
+ }).then(
369
+ (r) => r.ok,
370
+ () => false,
371
+ );
372
+ if (ok) return state.session;
373
+ }
374
+ if (!create) return null;
375
+ const session = await api("/api/sessions", {
376
+ method: "POST",
377
+ body: JSON.stringify({
378
+ agent: agentName(flags),
379
+ title: flags["session-title"],
380
+ cwd: process.cwd(),
381
+ project: resolveProject(flags).name,
382
+ key: harnessKey(),
383
+ }),
384
+ });
385
+ writeState({ session: session.id, agent: agentName(flags) });
386
+ return session.id;
387
+ }
388
+
389
+ // A monitor process (e.g. the Claude Code plugin) may not share the local
390
+ // state file written by the agent's CLI calls — different spawn tree, so
391
+ // `agentPid()` can hash to a different key. Fall back to asking the server for
392
+ // the most recently active session whose cwd matches ours. Uses raw fetch (not
393
+ // `api()`) so a transient failure returns null instead of exiting the process.
394
+ async function resolveSessionByCwd(cwd = process.cwd()) {
395
+ try {
396
+ const res = await fetch(`${BASE}/api/sessions`, {
397
+ headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
398
+ });
399
+ if (!res.ok) return null;
400
+ const sessions = await res.json();
401
+ return (
402
+ sessions
403
+ .filter((s) => s.cwd === cwd)
404
+ .sort((a, b) => String(b.lastActiveAt).localeCompare(String(a.lastActiveAt)))[0]?.id ?? null
405
+ );
406
+ } catch {
407
+ return null;
408
+ }
409
+ }
410
+
411
+ function readContent(arg) {
412
+ if (!arg || arg === "-") {
413
+ try {
414
+ return readFileSync(0, "utf8");
415
+ } catch {
416
+ fail("no input — pass a file path or pipe HTML on stdin");
417
+ }
418
+ }
419
+ try {
420
+ return readFileSync(arg, "utf8");
421
+ } catch {
422
+ fail(`cannot read file: ${arg}`);
423
+ }
424
+ }
425
+
426
+ function out(value) {
427
+ console.log(JSON.stringify(value, null, 2));
428
+ }
429
+
430
+ const CONTENT_TYPES = {
431
+ png: "image/png",
432
+ jpg: "image/jpeg",
433
+ jpeg: "image/jpeg",
434
+ gif: "image/gif",
435
+ webp: "image/webp",
436
+ avif: "image/avif",
437
+ svg: "image/svg+xml",
438
+ json: "application/json",
439
+ jsonl: "application/x-ndjson",
440
+ ndjson: "application/x-ndjson",
441
+ txt: "text/plain",
442
+ log: "text/plain",
443
+ csv: "text/csv",
444
+ pdf: "application/pdf",
445
+ };
446
+
447
+ function contentTypeFor(file) {
448
+ const ext = file.split(".").pop()?.toLowerCase() ?? "";
449
+ return CONTENT_TYPES[ext] ?? "application/octet-stream";
450
+ }
451
+
452
+ // Map a filename extension to a shiki language id. Only common languages —
453
+ // shiki knows many more, but this covers the files an agent is likely to
454
+ // `mockpit code`. Unmapped extensions return undefined (shiki "text").
455
+ const LANG_BY_EXT = {
456
+ ts: "typescript",
457
+ tsx: "tsx",
458
+ mts: "typescript",
459
+ cts: "typescript",
460
+ js: "javascript",
461
+ jsx: "jsx",
462
+ mjs: "javascript",
463
+ cjs: "javascript",
464
+ py: "python",
465
+ rb: "ruby",
466
+ go: "go",
467
+ rs: "rust",
468
+ java: "java",
469
+ kt: "kotlin",
470
+ swift: "swift",
471
+ c: "c",
472
+ h: "c",
473
+ cpp: "cpp",
474
+ cc: "cpp",
475
+ hpp: "cpp",
476
+ cs: "csharp",
477
+ php: "php",
478
+ sh: "bash",
479
+ bash: "bash",
480
+ zsh: "bash",
481
+ fish: "bash",
482
+ yml: "yaml",
483
+ yaml: "yaml",
484
+ toml: "toml",
485
+ json: "json",
486
+ jsonl: "json",
487
+ html: "html",
488
+ htm: "html",
489
+ css: "css",
490
+ scss: "scss",
491
+ sql: "sql",
492
+ md: "markdown",
493
+ markdown: "markdown",
494
+ dockerfile: "docker",
495
+ makefile: "make",
496
+ lua: "lua",
497
+ r: "r",
498
+ scala: "scala",
499
+ clj: "clojure",
500
+ ex: "elixir",
501
+ exs: "elixir",
502
+ erl: "erlang",
503
+ hs: "haskell",
504
+ ml: "ocaml",
505
+ nim: "nim",
506
+ dart: "dart",
507
+ groovy: "groovy",
508
+ gradle: "groovy",
509
+ vue: "vue",
510
+ svelte: "svelte",
511
+ xml: "xml",
512
+ graphql: "graphql",
513
+ gql: "graphql",
514
+ };
515
+
516
+ function inferLang(file) {
517
+ const base = file.split("/").pop() ?? file;
518
+ if (/^Dockerfile/i.test(base)) return "docker";
519
+ if (/^Makefile/i.test(base)) return "make";
520
+ const ext = base.split(".").pop()?.toLowerCase() ?? "";
521
+ return LANG_BY_EXT[ext];
522
+ }
523
+
524
+ // POST raw bytes to /api/assets. Returns { id, url, contentType, ... }.
525
+ async function uploadBytes(bytes, { filename, contentType, session, kind } = {}) {
526
+ const params = new URLSearchParams();
527
+ params.set("filename", filename ?? "upload");
528
+ if (session) params.set("session", session);
529
+ if (kind) params.set("kind", kind);
530
+ let res;
531
+ try {
532
+ res = await fetch(`${BASE}/api/assets?${params}`, {
533
+ method: "POST",
534
+ headers: {
535
+ "content-type": contentType ?? "application/octet-stream",
536
+ ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
537
+ },
538
+ body: bytes,
539
+ });
540
+ } catch {
541
+ fail(`server not reachable at ${BASE} — start it with: mockpit serve`);
542
+ }
543
+ const body = await res.json().catch(() => ({}));
544
+ if (!res.ok) fail(body.error ?? `${res.status} ${res.statusText}`);
545
+ return body;
546
+ }
547
+
548
+ // Upload a file from disk to /api/assets.
549
+ async function uploadFile(file, { session, kind } = {}) {
550
+ let bytes;
551
+ try {
552
+ bytes = readFileSync(file);
553
+ } catch {
554
+ fail(`cannot read file: ${file}`);
555
+ }
556
+ return uploadBytes(bytes, {
557
+ filename: file.split(/[\\/]/).pop() ?? "upload",
558
+ contentType: contentTypeFor(file),
559
+ session,
560
+ kind,
561
+ });
562
+ }
563
+
564
+ // Served by every mockpit server without an install (server/icons.ts).
565
+ const BUNDLED_ICON_SETS = ["lucide", "mage"];
566
+
567
+ // Find each Iconify set, upload it as a file asset, and record them all on the
568
+ // project's design in one PUT. A set that cannot be found is reported in
569
+ // `errors` and does not stop the others.
570
+ async function installIconSets(project, prefixes) {
571
+ const { findIconSet } = await import("./initDesign.js");
572
+ const added = [];
573
+ const errors = [];
574
+ for (const prefix of new Set(prefixes)) {
575
+ try {
576
+ const { set } = await findIconSet(prefix, process.cwd());
577
+ const asset = await uploadBytes(new TextEncoder().encode(JSON.stringify(set)), {
578
+ filename: `icons-${prefix}.json`,
579
+ contentType: "application/json",
580
+ kind: "file",
581
+ });
582
+ added.push({ prefix, assetId: asset.id });
583
+ } catch (err) {
584
+ errors.push({ prefix, message: err instanceof Error ? err.message : String(err) });
585
+ }
586
+ }
587
+ if (added.length === 0) return { added: [], errors, design: null };
588
+ const stored = (await api(`${projectPath(project)}/design`)) ?? {};
589
+ const keep = (stored.iconSets ?? []).filter((s) => !added.some((a) => a.prefix === s.prefix));
590
+ const design = await api(`${projectPath(project)}/design`, {
591
+ method: "PUT",
592
+ body: JSON.stringify({ ...stored, iconSets: [...keep, ...added] }),
593
+ });
594
+ return {
595
+ added: design.iconSets.filter((s) => added.some((a) => a.prefix === s.prefix)),
596
+ errors,
597
+ design,
598
+ };
599
+ }
600
+
601
+ // `--doc` is a file when one exists at that path, else the cheat sheet itself.
602
+ function readKitDoc(doc) {
603
+ if (doc === undefined) return undefined;
604
+ try {
605
+ if (existsSync(doc)) return readFileSync(doc, "utf8");
606
+ } catch {}
607
+ return doc;
608
+ }
609
+
610
+ // The server checks the URLs against the CDN allowlist and the doc's length;
611
+ // its one-line error is the message.
612
+ function putProjectKit(project, id, { url, script, doc }) {
613
+ return api(`${projectPath(project)}/kits/${encodeURIComponent(id)}`, {
614
+ method: "PUT",
615
+ body: JSON.stringify({ href: url, script, doc: readKitDoc(doc) }),
616
+ });
617
+ }
618
+
619
+ // Normalize repeated/comma-joined --kit flags into a deduped id list (or
620
+ // undefined). The server allowlists the ids; an unknown one is a clean 400.
621
+ function normalizeKits(flag) {
622
+ if (!flag) return undefined;
623
+ const ids = (Array.isArray(flag) ? flag : [flag])
624
+ .flatMap((s) => String(s).split(","))
625
+ .map((s) => s.trim())
626
+ .filter(Boolean);
627
+ return ids.length > 0 ? [...new Set(ids)] : undefined;
628
+ }
629
+
630
+ // Surface-kind flags accepted by `publish` and `surface add` (the two commands
631
+ // that compose a post from one flag per surface kind). Each is declared
632
+ // `multiple: true` in the parser, so a repeated flag yields an array — letting
633
+ // an author emit several surfaces of the same kind (--diff a --diff b).
634
+ const SURFACE_FLAGS = new Map([
635
+ ["md", "markdown"],
636
+ ["mermaid", "mermaid"],
637
+ ["diff", "diff"],
638
+ ["terminal", "terminal"],
639
+ ["data", "json"],
640
+ ["code", "code"],
641
+ ["image", "image"],
642
+ ]);
643
+
644
+ // Build a single surface object from one flag value. Mirrors the per-kind
645
+ // construction that used to be inlined in `publish` and `surface add`.
646
+ async function buildSurface(kind, value, { session, layout }) {
647
+ const file = value || "-";
648
+ if (kind === "markdown") return { kind: "markdown", markdown: readContent(file) };
649
+ if (kind === "mermaid") return { kind: "mermaid", mermaid: readContent(file) };
650
+ if (kind === "diff")
651
+ return {
652
+ kind: "diff",
653
+ patch: readContent(file),
654
+ ...(layout === "split" && { layout: "split" }),
655
+ };
656
+ if (kind === "terminal") return { kind: "terminal", text: readContent(file) };
657
+ if (kind === "json") {
658
+ const text = readContent(file);
659
+ try {
660
+ return { kind: "json", data: JSON.parse(text) };
661
+ } catch {
662
+ fail(`--data: invalid JSON${value && value !== "-" ? ` in ${value}` : ""}`);
663
+ }
664
+ }
665
+ if (kind === "code") {
666
+ const part = { kind: "code", code: readContent(file) };
667
+ const codeLang = value && value !== "-" ? inferLang(value) : undefined;
668
+ if (codeLang) part.language = codeLang;
669
+ if (value && value !== "-") part.title = value.split("/").pop() || value;
670
+ return part;
671
+ }
672
+ if (kind === "image") {
673
+ const asset = await uploadFile(value, { session, kind: "image" });
674
+ return { kind: "image", assetId: asset.id };
675
+ }
676
+ fail(`unknown surface kind: ${kind}`);
677
+ }
678
+
679
+ // Walk parseArgs `tokens` (which preserve command-line order, including
680
+ // repeats when a surface flag is `multiple: true`) and build one surface per
681
+ // flag occurrence, pulling successive values from each flag's value array.
682
+ // Surfaces render top-to-bottom, so order is user-visible — this honors the
683
+ // order the author wrote the flags, repeats included.
684
+ async function surfacesFromFlags(flags, tokens, { session, layout }) {
685
+ const idx = new Map();
686
+ const out = [];
687
+ for (const t of tokens ?? []) {
688
+ if (t.kind !== "option" || !SURFACE_FLAGS.has(t.name)) continue;
689
+ const flagName = t.name;
690
+ const arr = flags[flagName];
691
+ if (!Array.isArray(arr)) continue;
692
+ const i = idx.get(flagName) ?? 0;
693
+ const value = arr[i];
694
+ if (value === undefined) continue;
695
+ idx.set(flagName, i + 1);
696
+ out.push(await buildSurface(SURFACE_FLAGS.get(flagName), value, { session, layout }));
697
+ }
698
+ return out;
699
+ }
700
+
701
+ // A project is the repo the agent runs in. Resolution order is explicit flag,
702
+ // environment, git remote (owner/repo), then the directory name — so an agent
703
+ // that passes nothing still lands in a stable, human-recognizable project.
704
+ function resolveProject(flags = {}) {
705
+ if (flags.project) return { name: String(flags.project), source: "flag" };
706
+ if (process.env.MOCKPIT_PROJECT) {
707
+ return { name: process.env.MOCKPIT_PROJECT, source: "MOCKPIT_PROJECT" };
708
+ }
709
+ const remote = gitRemoteProject();
710
+ if (remote) return { name: remote, source: "git remote" };
711
+ return { name: process.cwd().split(/[\\/]/).filter(Boolean).pop() || "workspace", source: "cwd" };
712
+ }
713
+
714
+ function gitRemoteProject() {
715
+ let url;
716
+ try {
717
+ url = execFileSync("git", ["remote", "get-url", "origin"], {
718
+ encoding: "utf8",
719
+ stdio: ["ignore", "pipe", "ignore"],
720
+ }).trim();
721
+ } catch {
722
+ return null;
723
+ }
724
+ if (!url) return null;
725
+ const m = url.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/);
726
+ return m ? `${m[1]}/${m[2]}` : null;
727
+ }
728
+
729
+ function slugify(text) {
730
+ return String(text)
731
+ .normalize("NFKD")
732
+ .replace(/[^\w\s-]/g, "")
733
+ .trim()
734
+ .replace(/[\s_]+/g, "-")
735
+ .replace(/-+/g, "-")
736
+ .toLowerCase();
737
+ }
738
+
739
+ const projectPath = (project) => `/api/projects/${encodeURIComponent(project)}`;
740
+
741
+ const mockPath = (slug) => `/api/mocks/${encodeURIComponent(slug)}`;
742
+
743
+ const query = (params) => {
744
+ const q = new URLSearchParams();
745
+ for (const [k, v] of Object.entries(params)) {
746
+ if (v !== undefined && v !== null && v !== false) q.set(k, v === true ? "1" : String(v));
747
+ }
748
+ const s = q.toString();
749
+ return s ? `?${s}` : "";
750
+ };
751
+
752
+ // The flags every mock verb shares: which mock, which state, which variant.
753
+ const MOCK_FLAGS = {
754
+ mock: { type: "string" },
755
+ state: { type: "string" },
756
+ variant: { type: "string" },
757
+ project: { type: "string" },
758
+ session: { type: "string" },
759
+ };
760
+
761
+ function requireMock(flags, example) {
762
+ const slug = flags.mock ? slugify(flags.mock) : "";
763
+ if (!slug) die("--mock needs a slug", example);
764
+ return slug;
765
+ }
766
+
767
+ // A value that may be inline JSON or a file holding it.
768
+ function readJsonFlag(name, value) {
769
+ const text = existsSync(value) ? readFileSync(value, "utf8") : value;
770
+ try {
771
+ return JSON.parse(text);
772
+ } catch {
773
+ die(`--${name} is not valid JSON`, `--${name} '{"size":[16,8,48,1]}'`);
774
+ }
775
+ }
776
+
777
+ const stateLabel = (s) => (s === null || s === undefined ? "" : `${s}/`);
778
+
779
+ // A shell-quoted argument for the ready-to-run lines the CLI prints.
780
+ const shq = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`);
781
+
782
+ // The server's suggested ask as the command that sends it: an agent that was
783
+ // nudged can run it as is. --asks carries ids and scope, which flags can't.
784
+ function suggestedAskLine(slug, ask) {
785
+ return `mockpit ask --mock ${shq(slug)} --asks ${shq(JSON.stringify([ask]))}`;
786
+ }
787
+
788
+ // One line per write: what was published, its URL, the parts found per state,
789
+ // anything that moved, and feedback the user left while the agent worked.
790
+ function printPublished(result, flags) {
791
+ if (flags.json) return out(result);
792
+ if (flags.quiet) return;
793
+ const { mock, post } = result;
794
+ const branch = post.from !== undefined && post.from !== post.version - 1;
795
+ console.log(
796
+ `${mock.slug}/${stateLabel(post.state)}${post.variant} v${post.version}${branch && post.from ? ` (from v${post.from})` : ""} · ${result.url}`,
797
+ );
798
+ for (const { state, parts } of result.parts ?? []) {
799
+ if (parts.length === 0) continue;
800
+ console.log(`parts${state ? ` (${state})` : ""}: ${parts.map((p) => p.name).join(", ")}`);
801
+ }
802
+ if (result.applied?.length) console.log(`applied: ${result.applied.join(", ")}`);
803
+ const changes = result.partChanges;
804
+ if (changes?.renamed?.length) {
805
+ console.log(`renamed: ${changes.renamed.map((r) => `${r.from} → ${r.to}`).join(", ")}`);
806
+ }
807
+ if (changes?.vanished?.length) console.log(`vanished: ${changes.vanished.join(", ")}`);
808
+ for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
809
+ if (result.suggestedAsk)
810
+ console.log(`suggested: ${suggestedAskLine(mock.slug, result.suggestedAsk)}`);
811
+ for (const warning of result.warnings ?? []) console.log(`warning: ${warning}`);
812
+ printFeedback(result.feedback);
813
+ }
814
+
815
+ // Only speak when there IS feedback: an empty line per write is pure token
816
+ // cost, and the cursor guarantees anything pending arrives on some write.
817
+ function printFeedback(feedback) {
818
+ if (feedback?.length) console.log(`feedback: ${JSON.stringify(feedback)}`);
819
+ }
820
+
821
+ // What the user is doing right now. viewerOpen is workspace-wide, so it is
822
+ // said once rather than per mock.
823
+ function pendingLines(pending) {
824
+ const rows = pending ?? [];
825
+ const lines = rows
826
+ .filter((p) => p.draft)
827
+ .map(
828
+ (p) =>
829
+ `pending: ${p.mock} — the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comment${p.draft.comments === 1 ? "" : "s"})`,
830
+ );
831
+ if (lines.length === 0 && rows.some((p) => p.viewerOpen))
832
+ lines.push("pending: the viewer is open");
833
+ return lines;
834
+ }
835
+
836
+ function parsePublishFlags() {
837
+ return parse({
838
+ tokens: true,
839
+ allowPositionals: true,
840
+ options: {
841
+ ...MOCK_FLAGS,
842
+ title: { type: "string" },
843
+ kind: { type: "string" },
844
+ html: { type: "string" },
845
+ surfaces: { type: "string" },
846
+ parts: { type: "string", multiple: true },
847
+ knobs: { type: "string" },
848
+ from: { type: "string" },
849
+ prompt: { type: "string" },
850
+ kit: { type: "string", multiple: true },
851
+ layout: { type: "string" },
852
+ md: { type: "string", multiple: true },
853
+ mermaid: { type: "string", multiple: true },
854
+ diff: { type: "string", multiple: true },
855
+ terminal: { type: "string", multiple: true },
856
+ data: { type: "string", multiple: true },
857
+ code: { type: "string", multiple: true },
858
+ image: { type: "string", multiple: true },
859
+ "session-title": { type: "string" },
860
+ agent: { type: "string" },
861
+ "new-session": { type: "boolean" },
862
+ },
863
+ });
864
+ }
865
+
866
+ // `--parts name=file` (repeatable; `name#key` targets one instance): the new
867
+ // outer html of one part, spliced server-side into the latest version.
868
+ function readPartFlags(values, slug) {
869
+ if (!values?.length) return undefined;
870
+ const example = `mockpit publish --mock ${slug} --parts body=body.html`;
871
+ const parts = {};
872
+ let stdin = false;
873
+ for (const raw of values) {
874
+ const at = raw.indexOf("=");
875
+ if (at <= 0 || at === raw.length - 1) die(`--parts needs name=file (got "${raw}")`, example);
876
+ const name = raw.slice(0, at);
877
+ const file = raw.slice(at + 1);
878
+ if (file === "-") {
879
+ if (stdin) die("only one --parts can read stdin", example);
880
+ stdin = true;
881
+ } else if (!existsSync(file)) die(`cannot read ${file}`, `ls ${file}`);
882
+ parts[name] = readContent(file);
883
+ }
884
+ return parts;
885
+ }
886
+
887
+ async function publishMock() {
888
+ const { values: flags, positionals, tokens } = parsePublishFlags();
889
+ const slug = requireMock(flags, "mockpit publish --mock writer --html writer.html");
890
+ const project = resolveProject(flags).name;
891
+ const from = flags.from === undefined ? undefined : Number(flags.from);
892
+ if (from !== undefined && !Number.isInteger(from)) {
893
+ die(`--from must be a version number (got "${flags.from}")`, `mockpit read ${slug} --history`);
894
+ }
895
+ const file = flags.html ?? positionals[0];
896
+ const composed =
897
+ file !== undefined || tokens.some((t) => t.kind === "option" && SURFACE_FLAGS.has(t.name));
898
+ const given = [
899
+ composed && "--html",
900
+ flags.surfaces !== undefined && "--surfaces",
901
+ flags.parts?.length && "--parts",
902
+ ].filter(Boolean);
903
+ if (given.length > 1) {
904
+ die(
905
+ `pass one of --html, --surfaces or --parts, not ${given.join(" and ")}`,
906
+ `mockpit publish --help`,
907
+ );
908
+ }
909
+ // Checked before the session exists so a typo'd path leaves nothing behind on the server.
910
+ if (file && file !== "-" && !existsSync(file)) die(`cannot read ${file}`, `ls ${file}`);
911
+ const parts = readPartFlags(flags.parts, slug);
912
+ let surfaces =
913
+ flags.surfaces === undefined ? undefined : readJsonFlag("surfaces", flags.surfaces);
914
+ if (surfaces !== undefined && !Array.isArray(surfaces)) {
915
+ die(
916
+ "--surfaces must be a JSON array",
917
+ `--surfaces '[{"id":"<id>"},{"kind":"markdown","markdown":"# notes"}]'`,
918
+ );
919
+ }
920
+ const session = await resolveSession(flags, { create: true });
921
+ if (composed) {
922
+ surfaces = [];
923
+ if (file) {
924
+ const html = { kind: "html", html: readContent(file) };
925
+ const kits = normalizeKits(flags.kit);
926
+ if (kits) html.kits = kits;
927
+ surfaces.push(html);
928
+ }
929
+ surfaces.push(...(await surfacesFromFlags(flags, tokens, { session, layout: flags.layout })));
930
+ }
931
+ if (!surfaces && !parts)
932
+ die(`no html for ${slug}`, `mockpit publish --mock ${slug} --html <file>`);
933
+ const body = {
934
+ session,
935
+ project,
936
+ mock: slug,
937
+ state: flags.state,
938
+ variant: flags.variant,
939
+ title: flags.title,
940
+ kind: flags.kind,
941
+ ...(flags.knobs !== undefined && { knobs: readJsonFlag("knobs", flags.knobs) }),
942
+ ...(from !== undefined && { from }),
943
+ prompt: flags.prompt,
944
+ ...(surfaces && { surfaces }),
945
+ ...(parts && { parts }),
946
+ };
947
+ const result = await api(
948
+ "/api/mocks",
949
+ { method: "POST", body: JSON.stringify(body) },
950
+ { report: "die", fix: `mockpit read ${slug}` },
951
+ );
952
+ printPublished(result, flags);
953
+ return result;
954
+ }
955
+
956
+ // "Quiet" is an unbound option; "Quiet=quiet" binds it to the quiet variant.
957
+ function parseOption(raw) {
958
+ const at = raw.lastIndexOf("=");
959
+ if (at <= 0) return { label: raw };
960
+ return { label: raw.slice(0, at), variant: raw.slice(at + 1) };
961
+ }
962
+
963
+ // One feedback batch, one line — for a background monitor's notifications.
964
+ // The run envelope for a reader: what landed, what the script said, how it
965
+ // ended, and the feedback it took (which no later read will return).
966
+ function printRun(r) {
967
+ for (const c of r.calls ?? []) console.log(`${c.ok ? "ok " : "fail"} ${c.fn} ${c.summary}`);
968
+ for (const line of r.prints ?? []) console.log(`> ${line}`);
969
+ if (r.error) {
970
+ const at = r.error.line
971
+ ? ` at ${r.error.line}${r.error.column ? `:${r.error.column}` : ""}`
972
+ : "";
973
+ console.log(`error ${r.error.kind}${at}: ${r.error.message}`);
974
+ } else if (r.value !== undefined) {
975
+ console.log(typeof r.value === "string" ? r.value : JSON.stringify(r.value, null, 2));
976
+ }
977
+ if (r.feedback?.length) console.log(`feedback\n${JSON.stringify(r.feedback, null, 2)}`);
978
+ if (r.truncated) console.log("(output truncated)");
979
+ }
980
+
981
+ function watchLines(batch) {
982
+ const lines = [];
983
+ const name = batch.mock ?? "a mock";
984
+ if (batch.reply) {
985
+ const r = batch.reply;
986
+ const bits = [
987
+ r.asks?.length
988
+ ? r.asks
989
+ .map(
990
+ (a) =>
991
+ `${a.text || a.ask}: ${a.chosen.map((o) => (o.other ? `“${o.label}”` : o.label)).join("+")}${a.note ? ` (“${a.note}”)` : ""}`,
992
+ )
993
+ .join("; ")
994
+ : "",
995
+ Object.keys(r.tuned ?? {}).length ? `${Object.keys(r.tuned).length} tuned` : "",
996
+ Object.keys(r.mix ?? {}).length
997
+ ? `mix ${Object.entries(r.mix)
998
+ .map(([p, v]) => `${p}←${v}`)
999
+ .join(", ")}`
1000
+ : "",
1001
+ r.comments?.length ? `${r.comments.length} comment${r.comments.length === 1 ? "" : "s"}` : "",
1002
+ r.decision ? r.decision.kind : "",
1003
+ r.text ?? "",
1004
+ ].filter(Boolean);
1005
+ lines.push(`mockpit reply on ${name}: ${bits.join(" · ").replace(/\s+/g, " ")}`);
1006
+ }
1007
+ for (const c of batch.comments ?? []) {
1008
+ lines.push(
1009
+ `mockpit comment on ${name}: “${String(c.text ?? "")
1010
+ .replace(/\s+/g, " ")
1011
+ .trim()}”`,
1012
+ );
1013
+ }
1014
+ return lines;
1015
+ }
1016
+
1017
+ // GET where "not there yet" is an answer, not an exit — a project or mock that
1018
+ // doesn't exist is the normal state before the first publish.
1019
+ async function apiSoft(path) {
1020
+ let res;
1021
+ try {
1022
+ res = await fetch(`${BASE}${path}`, {
1023
+ headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
1024
+ });
1025
+ } catch {
1026
+ die(`cannot reach mockpit at ${BASE}`, "mockpit serve");
1027
+ }
1028
+ if (!res.ok) return null;
1029
+ return res.json().catch(() => null);
1030
+ }
1031
+
1032
+ // Keep the scratch directory out of the repo. Returns true when .gitignore was
1033
+ // touched, so init can report it.
1034
+ function ignoreMockpitDir() {
1035
+ const file = join(process.cwd(), ".gitignore");
1036
+ let current = "";
1037
+ try {
1038
+ current = readFileSync(file, "utf8");
1039
+ } catch {
1040
+ // no .gitignore yet — create one
1041
+ }
1042
+ if (/^\.mockpit\/?$/m.test(current)) return false;
1043
+ writeFileSync(
1044
+ file,
1045
+ current && !current.endsWith("\n") ? `${current}\n.mockpit/\n` : `${current}.mockpit/\n`,
1046
+ );
1047
+ return true;
1048
+ }
1049
+
1050
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1051
+
1052
+ // Semver greater-than for plain x.y.z (mirrors server/app.ts versionGt).
1053
+ function versionGt(a, b) {
1054
+ const pa = a.split("-")[0].split(".").map(Number);
1055
+ const pb = b.split("-")[0].split(".").map(Number);
1056
+ for (let i = 0; i < 3; i++) {
1057
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
1058
+ if (d !== 0) return d > 0;
1059
+ }
1060
+ return false;
1061
+ }
1062
+
1063
+ // Disk-cached update check so `mockpit version` doesn't hit the registry every
1064
+ // time. TTL = 24 hours; stale/missing/corrupt cache is silently ignored.
1065
+ const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
1066
+
1067
+ function updateCachePath() {
1068
+ const dir = join(tmpdir(), `mockpit-${userInfo().username}`);
1069
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1070
+ return join(dir, "update-check.json");
1071
+ }
1072
+
1073
+ function readUpdateCache() {
1074
+ try {
1075
+ const data = JSON.parse(readFileSync(updateCachePath(), "utf8"));
1076
+ if (Date.now() - data.at < UPDATE_CACHE_TTL_MS && typeof data.version === "string") {
1077
+ return data.version;
1078
+ }
1079
+ } catch {}
1080
+ return null;
1081
+ }
1082
+
1083
+ function writeUpdateCache(version) {
1084
+ try {
1085
+ writeFileSync(updateCachePath(), JSON.stringify({ at: Date.now(), version }));
1086
+ } catch {}
1087
+ }
1088
+
1089
+ const [cmd, ...rest] = process.argv.slice(2);
1090
+
1091
+ // Subcommand flag parsing. parseArgs is strict, so without this --help (or
1092
+ // any typo) throws a raw stack trace; instead --help/-h prints usage and
1093
+ // exits 0, and an unknown option fails with a one-line hint.
1094
+ //
1095
+ // Ids are base64url and can start with - or _ (~1/64 each). parseArgs strict
1096
+ // mode treats those as unknown options ("Unknown option '-6'" for an id like
1097
+ // "-6K4AJsKD4M"). We swap any id-shaped token that starts with a separator
1098
+ // for a sentinel before parsing, then restore it in the result — so positionals,
1099
+ // tokens, and option values all get the original id back, in the right order.
1100
+ const ID_LIKE = /^[-_](?![-_])[A-Za-z0-9_-]{7,}$/;
1101
+ function parse(config = {}) {
1102
+ const rescued = new Map();
1103
+ const args = rest.map((a) => {
1104
+ if (ID_LIKE.test(a)) {
1105
+ const s = `\x00${rescued.size}\x00`;
1106
+ rescued.set(s, a);
1107
+ return s;
1108
+ }
1109
+ return a;
1110
+ });
1111
+ let parsed;
1112
+ try {
1113
+ parsed = parseArgs({
1114
+ args,
1115
+ ...config,
1116
+ // --json/--quiet are global, but a command may redefine them (legacy
1117
+ // `publish --json <file>` adds a json surface), so config wins.
1118
+ options: {
1119
+ json: { type: "boolean" },
1120
+ quiet: { type: "boolean" },
1121
+ ...config.options,
1122
+ help: { type: "boolean", short: "h" },
1123
+ },
1124
+ });
1125
+ } catch (err) {
1126
+ if (!String(err?.code).startsWith("ERR_PARSE_ARGS")) throw err;
1127
+ fail(`${err.message.split(". ")[0]} — run "mockpit help"`);
1128
+ }
1129
+ if (parsed.values.help) printAndExit(COMMAND_HELP[cmd] ?? HELP);
1130
+ const restore = (v) => (typeof v === "string" && rescued.has(v) ? rescued.get(v) : v);
1131
+ if (parsed.positionals) parsed.positionals = parsed.positionals.map(restore);
1132
+ if (parsed.tokens) {
1133
+ parsed.tokens = parsed.tokens.map((t) =>
1134
+ t.kind === "positional" && rescued.has(t.value) ? { ...t, value: rescued.get(t.value) } : t,
1135
+ );
1136
+ }
1137
+ for (const k of Object.keys(parsed.values ?? {})) {
1138
+ const v = parsed.values[k];
1139
+ parsed.values[k] = Array.isArray(v) ? v.map(restore) : restore(v);
1140
+ }
1141
+ return parsed;
1142
+ }
1143
+
1144
+ // Development checkouts run TypeScript directly (Node strips types), but Node
1145
+ // refuses to type-strip files under node_modules — installed packages ship
1146
+ // compiled JS in dist/ (built on prepack) and must use it.
1147
+ function entrypoint(...parts) {
1148
+ const built = join(ROOT, "dist", ...parts).replace(/\.ts$/, ".js");
1149
+ return existsSync(built) ? built : join(ROOT, ...parts);
1150
+ }
1151
+
1152
+ const commands = {
1153
+ async serve() {
1154
+ const { values: flags } = parse({
1155
+ options: {
1156
+ port: { type: "string" },
1157
+ host: { type: "string" },
1158
+ open: { type: "boolean" },
1159
+ },
1160
+ });
1161
+ const port = flags.port ?? process.env.PORT ?? "8228";
1162
+ const host = flags.host ?? process.env.MOCKPIT_HOST;
1163
+ const child = spawn(process.execPath, [entrypoint("server", "index.ts")], {
1164
+ stdio: "inherit",
1165
+ env: { ...process.env, PORT: port, ...(host ? { MOCKPIT_HOST: host } : {}) },
1166
+ });
1167
+ if (flags.open) {
1168
+ const url = serveUrl(host, port);
1169
+ const { opener, openerArgs } =
1170
+ process.platform === "darwin"
1171
+ ? { opener: "open", openerArgs: [url] }
1172
+ : process.platform === "win32"
1173
+ ? { opener: "cmd", openerArgs: ["/c", "start", url] }
1174
+ : { opener: "xdg-open", openerArgs: [url] };
1175
+ setTimeout(() => spawn(opener, openerArgs, { stdio: "ignore" }), 700);
1176
+ }
1177
+ child.on("exit", (code) => process.exit(code ?? 0));
1178
+ },
1179
+
1180
+ async mcp() {
1181
+ parse();
1182
+ const child = spawn(process.execPath, [entrypoint("mcp", "server.ts")], {
1183
+ stdio: "inherit",
1184
+ env: process.env,
1185
+ });
1186
+ child.on("exit", (code) => process.exit(code ?? 0));
1187
+ },
1188
+
1189
+ // Detect the repo's design system once, store it on the project, and leave a
1190
+ // starter file behind — so every later publish is markup only, with no CSS or
1191
+ // icon paths pasted into the agent's context.
1192
+ async init() {
1193
+ const { values: flags } = parse({
1194
+ options: {
1195
+ project: { type: "string" },
1196
+ kit: { type: "string" },
1197
+ "kit-url": { type: "string" },
1198
+ "kit-script": { type: "string" },
1199
+ "kit-doc": { type: "string" },
1200
+ },
1201
+ });
1202
+ const { name: project, source } = resolveProject(flags);
1203
+ const say = (label, text) => {
1204
+ if (!flags.quiet && !flags.json) console.log(`${label.padEnd(8)} ${text}`);
1205
+ };
1206
+ const { detectDesign, detectIconSets, renderStarter } = await import("./initDesign.js");
1207
+ say("project:", `${project} (from ${source})`);
1208
+
1209
+ const design = await detectDesign(process.cwd());
1210
+ const d = design.detected;
1211
+ // `detected` is always an object, so emptiness has to be read off the
1212
+ // fields — otherwise a repo with no design system printed a blank line.
1213
+ const stripped = design.strippedImports?.length
1214
+ ? ` (stripped: ${design.strippedImports.join(", ")})`
1215
+ : "";
1216
+ const found = [
1217
+ d?.tailwind &&
1218
+ (design.tailwindSource ? `tailwind: ${design.tailwindSource}${stripped}` : "tailwind"),
1219
+ d?.shadcn && "shadcn",
1220
+ d?.cssVars ? `${d.cssVars} css vars${design.source ? ` from ${design.source}` : ""}` : null,
1221
+ d?.fonts?.length ? `fonts ${d.fonts.join(", ")}` : null,
1222
+ ].filter(Boolean);
1223
+ say(
1224
+ "design:",
1225
+ found.length ? found.join(" · ") : "nothing detected — using the built-in palette",
1226
+ );
1227
+ let kit = flags.kit ?? design.kit ?? "builtin";
1228
+ // The project kit is stored first so the design PUT can name it as default.
1229
+ if (flags["kit-url"] !== undefined) {
1230
+ kit = flags.kit ?? "project";
1231
+ await putProjectKit(project, kit, {
1232
+ url: flags["kit-url"],
1233
+ script: flags["kit-script"],
1234
+ doc: flags["kit-doc"],
1235
+ });
1236
+ }
1237
+ say("kit:", kit);
1238
+ const files = design.designFiles;
1239
+ if (files) {
1240
+ const sh = files.shadcn;
1241
+ say(
1242
+ "files:",
1243
+ [
1244
+ files.designMd && "DESIGN.md",
1245
+ files.tokens && `${files.tokens.count} tokens`,
1246
+ sh && `shadcn${sh.style ? ` ${sh.style}` : ""} (${sh.components.length} components)`,
1247
+ ]
1248
+ .filter(Boolean)
1249
+ .join(" · "),
1250
+ );
1251
+ }
1252
+
1253
+ let stored = await api(`${projectPath(project)}/design`, {
1254
+ method: "PUT",
1255
+ body: JSON.stringify({
1256
+ detected: design.detected ?? null,
1257
+ palette: design.palette ?? null,
1258
+ cssVars: design.cssVars ?? "",
1259
+ tailwindCss: design.tailwindCss ?? "",
1260
+ strippedImports: design.strippedImports ?? [],
1261
+ kit,
1262
+ designFiles: design.designFiles ?? null,
1263
+ }),
1264
+ });
1265
+
1266
+ // Sets the repo already uses are added the same way `icons add` does; one
1267
+ // that cannot be fetched (offline) is reported, never fatal to init.
1268
+ const usedSets = detectIconSets(process.cwd());
1269
+ const wanted = usedSets.filter((f) => !BUNDLED_ICON_SETS.includes(f.prefix));
1270
+ for (const f of usedSets) {
1271
+ if (BUNDLED_ICON_SETS.includes(f.prefix))
1272
+ say("icons:", `${f.prefix} (bundled, from ${f.from})`);
1273
+ }
1274
+ if (wanted.length) {
1275
+ const result = await installIconSets(
1276
+ project,
1277
+ wanted.map((f) => f.prefix),
1278
+ );
1279
+ for (const f of wanted) {
1280
+ const added = result.added.find((a) => a.prefix === f.prefix);
1281
+ const error = result.errors.find((e) => e.prefix === f.prefix);
1282
+ if (added) say("icons:", `${f.prefix} (${added.count} icons, from ${f.from})`);
1283
+ else say("icons:", `${f.prefix} not added: ${error?.message ?? "unknown error"}`);
1284
+ }
1285
+ if (result.design) stored = result.design;
1286
+ }
1287
+ const iconSets = [
1288
+ ...new Set([...(stored?.iconSets ?? []).map((s) => s.prefix), ...BUNDLED_ICON_SETS]),
1289
+ ];
1290
+ say("icons:", `${iconSets.join(", ")} available, as <i icon="prefix:name"></i>`);
1291
+
1292
+ const starter = join(process.cwd(), ".mockpit", "starter.html");
1293
+ mkdirSync(dirname(starter), { recursive: true });
1294
+ writeFileSync(starter, renderStarter({ ...design, kit }, iconSets));
1295
+ say("wrote:", ".mockpit/starter.html");
1296
+ if (ignoreMockpitDir()) say("wrote:", ".gitignore (+ .mockpit/)");
1297
+ say("next:", "mockpit guide");
1298
+ if (flags.json) out({ project, design: stored, starter });
1299
+ },
1300
+
1301
+ async publish() {
1302
+ await publishMock();
1303
+ },
1304
+
1305
+ async ask() {
1306
+ const { values: flags, positionals } = parse({
1307
+ allowPositionals: true,
1308
+ options: {
1309
+ mock: { type: "string" },
1310
+ project: { type: "string" },
1311
+ session: { type: "string" },
1312
+ option: { type: "string", multiple: true },
1313
+ scope: { type: "string" },
1314
+ state: { type: "string" },
1315
+ part: { type: "string" },
1316
+ multi: { type: "boolean" },
1317
+ id: { type: "string" },
1318
+ asks: { type: "string" },
1319
+ },
1320
+ });
1321
+ const slug = requireMock(flags, 'mockpit ask --mock writer "Which look?" --option Quiet=quiet');
1322
+ let asks;
1323
+ if (flags.asks !== undefined) {
1324
+ if (positionals.length > 0) {
1325
+ die(
1326
+ "use the question or --asks, not both",
1327
+ `mockpit ask --mock ${slug} --asks <json|file>`,
1328
+ );
1329
+ }
1330
+ asks = readJsonFlag("asks", flags.asks);
1331
+ if (!Array.isArray(asks)) asks = [asks];
1332
+ } else {
1333
+ const text = positionals.join(" ").trim();
1334
+ if (!text)
1335
+ die(
1336
+ "ask needs a question",
1337
+ `mockpit ask --mock ${slug} "Which look?" --option Quiet=quiet`,
1338
+ );
1339
+ const options = (flags.option ?? []).map(parseOption);
1340
+ if (options.length === 0) {
1341
+ die(
1342
+ "ask needs options",
1343
+ `mockpit ask --mock ${slug} "${text}" --option Quiet=quiet --option Dark=dark`,
1344
+ );
1345
+ }
1346
+ asks = [
1347
+ {
1348
+ text,
1349
+ options,
1350
+ ...(flags.id && { id: flags.id }),
1351
+ ...(flags.scope && { scope: flags.scope }),
1352
+ ...(flags.state && { state: flags.state }),
1353
+ ...(flags.part && { part: flags.part }),
1354
+ ...(flags.multi && { multi: true }),
1355
+ },
1356
+ ];
1357
+ }
1358
+ const session = await resolveSession(flags);
1359
+ const result = await api(
1360
+ `${mockPath(slug)}/asks`,
1361
+ {
1362
+ method: "POST",
1363
+ body: JSON.stringify({
1364
+ project: resolveProject(flags).name,
1365
+ session: session ?? undefined,
1366
+ asks,
1367
+ }),
1368
+ },
1369
+ { report: "die", fix: `mockpit read ${slug}` },
1370
+ );
1371
+ if (flags.json) return out(result);
1372
+ if (flags.quiet) return;
1373
+ for (const ask of result.asks) {
1374
+ console.log(
1375
+ `asked on ${result.mock}: ${ask.text} [${ask.options.map((o) => o.label).join(" | ")}]`,
1376
+ );
1377
+ }
1378
+ console.log(`tell the user to look at ${result.url}, then end your turn`);
1379
+ printFeedback(result.feedback);
1380
+ },
1381
+
1382
+ // Metadata by default. Bodies and version rows are opt-in, because a full
1383
+ // variant with its history is the biggest thing an agent can pull into context.
1384
+ async read() {
1385
+ const { values: flags, positionals } = parse({
1386
+ allowPositionals: true,
1387
+ options: {
1388
+ project: { type: "string" },
1389
+ body: { type: "boolean" },
1390
+ history: { type: "boolean" },
1391
+ },
1392
+ });
1393
+ const project = resolveProject(flags).name;
1394
+ if (positionals.length > 1) die("read takes one mock slug", "mockpit read writer");
1395
+ if (positionals.length === 1) {
1396
+ const slug = slugify(positionals[0]);
1397
+ return out(
1398
+ await api(
1399
+ `${mockPath(slug)}${query({ project, body: flags.body, history: flags.history })}`,
1400
+ {},
1401
+ { report: "die", fix: `mockpit read --project ${project}` },
1402
+ ),
1403
+ );
1404
+ }
1405
+ const list = (await apiSoft(`/api/mocks${query({ project })}`)) ?? {
1406
+ mocks: [],
1407
+ open: 0,
1408
+ pending: [],
1409
+ };
1410
+ if (flags.json) return out(list);
1411
+ if (flags.quiet) return;
1412
+ const mocks = list.mocks ?? [];
1413
+ console.log(
1414
+ `${project} · ${mocks.length} mock${mocks.length === 1 ? "" : "s"}` +
1415
+ (list.open
1416
+ ? ` · ${list.open} open ask${list.open === 1 ? "" : "s"}`
1417
+ : " · nothing waiting on the user"),
1418
+ );
1419
+ for (const m of mocks) {
1420
+ const states = m.states.length ? m.states.join(" / ") : "single state";
1421
+ console.log(
1422
+ ` ${m.slug} · ${m.kind} · ${states} · ${m.variants} variant${m.variants === 1 ? "" : "s"}${m.open ? ` · ${m.open} open` : ""}`,
1423
+ );
1424
+ }
1425
+ for (const line of pendingLines(list.pending)) console.log(line);
1426
+ },
1427
+ async export() {
1428
+ const { values: flags } = parse({
1429
+ options: {
1430
+ mock: { type: "string" },
1431
+ state: { type: "string" },
1432
+ variant: { type: "string" },
1433
+ project: { type: "string" },
1434
+ out: { type: "string" },
1435
+ },
1436
+ });
1437
+ const slug = requireMock(flags, "mockpit export --mock writer");
1438
+ const project = resolveProject(flags).name;
1439
+ const data = await api(
1440
+ `${mockPath(slug)}/export${query({ project, state: flags.state, variant: flags.variant })}`,
1441
+ {},
1442
+ { report: "die", fix: `mockpit read ${slug}` },
1443
+ );
1444
+ if (flags.json) return out(data);
1445
+ const root = join(flags.out ? flags.out : join(process.cwd(), ".mockpit", "accepted"), slug);
1446
+ for (const entry of data.states) {
1447
+ const dir = join(root, entry.state === null ? "default" : slugify(entry.state));
1448
+ mkdirSync(dir, { recursive: true });
1449
+ writeFileSync(join(dir, "index.html"), entry.html ?? "");
1450
+ writeFileSync(
1451
+ join(dir, "history.json"),
1452
+ JSON.stringify(
1453
+ {
1454
+ project,
1455
+ mock: slug,
1456
+ state: entry.state,
1457
+ variant: entry.variant,
1458
+ version: entry.version,
1459
+ status: entry.status,
1460
+ history: entry.history,
1461
+ ...(entry.screenshotUrl && { screenshotUrl: entry.screenshotUrl }),
1462
+ ...(data.reply && { tuned: data.reply.tuned }),
1463
+ },
1464
+ null,
1465
+ 2,
1466
+ ) + "\n",
1467
+ );
1468
+ if (!flags.quiet) {
1469
+ console.log(
1470
+ `${slug}/${stateLabel(entry.state)}${entry.variant} v${entry.version} → ${dir}`,
1471
+ );
1472
+ }
1473
+ }
1474
+ },
1475
+
1476
+ async run() {
1477
+ const { values: flags, positionals } = parse({
1478
+ allowPositionals: true,
1479
+ options: {
1480
+ session: { type: "string" },
1481
+ project: { type: "string" },
1482
+ agent: { type: "string" },
1483
+ },
1484
+ });
1485
+ if (positionals.length !== 1)
1486
+ die("run needs one script", "mockpit run loop.js (- reads stdin)");
1487
+ const code = readContent(positionals[0]);
1488
+ const session = await resolveSession(flags, { create: true });
1489
+ const result = await api(
1490
+ "/api/run",
1491
+ {
1492
+ method: "POST",
1493
+ body: JSON.stringify({
1494
+ code,
1495
+ session,
1496
+ project: resolveProject(flags).name,
1497
+ agent: agentName(flags),
1498
+ }),
1499
+ },
1500
+ { report: "die" },
1501
+ );
1502
+ if (flags.json) out(result);
1503
+ else if (!flags.quiet) printRun(result);
1504
+ if (!result.ok) process.exit(2);
1505
+ },
1506
+
1507
+ // Never waits: the server reads from the session's cursor, shared with watch
1508
+ // and piggyback, so each Send is delivered exactly once.
1509
+ async feedback() {
1510
+ const { values: flags } = parse({
1511
+ options: { session: { type: "string" }, project: { type: "string" } },
1512
+ });
1513
+ const session = await resolveSession(flags, { create: true });
1514
+ const result = await api(
1515
+ `/api/feedback${query({ session, project: resolveProject(flags).name })}`,
1516
+ {},
1517
+ { report: "die" },
1518
+ );
1519
+ if (!flags.quiet) out(result);
1520
+ },
1521
+ async watch() {
1522
+ const { values: flags } = parse({
1523
+ options: { session: { type: "string" }, after: { type: "string" } },
1524
+ });
1525
+ if (flags.after !== undefined && !/^\d+$/.test(flags.after)) {
1526
+ fail(`--after must be a number (got "${flags.after}")`);
1527
+ }
1528
+ // A continuous long-poll that streams each piece of feedback as one line —
1529
+ // one line is one monitor notification. It re-arms forever; a transient
1530
+ // network error backs off and retries rather than exiting.
1531
+ //
1532
+ // After the first poll it carries no client cursor: an author=user read
1533
+ // resumes from the session's server-side cursor and advances it, so
1534
+ // feedback is delivered exactly once across watch, feedback, and piggyback.
1535
+ let firstAfter = flags.after;
1536
+ for (;;) {
1537
+ const session = (await resolveSession(flags)) ?? (await resolveSessionByCwd());
1538
+ if (!session) {
1539
+ await sleep(2000);
1540
+ continue;
1541
+ }
1542
+ let result;
1543
+ try {
1544
+ const res = await fetch(
1545
+ `${BASE}/api/comments${query({ session, author: "user", after: firstAfter, wait: 60 })}`,
1546
+ { headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {} },
1547
+ );
1548
+ if (!res.ok) {
1549
+ await sleep(2000);
1550
+ continue;
1551
+ }
1552
+ result = await res.json();
1553
+ } catch {
1554
+ await sleep(2000);
1555
+ continue;
1556
+ }
1557
+ firstAfter = undefined;
1558
+ for (const batch of result.feedback ?? []) {
1559
+ for (const line of watchLines(batch)) console.log(line);
1560
+ }
1561
+ }
1562
+ },
1563
+
1564
+ async say() {
1565
+ const { values: flags, positionals } = parse({ allowPositionals: true, options: MOCK_FLAGS });
1566
+ const text = positionals.join(" ").trim();
1567
+ if (!text) die("say needs text", 'mockpit say "…" --mock writer');
1568
+ const slug = requireMock(flags, `mockpit say "${text}" --mock writer`);
1569
+ const session = await resolveSession(flags);
1570
+ const result = await api(
1571
+ `${mockPath(slug)}/say`,
1572
+ {
1573
+ method: "POST",
1574
+ body: JSON.stringify({
1575
+ message: text,
1576
+ state: flags.state,
1577
+ variant: flags.variant,
1578
+ project: resolveProject(flags).name,
1579
+ session: session ?? undefined,
1580
+ }),
1581
+ },
1582
+ { report: "die", fix: `mockpit read ${slug}` },
1583
+ );
1584
+ if (flags.json) return out(result);
1585
+ if (flags.quiet) return;
1586
+ console.log(`said on ${slug}`);
1587
+ printFeedback(result.feedback);
1588
+ },
1589
+ async demo() {
1590
+ parse();
1591
+ const { DEMO } = await import("./demoData.js");
1592
+ // The demo goes in through the same path an agent uses: one session, one
1593
+ // publish per (state, variant), then the asks.
1594
+ const session = await api("/api/sessions", {
1595
+ method: "POST",
1596
+ body: JSON.stringify({ agent: DEMO.agent, title: DEMO.sessionTitle, project: DEMO.project }),
1597
+ });
1598
+ for (const state of DEMO.states) {
1599
+ for (const variant of DEMO.variants) {
1600
+ await api("/api/mocks", {
1601
+ method: "POST",
1602
+ body: JSON.stringify({
1603
+ session: session.id,
1604
+ project: DEMO.project,
1605
+ mock: DEMO.slug,
1606
+ title: DEMO.title,
1607
+ state: state.label,
1608
+ variant: variant.name,
1609
+ knobs: DEMO.knobs,
1610
+ surfaces: [{ kind: "html", html: DEMO.render(state, variant) }],
1611
+ }),
1612
+ });
1613
+ }
1614
+ }
1615
+ await api(`${mockPath(DEMO.slug)}/asks`, {
1616
+ method: "POST",
1617
+ body: JSON.stringify({ project: DEMO.project, session: session.id, asks: DEMO.asks }),
1618
+ });
1619
+ console.log(
1620
+ `Seeded ${DEMO.project} › ${DEMO.title} (${DEMO.states.length} states × ${DEMO.variants.length} variants) — open ${BASE}/project/${encodeURIComponent(DEMO.project)}/${DEMO.slug}`,
1621
+ );
1622
+ },
1623
+
1624
+ async upload() {
1625
+ const { values: flags, positionals } = parse({
1626
+ allowPositionals: true,
1627
+ options: { session: { type: "string" }, kind: { type: "string" } },
1628
+ });
1629
+ const file = positionals[0];
1630
+ if (!file || file === "-") fail("usage: mockpit upload <file> [--kind k] [--session id]");
1631
+ const session = flags.session ?? (await resolveSession(flags, { create: true }));
1632
+ const asset = await uploadFile(file, { session, kind: flags.kind });
1633
+ out(asset);
1634
+ },
1635
+
1636
+ // Print the URL a file WILL have once uploaded, derived from its content hash
1637
+ // alone — no server call. Lets you write an <img src> (or reference the id)
1638
+ // before, or in parallel with, the upload. Matches the server's hashAssetId.
1639
+ async "asset-url"() {
1640
+ const { positionals } = parse({ allowPositionals: true, options: {} });
1641
+ const file = positionals[0];
1642
+ if (!file || file === "-") fail("usage: mockpit asset-url <file>");
1643
+ const id = createHash("sha256").update(readFileSync(file)).digest("hex");
1644
+ out({ id, url: `${BASE}/a/${id}` });
1645
+ },
1646
+
1647
+ // List the opt-in html kits this workspace offers (id, label, summary, classes).
1648
+ // Pair with `publish --kit <id>` to inject a kit's CSS/JS into an html surface.
1649
+ async icons() {
1650
+ const { values: flags, positionals } = parse({
1651
+ allowPositionals: true,
1652
+ options: { project: { type: "string" } },
1653
+ });
1654
+ const [sub = "list", ...sets] = positionals;
1655
+ const { name: project } = resolveProject(flags);
1656
+ if (sub === "list") {
1657
+ const { sets: rows } = await api(`${projectPath(project)}/icons`);
1658
+ if (flags.json) return out(rows);
1659
+ if (flags.quiet) return;
1660
+ for (const r of rows)
1661
+ console.log(`${r.prefix.padEnd(16)} ${String(r.count).padStart(6)} ${r.source}`);
1662
+ return;
1663
+ }
1664
+ if (sets.length === 0) fail(`usage: mockpit icons ${sub} <set> [<set>...]`);
1665
+ if (sub === "add") {
1666
+ const result = await installIconSets(project, sets);
1667
+ if (result.errors.length) fail(result.errors.map((e) => e.message).join("; "));
1668
+ if (flags.json) return out(result.design);
1669
+ for (const a of result.added) {
1670
+ if (!flags.quiet) console.log(`added ${a.prefix} (${a.count} icons)`);
1671
+ }
1672
+ return;
1673
+ }
1674
+ if (sub === "remove") {
1675
+ const stored = await api(`${projectPath(project)}/design`);
1676
+ const installed = stored?.iconSets ?? [];
1677
+ for (const set of sets) {
1678
+ if (installed.some((s) => s.prefix === set)) continue;
1679
+ fail(
1680
+ BUNDLED_ICON_SETS.includes(set)
1681
+ ? `${set} is bundled with the server and cannot be removed`
1682
+ : `${set} is not installed for ${project}`,
1683
+ );
1684
+ }
1685
+ const design = await api(`${projectPath(project)}/design`, {
1686
+ method: "PUT",
1687
+ body: JSON.stringify({
1688
+ ...stored,
1689
+ iconSets: installed.filter((s) => !sets.includes(s.prefix)),
1690
+ }),
1691
+ });
1692
+ if (flags.json) return out(design);
1693
+ if (!flags.quiet) console.log(`removed ${sets.join(", ")}`);
1694
+ return;
1695
+ }
1696
+ fail(`unknown icons command "${sub}" — run "mockpit icons --help"`);
1697
+ },
1698
+
1699
+ async kits() {
1700
+ const { values: flags } = parse({ options: { project: { type: "string" } } });
1701
+ const { name: project } = resolveProject(flags);
1702
+ out(await api(`/api/kits?${new URLSearchParams({ project })}`));
1703
+ },
1704
+
1705
+ async kit() {
1706
+ const { values: flags, positionals } = parse({
1707
+ allowPositionals: true,
1708
+ options: {
1709
+ project: { type: "string" },
1710
+ url: { type: "string" },
1711
+ script: { type: "string" },
1712
+ doc: { type: "string" },
1713
+ },
1714
+ });
1715
+ const [sub, id] = positionals;
1716
+ const { name: project } = resolveProject(flags);
1717
+ if ((sub !== "add" && sub !== "remove") || !id)
1718
+ fail("usage: mockpit kit add <id> --url <url> --doc <file|text> | mockpit kit remove <id>");
1719
+ const design =
1720
+ sub === "add"
1721
+ ? await putProjectKit(project, id, flags)
1722
+ : await api(`${projectPath(project)}/kits/${encodeURIComponent(id)}`, {
1723
+ method: "DELETE",
1724
+ });
1725
+ if (flags.json) return out(design);
1726
+ if (!flags.quiet) console.log(`${sub === "add" ? "added" : "removed"} kit ${id} (${project})`);
1727
+ },
1728
+
1729
+ async guide() {
1730
+ const { values: flags } = parse({
1731
+ options: { topic: { type: "string" }, project: { type: "string" } },
1732
+ });
1733
+ if (flags.topic !== undefined) return printTopic(flags.topic);
1734
+ return printBrief(flags);
1735
+ },
1736
+
1737
+ async setup() {
1738
+ parse();
1739
+ console.log(await fetchTextWithFallback("/setup", join(ROOT, "guide", "AGENT_SETUP.md")));
1740
+ },
1741
+
1742
+ // Print the running version and check for updates (non-blocking, best-effort).
1743
+ async version() {
1744
+ parse();
1745
+ console.log(`mockpit ${PKG_VERSION}`);
1746
+ try {
1747
+ const cached = readUpdateCache();
1748
+ let latest = cached;
1749
+ if (!latest) {
1750
+ const ctrl = new AbortController();
1751
+ const timer = setTimeout(() => ctrl.abort(), 3000);
1752
+ try {
1753
+ const res = await fetch("https://registry.npmjs.org/mockpit/latest", {
1754
+ signal: ctrl.signal,
1755
+ });
1756
+ clearTimeout(timer);
1757
+ if (res.ok) {
1758
+ const pkg = await res.json();
1759
+ if (typeof pkg.version === "string") {
1760
+ latest = pkg.version;
1761
+ writeUpdateCache(latest);
1762
+ }
1763
+ }
1764
+ } catch {
1765
+ // Offline / timed out — skip silently.
1766
+ }
1767
+ }
1768
+ if (latest && versionGt(latest, PKG_VERSION)) {
1769
+ console.log(`\nUpdate available: ${PKG_VERSION} → ${latest}`);
1770
+ console.log(`Run: npm install -g mockpit`);
1771
+ }
1772
+ } catch {
1773
+ // Never let the update check fail the command.
1774
+ }
1775
+ },
1776
+ };
1777
+
1778
+ // Topics ship with the CLI, so a typo is caught, and a known topic read, with
1779
+ // no server running.
1780
+ const TOPICS_DIR = join(ROOT, "guide", "topics");
1781
+
1782
+ async function printTopic(topic) {
1783
+ const topics = readdirSync(TOPICS_DIR)
1784
+ .filter((f) => f.endsWith(".md"))
1785
+ .map((f) => f.slice(0, -3))
1786
+ .sort();
1787
+ if (!topics.includes(topic)) {
1788
+ die(
1789
+ `unknown topic "${topic}"; topics: ${topics.join(", ")}`,
1790
+ `mockpit guide --topic ${topics[0]}`,
1791
+ );
1792
+ }
1793
+ const path = `/agent-howto?${new URLSearchParams({ topic })}`;
1794
+ console.log(await fetchTextWithFallback(path, join(TOPICS_DIR, `${topic}.md`)));
1795
+ }
1796
+
1797
+ // The brief is rendered from the project's stored design settings, so the
1798
+ // server's copy is the real one. With no server it still has to print: an
1799
+ // agent reads it before anything else, so render the generic brief locally
1800
+ // (the same code the server runs) and say where the project-aware one comes from.
1801
+ async function printBrief(flags) {
1802
+ const params = new URLSearchParams({ project: resolveProject(flags).name });
1803
+ try {
1804
+ const res = await fetch(`${BASE}/agent-howto?${params}`);
1805
+ if (res.ok) return console.log(await res.text());
1806
+ } catch {}
1807
+ const { renderBriefGuide } = await import(entrypoint("server", "designGuide.ts"));
1808
+ console.log(renderBriefGuide(null));
1809
+ console.error(
1810
+ `note: no mockpit at ${BASE}; this is the generic brief. Run mockpit serve, then mockpit init for this project's palette, kit and icons.`,
1811
+ );
1812
+ }
1813
+
1814
+ async function fetchTextWithFallback(path, localFile) {
1815
+ try {
1816
+ const res = await fetch(`${BASE}${path}`);
1817
+ if (res.ok) return await res.text();
1818
+ } catch {}
1819
+ return readFileSync(localFile, "utf8");
1820
+ }
1821
+
1822
+ if (cmd === "--version" || cmd === "-V") {
1823
+ await commands.version();
1824
+ } else if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
1825
+ console.log(HELP);
1826
+ } else if (commands[cmd]) {
1827
+ await commands[cmd]();
1828
+ } else {
1829
+ fail(`unknown command "${cmd}" — run "mockpit help"`);
1830
+ }