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/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
@@ -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
  }
@@ -137,8 +139,37 @@ function parseClassify(rest) {
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") {
@@ -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
  }
@@ -219,7 +262,7 @@ function parseArgs(argv) {
219
262
  message: "leglas explore needs a surface name, for example: 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: 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"];
@@ -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,78 @@ 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
986
  await writeQueue(cwd, []);
878
987
  }
879
988
 
989
+ // ../server/dist/renames.js
990
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
991
+ import { dirname as dirname4, join as join5 } from "path";
992
+ var RENAMES_PATH = ".leglas/renames.json";
993
+ async function readRenames(cwd) {
994
+ try {
995
+ const raw = await readFile4(join5(cwd, RENAMES_PATH), "utf8");
996
+ const parsed2 = JSON.parse(raw);
997
+ if (parsed2.renames === null || typeof parsed2.renames !== "object")
998
+ return {};
999
+ return Object.fromEntries(Object.entries(parsed2.renames).filter((entry) => typeof entry[1] === "string" && entry[1] !== ""));
1000
+ } catch {
1001
+ return {};
1002
+ }
1003
+ }
1004
+ async function writeRenames(cwd, renames) {
1005
+ const path = join5(cwd, RENAMES_PATH);
1006
+ await mkdir3(dirname4(path), { recursive: true });
1007
+ await writeFile3(path, `${JSON.stringify({ renames }, null, 2)}
1008
+ `, "utf8");
1009
+ }
1010
+ function resolveTitle(input, titles, renames) {
1011
+ if (titles.includes(input))
1012
+ return { ok: true, title: input };
1013
+ const matches = titles.filter((title) => renames[title] === input);
1014
+ if (matches.length === 1 && matches[0] !== void 0)
1015
+ return { ok: true, title: matches[0] };
1016
+ if (matches.length > 1)
1017
+ return { ok: false, reason: "ambiguous", matches };
1018
+ return { ok: false, reason: "unknown" };
1019
+ }
1020
+
880
1021
  // ../server/dist/server.js
881
1022
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
882
1023
  import http2 from "http";
883
1024
  import net3 from "net";
884
- import { extname, join as join5, normalize } from "path";
1025
+ import { extname, join as join6, normalize } from "path";
885
1026
  var LEGLAS_PREFIX = "/leglas";
886
1027
  var DEFAULT_PORT = 4100;
887
1028
  var PORT_ATTEMPTS = 20;
