leglas 0.2.0 → 0.4.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/README.md +125 -24
- package/dist/bin.js +1084 -242
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1084 -238
- package/dist/shell/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/shell/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/shell/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/shell/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/shell/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/shell/assets/index-BQHDjn0q.css +1 -0
- package/dist/shell/assets/index-T-uR7kT7.js +14 -0
- package/dist/shell/index.html +2 -2
- package/dist/watch.d.ts +2 -45
- package/package.json +3 -3
- package/dist/shell/assets/index-dMGYwlE1.css +0 -1
- package/dist/shell/assets/index-r7sGM7WL.js +0 -10
- package/dist/shell/assets/manrope-cyrillic-wght-normal-Dvxsihut.woff2 +0 -0
- package/dist/shell/assets/manrope-greek-wght-normal-DL7QRZyv.woff2 +0 -0
- package/dist/shell/assets/manrope-latin-ext-wght-normal-Ch3YOpNY.woff2 +0 -0
- package/dist/shell/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
- package/dist/shell/assets/manrope-vietnamese-wght-normal-usUDDRr7.woff2 +0 -0
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 spawn4 } 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 };
|
|
@@ -134,7 +134,7 @@ function parseClassify(rest) {
|
|
|
134
134
|
if (changes.length === 0) {
|
|
135
135
|
return {
|
|
136
136
|
kind: "error",
|
|
137
|
-
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"
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
return { kind: "classify", changes, json };
|
|
@@ -209,7 +209,7 @@ function parseArgs(argv) {
|
|
|
209
209
|
if (title === void 0) {
|
|
210
210
|
return {
|
|
211
211
|
kind: "error",
|
|
212
|
-
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'
|
|
213
213
|
};
|
|
214
214
|
}
|
|
215
215
|
if (to === void 0) {
|
|
@@ -259,7 +259,7 @@ function parseArgs(argv) {
|
|
|
259
259
|
if (surface === void 0) {
|
|
260
260
|
return {
|
|
261
261
|
kind: "error",
|
|
262
|
-
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"
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
265
|
return { kind: "explore", surface, count, basedOn, json };
|
|
@@ -300,7 +300,7 @@ function parseArgs(argv) {
|
|
|
300
300
|
if (title === void 0) {
|
|
301
301
|
return {
|
|
302
302
|
kind: "error",
|
|
303
|
-
message: 'leglas show needs a direction title, for example: leglas show "Aurora" --json'
|
|
303
|
+
message: 'leglas show needs a direction title, for example: npx leglas show "Aurora" --json'
|
|
304
304
|
};
|
|
305
305
|
}
|
|
306
306
|
return { kind: "show", title, json };
|
|
@@ -353,7 +353,7 @@ function parseArgs(argv) {
|
|
|
353
353
|
|
|
354
354
|
// src/run-classify.ts
|
|
355
355
|
import { stat } from "fs/promises";
|
|
356
|
-
import { join as
|
|
356
|
+
import { join as join8 } from "path";
|
|
357
357
|
|
|
358
358
|
// ../server/dist/config.js
|
|
359
359
|
var DEFAULT_DEV_SERVER = "http://localhost:3000";
|
|
@@ -485,6 +485,360 @@ function normalizeConfig(raw, options = {}) {
|
|
|
485
485
|
};
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
+
// ../server/dist/agent-command.js
|
|
489
|
+
var WATCH_PATH = ".leglas/watch.json";
|
|
490
|
+
var PROMPT_TOKEN = "{prompt}";
|
|
491
|
+
var EXAMPLE = `npx leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
|
|
492
|
+
function tokenize(template) {
|
|
493
|
+
const tokens = [];
|
|
494
|
+
let current = "";
|
|
495
|
+
let started = false;
|
|
496
|
+
let quote = null;
|
|
497
|
+
for (const character of template) {
|
|
498
|
+
if (quote !== null) {
|
|
499
|
+
if (character === quote)
|
|
500
|
+
quote = null;
|
|
501
|
+
else
|
|
502
|
+
current += character;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (character === '"' || character === "'") {
|
|
506
|
+
quote = character;
|
|
507
|
+
started = true;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (/\s/.test(character)) {
|
|
511
|
+
if (started)
|
|
512
|
+
tokens.push(current);
|
|
513
|
+
current = "";
|
|
514
|
+
started = false;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
current += character;
|
|
518
|
+
started = true;
|
|
519
|
+
}
|
|
520
|
+
if (quote !== null) {
|
|
521
|
+
return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
|
|
522
|
+
}
|
|
523
|
+
if (started)
|
|
524
|
+
tokens.push(current);
|
|
525
|
+
return { ok: true, tokens };
|
|
526
|
+
}
|
|
527
|
+
function parseTemplate(raw) {
|
|
528
|
+
const tokenized = tokenize(raw);
|
|
529
|
+
if (!tokenized.ok)
|
|
530
|
+
return tokenized;
|
|
531
|
+
const { tokens } = tokenized;
|
|
532
|
+
const [command, ...args] = tokens;
|
|
533
|
+
if (command === void 0) {
|
|
534
|
+
return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
|
|
535
|
+
}
|
|
536
|
+
if (tokens.some((token) => token !== PROMPT_TOKEN && token.includes(PROMPT_TOKEN))) {
|
|
537
|
+
return {
|
|
538
|
+
ok: false,
|
|
539
|
+
error: `${PROMPT_TOKEN} must stand as a word of its own, for example: ${EXAMPLE}`
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
|
|
543
|
+
if (placeholders > 1) {
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
if (command === PROMPT_TOKEN) {
|
|
550
|
+
return {
|
|
551
|
+
ok: false,
|
|
552
|
+
error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
return { ok: true, template: { command, args } };
|
|
556
|
+
}
|
|
557
|
+
function commandFor(template, prompt) {
|
|
558
|
+
if (!template.args.includes(PROMPT_TOKEN)) {
|
|
559
|
+
return { command: template.command, args: [...template.args, prompt] };
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
command: template.command,
|
|
563
|
+
args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
function nextRequest(requests, failed) {
|
|
567
|
+
return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ../server/dist/agents.js
|
|
571
|
+
import { spawn } from "child_process";
|
|
572
|
+
import { constants } from "fs";
|
|
573
|
+
import { access, mkdir, readFile, writeFile } from "fs/promises";
|
|
574
|
+
import { delimiter, dirname, isAbsolute, join, relative } from "path";
|
|
575
|
+
var KNOWN_AGENTS = {
|
|
576
|
+
claude: {
|
|
577
|
+
name: "Claude",
|
|
578
|
+
binary: "claude",
|
|
579
|
+
args: (prompt) => [
|
|
580
|
+
"-p",
|
|
581
|
+
prompt,
|
|
582
|
+
"--output-format",
|
|
583
|
+
"stream-json",
|
|
584
|
+
"--verbose",
|
|
585
|
+
"--permission-mode",
|
|
586
|
+
"acceptEdits"
|
|
587
|
+
],
|
|
588
|
+
terminalArgs: (prompt) => [
|
|
589
|
+
"-p",
|
|
590
|
+
prompt,
|
|
591
|
+
"--permission-mode",
|
|
592
|
+
"acceptEdits"
|
|
593
|
+
],
|
|
594
|
+
resumeArgs: (sessionId, prompt) => [
|
|
595
|
+
"-p",
|
|
596
|
+
"--resume",
|
|
597
|
+
sessionId,
|
|
598
|
+
prompt,
|
|
599
|
+
"--output-format",
|
|
600
|
+
"stream-json",
|
|
601
|
+
"--verbose",
|
|
602
|
+
"--permission-mode",
|
|
603
|
+
"acceptEdits"
|
|
604
|
+
],
|
|
605
|
+
// Every stream-json event names its session.
|
|
606
|
+
sessionFrom: (event) => typeof event.session_id === "string" && event.session_id !== "" ? event.session_id : null,
|
|
607
|
+
authArgs: ["auth", "status"],
|
|
608
|
+
// `claude auth status` prints JSON with a loggedIn boolean. Only that
|
|
609
|
+
// field decides; any other shape stays unknown.
|
|
610
|
+
authVerdict: (result2) => {
|
|
611
|
+
try {
|
|
612
|
+
const parsed2 = record(JSON.parse(result2.stdout));
|
|
613
|
+
if (parsed2?.loggedIn === true)
|
|
614
|
+
return "ok";
|
|
615
|
+
if (parsed2?.loggedIn === false)
|
|
616
|
+
return "signed-out";
|
|
617
|
+
} catch {
|
|
618
|
+
}
|
|
619
|
+
return "unknown";
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
codex: {
|
|
623
|
+
name: "Codex",
|
|
624
|
+
binary: "codex",
|
|
625
|
+
args: (prompt) => ["exec", "--json", "-s", "workspace-write", prompt],
|
|
626
|
+
terminalArgs: (prompt) => ["exec", "-s", "workspace-write", prompt],
|
|
627
|
+
// No sandbox flag here: `codex exec resume` refuses it and inherits the
|
|
628
|
+
// session's own sandbox, which the first turn set to workspace-write.
|
|
629
|
+
resumeArgs: (sessionId, prompt) => [
|
|
630
|
+
"exec",
|
|
631
|
+
"resume",
|
|
632
|
+
sessionId,
|
|
633
|
+
"--json",
|
|
634
|
+
prompt
|
|
635
|
+
],
|
|
636
|
+
sessionFrom: (event) => event.type === "thread.started" && typeof event.thread_id === "string" ? event.thread_id : null,
|
|
637
|
+
authArgs: ["login", "status"],
|
|
638
|
+
// `codex login status` exits 0 when logged in and nonzero when not.
|
|
639
|
+
authVerdict: (result2) => result2.code === 0 ? "ok" : "signed-out"
|
|
640
|
+
},
|
|
641
|
+
cursor: {
|
|
642
|
+
name: "Cursor",
|
|
643
|
+
binary: "cursor-agent",
|
|
644
|
+
args: (prompt) => ["-p", prompt, "--output-format", "stream-json"],
|
|
645
|
+
terminalArgs: (prompt) => ["-p", prompt],
|
|
646
|
+
authArgs: ["status"],
|
|
647
|
+
// UNVERIFIED: cursor-agent was not available on the build machine. The
|
|
648
|
+
// reading is deliberately loose, and anything ambiguous stays unknown.
|
|
649
|
+
authVerdict: (result2) => {
|
|
650
|
+
if (/logged in|signed in/i.test(result2.stdout))
|
|
651
|
+
return "ok";
|
|
652
|
+
if (result2.code !== 0 || /not logged in|log in|sign in/i.test(result2.stdout))
|
|
653
|
+
return "signed-out";
|
|
654
|
+
return "unknown";
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
var PROBE_TIMEOUT_MS = 3e3;
|
|
659
|
+
function execProbe(binary, args) {
|
|
660
|
+
return new Promise((resolve) => {
|
|
661
|
+
let child;
|
|
662
|
+
try {
|
|
663
|
+
child = spawn(binary, [...args], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
|
|
664
|
+
} catch {
|
|
665
|
+
return resolve(null);
|
|
666
|
+
}
|
|
667
|
+
let stdout = "";
|
|
668
|
+
child.stdout?.on("data", (chunk) => {
|
|
669
|
+
if (stdout.length < 4096)
|
|
670
|
+
stdout += chunk.toString();
|
|
671
|
+
});
|
|
672
|
+
const deadline = setTimeout(() => child.kill("SIGKILL"), PROBE_TIMEOUT_MS);
|
|
673
|
+
child.once("error", () => {
|
|
674
|
+
clearTimeout(deadline);
|
|
675
|
+
resolve(null);
|
|
676
|
+
});
|
|
677
|
+
child.once("close", (code, signal) => {
|
|
678
|
+
clearTimeout(deadline);
|
|
679
|
+
resolve(signal !== null ? null : { code: code ?? 0, stdout });
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
async function pathLookup(binary) {
|
|
684
|
+
const entries = (process.env.PATH ?? "").split(delimiter).filter((entry) => entry !== "");
|
|
685
|
+
const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((entry) => entry !== "") : [""];
|
|
686
|
+
for (const entry of entries) {
|
|
687
|
+
for (const extension of extensions) {
|
|
688
|
+
try {
|
|
689
|
+
await access(join(entry, `${binary}${extension}`), constants.X_OK);
|
|
690
|
+
return true;
|
|
691
|
+
} catch {
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
async function detectAgents(lookup = pathLookup, probe2 = execProbe) {
|
|
698
|
+
const entries = Object.entries(KNOWN_AGENTS);
|
|
699
|
+
return Promise.all(entries.map(async ([id, adapter]) => {
|
|
700
|
+
const available = await lookup(adapter.binary).catch(() => false);
|
|
701
|
+
if (!available)
|
|
702
|
+
return { id, name: adapter.name, available, auth: "unknown" };
|
|
703
|
+
const result2 = await probe2(adapter.binary, adapter.authArgs).catch(() => null);
|
|
704
|
+
return {
|
|
705
|
+
id,
|
|
706
|
+
name: adapter.name,
|
|
707
|
+
available,
|
|
708
|
+
auth: result2 === null ? "unknown" : adapter.authVerdict(result2)
|
|
709
|
+
};
|
|
710
|
+
}));
|
|
711
|
+
}
|
|
712
|
+
function record(value) {
|
|
713
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
714
|
+
}
|
|
715
|
+
function shownPath(value, cwd) {
|
|
716
|
+
if (typeof value !== "string" || value === "")
|
|
717
|
+
return null;
|
|
718
|
+
if (!isAbsolute(value))
|
|
719
|
+
return value;
|
|
720
|
+
return relative(cwd, value) || ".";
|
|
721
|
+
}
|
|
722
|
+
function shownCommand(value) {
|
|
723
|
+
let command = Array.isArray(value) ? value.filter((part) => typeof part === "string").join(" ") : typeof value === "string" ? value : "";
|
|
724
|
+
command = command.trim();
|
|
725
|
+
const wrapped = /^(?:\S*\/)?(?:bash|sh|zsh)\s+-l?c\s+([\s\S]*)$/.exec(command);
|
|
726
|
+
if (wrapped?.[1] !== void 0) {
|
|
727
|
+
command = wrapped[1].trim();
|
|
728
|
+
const quote = command[0];
|
|
729
|
+
if ((quote === "'" || quote === '"') && command.endsWith(quote) && command.length > 1) {
|
|
730
|
+
command = command.slice(1, -1);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
command = (command.split("\n")[0] ?? "").replace(/\s+/g, " ").trim();
|
|
734
|
+
if (command === "")
|
|
735
|
+
return null;
|
|
736
|
+
return command.length > 48 ? `${command.slice(0, 47)}\u2026` : command;
|
|
737
|
+
}
|
|
738
|
+
function claudeActivity(event, cwd) {
|
|
739
|
+
if (event.type !== "assistant")
|
|
740
|
+
return null;
|
|
741
|
+
const message = record(event.message);
|
|
742
|
+
if (message === null || !Array.isArray(message.content))
|
|
743
|
+
return null;
|
|
744
|
+
for (const rawBlock of message.content) {
|
|
745
|
+
const block = record(rawBlock);
|
|
746
|
+
if (block?.type !== "tool_use" || typeof block.name !== "string")
|
|
747
|
+
continue;
|
|
748
|
+
const input = record(block.input);
|
|
749
|
+
if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(block.name)) {
|
|
750
|
+
const path = shownPath(input?.file_path ?? input?.notebook_path, cwd);
|
|
751
|
+
return path === null ? `using ${block.name}` : `editing ${path}`;
|
|
752
|
+
}
|
|
753
|
+
if (block.name === "Read") {
|
|
754
|
+
const path = shownPath(input?.file_path ?? input?.path, cwd);
|
|
755
|
+
return path === null ? "using Read" : `reading ${path}`;
|
|
756
|
+
}
|
|
757
|
+
if (block.name === "Bash") {
|
|
758
|
+
const command = shownCommand(input?.command);
|
|
759
|
+
return command === null ? "running a command" : `running ${command}`;
|
|
760
|
+
}
|
|
761
|
+
if (block.name === "Grep" || block.name === "Glob")
|
|
762
|
+
return "searching the project";
|
|
763
|
+
return `using ${block.name}`;
|
|
764
|
+
}
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
function codexActivity(event, cwd) {
|
|
768
|
+
if (event.type !== "item.started" && event.type !== "item.completed")
|
|
769
|
+
return null;
|
|
770
|
+
const item = record(event.item);
|
|
771
|
+
if (item === null)
|
|
772
|
+
return null;
|
|
773
|
+
if (item.type === "command_execution") {
|
|
774
|
+
const command = shownCommand(item.command);
|
|
775
|
+
return command === null ? "running a command" : `running ${command}`;
|
|
776
|
+
}
|
|
777
|
+
if (item.type !== "file_change")
|
|
778
|
+
return null;
|
|
779
|
+
const first = Array.isArray(item.changes) ? record(item.changes[0]) : null;
|
|
780
|
+
const path = shownPath(first?.path ?? item.path, cwd);
|
|
781
|
+
return path === null ? null : `editing ${path}`;
|
|
782
|
+
}
|
|
783
|
+
function activityFrom(agent, line, cwd = process.cwd()) {
|
|
784
|
+
let event;
|
|
785
|
+
try {
|
|
786
|
+
event = record(JSON.parse(line));
|
|
787
|
+
} catch {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
if (event === null)
|
|
791
|
+
return null;
|
|
792
|
+
if (agent === "claude")
|
|
793
|
+
return claudeActivity(event, cwd);
|
|
794
|
+
if (agent === "codex")
|
|
795
|
+
return codexActivity(event, cwd);
|
|
796
|
+
if (agent === "cursor")
|
|
797
|
+
return claudeActivity(event, cwd);
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
function sessionFrom(agent, line) {
|
|
801
|
+
if (agent !== "claude" && agent !== "codex")
|
|
802
|
+
return null;
|
|
803
|
+
let event;
|
|
804
|
+
try {
|
|
805
|
+
event = record(JSON.parse(line));
|
|
806
|
+
} catch {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
if (event === null)
|
|
810
|
+
return null;
|
|
811
|
+
return KNOWN_AGENTS[agent].sessionFrom(event);
|
|
812
|
+
}
|
|
813
|
+
function isAgentChoice(value) {
|
|
814
|
+
return value === "custom" || typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
|
|
815
|
+
}
|
|
816
|
+
async function readWatchConfig(cwd) {
|
|
817
|
+
try {
|
|
818
|
+
const parsed2 = JSON.parse(await readFile(join(cwd, WATCH_PATH), "utf8"));
|
|
819
|
+
return record(parsed2) ?? {};
|
|
820
|
+
} catch {
|
|
821
|
+
return {};
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
async function readAgentChoice(cwd) {
|
|
825
|
+
const config = await readWatchConfig(cwd);
|
|
826
|
+
return {
|
|
827
|
+
agent: isAgentChoice(config.agent) ? config.agent : null,
|
|
828
|
+
run: typeof config.run === "string" && config.run !== "" ? config.run : null
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
async function saveAgentChoice(cwd, choice) {
|
|
832
|
+
const config = await readWatchConfig(cwd);
|
|
833
|
+
config.agent = choice.agent;
|
|
834
|
+
if (choice.run !== void 0)
|
|
835
|
+
config.run = choice.run;
|
|
836
|
+
const path = join(cwd, WATCH_PATH);
|
|
837
|
+
await mkdir(dirname(path), { recursive: true });
|
|
838
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}
|
|
839
|
+
`, "utf8");
|
|
840
|
+
}
|
|
841
|
+
|
|
488
842
|
// ../server/dist/classify.js
|
|
489
843
|
var MANIFESTS = /* @__PURE__ */ new Set([
|
|
490
844
|
"package.json",
|
|
@@ -522,12 +876,12 @@ function isExplorationFile(path) {
|
|
|
522
876
|
}
|
|
523
877
|
var CHECKOUT_STEPS = [
|
|
524
878
|
"Build the direction on its own branch: git switch -c <branch>, commit it there, switch back.",
|
|
525
|
-
'Register it: leglas add --title "<title>" --url "/" --branch <branch>.',
|
|
879
|
+
'Register it: npx leglas add --title "<title>" --url "/" --branch <branch>.',
|
|
526
880
|
"Make sure the config sets devCommand (with {port}), so Leglas can start the checkout."
|
|
527
881
|
];
|
|
528
882
|
var IN_APP_STEPS = [
|
|
529
883
|
"Author it additively under .leglas/variants/<surface>/, beside the existing directions.",
|
|
530
|
-
'Register it: leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
|
|
884
|
+
'Register it: npx leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
|
|
531
885
|
];
|
|
532
886
|
function classifyDirection(input) {
|
|
533
887
|
const checkout = (reason) => ({ level: "checkout", reason, steps: CHECKOUT_STEPS });
|
|
@@ -552,7 +906,7 @@ function classifyDirection(input) {
|
|
|
552
906
|
|
|
553
907
|
// ../server/dist/find-config.js
|
|
554
908
|
import { existsSync } from "fs";
|
|
555
|
-
import { dirname, join, parse } from "path";
|
|
909
|
+
import { dirname as dirname2, join as join2, parse } from "path";
|
|
556
910
|
var CONFIG_BASENAMES = [
|
|
557
911
|
"leglas.config.ts",
|
|
558
912
|
"leglas.config.mjs",
|
|
@@ -564,13 +918,13 @@ function findConfigFile(startDir) {
|
|
|
564
918
|
let dir = startDir;
|
|
565
919
|
for (; ; ) {
|
|
566
920
|
for (const basename4 of CONFIG_BASENAMES) {
|
|
567
|
-
const candidate =
|
|
921
|
+
const candidate = join2(dir, basename4);
|
|
568
922
|
if (existsSync(candidate))
|
|
569
923
|
return candidate;
|
|
570
924
|
}
|
|
571
925
|
if (dir === root)
|
|
572
926
|
return null;
|
|
573
|
-
const parent =
|
|
927
|
+
const parent = dirname2(dir);
|
|
574
928
|
if (parent === dir)
|
|
575
929
|
return null;
|
|
576
930
|
dir = parent;
|
|
@@ -578,19 +932,19 @@ function findConfigFile(startDir) {
|
|
|
578
932
|
}
|
|
579
933
|
|
|
580
934
|
// ../server/dist/load-config.js
|
|
581
|
-
import { readFile } from "fs/promises";
|
|
582
|
-
import { relative } from "path";
|
|
935
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
936
|
+
import { relative as relative2 } from "path";
|
|
583
937
|
import { pathToFileURL } from "url";
|
|
584
938
|
async function loadConfig(cwd) {
|
|
585
939
|
const path = findConfigFile(cwd);
|
|
586
940
|
if (path === null) {
|
|
587
941
|
return { ...normalizeConfig(void 0), path: null };
|
|
588
942
|
}
|
|
589
|
-
const label =
|
|
943
|
+
const label = relative2(cwd, path) || path;
|
|
590
944
|
let exported;
|
|
591
945
|
try {
|
|
592
946
|
if (path.endsWith(".json")) {
|
|
593
|
-
exported = JSON.parse(await
|
|
947
|
+
exported = JSON.parse(await readFile2(path, "utf8"));
|
|
594
948
|
} else {
|
|
595
949
|
const module = await import(pathToFileURL(path).href);
|
|
596
950
|
if (!("default" in module)) {
|
|
@@ -611,14 +965,14 @@ async function loadConfig(cwd) {
|
|
|
611
965
|
}
|
|
612
966
|
|
|
613
967
|
// ../server/dist/local-previews.js
|
|
614
|
-
import { mkdir, readFile as
|
|
615
|
-
import { dirname as
|
|
968
|
+
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
969
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
616
970
|
var LOCAL_PREVIEWS_PATH = ".leglas/previews.json";
|
|
617
971
|
async function readLocalPreviews(cwd) {
|
|
618
|
-
const path =
|
|
972
|
+
const path = join3(cwd, LOCAL_PREVIEWS_PATH);
|
|
619
973
|
let raw;
|
|
620
974
|
try {
|
|
621
|
-
raw = await
|
|
975
|
+
raw = await readFile3(path, "utf8");
|
|
622
976
|
} catch {
|
|
623
977
|
return { previews: [], errors: [] };
|
|
624
978
|
}
|
|
@@ -667,9 +1021,9 @@ async function addLocalPreview(cwd, input, shared) {
|
|
|
667
1021
|
if (check.config === null) {
|
|
668
1022
|
return { ok: false, error: check.errors.join(" ") };
|
|
669
1023
|
}
|
|
670
|
-
const path =
|
|
671
|
-
await
|
|
672
|
-
await
|
|
1024
|
+
const path = join3(cwd, LOCAL_PREVIEWS_PATH);
|
|
1025
|
+
await mkdir2(dirname3(path), { recursive: true });
|
|
1026
|
+
await writeFile2(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
|
|
673
1027
|
`, "utf8");
|
|
674
1028
|
return { ok: true };
|
|
675
1029
|
}
|
|
@@ -682,9 +1036,9 @@ async function dropLocalPreviews(cwd, titles) {
|
|
|
682
1036
|
const keep = existing.previews.filter((preview) => !titles.includes(preview.title));
|
|
683
1037
|
if (keep.length === existing.previews.length)
|
|
684
1038
|
return 0;
|
|
685
|
-
const path =
|
|
686
|
-
await
|
|
687
|
-
await
|
|
1039
|
+
const path = join3(cwd, LOCAL_PREVIEWS_PATH);
|
|
1040
|
+
await mkdir2(dirname3(path), { recursive: true });
|
|
1041
|
+
await writeFile2(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
|
|
688
1042
|
`, "utf8");
|
|
689
1043
|
return existing.previews.length - keep.length;
|
|
690
1044
|
}
|
|
@@ -753,10 +1107,10 @@ ${headers}\r
|
|
|
753
1107
|
}
|
|
754
1108
|
|
|
755
1109
|
// ../server/dist/worktree.js
|
|
756
|
-
import { execFile, spawn } from "child_process";
|
|
1110
|
+
import { execFile, spawn as spawn2 } from "child_process";
|
|
757
1111
|
import { rm } from "fs/promises";
|
|
758
1112
|
import net2 from "net";
|
|
759
|
-
import { join as
|
|
1113
|
+
import { join as join4 } from "path";
|
|
760
1114
|
import { promisify } from "util";
|
|
761
1115
|
var run = promisify(execFile);
|
|
762
1116
|
var WORKTREES_DIR = ".leglas/worktrees";
|
|
@@ -793,7 +1147,7 @@ function answers(port) {
|
|
|
793
1147
|
}
|
|
794
1148
|
async function startWorktree(options) {
|
|
795
1149
|
const readyTimeoutMs = options.readyTimeoutMs ?? 9e4;
|
|
796
|
-
const path =
|
|
1150
|
+
const path = join4(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
|
|
797
1151
|
const log = options.onLog ?? (() => {
|
|
798
1152
|
});
|
|
799
1153
|
await rm(path, { recursive: true, force: true });
|
|
@@ -854,7 +1208,7 @@ async function startAppProcess(options) {
|
|
|
854
1208
|
const port = await freePort();
|
|
855
1209
|
let child;
|
|
856
1210
|
try {
|
|
857
|
-
child =
|
|
1211
|
+
child = spawn2(substitutePort(options.devCommand, port), {
|
|
858
1212
|
cwd: options.cwd,
|
|
859
1213
|
shell: true,
|
|
860
1214
|
// Own process group, so stopping kills the shell and whatever it spawned
|
|
@@ -894,9 +1248,9 @@ async function startAppProcess(options) {
|
|
|
894
1248
|
}
|
|
895
1249
|
|
|
896
1250
|
// ../server/dist/requests.js
|
|
897
|
-
import { mkdir as
|
|
1251
|
+
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
898
1252
|
import { randomBytes } from "crypto";
|
|
899
|
-
import { dirname as
|
|
1253
|
+
import { dirname as dirname4, join as join5 } from "path";
|
|
900
1254
|
var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
|
|
901
1255
|
function targetFor(url) {
|
|
902
1256
|
if (!url.startsWith("/"))
|
|
@@ -922,17 +1276,20 @@ function composeRequest(preview, intent) {
|
|
|
922
1276
|
const target = preview.file ?? targetFor(preview.url);
|
|
923
1277
|
const cleaned = intent.trim();
|
|
924
1278
|
const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
|
|
1279
|
+
const pace = target === null ? `Once found, make the change and finish. ` : `Make the change in that file and finish. `;
|
|
925
1280
|
const prompt = `In this project, change only the "${preview.title}" design direction. ${where}
|
|
926
1281
|
|
|
927
1282
|
What to change: ${cleaned}
|
|
928
1283
|
|
|
1284
|
+
${pace}This is a scoped design change: no test run, no build, and no survey of the rest of the project is needed. The result is checked visually in a live preview, not by tooling.
|
|
1285
|
+
|
|
929
1286
|
Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. The direction is already registered, so nothing needs re-registering. Keep the change additive: do not rewrite shared components that other directions rely on.`;
|
|
930
1287
|
return { prompt, target };
|
|
931
1288
|
}
|
|
932
1289
|
var REQUESTS_PATH = ".leglas/requests.json";
|
|
933
1290
|
async function readRequests(cwd) {
|
|
934
1291
|
try {
|
|
935
|
-
const raw = await
|
|
1292
|
+
const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
|
|
936
1293
|
const parsed2 = JSON.parse(raw);
|
|
937
1294
|
if (!Array.isArray(parsed2.requests))
|
|
938
1295
|
return [];
|
|
@@ -949,9 +1306,9 @@ async function readRequests(cwd) {
|
|
|
949
1306
|
}
|
|
950
1307
|
}
|
|
951
1308
|
async function writeQueue(cwd, requests) {
|
|
952
|
-
const path =
|
|
953
|
-
await
|
|
954
|
-
await
|
|
1309
|
+
const path = join5(cwd, REQUESTS_PATH);
|
|
1310
|
+
await mkdir3(dirname4(path), { recursive: true });
|
|
1311
|
+
await writeFile3(path, `${JSON.stringify({ requests }, null, 2)}
|
|
955
1312
|
`, "utf8");
|
|
956
1313
|
}
|
|
957
1314
|
async function appendRequest(cwd, request) {
|
|
@@ -983,16 +1340,272 @@ async function removeRequest(cwd, id) {
|
|
|
983
1340
|
return true;
|
|
984
1341
|
}
|
|
985
1342
|
async function clearRequests(cwd) {
|
|
986
|
-
await
|
|
1343
|
+
const requests = await readRequests(cwd);
|
|
1344
|
+
const pending = requests.filter((request) => request.status !== "picked-up");
|
|
1345
|
+
const cleared = requests.length - pending.length;
|
|
1346
|
+
if (cleared > 0)
|
|
1347
|
+
await writeQueue(cwd, pending);
|
|
1348
|
+
return { cleared, pending: pending.length };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// ../server/dist/runner.js
|
|
1352
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
1353
|
+
var POLL_MS = 2e3;
|
|
1354
|
+
var OUTPUT_LINES = 20;
|
|
1355
|
+
var SESSION_TURNS_CAP = 8;
|
|
1356
|
+
function resolveCommand(choice, prompt, sessionId = null) {
|
|
1357
|
+
if (choice.agent === null)
|
|
1358
|
+
return null;
|
|
1359
|
+
if (choice.agent === "custom") {
|
|
1360
|
+
if (choice.run === null)
|
|
1361
|
+
return null;
|
|
1362
|
+
const parsed2 = parseTemplate(choice.run);
|
|
1363
|
+
if (!parsed2.ok)
|
|
1364
|
+
return null;
|
|
1365
|
+
return { agent: "custom", name: "Custom", ...commandFor(parsed2.template, prompt), resumed: false };
|
|
1366
|
+
}
|
|
1367
|
+
const adapter = KNOWN_AGENTS[choice.agent];
|
|
1368
|
+
if (sessionId !== null && "resumeArgs" in adapter) {
|
|
1369
|
+
return {
|
|
1370
|
+
agent: choice.agent,
|
|
1371
|
+
name: adapter.name,
|
|
1372
|
+
command: adapter.binary,
|
|
1373
|
+
args: adapter.resumeArgs(sessionId, prompt),
|
|
1374
|
+
resumed: true
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
return {
|
|
1378
|
+
agent: choice.agent,
|
|
1379
|
+
name: adapter.name,
|
|
1380
|
+
command: adapter.binary,
|
|
1381
|
+
args: adapter.args(prompt),
|
|
1382
|
+
resumed: false
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
function lineReader(stream, onLine) {
|
|
1386
|
+
let buffered = "";
|
|
1387
|
+
const flush = () => {
|
|
1388
|
+
if (buffered === "")
|
|
1389
|
+
return;
|
|
1390
|
+
onLine(buffered.replace(/\r$/, ""));
|
|
1391
|
+
buffered = "";
|
|
1392
|
+
};
|
|
1393
|
+
stream.on("data", (chunk) => {
|
|
1394
|
+
buffered += chunk.toString();
|
|
1395
|
+
const lines = buffered.split("\n");
|
|
1396
|
+
buffered = lines.pop() ?? "";
|
|
1397
|
+
for (const line of lines)
|
|
1398
|
+
onLine(line.replace(/\r$/, ""));
|
|
1399
|
+
});
|
|
1400
|
+
stream.on("end", flush);
|
|
1401
|
+
return flush;
|
|
1402
|
+
}
|
|
1403
|
+
function defaultSpawn(command, args, options) {
|
|
1404
|
+
return nodeSpawn(command, args, options);
|
|
1405
|
+
}
|
|
1406
|
+
function startRunner(options) {
|
|
1407
|
+
const spawn5 = options.spawn ?? defaultSpawn;
|
|
1408
|
+
const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
|
|
1409
|
+
const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
|
|
1410
|
+
const failed = /* @__PURE__ */ new Set();
|
|
1411
|
+
let state = {
|
|
1412
|
+
running: false,
|
|
1413
|
+
requestId: null,
|
|
1414
|
+
agent: null,
|
|
1415
|
+
activity: null,
|
|
1416
|
+
startedAt: null
|
|
1417
|
+
};
|
|
1418
|
+
let stopped = false;
|
|
1419
|
+
let ticking = null;
|
|
1420
|
+
let stopPromise = null;
|
|
1421
|
+
let active = null;
|
|
1422
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1423
|
+
const idle = () => {
|
|
1424
|
+
state = { running: false, requestId: null, agent: null, activity: null, startedAt: null };
|
|
1425
|
+
};
|
|
1426
|
+
const rememberLine = (lines, line) => {
|
|
1427
|
+
lines.push(line);
|
|
1428
|
+
if (lines.length > OUTPUT_LINES)
|
|
1429
|
+
lines.splice(0, lines.length - OUTPUT_LINES);
|
|
1430
|
+
};
|
|
1431
|
+
const reportFailure = (request, error, lines) => {
|
|
1432
|
+
console.error(`Leglas agent failed for ${request.title}: ${error}`);
|
|
1433
|
+
for (const line of lines)
|
|
1434
|
+
console.error(` ${line}`);
|
|
1435
|
+
};
|
|
1436
|
+
const runChild = (request, resolved, lines, observed) => {
|
|
1437
|
+
let child;
|
|
1438
|
+
try {
|
|
1439
|
+
child = spawn5(resolved.command, resolved.args, {
|
|
1440
|
+
cwd: options.cwd,
|
|
1441
|
+
shell: false,
|
|
1442
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1443
|
+
});
|
|
1444
|
+
} catch (error) {
|
|
1445
|
+
return Promise.resolve({
|
|
1446
|
+
ok: false,
|
|
1447
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
const current = { child, requestId: request.id, cancelled: false };
|
|
1451
|
+
active = current;
|
|
1452
|
+
const stdoutFlush = lineReader(child.stdout, (line) => {
|
|
1453
|
+
rememberLine(lines, line);
|
|
1454
|
+
const sessionId = sessionFrom(resolved.agent, line);
|
|
1455
|
+
if (sessionId !== null)
|
|
1456
|
+
observed.sessionId = sessionId;
|
|
1457
|
+
const activity = activityFrom(resolved.agent, line, options.cwd);
|
|
1458
|
+
if (activity !== null) {
|
|
1459
|
+
if (activity.startsWith("editing"))
|
|
1460
|
+
observed.edited = true;
|
|
1461
|
+
if (active === current)
|
|
1462
|
+
state = { ...state, activity };
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
|
|
1466
|
+
return new Promise((resolve) => {
|
|
1467
|
+
let settled = false;
|
|
1468
|
+
const settle = (outcome) => {
|
|
1469
|
+
if (settled)
|
|
1470
|
+
return;
|
|
1471
|
+
settled = true;
|
|
1472
|
+
stdoutFlush();
|
|
1473
|
+
stderrFlush();
|
|
1474
|
+
resolve(outcome);
|
|
1475
|
+
};
|
|
1476
|
+
child.once("error", (error) => settle({ ok: false, error: error.message }));
|
|
1477
|
+
child.once("close", (code, signal) => {
|
|
1478
|
+
if (current.cancelled)
|
|
1479
|
+
return settle({ ok: false, error: "cancelled" });
|
|
1480
|
+
if (signal !== null)
|
|
1481
|
+
return settle({ ok: false, error: `stopped by ${signal}` });
|
|
1482
|
+
settle({ ok: true, code: code ?? 0 });
|
|
1483
|
+
});
|
|
1484
|
+
}).finally(() => {
|
|
1485
|
+
if (active === current)
|
|
1486
|
+
active = null;
|
|
1487
|
+
});
|
|
1488
|
+
};
|
|
1489
|
+
const handle = async (request, choice) => {
|
|
1490
|
+
const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
|
|
1491
|
+
const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
|
|
1492
|
+
let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null);
|
|
1493
|
+
if (resolved === null)
|
|
1494
|
+
return;
|
|
1495
|
+
const lines = [];
|
|
1496
|
+
try {
|
|
1497
|
+
if (!await markPickedUp(options.cwd, request.id))
|
|
1498
|
+
return;
|
|
1499
|
+
if (stopped) {
|
|
1500
|
+
failed.add(request.id);
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
state = {
|
|
1504
|
+
running: true,
|
|
1505
|
+
requestId: request.id,
|
|
1506
|
+
agent: resolved.name,
|
|
1507
|
+
activity: null,
|
|
1508
|
+
startedAt: Date.now()
|
|
1509
|
+
};
|
|
1510
|
+
const observed = { sessionId: null, edited: false };
|
|
1511
|
+
let outcome = await runChild(request, resolved, lines, observed);
|
|
1512
|
+
const cancelled = !outcome.ok && outcome.error === "cancelled";
|
|
1513
|
+
if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && !cancelled && // Not redundant with the line above: a stop that lands between the
|
|
1514
|
+
// first child settling and the retry starting finds no child to
|
|
1515
|
+
// cancel, so nothing says "cancelled". Stopped still means stopped.
|
|
1516
|
+
!stopped) {
|
|
1517
|
+
sessions.delete(resolved.agent);
|
|
1518
|
+
const cold = resolveCommand(choice, request.prompt);
|
|
1519
|
+
if (cold !== null) {
|
|
1520
|
+
resolved = cold;
|
|
1521
|
+
observed.sessionId = null;
|
|
1522
|
+
state = { ...state, activity: null };
|
|
1523
|
+
outcome = await runChild(request, resolved, lines, observed);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (outcome.ok && outcome.code === 0) {
|
|
1527
|
+
if (observed.sessionId !== null) {
|
|
1528
|
+
const previous = sessions.get(resolved.agent);
|
|
1529
|
+
sessions.set(resolved.agent, {
|
|
1530
|
+
id: observed.sessionId,
|
|
1531
|
+
turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
await removeRequest(options.cwd, request.id);
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
sessions.delete(resolved.agent);
|
|
1538
|
+
failed.add(request.id);
|
|
1539
|
+
reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
|
|
1540
|
+
} finally {
|
|
1541
|
+
idle();
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
const tick = async () => {
|
|
1545
|
+
if (stopped)
|
|
1546
|
+
return;
|
|
1547
|
+
const choice = await readAgentChoice(options.cwd);
|
|
1548
|
+
if (choice.agent === null || stopped)
|
|
1549
|
+
return;
|
|
1550
|
+
if (options.externallyAttached())
|
|
1551
|
+
return;
|
|
1552
|
+
const request = nextRequest(await readRequests(options.cwd), failed);
|
|
1553
|
+
if (request !== null && !stopped)
|
|
1554
|
+
await handle(request, choice);
|
|
1555
|
+
};
|
|
1556
|
+
const schedule = () => {
|
|
1557
|
+
if (stopped || ticking !== null)
|
|
1558
|
+
return;
|
|
1559
|
+
const task = tick();
|
|
1560
|
+
ticking = task;
|
|
1561
|
+
void task.catch((error) => console.error(`Leglas runner: ${error instanceof Error ? error.message : String(error)}`)).finally(() => {
|
|
1562
|
+
if (ticking === task)
|
|
1563
|
+
ticking = null;
|
|
1564
|
+
});
|
|
1565
|
+
};
|
|
1566
|
+
const timer = setEvery(schedule, POLL_MS);
|
|
1567
|
+
schedule();
|
|
1568
|
+
const cancel = (id) => {
|
|
1569
|
+
if (active === null || active.cancelled)
|
|
1570
|
+
return false;
|
|
1571
|
+
if (id !== void 0 && active.requestId !== id)
|
|
1572
|
+
return false;
|
|
1573
|
+
active.cancelled = true;
|
|
1574
|
+
failed.add(active.requestId);
|
|
1575
|
+
try {
|
|
1576
|
+
active.child.kill("SIGTERM");
|
|
1577
|
+
} catch {
|
|
1578
|
+
}
|
|
1579
|
+
return true;
|
|
1580
|
+
};
|
|
1581
|
+
const stop = () => {
|
|
1582
|
+
if (stopPromise !== null)
|
|
1583
|
+
return stopPromise;
|
|
1584
|
+
stopped = true;
|
|
1585
|
+
clearEvery(timer);
|
|
1586
|
+
cancel();
|
|
1587
|
+
stopPromise = Promise.resolve(ticking).catch(() => {
|
|
1588
|
+
}).then(() => {
|
|
1589
|
+
});
|
|
1590
|
+
return stopPromise;
|
|
1591
|
+
};
|
|
1592
|
+
return {
|
|
1593
|
+
stop,
|
|
1594
|
+
snapshot: () => ({ ...state, failedIds: [...failed] }),
|
|
1595
|
+
cancel,
|
|
1596
|
+
// schedule already refuses to overlap a tick in flight, so a nudge during
|
|
1597
|
+
// a run costs nothing and a nudge between runs starts the next one now.
|
|
1598
|
+
nudge: schedule
|
|
1599
|
+
};
|
|
987
1600
|
}
|
|
988
1601
|
|
|
989
1602
|
// ../server/dist/renames.js
|
|
990
|
-
import { mkdir as
|
|
991
|
-
import { dirname as
|
|
1603
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
1604
|
+
import { dirname as dirname5, join as join6 } from "path";
|
|
992
1605
|
var RENAMES_PATH = ".leglas/renames.json";
|
|
993
1606
|
async function readRenames(cwd) {
|
|
994
1607
|
try {
|
|
995
|
-
const raw = await
|
|
1608
|
+
const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
|
|
996
1609
|
const parsed2 = JSON.parse(raw);
|
|
997
1610
|
if (parsed2.renames === null || typeof parsed2.renames !== "object")
|
|
998
1611
|
return {};
|
|
@@ -1002,9 +1615,9 @@ async function readRenames(cwd) {
|
|
|
1002
1615
|
}
|
|
1003
1616
|
}
|
|
1004
1617
|
async function writeRenames(cwd, renames) {
|
|
1005
|
-
const path =
|
|
1006
|
-
await
|
|
1007
|
-
await
|
|
1618
|
+
const path = join6(cwd, RENAMES_PATH);
|
|
1619
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
1620
|
+
await writeFile4(path, `${JSON.stringify({ renames }, null, 2)}
|
|
1008
1621
|
`, "utf8");
|
|
1009
1622
|
}
|
|
1010
1623
|
function resolveTitle(input, titles, renames) {
|
|
@@ -1022,7 +1635,7 @@ function resolveTitle(input, titles, renames) {
|
|
|
1022
1635
|
import { createReadStream, existsSync as existsSync2, statSync } from "fs";
|
|
1023
1636
|
import http2 from "http";
|
|
1024
1637
|
import net3 from "net";
|
|
1025
|
-
import { extname, join as
|
|
1638
|
+
import { extname, join as join7, normalize, relative as relative3 } from "path";
|
|
1026
1639
|
var LEGLAS_PREFIX = "/leglas";
|
|
1027
1640
|
var DEFAULT_PORT = 4100;
|
|
1028
1641
|
var PORT_ATTEMPTS = 20;
|
|
@@ -1053,6 +1666,52 @@ function sendJson(res, status, body) {
|
|
|
1053
1666
|
});
|
|
1054
1667
|
res.end(payload);
|
|
1055
1668
|
}
|
|
1669
|
+
function isKnownAgent(value) {
|
|
1670
|
+
return typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
|
|
1671
|
+
}
|
|
1672
|
+
function isAllowedMutationHost(hostname) {
|
|
1673
|
+
const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
1674
|
+
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1")
|
|
1675
|
+
return true;
|
|
1676
|
+
if (bare.endsWith(".local"))
|
|
1677
|
+
return true;
|
|
1678
|
+
if (!net3.isIPv4(bare))
|
|
1679
|
+
return false;
|
|
1680
|
+
const [first, second] = bare.split(".").map(Number);
|
|
1681
|
+
return first === 10 || first === 172 && second !== void 0 && second >= 16 && second <= 31 || first === 192 && second === 168;
|
|
1682
|
+
}
|
|
1683
|
+
function isLoopbackAddress(address) {
|
|
1684
|
+
if (address === void 0)
|
|
1685
|
+
return false;
|
|
1686
|
+
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1" || address.startsWith("127.");
|
|
1687
|
+
}
|
|
1688
|
+
function isTrustedMutation(req) {
|
|
1689
|
+
if (!isLoopbackAddress(req.socket.remoteAddress))
|
|
1690
|
+
return false;
|
|
1691
|
+
if (typeof req.headers.host !== "string")
|
|
1692
|
+
return false;
|
|
1693
|
+
let host;
|
|
1694
|
+
try {
|
|
1695
|
+
host = new URL(`http://${req.headers.host}`);
|
|
1696
|
+
} catch {
|
|
1697
|
+
return false;
|
|
1698
|
+
}
|
|
1699
|
+
if (!isAllowedMutationHost(host.hostname))
|
|
1700
|
+
return false;
|
|
1701
|
+
const rawOrigin = req.headers.origin;
|
|
1702
|
+
if (rawOrigin === void 0)
|
|
1703
|
+
return true;
|
|
1704
|
+
try {
|
|
1705
|
+
const origin = new URL(rawOrigin);
|
|
1706
|
+
return origin.protocol === "http:" && origin.host === host.host;
|
|
1707
|
+
} catch {
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
function hasJsonBody(req) {
|
|
1712
|
+
const contentType = req.headers["content-type"];
|
|
1713
|
+
return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
|
|
1714
|
+
}
|
|
1056
1715
|
function probe(target, timeoutMs = 1e3) {
|
|
1057
1716
|
return new Promise((resolve) => {
|
|
1058
1717
|
let url;
|
|
@@ -1074,8 +1733,8 @@ function probe(target, timeoutMs = 1e3) {
|
|
|
1074
1733
|
});
|
|
1075
1734
|
}
|
|
1076
1735
|
function serveFrom(res, dir, relativePath) {
|
|
1077
|
-
const
|
|
1078
|
-
const candidate =
|
|
1736
|
+
const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
|
|
1737
|
+
const candidate = join7(dir, relative5);
|
|
1079
1738
|
if (!candidate.startsWith(dir))
|
|
1080
1739
|
return false;
|
|
1081
1740
|
if (!existsSync2(candidate) || !statSync(candidate).isFile())
|
|
@@ -1088,9 +1747,36 @@ function serveFrom(res, dir, relativePath) {
|
|
|
1088
1747
|
return true;
|
|
1089
1748
|
}
|
|
1090
1749
|
function serveShellFile(res, shellDir, urlPath) {
|
|
1091
|
-
const
|
|
1092
|
-
const isRoot =
|
|
1093
|
-
return serveFrom(res, shellDir, isRoot ? "index.html" :
|
|
1750
|
+
const relative5 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
|
|
1751
|
+
const isRoot = relative5 === "" || relative5 === "." || relative5 === "/";
|
|
1752
|
+
return serveFrom(res, shellDir, isRoot ? "index.html" : relative5);
|
|
1753
|
+
}
|
|
1754
|
+
function snapshotConfig(cwd) {
|
|
1755
|
+
const path = findConfigFile(cwd);
|
|
1756
|
+
if (path === null)
|
|
1757
|
+
return null;
|
|
1758
|
+
try {
|
|
1759
|
+
return { path, mtimeMs: statSync(path).mtimeMs };
|
|
1760
|
+
} catch {
|
|
1761
|
+
return null;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
function configStalenessNotice(cwd, boot, current) {
|
|
1765
|
+
if (boot === null && current === null)
|
|
1766
|
+
return null;
|
|
1767
|
+
if (boot === null && current !== null) {
|
|
1768
|
+
const label = relative3(cwd, current.path) || current.path;
|
|
1769
|
+
return `${label} appeared after Leglas started. Restart leglas to pick it up.`;
|
|
1770
|
+
}
|
|
1771
|
+
if (boot !== null && current === null) {
|
|
1772
|
+
const label = relative3(cwd, boot.path) || boot.path;
|
|
1773
|
+
return `${label} was removed after Leglas started. Restart leglas to run without it.`;
|
|
1774
|
+
}
|
|
1775
|
+
if (boot !== null && current !== null && (boot.path !== current.path || boot.mtimeMs !== current.mtimeMs)) {
|
|
1776
|
+
const label = relative3(cwd, current.path) || current.path;
|
|
1777
|
+
return `${label} changed after Leglas started. Restart leglas to pick it up.`;
|
|
1778
|
+
}
|
|
1779
|
+
return null;
|
|
1094
1780
|
}
|
|
1095
1781
|
var PLACEHOLDER = `<!doctype html>
|
|
1096
1782
|
<meta charset="utf-8">
|
|
@@ -1133,15 +1819,46 @@ async function bind(server, requested) {
|
|
|
1133
1819
|
throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
|
|
1134
1820
|
}
|
|
1135
1821
|
async function startServer(options) {
|
|
1136
|
-
const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map() } = options;
|
|
1822
|
+
const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
|
|
1137
1823
|
const target = config?.devServer ?? "http://localhost:3000";
|
|
1138
1824
|
const proxy = createProxyHandler({ target });
|
|
1825
|
+
const bootConfigSnapshot = snapshotConfig(cwd);
|
|
1139
1826
|
let lastSeen = null;
|
|
1827
|
+
const externallyAttached = () => lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS;
|
|
1828
|
+
let runner = null;
|
|
1829
|
+
let agentsCache = null;
|
|
1830
|
+
let agentsInflight = null;
|
|
1831
|
+
const AGENTS_FRESH_MS = 3e4;
|
|
1832
|
+
const probeAgents = () => {
|
|
1833
|
+
agentsInflight ??= detect().then((agents) => {
|
|
1834
|
+
agentsCache = { at: Date.now(), agents };
|
|
1835
|
+
return agents;
|
|
1836
|
+
}).finally(() => {
|
|
1837
|
+
agentsInflight = null;
|
|
1838
|
+
});
|
|
1839
|
+
return agentsInflight;
|
|
1840
|
+
};
|
|
1841
|
+
const currentAgents = () => {
|
|
1842
|
+
if (agentsCache === null)
|
|
1843
|
+
return probeAgents();
|
|
1844
|
+
if (Date.now() - agentsCache.at > AGENTS_FRESH_MS) {
|
|
1845
|
+
void probeAgents().catch(() => {
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
return Promise.resolve(agentsCache.agents);
|
|
1849
|
+
};
|
|
1140
1850
|
const server = http2.createServer((req, res) => {
|
|
1141
1851
|
const url = req.url ?? "/";
|
|
1142
1852
|
const path = url.split("?")[0] ?? "/";
|
|
1853
|
+
if (req.method === "POST" && path.startsWith(`${LEGLAS_PREFIX}/api/`) && !isTrustedMutation(req)) {
|
|
1854
|
+
return sendJson(res, 403, { ok: false, error: "Cross-origin API mutations are refused." });
|
|
1855
|
+
}
|
|
1143
1856
|
if (path === `${LEGLAS_PREFIX}/api/config`) {
|
|
1144
1857
|
const boot = config?.previews ?? [];
|
|
1858
|
+
const errors = [...configErrors];
|
|
1859
|
+
const notice = configStalenessNotice(cwd, bootConfigSnapshot, snapshotConfig(cwd));
|
|
1860
|
+
if (notice !== null)
|
|
1861
|
+
errors.push(notice);
|
|
1145
1862
|
return void readLocalPreviews(cwd).then(({ previews: local }) => {
|
|
1146
1863
|
const known = new Set(boot.map((preview) => preview.title));
|
|
1147
1864
|
const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
|
|
@@ -1149,13 +1866,13 @@ async function startServer(options) {
|
|
|
1149
1866
|
project,
|
|
1150
1867
|
devServer: target,
|
|
1151
1868
|
previews: [...boot, ...fresh],
|
|
1152
|
-
errors
|
|
1869
|
+
errors
|
|
1153
1870
|
});
|
|
1154
1871
|
}).catch(() => sendJson(res, 200, {
|
|
1155
1872
|
project,
|
|
1156
1873
|
devServer: target,
|
|
1157
1874
|
previews: boot,
|
|
1158
|
-
errors
|
|
1875
|
+
errors
|
|
1159
1876
|
}));
|
|
1160
1877
|
}
|
|
1161
1878
|
if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
|
|
@@ -1179,7 +1896,54 @@ async function startServer(options) {
|
|
|
1179
1896
|
url: preview.url,
|
|
1180
1897
|
intent: parsed2.intent.trim(),
|
|
1181
1898
|
...composed
|
|
1182
|
-
}).then(() =>
|
|
1899
|
+
}).then(() => {
|
|
1900
|
+
runner?.nudge();
|
|
1901
|
+
sendJson(res, 200, { ok: true, ...composed });
|
|
1902
|
+
}).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
if (path === `${LEGLAS_PREFIX}/api/agents` && req.method === "GET") {
|
|
1906
|
+
return void Promise.all([currentAgents(), readAgentChoice(cwd)]).then(([agents, choice]) => sendJson(res, 200, {
|
|
1907
|
+
agents,
|
|
1908
|
+
choice: choice.agent,
|
|
1909
|
+
customRun: choice.run
|
|
1910
|
+
}));
|
|
1911
|
+
}
|
|
1912
|
+
if (path === `${LEGLAS_PREFIX}/api/agent` && req.method === "POST") {
|
|
1913
|
+
if (!isLoopbackAddress(req.socket.remoteAddress)) {
|
|
1914
|
+
return sendJson(res, 403, {
|
|
1915
|
+
ok: false,
|
|
1916
|
+
error: "The agent choice can only be made from the machine running Leglas."
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
if (!hasJsonBody(req)) {
|
|
1920
|
+
return sendJson(res, 400, { ok: false, error: "Agent choice must be JSON." });
|
|
1921
|
+
}
|
|
1922
|
+
let body = "";
|
|
1923
|
+
req.on("data", (chunk) => body += chunk);
|
|
1924
|
+
return void req.on("end", () => {
|
|
1925
|
+
let parsed2;
|
|
1926
|
+
try {
|
|
1927
|
+
parsed2 = JSON.parse(body || "{}");
|
|
1928
|
+
} catch {
|
|
1929
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
1930
|
+
}
|
|
1931
|
+
if (!isKnownAgent(parsed2.agent) && parsed2.agent !== "custom") {
|
|
1932
|
+
return sendJson(res, 400, { ok: false, error: "Body needs a known agent." });
|
|
1933
|
+
}
|
|
1934
|
+
if (parsed2.run !== void 0 && typeof parsed2.run !== "string") {
|
|
1935
|
+
return sendJson(res, 400, { ok: false, error: "The custom run command must be a string." });
|
|
1936
|
+
}
|
|
1937
|
+
if (parsed2.agent === "custom") {
|
|
1938
|
+
if (typeof parsed2.run !== "string") {
|
|
1939
|
+
return sendJson(res, 400, { ok: false, error: "A custom agent needs a run command." });
|
|
1940
|
+
}
|
|
1941
|
+
const template = parseTemplate(parsed2.run);
|
|
1942
|
+
if (!template.ok)
|
|
1943
|
+
return sendJson(res, 400, { ok: false, error: template.error });
|
|
1944
|
+
return void saveAgentChoice(cwd, { agent: "custom", run: parsed2.run }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
|
|
1945
|
+
}
|
|
1946
|
+
return void saveAgentChoice(cwd, { agent: parsed2.agent }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
|
|
1183
1947
|
});
|
|
1184
1948
|
}
|
|
1185
1949
|
if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
|
|
@@ -1200,11 +1964,119 @@ async function startServer(options) {
|
|
|
1200
1964
|
});
|
|
1201
1965
|
}
|
|
1202
1966
|
if (path === `${LEGLAS_PREFIX}/api/requests` && req.method === "GET") {
|
|
1967
|
+
const snapshot = runner?.snapshot() ?? {
|
|
1968
|
+
running: false,
|
|
1969
|
+
requestId: null,
|
|
1970
|
+
agent: null,
|
|
1971
|
+
activity: null,
|
|
1972
|
+
startedAt: null,
|
|
1973
|
+
failedIds: []
|
|
1974
|
+
};
|
|
1203
1975
|
return void readRequests(cwd).then((requests) => sendJson(res, 200, {
|
|
1204
|
-
requests: requests.map(({ id, title, intent, status }) => ({
|
|
1205
|
-
|
|
1976
|
+
requests: requests.map(({ id, title, intent, status }) => ({
|
|
1977
|
+
id,
|
|
1978
|
+
title,
|
|
1979
|
+
intent,
|
|
1980
|
+
status: snapshot.running && snapshot.requestId === id ? "running" : snapshot.failedIds.includes(id) ? "failed" : status
|
|
1981
|
+
})),
|
|
1982
|
+
agent: {
|
|
1983
|
+
attached: externallyAttached(),
|
|
1984
|
+
running: snapshot.running,
|
|
1985
|
+
name: snapshot.running ? snapshot.agent : null,
|
|
1986
|
+
activity: snapshot.running ? snapshot.activity : null,
|
|
1987
|
+
startedAt: snapshot.running ? snapshot.startedAt : null
|
|
1988
|
+
}
|
|
1206
1989
|
}));
|
|
1207
1990
|
}
|
|
1991
|
+
if (path === `${LEGLAS_PREFIX}/api/requests/cancel` && req.method === "POST") {
|
|
1992
|
+
if (!hasJsonBody(req)) {
|
|
1993
|
+
return sendJson(res, 200, { ok: true, cancelled: runner?.cancel() ?? false });
|
|
1994
|
+
}
|
|
1995
|
+
let body = "";
|
|
1996
|
+
req.on("data", (chunk) => body += chunk);
|
|
1997
|
+
return void req.on("end", () => {
|
|
1998
|
+
let parsed2;
|
|
1999
|
+
try {
|
|
2000
|
+
parsed2 = JSON.parse(body || "{}");
|
|
2001
|
+
} catch {
|
|
2002
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
2003
|
+
}
|
|
2004
|
+
if (parsed2.id !== void 0 && typeof parsed2.id !== "string") {
|
|
2005
|
+
return sendJson(res, 400, { ok: false, error: "The request id must be a string." });
|
|
2006
|
+
}
|
|
2007
|
+
return sendJson(res, 200, { ok: true, cancelled: runner?.cancel(parsed2.id) ?? false });
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
if (path === `${LEGLAS_PREFIX}/api/requests/retry` && req.method === "POST") {
|
|
2011
|
+
if (!hasJsonBody(req)) {
|
|
2012
|
+
return sendJson(res, 400, { ok: false, error: "Retry must be JSON." });
|
|
2013
|
+
}
|
|
2014
|
+
let body = "";
|
|
2015
|
+
req.on("data", (chunk) => body += chunk);
|
|
2016
|
+
return void req.on("end", async () => {
|
|
2017
|
+
let parsed2;
|
|
2018
|
+
try {
|
|
2019
|
+
parsed2 = JSON.parse(body || "{}");
|
|
2020
|
+
} catch {
|
|
2021
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
2022
|
+
}
|
|
2023
|
+
if (typeof parsed2.id !== "string") {
|
|
2024
|
+
return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
|
|
2025
|
+
}
|
|
2026
|
+
const request = (await readRequests(cwd)).find((entry) => entry.id === parsed2.id);
|
|
2027
|
+
if (request === void 0) {
|
|
2028
|
+
return sendJson(res, 404, { ok: false, error: "No such request." });
|
|
2029
|
+
}
|
|
2030
|
+
if (!(runner?.snapshot().failedIds.includes(request.id) ?? false)) {
|
|
2031
|
+
return sendJson(res, 400, { ok: false, error: "Only a failed request can be retried." });
|
|
2032
|
+
}
|
|
2033
|
+
try {
|
|
2034
|
+
if (!await removeRequest(cwd, request.id)) {
|
|
2035
|
+
return sendJson(res, 404, { ok: false, error: "No such request." });
|
|
2036
|
+
}
|
|
2037
|
+
await appendRequest(cwd, {
|
|
2038
|
+
title: request.title,
|
|
2039
|
+
url: request.url,
|
|
2040
|
+
intent: request.intent,
|
|
2041
|
+
target: request.target,
|
|
2042
|
+
prompt: request.prompt
|
|
2043
|
+
});
|
|
2044
|
+
runner?.nudge();
|
|
2045
|
+
return sendJson(res, 200, { ok: true });
|
|
2046
|
+
} catch {
|
|
2047
|
+
return sendJson(res, 500, { ok: false, error: "The request could not be retried." });
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
|
|
2052
|
+
if (!hasJsonBody(req)) {
|
|
2053
|
+
return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
|
|
2054
|
+
}
|
|
2055
|
+
let body = "";
|
|
2056
|
+
req.on("data", (chunk) => body += chunk);
|
|
2057
|
+
return void req.on("end", async () => {
|
|
2058
|
+
let parsed2;
|
|
2059
|
+
try {
|
|
2060
|
+
parsed2 = JSON.parse(body || "{}");
|
|
2061
|
+
} catch {
|
|
2062
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
2063
|
+
}
|
|
2064
|
+
if (typeof parsed2.id !== "string") {
|
|
2065
|
+
return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
|
|
2066
|
+
}
|
|
2067
|
+
if (!(runner?.snapshot().failedIds.includes(parsed2.id) ?? false)) {
|
|
2068
|
+
return sendJson(res, 400, { ok: false, error: "Only a failed request can be dismissed." });
|
|
2069
|
+
}
|
|
2070
|
+
try {
|
|
2071
|
+
if (!await removeRequest(cwd, parsed2.id)) {
|
|
2072
|
+
return sendJson(res, 404, { ok: false, error: "No such request." });
|
|
2073
|
+
}
|
|
2074
|
+
return sendJson(res, 200, { ok: true });
|
|
2075
|
+
} catch {
|
|
2076
|
+
return sendJson(res, 500, { ok: false, error: "The request could not be dismissed." });
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
1208
2080
|
if (path === `${LEGLAS_PREFIX}/api/renames` && req.method === "POST") {
|
|
1209
2081
|
let body = "";
|
|
1210
2082
|
req.on("data", (chunk) => body += chunk);
|
|
@@ -1229,21 +2101,28 @@ async function startServer(options) {
|
|
|
1229
2101
|
const rest = path.slice(FILES_PREFIX.length + 1);
|
|
1230
2102
|
const slash = rest.indexOf("/");
|
|
1231
2103
|
const slug = slash === -1 ? rest : rest.slice(0, slash);
|
|
1232
|
-
let
|
|
2104
|
+
let relative5 = slash === -1 ? "" : rest.slice(slash + 1);
|
|
1233
2105
|
try {
|
|
1234
|
-
|
|
2106
|
+
relative5 = decodeURIComponent(relative5);
|
|
1235
2107
|
} catch {
|
|
1236
|
-
|
|
2108
|
+
relative5 = "";
|
|
1237
2109
|
}
|
|
1238
2110
|
const dir = fileMounts.get(slug);
|
|
1239
|
-
if (dir !== void 0 &&
|
|
2111
|
+
if (dir !== void 0 && relative5 !== "" && serveFrom(res, dir, relative5))
|
|
1240
2112
|
return;
|
|
1241
2113
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
1242
2114
|
return res.end("Leglas: no such preview file.");
|
|
1243
2115
|
}
|
|
2116
|
+
if (path.startsWith(`${LEGLAS_PREFIX}/api/`)) {
|
|
2117
|
+
return sendJson(res, 404, { error: "No such Leglas API path." });
|
|
2118
|
+
}
|
|
1244
2119
|
if (path === LEGLAS_PREFIX || path.startsWith(`${LEGLAS_PREFIX}/`)) {
|
|
1245
2120
|
if (shellDir !== null && serveShellFile(res, shellDir, path))
|
|
1246
2121
|
return;
|
|
2122
|
+
if (shellDir !== null) {
|
|
2123
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
2124
|
+
return res.end("Leglas: no such path.");
|
|
2125
|
+
}
|
|
1247
2126
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1248
2127
|
return res.end(PLACEHOLDER);
|
|
1249
2128
|
}
|
|
@@ -1261,16 +2140,23 @@ async function startServer(options) {
|
|
|
1261
2140
|
proxy.upgrade(req, socket, head);
|
|
1262
2141
|
});
|
|
1263
2142
|
const port = await bind(server, options.port ?? DEFAULT_PORT);
|
|
2143
|
+
runner = startRunner({ cwd, externallyAttached });
|
|
2144
|
+
let closePromise = null;
|
|
1264
2145
|
return {
|
|
1265
2146
|
port,
|
|
1266
2147
|
url: `http://localhost:${port}`,
|
|
1267
|
-
close: () =>
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
2148
|
+
close: () => {
|
|
2149
|
+
if (closePromise !== null)
|
|
2150
|
+
return closePromise;
|
|
2151
|
+
closePromise = runner.stop().then(() => new Promise((done) => {
|
|
2152
|
+
for (const socket of sockets)
|
|
2153
|
+
socket.destroy();
|
|
2154
|
+
sockets.clear();
|
|
2155
|
+
server.closeAllConnections();
|
|
2156
|
+
server.close(() => done());
|
|
2157
|
+
}));
|
|
2158
|
+
return closePromise;
|
|
2159
|
+
}
|
|
1274
2160
|
};
|
|
1275
2161
|
}
|
|
1276
2162
|
|
|
@@ -1279,7 +2165,7 @@ async function runClassify(options, deps) {
|
|
|
1279
2165
|
const declared = await Promise.all(
|
|
1280
2166
|
options.changes.map(async (change) => ({
|
|
1281
2167
|
...change,
|
|
1282
|
-
exists: await stat(
|
|
2168
|
+
exists: await stat(join8(options.cwd, change.path)).then(
|
|
1283
2169
|
() => true,
|
|
1284
2170
|
() => false
|
|
1285
2171
|
)
|
|
@@ -1515,8 +2401,8 @@ The set exists to be chosen from, and the choice only means something if the dir
|
|
|
1515
2401
|
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}".
|
|
1516
2402
|
|
|
1517
2403
|
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:
|
|
2404
|
+
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>"`;
|
|
2405
|
+
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:
|
|
1520
2406
|
|
|
1521
2407
|
${register}
|
|
1522
2408
|
|
|
@@ -1544,8 +2430,8 @@ function runExplore(options, deps) {
|
|
|
1544
2430
|
}
|
|
1545
2431
|
|
|
1546
2432
|
// src/run-init.ts
|
|
1547
|
-
import { readFile as
|
|
1548
|
-
import { join as
|
|
2433
|
+
import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
|
|
2434
|
+
import { join as join9 } from "path";
|
|
1549
2435
|
|
|
1550
2436
|
// src/init.ts
|
|
1551
2437
|
var AGENTS_MARKER_START = "<!-- leglas:start -->";
|
|
@@ -1572,46 +2458,48 @@ When asked for design variations, alternatives, or "a few options":
|
|
|
1572
2458
|
If a direction cannot be additive, because it must change dependencies,
|
|
1573
2459
|
change build configuration, or rewrite an existing file's behaviour, ask
|
|
1574
2460
|
where it should live before writing it:
|
|
1575
|
-
\`leglas classify --change package.json --rewrite src/theme.css --json\`.
|
|
2461
|
+
\`npx leglas classify --change package.json --rewrite src/theme.css --json\`.
|
|
1576
2462
|
When the answer is \`checkout\`, build the direction on its own git branch
|
|
1577
|
-
and register it with \`leglas add --title "\u2026" --url "/" --branch <branch>\`
|
|
2463
|
+
and register it with \`npx leglas add --title "\u2026" --url "/" --branch <branch>\`
|
|
1578
2464
|
(the config needs \`devCommand\` with \`{port}\`). Everything below is the
|
|
1579
2465
|
ordinary, in-app path.
|
|
1580
|
-
2. Run \`leglas explore <surface> --count <n>\` first, adding
|
|
2466
|
+
2. Run \`npx leglas explore <surface> --count <n>\` first, adding
|
|
1581
2467
|
\`--based-on "<title>"\` when the user wants variations of a direction they
|
|
1582
2468
|
already like. It prints what the set needs and how to register it. In
|
|
1583
2469
|
short: new directions must genuinely disagree with each other, variants of
|
|
1584
2470
|
one must not, and either way decide the whole set before building any of
|
|
1585
2471
|
it. The designs themselves are yours.
|
|
1586
2472
|
3. If the surface has no switcher yet, run
|
|
1587
|
-
\`leglas new <surface> --from <the component that renders it today>\`. It
|
|
2473
|
+
\`npx leglas new <surface> --from <the component that renders it today>\`. It
|
|
1588
2474
|
writes one under \`.leglas/variants/<surface>/\` and prints the single line
|
|
1589
2475
|
to add where that surface renders. \`--from\` makes the baseline re-export
|
|
1590
2476
|
the real component rather than copying it, so it stays live.
|
|
1591
|
-
4. Before building, make sure the interface is up. If
|
|
1592
|
-
already running, tell the user to run
|
|
1593
|
-
rather than when the set is done: the rail picks up each
|
|
1594
|
-
within seconds, so they get to watch the exploration fill in.
|
|
2477
|
+
4. Before building, make sure the interface is up. If Leglas is not
|
|
2478
|
+
already running, tell the user to run \`npx leglas\`, and hand them the
|
|
2479
|
+
URL now rather than when the set is done: the rail picks up each
|
|
2480
|
+
registration within seconds, so they get to watch the exploration fill in.
|
|
1595
2481
|
5. Build one direction at a time: its own file beside the others in
|
|
1596
2482
|
\`.leglas/variants/<surface>/\`, listed in the \`DIRECTIONS\` map in that
|
|
1597
2483
|
folder's \`switch\` file, then registered the moment it renders:
|
|
1598
|
-
\`leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
|
|
2484
|
+
\`npx leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
|
|
1599
2485
|
Register each direction as it lands, never the whole set at the end. To
|
|
1600
2486
|
the user watching the rail, a batch at the end is minutes of nothing and
|
|
1601
2487
|
then everything at once.
|
|
1602
2488
|
|
|
1603
|
-
When the user asks to change one direction, check \`leglas requests --json\`
|
|
2489
|
+
When the user asks to change one direction, check \`npx leglas requests --json\`
|
|
1604
2490
|
first: they may have described it from the interface, and the request names the
|
|
1605
|
-
exact file.
|
|
2491
|
+
exact file. Acknowledge them with \`npx leglas requests --clear\` once done: it
|
|
2492
|
+
drops what you collected and reports anything the user typed while you worked,
|
|
2493
|
+
which is yours to collect and do next.
|
|
1606
2494
|
|
|
1607
2495
|
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
|
|
2496
|
+
each one, tell them about \`npx leglas watch --run "claude -p {prompt}"\` (any
|
|
1609
2497
|
agent command works; {prompt} receives the request). It runs in their
|
|
1610
2498
|
terminal, hands each request to that command as it arrives, and the interface
|
|
1611
2499
|
shows the request's progress.
|
|
1612
2500
|
|
|
1613
2501
|
When the user picks a winner, run
|
|
1614
|
-
\`leglas keep "<title>" --to <path in real source>\`. It moves that direction
|
|
2502
|
+
\`npx leglas keep "<title>" --to <path in real source>\`. It moves that direction
|
|
1615
2503
|
out of the ignored directory, deletes the rest of the exploration, and drops
|
|
1616
2504
|
them from the rail. Then change their component to use the kept component
|
|
1617
2505
|
instead of the switcher.
|
|
@@ -1622,14 +2510,14 @@ Useful to know:
|
|
|
1622
2510
|
ships. Move a direction into real source only when it wins.
|
|
1623
2511
|
- If the project has no running app yet, a direction can be a plain HTML
|
|
1624
2512
|
file: write it under \`.leglas/pages/\` and register it with
|
|
1625
|
-
\`leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
|
|
2513
|
+
\`npx leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
|
|
1626
2514
|
serves the file itself, so no dev server is needed. Sibling assets in the
|
|
1627
2515
|
same directory resolve normally.
|
|
1628
2516
|
- Titles identify previews and must be unique. The user may rename one in the
|
|
1629
2517
|
rail, which renames it on their machine only; the commands answer to either
|
|
1630
2518
|
name, so use whichever they said.
|
|
1631
|
-
- \`leglas list\` shows every direction, shared and local.
|
|
1632
|
-
- \`leglas show "<title>" --json\` answers for one of them: the file behind it,
|
|
2519
|
+
- \`npx leglas list\` shows every direction, shared and local.
|
|
2520
|
+
- \`npx leglas show "<title>" --json\` answers for one of them: the file behind it,
|
|
1633
2521
|
the variants based on it, what it is being compared against, and anything
|
|
1634
2522
|
they have asked for that is not done yet. Run it when handed a direction you
|
|
1635
2523
|
did not register yourself.
|
|
@@ -1639,7 +2527,7 @@ Useful to know:
|
|
|
1639
2527
|
${AGENTS_MARKER_END}
|
|
1640
2528
|
`;
|
|
1641
2529
|
var STARTER_CONFIG = `// Previews are URLs of your own app. Add one per direction you want to
|
|
1642
|
-
// compare, then run \`leglas\`.
|
|
2530
|
+
// compare, then run \`npx leglas\`.
|
|
1643
2531
|
export default {
|
|
1644
2532
|
// Where your dev server is. Override at the command line with --user-port.
|
|
1645
2533
|
devServer: "http://localhost:3000",
|
|
@@ -1676,7 +2564,7 @@ ${AGENTS_SECTION}`
|
|
|
1676
2564
|
// src/run-init.ts
|
|
1677
2565
|
async function readIfPresent(path) {
|
|
1678
2566
|
try {
|
|
1679
|
-
return await
|
|
2567
|
+
return await readFile6(path, "utf8");
|
|
1680
2568
|
} catch {
|
|
1681
2569
|
return null;
|
|
1682
2570
|
}
|
|
@@ -1684,18 +2572,18 @@ async function readIfPresent(path) {
|
|
|
1684
2572
|
async function runInit(options, deps) {
|
|
1685
2573
|
const existingConfig = findConfigFile(options.cwd);
|
|
1686
2574
|
const plan = planInit({
|
|
1687
|
-
agents: await readIfPresent(
|
|
2575
|
+
agents: await readIfPresent(join9(options.cwd, "AGENTS.md")),
|
|
1688
2576
|
config: existingConfig === null ? null : "present",
|
|
1689
|
-
gitignore: await readIfPresent(
|
|
2577
|
+
gitignore: await readIfPresent(join9(options.cwd, ".gitignore")),
|
|
1690
2578
|
force: options.force
|
|
1691
2579
|
});
|
|
1692
2580
|
const touched = [];
|
|
1693
2581
|
for (const write of plan.writes) {
|
|
1694
|
-
await
|
|
2582
|
+
await writeFile5(join9(options.cwd, write.path), write.contents, "utf8");
|
|
1695
2583
|
touched.push(write.path);
|
|
1696
2584
|
}
|
|
1697
2585
|
if (plan.gitignore !== null) {
|
|
1698
|
-
await
|
|
2586
|
+
await writeFile5(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
1699
2587
|
touched.push(".gitignore");
|
|
1700
2588
|
}
|
|
1701
2589
|
if (options.json) {
|
|
@@ -1715,8 +2603,8 @@ async function runInit(options, deps) {
|
|
|
1715
2603
|
|
|
1716
2604
|
// src/run-keep.ts
|
|
1717
2605
|
import { existsSync as existsSync3 } from "fs";
|
|
1718
|
-
import { mkdir as
|
|
1719
|
-
import { dirname as
|
|
2606
|
+
import { mkdir as mkdir5, readFile as readFile7, rm as rm2, writeFile as writeFile6 } from "fs/promises";
|
|
2607
|
+
import { dirname as dirname6, join as join10 } from "path";
|
|
1720
2608
|
|
|
1721
2609
|
// src/keep.ts
|
|
1722
2610
|
import { basename as basename2, extname as extname2, normalize as normalize2 } from "path";
|
|
@@ -1737,7 +2625,7 @@ function planKeep(options) {
|
|
|
1737
2625
|
if (!winner) {
|
|
1738
2626
|
return {
|
|
1739
2627
|
ok: false,
|
|
1740
|
-
error: `No direction called ${JSON.stringify(options.title)}. Run leglas list to see them.`
|
|
2628
|
+
error: `No direction called ${JSON.stringify(options.title)}. Run npx leglas list to see them.`
|
|
1741
2629
|
};
|
|
1742
2630
|
}
|
|
1743
2631
|
const from = targetFor(winner.url);
|
|
@@ -1785,7 +2673,7 @@ function resolveOrExplain(input, titles, renames) {
|
|
|
1785
2673
|
}
|
|
1786
2674
|
return {
|
|
1787
2675
|
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.`
|
|
2676
|
+
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.`
|
|
1789
2677
|
};
|
|
1790
2678
|
}
|
|
1791
2679
|
|
|
@@ -1815,18 +2703,18 @@ async function runKeep(options, deps) {
|
|
|
1815
2703
|
if (!resolved.ok) return fail(resolved.error);
|
|
1816
2704
|
const plan = planKeep({ title: resolved.title, previews, to: options.to });
|
|
1817
2705
|
if (!plan.ok) return fail(plan.error);
|
|
1818
|
-
const from =
|
|
1819
|
-
const to =
|
|
2706
|
+
const from = join10(options.cwd, plan.move.from);
|
|
2707
|
+
const to = join10(options.cwd, plan.move.to);
|
|
1820
2708
|
if (!existsSync3(from)) {
|
|
1821
2709
|
return fail(`${plan.move.from} does not exist. Nothing to keep.`);
|
|
1822
2710
|
}
|
|
1823
2711
|
if (existsSync3(to)) {
|
|
1824
2712
|
return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
|
|
1825
2713
|
}
|
|
1826
|
-
const source = await
|
|
1827
|
-
await
|
|
1828
|
-
await
|
|
1829
|
-
await rm2(
|
|
2714
|
+
const source = await readFile7(from, "utf8");
|
|
2715
|
+
await mkdir5(dirname6(to), { recursive: true });
|
|
2716
|
+
await writeFile6(to, renameExport(source, plan.exportName), "utf8");
|
|
2717
|
+
await rm2(join10(options.cwd, plan.removeDir), { recursive: true, force: true });
|
|
1830
2718
|
const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
|
|
1831
2719
|
if (options.json) {
|
|
1832
2720
|
deps.log(
|
|
@@ -1861,11 +2749,11 @@ async function runKeep(options, deps) {
|
|
|
1861
2749
|
|
|
1862
2750
|
// src/run-new.ts
|
|
1863
2751
|
import { existsSync as existsSync4 } from "fs";
|
|
1864
|
-
import { mkdir as
|
|
1865
|
-
import { dirname as
|
|
2752
|
+
import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
|
|
2753
|
+
import { dirname as dirname7, join as join11 } from "path";
|
|
1866
2754
|
async function readIfPresent2(path) {
|
|
1867
2755
|
try {
|
|
1868
|
-
return await
|
|
2756
|
+
return await readFile8(path, "utf8");
|
|
1869
2757
|
} catch {
|
|
1870
2758
|
return null;
|
|
1871
2759
|
}
|
|
@@ -1873,7 +2761,7 @@ async function readIfPresent2(path) {
|
|
|
1873
2761
|
async function runNew(options, deps) {
|
|
1874
2762
|
let from;
|
|
1875
2763
|
if (options.from !== void 0) {
|
|
1876
|
-
const contents = await readIfPresent2(
|
|
2764
|
+
const contents = await readIfPresent2(join11(options.cwd, options.from));
|
|
1877
2765
|
if (contents === null) {
|
|
1878
2766
|
const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
|
|
1879
2767
|
if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
|
|
@@ -1884,8 +2772,8 @@ async function runNew(options, deps) {
|
|
|
1884
2772
|
}
|
|
1885
2773
|
const plan = planNew({
|
|
1886
2774
|
surface: options.surface,
|
|
1887
|
-
packageJson: await readIfPresent2(
|
|
1888
|
-
gitignore: await readIfPresent2(
|
|
2775
|
+
packageJson: await readIfPresent2(join11(options.cwd, "package.json")),
|
|
2776
|
+
gitignore: await readIfPresent2(join11(options.cwd, ".gitignore")),
|
|
1889
2777
|
from
|
|
1890
2778
|
});
|
|
1891
2779
|
const fail = (error) => {
|
|
@@ -1908,19 +2796,19 @@ async function runNew(options, deps) {
|
|
|
1908
2796
|
deps.log(plan.instructions);
|
|
1909
2797
|
return { exitCode: 0, written: [] };
|
|
1910
2798
|
}
|
|
1911
|
-
const existing = plan.writes.filter((write) => existsSync4(
|
|
2799
|
+
const existing = plan.writes.filter((write) => existsSync4(join11(options.cwd, write.path)));
|
|
1912
2800
|
if (existing.length > 0) {
|
|
1913
2801
|
return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
|
|
1914
2802
|
}
|
|
1915
2803
|
const written = [];
|
|
1916
2804
|
for (const write of plan.writes) {
|
|
1917
|
-
const target =
|
|
1918
|
-
await
|
|
1919
|
-
await
|
|
2805
|
+
const target = join11(options.cwd, write.path);
|
|
2806
|
+
await mkdir6(dirname7(target), { recursive: true });
|
|
2807
|
+
await writeFile7(target, write.contents, "utf8");
|
|
1920
2808
|
written.push(write.path);
|
|
1921
2809
|
}
|
|
1922
2810
|
if (plan.gitignore !== null) {
|
|
1923
|
-
await
|
|
2811
|
+
await writeFile7(join11(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
1924
2812
|
written.push(".gitignore");
|
|
1925
2813
|
}
|
|
1926
2814
|
if (options.json) {
|
|
@@ -1935,27 +2823,27 @@ async function runNew(options, deps) {
|
|
|
1935
2823
|
deps.log("Then register them so they appear in the interface:");
|
|
1936
2824
|
deps.log("");
|
|
1937
2825
|
for (const preview of plan.previews) {
|
|
1938
|
-
deps.log(` leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
|
|
2826
|
+
deps.log(` npx leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
|
|
1939
2827
|
}
|
|
1940
2828
|
return { exitCode: 0, written };
|
|
1941
2829
|
}
|
|
1942
2830
|
|
|
1943
2831
|
// src/run-previews.ts
|
|
1944
|
-
import { readFile as
|
|
1945
|
-
import { join as
|
|
2832
|
+
import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
|
|
2833
|
+
import { join as join12 } from "path";
|
|
1946
2834
|
function envelope(deps, ok, body) {
|
|
1947
2835
|
deps.log(JSON.stringify({ ok, ...body }));
|
|
1948
2836
|
}
|
|
1949
2837
|
async function ensureIgnored(cwd) {
|
|
1950
|
-
const path =
|
|
2838
|
+
const path = join12(cwd, ".gitignore");
|
|
1951
2839
|
let current = null;
|
|
1952
2840
|
try {
|
|
1953
|
-
current = await
|
|
2841
|
+
current = await readFile9(path, "utf8");
|
|
1954
2842
|
} catch {
|
|
1955
2843
|
current = null;
|
|
1956
2844
|
}
|
|
1957
2845
|
const next = ignoreEntry(current);
|
|
1958
|
-
if (next !== null) await
|
|
2846
|
+
if (next !== null) await writeFile8(path, next, "utf8");
|
|
1959
2847
|
}
|
|
1960
2848
|
async function runAdd(options, deps) {
|
|
1961
2849
|
const loaded = await loadConfig(options.cwd);
|
|
@@ -1964,7 +2852,7 @@ async function runAdd(options, deps) {
|
|
|
1964
2852
|
const local = await readLocalPreviews(options.cwd);
|
|
1965
2853
|
const titles = new Set([...shared, ...local.previews].map((preview) => preview.title));
|
|
1966
2854
|
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.`;
|
|
2855
|
+
const error = `--based-on names ${JSON.stringify(options.preview.basedOn)}, which is not a registered direction. npx leglas list shows what exists.`;
|
|
1968
2856
|
if (options.json) envelope(deps, false, { error });
|
|
1969
2857
|
else deps.error(error);
|
|
1970
2858
|
return { exitCode: 1 };
|
|
@@ -1997,6 +2885,7 @@ async function runAdd(options, deps) {
|
|
|
1997
2885
|
local: true,
|
|
1998
2886
|
...options.preview.branch === void 0 ? {} : { branch: options.preview.branch },
|
|
1999
2887
|
...options.preview.file === void 0 ? {} : { file: options.preview.file },
|
|
2888
|
+
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.",
|
|
2000
2889
|
...needsDevCommand ? { warning: "The config sets no devCommand, so Leglas cannot start this branch yet. Add devCommand (with {port}) to the config." } : {}
|
|
2001
2890
|
});
|
|
2002
2891
|
} else {
|
|
@@ -2012,7 +2901,7 @@ async function runAdd(options, deps) {
|
|
|
2012
2901
|
if (options.preview.branch === void 0 && options.preview.file === void 0) {
|
|
2013
2902
|
deps.log("Local to this machine. A running interface picks it up within seconds.");
|
|
2014
2903
|
} else {
|
|
2015
|
-
deps.log("Local to this machine. Restart Leglas to see it, or run leglas list.");
|
|
2904
|
+
deps.log("Local to this machine. Restart Leglas to see it, or run npx leglas list.");
|
|
2016
2905
|
}
|
|
2017
2906
|
}
|
|
2018
2907
|
return { exitCode: 0 };
|
|
@@ -2047,7 +2936,7 @@ async function runList(options, deps) {
|
|
|
2047
2936
|
return { exitCode: errors.length === 0 ? 0 : 1 };
|
|
2048
2937
|
}
|
|
2049
2938
|
if (previews.length === 0) {
|
|
2050
|
-
deps.log("No previews yet. Add one with leglas add, or list them in leglas.config.ts.");
|
|
2939
|
+
deps.log("No previews yet. Add one with npx leglas add, or list them in leglas.config.ts.");
|
|
2051
2940
|
} else {
|
|
2052
2941
|
const width = Math.max(...previews.map((preview) => preview.title.length));
|
|
2053
2942
|
for (const preview of previews) {
|
|
@@ -2062,9 +2951,16 @@ async function runList(options, deps) {
|
|
|
2062
2951
|
}
|
|
2063
2952
|
async function runRequests(options, deps) {
|
|
2064
2953
|
if (options.clear) {
|
|
2065
|
-
await clearRequests(options.cwd);
|
|
2066
|
-
if (options.json) envelope(deps, true, { cleared
|
|
2067
|
-
else
|
|
2954
|
+
const { cleared, pending } = await clearRequests(options.cwd);
|
|
2955
|
+
if (options.json) envelope(deps, true, { cleared, pending });
|
|
2956
|
+
else {
|
|
2957
|
+
deps.log(cleared === 1 ? "Cleared 1 request." : `Cleared ${cleared} requests.`);
|
|
2958
|
+
if (pending > 0) {
|
|
2959
|
+
deps.log(
|
|
2960
|
+
`${pending} arrived while you worked. Run npx leglas requests to collect ${pending === 1 ? "it" : "them"}.`
|
|
2961
|
+
);
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2068
2964
|
return { exitCode: 0 };
|
|
2069
2965
|
}
|
|
2070
2966
|
const requests = await collectRequests(options.cwd);
|
|
@@ -2081,7 +2977,7 @@ async function runRequests(options, deps) {
|
|
|
2081
2977
|
if (request.target !== null) deps.log(` ${request.target}`);
|
|
2082
2978
|
}
|
|
2083
2979
|
deps.log("");
|
|
2084
|
-
deps.log("Run leglas requests --json to get the full prompts, then --clear when done.");
|
|
2980
|
+
deps.log("Run npx leglas requests --json to get the full prompts, then --clear when done.");
|
|
2085
2981
|
return { exitCode: 0 };
|
|
2086
2982
|
}
|
|
2087
2983
|
|
|
@@ -2107,7 +3003,7 @@ function planShow({ title, previews, requests }) {
|
|
|
2107
3003
|
if (!found) {
|
|
2108
3004
|
return {
|
|
2109
3005
|
ok: false,
|
|
2110
|
-
error: `No direction called ${JSON.stringify(title)}. Run leglas list to see them.`
|
|
3006
|
+
error: `No direction called ${JSON.stringify(title)}. Run npx leglas list to see them.`
|
|
2111
3007
|
};
|
|
2112
3008
|
}
|
|
2113
3009
|
const variants = previews.filter((preview) => preview.basedOn === title).map(describe);
|
|
@@ -2186,106 +3082,30 @@ async function runShow(options, deps) {
|
|
|
2186
3082
|
deps.log(` Pending, not yet done (${plan.requests.length}):`);
|
|
2187
3083
|
for (const request of plan.requests) deps.log(` ${request.status} ${request.intent}`);
|
|
2188
3084
|
deps.log("");
|
|
2189
|
-
deps.log(" Run leglas requests --json for the full prompts.");
|
|
3085
|
+
deps.log(" Run npx leglas requests --json for the full prompts.");
|
|
2190
3086
|
}
|
|
2191
3087
|
return { exitCode: 0 };
|
|
2192
3088
|
}
|
|
2193
3089
|
|
|
2194
3090
|
// src/run-watch.ts
|
|
2195
|
-
import { spawn as
|
|
2196
|
-
import { mkdir as
|
|
2197
|
-
import { dirname as
|
|
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;
|
|
3091
|
+
import { spawn as spawn3 } from "child_process";
|
|
3092
|
+
import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile9 } from "fs/promises";
|
|
3093
|
+
import { dirname as dirname8, join as join13 } from "path";
|
|
3094
|
+
var POLL_MS2 = 2e3;
|
|
2275
3095
|
var HEARTBEAT_TIMEOUT_MS = 1e3;
|
|
2276
|
-
async function
|
|
3096
|
+
async function saveTemplate(cwd, run3) {
|
|
3097
|
+
const path = join13(cwd, WATCH_PATH);
|
|
3098
|
+
let config = {};
|
|
2277
3099
|
try {
|
|
2278
|
-
const
|
|
2279
|
-
|
|
2280
|
-
|
|
3100
|
+
const parsed2 = JSON.parse(await readFile10(path, "utf8"));
|
|
3101
|
+
if (typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2)) {
|
|
3102
|
+
config = parsed2;
|
|
3103
|
+
}
|
|
2281
3104
|
} catch {
|
|
2282
|
-
return null;
|
|
2283
3105
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
await mkdir6(dirname7(path), { recursive: true });
|
|
2288
|
-
await writeFile8(path, `${JSON.stringify({ run: run3 }, null, 2)}
|
|
3106
|
+
config.run = run3;
|
|
3107
|
+
await mkdir7(dirname8(path), { recursive: true });
|
|
3108
|
+
await writeFile9(path, `${JSON.stringify(config, null, 2)}
|
|
2289
3109
|
`, "utf8");
|
|
2290
3110
|
}
|
|
2291
3111
|
function spawnAgent(command, args, cwd) {
|
|
@@ -2296,7 +3116,7 @@ function spawnAgent(command, args, cwd) {
|
|
|
2296
3116
|
settled = true;
|
|
2297
3117
|
resolve(outcome);
|
|
2298
3118
|
};
|
|
2299
|
-
const child =
|
|
3119
|
+
const child = spawn3(command, args, { cwd, stdio: "inherit" });
|
|
2300
3120
|
child.on("error", (error) => settle({ ok: false, error: error.message }));
|
|
2301
3121
|
child.on(
|
|
2302
3122
|
"close",
|
|
@@ -2307,21 +3127,34 @@ function spawnAgent(command, args, cwd) {
|
|
|
2307
3127
|
});
|
|
2308
3128
|
}
|
|
2309
3129
|
async function runWatch(options, deps) {
|
|
2310
|
-
const saved = options.run === void 0 ? await
|
|
2311
|
-
const raw = options.run ?? saved;
|
|
2312
|
-
|
|
3130
|
+
const saved = options.run === void 0 ? await readAgentChoice(options.cwd) : { agent: null, run: null };
|
|
3131
|
+
const raw = options.run ?? saved.run;
|
|
3132
|
+
let template;
|
|
3133
|
+
let shownCommand2;
|
|
3134
|
+
let synthesizedAgent = null;
|
|
3135
|
+
if (raw !== null) {
|
|
3136
|
+
const parsed2 = parseTemplate(raw);
|
|
3137
|
+
if (!parsed2.ok) {
|
|
3138
|
+
deps.error(parsed2.error);
|
|
3139
|
+
return { exitCode: 1 };
|
|
3140
|
+
}
|
|
3141
|
+
template = parsed2.template;
|
|
3142
|
+
shownCommand2 = raw;
|
|
3143
|
+
} else if (saved.agent !== null && saved.agent !== "custom") {
|
|
3144
|
+
const adapter = KNOWN_AGENTS[saved.agent];
|
|
3145
|
+
template = {
|
|
3146
|
+
command: adapter.binary,
|
|
3147
|
+
args: adapter.terminalArgs(PROMPT_TOKEN)
|
|
3148
|
+
};
|
|
3149
|
+
shownCommand2 = [template.command, ...template.args].join(" ");
|
|
3150
|
+
synthesizedAgent = adapter.name;
|
|
3151
|
+
} else {
|
|
2313
3152
|
deps.error(
|
|
2314
|
-
'Watch needs an agent command the first time:
|
|
3153
|
+
'Watch needs an agent command the first time: pick an agent in the interface, or pass --run "claude -p {prompt}".'
|
|
2315
3154
|
);
|
|
2316
3155
|
return { exitCode: 1 };
|
|
2317
3156
|
}
|
|
2318
|
-
|
|
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(() => {
|
|
3157
|
+
if (options.run !== void 0) await saveTemplate(options.cwd, options.run).catch(() => {
|
|
2325
3158
|
});
|
|
2326
3159
|
const base = `http://localhost:${options.port ?? DEFAULT_PORT}`;
|
|
2327
3160
|
const heartbeat = async (watching) => {
|
|
@@ -2335,11 +3168,15 @@ async function runWatch(options, deps) {
|
|
|
2335
3168
|
} catch {
|
|
2336
3169
|
}
|
|
2337
3170
|
};
|
|
2338
|
-
|
|
3171
|
+
if (synthesizedAgent !== null) {
|
|
3172
|
+
deps.log(`Using ${synthesizedAgent}, chosen in the interface.`);
|
|
3173
|
+
}
|
|
3174
|
+
deps.log(`Watching for change requests. Each one runs: ${shownCommand2}`);
|
|
2339
3175
|
deps.log("Stop with Ctrl-C.");
|
|
2340
3176
|
const failed = /* @__PURE__ */ new Set();
|
|
2341
3177
|
let stopped = false;
|
|
2342
3178
|
let busy = false;
|
|
3179
|
+
let announced = false;
|
|
2343
3180
|
let inflight = null;
|
|
2344
3181
|
const handle = async (request) => {
|
|
2345
3182
|
deps.log("");
|
|
@@ -2361,7 +3198,12 @@ async function runWatch(options, deps) {
|
|
|
2361
3198
|
};
|
|
2362
3199
|
const tick = async () => {
|
|
2363
3200
|
if (stopped) return;
|
|
2364
|
-
|
|
3201
|
+
if (announced) {
|
|
3202
|
+
void heartbeat(true);
|
|
3203
|
+
} else {
|
|
3204
|
+
await heartbeat(true);
|
|
3205
|
+
announced = true;
|
|
3206
|
+
}
|
|
2365
3207
|
if (busy) return;
|
|
2366
3208
|
busy = true;
|
|
2367
3209
|
try {
|
|
@@ -2378,7 +3220,7 @@ async function runWatch(options, deps) {
|
|
|
2378
3220
|
}
|
|
2379
3221
|
};
|
|
2380
3222
|
return new Promise((resolve) => {
|
|
2381
|
-
const timer = setInterval(() => void tick(),
|
|
3223
|
+
const timer = setInterval(() => void tick(), POLL_MS2);
|
|
2382
3224
|
const stop = () => {
|
|
2383
3225
|
if (stopped) return;
|
|
2384
3226
|
stopped = true;
|
|
@@ -2398,14 +3240,14 @@ async function runWatch(options, deps) {
|
|
|
2398
3240
|
// src/run.ts
|
|
2399
3241
|
import { existsSync as existsSync5 } from "fs";
|
|
2400
3242
|
import { createRequire } from "module";
|
|
2401
|
-
import { basename as basename3, dirname as
|
|
3243
|
+
import { basename as basename3, dirname as dirname9, join as join14, relative as relative4 } from "path";
|
|
2402
3244
|
import { fileURLToPath } from "url";
|
|
2403
3245
|
function findShellDir() {
|
|
2404
|
-
const bundled =
|
|
2405
|
-
if (existsSync5(
|
|
3246
|
+
const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
|
|
3247
|
+
if (existsSync5(join14(bundled, "index.html"))) return bundled;
|
|
2406
3248
|
try {
|
|
2407
3249
|
const require2 = createRequire(import.meta.url);
|
|
2408
|
-
return
|
|
3250
|
+
return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
|
|
2409
3251
|
} catch {
|
|
2410
3252
|
return null;
|
|
2411
3253
|
}
|
|
@@ -2439,7 +3281,7 @@ async function run2(options, deps) {
|
|
|
2439
3281
|
const fileMounts = /* @__PURE__ */ new Map();
|
|
2440
3282
|
for (const preview of merged?.previews ?? []) {
|
|
2441
3283
|
if (preview.file !== void 0) {
|
|
2442
|
-
const absolute =
|
|
3284
|
+
const absolute = join14(options.cwd, preview.file);
|
|
2443
3285
|
if (!existsSync5(absolute)) {
|
|
2444
3286
|
worktreeErrors.push(
|
|
2445
3287
|
`"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
|
|
@@ -2450,7 +3292,7 @@ async function run2(options, deps) {
|
|
|
2450
3292
|
for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
|
|
2451
3293
|
slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
|
|
2452
3294
|
}
|
|
2453
|
-
fileMounts.set(slug,
|
|
3295
|
+
fileMounts.set(slug, dirname9(absolute));
|
|
2454
3296
|
previews.push({
|
|
2455
3297
|
...preview,
|
|
2456
3298
|
url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
|
|
@@ -2511,7 +3353,7 @@ async function run2(options, deps) {
|
|
|
2511
3353
|
})
|
|
2512
3354
|
);
|
|
2513
3355
|
} else {
|
|
2514
|
-
const configLabel = loaded.path === null ? "no config file, previewing the app root" :
|
|
3356
|
+
const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative4(options.cwd, loaded.path) || loaded.path;
|
|
2515
3357
|
deps.log(`Leglas ${url}`);
|
|
2516
3358
|
deps.log(
|
|
2517
3359
|
`app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
|
|
@@ -2605,7 +3447,7 @@ function version() {
|
|
|
2605
3447
|
async function openBrowser(url) {
|
|
2606
3448
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2607
3449
|
try {
|
|
2608
|
-
|
|
3450
|
+
spawn4(command, [url], { detached: true, stdio: "ignore" }).unref();
|
|
2609
3451
|
} catch {
|
|
2610
3452
|
}
|
|
2611
3453
|
}
|