leglas 0.1.1 → 0.2.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/dist/index.js CHANGED
@@ -56,6 +56,7 @@ function parseAdd(rest) {
56
56
  let note;
57
57
  let branch;
58
58
  let file;
59
+ let basedOn;
59
60
  const tags = [];
60
61
  let json = false;
61
62
  for (let index = 0; index < rest.length; index += 1) {
@@ -74,7 +75,7 @@ function parseAdd(rest) {
74
75
  } else {
75
76
  value = argument.slice(equals + 1);
76
77
  }
77
- if (!["--title", "--url", "--note", "--tag", "--branch", "--file"].includes(flag)) {
78
+ if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on"].includes(flag)) {
78
79
  return { kind: "error", message: `leglas add does not take ${flag}.` };
79
80
  }
80
81
  if (value === void 0 || value === "") {
@@ -85,6 +86,7 @@ function parseAdd(rest) {
85
86
  else if (flag === "--note") note = value;
86
87
  else if (flag === "--branch") branch = value;
87
88
  else if (flag === "--file") file = value;
89
+ else if (flag === "--based-on") basedOn = value;
88
90
  else tags.push(value);
89
91
  }
90
92
  if (title === void 0) {
@@ -98,7 +100,7 @@ function parseAdd(rest) {
98
100
  }
99
101
  return {
100
102
  kind: "add",
101
- preview: { title, url, note, tags: tags.length > 0 ? tags : void 0, branch, file },
103
+ preview: { title, url, note, tags: tags.length > 0 ? tags : void 0, branch, file, basedOn },
102
104
  json
103
105
  };
104
106
  }
@@ -131,8 +133,37 @@ function parseClassify(rest) {
131
133
  }
132
134
  return { kind: "classify", changes, json };
133
135
  }
136
+ function parseWatch(rest) {
137
+ let run3;
138
+ let port;
139
+ for (let index = 0; index < rest.length; index += 1) {
140
+ const argument = rest[index];
141
+ if (argument === "--help" || argument === "-h") return { kind: "help" };
142
+ const equals = argument.indexOf("=");
143
+ const flag = equals === -1 ? argument : argument.slice(0, equals);
144
+ if (flag !== "--run" && flag !== "--port") {
145
+ return { kind: "error", message: `leglas watch does not take ${argument}.` };
146
+ }
147
+ const value = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
148
+ if (value === void 0 || value === "") {
149
+ return {
150
+ kind: "error",
151
+ message: flag === "--run" ? '--run needs an agent command, for example --run "claude -p {prompt}"' : "--port needs a value."
152
+ };
153
+ }
154
+ if (flag === "--run") {
155
+ run3 = value;
156
+ continue;
157
+ }
158
+ const parsed = parsePort(flag, value);
159
+ if (typeof parsed !== "number") return { kind: "error", message: parsed.error };
160
+ port = parsed;
161
+ }
162
+ return { kind: "watch", run: run3, port };
163
+ }
134
164
  function parseArgs(argv) {
135
165
  if (argv[0] === "new") return parseNew(argv.slice(1));
166
+ if (argv[0] === "watch") return parseWatch(argv.slice(1));
136
167
  if (argv[0] === "add") return parseAdd(argv.slice(1));
137
168
  if (argv[0] === "classify") return parseClassify(argv.slice(1));
138
169
  if (argv[0] === "init") {
@@ -184,6 +215,7 @@ function parseArgs(argv) {
184
215
  const rest = argv.slice(1);
185
216
  let surface;
186
217
  let count = 3;
218
+ let basedOn = null;
187
219
  let json = false;
188
220
  for (let index = 0; index < rest.length; index += 1) {
189
221
  const argument = rest[index];
@@ -199,6 +231,17 @@ function parseArgs(argv) {
199
231
  count = Number(raw);
200
232
  continue;
201
233
  }
234
+ if (argument === "--based-on" || argument.startsWith("--based-on=")) {
235
+ const raw = argument.includes("=") ? argument.split("=")[1] : rest[index += 1];
236
+ if (raw === void 0 || raw === "") {
237
+ return {
238
+ kind: "error",
239
+ message: '--based-on needs a direction title, for example --based-on "Aurora".'
240
+ };
241
+ }
242
+ basedOn = raw;
243
+ continue;
244
+ }
202
245
  if (argument.startsWith("-")) {
203
246
  return { kind: "error", message: `leglas explore does not take ${argument}.` };
204
247
  }
@@ -213,7 +256,7 @@ function parseArgs(argv) {
213
256
  message: "leglas explore needs a surface name, for example: leglas explore hero --count 6"
214
257
  };
215
258
  }
216
- return { kind: "explore", surface, count, json };
259
+ return { kind: "explore", surface, count, basedOn, json };
217
260
  }
218
261
  if (argv[0] === "requests") {
219
262
  const rest = argv.slice(1);
@@ -231,6 +274,31 @@ function parseArgs(argv) {
231
274
  }
232
275
  return { kind: "list", json: rest.includes("--json") };
233
276
  }
277
+ if (argv[0] === "show") {
278
+ const rest = argv.slice(1);
279
+ let title;
280
+ let json = false;
281
+ for (const argument of rest) {
282
+ if (argument === "--json") {
283
+ json = true;
284
+ continue;
285
+ }
286
+ if (argument.startsWith("-")) {
287
+ return { kind: "error", message: `leglas show does not take ${argument}.` };
288
+ }
289
+ if (title !== void 0) {
290
+ return { kind: "error", message: "leglas show takes one direction title." };
291
+ }
292
+ title = argument;
293
+ }
294
+ if (title === void 0) {
295
+ return {
296
+ kind: "error",
297
+ message: 'leglas show needs a direction title, for example: leglas show "Aurora" --json'
298
+ };
299
+ }
300
+ return { kind: "show", title, json };
301
+ }
234
302
  const options = {
235
303
  port: void 0,
236
304
  userPort: void 0,
@@ -478,132 +546,40 @@ Rewriting your component automatically is how a tool breaks a codebase it does n
478
546
  };
479
547
  }
480
548
 
481
- // src/briefs.ts
482
- var ALL_BRIEFS = [
483
- {
484
- slug: "quiet",
485
- name: "Quiet",
486
- brief: "Reduce until almost nothing is left. Generous whitespace, a single focal element, and typography carrying the whole hierarchy. Remove decoration rather than softening it.",
487
- avoid: "Adding a subtle gradient or a lighter shade and calling the result minimal."
488
- },
489
- {
490
- slug: "image-led",
491
- name: "Image-led",
492
- brief: "Let imagery be the page. Full-bleed visual, text as a restrained overlay, and a composition that follows the artwork rather than sitting beside it.",
493
- avoid: "Keeping the existing layout and enlarging the picture inside it."
494
- },
495
- {
496
- slug: "kinetic",
497
- name: "Kinetic",
498
- brief: "Motion carries the hierarchy. Something continuous and ambient, with elements arriving in a deliberate sequence. Honour prefers-reduced-motion with a still composition that still works.",
499
- avoid: "A fade-in on scroll bolted onto the current design."
500
- },
501
- {
502
- slug: "editorial",
503
- name: "Editorial",
504
- brief: "Compose it like a magazine spread. Asymmetric grid, large display type with tight leading, rules and captions, imagery treated as a plate rather than a background.",
505
- avoid: "A centred headline above a centred paragraph."
506
- },
507
- {
508
- slug: "dense",
509
- name: "Dense",
510
- brief: "Information forward. Tighter rhythm, smaller type, several entry points visible at once, and the confidence that the reader wants more rather than less.",
511
- avoid: "The same layout with the padding reduced."
512
- },
513
- {
514
- slug: "high-contrast",
515
- name: "High contrast",
516
- brief: "Commit to a hard palette: near-black against one saturated accent, or the whole thing inverted. Define shapes with edges rather than gradients.",
517
- avoid: "Darkening the existing palette by a few steps."
518
- },
519
- {
520
- slug: "material",
521
- name: "Material",
522
- brief: "Give it depth and surface. Layered planes, grain or noise, shadow used structurally to stack elements, a sense that the parts are physical objects.",
523
- avoid: "One drop shadow on an otherwise flat card."
524
- },
525
- {
526
- slug: "type-led",
527
- name: "Type-led",
528
- brief: "Remove imagery entirely. Build the composition from letterforms: extreme scale contrast, a second typeface earning its place, text as the visual itself.",
529
- avoid: "Keeping the image and setting the headline larger."
530
- },
531
- {
532
- slug: "playful",
533
- name: "Playful",
534
- brief: "Deliberate imperfection. Rotation, overlap, irregular or hand-made elements, and one colour that ought not to work but does.",
535
- avoid: "Increasing the border radius and little else."
536
- },
537
- {
538
- slug: "systemic",
539
- name: "Systemic",
540
- brief: "Make the structure visible. Modular blocks on a stated grid, consistent module sizes, alignment itself as the aesthetic.",
541
- avoid: "Adding borders around the sections that already exist."
542
- }
543
- ];
544
- function briefsFor(count) {
545
- if (!Number.isFinite(count) || count <= 0) return [];
546
- return ALL_BRIEFS.slice(0, Math.min(Math.floor(count), ALL_BRIEFS.length));
547
- }
548
- function planBriefs(surface, count) {
549
+ // src/explore.ts
550
+ function planExplore(surface, count, basedOn = null) {
549
551
  const slug = surfaceSlug(surface);
550
- const chosen = briefsFor(count);
551
- const previews = chosen.map((brief) => ({
552
- title: brief.name,
553
- url: `/?v-${slug}=${brief.slug}`
554
- }));
555
- const commands = chosen.map(
556
- (brief) => `leglas add --title ${JSON.stringify(brief.name)} --url ${JSON.stringify(
557
- `/?v-${slug}=${brief.slug}`
558
- )} --note ${JSON.stringify(`${brief.brief.split(".")[0]}.`)}`
559
- );
560
- const instructions = `Build ${chosen.length} direction${chosen.length === 1 ? "" : "s"} for "${surface}", one per angle below.
552
+ const goal = basedOn === null ? `Build ${count} design directions for "${surface}".
553
+
554
+ The set exists to be chosen from, and the choice only means something if the directions genuinely disagree: ${count} variants of one idea would make it empty. What counts as different is yours to decide, and the strongest sets disagree about more than styling.
555
+
556
+ One trap, seen every time this goes wrong: a set collapses toward whichever direction is built first. Decide all ${count} before building any, and if two would read as the same direction at a glance, replace one of them.` : `Build ${count} variations of the "${basedOn}" direction for "${surface}".
561
557
 
562
- Each goes in its own file under .leglas/variants/${slug}/, named after its slug, and is listed in the DIRECTIONS map in that folder's switch file. If the surface has no switch file yet, run \`leglas new ${slug}\` first.
558
+ The set exists to pick a variant of a direction already chosen, so every variation must stay recognisably that direction. The trap here is drift: change enough and the comparison stops being about the variant. Vary each one deliberately and hold everything else still; if a variation grows into a new direction, it belongs in its own exploration instead.`;
559
+ const register = basedOn === null ? ` leglas add --title "<name>" --url "/?v-${slug}=<key>" --note "<the idea, one line>"` : ` leglas add --title "<name>" --url "/?v-${slug}=<key>" --based-on ${JSON.stringify(basedOn)} --note "<the idea, one line>"`;
560
+ const mechanics = `Each one is its own file under .leglas/variants/${slug}/, listed in the DIRECTIONS map in that folder's switch file. If there is no switch file yet, run \`leglas new ${slug}\` first. Register each one the moment it renders, not the set at the end. The interface picks a registration up within seconds, so whoever asked watches the set fill in:
563
561
 
564
- Keep them distinct from each other. The point of exploring several at once is that they disagree; directions that converge on one look waste the exercise. Read each angle's "avoid" line before starting, because it names the obvious reading that collapses the difference.
562
+ ${register}
565
563
 
566
- Then register them:
564
+ The title and note are what the user judges from in the rail, so name each one for its idea rather than numbering it.`;
565
+ return { surface, slug, count, basedOn, instructions: `${goal}
567
566
 
568
- ` + commands.map((command) => ` ${command}`).join("\n");
569
- return { previews, commands, instructions };
567
+ ${mechanics}` };
570
568
  }
571
569
 
572
570
  // src/run-explore.ts
573
571
  function runExplore(options, deps) {
574
- const chosen = briefsFor(options.count);
575
- if (chosen.length === 0) {
572
+ if (!Number.isFinite(options.count) || options.count <= 0) {
576
573
  deps.log(
577
574
  options.json ? JSON.stringify({ ok: false, error: "Ask for at least one direction." }) : "Ask for at least one direction, for example --count 4."
578
575
  );
579
576
  return { exitCode: 1 };
580
577
  }
581
- const plan = planBriefs(options.surface, options.count);
578
+ const plan = planExplore(options.surface, Math.floor(options.count), options.basedOn);
582
579
  if (options.json) {
583
- deps.log(
584
- JSON.stringify({
585
- ok: true,
586
- surface: options.surface,
587
- directions: chosen,
588
- previews: plan.previews,
589
- commands: plan.commands,
590
- instructions: plan.instructions
591
- })
592
- );
580
+ deps.log(JSON.stringify({ ok: true, ...plan }));
593
581
  return { exitCode: 0 };
594
582
  }
595
- if (options.count > ALL_BRIEFS.length) {
596
- deps.log(
597
- `Asked for ${options.count}; there are ${ALL_BRIEFS.length} distinct angles, so ${ALL_BRIEFS.length} follow.`
598
- );
599
- deps.log("");
600
- }
601
- for (const brief of chosen) {
602
- deps.log(`${brief.name}`);
603
- deps.log(` ${brief.brief}`);
604
- deps.log(` Avoid: ${brief.avoid}`);
605
- deps.log("");
606
- }
607
583
  deps.log(plan.instructions);
608
584
  return { exitCode: 0 };
609
585
  }
@@ -638,27 +614,39 @@ When asked for design variations, alternatives, or "a few options":
638
614
  and register it with \`leglas add --title "\u2026" --url "/" --branch <branch>\`
639
615
  (the config needs \`devCommand\` with \`{port}\`). Everything below is the
640
616
  ordinary, in-app path.
641
- 2. Run \`leglas explore <surface> --count <n>\` first. It returns distinct
642
- angles to build, each with what to avoid, so several directions genuinely
643
- disagree instead of becoming shades of one idea. Follow those angles rather
644
- than inventing your own variations of the current design.
617
+ 2. Run \`leglas explore <surface> --count <n>\` first, adding
618
+ \`--based-on "<title>"\` when the user wants variations of a direction they
619
+ already like. It prints what the set needs and how to register it. In
620
+ short: new directions must genuinely disagree with each other, variants of
621
+ one must not, and either way decide the whole set before building any of
622
+ it. The designs themselves are yours.
645
623
  3. If the surface has no switcher yet, run
646
624
  \`leglas new <surface> --from <the component that renders it today>\`. It
647
625
  writes one under \`.leglas/variants/<surface>/\` and prints the single line
648
626
  to add where that surface renders. \`--from\` makes the baseline re-export
649
627
  the real component rather than copying it, so it stays live.
650
- 4. Put each direction in its own file beside the others in
651
- \`.leglas/variants/<surface>/\`, then list it in the \`DIRECTIONS\` map in
652
- that folder's \`switch\` file.
653
- 5. Register each one so it appears in the interface:
628
+ 4. Before building, make sure the interface is up. If \`leglas\` is not
629
+ already running, tell the user to run it, and hand them the URL now
630
+ rather than when the set is done: the rail picks up each registration
631
+ within seconds, so they get to watch the exploration fill in.
632
+ 5. Build one direction at a time: its own file beside the others in
633
+ \`.leglas/variants/<surface>/\`, listed in the \`DIRECTIONS\` map in that
634
+ folder's \`switch\` file, then registered the moment it renders:
654
635
  \`leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
655
- 6. Tell the user to open the interface, or to run \`leglas\` if it is not
656
- already running.
636
+ Register each direction as it lands, never the whole set at the end. To
637
+ the user watching the rail, a batch at the end is minutes of nothing and
638
+ then everything at once.
657
639
 
658
640
  When the user asks to change one direction, check \`leglas requests --json\`
659
641
  first: they may have described it from the interface, and the request names the
660
642
  exact file. Clear the queue with \`leglas requests --clear\` once done.
661
643
 
644
+ If the user wants requests handled the moment they are typed, without relaying
645
+ each one, tell them about \`leglas watch --run "claude -p {prompt}"\` (any
646
+ agent command works; {prompt} receives the request). It runs in their
647
+ terminal, hands each request to that command as it arrives, and the interface
648
+ shows the request's progress.
649
+
662
650
  When the user picks a winner, run
663
651
  \`leglas keep "<title>" --to <path in real source>\`. It moves that direction
664
652
  out of the ignored directory, deletes the rest of the exploration, and drops
@@ -674,8 +662,14 @@ Useful to know:
674
662
  \`leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
675
663
  serves the file itself, so no dev server is needed. Sibling assets in the
676
664
  same directory resolve normally.
677
- - Titles identify previews and must be unique.
665
+ - Titles identify previews and must be unique. The user may rename one in the
666
+ rail, which renames it on their machine only; the commands answer to either
667
+ name, so use whichever they said.
678
668
  - \`leglas list\` shows every direction, shared and local.
669
+ - \`leglas show "<title>" --json\` answers for one of them: the file behind it,
670
+ the variants based on it, what it is being compared against, and anything
671
+ they have asked for that is not done yet. Run it when handed a direction you
672
+ did not register yourself.
679
673
  - Every command accepts \`--json\` and prints one envelope with a stable exit
680
674
  code, so you can drive it without parsing prose.
681
675
 
@@ -808,6 +802,10 @@ function normalizeConfig(raw, options = {}) {
808
802
  errors.push(`${at} names a branch and a file; a file preview is served by Leglas itself and has no checkout.`);
809
803
  }
810
804
  }
805
+ const basedOn = entry["basedOn"];
806
+ if (basedOn !== void 0 && (typeof basedOn !== "string" || basedOn.trim() === "")) {
807
+ errors.push(`${at} has a basedOn that is not a direction title.`);
808
+ }
811
809
  const tags = entry["tags"];
812
810
  previews.push({
813
811
  title: typeof title === "string" ? title : "",
@@ -815,7 +813,8 @@ function normalizeConfig(raw, options = {}) {
815
813
  note: typeof entry["note"] === "string" ? entry["note"] : void 0,
816
814
  tags: Array.isArray(tags) ? tags.filter((tag) => typeof tag === "string") : [],
817
815
  ...typeof branch === "string" ? { branch } : {},
818
- ...typeof file === "string" ? { file } : {}
816
+ ...typeof file === "string" ? { file } : {},
817
+ ...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {}
819
818
  });
820
819
  });
821
820
  const devCommand = source["devCommand"];
@@ -1019,7 +1018,8 @@ async function addLocalPreview(cwd, input, shared) {
1019
1018
  ...input.note === void 0 ? {} : { note: input.note },
1020
1019
  ...input.tags === void 0 ? {} : { tags: input.tags },
1021
1020
  ...input.branch === void 0 ? {} : { branch: input.branch },
1022
- ...input.file === void 0 ? {} : { file: input.file }
1021
+ ...input.file === void 0 ? {} : { file: input.file },
1022
+ ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn }
1023
1023
  };
1024
1024
  const check = normalizeConfig({ previews: [candidate] }, { requireDevCommand: false });
1025
1025
  if (check.config === null) {
@@ -1253,6 +1253,7 @@ async function startAppProcess(options) {
1253
1253
 
1254
1254
  // ../server/dist/requests.js
1255
1255
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1256
+ import { randomBytes } from "crypto";
1256
1257
  import { dirname as dirname3, join as join4 } from "path";
1257
1258
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
1258
1259
  function targetFor(url) {
@@ -1291,7 +1292,16 @@ async function readRequests(cwd) {
1291
1292
  try {
1292
1293
  const raw = await readFile3(join4(cwd, REQUESTS_PATH), "utf8");
1293
1294
  const parsed = JSON.parse(raw);
1294
- return Array.isArray(parsed.requests) ? parsed.requests : [];
1295
+ if (!Array.isArray(parsed.requests))
1296
+ return [];
1297
+ return parsed.requests.map((request, index) => {
1298
+ const entry = request;
1299
+ return {
1300
+ ...entry,
1301
+ id: typeof entry.id === "string" ? entry.id : String(index),
1302
+ status: entry.status === "picked-up" ? "picked-up" : "queued"
1303
+ };
1304
+ });
1295
1305
  } catch {
1296
1306
  return [];
1297
1307
  }
@@ -1303,20 +1313,78 @@ async function writeQueue(cwd, requests) {
1303
1313
  `, "utf8");
1304
1314
  }
1305
1315
  async function appendRequest(cwd, request) {
1306
- await writeQueue(cwd, [...await readRequests(cwd), request]);
1316
+ await writeQueue(cwd, [
1317
+ ...await readRequests(cwd),
1318
+ { ...request, id: randomBytes(6).toString("base64url"), status: "queued" }
1319
+ ]);
1320
+ }
1321
+ async function collectRequests(cwd) {
1322
+ const requests = await readRequests(cwd);
1323
+ const collected = requests.map((request) => ({ ...request, status: "picked-up" }));
1324
+ if (requests.some((request) => request.status !== "picked-up"))
1325
+ await writeQueue(cwd, collected);
1326
+ return collected;
1327
+ }
1328
+ async function markPickedUp(cwd, id) {
1329
+ const requests = await readRequests(cwd);
1330
+ if (!requests.some((request) => request.id === id && request.status !== "picked-up"))
1331
+ return false;
1332
+ await writeQueue(cwd, requests.map((request) => request.id === id ? { ...request, status: "picked-up" } : request));
1333
+ return true;
1334
+ }
1335
+ async function removeRequest(cwd, id) {
1336
+ const requests = await readRequests(cwd);
1337
+ const remaining = requests.filter((request) => request.id !== id);
1338
+ if (remaining.length === requests.length)
1339
+ return false;
1340
+ await writeQueue(cwd, remaining);
1341
+ return true;
1307
1342
  }
1308
1343
  async function clearRequests(cwd) {
1309
1344
  await writeQueue(cwd, []);
1310
1345
  }
1311
1346
 
1347
+ // ../server/dist/renames.js
1348
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1349
+ import { dirname as dirname4, join as join5 } from "path";
1350
+ var RENAMES_PATH = ".leglas/renames.json";
1351
+ async function readRenames(cwd) {
1352
+ try {
1353
+ const raw = await readFile4(join5(cwd, RENAMES_PATH), "utf8");
1354
+ const parsed = JSON.parse(raw);
1355
+ if (parsed.renames === null || typeof parsed.renames !== "object")
1356
+ return {};
1357
+ return Object.fromEntries(Object.entries(parsed.renames).filter((entry) => typeof entry[1] === "string" && entry[1] !== ""));
1358
+ } catch {
1359
+ return {};
1360
+ }
1361
+ }
1362
+ async function writeRenames(cwd, renames) {
1363
+ const path = join5(cwd, RENAMES_PATH);
1364
+ await mkdir3(dirname4(path), { recursive: true });
1365
+ await writeFile3(path, `${JSON.stringify({ renames }, null, 2)}
1366
+ `, "utf8");
1367
+ }
1368
+ function resolveTitle(input, titles, renames) {
1369
+ if (titles.includes(input))
1370
+ return { ok: true, title: input };
1371
+ const matches = titles.filter((title) => renames[title] === input);
1372
+ if (matches.length === 1 && matches[0] !== void 0)
1373
+ return { ok: true, title: matches[0] };
1374
+ if (matches.length > 1)
1375
+ return { ok: false, reason: "ambiguous", matches };
1376
+ return { ok: false, reason: "unknown" };
1377
+ }
1378
+
1312
1379
  // ../server/dist/server.js
1313
1380
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
1314
1381
  import http2 from "http";
1315
1382
  import net3 from "net";
1316
- import { extname, join as join5, normalize } from "path";
1383
+ import { extname, join as join6, normalize } from "path";
1317
1384
  var LEGLAS_PREFIX = "/leglas";
1318
1385
  var DEFAULT_PORT = 4100;
1319
1386
  var PORT_ATTEMPTS = 20;
1387
+ var ATTACHED_WINDOW_MS = 6e3;
1320
1388
  var CONTENT_TYPES = {
1321
1389
  ".css": "text/css; charset=utf-8",
1322
1390
  ".gif": "image/gif",
@@ -1365,7 +1433,7 @@ function probe(target, timeoutMs = 1e3) {
1365
1433
  }
1366
1434
  function serveFrom(res, dir, relativePath) {
1367
1435
  const relative3 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
1368
- const candidate = join5(dir, relative3);
1436
+ const candidate = join6(dir, relative3);
1369
1437
  if (!candidate.startsWith(dir))
1370
1438
  return false;
1371
1439
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -1390,7 +1458,8 @@ var PLACEHOLDER = `<!doctype html>
1390
1458
  <p>The server is running and proxying your app. The interface has not been
1391
1459
  built into this install yet.</p>
1392
1460
  <p><a href="/leglas/api/config">/leglas/api/config</a> \xB7
1393
- <a href="/leglas/api/health">/leglas/api/health</a></p>
1461
+ <a href="/leglas/api/health">/leglas/api/health</a> \xB7
1462
+ <a href="/leglas/api/requests">/leglas/api/requests</a></p>
1394
1463
  </body>`;
1395
1464
  function listen(server, port) {
1396
1465
  return new Promise((resolve, reject) => {
@@ -1425,28 +1494,40 @@ async function startServer(options) {
1425
1494
  const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map() } = options;
1426
1495
  const target = config?.devServer ?? "http://localhost:3000";
1427
1496
  const proxy = createProxyHandler({ target });
1497
+ let lastSeen = null;
1428
1498
  const server = http2.createServer((req, res) => {
1429
1499
  const url = req.url ?? "/";
1430
1500
  const path = url.split("?")[0] ?? "/";
1431
1501
  if (path === `${LEGLAS_PREFIX}/api/config`) {
1432
- return sendJson(res, 200, {
1502
+ const boot = config?.previews ?? [];
1503
+ return void readLocalPreviews(cwd).then(({ previews: local }) => {
1504
+ const known = new Set(boot.map((preview) => preview.title));
1505
+ const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
1506
+ sendJson(res, 200, {
1507
+ project,
1508
+ devServer: target,
1509
+ previews: [...boot, ...fresh],
1510
+ errors: configErrors
1511
+ });
1512
+ }).catch(() => sendJson(res, 200, {
1433
1513
  project,
1434
1514
  devServer: target,
1435
- previews: config?.previews ?? [],
1515
+ previews: boot,
1436
1516
  errors: configErrors
1437
- });
1517
+ }));
1438
1518
  }
1439
1519
  if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
1440
1520
  let body = "";
1441
1521
  req.on("data", (chunk) => body += chunk);
1442
- return void req.on("end", () => {
1522
+ return void req.on("end", async () => {
1443
1523
  let parsed;
1444
1524
  try {
1445
1525
  parsed = JSON.parse(body || "{}");
1446
1526
  } catch {
1447
1527
  return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1448
1528
  }
1449
- const preview = (config?.previews ?? []).find((entry) => entry.title === parsed.title);
1529
+ const local = await readLocalPreviews(cwd).then((read) => read.previews, () => []);
1530
+ const preview = [...config?.previews ?? [], ...local].find((entry) => entry.title === parsed.title);
1450
1531
  if (!preview || !parsed.intent?.trim()) {
1451
1532
  return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
1452
1533
  }
@@ -1459,6 +1540,46 @@ async function startServer(options) {
1459
1540
  }).then(() => sendJson(res, 200, { ok: true, ...composed })).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
1460
1541
  });
1461
1542
  }
1543
+ if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
1544
+ let body = "";
1545
+ req.on("data", (chunk) => body += chunk);
1546
+ return void req.on("end", () => {
1547
+ let parsed;
1548
+ try {
1549
+ parsed = JSON.parse(body || "{}");
1550
+ } catch {
1551
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1552
+ }
1553
+ if (typeof parsed.watching !== "boolean") {
1554
+ return sendJson(res, 400, { ok: false, error: "Body needs a watching boolean." });
1555
+ }
1556
+ lastSeen = parsed.watching ? Date.now() : null;
1557
+ sendJson(res, 200, { ok: true });
1558
+ });
1559
+ }
1560
+ if (path === `${LEGLAS_PREFIX}/api/requests` && req.method === "GET") {
1561
+ return void readRequests(cwd).then((requests) => sendJson(res, 200, {
1562
+ requests: requests.map(({ id, title, intent, status }) => ({ id, title, intent, status })),
1563
+ agent: { attached: lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS }
1564
+ }));
1565
+ }
1566
+ if (path === `${LEGLAS_PREFIX}/api/renames` && req.method === "POST") {
1567
+ let body = "";
1568
+ req.on("data", (chunk) => body += chunk);
1569
+ return void req.on("end", () => {
1570
+ let parsed;
1571
+ try {
1572
+ parsed = JSON.parse(body || "{}");
1573
+ } catch {
1574
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1575
+ }
1576
+ if (parsed.renames === null || typeof parsed.renames !== "object") {
1577
+ return sendJson(res, 400, { ok: false, error: "Body needs a renames object." });
1578
+ }
1579
+ const renames = Object.fromEntries(Object.entries(parsed.renames).filter((entry) => typeof entry[1] === "string" && entry[1] !== ""));
1580
+ void writeRenames(cwd, renames).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 200, { ok: false }));
1581
+ });
1582
+ }
1462
1583
  if (path === `${LEGLAS_PREFIX}/api/health`) {
1463
1584
  return void probe(target).then((reachable) => sendJson(res, 200, { devServer: target, reachable }));
1464
1585
  }
@@ -1566,11 +1687,11 @@ function planKeep(options) {
1566
1687
  }
1567
1688
 
1568
1689
  // src/run-init.ts
1569
- import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1570
- import { join as join6 } from "path";
1690
+ import { readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1691
+ import { join as join7 } from "path";
1571
1692
  async function readIfPresent(path) {
1572
1693
  try {
1573
- return await readFile4(path, "utf8");
1694
+ return await readFile5(path, "utf8");
1574
1695
  } catch {
1575
1696
  return null;
1576
1697
  }
@@ -1578,18 +1699,18 @@ async function readIfPresent(path) {
1578
1699
  async function runInit(options, deps) {
1579
1700
  const existingConfig = findConfigFile(options.cwd);
1580
1701
  const plan = planInit({
1581
- agents: await readIfPresent(join6(options.cwd, "AGENTS.md")),
1702
+ agents: await readIfPresent(join7(options.cwd, "AGENTS.md")),
1582
1703
  config: existingConfig === null ? null : "present",
1583
- gitignore: await readIfPresent(join6(options.cwd, ".gitignore")),
1704
+ gitignore: await readIfPresent(join7(options.cwd, ".gitignore")),
1584
1705
  force: options.force
1585
1706
  });
1586
1707
  const touched = [];
1587
1708
  for (const write of plan.writes) {
1588
- await writeFile3(join6(options.cwd, write.path), write.contents, "utf8");
1709
+ await writeFile4(join7(options.cwd, write.path), write.contents, "utf8");
1589
1710
  touched.push(write.path);
1590
1711
  }
1591
1712
  if (plan.gitignore !== null) {
1592
- await writeFile3(join6(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1713
+ await writeFile4(join7(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1593
1714
  touched.push(".gitignore");
1594
1715
  }
1595
1716
  if (options.json) {
@@ -1609,8 +1730,26 @@ async function runInit(options, deps) {
1609
1730
 
1610
1731
  // src/run-keep.ts
1611
1732
  import { existsSync as existsSync3 } from "fs";
1612
- import { mkdir as mkdir3, readFile as readFile5, rm as rm2, writeFile as writeFile4 } from "fs/promises";
1613
- import { dirname as dirname4, join as join7 } from "path";
1733
+ import { mkdir as mkdir4, readFile as readFile6, rm as rm2, writeFile as writeFile5 } from "fs/promises";
1734
+ import { dirname as dirname5, join as join8 } from "path";
1735
+
1736
+ // src/resolve-title.ts
1737
+ function resolveOrExplain(input, titles, renames) {
1738
+ const resolution = resolveTitle(input, titles, renames);
1739
+ if (resolution.ok) return { ok: true, title: resolution.title };
1740
+ if (resolution.reason === "ambiguous") {
1741
+ return {
1742
+ ok: false,
1743
+ error: `More than one direction is called ${JSON.stringify(input)} on this machine: ${resolution.matches.join(", ")}. Name the one you mean by its title in the config.`
1744
+ };
1745
+ }
1746
+ return {
1747
+ ok: false,
1748
+ error: `No direction called ${JSON.stringify(input)}. Renaming one in the rail only renames it here, and it still answers to its title in the config, which its reference block quotes. leglas list shows every title.`
1749
+ };
1750
+ }
1751
+
1752
+ // src/run-keep.ts
1614
1753
  function renameExport(source, to) {
1615
1754
  const match = /export function ([A-Za-z0-9_]+)\s*\(/.exec(source);
1616
1755
  if (!match || match[1] === void 0) return source;
@@ -1623,31 +1762,37 @@ async function runKeep(options, deps) {
1623
1762
  const loaded = await loadConfig(options.cwd);
1624
1763
  const local = await readLocalPreviews(options.cwd);
1625
1764
  const previews = [...loaded.config?.previews ?? [], ...local.previews];
1626
- const plan = planKeep({ title: options.title, previews, to: options.to });
1627
1765
  const fail = (error) => {
1628
1766
  if (options.json) deps.log(JSON.stringify({ ok: false, error }));
1629
1767
  else deps.error(error);
1630
1768
  return { exitCode: 1 };
1631
1769
  };
1770
+ const resolved = resolveOrExplain(
1771
+ options.title,
1772
+ previews.map((preview) => preview.title),
1773
+ await readRenames(options.cwd)
1774
+ );
1775
+ if (!resolved.ok) return fail(resolved.error);
1776
+ const plan = planKeep({ title: resolved.title, previews, to: options.to });
1632
1777
  if (!plan.ok) return fail(plan.error);
1633
- const from = join7(options.cwd, plan.move.from);
1634
- const to = join7(options.cwd, plan.move.to);
1778
+ const from = join8(options.cwd, plan.move.from);
1779
+ const to = join8(options.cwd, plan.move.to);
1635
1780
  if (!existsSync3(from)) {
1636
1781
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
1637
1782
  }
1638
1783
  if (existsSync3(to)) {
1639
1784
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
1640
1785
  }
1641
- const source = await readFile5(from, "utf8");
1642
- await mkdir3(dirname4(to), { recursive: true });
1643
- await writeFile4(to, renameExport(source, plan.exportName), "utf8");
1644
- await rm2(join7(options.cwd, plan.removeDir), { recursive: true, force: true });
1786
+ const source = await readFile6(from, "utf8");
1787
+ await mkdir4(dirname5(to), { recursive: true });
1788
+ await writeFile5(to, renameExport(source, plan.exportName), "utf8");
1789
+ await rm2(join8(options.cwd, plan.removeDir), { recursive: true, force: true });
1645
1790
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
1646
1791
  if (options.json) {
1647
1792
  deps.log(
1648
1793
  JSON.stringify({
1649
1794
  ok: true,
1650
- kept: options.title,
1795
+ kept: resolved.title,
1651
1796
  to: plan.move.to,
1652
1797
  exportName: plan.exportName,
1653
1798
  removed: plan.removeDir,
@@ -1676,11 +1821,11 @@ async function runKeep(options, deps) {
1676
1821
 
1677
1822
  // src/run-new.ts
1678
1823
  import { existsSync as existsSync4 } from "fs";
1679
- import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
1680
- import { dirname as dirname5, join as join8 } from "path";
1824
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
1825
+ import { dirname as dirname6, join as join9 } from "path";
1681
1826
  async function readIfPresent2(path) {
1682
1827
  try {
1683
- return await readFile6(path, "utf8");
1828
+ return await readFile7(path, "utf8");
1684
1829
  } catch {
1685
1830
  return null;
1686
1831
  }
@@ -1688,7 +1833,7 @@ async function readIfPresent2(path) {
1688
1833
  async function runNew(options, deps) {
1689
1834
  let from;
1690
1835
  if (options.from !== void 0) {
1691
- const contents = await readIfPresent2(join8(options.cwd, options.from));
1836
+ const contents = await readIfPresent2(join9(options.cwd, options.from));
1692
1837
  if (contents === null) {
1693
1838
  const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
1694
1839
  if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
@@ -1699,8 +1844,8 @@ async function runNew(options, deps) {
1699
1844
  }
1700
1845
  const plan = planNew({
1701
1846
  surface: options.surface,
1702
- packageJson: await readIfPresent2(join8(options.cwd, "package.json")),
1703
- gitignore: await readIfPresent2(join8(options.cwd, ".gitignore")),
1847
+ packageJson: await readIfPresent2(join9(options.cwd, "package.json")),
1848
+ gitignore: await readIfPresent2(join9(options.cwd, ".gitignore")),
1704
1849
  from
1705
1850
  });
1706
1851
  const fail = (error) => {
@@ -1723,19 +1868,19 @@ async function runNew(options, deps) {
1723
1868
  deps.log(plan.instructions);
1724
1869
  return { exitCode: 0, written: [] };
1725
1870
  }
1726
- const existing = plan.writes.filter((write) => existsSync4(join8(options.cwd, write.path)));
1871
+ const existing = plan.writes.filter((write) => existsSync4(join9(options.cwd, write.path)));
1727
1872
  if (existing.length > 0) {
1728
1873
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
1729
1874
  }
1730
1875
  const written = [];
1731
1876
  for (const write of plan.writes) {
1732
- const target = join8(options.cwd, write.path);
1733
- await mkdir4(dirname5(target), { recursive: true });
1734
- await writeFile5(target, write.contents, "utf8");
1877
+ const target = join9(options.cwd, write.path);
1878
+ await mkdir5(dirname6(target), { recursive: true });
1879
+ await writeFile6(target, write.contents, "utf8");
1735
1880
  written.push(write.path);
1736
1881
  }
1737
1882
  if (plan.gitignore !== null) {
1738
- await writeFile5(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1883
+ await writeFile6(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1739
1884
  written.push(".gitignore");
1740
1885
  }
1741
1886
  if (options.json) {
@@ -1756,25 +1901,35 @@ async function runNew(options, deps) {
1756
1901
  }
1757
1902
 
1758
1903
  // src/run-previews.ts
1759
- import { readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
1760
- import { join as join9 } from "path";
1904
+ import { readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
1905
+ import { join as join10 } from "path";
1761
1906
  function envelope(deps, ok, body) {
1762
1907
  deps.log(JSON.stringify({ ok, ...body }));
1763
1908
  }
1764
1909
  async function ensureIgnored(cwd) {
1765
- const path = join9(cwd, ".gitignore");
1910
+ const path = join10(cwd, ".gitignore");
1766
1911
  let current = null;
1767
1912
  try {
1768
- current = await readFile7(path, "utf8");
1913
+ current = await readFile8(path, "utf8");
1769
1914
  } catch {
1770
1915
  current = null;
1771
1916
  }
1772
1917
  const next = ignoreEntry(current);
1773
- if (next !== null) await writeFile6(path, next, "utf8");
1918
+ if (next !== null) await writeFile7(path, next, "utf8");
1774
1919
  }
1775
1920
  async function runAdd(options, deps) {
1776
1921
  const loaded = await loadConfig(options.cwd);
1777
1922
  const shared = loaded.config?.previews ?? [];
1923
+ if (options.preview.basedOn !== void 0) {
1924
+ const local = await readLocalPreviews(options.cwd);
1925
+ const titles = new Set([...shared, ...local.previews].map((preview) => preview.title));
1926
+ if (!titles.has(options.preview.basedOn)) {
1927
+ const error = `--based-on names ${JSON.stringify(options.preview.basedOn)}, which is not a registered direction. leglas list shows what exists.`;
1928
+ if (options.json) envelope(deps, false, { error });
1929
+ else deps.error(error);
1930
+ return { exitCode: 1 };
1931
+ }
1932
+ }
1778
1933
  const outcome = await addLocalPreview(
1779
1934
  options.cwd,
1780
1935
  {
@@ -1783,7 +1938,8 @@ async function runAdd(options, deps) {
1783
1938
  note: options.preview.note,
1784
1939
  tags: options.preview.tags,
1785
1940
  branch: options.preview.branch,
1786
- file: options.preview.file
1941
+ file: options.preview.file,
1942
+ basedOn: options.preview.basedOn
1787
1943
  },
1788
1944
  shared
1789
1945
  );
@@ -1813,7 +1969,11 @@ async function runAdd(options, deps) {
1813
1969
  deps.log(" Add devCommand (with {port}) to the config.");
1814
1970
  deps.log("");
1815
1971
  }
1816
- deps.log("Local to this machine. Restart Leglas to see it, or run leglas list.");
1972
+ if (options.preview.branch === void 0 && options.preview.file === void 0) {
1973
+ deps.log("Local to this machine. A running interface picks it up within seconds.");
1974
+ } else {
1975
+ deps.log("Local to this machine. Restart Leglas to see it, or run leglas list.");
1976
+ }
1817
1977
  }
1818
1978
  return { exitCode: 0 };
1819
1979
  }
@@ -1827,9 +1987,17 @@ async function runList(options, deps) {
1827
1987
  ];
1828
1988
  if (options.json) {
1829
1989
  envelope(deps, errors.length === 0, {
1990
+ // The whole record, not a summary of it. What the config holds about a
1991
+ // preview — its note, its tags, the direction it is a variant of — is
1992
+ // exactly what tells an agent why these are being compared, and leaving
1993
+ // it out made the listing thinner than the reference block that points
1994
+ // at it.
1830
1995
  previews: previews.map((preview) => ({
1831
1996
  title: preview.title,
1832
1997
  url: preview.url,
1998
+ note: preview.note ?? null,
1999
+ tags: preview.tags,
2000
+ basedOn: preview.basedOn ?? null,
1833
2001
  local: preview.local,
1834
2002
  branch: preview.branch ?? null,
1835
2003
  file: preview.file ?? null
@@ -1859,7 +2027,7 @@ async function runRequests(options, deps) {
1859
2027
  else deps.log("Queue cleared.");
1860
2028
  return { exitCode: 0 };
1861
2029
  }
1862
- const requests = await readRequests(options.cwd);
2030
+ const requests = await collectRequests(options.cwd);
1863
2031
  if (options.json) {
1864
2032
  envelope(deps, true, { requests });
1865
2033
  return { exitCode: 0 };
@@ -1877,14 +2045,322 @@ async function runRequests(options, deps) {
1877
2045
  return { exitCode: 0 };
1878
2046
  }
1879
2047
 
2048
+ // src/show.ts
2049
+ function describe(preview) {
2050
+ return {
2051
+ title: preview.title,
2052
+ url: preview.url,
2053
+ note: preview.note ?? null,
2054
+ tags: preview.tags,
2055
+ basedOn: preview.basedOn ?? null,
2056
+ branch: preview.branch ?? null,
2057
+ file: preview.file ?? null,
2058
+ local: preview.local === true,
2059
+ // A file preview names its own source. Everything else is decoded from the
2060
+ // URL, and a URL outside the convention yields nothing rather than a path
2061
+ // that looks authoritative and is not there.
2062
+ target: preview.file ?? targetFor(preview.url)
2063
+ };
2064
+ }
2065
+ function planShow({ title, previews, requests }) {
2066
+ const found = previews.find((preview) => preview.title === title);
2067
+ if (!found) {
2068
+ return {
2069
+ ok: false,
2070
+ error: `No direction called ${JSON.stringify(title)}. Run leglas list to see them.`
2071
+ };
2072
+ }
2073
+ const variants = previews.filter((preview) => preview.basedOn === title).map(describe);
2074
+ const variantTitles = new Set(variants.map((variant) => variant.title));
2075
+ return {
2076
+ ok: true,
2077
+ direction: describe(found),
2078
+ variants,
2079
+ // Its own variants are already listed in full, so they are not repeated
2080
+ // here; this is the rest of the comparison.
2081
+ comparedWith: previews.map((preview) => preview.title).filter((other) => other !== title && !variantTitles.has(other)),
2082
+ requests: requests.filter((request) => request.title === title).map((request) => ({
2083
+ id: request.id,
2084
+ intent: request.intent,
2085
+ target: request.target,
2086
+ prompt: request.prompt,
2087
+ status: request.status
2088
+ }))
2089
+ };
2090
+ }
2091
+
2092
+ // src/run-show.ts
2093
+ async function runShow(options, deps) {
2094
+ const loaded = await loadConfig(options.cwd);
2095
+ const local = await readLocalPreviews(options.cwd);
2096
+ const requests = await readRequests(options.cwd);
2097
+ const previews = [
2098
+ ...(loaded.config?.previews ?? []).map((preview) => ({ ...preview, local: false })),
2099
+ ...local.previews
2100
+ ];
2101
+ const resolved = resolveOrExplain(
2102
+ options.title,
2103
+ previews.map((preview) => preview.title),
2104
+ await readRenames(options.cwd)
2105
+ );
2106
+ if (!resolved.ok) {
2107
+ if (options.json) deps.log(JSON.stringify({ ok: false, error: resolved.error }));
2108
+ else deps.error(resolved.error);
2109
+ return { exitCode: 1 };
2110
+ }
2111
+ const plan = planShow({ title: resolved.title, previews, requests });
2112
+ if (!plan.ok) {
2113
+ if (options.json) deps.log(JSON.stringify({ ok: false, error: plan.error }));
2114
+ else deps.error(plan.error);
2115
+ return { exitCode: 1 };
2116
+ }
2117
+ if (options.json) {
2118
+ deps.log(
2119
+ JSON.stringify({
2120
+ ok: true,
2121
+ direction: plan.direction,
2122
+ variants: plan.variants,
2123
+ comparedWith: plan.comparedWith,
2124
+ requests: plan.requests
2125
+ })
2126
+ );
2127
+ return { exitCode: 0 };
2128
+ }
2129
+ const { direction } = plan;
2130
+ deps.log(` ${direction.title}${direction.local ? " (local)" : ""}`);
2131
+ if (direction.note !== null) deps.log(` ${direction.note}`);
2132
+ deps.log("");
2133
+ if (direction.target !== null) deps.log(` file ${direction.target}`);
2134
+ if (direction.branch !== null) deps.log(` branch ${direction.branch}`);
2135
+ deps.log(` url ${direction.url}`);
2136
+ if (direction.tags.length > 0) deps.log(` tags ${direction.tags.join(", ")}`);
2137
+ if (direction.basedOn !== null) deps.log(` variant of ${direction.basedOn}`);
2138
+ if (plan.variants.length > 0) {
2139
+ deps.log(` variants ${plan.variants.map((variant) => variant.title).join(", ")}`);
2140
+ }
2141
+ if (plan.comparedWith.length > 0) {
2142
+ deps.log(` against ${plan.comparedWith.join(", ")}`);
2143
+ }
2144
+ if (plan.requests.length > 0) {
2145
+ deps.log("");
2146
+ deps.log(` Pending, not yet done (${plan.requests.length}):`);
2147
+ for (const request of plan.requests) deps.log(` ${request.status} ${request.intent}`);
2148
+ deps.log("");
2149
+ deps.log(" Run leglas requests --json for the full prompts.");
2150
+ }
2151
+ return { exitCode: 0 };
2152
+ }
2153
+
2154
+ // src/watch.ts
2155
+ var WATCH_PATH = ".leglas/watch.json";
2156
+ var PROMPT_TOKEN = "{prompt}";
2157
+ var EXAMPLE = `leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
2158
+ function tokenize(template) {
2159
+ const tokens = [];
2160
+ let current = "";
2161
+ let started = false;
2162
+ let quote = null;
2163
+ for (const character of template) {
2164
+ if (quote !== null) {
2165
+ if (character === quote) quote = null;
2166
+ else current += character;
2167
+ continue;
2168
+ }
2169
+ if (character === '"' || character === "'") {
2170
+ quote = character;
2171
+ started = true;
2172
+ continue;
2173
+ }
2174
+ if (/\s/.test(character)) {
2175
+ if (started) tokens.push(current);
2176
+ current = "";
2177
+ started = false;
2178
+ continue;
2179
+ }
2180
+ current += character;
2181
+ started = true;
2182
+ }
2183
+ if (quote !== null) {
2184
+ return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
2185
+ }
2186
+ if (started) tokens.push(current);
2187
+ return { ok: true, tokens };
2188
+ }
2189
+ function parseTemplate(raw) {
2190
+ const tokenized = tokenize(raw);
2191
+ if (!tokenized.ok) return tokenized;
2192
+ const { tokens } = tokenized;
2193
+ const [command, ...args] = tokens;
2194
+ if (command === void 0) {
2195
+ return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
2196
+ }
2197
+ const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
2198
+ if (placeholders === 0) {
2199
+ return {
2200
+ ok: false,
2201
+ error: `The agent command needs ${PROMPT_TOKEN} as a word of its own, for example: ${EXAMPLE}`
2202
+ };
2203
+ }
2204
+ if (placeholders > 1) {
2205
+ return {
2206
+ ok: false,
2207
+ error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
2208
+ };
2209
+ }
2210
+ if (command === PROMPT_TOKEN) {
2211
+ return {
2212
+ ok: false,
2213
+ error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
2214
+ };
2215
+ }
2216
+ return { ok: true, template: { command, args } };
2217
+ }
2218
+ function commandFor(template, prompt) {
2219
+ return {
2220
+ command: template.command,
2221
+ args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
2222
+ };
2223
+ }
2224
+ function nextRequest(requests, failed) {
2225
+ return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
2226
+ }
2227
+
2228
+ // src/run-watch.ts
2229
+ import { spawn as spawn2 } from "child_process";
2230
+ import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2231
+ import { dirname as dirname7, join as join11 } from "path";
2232
+ var POLL_MS = 2e3;
2233
+ var HEARTBEAT_TIMEOUT_MS = 1e3;
2234
+ async function readSavedTemplate(cwd) {
2235
+ try {
2236
+ const raw = await readFile9(join11(cwd, WATCH_PATH), "utf8");
2237
+ const parsed = JSON.parse(raw);
2238
+ return typeof parsed.run === "string" && parsed.run !== "" ? parsed.run : null;
2239
+ } catch {
2240
+ return null;
2241
+ }
2242
+ }
2243
+ async function saveTemplate(cwd, run3) {
2244
+ const path = join11(cwd, WATCH_PATH);
2245
+ await mkdir6(dirname7(path), { recursive: true });
2246
+ await writeFile8(path, `${JSON.stringify({ run: run3 }, null, 2)}
2247
+ `, "utf8");
2248
+ }
2249
+ function spawnAgent(command, args, cwd) {
2250
+ return new Promise((resolve) => {
2251
+ let settled = false;
2252
+ const settle = (outcome) => {
2253
+ if (settled) return;
2254
+ settled = true;
2255
+ resolve(outcome);
2256
+ };
2257
+ const child = spawn2(command, args, { cwd, stdio: "inherit" });
2258
+ child.on("error", (error) => settle({ ok: false, error: error.message }));
2259
+ child.on(
2260
+ "close",
2261
+ (code, signal) => settle(
2262
+ signal === null ? { ok: true, code: code ?? 0 } : { ok: false, error: `stopped by ${signal}` }
2263
+ )
2264
+ );
2265
+ });
2266
+ }
2267
+ async function runWatch(options, deps) {
2268
+ const saved = options.run === void 0 ? await readSavedTemplate(options.cwd) : null;
2269
+ const raw = options.run ?? saved;
2270
+ if (raw === null) {
2271
+ deps.error(
2272
+ 'Watch needs an agent command the first time: leglas watch --run "claude -p {prompt}"'
2273
+ );
2274
+ return { exitCode: 1 };
2275
+ }
2276
+ const parsed = parseTemplate(raw);
2277
+ if (!parsed.ok) {
2278
+ deps.error(parsed.error);
2279
+ return { exitCode: 1 };
2280
+ }
2281
+ const template = parsed.template;
2282
+ if (options.run !== void 0) await saveTemplate(options.cwd, raw).catch(() => {
2283
+ });
2284
+ const base = `http://localhost:${options.port ?? DEFAULT_PORT}`;
2285
+ const heartbeat = async (watching) => {
2286
+ try {
2287
+ await fetch(`${base}${LEGLAS_PREFIX}/api/watch`, {
2288
+ method: "POST",
2289
+ headers: { "content-type": "application/json" },
2290
+ body: JSON.stringify({ watching }),
2291
+ signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS)
2292
+ });
2293
+ } catch {
2294
+ }
2295
+ };
2296
+ deps.log(`Watching for change requests. Each one runs: ${raw}`);
2297
+ deps.log("Stop with Ctrl-C.");
2298
+ const failed = /* @__PURE__ */ new Set();
2299
+ let stopped = false;
2300
+ let busy = false;
2301
+ let inflight = null;
2302
+ const handle = async (request) => {
2303
+ deps.log("");
2304
+ deps.log(` ${request.title}: ${request.intent}`);
2305
+ if (request.target !== null) deps.log(` ${request.target}`);
2306
+ await markPickedUp(options.cwd, request.id);
2307
+ const { command, args } = commandFor(template, request.prompt);
2308
+ const outcome = await spawnAgent(command, args, options.cwd);
2309
+ if (outcome.ok && outcome.code === 0) {
2310
+ await removeRequest(options.cwd, request.id);
2311
+ deps.log(` done ${request.title}`);
2312
+ return;
2313
+ }
2314
+ failed.add(request.id);
2315
+ deps.error(
2316
+ ` failed ${request.title}: ${outcome.ok ? `${command} exited ${outcome.code}` : outcome.error}`
2317
+ );
2318
+ deps.error(" Left in the queue and not retried.");
2319
+ };
2320
+ const tick = async () => {
2321
+ if (stopped) return;
2322
+ void heartbeat(true);
2323
+ if (busy) return;
2324
+ busy = true;
2325
+ try {
2326
+ const request = nextRequest(await readRequests(options.cwd), failed);
2327
+ if (request !== null && !stopped) {
2328
+ inflight = handle(request);
2329
+ await inflight;
2330
+ }
2331
+ } catch (error) {
2332
+ deps.error(` ! ${error instanceof Error ? error.message : String(error)}`);
2333
+ } finally {
2334
+ inflight = null;
2335
+ busy = false;
2336
+ }
2337
+ };
2338
+ return new Promise((resolve) => {
2339
+ const timer = setInterval(() => void tick(), POLL_MS);
2340
+ const stop = () => {
2341
+ if (stopped) return;
2342
+ stopped = true;
2343
+ clearInterval(timer);
2344
+ process.off("SIGINT", stop);
2345
+ process.off("SIGTERM", stop);
2346
+ void Promise.resolve(inflight).catch(() => {
2347
+ }).then(() => heartbeat(false)).then(() => resolve({ exitCode: 0 }));
2348
+ };
2349
+ process.on("SIGINT", stop);
2350
+ process.on("SIGTERM", stop);
2351
+ options.signal?.addEventListener("abort", stop, { once: true });
2352
+ void tick();
2353
+ });
2354
+ }
2355
+
1880
2356
  // src/run-classify.ts
1881
2357
  import { stat } from "fs/promises";
1882
- import { join as join10 } from "path";
2358
+ import { join as join12 } from "path";
1883
2359
  async function runClassify(options, deps) {
1884
2360
  const declared = await Promise.all(
1885
2361
  options.changes.map(async (change) => ({
1886
2362
  ...change,
1887
- exists: await stat(join10(options.cwd, change.path)).then(
2363
+ exists: await stat(join12(options.cwd, change.path)).then(
1888
2364
  () => true,
1889
2365
  () => false
1890
2366
  )
@@ -1912,14 +2388,14 @@ async function runClassify(options, deps) {
1912
2388
  // src/run.ts
1913
2389
  import { existsSync as existsSync5 } from "fs";
1914
2390
  import { createRequire } from "module";
1915
- import { basename as basename3, dirname as dirname6, join as join11, relative as relative2 } from "path";
2391
+ import { basename as basename3, dirname as dirname8, join as join13, relative as relative2 } from "path";
1916
2392
  import { fileURLToPath } from "url";
1917
2393
  function findShellDir() {
1918
- const bundled = join11(dirname6(fileURLToPath(import.meta.url)), "shell");
1919
- if (existsSync5(join11(bundled, "index.html"))) return bundled;
2394
+ const bundled = join13(dirname8(fileURLToPath(import.meta.url)), "shell");
2395
+ if (existsSync5(join13(bundled, "index.html"))) return bundled;
1920
2396
  try {
1921
2397
  const require2 = createRequire(import.meta.url);
1922
- return dirname6(require2.resolve("@leglas/shell/dist/index.html"));
2398
+ return dirname8(require2.resolve("@leglas/shell/dist/index.html"));
1923
2399
  } catch {
1924
2400
  return null;
1925
2401
  }
@@ -1953,7 +2429,7 @@ async function run2(options, deps) {
1953
2429
  const fileMounts = /* @__PURE__ */ new Map();
1954
2430
  for (const preview of merged?.previews ?? []) {
1955
2431
  if (preview.file !== void 0) {
1956
- const absolute = join11(options.cwd, preview.file);
2432
+ const absolute = join13(options.cwd, preview.file);
1957
2433
  if (!existsSync5(absolute)) {
1958
2434
  worktreeErrors.push(
1959
2435
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -1964,7 +2440,7 @@ async function run2(options, deps) {
1964
2440
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
1965
2441
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
1966
2442
  }
1967
- fileMounts.set(slug, dirname6(absolute));
2443
+ fileMounts.set(slug, dirname8(absolute));
1968
2444
  previews.push({
1969
2445
  ...preview,
1970
2446
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -2064,15 +2540,20 @@ async function run2(options, deps) {
2064
2540
  export {
2065
2541
  AGENTS_MARKER_END,
2066
2542
  AGENTS_MARKER_START,
2067
- ALL_BRIEFS,
2543
+ PROMPT_TOKEN,
2544
+ WATCH_PATH,
2068
2545
  baselineFrom,
2069
- briefsFor,
2546
+ commandFor,
2070
2547
  detectFramework,
2548
+ nextRequest,
2071
2549
  parseArgs,
2072
- planBriefs,
2550
+ parseTemplate,
2551
+ planExplore,
2073
2552
  planInit,
2074
2553
  planKeep,
2075
2554
  planNew,
2555
+ planShow,
2556
+ readRequests,
2076
2557
  run2 as run,
2077
2558
  runAdd,
2078
2559
  runClassify,
@@ -2082,5 +2563,7 @@ export {
2082
2563
  runList,
2083
2564
  runNew,
2084
2565
  runRequests,
2566
+ runShow,
2567
+ runWatch,
2085
2568
  surfaceSlug
2086
2569
  };