1029
+ var ATTACHED_WINDOW_MS = 6e3;
888
1030
  var CONTENT_TYPES = {
889
1031
  ".css": "text/css; charset=utf-8",
890
1032
  ".gif": "image/gif",
@@ -933,7 +1075,7 @@ function probe(target, timeoutMs = 1e3) {
933
1075
  }
934
1076
  function serveFrom(res, dir, relativePath) {
935
1077
  const relative3 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
936
- const candidate = join5(dir, relative3);
1078
+ const candidate = join6(dir, relative3);
937
1079
  if (!candidate.startsWith(dir))
938
1080
  return false;
939
1081
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -958,7 +1100,8 @@ var PLACEHOLDER = `<!doctype html>
958
1100
  <p>The server is running and proxying your app. The interface has not been
959
1101
  built into this install yet.</p>
960
1102
  <p><a href="/leglas/api/config">/leglas/api/config</a> \xB7
961
- <a href="/leglas/api/health">/leglas/api/health</a></p>
1103
+ <a href="/leglas/api/health">/leglas/api/health</a> \xB7
1104
+ <a href="/leglas/api/requests">/leglas/api/requests</a></p>
962
1105
  </body>`;
963
1106
  function listen(server, port) {
964
1107
  return new Promise((resolve, reject) => {
@@ -993,28 +1136,40 @@ async function startServer(options) {
993
1136
  const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map() } = options;
994
1137
  const target = config?.devServer ?? "http://localhost:3000";
995
1138
  const proxy = createProxyHandler({ target });
1139
+ let lastSeen = null;
996
1140
  const server = http2.createServer((req, res) => {
997
1141
  const url = req.url ?? "/";
998
1142
  const path = url.split("?")[0] ?? "/";
999
1143
  if (path === `${LEGLAS_PREFIX}/api/config`) {
1000
- return sendJson(res, 200, {
1144
+ const boot = config?.previews ?? [];
1145
+ return void readLocalPreviews(cwd).then(({ previews: local }) => {
1146
+ const known = new Set(boot.map((preview) => preview.title));
1147
+ const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
1148
+ sendJson(res, 200, {
1149
+ project,
1150
+ devServer: target,
1151
+ previews: [...boot, ...fresh],
1152
+ errors: configErrors
1153
+ });
1154
+ }).catch(() => sendJson(res, 200, {
1001
1155
  project,
1002
1156
  devServer: target,
1003
- previews: config?.previews ?? [],
1157
+ previews: boot,
1004
1158
  errors: configErrors
1005
- });
1159
+ }));
1006
1160
  }
1007
1161
  if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
1008
1162
  let body = "";
1009
1163
  req.on("data", (chunk) => body += chunk);
1010
- return void req.on("end", () => {
1164
+ return void req.on("end", async () => {
1011
1165
  let parsed2;
1012
1166
  try {
1013
1167
  parsed2 = JSON.parse(body || "{}");
1014
1168
  } catch {
1015
1169
  return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1016
1170
  }
1017
- const preview = (config?.previews ?? []).find((entry) => entry.title === parsed2.title);
1171
+ const local = await readLocalPreviews(cwd).then((read) => read.previews, () => []);
1172
+ const preview = [...config?.previews ?? [], ...local].find((entry) => entry.title === parsed2.title);
1018
1173
  if (!preview || !parsed2.intent?.trim()) {
1019
1174
  return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
1020
1175
  }
@@ -1027,6 +1182,46 @@ async function startServer(options) {
1027
1182
  }).then(() => sendJson(res, 200, { ok: true, ...composed })).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
1028
1183
  });
1029
1184
  }
1185
+ if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
1186
+ let body = "";
1187
+ req.on("data", (chunk) => body += chunk);
1188
+ return void req.on("end", () => {
1189
+ let parsed2;
1190
+ try {
1191
+ parsed2 = JSON.parse(body || "{}");
1192
+ } catch {
1193
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1194
+ }
1195
+ if (typeof parsed2.watching !== "boolean") {
1196
+ return sendJson(res, 400, { ok: false, error: "Body needs a watching boolean." });
1197
+ }
1198
+ lastSeen = parsed2.watching ? Date.now() : null;
1199
+ sendJson(res, 200, { ok: true });
1200
+ });
1201
+ }
1202
+ if (path === `${LEGLAS_PREFIX}/api/requests` && req.method === "GET") {
1203
+ return void readRequests(cwd).then((requests) => sendJson(res, 200, {
1204
+ requests: requests.map(({ id, title, intent, status }) => ({ id, title, intent, status })),
1205
+ agent: { attached: lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS }
1206
+ }));
1207
+ }
1208
+ if (path === `${LEGLAS_PREFIX}/api/renames` && req.method === "POST") {
1209
+ let body = "";
1210
+ req.on("data", (chunk) => body += chunk);
1211
+ return void req.on("end", () => {
1212
+ let parsed2;
1213
+ try {
1214
+ parsed2 = JSON.parse(body || "{}");
1215
+ } catch {
1216
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1217
+ }
1218
+ if (parsed2.renames === null || typeof parsed2.renames !== "object") {
1219
+ return sendJson(res, 400, { ok: false, error: "Body needs a renames object." });
1220
+ }
1221
+ const renames = Object.fromEntries(Object.entries(parsed2.renames).filter((entry) => typeof entry[1] === "string" && entry[1] !== ""));
1222
+ void writeRenames(cwd, renames).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 200, { ok: false }));
1223
+ });
1224
+ }
1030
1225
  if (path === `${LEGLAS_PREFIX}/api/health`) {
1031
1226
  return void probe(target).then((reachable) => sendJson(res, 200, { devServer: target, reachable }));
1032
1227
  }
@@ -1084,7 +1279,7 @@ async function runClassify(options, deps) {
1084
1279
  const declared = await Promise.all(
1085
1280
  options.changes.map(async (change) => ({
1086
1281
  ...change,
1087
- exists: await stat(join6(options.cwd, change.path)).then(
1282
+ exists: await stat(join7(options.cwd, change.path)).then(
1088
1283
  () => true,
1089
1284
  () => false
1090
1285
  )
@@ -1310,139 +1505,47 @@ Rewriting your component automatically is how a tool breaks a codebase it does n
1310
1505
  };
1311
1506
  }
1312
1507
 
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) {
1508
+ // src/explore.ts
1509
+ function planExplore(surface, count, basedOn = null) {
1381
1510
  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.
1511
+ const goal = basedOn === null ? `Build ${count} design directions for "${surface}".
1512
+
1513
+ 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.
1393
1514
 
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.
1515
+ 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}".
1395
1516
 
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.
1517
+ 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.`;
1518
+ 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>"`;
1519
+ 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:
1397
1520
 
1398
- Then register them:
1521
+ ${register}
1399
1522
 
1400
- ` + commands.map((command) => ` ${command}`).join("\n");
1401
- return { previews, commands, instructions };
1523
+ The title and note are what the user judges from in the rail, so name each one for its idea rather than numbering it.`;
1524
+ return { surface, slug, count, basedOn, instructions: `${goal}
1525
+
1526
+ ${mechanics}` };
1402
1527
  }
1403
1528
 
1404
1529
  // src/run-explore.ts
1405
1530
  function runExplore(options, deps) {
1406
- const chosen = briefsFor(options.count);
1407
- if (chosen.length === 0) {
1531
+ if (!Number.isFinite(options.count) || options.count <= 0) {
1408
1532
  deps.log(
1409
1533
  options.json ? JSON.stringify({ ok: false, error: "Ask for at least one direction." }) : "Ask for at least one direction, for example --count 4."
1410
1534
  );
1411
1535
  return { exitCode: 1 };
1412
1536
  }
1413
- const plan = planBriefs(options.surface, options.count);
1537
+ const plan = planExplore(options.surface, Math.floor(options.count), options.basedOn);
1414
1538
  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
- );
1539
+ deps.log(JSON.stringify({ ok: true, ...plan }));
1425
1540
  return { exitCode: 0 };
1426
1541
  }
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
1542
  deps.log(plan.instructions);
1440
1543
  return { exitCode: 0 };
1441
1544
  }
1442
1545
 
1443
1546
  // src/run-init.ts
1444
- import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1445
- import { join as join7 } from "path";
1547
+ import { readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1548
+ import { join as join8 } from "path";
1446
1549
 
1447
1550
  // src/init.ts
1448
1551
  var AGENTS_MARKER_START = "<!-- leglas:start -->";
@@ -1474,27 +1577,39 @@ When asked for design variations, alternatives, or "a few options":
1474
1577
  and register it with \`leglas add --title "\u2026" --url "/" --branch <branch>\`
1475
1578
  (the config needs \`devCommand\` with \`{port}\`). Everything below is the
1476
1579
  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.
1580
+ 2. Run \`leglas explore <surface> --count <n>\` first, adding
1581
+ \`--based-on "<title>"\` when the user wants variations of a direction they
1582
+ already like. It prints what the set needs and how to register it. In
1583
+ short: new directions must genuinely disagree with each other, variants of
1584
+ one must not, and either way decide the whole set before building any of
1585
+ it. The designs themselves are yours.
1481
1586
  3. If the surface has no switcher yet, run
1482
1587
  \`leglas new <surface> --from <the component that renders it today>\`. It
1483
1588
  writes one under \`.leglas/variants/<surface>/\` and prints the single line
1484
1589
  to add where that surface renders. \`--from\` makes the baseline re-export
1485
1590
  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:
1591
+ 4. Before building, make sure the interface is up. If \`leglas\` is not
1592
+ already running, tell the user to run it, and hand them the URL now
1593
+ rather than when the set is done: the rail picks up each registration
1594
+ within seconds, so they get to watch the exploration fill in.
1595
+ 5. Build one direction at a time: its own file beside the others in
1596
+ \`.leglas/variants/<surface>/\`, listed in the \`DIRECTIONS\` map in that
1597
+ folder's \`switch\` file, then registered the moment it renders:
1490
1598
  \`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.
1599
+ Register each direction as it lands, never the whole set at the end. To
1600
+ the user watching the rail, a batch at the end is minutes of nothing and
1601
+ then everything at once.
1493
1602
 
1494
1603
  When the user asks to change one direction, check \`leglas requests --json\`
1495
1604
  first: they may have described it from the interface, and the request names the
1496
1605
  exact file. Clear the queue with \`leglas requests --clear\` once done.
1497
1606
 
1607
+ If the user wants requests handled the moment they are typed, without relaying
1608
+ each one, tell them about \`leglas watch --run "claude -p {prompt}"\` (any
1609
+ agent command works; {prompt} receives the request). It runs in their
1610
+ terminal, hands each request to that command as it arrives, and the interface
1611
+ shows the request's progress.
1612
+
1498
1613
  When the user picks a winner, run
1499
1614
  \`leglas keep "<title>" --to <path in real source>\`. It moves that direction
1500
1615
  out of the ignored directory, deletes the rest of the exploration, and drops
@@ -1510,8 +1625,14 @@ Useful to know:
1510
1625
  \`leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
1511
1626
  serves the file itself, so no dev server is needed. Sibling assets in the
1512
1627
  same directory resolve normally.
1513
- - Titles identify previews and must be unique.
1628
+ - Titles identify previews and must be unique. The user may rename one in the
1629
+ rail, which renames it on their machine only; the commands answer to either
1630
+ name, so use whichever they said.
1514
1631
  - \`leglas list\` shows every direction, shared and local.
1632
+ - \`leglas show "<title>" --json\` answers for one of them: the file behind it,
1633
+ the variants based on it, what it is being compared against, and anything
1634
+ they have asked for that is not done yet. Run it when handed a direction you
1635
+ did not register yourself.
1515
1636
  - Every command accepts \`--json\` and prints one envelope with a stable exit
1516
1637
  code, so you can drive it without parsing prose.
1517
1638
 
@@ -1555,7 +1676,7 @@ ${AGENTS_SECTION}`
1555
1676
  // src/run-init.ts
1556
1677
  async function readIfPresent(path) {
1557
1678
  try {
1558
- return await readFile4(path, "utf8");
1679
+ return await readFile5(path, "utf8");
1559
1680
  } catch {
1560
1681
  return null;
1561
1682
  }
@@ -1563,18 +1684,18 @@ async function readIfPresent(path) {
1563
1684
  async function runInit(options, deps) {
1564
1685
  const existingConfig = findConfigFile(options.cwd);
1565
1686
  const plan = planInit({
1566
- agents: await readIfPresent(join7(options.cwd, "AGENTS.md")),
1687
+ agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
1567
1688
  config: existingConfig === null ? null : "present",
1568
- gitignore: await readIfPresent(join7(options.cwd, ".gitignore")),
1689
+ gitignore: await readIfPresent(join8(options.cwd, ".gitignore")),
1569
1690
  force: options.force
1570
1691
  });
1571
1692
  const touched = [];
1572
1693
  for (const write of plan.writes) {
1573
- await writeFile3(join7(options.cwd, write.path), write.contents, "utf8");
1694
+ await writeFile4(join8(options.cwd, write.path), write.contents, "utf8");
1574
1695
  touched.push(write.path);
1575
1696
  }
1576
1697
  if (plan.gitignore !== null) {
1577
- await writeFile3(join7(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1698
+ await writeFile4(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1578
1699
  touched.push(".gitignore");
1579
1700
  }
1580
1701
  if (options.json) {
@@ -1594,8 +1715,8 @@ async function runInit(options, deps) {
1594
1715
 
1595
1716
  // src/run-keep.ts
1596
1717
  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";
1718
+ import { mkdir as mkdir4, readFile as readFile6, rm as rm2, writeFile as writeFile5 } from "fs/promises";
1719
+ import { dirname as dirname5, join as join9 } from "path";
1599
1720
 
1600
1721
  // src/keep.ts
1601
1722
  import { basename as basename2, extname as extname2, normalize as normalize2 } from "path";
@@ -1652,6 +1773,22 @@ function planKeep(options) {
1652
1773
  };
1653
1774
  }
1654
1775
 
1776
+ // src/resolve-title.ts
1777
+ function resolveOrExplain(input, titles, renames) {
1778
+ const resolution = resolveTitle(input, titles, renames);
1779
+ if (resolution.ok) return { ok: true, title: resolution.title };
1780
+ if (resolution.reason === "ambiguous") {
1781
+ return {
1782
+ ok: false,
1783
+ 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.`
1784
+ };
1785
+ }
1786
+ return {
1787
+ ok: false,
1788
+ 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.`
1789
+ };
1790
+ }
1791
+
1655
1792
  // src/run-keep.ts
1656
1793
  function renameExport(source, to) {
1657
1794
  const match = /export function ([A-Za-z0-9_]+)\s*\(/.exec(source);
@@ -1665,31 +1802,37 @@ async function runKeep(options, deps) {
1665
1802
  const loaded = await loadConfig(options.cwd);
1666
1803
  const local = await readLocalPreviews(options.cwd);
1667
1804
  const previews = [...loaded.config?.previews ?? [], ...local.previews];
1668
- const plan = planKeep({ title: options.title, previews, to: options.to });
1669
1805
  const fail = (error) => {
1670
1806
  if (options.json) deps.log(JSON.stringify({ ok: false, error }));
1671
1807
  else deps.error(error);
1672
1808
  return { exitCode: 1 };
1673
1809
  };
1810
+ const resolved = resolveOrExplain(
1811
+ options.title,
1812
+ previews.map((preview) => preview.title),
1813
+ await readRenames(options.cwd)
1814
+ );
1815
+ if (!resolved.ok) return fail(resolved.error);
1816
+ const plan = planKeep({ title: resolved.title, previews, to: options.to });
1674
1817
  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);
1818
+ const from = join9(options.cwd, plan.move.from);
1819
+ const to = join9(options.cwd, plan.move.to);
1677
1820
  if (!existsSync3(from)) {
1678
1821
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
1679
1822
  }
1680
1823
  if (existsSync3(to)) {
1681
1824
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
1682
1825
  }
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 });
1826
+ const source = await readFile6(from, "utf8");
1827
+ await mkdir4(dirname5(to), { recursive: true });
1828
+ await writeFile5(to, renameExport(source, plan.exportName), "utf8");
1829
+ await rm2(join9(options.cwd, plan.removeDir), { recursive: true, force: true });
1687
1830
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
1688
1831
  if (options.json) {
1689
1832
  deps.log(
1690
1833
  JSON.stringify({
1691
1834
  ok: true,
1692
- kept: options.title,
1835
+ kept: resolved.title,
1693
1836
  to: plan.move.to,
1694
1837
  exportName: plan.exportName,
1695
1838
  removed: plan.removeDir,
@@ -1718,11 +1861,11 @@ async function runKeep(options, deps) {
1718
1861
 
1719
1862
  // src/run-new.ts
1720
1863
  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";
1864
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
1865
+ import { dirname as dirname6, join as join10 } from "path";
1723
1866
  async function readIfPresent2(path) {
1724
1867
  try {
1725
- return await readFile6(path, "utf8");
1868
+ return await readFile7(path, "utf8");
1726
1869
  } catch {
1727
1870
  return null;
1728
1871
  }
@@ -1730,7 +1873,7 @@ async function readIfPresent2(path) {
1730
1873
  async function runNew(options, deps) {
1731
1874
  let from;
1732
1875
  if (options.from !== void 0) {
1733
- const contents = await readIfPresent2(join9(options.cwd, options.from));
1876
+ const contents = await readIfPresent2(join10(options.cwd, options.from));
1734
1877
  if (contents === null) {
1735
1878
  const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
1736
1879
  if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
@@ -1741,8 +1884,8 @@ async function runNew(options, deps) {
1741
1884
  }
1742
1885
  const plan = planNew({
1743
1886
  surface: options.surface,
1744
- packageJson: await readIfPresent2(join9(options.cwd, "package.json")),
1745
- gitignore: await readIfPresent2(join9(options.cwd, ".gitignore")),
1887
+ packageJson: await readIfPresent2(join10(options.cwd, "package.json")),
1888
+ gitignore: await readIfPresent2(join10(options.cwd, ".gitignore")),
1746
1889
  from
1747
1890
  });
1748
1891
  const fail = (error) => {
@@ -1765,19 +1908,19 @@ async function runNew(options, deps) {
1765
1908
  deps.log(plan.instructions);
1766
1909
  return { exitCode: 0, written: [] };
1767
1910
  }
1768
- const existing = plan.writes.filter((write) => existsSync4(join9(options.cwd, write.path)));
1911
+ const existing = plan.writes.filter((write) => existsSync4(join10(options.cwd, write.path)));
1769
1912
  if (existing.length > 0) {
1770
1913
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
1771
1914
  }
1772
1915
  const written = [];
1773
1916
  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");
1917
+ const target = join10(options.cwd, write.path);
1918
+ await mkdir5(dirname6(target), { recursive: true });
1919
+ await writeFile6(target, write.contents, "utf8");
1777
1920
  written.push(write.path);
1778
1921
  }
1779
1922
  if (plan.gitignore !== null) {
1780
- await writeFile5(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1923
+ await writeFile6(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1781
1924
  written.push(".gitignore");
1782
1925
  }
1783
1926
  if (options.json) {
@@ -1798,25 +1941,35 @@ async function runNew(options, deps) {
1798
1941
  }
1799
1942
 
1800
1943
  // src/run-previews.ts
1801
- import { readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
1802
- import { join as join10 } from "path";
1944
+ import { readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
1945
+ import { join as join11 } from "path";
1803
1946
  function envelope(deps, ok, body) {
1804
1947
  deps.log(JSON.stringify({ ok, ...body }));
1805
1948
  }
1806
1949
  async function ensureIgnored(cwd) {
1807
- const path = join10(cwd, ".gitignore");
1950
+ const path = join11(cwd, ".gitignore");
1808
1951
  let current = null;
1809
1952
  try {
1810
- current = await readFile7(path, "utf8");
1953
+ current = await readFile8(path, "utf8");
1811
1954
  } catch {
1812
1955
  current = null;
1813
1956
  }
1814
1957
  const next = ignoreEntry(current);
1815
- if (next !== null) await writeFile6(path, next, "utf8");
1958
+ if (next !== null) await writeFile7(path, next, "utf8");
1816
1959
  }
1817
1960
  async function runAdd(options, deps) {
1818
1961
  const loaded = await loadConfig(options.cwd);
1819
1962
  const shared = loaded.config?.previews ?? [];
1963
+ if (options.preview.basedOn !== void 0) {
1964
+ const local = await readLocalPreviews(options.cwd);
1965
+ const titles = new Set([...shared, ...local.previews].map((preview) => preview.title));
1966
+ if (!titles.has(options.preview.basedOn)) {
1967
+ const error = `--based-on names ${JSON.stringify(options.preview.basedOn)}, which is not a registered direction. leglas list shows what exists.`;
1968
+ if (options.json) envelope(deps, false, { error });
1969
+ else deps.error(error);
1970
+ return { exitCode: 1 };
1971
+ }
1972
+ }
1820
1973
  const outcome = await addLocalPreview(
1821
1974
  options.cwd,
1822
1975
  {
@@ -1825,7 +1978,8 @@ async function runAdd(options, deps) {
1825
1978
  note: options.preview.note,
1826
1979
  tags: options.preview.tags,
1827
1980
  branch: options.preview.branch,
1828
- file: options.preview.file
1981
+ file: options.preview.file,
1982
+ basedOn: options.preview.basedOn
1829
1983
  },
1830
1984
  shared
1831
1985
  );
@@ -1855,7 +2009,11 @@ async function runAdd(options, deps) {
1855
2009
  deps.log(" Add devCommand (with {port}) to the config.");
1856
2010
  deps.log("");
1857
2011
  }
1858
- deps.log("Local to this machine. Restart Leglas to see it, or run leglas list.");
2012
+ if (options.preview.branch === void 0 && options.preview.file === void 0) {
2013
+ deps.log("Local to this machine. A running interface picks it up within seconds.");
2014
+ } else {
2015
+ deps.log("Local to this machine. Restart Leglas to see it, or run leglas list.");
2016
+ }
1859
2017
  }
1860
2018
  return { exitCode: 0 };
1861
2019
  }
@@ -1869,9 +2027,17 @@ async function runList(options, deps) {
1869
2027
  ];
1870
2028
  if (options.json) {
1871
2029
  envelope(deps, errors.length === 0, {
2030
+ // The whole record, not a summary of it. What the config holds about a
2031
+ // preview — its note, its tags, the direction it is a variant of — is
2032
+ // exactly what tells an agent why these are being compared, and leaving
2033
+ // it out made the listing thinner than the reference block that points
2034
+ // at it.
1872
2035
  previews: previews.map((preview) => ({
1873
2036
  title: preview.title,
1874
2037
  url: preview.url,
2038
+ note: preview.note ?? null,
2039
+ tags: preview.tags,
2040
+ basedOn: preview.basedOn ?? null,
1875
2041
  local: preview.local,
1876
2042
  branch: preview.branch ?? null,
1877
2043
  file: preview.file ?? null
@@ -1901,7 +2067,7 @@ async function runRequests(options, deps) {
1901
2067
  else deps.log("Queue cleared.");
1902
2068
  return { exitCode: 0 };
1903
2069
  }
1904
- const requests = await readRequests(options.cwd);
2070
+ const requests = await collectRequests(options.cwd);
1905
2071
  if (options.json) {
1906
2072
  envelope(deps, true, { requests });
1907
2073
  return { exitCode: 0 };
@@ -1919,17 +2085,327 @@ async function runRequests(options, deps) {
1919
2085
  return { exitCode: 0 };
1920
2086
  }
1921
2087
 
2088
+ // src/show.ts
2089
+ function describe(preview) {
2090
+ return {
2091
+ title: preview.title,
2092
+ url: preview.url,
2093
+ note: preview.note ?? null,
2094
+ tags: preview.tags,
2095
+ basedOn: preview.basedOn ?? null,
2096
+ branch: preview.branch ?? null,
2097
+ file: preview.file ?? null,
2098
+ local: preview.local === true,
2099
+ // A file preview names its own source. Everything else is decoded from the
2100
+ // URL, and a URL outside the convention yields nothing rather than a path
2101
+ // that looks authoritative and is not there.
2102
+ target: preview.file ?? targetFor(preview.url)
2103
+ };
2104
+ }
2105
+ function planShow({ title, previews, requests }) {
2106
+ const found = previews.find((preview) => preview.title === title);
2107
+ if (!found) {
2108
+ return {
2109
+ ok: false,
2110
+ error: `No direction called ${JSON.stringify(title)}. Run leglas list to see them.`
2111
+ };
2112
+ }
2113
+ const variants = previews.filter((preview) => preview.basedOn === title).map(describe);
2114
+ const variantTitles = new Set(variants.map((variant) => variant.title));
2115
+ return {
2116
+ ok: true,
2117
+ direction: describe(found),
2118
+ variants,
2119
+ // Its own variants are already listed in full, so they are not repeated
2120
+ // here; this is the rest of the comparison.
2121
+ comparedWith: previews.map((preview) => preview.title).filter((other) => other !== title && !variantTitles.has(other)),
2122
+ requests: requests.filter((request) => request.title === title).map((request) => ({
2123
+ id: request.id,
2124
+ intent: request.intent,
2125
+ target: request.target,
2126
+ prompt: request.prompt,
2127
+ status: request.status
2128
+ }))
2129
+ };
2130
+ }
2131
+
2132
+ // src/run-show.ts
2133
+ async function runShow(options, deps) {
2134
+ const loaded = await loadConfig(options.cwd);
2135
+ const local = await readLocalPreviews(options.cwd);
2136
+ const requests = await readRequests(options.cwd);
2137
+ const previews = [
2138
+ ...(loaded.config?.previews ?? []).map((preview) => ({ ...preview, local: false })),
2139
+ ...local.previews
2140
+ ];
2141
+ const resolved = resolveOrExplain(
2142
+ options.title,
2143
+ previews.map((preview) => preview.title),
2144
+ await readRenames(options.cwd)
2145
+ );
2146
+ if (!resolved.ok) {
2147
+ if (options.json) deps.log(JSON.stringify({ ok: false, error: resolved.error }));
2148
+ else deps.error(resolved.error);
2149
+ return { exitCode: 1 };
2150
+ }
2151
+ const plan = planShow({ title: resolved.title, previews, requests });
2152
+ if (!plan.ok) {
2153
+ if (options.json) deps.log(JSON.stringify({ ok: false, error: plan.error }));
2154
+ else deps.error(plan.error);
2155
+ return { exitCode: 1 };
2156
+ }
2157
+ if (options.json) {
2158
+ deps.log(
2159
+ JSON.stringify({
2160
+ ok: true,
2161
+ direction: plan.direction,
2162
+ variants: plan.variants,
2163
+ comparedWith: plan.comparedWith,
2164
+ requests: plan.requests
2165
+ })
2166
+ );
2167
+ return { exitCode: 0 };
2168
+ }
2169
+ const { direction } = plan;
2170
+ deps.log(` ${direction.title}${direction.local ? " (local)" : ""}`);
2171
+ if (direction.note !== null) deps.log(` ${direction.note}`);
2172
+ deps.log("");
2173
+ if (direction.target !== null) deps.log(` file ${direction.target}`);
2174
+ if (direction.branch !== null) deps.log(` branch ${direction.branch}`);
2175
+ deps.log(` url ${direction.url}`);
2176
+ if (direction.tags.length > 0) deps.log(` tags ${direction.tags.join(", ")}`);
2177
+ if (direction.basedOn !== null) deps.log(` variant of ${direction.basedOn}`);
2178
+ if (plan.variants.length > 0) {
2179
+ deps.log(` variants ${plan.variants.map((variant) => variant.title).join(", ")}`);
2180
+ }
2181
+ if (plan.comparedWith.length > 0) {
2182
+ deps.log(` against ${plan.comparedWith.join(", ")}`);
2183
+ }
2184
+ if (plan.requests.length > 0) {
2185
+ deps.log("");
2186
+ deps.log(` Pending, not yet done (${plan.requests.length}):`);
2187
+ for (const request of plan.requests) deps.log(` ${request.status} ${request.intent}`);
2188
+ deps.log("");
2189
+ deps.log(" Run leglas requests --json for the full prompts.");
2190
+ }
2191
+ return { exitCode: 0 };
2192
+ }
2193
+
2194
+ // src/run-watch.ts
2195
+ import { spawn as spawn2 } from "child_process";
2196
+ import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2197
+ import { dirname as dirname7, join as join12 } from "path";
2198
+
2199
+ // src/watch.ts
2200
+ var WATCH_PATH = ".leglas/watch.json";
2201
+ var PROMPT_TOKEN = "{prompt}";
2202
+ var EXAMPLE = `leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
2203
+ function tokenize(template) {
2204
+ const tokens = [];
2205
+ let current = "";
2206
+ let started = false;
2207
+ let quote = null;
2208
+ for (const character of template) {
2209
+ if (quote !== null) {
2210
+ if (character === quote) quote = null;
2211
+ else current += character;
2212
+ continue;
2213
+ }
2214
+ if (character === '"' || character === "'") {
2215
+ quote = character;
2216
+ started = true;
2217
+ continue;
2218
+ }
2219
+ if (/\s/.test(character)) {
2220
+ if (started) tokens.push(current);
2221
+ current = "";
2222
+ started = false;
2223
+ continue;
2224
+ }
2225
+ current += character;
2226
+ started = true;
2227
+ }
2228
+ if (quote !== null) {
2229
+ return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
2230
+ }
2231
+ if (started) tokens.push(current);
2232
+ return { ok: true, tokens };
2233
+ }
2234
+ function parseTemplate(raw) {
2235
+ const tokenized = tokenize(raw);
2236
+ if (!tokenized.ok) return tokenized;
2237
+ const { tokens } = tokenized;
2238
+ const [command, ...args] = tokens;
2239
+ if (command === void 0) {
2240
+ return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
2241
+ }
2242
+ const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
2243
+ if (placeholders === 0) {
2244
+ return {
2245
+ ok: false,
2246
+ error: `The agent command needs ${PROMPT_TOKEN} as a word of its own, for example: ${EXAMPLE}`
2247
+ };
2248
+ }
2249
+ if (placeholders > 1) {
2250
+ return {
2251
+ ok: false,
2252
+ error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
2253
+ };
2254
+ }
2255
+ if (command === PROMPT_TOKEN) {
2256
+ return {
2257
+ ok: false,
2258
+ error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
2259
+ };
2260
+ }
2261
+ return { ok: true, template: { command, args } };
2262
+ }
2263
+ function commandFor(template, prompt) {
2264
+ return {
2265
+ command: template.command,
2266
+ args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
2267
+ };
2268
+ }
2269
+ function nextRequest(requests, failed) {
2270
+ return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
2271
+ }
2272
+
2273
+ // src/run-watch.ts
2274
+ var POLL_MS = 2e3;
2275
+ var HEARTBEAT_TIMEOUT_MS = 1e3;
2276
+ async function readSavedTemplate(cwd) {
2277
+ try {
2278
+ const raw = await readFile9(join12(cwd, WATCH_PATH), "utf8");
2279
+ const parsed2 = JSON.parse(raw);
2280
+ return typeof parsed2.run === "string" && parsed2.run !== "" ? parsed2.run : null;
2281
+ } catch {
2282
+ return null;
2283
+ }
2284
+ }
2285
+ async function saveTemplate(cwd, run3) {
2286
+ const path = join12(cwd, WATCH_PATH);
2287
+ await mkdir6(dirname7(path), { recursive: true });
2288
+ await writeFile8(path, `${JSON.stringify({ run: run3 }, null, 2)}
2289
+ `, "utf8");
2290
+ }
2291
+ function spawnAgent(command, args, cwd) {
2292
+ return new Promise((resolve) => {
2293
+ let settled = false;
2294
+ const settle = (outcome) => {
2295
+ if (settled) return;
2296
+ settled = true;
2297
+ resolve(outcome);
2298
+ };
2299
+ const child = spawn2(command, args, { cwd, stdio: "inherit" });
2300
+ child.on("error", (error) => settle({ ok: false, error: error.message }));
2301
+ child.on(
2302
+ "close",
2303
+ (code, signal) => settle(
2304
+ signal === null ? { ok: true, code: code ?? 0 } : { ok: false, error: `stopped by ${signal}` }
2305
+ )
2306
+ );
2307
+ });
2308
+ }
2309
+ async function runWatch(options, deps) {
2310
+ const saved = options.run === void 0 ? await readSavedTemplate(options.cwd) : null;
2311
+ const raw = options.run ?? saved;
2312
+ if (raw === null) {
2313
+ deps.error(
2314
+ 'Watch needs an agent command the first time: leglas watch --run "claude -p {prompt}"'
2315
+ );
2316
+ return { exitCode: 1 };
2317
+ }
2318
+ const parsed2 = parseTemplate(raw);
2319
+ if (!parsed2.ok) {
2320
+ deps.error(parsed2.error);
2321
+ return { exitCode: 1 };
2322
+ }
2323
+ const template = parsed2.template;
2324
+ if (options.run !== void 0) await saveTemplate(options.cwd, raw).catch(() => {
2325
+ });
2326
+ const base = `http://localhost:${options.port ?? DEFAULT_PORT}`;
2327
+ const heartbeat = async (watching) => {
2328
+ try {
2329
+ await fetch(`${base}${LEGLAS_PREFIX}/api/watch`, {
2330
+ method: "POST",
2331
+ headers: { "content-type": "application/json" },
2332
+ body: JSON.stringify({ watching }),
2333
+ signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS)
2334
+ });
2335
+ } catch {
2336
+ }
2337
+ };
2338
+ deps.log(`Watching for change requests. Each one runs: ${raw}`);
2339
+ deps.log("Stop with Ctrl-C.");
2340
+ const failed = /* @__PURE__ */ new Set();
2341
+ let stopped = false;
2342
+ let busy = false;
2343
+ let inflight = null;
2344
+ const handle = async (request) => {
2345
+ deps.log("");
2346
+ deps.log(` ${request.title}: ${request.intent}`);
2347
+ if (request.target !== null) deps.log(` ${request.target}`);
2348
+ await markPickedUp(options.cwd, request.id);
2349
+ const { command, args } = commandFor(template, request.prompt);
2350
+ const outcome = await spawnAgent(command, args, options.cwd);
2351
+ if (outcome.ok && outcome.code === 0) {
2352
+ await removeRequest(options.cwd, request.id);
2353
+ deps.log(` done ${request.title}`);
2354
+ return;
2355
+ }
2356
+ failed.add(request.id);
2357
+ deps.error(
2358
+ ` failed ${request.title}: ${outcome.ok ? `${command} exited ${outcome.code}` : outcome.error}`
2359
+ );
2360
+ deps.error(" Left in the queue and not retried.");
2361
+ };
2362
+ const tick = async () => {
2363
+ if (stopped) return;
2364
+ void heartbeat(true);
2365
+ if (busy) return;
2366
+ busy = true;
2367
+ try {
2368
+ const request = nextRequest(await readRequests(options.cwd), failed);
2369
+ if (request !== null && !stopped) {
2370
+ inflight = handle(request);
2371
+ await inflight;
2372
+ }
2373
+ } catch (error) {
2374
+ deps.error(` ! ${error instanceof Error ? error.message : String(error)}`);
2375
+ } finally {
2376
+ inflight = null;
2377
+ busy = false;
2378
+ }
2379
+ };
2380
+ return new Promise((resolve) => {
2381
+ const timer = setInterval(() => void tick(), POLL_MS);
2382
+ const stop = () => {
2383
+ if (stopped) return;
2384
+ stopped = true;
2385
+ clearInterval(timer);
2386
+ process.off("SIGINT", stop);
2387
+ process.off("SIGTERM", stop);
2388
+ void Promise.resolve(inflight).catch(() => {
2389
+ }).then(() => heartbeat(false)).then(() => resolve({ exitCode: 0 }));
2390
+ };
2391
+ process.on("SIGINT", stop);
2392
+ process.on("SIGTERM", stop);
2393
+ options.signal?.addEventListener("abort", stop, { once: true });
2394
+ void tick();
2395
+ });
2396
+ }
2397
+
1922
2398
  // src/run.ts
1923
2399
  import { existsSync as existsSync5 } from "fs";
1924
2400
  import { createRequire } from "module";
1925
- import { basename as basename3, dirname as dirname6, join as join11, relative as relative2 } from "path";
2401
+ import { basename as basename3, dirname as dirname8, join as join13, relative as relative2 } from "path";
1926
2402
  import { fileURLToPath } from "url";
1927
2403
  function findShellDir() {
1928
- const bundled = join11(dirname6(fileURLToPath(import.meta.url)), "shell");
1929
- if (existsSync5(join11(bundled, "index.html"))) return bundled;
2404
+ const bundled = join13(dirname8(fileURLToPath(import.meta.url)), "shell");
2405
+ if (existsSync5(join13(bundled, "index.html"))) return bundled;
1930
2406
  try {
1931
2407
  const require2 = createRequire(import.meta.url);
1932
- return dirname6(require2.resolve("@leglas/shell/dist/index.html"));
2408
+ return dirname8(require2.resolve("@leglas/shell/dist/index.html"));
1933
2409
  } catch {
1934
2410
  return null;
1935
2411
  }
@@ -1963,7 +2439,7 @@ async function run2(options, deps) {
1963
2439
  const fileMounts = /* @__PURE__ */ new Map();
1964
2440
  for (const preview of merged?.previews ?? []) {
1965
2441
  if (preview.file !== void 0) {
1966
- const absolute = join11(options.cwd, preview.file);
2442
+ const absolute = join13(options.cwd, preview.file);
1967
2443
  if (!existsSync5(absolute)) {
1968
2444
  worktreeErrors.push(
1969
2445
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -1974,7 +2450,7 @@ async function run2(options, deps) {
1974
2450
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
1975
2451
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
1976
2452
  }
1977
- fileMounts.set(slug, dirname6(absolute));
2453
+ fileMounts.set(slug, dirname8(absolute));
1978
2454
  previews.push({
1979
2455
  ...preview,
1980
2456
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -2079,11 +2555,13 @@ Usage
2079
2555
  leglas init Prepare a project and teach its agents
2080
2556
  leglas [options] Start the server and open the interface
2081
2557
  leglas new <surface> Scaffold a branch point for a surface
2082
- leglas explore <surface> Print distinct angles for an agent to build
2558
+ leglas explore <surface> Brief an agent's exploration of a surface
2083
2559
  leglas classify Decide where a direction should live
2084
2560
  leglas add --title T --url U Register a preview on this machine
2085
2561
  leglas list Show every preview, shared and local
2562
+ leglas show <title> Everything Leglas knows about one direction
2086
2563
  leglas requests Show change requests made from the interface
2564
+ leglas watch --run "<cmd>" Hand each request to your agent as it arrives
2087
2565
  leglas keep <title> --to <path> Keep a winner and end the exploration
2088
2566
 
2089
2567
  Options
@@ -2100,7 +2578,13 @@ Options for new
2100
2578
  --from <path> Use an existing component as the baseline
2101
2579
 
2102
2580
  Options for explore
2103
- --count <n> How many angles (default 3)
2581
+ --count <n> How many directions (default 3)
2582
+ --based-on <title> Variants of an existing direction instead of new ones
2583
+
2584
+ Options for watch
2585
+ --run <command> Your agent, with {prompt} where the request goes, for
2586
+ example "claude -p {prompt}". Remembered after first use
2587
+ --port <port> Port Leglas itself is on (default: 4100)
2104
2588
 
2105
2589
  Options for classify
2106
2590
  --change <path> A file the direction creates or wires up (repeatable)
@@ -2111,6 +2595,7 @@ Options for add
2111
2595
  --tag <text> Repeatable
2112
2596
  --branch <name> Back the preview with a checkout of this git branch
2113
2597
  --file <path> Preview a plain HTML file served by Leglas itself
2598
+ --based-on <title> The direction this is a variant of; groups the family
2114
2599
  `;
2115
2600
  function version() {
2116
2601
  const require2 = createRequire2(import.meta.url);
@@ -2120,7 +2605,7 @@ function version() {
2120
2605
  async function openBrowser(url) {
2121
2606
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2122
2607
  try {
2123
- spawn2(command, [url], { detached: true, stdio: "ignore" }).unref();
2608
+ spawn3(command, [url], { detached: true, stdio: "ignore" }).unref();
2124
2609
  } catch {
2125
2610
  }
2126
2611
  }
@@ -2185,7 +2670,7 @@ if (parsed.kind === "keep") {
2185
2670
  }
2186
2671
  if (parsed.kind === "explore") {
2187
2672
  const outcome = runExplore(
2188
- { surface: parsed.surface, count: parsed.count, json: parsed.json },
2673
+ { surface: parsed.surface, count: parsed.count, basedOn: parsed.basedOn, json: parsed.json },
2189
2674
  { log: (line) => process.stdout.write(`${line}
2190
2675
  `) }
2191
2676
  );
@@ -2198,10 +2683,24 @@ if (parsed.kind === "requests") {
2198
2683
  );
2199
2684
  process.exit(outcome.exitCode);
2200
2685
  }
2686
+ if (parsed.kind === "watch") {
2687
+ const outcome = await runWatch(
2688
+ { run: parsed.run, port: parsed.port, cwd: process.cwd() },
2689
+ previewDeps
2690
+ );
2691
+ process.exit(outcome.exitCode);
2692
+ }
2201
2693
  if (parsed.kind === "list") {
2202
2694
  const outcome = await runList({ json: parsed.json, cwd: process.cwd() }, previewDeps);
2203
2695
  process.exit(outcome.exitCode);
2204
2696
  }
2697
+ if (parsed.kind === "show") {
2698
+ const outcome = await runShow(
2699
+ { title: parsed.title, json: parsed.json, cwd: process.cwd() },
2700
+ previewDeps
2701
+ );
2702
+ process.exit(outcome.exitCode);
2703
+ }
2205
2704
  if (parsed.kind === "new") {
2206
2705
  const outcome = await runNew(
2207
2706
  { surface: parsed.surface, print: parsed.print, json: parsed.json, from: parsed.from, cwd: process.cwd() },