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/LICENSE +21 -0
- package/README.md +322 -294
- package/dist/args.d.ts +11 -0
- package/dist/bin.js +772 -219
- package/dist/explore.d.ts +25 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +754 -217
- package/dist/resolve-title.d.ts +19 -0
- package/dist/run-explore.d.ts +6 -5
- package/dist/run-show.d.ts +18 -0
- package/dist/run-watch.d.ts +23 -0
- package/dist/shell/assets/index-Bl87Vq9G.js +10 -0
- package/dist/shell/assets/index-dMGYwlE1.css +1 -0
- package/dist/shell/favicon.svg +48 -0
- package/dist/shell/index.html +3 -2
- package/dist/show.d.ts +59 -0
- package/dist/watch.d.ts +45 -0
- package/package.json +9 -3
- package/dist/briefs.d.ts +0 -32
- package/dist/shell/assets/index-D-nej-Uo.js +0 -9
- package/dist/shell/assets/index-DcVuoqfz.css +0 -1
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
|
|
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
|
|
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
|
-
|
|
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, [
|
|
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
|
|
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
|
|
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
|
|
936
|
-
const candidate =
|
|
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
|
|
950
|
-
const isRoot =
|
|
951
|
-
return serveFrom(res, shellDir, isRoot ? "index.html" :
|
|
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
|
|
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
|
-
|
|
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:
|
|
1004
|
-
errors
|
|
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
|
|
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
|
|
1269
|
+
let relative4 = slash === -1 ? "" : rest.slice(slash + 1);
|
|
1038
1270
|
try {
|
|
1039
|
-
|
|
1271
|
+
relative4 = decodeURIComponent(relative4);
|
|
1040
1272
|
} catch {
|
|
1041
|
-
|
|
1273
|
+
relative4 = "";
|
|
1042
1274
|
}
|
|
1043
1275
|
const dir = fileMounts.get(slug);
|
|
1044
|
-
if (dir !== void 0 &&
|
|
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(
|
|
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/
|
|
1314
|
-
|
|
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
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1565
|
+
${register}
|
|
1397
1566
|
|
|
1398
|
-
|
|
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
|
-
|
|
1401
|
-
return { previews, commands, instructions };
|
|
1570
|
+
${mechanics}` };
|
|
1402
1571
|
}
|
|
1403
1572
|
|
|
1404
1573
|
// src/run-explore.ts
|
|
1405
1574
|
function runExplore(options, deps) {
|
|
1406
|
-
|
|
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 =
|
|
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
|
|
1445
|
-
import { join as
|
|
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
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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.
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
1733
|
+
agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
|
|
1567
1734
|
config: existingConfig === null ? null : "present",
|
|
1568
|
-
gitignore: await readIfPresent(
|
|
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
|
|
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
|
|
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
|
|
1598
|
-
import { dirname as
|
|
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 =
|
|
1676
|
-
const 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
|
|
1684
|
-
await
|
|
1685
|
-
await
|
|
1686
|
-
await rm2(
|
|
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:
|
|
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
|
|
1722
|
-
import { dirname as
|
|
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
|
|
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(
|
|
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(
|
|
1745
|
-
gitignore: await readIfPresent2(
|
|
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(
|
|
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 =
|
|
1775
|
-
await
|
|
1776
|
-
await
|
|
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
|
|
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
|
|
1802
|
-
import { join as
|
|
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 =
|
|
1996
|
+
const path = join11(cwd, ".gitignore");
|
|
1808
1997
|
let current = null;
|
|
1809
1998
|
try {
|
|
1810
|
-
current = await
|
|
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
|
|
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
|
-
|
|
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
|
|
1901
|
-
else
|
|
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
|
|
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
|
|
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 =
|
|
1929
|
-
if (existsSync5(
|
|
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
|
|
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 =
|
|
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,
|
|
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" :
|
|
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>
|
|
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
|
|
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
|
-
|
|
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() },
|