leglas 0.1.0 → 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/LICENSE +21 -0
- package/README.md +275 -303
- package/dist/args.d.ts +11 -0
- package/dist/bin.js +683 -184
- package/dist/explore.d.ts +25 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +665 -182
- 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-dMGYwlE1.css +1 -0
- package/dist/shell/assets/index-r7sGM7WL.js +10 -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
|
|
@@ -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
|
|
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
|
-
|
|
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, [
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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/
|
|
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) {
|
|
1508
|
+
// src/explore.ts
|
|
1509
|
+
function planExplore(surface, count, basedOn = null) {
|
|
1381
1510
|
const slug = surfaceSlug(surface);
|
|
1382
|
-
const
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1521
|
+
${register}
|
|
1399
1522
|
|
|
1400
|
-
|
|
1401
|
-
return {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1445
|
-
import { join as
|
|
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
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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.
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
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
|
-
|
|
1492
|
-
|
|
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
|
|
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(
|
|
1687
|
+
agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
|
|
1567
1688
|
config: existingConfig === null ? null : "present",
|
|
1568
|
-
gitignore: await readIfPresent(
|
|
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
|
|
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
|
|
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
|
|
1598
|
-
import { dirname as
|
|
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 =
|
|
1676
|
-
const 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
|
|
1684
|
-
await
|
|
1685
|
-
await
|
|
1686
|
-
await rm2(
|
|
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:
|
|
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
|
|
1722
|
-
import { dirname as
|
|
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
|
|
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(
|
|
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(
|
|
1745
|
-
gitignore: await readIfPresent2(
|
|
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(
|
|
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 =
|
|
1775
|
-
await
|
|
1776
|
-
await
|
|
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
|
|
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
|
|
1802
|
-
import { join as
|
|
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 =
|
|
1950
|
+
const path = join11(cwd, ".gitignore");
|
|
1808
1951
|
let current = null;
|
|
1809
1952
|
try {
|
|
1810
|
-
current = await
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
1929
|
-
if (existsSync5(
|
|
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
|
|
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 =
|
|
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,
|
|
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>
|
|
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
|
|
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
|
-
|
|
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() },
|