leglas 0.1.1 → 0.3.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/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bin.ts
4
- import { spawn as spawn2 } from "child_process";
4
+ import { spawn as spawn3 } from "child_process";
5
5
  import { createRequire as createRequire2 } from "module";
6
6
 
7
7
  // src/args.ts
@@ -51,7 +51,7 @@ function parseNew(rest) {
51
51
  if (surface === void 0) {
52
52
  return {
53
53
  kind: "error",
54
- message: "leglas new needs a surface name, for example: leglas new hero"
54
+ message: "leglas new needs a surface name, for example: npx leglas new hero"
55
55
  };
56
56
  }
57
57
  return { kind: "new", surface, print, json, from };
@@ -62,6 +62,7 @@ function parseAdd(rest) {
62
62
  let note;
63
63
  let branch;
64
64
  let file;
65
+ let basedOn;
65
66
  const tags = [];
66
67
  let json = false;
67
68
  for (let index = 0; index < rest.length; index += 1) {
@@ -80,7 +81,7 @@ function parseAdd(rest) {
80
81
  } else {
81
82
  value = argument.slice(equals + 1);
82
83
  }
83
- if (!["--title", "--url", "--note", "--tag", "--branch", "--file"].includes(flag)) {
84
+ if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on"].includes(flag)) {
84
85
  return { kind: "error", message: `leglas add does not take ${flag}.` };
85
86
  }
86
87
  if (value === void 0 || value === "") {
@@ -91,6 +92,7 @@ function parseAdd(rest) {
91
92
  else if (flag === "--note") note = value;
92
93
  else if (flag === "--branch") branch = value;
93
94
  else if (flag === "--file") file = value;
95
+ else if (flag === "--based-on") basedOn = value;
94
96
  else tags.push(value);
95
97
  }
96
98
  if (title === void 0) {
@@ -104,7 +106,7 @@ function parseAdd(rest) {
104
106
  }
105
107
  return {
106
108
  kind: "add",
107
- preview: { title, url, note, tags: tags.length > 0 ? tags : void 0, branch, file },
109
+ preview: { title, url, note, tags: tags.length > 0 ? tags : void 0, branch, file, basedOn },
108
110
  json
109
111
  };
110
112
  }
@@ -132,13 +134,42 @@ function parseClassify(rest) {
132
134
  if (changes.length === 0) {
133
135
  return {
134
136
  kind: "error",
135
- message: "leglas classify needs what the direction will touch, for example: leglas classify --change package.json --rewrite src/theme.css"
137
+ message: "leglas classify needs what the direction will touch, for example: npx leglas classify --change package.json --rewrite src/theme.css"
136
138
  };
137
139
  }
138
140
  return { kind: "classify", changes, json };
139
141
  }
142
+ function parseWatch(rest) {
143
+ let run3;
144
+ let port;
145
+ for (let index = 0; index < rest.length; index += 1) {
146
+ const argument = rest[index];
147
+ if (argument === "--help" || argument === "-h") return { kind: "help" };
148
+ const equals = argument.indexOf("=");
149
+ const flag = equals === -1 ? argument : argument.slice(0, equals);
150
+ if (flag !== "--run" && flag !== "--port") {
151
+ return { kind: "error", message: `leglas watch does not take ${argument}.` };
152
+ }
153
+ const value = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
154
+ if (value === void 0 || value === "") {
155
+ return {
156
+ kind: "error",
157
+ message: flag === "--run" ? '--run needs an agent command, for example --run "claude -p {prompt}"' : "--port needs a value."
158
+ };
159
+ }
160
+ if (flag === "--run") {
161
+ run3 = value;
162
+ continue;
163
+ }
164
+ const parsed2 = parsePort(flag, value);
165
+ if (typeof parsed2 !== "number") return { kind: "error", message: parsed2.error };
166
+ port = parsed2;
167
+ }
168
+ return { kind: "watch", run: run3, port };
169
+ }
140
170
  function parseArgs(argv) {
141
171
  if (argv[0] === "new") return parseNew(argv.slice(1));
172
+ if (argv[0] === "watch") return parseWatch(argv.slice(1));
142
173
  if (argv[0] === "add") return parseAdd(argv.slice(1));
143
174
  if (argv[0] === "classify") return parseClassify(argv.slice(1));
144
175
  if (argv[0] === "init") {
@@ -178,7 +209,7 @@ function parseArgs(argv) {
178
209
  if (title === void 0) {
179
210
  return {
180
211
  kind: "error",
181
- message: 'leglas keep needs a direction title, for example: leglas keep "Aurora" --to src/components/hero.tsx'
212
+ message: 'leglas keep needs a direction title, for example: npx leglas keep "Aurora" --to src/components/hero.tsx'
182
213
  };
183
214
  }
184
215
  if (to === void 0) {
@@ -190,6 +221,7 @@ function parseArgs(argv) {
190
221
  const rest = argv.slice(1);
191
222
  let surface;
192
223
  let count = 3;
224
+ let basedOn = null;
193
225
  let json = false;
194
226
  for (let index = 0; index < rest.length; index += 1) {
195
227
  const argument = rest[index];
@@ -205,6 +237,17 @@ function parseArgs(argv) {
205
237
  count = Number(raw);
206
238
  continue;
207
239
  }
240
+ if (argument === "--based-on" || argument.startsWith("--based-on=")) {
241
+ const raw = argument.includes("=") ? argument.split("=")[1] : rest[index += 1];
242
+ if (raw === void 0 || raw === "") {
243
+ return {
244
+ kind: "error",
245
+ message: '--based-on needs a direction title, for example --based-on "Aurora".'
246
+ };
247
+ }
248
+ basedOn = raw;
249
+ continue;
250
+ }
208
251
  if (argument.startsWith("-")) {
209
252
  return { kind: "error", message: `leglas explore does not take ${argument}.` };
210
253
  }
@@ -216,10 +259,10 @@ function parseArgs(argv) {
216
259
  if (surface === void 0) {
217
260
  return {
218
261
  kind: "error",
219
- message: "leglas explore needs a surface name, for example: leglas explore hero --count 6"
262
+ message: "leglas explore needs a surface name, for example: npx leglas explore hero --count 6"
220
263
  };
221
264
  }
222
- return { kind: "explore", surface, count, json };
265
+ return { kind: "explore", surface, count, basedOn, json };
223
266
  }
224
267
  if (argv[0] === "requests") {
225
268
  const rest = argv.slice(1);
@@ -237,6 +280,31 @@ function parseArgs(argv) {
237
280
  }
238
281
  return { kind: "list", json: rest.includes("--json") };
239
282
  }
283
+ if (argv[0] === "show") {
284
+ const rest = argv.slice(1);
285
+ let title;
286
+ let json = false;
287
+ for (const argument of rest) {
288
+ if (argument === "--json") {
289
+ json = true;
290
+ continue;
291
+ }
292
+ if (argument.startsWith("-")) {
293
+ return { kind: "error", message: `leglas show does not take ${argument}.` };
294
+ }
295
+ if (title !== void 0) {
296
+ return { kind: "error", message: "leglas show takes one direction title." };
297
+ }
298
+ title = argument;
299
+ }
300
+ if (title === void 0) {
301
+ return {
302
+ kind: "error",
303
+ message: 'leglas show needs a direction title, for example: npx leglas show "Aurora" --json'
304
+ };
305
+ }
306
+ return { kind: "show", title, json };
307
+ }
240
308
  const options = {
241
309
  port: void 0,
242
310
  userPort: void 0,
@@ -285,7 +353,7 @@ function parseArgs(argv) {
285
353
 
286
354
  // src/run-classify.ts
287
355
  import { stat } from "fs/promises";
288
- import { join as join6 } from "path";
356
+ import { join as join7 } from "path";
289
357
 
290
358
  // ../server/dist/config.js
291
359
  var DEFAULT_DEV_SERVER = "http://localhost:3000";
@@ -376,6 +444,10 @@ function normalizeConfig(raw, options = {}) {
376
444
  errors.push(`${at} names a branch and a file; a file preview is served by Leglas itself and has no checkout.`);
377
445
  }
378
446
  }
447
+ const basedOn = entry["basedOn"];
448
+ if (basedOn !== void 0 && (typeof basedOn !== "string" || basedOn.trim() === "")) {
449
+ errors.push(`${at} has a basedOn that is not a direction title.`);
450
+ }
379
451
  const tags = entry["tags"];
380
452
  previews.push({
381
453
  title: typeof title === "string" ? title : "",
@@ -383,7 +455,8 @@ function normalizeConfig(raw, options = {}) {
383
455
  note: typeof entry["note"] === "string" ? entry["note"] : void 0,
384
456
  tags: Array.isArray(tags) ? tags.filter((tag) => typeof tag === "string") : [],
385
457
  ...typeof branch === "string" ? { branch } : {},
386
- ...typeof file === "string" ? { file } : {}
458
+ ...typeof file === "string" ? { file } : {},
459
+ ...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {}
387
460
  });
388
461
  });
389
462
  const devCommand = source["devCommand"];
@@ -449,12 +522,12 @@ function isExplorationFile(path) {
449
522
  }
450
523
  var CHECKOUT_STEPS = [
451
524
  "Build the direction on its own branch: git switch -c <branch>, commit it there, switch back.",
452
- 'Register it: leglas add --title "<title>" --url "/" --branch <branch>.',
525
+ 'Register it: npx leglas add --title "<title>" --url "/" --branch <branch>.',
453
526
  "Make sure the config sets devCommand (with {port}), so Leglas can start the checkout."
454
527
  ];
455
528
  var IN_APP_STEPS = [
456
529
  "Author it additively under .leglas/variants/<surface>/, beside the existing directions.",
457
- 'Register it: leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
530
+ 'Register it: npx leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
458
531
  ];
459
532
  function classifyDirection(input) {
460
533
  const checkout = (reason) => ({ level: "checkout", reason, steps: CHECKOUT_STEPS });
@@ -587,7 +660,8 @@ async function addLocalPreview(cwd, input, shared) {
587
660
  ...input.note === void 0 ? {} : { note: input.note },
588
661
  ...input.tags === void 0 ? {} : { tags: input.tags },
589
662
  ...input.branch === void 0 ? {} : { branch: input.branch },
590
- ...input.file === void 0 ? {} : { file: input.file }
663
+ ...input.file === void 0 ? {} : { file: input.file },
664
+ ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn }
591
665
  };
592
666
  const check = normalizeConfig({ previews: [candidate] }, { requireDevCommand: false });
593
667
  if (check.config === null) {
@@ -821,6 +895,7 @@ async function startAppProcess(options) {
821
895
 
822
896
  // ../server/dist/requests.js
823
897
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
898
+ import { randomBytes } from "crypto";
824
899
  import { dirname as dirname3, join as join4 } from "path";
825
900
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
826
901
  function targetFor(url) {
@@ -859,7 +934,16 @@ async function readRequests(cwd) {
859
934
  try {
860
935
  const raw = await readFile3(join4(cwd, REQUESTS_PATH), "utf8");
861
936
  const parsed2 = JSON.parse(raw);
862
- return Array.isArray(parsed2.requests) ? parsed2.requests : [];
937
+ if (!Array.isArray(parsed2.requests))
938
+ return [];
939
+ return parsed2.requests.map((request, index) => {
940
+ const entry = request;
941
+ return {
942
+ ...entry,
943
+ id: typeof entry.id === "string" ? entry.id : String(index),
944
+ status: entry.status === "picked-up" ? "picked-up" : "queued"
945
+ };
946
+ });
863
947
  } catch {
864
948
  return [];
865
949
  }
@@ -871,20 +955,83 @@ async function writeQueue(cwd, requests) {
871
955
  `, "utf8");
872
956
  }
873
957
  async function appendRequest(cwd, request) {
874
- await writeQueue(cwd, [...await readRequests(cwd), request]);
958
+ await writeQueue(cwd, [
959
+ ...await readRequests(cwd),
960
+ { ...request, id: randomBytes(6).toString("base64url"), status: "queued" }
961
+ ]);
962
+ }
963
+ async function collectRequests(cwd) {
964
+ const requests = await readRequests(cwd);
965
+ const collected = requests.map((request) => ({ ...request, status: "picked-up" }));
966
+ if (requests.some((request) => request.status !== "picked-up"))
967
+ await writeQueue(cwd, collected);
968
+ return collected;
969
+ }
970
+ async function markPickedUp(cwd, id) {
971
+ const requests = await readRequests(cwd);
972
+ if (!requests.some((request) => request.id === id && request.status !== "picked-up"))
973
+ return false;
974
+ await writeQueue(cwd, requests.map((request) => request.id === id ? { ...request, status: "picked-up" } : request));
975
+ return true;
976
+ }
977
+ async function removeRequest(cwd, id) {
978
+ const requests = await readRequests(cwd);
979
+ const remaining = requests.filter((request) => request.id !== id);
980
+ if (remaining.length === requests.length)
981
+ return false;
982
+ await writeQueue(cwd, remaining);
983
+ return true;
875
984
  }
876
985
  async function clearRequests(cwd) {
877
- await writeQueue(cwd, []);
986
+ const requests = await readRequests(cwd);
987
+ const pending = requests.filter((request) => request.status !== "picked-up");
988
+ const cleared = requests.length - pending.length;
989
+ if (cleared > 0)
990
+ await writeQueue(cwd, pending);
991
+ return { cleared, pending: pending.length };
992
+ }
993
+
994
+ // ../server/dist/renames.js
995
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
996
+ import { dirname as dirname4, join as join5 } from "path";
997
+ var RENAMES_PATH = ".leglas/renames.json";
998
+ async function readRenames(cwd) {
999
+ try {
1000
+ const raw = await readFile4(join5(cwd, RENAMES_PATH), "utf8");
1001
+ const parsed2 = JSON.parse(raw);
1002
+ if (parsed2.renames === null || typeof parsed2.renames !== "object")
1003
+ return {};
1004
+ return Object.fromEntries(Object.entries(parsed2.renames).filter((entry) => typeof entry[1] === "string" && entry[1] !== ""));
1005
+ } catch {
1006
+ return {};
1007
+ }
1008
+ }
1009
+ async function writeRenames(cwd, renames) {
1010
+ const path = join5(cwd, RENAMES_PATH);
1011
+ await mkdir3(dirname4(path), { recursive: true });
1012
+ await writeFile3(path, `${JSON.stringify({ renames }, null, 2)}
1013
+ `, "utf8");
1014
+ }
1015
+ function resolveTitle(input, titles, renames) {
1016
+ if (titles.includes(input))
1017
+ return { ok: true, title: input };
1018
+ const matches = titles.filter((title) => renames[title] === input);
1019
+ if (matches.length === 1 && matches[0] !== void 0)
1020
+ return { ok: true, title: matches[0] };
1021
+ if (matches.length > 1)
1022
+ return { ok: false, reason: "ambiguous", matches };
1023
+ return { ok: false, reason: "unknown" };
878
1024
  }
879
1025
 
880
1026
  // ../server/dist/server.js
881
1027
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
882
1028
  import http2 from "http";
883
1029
  import net3 from "net";
884
- import { extname, join as join5, normalize } from "path";
1030
+ import { extname, join as join6, normalize, relative as relative2 } from "path";
885
1031
  var LEGLAS_PREFIX = "/leglas";
886
1032
  var DEFAULT_PORT = 4100;
887
1033
  var PORT_ATTEMPTS = 20;
1034
+ var ATTACHED_WINDOW_MS = 6e3;
888
1035
  var CONTENT_TYPES = {
889
1036
  ".css": "text/css; charset=utf-8",
890
1037
  ".gif": "image/gif",
@@ -932,8 +1079,8 @@ function probe(target, timeoutMs = 1e3) {
932
1079
  });
933
1080
  }
934
1081
  function serveFrom(res, dir, relativePath) {
935
- const relative3 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
936
- const candidate = join5(dir, relative3);
1082
+ const relative4 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
1083
+ const candidate = join6(dir, relative4);
937
1084
  if (!candidate.startsWith(dir))
938
1085
  return false;
939
1086
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -946,9 +1093,36 @@ function serveFrom(res, dir, relativePath) {
946
1093
  return true;
947
1094
  }
948
1095
  function serveShellFile(res, shellDir, urlPath) {
949
- const relative3 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
950
- const isRoot = relative3 === "" || relative3 === "." || relative3 === "/";
951
- return serveFrom(res, shellDir, isRoot ? "index.html" : relative3);
1096
+ const relative4 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
1097
+ const isRoot = relative4 === "" || relative4 === "." || relative4 === "/";
1098
+ return serveFrom(res, shellDir, isRoot ? "index.html" : relative4);
1099
+ }
1100
+ function snapshotConfig(cwd) {
1101
+ const path = findConfigFile(cwd);
1102
+ if (path === null)
1103
+ return null;
1104
+ try {
1105
+ return { path, mtimeMs: statSync(path).mtimeMs };
1106
+ } catch {
1107
+ return null;
1108
+ }
1109
+ }
1110
+ function configStalenessNotice(cwd, boot, current) {
1111
+ if (boot === null && current === null)
1112
+ return null;
1113
+ if (boot === null && current !== null) {
1114
+ const label = relative2(cwd, current.path) || current.path;
1115
+ return `${label} appeared after Leglas started. Restart leglas to pick it up.`;
1116
+ }
1117
+ if (boot !== null && current === null) {
1118
+ const label = relative2(cwd, boot.path) || boot.path;
1119
+ return `${label} was removed after Leglas started. Restart leglas to run without it.`;
1120
+ }
1121
+ if (boot !== null && current !== null && (boot.path !== current.path || boot.mtimeMs !== current.mtimeMs)) {
1122
+ const label = relative2(cwd, current.path) || current.path;
1123
+ return `${label} changed after Leglas started. Restart leglas to pick it up.`;
1124
+ }
1125
+ return null;
952
1126
  }
953
1127
  var PLACEHOLDER = `<!doctype html>
954
1128
  <meta charset="utf-8">
@@ -958,7 +1132,8 @@ var PLACEHOLDER = `<!doctype html>
958
1132
  <p>The server is running and proxying your app. The interface has not been
959
1133
  built into this install yet.</p>
960
1134
  <p><a href="/leglas/api/config">/leglas/api/config</a> \xB7
961
- <a href="/leglas/api/health">/leglas/api/health</a></p>
1135
+ <a href="/leglas/api/health">/leglas/api/health</a> \xB7
1136
+ <a href="/leglas/api/requests">/leglas/api/requests</a></p>
962
1137
  </body>`;
963
1138
  function listen(server, port) {
964
1139
  return new Promise((resolve, reject) => {
@@ -993,28 +1168,45 @@ async function startServer(options) {
993
1168
  const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map() } = options;
994
1169
  const target = config?.devServer ?? "http://localhost:3000";
995
1170
  const proxy = createProxyHandler({ target });
1171
+ const bootConfigSnapshot = snapshotConfig(cwd);
1172
+ let lastSeen = null;
996
1173
  const server = http2.createServer((req, res) => {
997
1174
  const url = req.url ?? "/";
998
1175
  const path = url.split("?")[0] ?? "/";
999
1176
  if (path === `${LEGLAS_PREFIX}/api/config`) {
1000
- return sendJson(res, 200, {
1177
+ const boot = config?.previews ?? [];
1178
+ const errors = [...configErrors];
1179
+ const notice = configStalenessNotice(cwd, bootConfigSnapshot, snapshotConfig(cwd));
1180
+ if (notice !== null)
1181
+ errors.push(notice);
1182
+ return void readLocalPreviews(cwd).then(({ previews: local }) => {
1183
+ const known = new Set(boot.map((preview) => preview.title));
1184
+ const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
1185
+ sendJson(res, 200, {
1186
+ project,
1187
+ devServer: target,
1188
+ previews: [...boot, ...fresh],
1189
+ errors
1190
+ });
1191
+ }).catch(() => sendJson(res, 200, {
1001
1192
  project,
1002
1193
  devServer: target,
1003
- previews: config?.previews ?? [],
1004
- errors: configErrors
1005
- });
1194
+ previews: boot,
1195
+ errors
1196
+ }));
1006
1197
  }
1007
1198
  if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
1008
1199
  let body = "";
1009
1200
  req.on("data", (chunk) => body += chunk);
1010
- return void req.on("end", () => {
1201
+ return void req.on("end", async () => {
1011
1202
  let parsed2;
1012
1203
  try {
1013
1204
  parsed2 = JSON.parse(body || "{}");
1014
1205
  } catch {
1015
1206
  return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1016
1207
  }
1017
- const preview = (config?.previews ?? []).find((entry) => entry.title === parsed2.title);
1208
+ const local = await readLocalPreviews(cwd).then((read) => read.previews, () => []);
1209
+ const preview = [...config?.previews ?? [], ...local].find((entry) => entry.title === parsed2.title);
1018
1210
  if (!preview || !parsed2.intent?.trim()) {
1019
1211
  return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
1020
1212
  }
@@ -1027,6 +1219,46 @@ async function startServer(options) {
1027
1219
  }).then(() => sendJson(res, 200, { ok: true, ...composed })).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
1028
1220
  });
1029
1221
  }
1222
+ if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
1223
+ let body = "";
1224
+ req.on("data", (chunk) => body += chunk);
1225
+ return void req.on("end", () => {
1226
+ let parsed2;
1227
+ try {
1228
+ parsed2 = JSON.parse(body || "{}");
1229
+ } catch {
1230
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1231
+ }
1232
+ if (typeof parsed2.watching !== "boolean") {
1233
+ return sendJson(res, 400, { ok: false, error: "Body needs a watching boolean." });
1234
+ }
1235
+ lastSeen = parsed2.watching ? Date.now() : null;
1236
+ sendJson(res, 200, { ok: true });
1237
+ });
1238
+ }
1239
+ if (path === `${LEGLAS_PREFIX}/api/requests` && req.method === "GET") {
1240
+ return void readRequests(cwd).then((requests) => sendJson(res, 200, {
1241
+ requests: requests.map(({ id, title, intent, status }) => ({ id, title, intent, status })),
1242
+ agent: { attached: lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS }
1243
+ }));
1244
+ }
1245
+ if (path === `${LEGLAS_PREFIX}/api/renames` && req.method === "POST") {
1246
+ let body = "";
1247
+ req.on("data", (chunk) => body += chunk);
1248
+ return void req.on("end", () => {
1249
+ let parsed2;
1250
+ try {
1251
+ parsed2 = JSON.parse(body || "{}");
1252
+ } catch {
1253
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1254
+ }
1255
+ if (parsed2.renames === null || typeof parsed2.renames !== "object") {
1256
+ return sendJson(res, 400, { ok: false, error: "Body needs a renames object." });
1257
+ }
1258
+ const renames = Object.fromEntries(Object.entries(parsed2.renames).filter((entry) => typeof entry[1] === "string" && entry[1] !== ""));
1259
+ void writeRenames(cwd, renames).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 200, { ok: false }));
1260
+ });
1261
+ }
1030
1262
  if (path === `${LEGLAS_PREFIX}/api/health`) {
1031
1263
  return void probe(target).then((reachable) => sendJson(res, 200, { devServer: target, reachable }));
1032
1264
  }
@@ -1034,21 +1266,28 @@ async function startServer(options) {
1034
1266
  const rest = path.slice(FILES_PREFIX.length + 1);
1035
1267
  const slash = rest.indexOf("/");
1036
1268
  const slug = slash === -1 ? rest : rest.slice(0, slash);
1037
- let relative3 = slash === -1 ? "" : rest.slice(slash + 1);
1269
+ let relative4 = slash === -1 ? "" : rest.slice(slash + 1);
1038
1270
  try {
1039
- relative3 = decodeURIComponent(relative3);
1271
+ relative4 = decodeURIComponent(relative4);
1040
1272
  } catch {
1041
- relative3 = "";
1273
+ relative4 = "";
1042
1274
  }
1043
1275
  const dir = fileMounts.get(slug);
1044
- if (dir !== void 0 && relative3 !== "" && serveFrom(res, dir, relative3))
1276
+ if (dir !== void 0 && relative4 !== "" && serveFrom(res, dir, relative4))
1045
1277
  return;
1046
1278
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
1047
1279
  return res.end("Leglas: no such preview file.");
1048
1280
  }
1281
+ if (path.startsWith(`${LEGLAS_PREFIX}/api/`)) {
1282
+ return sendJson(res, 404, { error: "No such Leglas API path." });
1283
+ }
1049
1284
  if (path === LEGLAS_PREFIX || path.startsWith(`${LEGLAS_PREFIX}/`)) {
1050
1285
  if (shellDir !== null && serveShellFile(res, shellDir, path))
1051
1286
  return;
1287
+ if (shellDir !== null) {
1288
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
1289
+ return res.end("Leglas: no such path.");
1290
+ }
1052
1291
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1053
1292
  return res.end(PLACEHOLDER);
1054
1293
  }
@@ -1084,7 +1323,7 @@ async function runClassify(options, deps) {
1084
1323
  const declared = await Promise.all(
1085
1324
  options.changes.map(async (change) => ({
1086
1325
  ...change,
1087
- exists: await stat(join6(options.cwd, change.path)).then(
1326
+ exists: await stat(join7(options.cwd, change.path)).then(
1088
1327
  () => true,
1089
1328
  () => false
1090
1329
  )
@@ -1310,139 +1549,47 @@ Rewriting your component automatically is how a tool breaks a codebase it does n
1310
1549
  };
1311
1550
  }
1312
1551
 
1313
- // src/briefs.ts
1314
- var ALL_BRIEFS = [
1315
- {
1316
- slug: "quiet",
1317
- name: "Quiet",
1318
- 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.",
1319
- avoid: "Adding a subtle gradient or a lighter shade and calling the result minimal."
1320
- },
1321
- {
1322
- slug: "image-led",
1323
- name: "Image-led",
1324
- 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.",
1325
- avoid: "Keeping the existing layout and enlarging the picture inside it."
1326
- },
1327
- {
1328
- slug: "kinetic",
1329
- name: "Kinetic",
1330
- 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.",
1331
- avoid: "A fade-in on scroll bolted onto the current design."
1332
- },
1333
- {
1334
- slug: "editorial",
1335
- name: "Editorial",
1336
- 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.",
1337
- avoid: "A centred headline above a centred paragraph."
1338
- },
1339
- {
1340
- slug: "dense",
1341
- name: "Dense",
1342
- brief: "Information forward. Tighter rhythm, smaller type, several entry points visible at once, and the confidence that the reader wants more rather than less.",
1343
- avoid: "The same layout with the padding reduced."
1344
- },
1345
- {
1346
- slug: "high-contrast",
1347
- name: "High contrast",
1348
- brief: "Commit to a hard palette: near-black against one saturated accent, or the whole thing inverted. Define shapes with edges rather than gradients.",
1349
- avoid: "Darkening the existing palette by a few steps."
1350
- },
1351
- {
1352
- slug: "material",
1353
- name: "Material",
1354
- 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.",
1355
- avoid: "One drop shadow on an otherwise flat card."
1356
- },
1357
- {
1358
- slug: "type-led",
1359
- name: "Type-led",
1360
- brief: "Remove imagery entirely. Build the composition from letterforms: extreme scale contrast, a second typeface earning its place, text as the visual itself.",
1361
- avoid: "Keeping the image and setting the headline larger."
1362
- },
1363
- {
1364
- slug: "playful",
1365
- name: "Playful",
1366
- brief: "Deliberate imperfection. Rotation, overlap, irregular or hand-made elements, and one colour that ought not to work but does.",
1367
- avoid: "Increasing the border radius and little else."
1368
- },
1369
- {
1370
- slug: "systemic",
1371
- name: "Systemic",
1372
- brief: "Make the structure visible. Modular blocks on a stated grid, consistent module sizes, alignment itself as the aesthetic.",
1373
- avoid: "Adding borders around the sections that already exist."
1374
- }
1375
- ];
1376
- function briefsFor(count) {
1377
- if (!Number.isFinite(count) || count <= 0) return [];
1378
- return ALL_BRIEFS.slice(0, Math.min(Math.floor(count), ALL_BRIEFS.length));
1379
- }
1380
- function planBriefs(surface, count) {
1552
+ // src/explore.ts
1553
+ function planExplore(surface, count, basedOn = null) {
1381
1554
  const slug = surfaceSlug(surface);
1382
- const chosen = briefsFor(count);
1383
- const previews = chosen.map((brief) => ({
1384
- title: brief.name,
1385
- url: `/?v-${slug}=${brief.slug}`
1386
- }));
1387
- const commands = chosen.map(
1388
- (brief) => `leglas add --title ${JSON.stringify(brief.name)} --url ${JSON.stringify(
1389
- `/?v-${slug}=${brief.slug}`
1390
- )} --note ${JSON.stringify(`${brief.brief.split(".")[0]}.`)}`
1391
- );
1392
- const instructions = `Build ${chosen.length} direction${chosen.length === 1 ? "" : "s"} for "${surface}", one per angle below.
1555
+ const goal = basedOn === null ? `Build ${count} design directions for "${surface}".
1556
+
1557
+ 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.
1558
+
1559
+ 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}".
1393
1560
 
1394
- 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.
1561
+ 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.`;
1562
+ const register = basedOn === null ? ` npx leglas add --title "<name>" --url "/?v-${slug}=<key>" --note "<the idea, one line>"` : ` npx leglas add --title "<name>" --url "/?v-${slug}=<key>" --based-on ${JSON.stringify(basedOn)} --note "<the idea, one line>"`;
1563
+ 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 \`npx 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:
1395
1564
 
1396
- 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.
1565
+ ${register}
1397
1566
 
1398
- Then register them:
1567
+ The title and note are what the user judges from in the rail, so name each one for its idea rather than numbering it.`;
1568
+ return { surface, slug, count, basedOn, instructions: `${goal}
1399
1569
 
1400
- ` + commands.map((command) => ` ${command}`).join("\n");
1401
- return { previews, commands, instructions };
1570
+ ${mechanics}` };
1402
1571
  }
1403
1572
 
1404
1573
  // src/run-explore.ts
1405
1574
  function runExplore(options, deps) {
1406
- const chosen = briefsFor(options.count);
1407
- if (chosen.length === 0) {
1575
+ if (!Number.isFinite(options.count) || options.count <= 0) {
1408
1576
  deps.log(
1409
1577
  options.json ? JSON.stringify({ ok: false, error: "Ask for at least one direction." }) : "Ask for at least one direction, for example --count 4."
1410
1578
  );
1411
1579
  return { exitCode: 1 };
1412
1580
  }
1413
- const plan = planBriefs(options.surface, options.count);
1581
+ const plan = planExplore(options.surface, Math.floor(options.count), options.basedOn);
1414
1582
  if (options.json) {
1415
- deps.log(
1416
- JSON.stringify({
1417
- ok: true,
1418
- surface: options.surface,
1419
- directions: chosen,
1420
- previews: plan.previews,
1421
- commands: plan.commands,
1422
- instructions: plan.instructions
1423
- })
1424
- );
1583
+ deps.log(JSON.stringify({ ok: true, ...plan }));
1425
1584
  return { exitCode: 0 };
1426
1585
  }
1427
- if (options.count > ALL_BRIEFS.length) {
1428
- deps.log(
1429
- `Asked for ${options.count}; there are ${ALL_BRIEFS.length} distinct angles, so ${ALL_BRIEFS.length} follow.`
1430
- );
1431
- deps.log("");
1432
- }
1433
- for (const brief of chosen) {
1434
- deps.log(`${brief.name}`);
1435
- deps.log(` ${brief.brief}`);
1436
- deps.log(` Avoid: ${brief.avoid}`);
1437
- deps.log("");
1438
- }
1439
1586
  deps.log(plan.instructions);
1440
1587
  return { exitCode: 0 };
1441
1588
  }
1442
1589
 
1443
1590
  // src/run-init.ts
1444
- import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1445
- import { join as join7 } from "path";
1591
+ import { readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1592
+ import { join as join8 } from "path";
1446
1593
 
1447
1594
  // src/init.ts
1448
1595
  var AGENTS_MARKER_START = "<!-- leglas:start -->";
@@ -1469,34 +1616,48 @@ When asked for design variations, alternatives, or "a few options":
1469
1616
  If a direction cannot be additive, because it must change dependencies,
1470
1617
  change build configuration, or rewrite an existing file's behaviour, ask
1471
1618
  where it should live before writing it:
1472
- \`leglas classify --change package.json --rewrite src/theme.css --json\`.
1619
+ \`npx leglas classify --change package.json --rewrite src/theme.css --json\`.
1473
1620
  When the answer is \`checkout\`, build the direction on its own git branch
1474
- and register it with \`leglas add --title "\u2026" --url "/" --branch <branch>\`
1621
+ and register it with \`npx leglas add --title "\u2026" --url "/" --branch <branch>\`
1475
1622
  (the config needs \`devCommand\` with \`{port}\`). Everything below is the
1476
1623
  ordinary, in-app path.
1477
- 2. Run \`leglas explore <surface> --count <n>\` first. It returns distinct
1478
- angles to build, each with what to avoid, so several directions genuinely
1479
- disagree instead of becoming shades of one idea. Follow those angles rather
1480
- than inventing your own variations of the current design.
1624
+ 2. Run \`npx leglas explore <surface> --count <n>\` first, adding
1625
+ \`--based-on "<title>"\` when the user wants variations of a direction they
1626
+ already like. It prints what the set needs and how to register it. In
1627
+ short: new directions must genuinely disagree with each other, variants of
1628
+ one must not, and either way decide the whole set before building any of
1629
+ it. The designs themselves are yours.
1481
1630
  3. If the surface has no switcher yet, run
1482
- \`leglas new <surface> --from <the component that renders it today>\`. It
1631
+ \`npx leglas new <surface> --from <the component that renders it today>\`. It
1483
1632
  writes one under \`.leglas/variants/<surface>/\` and prints the single line
1484
1633
  to add where that surface renders. \`--from\` makes the baseline re-export
1485
1634
  the real component rather than copying it, so it stays live.
1486
- 4. Put each direction in its own file beside the others in
1487
- \`.leglas/variants/<surface>/\`, then list it in the \`DIRECTIONS\` map in
1488
- that folder's \`switch\` file.
1489
- 5. Register each one so it appears in the interface:
1490
- \`leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
1491
- 6. Tell the user to open the interface, or to run \`leglas\` if it is not
1492
- already running.
1493
-
1494
- When the user asks to change one direction, check \`leglas requests --json\`
1635
+ 4. Before building, make sure the interface is up. If Leglas is not
1636
+ already running, tell the user to run \`npx leglas\`, and hand them the
1637
+ URL now rather than when the set is done: the rail picks up each
1638
+ registration within seconds, so they get to watch the exploration fill in.
1639
+ 5. Build one direction at a time: its own file beside the others in
1640
+ \`.leglas/variants/<surface>/\`, listed in the \`DIRECTIONS\` map in that
1641
+ folder's \`switch\` file, then registered the moment it renders:
1642
+ \`npx leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
1643
+ Register each direction as it lands, never the whole set at the end. To
1644
+ the user watching the rail, a batch at the end is minutes of nothing and
1645
+ then everything at once.
1646
+
1647
+ When the user asks to change one direction, check \`npx leglas requests --json\`
1495
1648
  first: they may have described it from the interface, and the request names the
1496
- exact file. Clear the queue with \`leglas requests --clear\` once done.
1649
+ exact file. Acknowledge them with \`npx leglas requests --clear\` once done: it
1650
+ drops what you collected and reports anything the user typed while you worked,
1651
+ which is yours to collect and do next.
1652
+
1653
+ If the user wants requests handled the moment they are typed, without relaying
1654
+ each one, tell them about \`npx leglas watch --run "claude -p {prompt}"\` (any
1655
+ agent command works; {prompt} receives the request). It runs in their
1656
+ terminal, hands each request to that command as it arrives, and the interface
1657
+ shows the request's progress.
1497
1658
 
1498
1659
  When the user picks a winner, run
1499
- \`leglas keep "<title>" --to <path in real source>\`. It moves that direction
1660
+ \`npx leglas keep "<title>" --to <path in real source>\`. It moves that direction
1500
1661
  out of the ignored directory, deletes the rest of the exploration, and drops
1501
1662
  them from the rail. Then change their component to use the kept component
1502
1663
  instead of the switcher.
@@ -1507,18 +1668,24 @@ Useful to know:
1507
1668
  ships. Move a direction into real source only when it wins.
1508
1669
  - If the project has no running app yet, a direction can be a plain HTML
1509
1670
  file: write it under \`.leglas/pages/\` and register it with
1510
- \`leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
1671
+ \`npx leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
1511
1672
  serves the file itself, so no dev server is needed. Sibling assets in the
1512
1673
  same directory resolve normally.
1513
- - Titles identify previews and must be unique.
1514
- - \`leglas list\` shows every direction, shared and local.
1674
+ - Titles identify previews and must be unique. The user may rename one in the
1675
+ rail, which renames it on their machine only; the commands answer to either
1676
+ name, so use whichever they said.
1677
+ - \`npx leglas list\` shows every direction, shared and local.
1678
+ - \`npx leglas show "<title>" --json\` answers for one of them: the file behind it,
1679
+ the variants based on it, what it is being compared against, and anything
1680
+ they have asked for that is not done yet. Run it when handed a direction you
1681
+ did not register yourself.
1515
1682
  - Every command accepts \`--json\` and prints one envelope with a stable exit
1516
1683
  code, so you can drive it without parsing prose.
1517
1684
 
1518
1685
  ${AGENTS_MARKER_END}
1519
1686
  `;
1520
1687
  var STARTER_CONFIG = `// Previews are URLs of your own app. Add one per direction you want to
1521
- // compare, then run \`leglas\`.
1688
+ // compare, then run \`npx leglas\`.
1522
1689
  export default {
1523
1690
  // Where your dev server is. Override at the command line with --user-port.
1524
1691
  devServer: "http://localhost:3000",
@@ -1555,7 +1722,7 @@ ${AGENTS_SECTION}`
1555
1722
  // src/run-init.ts
1556
1723
  async function readIfPresent(path) {
1557
1724
  try {
1558
- return await readFile4(path, "utf8");
1725
+ return await readFile5(path, "utf8");
1559
1726
  } catch {
1560
1727
  return null;
1561
1728
  }
@@ -1563,18 +1730,18 @@ async function readIfPresent(path) {
1563
1730
  async function runInit(options, deps) {
1564
1731
  const existingConfig = findConfigFile(options.cwd);
1565
1732
  const plan = planInit({
1566
- agents: await readIfPresent(join7(options.cwd, "AGENTS.md")),
1733
+ agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
1567
1734
  config: existingConfig === null ? null : "present",
1568
- gitignore: await readIfPresent(join7(options.cwd, ".gitignore")),
1735
+ gitignore: await readIfPresent(join8(options.cwd, ".gitignore")),
1569
1736
  force: options.force
1570
1737
  });
1571
1738
  const touched = [];
1572
1739
  for (const write of plan.writes) {
1573
- await writeFile3(join7(options.cwd, write.path), write.contents, "utf8");
1740
+ await writeFile4(join8(options.cwd, write.path), write.contents, "utf8");
1574
1741
  touched.push(write.path);
1575
1742
  }
1576
1743
  if (plan.gitignore !== null) {
1577
- await writeFile3(join7(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1744
+ await writeFile4(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1578
1745
  touched.push(".gitignore");
1579
1746
  }
1580
1747
  if (options.json) {
@@ -1594,8 +1761,8 @@ async function runInit(options, deps) {
1594
1761
 
1595
1762
  // src/run-keep.ts
1596
1763
  import { existsSync as existsSync3 } from "fs";
1597
- import { mkdir as mkdir3, readFile as readFile5, rm as rm2, writeFile as writeFile4 } from "fs/promises";
1598
- import { dirname as dirname4, join as join8 } from "path";
1764
+ import { mkdir as mkdir4, readFile as readFile6, rm as rm2, writeFile as writeFile5 } from "fs/promises";
1765
+ import { dirname as dirname5, join as join9 } from "path";
1599
1766
 
1600
1767
  // src/keep.ts
1601
1768
  import { basename as basename2, extname as extname2, normalize as normalize2 } from "path";
@@ -1616,7 +1783,7 @@ function planKeep(options) {
1616
1783
  if (!winner) {
1617
1784
  return {
1618
1785
  ok: false,
1619
- error: `No direction called ${JSON.stringify(options.title)}. Run leglas list to see them.`
1786
+ error: `No direction called ${JSON.stringify(options.title)}. Run npx leglas list to see them.`
1620
1787
  };
1621
1788
  }
1622
1789
  const from = targetFor(winner.url);
@@ -1652,6 +1819,22 @@ function planKeep(options) {
1652
1819
  };
1653
1820
  }
1654
1821
 
1822
+ // src/resolve-title.ts
1823
+ function resolveOrExplain(input, titles, renames) {
1824
+ const resolution = resolveTitle(input, titles, renames);
1825
+ if (resolution.ok) return { ok: true, title: resolution.title };
1826
+ if (resolution.reason === "ambiguous") {
1827
+ return {
1828
+ ok: false,
1829
+ 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.`
1830
+ };
1831
+ }
1832
+ return {
1833
+ ok: false,
1834
+ 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. npx leglas list shows every title.`
1835
+ };
1836
+ }
1837
+
1655
1838
  // src/run-keep.ts
1656
1839
  function renameExport(source, to) {
1657
1840
  const match = /export function ([A-Za-z0-9_]+)\s*\(/.exec(source);
@@ -1665,31 +1848,37 @@ async function runKeep(options, deps) {
1665
1848
  const loaded = await loadConfig(options.cwd);
1666
1849
  const local = await readLocalPreviews(options.cwd);
1667
1850
  const previews = [...loaded.config?.previews ?? [], ...local.previews];
1668
- const plan = planKeep({ title: options.title, previews, to: options.to });
1669
1851
  const fail = (error) => {
1670
1852
  if (options.json) deps.log(JSON.stringify({ ok: false, error }));
1671
1853
  else deps.error(error);
1672
1854
  return { exitCode: 1 };
1673
1855
  };
1856
+ const resolved = resolveOrExplain(
1857
+ options.title,
1858
+ previews.map((preview) => preview.title),
1859
+ await readRenames(options.cwd)
1860
+ );
1861
+ if (!resolved.ok) return fail(resolved.error);
1862
+ const plan = planKeep({ title: resolved.title, previews, to: options.to });
1674
1863
  if (!plan.ok) return fail(plan.error);
1675
- const from = join8(options.cwd, plan.move.from);
1676
- const to = join8(options.cwd, plan.move.to);
1864
+ const from = join9(options.cwd, plan.move.from);
1865
+ const to = join9(options.cwd, plan.move.to);
1677
1866
  if (!existsSync3(from)) {
1678
1867
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
1679
1868
  }
1680
1869
  if (existsSync3(to)) {
1681
1870
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
1682
1871
  }
1683
- const source = await readFile5(from, "utf8");
1684
- await mkdir3(dirname4(to), { recursive: true });
1685
- await writeFile4(to, renameExport(source, plan.exportName), "utf8");
1686
- await rm2(join8(options.cwd, plan.removeDir), { recursive: true, force: true });
1872
+ const source = await readFile6(from, "utf8");
1873
+ await mkdir4(dirname5(to), { recursive: true });
1874
+ await writeFile5(to, renameExport(source, plan.exportName), "utf8");
1875
+ await rm2(join9(options.cwd, plan.removeDir), { recursive: true, force: true });
1687
1876
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
1688
1877
  if (options.json) {
1689
1878
  deps.log(
1690
1879
  JSON.stringify({
1691
1880
  ok: true,
1692
- kept: options.title,
1881
+ kept: resolved.title,
1693
1882
  to: plan.move.to,
1694
1883
  exportName: plan.exportName,
1695
1884
  removed: plan.removeDir,
@@ -1718,11 +1907,11 @@ async function runKeep(options, deps) {
1718
1907
 
1719
1908
  // src/run-new.ts
1720
1909
  import { existsSync as existsSync4 } from "fs";
1721
- import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
1722
- import { dirname as dirname5, join as join9 } from "path";
1910
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
1911
+ import { dirname as dirname6, join as join10 } from "path";
1723
1912
  async function readIfPresent2(path) {
1724
1913
  try {
1725
- return await readFile6(path, "utf8");
1914
+ return await readFile7(path, "utf8");
1726
1915
  } catch {
1727
1916
  return null;
1728
1917
  }
@@ -1730,7 +1919,7 @@ async function readIfPresent2(path) {
1730
1919
  async function runNew(options, deps) {
1731
1920
  let from;
1732
1921
  if (options.from !== void 0) {
1733
- const contents = await readIfPresent2(join9(options.cwd, options.from));
1922
+ const contents = await readIfPresent2(join10(options.cwd, options.from));
1734
1923
  if (contents === null) {
1735
1924
  const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
1736
1925
  if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
@@ -1741,8 +1930,8 @@ async function runNew(options, deps) {
1741
1930
  }
1742
1931
  const plan = planNew({
1743
1932
  surface: options.surface,
1744
- packageJson: await readIfPresent2(join9(options.cwd, "package.json")),
1745
- gitignore: await readIfPresent2(join9(options.cwd, ".gitignore")),
1933
+ packageJson: await readIfPresent2(join10(options.cwd, "package.json")),
1934
+ gitignore: await readIfPresent2(join10(options.cwd, ".gitignore")),
1746
1935
  from
1747
1936
  });
1748
1937
  const fail = (error) => {
@@ -1765,19 +1954,19 @@ async function runNew(options, deps) {
1765
1954
  deps.log(plan.instructions);
1766
1955
  return { exitCode: 0, written: [] };
1767
1956
  }
1768
- const existing = plan.writes.filter((write) => existsSync4(join9(options.cwd, write.path)));
1957
+ const existing = plan.writes.filter((write) => existsSync4(join10(options.cwd, write.path)));
1769
1958
  if (existing.length > 0) {
1770
1959
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
1771
1960
  }
1772
1961
  const written = [];
1773
1962
  for (const write of plan.writes) {
1774
- const target = join9(options.cwd, write.path);
1775
- await mkdir4(dirname5(target), { recursive: true });
1776
- await writeFile5(target, write.contents, "utf8");
1963
+ const target = join10(options.cwd, write.path);
1964
+ await mkdir5(dirname6(target), { recursive: true });
1965
+ await writeFile6(target, write.contents, "utf8");
1777
1966
  written.push(write.path);
1778
1967
  }
1779
1968
  if (plan.gitignore !== null) {
1780
- await writeFile5(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1969
+ await writeFile6(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1781
1970
  written.push(".gitignore");
1782
1971
  }
1783
1972
  if (options.json) {
@@ -1792,31 +1981,41 @@ async function runNew(options, deps) {
1792
1981
  deps.log("Then register them so they appear in the interface:");
1793
1982
  deps.log("");
1794
1983
  for (const preview of plan.previews) {
1795
- deps.log(` leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
1984
+ deps.log(` npx leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
1796
1985
  }
1797
1986
  return { exitCode: 0, written };
1798
1987
  }
1799
1988
 
1800
1989
  // src/run-previews.ts
1801
- import { readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
1802
- import { join as join10 } from "path";
1990
+ import { readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
1991
+ import { join as join11 } from "path";
1803
1992
  function envelope(deps, ok, body) {
1804
1993
  deps.log(JSON.stringify({ ok, ...body }));
1805
1994
  }
1806
1995
  async function ensureIgnored(cwd) {
1807
- const path = join10(cwd, ".gitignore");
1996
+ const path = join11(cwd, ".gitignore");
1808
1997
  let current = null;
1809
1998
  try {
1810
- current = await readFile7(path, "utf8");
1999
+ current = await readFile8(path, "utf8");
1811
2000
  } catch {
1812
2001
  current = null;
1813
2002
  }
1814
2003
  const next = ignoreEntry(current);
1815
- if (next !== null) await writeFile6(path, next, "utf8");
2004
+ if (next !== null) await writeFile7(path, next, "utf8");
1816
2005
  }
1817
2006
  async function runAdd(options, deps) {
1818
2007
  const loaded = await loadConfig(options.cwd);
1819
2008
  const shared = loaded.config?.previews ?? [];
2009
+ if (options.preview.basedOn !== void 0) {
2010
+ const local = await readLocalPreviews(options.cwd);
2011
+ const titles = new Set([...shared, ...local.previews].map((preview) => preview.title));
2012
+ if (!titles.has(options.preview.basedOn)) {
2013
+ const error = `--based-on names ${JSON.stringify(options.preview.basedOn)}, which is not a registered direction. npx leglas list shows what exists.`;
2014
+ if (options.json) envelope(deps, false, { error });
2015
+ else deps.error(error);
2016
+ return { exitCode: 1 };
2017
+ }
2018
+ }
1820
2019
  const outcome = await addLocalPreview(
1821
2020
  options.cwd,
1822
2021
  {
@@ -1825,7 +2024,8 @@ async function runAdd(options, deps) {
1825
2024
  note: options.preview.note,
1826
2025
  tags: options.preview.tags,
1827
2026
  branch: options.preview.branch,
1828
- file: options.preview.file
2027
+ file: options.preview.file,
2028
+ basedOn: options.preview.basedOn
1829
2029
  },
1830
2030
  shared
1831
2031
  );
@@ -1843,6 +2043,7 @@ async function runAdd(options, deps) {
1843
2043
  local: true,
1844
2044
  ...options.preview.branch === void 0 ? {} : { branch: options.preview.branch },
1845
2045
  ...options.preview.file === void 0 ? {} : { file: options.preview.file },
2046
+ note: options.preview.branch === void 0 && options.preview.file === void 0 ? "A running interface picks this up within seconds." : "Restart Leglas to see this preview: branch checkouts and file mounts are built when Leglas starts.",
1846
2047
  ...needsDevCommand ? { warning: "The config sets no devCommand, so Leglas cannot start this branch yet. Add devCommand (with {port}) to the config." } : {}
1847
2048
  });
1848
2049
  } else {
@@ -1855,7 +2056,11 @@ async function runAdd(options, deps) {
1855
2056
  deps.log(" Add devCommand (with {port}) to the config.");
1856
2057
  deps.log("");
1857
2058
  }
1858
- deps.log("Local to this machine. Restart Leglas to see it, or run leglas list.");
2059
+ if (options.preview.branch === void 0 && options.preview.file === void 0) {
2060
+ deps.log("Local to this machine. A running interface picks it up within seconds.");
2061
+ } else {
2062
+ deps.log("Local to this machine. Restart Leglas to see it, or run npx leglas list.");
2063
+ }
1859
2064
  }
1860
2065
  return { exitCode: 0 };
1861
2066
  }
@@ -1869,9 +2074,17 @@ async function runList(options, deps) {
1869
2074
  ];
1870
2075
  if (options.json) {
1871
2076
  envelope(deps, errors.length === 0, {
2077
+ // The whole record, not a summary of it. What the config holds about a
2078
+ // preview — its note, its tags, the direction it is a variant of — is
2079
+ // exactly what tells an agent why these are being compared, and leaving
2080
+ // it out made the listing thinner than the reference block that points
2081
+ // at it.
1872
2082
  previews: previews.map((preview) => ({
1873
2083
  title: preview.title,
1874
2084
  url: preview.url,
2085
+ note: preview.note ?? null,
2086
+ tags: preview.tags,
2087
+ basedOn: preview.basedOn ?? null,
1875
2088
  local: preview.local,
1876
2089
  branch: preview.branch ?? null,
1877
2090
  file: preview.file ?? null
@@ -1881,7 +2094,7 @@ async function runList(options, deps) {
1881
2094
  return { exitCode: errors.length === 0 ? 0 : 1 };
1882
2095
  }
1883
2096
  if (previews.length === 0) {
1884
- deps.log("No previews yet. Add one with leglas add, or list them in leglas.config.ts.");
2097
+ deps.log("No previews yet. Add one with npx leglas add, or list them in leglas.config.ts.");
1885
2098
  } else {
1886
2099
  const width = Math.max(...previews.map((preview) => preview.title.length));
1887
2100
  for (const preview of previews) {
@@ -1896,12 +2109,19 @@ async function runList(options, deps) {
1896
2109
  }
1897
2110
  async function runRequests(options, deps) {
1898
2111
  if (options.clear) {
1899
- await clearRequests(options.cwd);
1900
- if (options.json) envelope(deps, true, { cleared: true });
1901
- else deps.log("Queue cleared.");
2112
+ const { cleared, pending } = await clearRequests(options.cwd);
2113
+ if (options.json) envelope(deps, true, { cleared, pending });
2114
+ else {
2115
+ deps.log(cleared === 1 ? "Cleared 1 request." : `Cleared ${cleared} requests.`);
2116
+ if (pending > 0) {
2117
+ deps.log(
2118
+ `${pending} arrived while you worked. Run npx leglas requests to collect ${pending === 1 ? "it" : "them"}.`
2119
+ );
2120
+ }
2121
+ }
1902
2122
  return { exitCode: 0 };
1903
2123
  }
1904
- const requests = await readRequests(options.cwd);
2124
+ const requests = await collectRequests(options.cwd);
1905
2125
  if (options.json) {
1906
2126
  envelope(deps, true, { requests });
1907
2127
  return { exitCode: 0 };
@@ -1915,21 +2135,331 @@ async function runRequests(options, deps) {
1915
2135
  if (request.target !== null) deps.log(` ${request.target}`);
1916
2136
  }
1917
2137
  deps.log("");
1918
- deps.log("Run leglas requests --json to get the full prompts, then --clear when done.");
2138
+ deps.log("Run npx leglas requests --json to get the full prompts, then --clear when done.");
1919
2139
  return { exitCode: 0 };
1920
2140
  }
1921
2141
 
2142
+ // src/show.ts
2143
+ function describe(preview) {
2144
+ return {
2145
+ title: preview.title,
2146
+ url: preview.url,
2147
+ note: preview.note ?? null,
2148
+ tags: preview.tags,
2149
+ basedOn: preview.basedOn ?? null,
2150
+ branch: preview.branch ?? null,
2151
+ file: preview.file ?? null,
2152
+ local: preview.local === true,
2153
+ // A file preview names its own source. Everything else is decoded from the
2154
+ // URL, and a URL outside the convention yields nothing rather than a path
2155
+ // that looks authoritative and is not there.
2156
+ target: preview.file ?? targetFor(preview.url)
2157
+ };
2158
+ }
2159
+ function planShow({ title, previews, requests }) {
2160
+ const found = previews.find((preview) => preview.title === title);
2161
+ if (!found) {
2162
+ return {
2163
+ ok: false,
2164
+ error: `No direction called ${JSON.stringify(title)}. Run npx leglas list to see them.`
2165
+ };
2166
+ }
2167
+ const variants = previews.filter((preview) => preview.basedOn === title).map(describe);
2168
+ const variantTitles = new Set(variants.map((variant) => variant.title));
2169
+ return {
2170
+ ok: true,
2171
+ direction: describe(found),
2172
+ variants,
2173
+ // Its own variants are already listed in full, so they are not repeated
2174
+ // here; this is the rest of the comparison.
2175
+ comparedWith: previews.map((preview) => preview.title).filter((other) => other !== title && !variantTitles.has(other)),
2176
+ requests: requests.filter((request) => request.title === title).map((request) => ({
2177
+ id: request.id,
2178
+ intent: request.intent,
2179
+ target: request.target,
2180
+ prompt: request.prompt,
2181
+ status: request.status
2182
+ }))
2183
+ };
2184
+ }
2185
+
2186
+ // src/run-show.ts
2187
+ async function runShow(options, deps) {
2188
+ const loaded = await loadConfig(options.cwd);
2189
+ const local = await readLocalPreviews(options.cwd);
2190
+ const requests = await readRequests(options.cwd);
2191
+ const previews = [
2192
+ ...(loaded.config?.previews ?? []).map((preview) => ({ ...preview, local: false })),
2193
+ ...local.previews
2194
+ ];
2195
+ const resolved = resolveOrExplain(
2196
+ options.title,
2197
+ previews.map((preview) => preview.title),
2198
+ await readRenames(options.cwd)
2199
+ );
2200
+ if (!resolved.ok) {
2201
+ if (options.json) deps.log(JSON.stringify({ ok: false, error: resolved.error }));
2202
+ else deps.error(resolved.error);
2203
+ return { exitCode: 1 };
2204
+ }
2205
+ const plan = planShow({ title: resolved.title, previews, requests });
2206
+ if (!plan.ok) {
2207
+ if (options.json) deps.log(JSON.stringify({ ok: false, error: plan.error }));
2208
+ else deps.error(plan.error);
2209
+ return { exitCode: 1 };
2210
+ }
2211
+ if (options.json) {
2212
+ deps.log(
2213
+ JSON.stringify({
2214
+ ok: true,
2215
+ direction: plan.direction,
2216
+ variants: plan.variants,
2217
+ comparedWith: plan.comparedWith,
2218
+ requests: plan.requests
2219
+ })
2220
+ );
2221
+ return { exitCode: 0 };
2222
+ }
2223
+ const { direction } = plan;
2224
+ deps.log(` ${direction.title}${direction.local ? " (local)" : ""}`);
2225
+ if (direction.note !== null) deps.log(` ${direction.note}`);
2226
+ deps.log("");
2227
+ if (direction.target !== null) deps.log(` file ${direction.target}`);
2228
+ if (direction.branch !== null) deps.log(` branch ${direction.branch}`);
2229
+ deps.log(` url ${direction.url}`);
2230
+ if (direction.tags.length > 0) deps.log(` tags ${direction.tags.join(", ")}`);
2231
+ if (direction.basedOn !== null) deps.log(` variant of ${direction.basedOn}`);
2232
+ if (plan.variants.length > 0) {
2233
+ deps.log(` variants ${plan.variants.map((variant) => variant.title).join(", ")}`);
2234
+ }
2235
+ if (plan.comparedWith.length > 0) {
2236
+ deps.log(` against ${plan.comparedWith.join(", ")}`);
2237
+ }
2238
+ if (plan.requests.length > 0) {
2239
+ deps.log("");
2240
+ deps.log(` Pending, not yet done (${plan.requests.length}):`);
2241
+ for (const request of plan.requests) deps.log(` ${request.status} ${request.intent}`);
2242
+ deps.log("");
2243
+ deps.log(" Run npx leglas requests --json for the full prompts.");
2244
+ }
2245
+ return { exitCode: 0 };
2246
+ }
2247
+
2248
+ // src/run-watch.ts
2249
+ import { spawn as spawn2 } from "child_process";
2250
+ import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2251
+ import { dirname as dirname7, join as join12 } from "path";
2252
+
2253
+ // src/watch.ts
2254
+ var WATCH_PATH = ".leglas/watch.json";
2255
+ var PROMPT_TOKEN = "{prompt}";
2256
+ var EXAMPLE = `npx leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
2257
+ function tokenize(template) {
2258
+ const tokens = [];
2259
+ let current = "";
2260
+ let started = false;
2261
+ let quote = null;
2262
+ for (const character of template) {
2263
+ if (quote !== null) {
2264
+ if (character === quote) quote = null;
2265
+ else current += character;
2266
+ continue;
2267
+ }
2268
+ if (character === '"' || character === "'") {
2269
+ quote = character;
2270
+ started = true;
2271
+ continue;
2272
+ }
2273
+ if (/\s/.test(character)) {
2274
+ if (started) tokens.push(current);
2275
+ current = "";
2276
+ started = false;
2277
+ continue;
2278
+ }
2279
+ current += character;
2280
+ started = true;
2281
+ }
2282
+ if (quote !== null) {
2283
+ return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
2284
+ }
2285
+ if (started) tokens.push(current);
2286
+ return { ok: true, tokens };
2287
+ }
2288
+ function parseTemplate(raw) {
2289
+ const tokenized = tokenize(raw);
2290
+ if (!tokenized.ok) return tokenized;
2291
+ const { tokens } = tokenized;
2292
+ const [command, ...args] = tokens;
2293
+ if (command === void 0) {
2294
+ return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
2295
+ }
2296
+ const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
2297
+ if (placeholders === 0) {
2298
+ return {
2299
+ ok: false,
2300
+ error: `The agent command needs ${PROMPT_TOKEN} as a word of its own, for example: ${EXAMPLE}`
2301
+ };
2302
+ }
2303
+ if (placeholders > 1) {
2304
+ return {
2305
+ ok: false,
2306
+ error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
2307
+ };
2308
+ }
2309
+ if (command === PROMPT_TOKEN) {
2310
+ return {
2311
+ ok: false,
2312
+ error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
2313
+ };
2314
+ }
2315
+ return { ok: true, template: { command, args } };
2316
+ }
2317
+ function commandFor(template, prompt) {
2318
+ return {
2319
+ command: template.command,
2320
+ args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
2321
+ };
2322
+ }
2323
+ function nextRequest(requests, failed) {
2324
+ return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
2325
+ }
2326
+
2327
+ // src/run-watch.ts
2328
+ var POLL_MS = 2e3;
2329
+ var HEARTBEAT_TIMEOUT_MS = 1e3;
2330
+ async function readSavedTemplate(cwd) {
2331
+ try {
2332
+ const raw = await readFile9(join12(cwd, WATCH_PATH), "utf8");
2333
+ const parsed2 = JSON.parse(raw);
2334
+ return typeof parsed2.run === "string" && parsed2.run !== "" ? parsed2.run : null;
2335
+ } catch {
2336
+ return null;
2337
+ }
2338
+ }
2339
+ async function saveTemplate(cwd, run3) {
2340
+ const path = join12(cwd, WATCH_PATH);
2341
+ await mkdir6(dirname7(path), { recursive: true });
2342
+ await writeFile8(path, `${JSON.stringify({ run: run3 }, null, 2)}
2343
+ `, "utf8");
2344
+ }
2345
+ function spawnAgent(command, args, cwd) {
2346
+ return new Promise((resolve) => {
2347
+ let settled = false;
2348
+ const settle = (outcome) => {
2349
+ if (settled) return;
2350
+ settled = true;
2351
+ resolve(outcome);
2352
+ };
2353
+ const child = spawn2(command, args, { cwd, stdio: "inherit" });
2354
+ child.on("error", (error) => settle({ ok: false, error: error.message }));
2355
+ child.on(
2356
+ "close",
2357
+ (code, signal) => settle(
2358
+ signal === null ? { ok: true, code: code ?? 0 } : { ok: false, error: `stopped by ${signal}` }
2359
+ )
2360
+ );
2361
+ });
2362
+ }
2363
+ async function runWatch(options, deps) {
2364
+ const saved = options.run === void 0 ? await readSavedTemplate(options.cwd) : null;
2365
+ const raw = options.run ?? saved;
2366
+ if (raw === null) {
2367
+ deps.error(
2368
+ 'Watch needs an agent command the first time: npx leglas watch --run "claude -p {prompt}"'
2369
+ );
2370
+ return { exitCode: 1 };
2371
+ }
2372
+ const parsed2 = parseTemplate(raw);
2373
+ if (!parsed2.ok) {
2374
+ deps.error(parsed2.error);
2375
+ return { exitCode: 1 };
2376
+ }
2377
+ const template = parsed2.template;
2378
+ if (options.run !== void 0) await saveTemplate(options.cwd, raw).catch(() => {
2379
+ });
2380
+ const base = `http://localhost:${options.port ?? DEFAULT_PORT}`;
2381
+ const heartbeat = async (watching) => {
2382
+ try {
2383
+ await fetch(`${base}${LEGLAS_PREFIX}/api/watch`, {
2384
+ method: "POST",
2385
+ headers: { "content-type": "application/json" },
2386
+ body: JSON.stringify({ watching }),
2387
+ signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS)
2388
+ });
2389
+ } catch {
2390
+ }
2391
+ };
2392
+ deps.log(`Watching for change requests. Each one runs: ${raw}`);
2393
+ deps.log("Stop with Ctrl-C.");
2394
+ const failed = /* @__PURE__ */ new Set();
2395
+ let stopped = false;
2396
+ let busy = false;
2397
+ let inflight = null;
2398
+ const handle = async (request) => {
2399
+ deps.log("");
2400
+ deps.log(` ${request.title}: ${request.intent}`);
2401
+ if (request.target !== null) deps.log(` ${request.target}`);
2402
+ await markPickedUp(options.cwd, request.id);
2403
+ const { command, args } = commandFor(template, request.prompt);
2404
+ const outcome = await spawnAgent(command, args, options.cwd);
2405
+ if (outcome.ok && outcome.code === 0) {
2406
+ await removeRequest(options.cwd, request.id);
2407
+ deps.log(` done ${request.title}`);
2408
+ return;
2409
+ }
2410
+ failed.add(request.id);
2411
+ deps.error(
2412
+ ` failed ${request.title}: ${outcome.ok ? `${command} exited ${outcome.code}` : outcome.error}`
2413
+ );
2414
+ deps.error(" Left in the queue and not retried.");
2415
+ };
2416
+ const tick = async () => {
2417
+ if (stopped) return;
2418
+ void heartbeat(true);
2419
+ if (busy) return;
2420
+ busy = true;
2421
+ try {
2422
+ const request = nextRequest(await readRequests(options.cwd), failed);
2423
+ if (request !== null && !stopped) {
2424
+ inflight = handle(request);
2425
+ await inflight;
2426
+ }
2427
+ } catch (error) {
2428
+ deps.error(` ! ${error instanceof Error ? error.message : String(error)}`);
2429
+ } finally {
2430
+ inflight = null;
2431
+ busy = false;
2432
+ }
2433
+ };
2434
+ return new Promise((resolve) => {
2435
+ const timer = setInterval(() => void tick(), POLL_MS);
2436
+ const stop = () => {
2437
+ if (stopped) return;
2438
+ stopped = true;
2439
+ clearInterval(timer);
2440
+ process.off("SIGINT", stop);
2441
+ process.off("SIGTERM", stop);
2442
+ void Promise.resolve(inflight).catch(() => {
2443
+ }).then(() => heartbeat(false)).then(() => resolve({ exitCode: 0 }));
2444
+ };
2445
+ process.on("SIGINT", stop);
2446
+ process.on("SIGTERM", stop);
2447
+ options.signal?.addEventListener("abort", stop, { once: true });
2448
+ void tick();
2449
+ });
2450
+ }
2451
+
1922
2452
  // src/run.ts
1923
2453
  import { existsSync as existsSync5 } from "fs";
1924
2454
  import { createRequire } from "module";
1925
- import { basename as basename3, dirname as dirname6, join as join11, relative as relative2 } from "path";
2455
+ import { basename as basename3, dirname as dirname8, join as join13, relative as relative3 } from "path";
1926
2456
  import { fileURLToPath } from "url";
1927
2457
  function findShellDir() {
1928
- const bundled = join11(dirname6(fileURLToPath(import.meta.url)), "shell");
1929
- if (existsSync5(join11(bundled, "index.html"))) return bundled;
2458
+ const bundled = join13(dirname8(fileURLToPath(import.meta.url)), "shell");
2459
+ if (existsSync5(join13(bundled, "index.html"))) return bundled;
1930
2460
  try {
1931
2461
  const require2 = createRequire(import.meta.url);
1932
- return dirname6(require2.resolve("@leglas/shell/dist/index.html"));
2462
+ return dirname8(require2.resolve("@leglas/shell/dist/index.html"));
1933
2463
  } catch {
1934
2464
  return null;
1935
2465
  }
@@ -1963,7 +2493,7 @@ async function run2(options, deps) {
1963
2493
  const fileMounts = /* @__PURE__ */ new Map();
1964
2494
  for (const preview of merged?.previews ?? []) {
1965
2495
  if (preview.file !== void 0) {
1966
- const absolute = join11(options.cwd, preview.file);
2496
+ const absolute = join13(options.cwd, preview.file);
1967
2497
  if (!existsSync5(absolute)) {
1968
2498
  worktreeErrors.push(
1969
2499
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -1974,7 +2504,7 @@ async function run2(options, deps) {
1974
2504
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
1975
2505
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
1976
2506
  }
1977
- fileMounts.set(slug, dirname6(absolute));
2507
+ fileMounts.set(slug, dirname8(absolute));
1978
2508
  previews.push({
1979
2509
  ...preview,
1980
2510
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -2035,7 +2565,7 @@ async function run2(options, deps) {
2035
2565
  })
2036
2566
  );
2037
2567
  } else {
2038
- const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative2(options.cwd, loaded.path) || loaded.path;
2568
+ const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative3(options.cwd, loaded.path) || loaded.path;
2039
2569
  deps.log(`Leglas ${url}`);
2040
2570
  deps.log(
2041
2571
  `app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
@@ -2079,11 +2609,13 @@ Usage
2079
2609
  leglas init Prepare a project and teach its agents
2080
2610
  leglas [options] Start the server and open the interface
2081
2611
  leglas new <surface> Scaffold a branch point for a surface
2082
- leglas explore <surface> Print distinct angles for an agent to build
2612
+ leglas explore <surface> Brief an agent's exploration of a surface
2083
2613
  leglas classify Decide where a direction should live
2084
2614
  leglas add --title T --url U Register a preview on this machine
2085
2615
  leglas list Show every preview, shared and local
2616
+ leglas show <title> Everything Leglas knows about one direction
2086
2617
  leglas requests Show change requests made from the interface
2618
+ leglas watch --run "<cmd>" Hand each request to your agent as it arrives
2087
2619
  leglas keep <title> --to <path> Keep a winner and end the exploration
2088
2620
 
2089
2621
  Options
@@ -2100,7 +2632,13 @@ Options for new
2100
2632
  --from <path> Use an existing component as the baseline
2101
2633
 
2102
2634
  Options for explore
2103
- --count <n> How many angles (default 3)
2635
+ --count <n> How many directions (default 3)
2636
+ --based-on <title> Variants of an existing direction instead of new ones
2637
+
2638
+ Options for watch
2639
+ --run <command> Your agent, with {prompt} where the request goes, for
2640
+ example "claude -p {prompt}". Remembered after first use
2641
+ --port <port> Port Leglas itself is on (default: 4100)
2104
2642
 
2105
2643
  Options for classify
2106
2644
  --change <path> A file the direction creates or wires up (repeatable)
@@ -2111,6 +2649,7 @@ Options for add
2111
2649
  --tag <text> Repeatable
2112
2650
  --branch <name> Back the preview with a checkout of this git branch
2113
2651
  --file <path> Preview a plain HTML file served by Leglas itself
2652
+ --based-on <title> The direction this is a variant of; groups the family
2114
2653
  `;
2115
2654
  function version() {
2116
2655
  const require2 = createRequire2(import.meta.url);
@@ -2120,7 +2659,7 @@ function version() {
2120
2659
  async function openBrowser(url) {
2121
2660
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2122
2661
  try {
2123
- spawn2(command, [url], { detached: true, stdio: "ignore" }).unref();
2662
+ spawn3(command, [url], { detached: true, stdio: "ignore" }).unref();
2124
2663
  } catch {
2125
2664
  }
2126
2665
  }
@@ -2185,7 +2724,7 @@ if (parsed.kind === "keep") {
2185
2724
  }
2186
2725
  if (parsed.kind === "explore") {
2187
2726
  const outcome = runExplore(
2188
- { surface: parsed.surface, count: parsed.count, json: parsed.json },
2727
+ { surface: parsed.surface, count: parsed.count, basedOn: parsed.basedOn, json: parsed.json },
2189
2728
  { log: (line) => process.stdout.write(`${line}
2190
2729
  `) }
2191
2730
  );
@@ -2198,10 +2737,24 @@ if (parsed.kind === "requests") {
2198
2737
  );
2199
2738
  process.exit(outcome.exitCode);
2200
2739
  }
2740
+ if (parsed.kind === "watch") {
2741
+ const outcome = await runWatch(
2742
+ { run: parsed.run, port: parsed.port, cwd: process.cwd() },
2743
+ previewDeps
2744
+ );
2745
+ process.exit(outcome.exitCode);
2746
+ }
2201
2747
  if (parsed.kind === "list") {
2202
2748
  const outcome = await runList({ json: parsed.json, cwd: process.cwd() }, previewDeps);
2203
2749
  process.exit(outcome.exitCode);
2204
2750
  }
2751
+ if (parsed.kind === "show") {
2752
+ const outcome = await runShow(
2753
+ { title: parsed.title, json: parsed.json, cwd: process.cwd() },
2754
+ previewDeps
2755
+ );
2756
+ process.exit(outcome.exitCode);
2757
+ }
2205
2758
  if (parsed.kind === "new") {
2206
2759
  const outcome = await runNew(
2207
2760
  { surface: parsed.surface, print: parsed.print, json: parsed.json, from: parsed.from, cwd: process.cwd() },