mockpit 1.3.0 → 2.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.
- package/README.md +61 -45
- package/bin/demoData.js +3 -3
- package/bin/mockpit.js +327 -52
- package/dist/mcp/server.js +35 -0
- package/dist/server/apiViews.d.ts +308 -23
- package/dist/server/apiViews.js +60 -19
- package/dist/server/app.d.ts +4 -1
- package/dist/server/app.js +608 -119
- package/dist/server/codeWorker.js +2 -1
- package/dist/server/decisions.d.ts +5 -5
- package/dist/server/decisions.js +10 -9
- package/dist/server/designGuide.d.ts +6 -2
- package/dist/server/designGuide.js +77 -43
- package/dist/server/embeds.d.ts +1 -0
- package/dist/server/embeds.js +30 -4
- package/dist/server/feedbackBatch.d.ts +6 -6
- package/dist/server/feedbackBatch.js +18 -11
- package/dist/server/guidance.d.ts +23 -0
- package/dist/server/guidance.js +71 -0
- package/dist/server/index.js +10 -1
- package/dist/server/mcpHttp.d.ts +3 -0
- package/dist/server/mcpHttp.js +32 -0
- package/dist/server/mcpSpec.d.ts +2 -1
- package/dist/server/mcpSpec.js +74 -24
- package/dist/server/postSurfaces.js +3 -0
- package/dist/server/run.js +6 -1
- package/dist/server/runApi.d.ts +3 -3
- package/dist/server/runApi.js +16 -12
- package/dist/server/sqlStore.d.ts +10 -3
- package/dist/server/sqlStore.js +230 -16
- package/dist/server/types.d.ts +46 -17
- package/dist/server/types.js +34 -11
- package/extensions/mockpit.js +179 -16
- package/guide/packs/design-craft.md +35 -0
- package/guide/topics/asks.md +60 -56
- package/guide/topics/feedback.md +104 -74
- package/guide/topics/html.md +5 -11
- package/guide/topics/http.md +6 -3
- package/guide/topics/knobs.md +2 -2
- package/guide/topics/scripts.md +2 -2
- package/guide/topics/surfaces.md +28 -10
- package/package.json +1 -4
- package/skills/launch-video/SKILL.md +1 -1
- package/skills/mockpit/SKILL.md +18 -10
- package/viewer/dist/index.html +8 -7
package/bin/mockpit.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
writeFileSync,
|
|
10
10
|
writeSync,
|
|
11
11
|
} from "node:fs";
|
|
12
|
-
import { tmpdir, userInfo } from "node:os";
|
|
12
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { parseArgs } from "node:util";
|
|
@@ -29,23 +29,23 @@ const HEADERS = {
|
|
|
29
29
|
const HELP = `mockpit — a live visual surface for terminal coding agents
|
|
30
30
|
|
|
31
31
|
vocabulary: project › mock › state › variant › version. A project is a repo; a
|
|
32
|
-
mock is
|
|
32
|
+
mock is a sketch, page or component, by slug; a state is one thing the user sees in it ("Lab
|
|
33
33
|
open"); a variant is a parallel design of a state; a version is its history.
|
|
34
|
-
|
|
34
|
+
Declare the parts you want feedback on with data-part="name" in the html.
|
|
35
35
|
|
|
36
36
|
the loop (never blocks on the user):
|
|
37
37
|
1. mockpit init once per repo; read the brief (mockpit guide).
|
|
38
|
-
2. mockpit publish once per variant;
|
|
39
|
-
3. A choice is several variants plus one
|
|
38
|
+
2. mockpit publish once per variant; declare parts with data-part.
|
|
39
|
+
3. A choice is several variants plus one question that binds them: mockpit ask
|
|
40
40
|
with options bound to variants. A variant decides everything it differs
|
|
41
41
|
on; don't re-ask it. Visual options carry a variant or knob set. One
|
|
42
42
|
render plus a control: --knobs.
|
|
43
43
|
4. Tell the user in one line where to look, then end your turn. The question
|
|
44
|
-
itself lives in the mock
|
|
44
|
+
itself lives in the mock, never in chat. The user answers in the
|
|
45
45
|
browser at their own pace. Never poll.
|
|
46
46
|
5. When the user says they answered (or a mockpit watch line wakes you), run
|
|
47
47
|
mockpit feedback. Every write also returns feedback; read it.
|
|
48
|
-
6. mockpit publish the revision; mockpit export once a variant is
|
|
48
|
+
6. mockpit publish the revision; mockpit export once a variant is in use.
|
|
49
49
|
In Claude Code, arm mockpit watch under Monitor after asking, then end the
|
|
50
50
|
turn; elsewhere the user's next message is the wake-up.
|
|
51
51
|
|
|
@@ -56,25 +56,31 @@ verbs:
|
|
|
56
56
|
mockpit publish --mock <slug> --html <file> [options]
|
|
57
57
|
create or version one variant of one state
|
|
58
58
|
--state <label> state, in the user's words (omit for a single-state mock)
|
|
59
|
+
--situation <s> another render of that state ("Couldn't save", "Loading")
|
|
59
60
|
--variant <name> variant label (default "default")
|
|
60
61
|
--html <file|-> the html body (or a positional file)
|
|
61
62
|
--surfaces <json|file> instead: the full ordered surface list; {"id"}
|
|
62
63
|
alone keeps a surface, a missing id removes it
|
|
63
64
|
--parts <name=file|-> instead: replace just that data-part's element in
|
|
64
65
|
the latest version (repeatable; name#key targets one instance)
|
|
65
|
-
--fills <part#fill=file|-> instead: replace or add one fill of a
|
|
66
|
+
--fills <part#fill=file|-> instead: replace or add one fill of a part in
|
|
66
67
|
the latest version (repeatable)
|
|
67
68
|
--title <t> mock title
|
|
68
|
-
--kind <k> component
|
|
69
|
+
--kind <k> page|component, only for an app's screens (default sketch)
|
|
70
|
+
--area <name> where in the product it belongs ("Shell"); Home groups by it
|
|
71
|
+
--source <path> the code path that implements it
|
|
69
72
|
--knobs <json|file> knobs in tunekit usePane shape, keyed by path
|
|
70
73
|
--from <N> branch from version N
|
|
71
74
|
--prompt <text> what prompted this version
|
|
72
75
|
--note <text> one line on what this variant is, shown beside it
|
|
73
76
|
--widths <list> device widths to show it at, e.g. 390,1280
|
|
74
|
-
--flow <name> [--step <N>] make this
|
|
75
|
-
it out); with no html it only places the
|
|
77
|
+
--flow <name> [--step <N>] make this state step N of a flow (0 takes
|
|
78
|
+
it out); with no html it only places the state
|
|
76
79
|
--project <name> project (default: git remote, else directory name)
|
|
77
80
|
--kit <id> opt the html surface into a kit (repeatable; see "mockpit kits")
|
|
81
|
+
--poster <file> a still of the page (png, jpg); Studio and Home show it
|
|
82
|
+
instead of running the page. Send one with every version
|
|
83
|
+
that changes the page, or it goes
|
|
78
84
|
--md/--mermaid/--diff/--terminal/--data/--code/--image <file|->
|
|
79
85
|
add a surface of that kind after the html (repeatable;
|
|
80
86
|
--data is a JSON tree)
|
|
@@ -82,12 +88,13 @@ verbs:
|
|
|
82
88
|
ask the user; bind options to variants
|
|
83
89
|
--option <l[=v]> an option (repeatable); "=variant" binds it to a variant,
|
|
84
90
|
"=variant@3" to its version 3, "=knob:path=value" sets one knob
|
|
91
|
+
--recommend <l> the option you would pick, by label; a quiet mark on it
|
|
85
92
|
--scope <s> mock|state|part (default mock)
|
|
86
|
-
--state <label> the state a state-scoped
|
|
87
|
-
--part <name> the part a part-scoped
|
|
93
|
+
--state <label> the state a state-scoped question is about
|
|
94
|
+
--part <name> the part a part-scoped question is about
|
|
88
95
|
--multi allow several answers
|
|
89
|
-
--id <id> stable id; reusing one replaces that
|
|
90
|
-
--asks <json|file> several
|
|
96
|
+
--id <id> stable id; reusing one replaces that question
|
|
97
|
+
--asks <json|file> several questions at once, full shape
|
|
91
98
|
mockpit read [slug] [--body] [--history]
|
|
92
99
|
no slug: every mock plus what the user
|
|
93
100
|
is doing now; a slug: that mock
|
|
@@ -95,12 +102,20 @@ verbs:
|
|
|
95
102
|
plus pending; returns at once
|
|
96
103
|
mockpit say "<text>" --mock <slug> [--state s] [--variant v]
|
|
97
104
|
a plain-text message in a mock's thread
|
|
105
|
+
mockpit edit --mock <slug> [--slug s] [--title t] [--kind k] [--area a] [--source p]
|
|
106
|
+
change a mock without a new version ("" clears)
|
|
107
|
+
mockpit edit --project <name> --rename <new>
|
|
108
|
+
rename a project
|
|
109
|
+
mockpit edit --project <name> --area-order "Shell,Food"
|
|
110
|
+
the order Home lists areas in
|
|
98
111
|
mockpit export --mock <slug> [--state s] [--variant v] [--out <dir>]
|
|
99
|
-
write the
|
|
112
|
+
write the html in use + history per state
|
|
100
113
|
to .mockpit/accepted/<mock>/<state>/
|
|
101
114
|
mockpit upload <file> [--kind image|file]
|
|
102
115
|
upload an asset, print its id and URL
|
|
103
116
|
mockpit guide [--topic <id>] the project-aware brief, or one reference topic
|
|
117
|
+
mockpit rules [--mine] send .mockpit/rules.md (or, with --mine, your
|
|
118
|
+
~/.mockpit/rules.md) to the server for the brief
|
|
104
119
|
mockpit run <file|-> run a script against the mockpit API on the
|
|
105
120
|
server (mockpit guide --topic scripts)
|
|
106
121
|
mockpit watch [--session <id>] stream user feedback, one line per Send
|
|
@@ -148,6 +163,15 @@ const COMMAND_HELP = {
|
|
|
148
163
|
(gitignored). --kit makes a bundled or project kit the default for every html
|
|
149
164
|
surface. --kit-url defines the project's own kit (id from --kit, else
|
|
150
165
|
"project") and makes it the default; the doc is the cheat sheet the brief prints.`,
|
|
166
|
+
rules: `mockpit rules [--project <name>]
|
|
167
|
+
mockpit rules --mine
|
|
168
|
+
Guidance on top of the core brief, highest first: the project's
|
|
169
|
+
.mockpit/rules.md (committed with the repo), your ~/.mockpit/rules.md, then
|
|
170
|
+
the packs either names (\`packs: [design-craft]\` in its frontmatter). A file
|
|
171
|
+
has "## Rules" (always followed) and "## Suggestions" (aimed for).
|
|
172
|
+
This sends the file as it is on disk; a missing file clears the stored copy.
|
|
173
|
+
init sends the project's too. A local server reads yours live, so --mine is
|
|
174
|
+
for a deployed one.`,
|
|
151
175
|
kit: `mockpit kit add <id> --url <https url> --doc <file.md|text> [--script <url>]
|
|
152
176
|
mockpit kit remove <id>
|
|
153
177
|
A project kit is a stylesheet (and optional script) on the CDN allowlist plus a
|
|
@@ -165,6 +189,8 @@ mockpit publish --mock <slug> --parts <name=file|-> ... [options]
|
|
|
165
189
|
mockpit publish --mock <slug> --fills <part#fill=file|-> ... [options]
|
|
166
190
|
Creates the mock, state or variant, or the next version of an existing one.
|
|
167
191
|
--state <label> state in the user's words (omit for a single-state mock)
|
|
192
|
+
--situation <s> another render of that state ("Couldn't save", "Loading"),
|
|
193
|
+
after the state itself is published
|
|
168
194
|
--variant <name> variant label (default "default")
|
|
169
195
|
--surfaces <json|file> the full ordered list: {"id"} alone keeps a surface,
|
|
170
196
|
a missing id removes it, order is the list order
|
|
@@ -172,9 +198,14 @@ mockpit publish --mock <slug> --fills <part#fill=file|-> ... [options]
|
|
|
172
198
|
(name#key for one instance) in the latest version; repeatable
|
|
173
199
|
--fills <part#fill=file|-> replace the element carrying data-part="part"
|
|
174
200
|
data-fill="fill" in the latest version, or add it after
|
|
175
|
-
that
|
|
201
|
+
that part's other fills; the marks are added if missing
|
|
176
202
|
--title <t> mock title
|
|
177
|
-
--kind <k> component|page
|
|
203
|
+
--kind <k> page|component|sketch: page for a screen of the product,
|
|
204
|
+
component for a piece pages embed (embedding makes one);
|
|
205
|
+
omit for sketches, diagrams, reviews (default sketch)
|
|
206
|
+
--area <name> where in the product it belongs, in the project's words
|
|
207
|
+
("Shell", "Food"); Home groups each tab by it. "" clears
|
|
208
|
+
--source <path> the code path that implements it (apps/web/src/shell). "" clears
|
|
178
209
|
--knobs <json|file> knobs in tunekit usePane shape
|
|
179
210
|
--from <N> branch from version N
|
|
180
211
|
--fork <v[@N]> start this new variant from sibling v (its version N, else
|
|
@@ -185,38 +216,54 @@ mockpit publish --mock <slug> --fills <part#fill=file|-> ... [options]
|
|
|
185
216
|
inside the design)
|
|
186
217
|
--widths <list> device widths the mock is judged at, side by side, e.g.
|
|
187
218
|
390,1280 (the ones the user ships to)
|
|
188
|
-
--flow <name> a flow this
|
|
219
|
+
--flow <name> a flow this state is a step of: the states the user moves
|
|
189
220
|
between, in order, across mocks; without html it only
|
|
190
|
-
places the existing
|
|
221
|
+
places the existing state
|
|
191
222
|
--step <N> its 1-based place in the flow (moves it if it is there);
|
|
192
223
|
0 takes it out; omitted, it joins at the end
|
|
193
224
|
--project <name> project (default: git remote, else directory name)
|
|
225
|
+
--poster <file> a still of the page (png, jpg) that Studio and Home show
|
|
226
|
+
instead of running it; with --parts or --fills it is the
|
|
227
|
+
still of the spliced page. A version that changes the page
|
|
228
|
+
without one drops the old still
|
|
194
229
|
--md/--mermaid/--diff/--terminal/--data/--code/--image <file> more surfaces
|
|
195
230
|
--json / --quiet
|
|
196
231
|
Prints the parts found per state, parts that vanished or were renamed,
|
|
197
|
-
nudges, a ready-to-run "mockpit ask" when variants have no
|
|
232
|
+
nudges, a ready-to-run "mockpit ask" when variants have no question, and feedback.`,
|
|
198
233
|
ask: `mockpit ask --mock <slug> "<question>" --option <label[=variant]> ... [--scope mock|state|part]
|
|
199
|
-
[--state s] [--part p] [--multi] [--id id] [--after <ask id>]
|
|
234
|
+
[--state s] [--part p] [--multi] [--id id] [--after <ask id>] [--recommend <label>]
|
|
200
235
|
mockpit ask --mock <slug> --asks <json|file>
|
|
201
|
-
Ask the user. A choice is several variants plus one
|
|
236
|
+
Ask the user. A choice is several variants plus one question that binds them.
|
|
202
237
|
Then tell the user where to look and end your turn.`,
|
|
203
238
|
read: `mockpit read [slug] [--project <name>] [--body] [--history] [--tree]
|
|
204
|
-
No slug: one line per mock (states, variants, open
|
|
205
|
-
the user is doing now. A slug: the mock's states, variants,
|
|
239
|
+
No slug: one line per mock (states, variants, open questions), one per flow, plus pending, what
|
|
240
|
+
the user is doing now. A slug: the mock's states, variants, questions with
|
|
206
241
|
answers, parts, knobs and settled values, plus its pending, as JSON.
|
|
207
242
|
--body includes surfaces, --history version rows. --tree (no slug): the
|
|
208
243
|
project as a tree, each mock's variants with status and forkedFrom, and the
|
|
209
|
-
mocks it
|
|
244
|
+
mocks it embeds and is embedded in, as JSON. Never consumes feedback.`,
|
|
210
245
|
feedback: `mockpit feedback [--project <name>] [--session <id>]
|
|
211
246
|
Returns at once with {feedback, pending} as JSON: one batch per mock the user
|
|
212
|
-
sent since you last heard ({mock, reply: {asks,
|
|
213
|
-
|
|
247
|
+
sent since you last heard ({mock, reply: {asks, borrowed, tuned, comments,
|
|
248
|
+
verdict}, messages, inUse, archived}, or {mock, closed: true, dismissed} when the
|
|
214
249
|
user closed it unanswered), delivered once across feedback, watch and
|
|
215
250
|
every write; and pending ({mock, viewerOpen, draft}) so you can tell the
|
|
216
251
|
user "take your time" or "2 of 3 answered". Run it when the user says they
|
|
217
252
|
answered.`,
|
|
218
253
|
say: `mockpit say "<text>" --mock <slug> [--state s] [--variant v]
|
|
219
254
|
A plain-text message in the mock's thread, as your session's agent.`,
|
|
255
|
+
edit: `mockpit edit --mock <slug> [--slug <new>] [--title <t>] [--kind page|component|sketch]
|
|
256
|
+
[--area <name>|""] [--source <path>|""] [--project <name>]
|
|
257
|
+
mockpit edit --project <name> --rename <new name>
|
|
258
|
+
mockpit edit --project <name> --area-order "Shell,Food,Chat"
|
|
259
|
+
Change a mock without publishing a new version; "" clears area or source. A new
|
|
260
|
+
slug rewrites every embed, data-goto and flow step that names the old one, in
|
|
261
|
+
every version. A mock still embedded somewhere stays a component, not a sketch.
|
|
262
|
+
--rename moves the project's mocks, sessions, flows and design settings to the
|
|
263
|
+
new name (refused if that project has mocks); pass --project <new> or set
|
|
264
|
+
MOCKPIT_PROJECT afterwards if the repo still resolves to the old name.
|
|
265
|
+
--area-order lists the project's areas in the order Home shows them; areas it
|
|
266
|
+
leaves out follow alphabetically, "" goes back to alphabetical.`,
|
|
220
267
|
run: `mockpit run <file|-> [--session <id>] [--project <name>] [--json]
|
|
221
268
|
Run a script (the body of an async function, plain JavaScript) in the server's
|
|
222
269
|
sandbox against the mockpit API; see mockpit guide --topic scripts. Prints each
|
|
@@ -229,11 +276,11 @@ mockpit publish --mock <slug> --fills <part#fill=file|-> ... [options]
|
|
|
229
276
|
Delivery is shared with feedback and every write: each Send arrives once.`,
|
|
230
277
|
export: `mockpit export --mock <slug> [--state s] [--variant v] [--out <dir>]
|
|
231
278
|
Write index.html + history.json per state to .mockpit/accepted/<mock>/<state>/;
|
|
232
|
-
with
|
|
279
|
+
with fills, index.html is the chosen state, plus all-fills.html and decisions.json.`,
|
|
233
280
|
upload: `mockpit upload <file> [--kind image|file] [--session <id>]
|
|
234
281
|
Upload an asset; prints its id and URL. Use the id as an image surface's assetId.`,
|
|
235
282
|
guide: `mockpit guide [--topic <id>] [--project <name>]
|
|
236
|
-
No flag prints the brief: the loop, parts,
|
|
283
|
+
No flag prints the brief: the loop, parts, questions and knobs, feedback, and
|
|
237
284
|
this project's palette, kit and icons. --topic prints one reference topic
|
|
238
285
|
(knobs, asks, surfaces, html, feedback, http, scripts).`,
|
|
239
286
|
};
|
|
@@ -422,6 +469,19 @@ async function resolveSession(flags, { create = false } = {}) {
|
|
|
422
469
|
return session.id;
|
|
423
470
|
}
|
|
424
471
|
|
|
472
|
+
// The harness's conversation id names exactly one session, where the cwd can
|
|
473
|
+
// match several agents working in one repo; the watcher would then take a
|
|
474
|
+
// sibling's replies. Never creates one: most conversations never use mockpit.
|
|
475
|
+
async function resolveSessionByKey(key) {
|
|
476
|
+
try {
|
|
477
|
+
const res = await fetch(`${BASE}/api/sessions${query({ key })}`, { headers: HEADERS });
|
|
478
|
+
if (!res.ok) return null;
|
|
479
|
+
return (await res.json())[0]?.id ?? null;
|
|
480
|
+
} catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
425
485
|
// A monitor process (e.g. the Claude Code plugin) may not share the local
|
|
426
486
|
// state file written by the agent's CLI calls — different spawn tree, so
|
|
427
487
|
// `agentPid()` can hash to a different key. Fall back to asking the server for
|
|
@@ -879,9 +939,13 @@ function parsePublishFlags() {
|
|
|
879
939
|
allowPositionals: true,
|
|
880
940
|
options: {
|
|
881
941
|
...MOCK_FLAGS,
|
|
942
|
+
situation: { type: "string" },
|
|
882
943
|
title: { type: "string" },
|
|
883
944
|
kind: { type: "string" },
|
|
945
|
+
area: { type: "string" },
|
|
946
|
+
source: { type: "string" },
|
|
884
947
|
html: { type: "string" },
|
|
948
|
+
poster: { type: "string" },
|
|
885
949
|
surfaces: { type: "string" },
|
|
886
950
|
parts: { type: "string", multiple: true },
|
|
887
951
|
fills: { type: "string", multiple: true },
|
|
@@ -911,7 +975,7 @@ function parsePublishFlags() {
|
|
|
911
975
|
|
|
912
976
|
// `--parts name=file` (repeatable; `name#key` targets one instance): the new
|
|
913
977
|
// outer html of one part, spliced server-side into the latest version.
|
|
914
|
-
// `--fills part#fill=file` is the same for one
|
|
978
|
+
// `--fills part#fill=file` is the same for one fill of a part.
|
|
915
979
|
function readPartFlags(values, slug, flag = "parts") {
|
|
916
980
|
if (!values?.length) return undefined;
|
|
917
981
|
const example =
|
|
@@ -971,6 +1035,9 @@ async function publishMock() {
|
|
|
971
1035
|
}
|
|
972
1036
|
// Checked before the session exists so a typo'd path leaves nothing behind on the server.
|
|
973
1037
|
if (file && file !== "-" && !existsSync(file)) die(`cannot read ${file}`, `ls ${file}`);
|
|
1038
|
+
if (flags.poster !== undefined && !existsSync(flags.poster)) {
|
|
1039
|
+
die(`cannot read ${flags.poster}`, `ls ${flags.poster}`);
|
|
1040
|
+
}
|
|
974
1041
|
const parts = readPartFlags(flags.parts, slug);
|
|
975
1042
|
const fills = readPartFlags(flags.fills, slug, "fills");
|
|
976
1043
|
let surfaces =
|
|
@@ -994,14 +1061,19 @@ async function publishMock() {
|
|
|
994
1061
|
}
|
|
995
1062
|
if (!surfaces && !parts && !fills && !flags.fork && flags.flow === undefined)
|
|
996
1063
|
die(`no html for ${slug}`, `mockpit publish --mock ${slug} --html <file>`);
|
|
1064
|
+
const poster =
|
|
1065
|
+
flags.poster === undefined ? undefined : (await uploadFile(flags.poster, { session })).id;
|
|
997
1066
|
const body = {
|
|
998
1067
|
session,
|
|
999
1068
|
project,
|
|
1000
1069
|
mock: slug,
|
|
1001
1070
|
state: flags.state,
|
|
1071
|
+
situation: flags.situation,
|
|
1002
1072
|
variant: flags.variant,
|
|
1003
1073
|
title: flags.title,
|
|
1004
1074
|
kind: flags.kind,
|
|
1075
|
+
area: flags.area,
|
|
1076
|
+
source: flags.source,
|
|
1005
1077
|
...(flags.knobs !== undefined && { knobs: readJsonFlag("knobs", flags.knobs) }),
|
|
1006
1078
|
...(from !== undefined && { from }),
|
|
1007
1079
|
...(flags.fork !== undefined && { fork: flags.fork }),
|
|
@@ -1018,6 +1090,7 @@ async function publishMock() {
|
|
|
1018
1090
|
...(surfaces && { surfaces }),
|
|
1019
1091
|
...(parts && { parts }),
|
|
1020
1092
|
...(fills && { fills }),
|
|
1093
|
+
...(poster && { poster }),
|
|
1021
1094
|
};
|
|
1022
1095
|
const result = await api(
|
|
1023
1096
|
"/api/mocks",
|
|
@@ -1077,12 +1150,20 @@ function watchLines(batch) {
|
|
|
1077
1150
|
const lines = [];
|
|
1078
1151
|
const name = batch.mock ?? "a mock";
|
|
1079
1152
|
if (batch.handoff) lines.push(`mockpit handoff on ${name}: ${batch.handoff}`);
|
|
1080
|
-
if (batch.
|
|
1153
|
+
if (batch.closed) {
|
|
1081
1154
|
const n = batch.dismissed?.length ?? 0;
|
|
1082
1155
|
lines.push(
|
|
1083
|
-
`mockpit
|
|
1156
|
+
`mockpit closed ${name}: ${n ? `${n} question${n === 1 ? "" : "s"} dismissed unanswered (${batch.dismissed.map((d) => d.text || d.ask).join("; ")}); don't ask again` : "closed by the user"}`,
|
|
1084
1157
|
);
|
|
1085
1158
|
}
|
|
1159
|
+
if (batch.edited) {
|
|
1160
|
+
const e = batch.edited;
|
|
1161
|
+
const bits = [
|
|
1162
|
+
e.kind ? `now a ${e.kind}` : "",
|
|
1163
|
+
e.area !== undefined ? (e.area ? `area ${e.area}` : "no area") : "",
|
|
1164
|
+
].filter(Boolean);
|
|
1165
|
+
lines.push(`mockpit edited ${name}: the user moved it (${bits.join(", ")})`);
|
|
1166
|
+
}
|
|
1086
1167
|
if (batch.reply) {
|
|
1087
1168
|
const r = batch.reply;
|
|
1088
1169
|
const bits = [
|
|
@@ -1095,21 +1176,30 @@ function watchLines(batch) {
|
|
|
1095
1176
|
.join("; ")
|
|
1096
1177
|
: "",
|
|
1097
1178
|
Object.keys(r.tuned ?? {}).length ? `${Object.keys(r.tuned).length} tuned` : "",
|
|
1098
|
-
Object.keys(r.
|
|
1099
|
-
? `
|
|
1179
|
+
Object.keys(r.borrowed ?? {}).length
|
|
1180
|
+
? `borrowed ${Object.entries(r.borrowed)
|
|
1100
1181
|
.map(([p, v]) => `${p}←${v}`)
|
|
1101
1182
|
.join(", ")}`
|
|
1102
1183
|
: "",
|
|
1103
|
-
r.comments
|
|
1184
|
+
...(r.comments ?? [])
|
|
1185
|
+
.filter((c) => c.borrow)
|
|
1186
|
+
.map(
|
|
1187
|
+
(c) =>
|
|
1188
|
+
`borrow ${c.borrow.part ? `${c.borrow.part} ` : ""}from ${c.borrow.from} into ${c.variant}@${c.version}${c.text ? ` (“${c.text}”)` : ""}`,
|
|
1189
|
+
),
|
|
1190
|
+
(() => {
|
|
1191
|
+
const n = (r.comments ?? []).filter((c) => !c.borrow).length;
|
|
1192
|
+
return n ? `${n} comment${n === 1 ? "" : "s"}` : "";
|
|
1193
|
+
})(),
|
|
1104
1194
|
r.reactions?.length ? r.reactions.join(", ") : "",
|
|
1105
|
-
r.
|
|
1195
|
+
r.verdict ? `${r.verdict.kind} ${r.verdict.variant}` : "",
|
|
1106
1196
|
r.text ?? "",
|
|
1107
1197
|
].filter(Boolean);
|
|
1108
1198
|
lines.push(`mockpit reply on ${name}: ${bits.join(" · ").replace(/\s+/g, " ")}`);
|
|
1109
1199
|
}
|
|
1110
|
-
for (const c of batch.
|
|
1200
|
+
for (const c of batch.messages ?? []) {
|
|
1111
1201
|
lines.push(
|
|
1112
|
-
`mockpit
|
|
1202
|
+
`mockpit message on ${name}: “${String(c.text ?? "")
|
|
1113
1203
|
.replace(/\s+/g, " ")
|
|
1114
1204
|
.trim()}”`,
|
|
1115
1205
|
);
|
|
@@ -1132,8 +1222,13 @@ async function apiSoft(path) {
|
|
|
1132
1222
|
return res.json().catch(() => null);
|
|
1133
1223
|
}
|
|
1134
1224
|
|
|
1135
|
-
// Keep the scratch directory out of the repo
|
|
1136
|
-
//
|
|
1225
|
+
// Keep the scratch directory out of the repo, all but the project's rules
|
|
1226
|
+
// file, which is meant to be committed. Git can't re-include a file under an
|
|
1227
|
+
// ignored directory, so the ignore has to name the directory's contents, and
|
|
1228
|
+
// the `.mockpit/` line older versions wrote is upgraded in place. Returns true
|
|
1229
|
+
// when .gitignore was touched, so init can report it.
|
|
1230
|
+
const MOCKPIT_IGNORE = ".mockpit/*\n!.mockpit/rules.md";
|
|
1231
|
+
|
|
1137
1232
|
function ignoreMockpitDir() {
|
|
1138
1233
|
const file = join(process.cwd(), ".gitignore");
|
|
1139
1234
|
let current = "";
|
|
@@ -1142,14 +1237,56 @@ function ignoreMockpitDir() {
|
|
|
1142
1237
|
} catch {
|
|
1143
1238
|
// no .gitignore yet — create one
|
|
1144
1239
|
}
|
|
1145
|
-
if (/^\.mockpit
|
|
1240
|
+
if (/^\.mockpit\/\*$/m.test(current) && /^!\.mockpit\/rules\.md$/m.test(current)) return false;
|
|
1241
|
+
if (/^\.mockpit\/?$/m.test(current)) {
|
|
1242
|
+
writeFileSync(file, current.replace(/^\.mockpit\/?$/m, MOCKPIT_IGNORE));
|
|
1243
|
+
return true;
|
|
1244
|
+
}
|
|
1245
|
+
const lines = /^\.mockpit\/\*$/m.test(current) ? "!.mockpit/rules.md" : MOCKPIT_IGNORE;
|
|
1146
1246
|
writeFileSync(
|
|
1147
1247
|
file,
|
|
1148
|
-
current && !current.endsWith("\n") ? `${current}\n
|
|
1248
|
+
current && !current.endsWith("\n") ? `${current}\n${lines}\n` : `${current}${lines}\n`,
|
|
1149
1249
|
);
|
|
1150
1250
|
return true;
|
|
1151
1251
|
}
|
|
1152
1252
|
|
|
1253
|
+
function repoRoot() {
|
|
1254
|
+
try {
|
|
1255
|
+
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
1256
|
+
encoding: "utf8",
|
|
1257
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1258
|
+
}).trim();
|
|
1259
|
+
if (top) return top;
|
|
1260
|
+
} catch {}
|
|
1261
|
+
return process.cwd();
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// Where `mockpit serve` reads the user's own rules: next to its database.
|
|
1265
|
+
const myRulesFile = () =>
|
|
1266
|
+
join(dirname(process.env.MOCKPIT_DB ?? join(homedir(), ".mockpit", "mockpit.db")), "rules.md");
|
|
1267
|
+
|
|
1268
|
+
// Sends a rules file as it is on disk: a missing file clears the stored copy,
|
|
1269
|
+
// so deleting the file and re-running is how rules are switched off.
|
|
1270
|
+
async function sendRules(file, path) {
|
|
1271
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
1272
|
+
const result = await api(
|
|
1273
|
+
path,
|
|
1274
|
+
{ method: "PUT", body: JSON.stringify({ text }) },
|
|
1275
|
+
{ report: "die", fix: "mockpit rules --help" },
|
|
1276
|
+
);
|
|
1277
|
+
return { ...result, missing: !text };
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function rulesSummary(r) {
|
|
1281
|
+
if (r.cleared) return "none (cleared)";
|
|
1282
|
+
const bits = [
|
|
1283
|
+
r.packs?.length ? `packs ${r.packs.join(", ")}` : null,
|
|
1284
|
+
r.rules ? "own rules" : null,
|
|
1285
|
+
].filter(Boolean);
|
|
1286
|
+
const unknown = r.unknown?.length ? ` · unknown pack ${r.unknown.join(", ")}` : "";
|
|
1287
|
+
return `${bits.join(" · ") || "empty"}${unknown}`;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1153
1290
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1154
1291
|
|
|
1155
1292
|
// Semver greater-than for plain x.y.z (mirrors server/app.ts versionGt).
|
|
@@ -1396,7 +1533,11 @@ const commands = {
|
|
|
1396
1533
|
mkdirSync(dirname(starter), { recursive: true });
|
|
1397
1534
|
writeFileSync(starter, renderStarter({ ...design, kit }, iconSets));
|
|
1398
1535
|
say("wrote:", ".mockpit/starter.html");
|
|
1399
|
-
if (ignoreMockpitDir()) say("wrote:", ".gitignore (+ .mockpit/)");
|
|
1536
|
+
if (ignoreMockpitDir()) say("wrote:", ".gitignore (+ .mockpit/, keeping .mockpit/rules.md)");
|
|
1537
|
+
const rulesFile = join(repoRoot(), ".mockpit", "rules.md");
|
|
1538
|
+
if (existsSync(rulesFile)) {
|
|
1539
|
+
say("rules:", rulesSummary(await sendRules(rulesFile, `${projectPath(project)}/rules`)));
|
|
1540
|
+
}
|
|
1400
1541
|
say("next:", "mockpit guide");
|
|
1401
1542
|
if (flags.json) out({ project, design: stored, starter });
|
|
1402
1543
|
},
|
|
@@ -1413,6 +1554,7 @@ const commands = {
|
|
|
1413
1554
|
project: { type: "string" },
|
|
1414
1555
|
session: { type: "string" },
|
|
1415
1556
|
option: { type: "string", multiple: true },
|
|
1557
|
+
recommend: { type: "string" },
|
|
1416
1558
|
scope: { type: "string" },
|
|
1417
1559
|
state: { type: "string" },
|
|
1418
1560
|
part: { type: "string" },
|
|
@@ -1422,7 +1564,10 @@ const commands = {
|
|
|
1422
1564
|
asks: { type: "string" },
|
|
1423
1565
|
},
|
|
1424
1566
|
});
|
|
1425
|
-
const slug = requireMock(
|
|
1567
|
+
const slug = requireMock(
|
|
1568
|
+
flags,
|
|
1569
|
+
'mockpit ask --mock writer "Quiet or dark?" --option Quiet=quiet',
|
|
1570
|
+
);
|
|
1426
1571
|
let asks;
|
|
1427
1572
|
if (flags.asks !== undefined) {
|
|
1428
1573
|
if (positionals.length > 0) {
|
|
@@ -1438,7 +1583,7 @@ const commands = {
|
|
|
1438
1583
|
if (!text)
|
|
1439
1584
|
die(
|
|
1440
1585
|
"ask needs a question",
|
|
1441
|
-
`mockpit ask --mock ${slug} "
|
|
1586
|
+
`mockpit ask --mock ${slug} "Quiet or dark?" --option Quiet=quiet`,
|
|
1442
1587
|
);
|
|
1443
1588
|
const options = (flags.option ?? []).map(parseOption);
|
|
1444
1589
|
if (options.length === 0) {
|
|
@@ -1447,6 +1592,17 @@ const commands = {
|
|
|
1447
1592
|
`mockpit ask --mock ${slug} "${text}" --option Quiet=quiet --option Dark=dark`,
|
|
1448
1593
|
);
|
|
1449
1594
|
}
|
|
1595
|
+
if (flags.recommend !== undefined) {
|
|
1596
|
+
const pick = options.find(
|
|
1597
|
+
(o) => o.label.toLowerCase() === flags.recommend.trim().toLowerCase(),
|
|
1598
|
+
);
|
|
1599
|
+
if (!pick)
|
|
1600
|
+
die(
|
|
1601
|
+
`--recommend "${flags.recommend}" names no option`,
|
|
1602
|
+
`mockpit ask --mock ${slug} "${text}" --option Quiet=quiet --option Dark=dark --recommend Quiet`,
|
|
1603
|
+
);
|
|
1604
|
+
pick.recommended = true;
|
|
1605
|
+
}
|
|
1450
1606
|
asks = [
|
|
1451
1607
|
{
|
|
1452
1608
|
text,
|
|
@@ -1529,13 +1685,13 @@ const commands = {
|
|
|
1529
1685
|
console.log(
|
|
1530
1686
|
`${project} · ${mocks.length} mock${mocks.length === 1 ? "" : "s"}` +
|
|
1531
1687
|
(list.open
|
|
1532
|
-
? ` · ${list.open} open
|
|
1688
|
+
? ` · ${list.open} open question${list.open === 1 ? "" : "s"}`
|
|
1533
1689
|
: " · nothing waiting on the user"),
|
|
1534
1690
|
);
|
|
1535
1691
|
for (const m of mocks) {
|
|
1536
1692
|
const states = m.states.length ? m.states.join(" / ") : "single state";
|
|
1537
1693
|
console.log(
|
|
1538
|
-
` ${m.slug} · ${m.kind} · ${states} · ${m.variants} variant${m.variants === 1 ? "" : "s"}${m.
|
|
1694
|
+
` ${m.slug} · ${m.kind}${m.area ? ` · ${m.area}` : ""} · ${states} · ${m.variants} variant${m.variants === 1 ? "" : "s"}${m.closedAt ? " · closed" : m.open ? ` · ${m.open} open` : ""}`,
|
|
1539
1695
|
);
|
|
1540
1696
|
}
|
|
1541
1697
|
for (const flow of list.flows ?? []) console.log(` ${flowLine(flow)}`);
|
|
@@ -1564,8 +1720,8 @@ const commands = {
|
|
|
1564
1720
|
const dir = join(root, entry.state === null ? "default" : slugify(entry.state));
|
|
1565
1721
|
mkdirSync(dir, { recursive: true });
|
|
1566
1722
|
writeFileSync(join(dir, "index.html"), entry.html ?? "");
|
|
1567
|
-
// The
|
|
1568
|
-
if (entry.
|
|
1723
|
+
// The state with every fill, when index.html is the composed choice.
|
|
1724
|
+
if (entry.allFills) writeFileSync(join(dir, "all-fills.html"), entry.allFills);
|
|
1569
1725
|
const decided = data.decisions?.states?.[entry.state ?? ""];
|
|
1570
1726
|
if (decided) {
|
|
1571
1727
|
writeFileSync(
|
|
@@ -1660,9 +1816,13 @@ const commands = {
|
|
|
1660
1816
|
// feedback is delivered exactly once across watch, feedback, and piggyback.
|
|
1661
1817
|
let firstAfter = flags.after;
|
|
1662
1818
|
for (;;) {
|
|
1663
|
-
const
|
|
1819
|
+
const key = harnessKey();
|
|
1820
|
+
const session =
|
|
1821
|
+
(await resolveSession(flags)) ??
|
|
1822
|
+
(key ? await resolveSessionByKey(key) : await resolveSessionByCwd());
|
|
1664
1823
|
if (!session) {
|
|
1665
|
-
|
|
1824
|
+
// A conversation that hasn't used mockpit yet may never; ask rarely.
|
|
1825
|
+
await sleep(key ? 10_000 : 2000);
|
|
1666
1826
|
continue;
|
|
1667
1827
|
}
|
|
1668
1828
|
let result;
|
|
@@ -1712,6 +1872,102 @@ const commands = {
|
|
|
1712
1872
|
console.log(`said on ${slug}`);
|
|
1713
1873
|
printFeedback(result.feedback);
|
|
1714
1874
|
},
|
|
1875
|
+
async edit() {
|
|
1876
|
+
const { values: flags } = parse({
|
|
1877
|
+
options: {
|
|
1878
|
+
mock: { type: "string" },
|
|
1879
|
+
project: { type: "string" },
|
|
1880
|
+
session: { type: "string" },
|
|
1881
|
+
slug: { type: "string" },
|
|
1882
|
+
title: { type: "string" },
|
|
1883
|
+
kind: { type: "string" },
|
|
1884
|
+
area: { type: "string" },
|
|
1885
|
+
source: { type: "string" },
|
|
1886
|
+
rename: { type: "string" },
|
|
1887
|
+
"area-order": { type: "string" },
|
|
1888
|
+
},
|
|
1889
|
+
});
|
|
1890
|
+
const project = resolveProject(flags).name;
|
|
1891
|
+
const session = (await resolveSession(flags)) ?? undefined;
|
|
1892
|
+
const areaOrder = flags["area-order"];
|
|
1893
|
+
if (flags.rename !== undefined || areaOrder !== undefined) {
|
|
1894
|
+
if (flags.mock) {
|
|
1895
|
+
die(
|
|
1896
|
+
"--rename and --area-order edit the project; edit the mock without them",
|
|
1897
|
+
"mockpit edit --help",
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
if (flags.rename !== undefined && !flags.rename.trim()) {
|
|
1901
|
+
die("--rename needs the new name", `mockpit edit --rename <name>`);
|
|
1902
|
+
}
|
|
1903
|
+
const result = await api(
|
|
1904
|
+
projectPath(project),
|
|
1905
|
+
{
|
|
1906
|
+
method: "PATCH",
|
|
1907
|
+
body: JSON.stringify({
|
|
1908
|
+
name: flags.rename,
|
|
1909
|
+
...(areaOrder === undefined ? {} : { areaOrder: areaOrder.split(",") }),
|
|
1910
|
+
session,
|
|
1911
|
+
}),
|
|
1912
|
+
},
|
|
1913
|
+
{ report: "die", fix: "mockpit read" },
|
|
1914
|
+
);
|
|
1915
|
+
if (flags.json) return out(result);
|
|
1916
|
+
if (flags.quiet) return;
|
|
1917
|
+
if (result.areaOrder) {
|
|
1918
|
+
console.log(
|
|
1919
|
+
`area order ${result.areaOrder.length ? result.areaOrder.join(", ") : "alphabetical"} · ${result.url}`,
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
|
|
1923
|
+
if (!result.renamed) return printFeedback(result.feedback);
|
|
1924
|
+
console.log(
|
|
1925
|
+
`renamed project ${result.renamed.from} → ${result.renamed.to} · ${result.mocks} mock${result.mocks === 1 ? "" : "s"} · ${result.url}`,
|
|
1926
|
+
);
|
|
1927
|
+
if (!flags.project && !process.env.MOCKPIT_PROJECT) {
|
|
1928
|
+
console.log(
|
|
1929
|
+
`note: this repo still resolves to "${project}"; pass --project ${shq(result.project)} or set MOCKPIT_PROJECT`,
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
printFeedback(result.feedback);
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
const slug = requireMock(flags, "mockpit edit --mock writer --area Shell");
|
|
1936
|
+
const fields = ["slug", "title", "kind", "area", "source"].filter(
|
|
1937
|
+
(k) => flags[k] !== undefined,
|
|
1938
|
+
);
|
|
1939
|
+
if (!fields.length) {
|
|
1940
|
+
die("edit needs --slug, --title, --kind, --area or --source", "mockpit edit --help");
|
|
1941
|
+
}
|
|
1942
|
+
const result = await api(
|
|
1943
|
+
mockPath(slug),
|
|
1944
|
+
{
|
|
1945
|
+
method: "PATCH",
|
|
1946
|
+
body: JSON.stringify({
|
|
1947
|
+
project,
|
|
1948
|
+
session,
|
|
1949
|
+
...Object.fromEntries(fields.map((k) => [k, flags[k]])),
|
|
1950
|
+
}),
|
|
1951
|
+
},
|
|
1952
|
+
{ report: "die", fix: `mockpit read ${slug}` },
|
|
1953
|
+
);
|
|
1954
|
+
if (flags.json) return out(result);
|
|
1955
|
+
if (flags.quiet) return;
|
|
1956
|
+
const m = result.mock;
|
|
1957
|
+
console.log(
|
|
1958
|
+
[
|
|
1959
|
+
result.renamed ? `${result.renamed.from} → ${m.slug}` : m.slug,
|
|
1960
|
+
m.kind,
|
|
1961
|
+
m.area && `area ${m.area}`,
|
|
1962
|
+
m.source && `source ${m.source}`,
|
|
1963
|
+
result.url,
|
|
1964
|
+
]
|
|
1965
|
+
.filter(Boolean)
|
|
1966
|
+
.join(" · "),
|
|
1967
|
+
);
|
|
1968
|
+
for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
|
|
1969
|
+
printFeedback(result.feedback);
|
|
1970
|
+
},
|
|
1715
1971
|
async demo() {
|
|
1716
1972
|
parse();
|
|
1717
1973
|
const { DEMO } = await import("./demoData.js");
|
|
@@ -1729,6 +1985,7 @@ const commands = {
|
|
|
1729
1985
|
session: session.id,
|
|
1730
1986
|
project: DEMO.project,
|
|
1731
1987
|
mock: DEMO.slug,
|
|
1988
|
+
kind: "page",
|
|
1732
1989
|
title: DEMO.title,
|
|
1733
1990
|
state: state.label,
|
|
1734
1991
|
variant: variant.name,
|
|
@@ -1860,6 +2117,24 @@ const commands = {
|
|
|
1860
2117
|
return printBrief(flags);
|
|
1861
2118
|
},
|
|
1862
2119
|
|
|
2120
|
+
// A rules file is the user's or the project's own guidance on top of the
|
|
2121
|
+
// core brief; the brief prints it, so this only has to put it on the server.
|
|
2122
|
+
async rules() {
|
|
2123
|
+
const { values: flags } = parse({
|
|
2124
|
+
options: { mine: { type: "boolean" }, project: { type: "string" } },
|
|
2125
|
+
});
|
|
2126
|
+
const mine = !!flags.mine;
|
|
2127
|
+
const file = mine ? myRulesFile() : join(repoRoot(), ".mockpit", "rules.md");
|
|
2128
|
+
const project = mine ? null : resolveProject(flags).name;
|
|
2129
|
+
const result = await sendRules(file, mine ? "/api/rules" : `${projectPath(project)}/rules`);
|
|
2130
|
+
if (flags.json) return out(result);
|
|
2131
|
+
if (flags.quiet) return;
|
|
2132
|
+
const where = mine ? "your rules" : `project ${project}`;
|
|
2133
|
+
const from = result.missing ? `no ${file}` : file;
|
|
2134
|
+
console.log(`${where}: ${rulesSummary(result)} (from ${from})`);
|
|
2135
|
+
if (!result.missing) console.log("next:", "mockpit guide");
|
|
2136
|
+
},
|
|
2137
|
+
|
|
1863
2138
|
async setup() {
|
|
1864
2139
|
parse();
|
|
1865
2140
|
console.log(await fetchTextWithFallback("/setup", join(ROOT, "guide", "AGENT_SETUP.md")));
|