portable-agent-layer 0.64.0 → 0.65.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 +1 -1
- package/assets/skills/consulting-report/tools/generate-pdf.mjs +2 -2
- package/assets/skills/consulting-report/tools/generate-pdf.ts +5 -2
- package/assets/skills/playwright/SKILL.md +2 -2
- package/assets/skills/playwright/tools/shot.ts +6 -7
- package/assets/skills/projects/SKILL.md +4 -1
- package/assets/templates/settings.claude.json +2 -1
- package/package.json +15 -4
- package/src/cli/index.ts +93 -7
- package/src/cli/migrate.ts +69 -3
- package/src/hooks/lib/anchor.ts +90 -0
- package/src/hooks/lib/bindings.ts +117 -0
- package/src/hooks/lib/export.ts +38 -1
- package/src/hooks/lib/import-merge.ts +220 -0
- package/src/hooks/lib/inference.ts +113 -72
- package/src/hooks/lib/machine.ts +176 -0
- package/src/hooks/lib/projects.ts +223 -15
- package/src/hooks/lib/relationship.ts +3 -1
- package/src/hooks/lib/remote.ts +58 -0
- package/src/hooks/lib/retrieval.ts +8 -2
- package/src/hooks/lib/signals.ts +2 -1
- package/src/hooks/lib/stop.ts +5 -2
- package/src/targets/lib.ts +79 -34
- package/src/tools/agent/algorithm-reflect.ts +45 -11
- package/src/tools/agent/project.ts +148 -23
- package/src/tools/agent/thread.ts +7 -2
- package/assets/skills/playwright/tools/shot-lib.mjs +0 -44
- package/assets/skills/playwright/tools/shot.mjs +0 -89
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { resolve } from "node:path";
|
|
26
26
|
import { parseArgs } from "node:util";
|
|
27
|
+
import { writeBinding } from "../../hooks/lib/bindings";
|
|
27
28
|
import { paths } from "../../hooks/lib/paths";
|
|
28
29
|
import {
|
|
29
30
|
defaultSlug,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
legacyJsonToProgress,
|
|
33
34
|
type ProjectProgress,
|
|
34
35
|
type ProjectStatus,
|
|
36
|
+
proposeBinding,
|
|
35
37
|
readAllProjects,
|
|
36
38
|
readProject,
|
|
37
39
|
writeProject,
|
|
@@ -132,14 +134,28 @@ function cmdResume(args: string[]): void {
|
|
|
132
134
|
if (!name) fail("Usage: resume <name>");
|
|
133
135
|
const { criteria, changelog, ...project } = requireProject(name);
|
|
134
136
|
const iscs = parseIscs(criteria ?? "");
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
+
const archived = parseIscs(changelog ?? "");
|
|
138
|
+
const openIscs = iscs.filter((i) => i.status === "open");
|
|
139
|
+
const all = [...iscs, ...archived];
|
|
140
|
+
const done = all.filter((i) => i.status === "done").length;
|
|
141
|
+
const retired = all.filter((i) => i.status === "retired").length;
|
|
142
|
+
// Resuming a project PAL cannot locate is the natural moment to offer a
|
|
143
|
+
// binding: the user just named this project, so the suggestion is wanted rather
|
|
144
|
+
// than volunteered. It is only ever a command — nothing binds on its own.
|
|
145
|
+
const unlocatable = !project.path || !existsSync(project.path);
|
|
146
|
+
const binding = unlocatable
|
|
147
|
+
? proposeBinding({ ...project, criteria, changelog })
|
|
148
|
+
: null;
|
|
149
|
+
|
|
137
150
|
ok({
|
|
138
151
|
project: {
|
|
139
152
|
...project,
|
|
140
153
|
open_iscs: openIscs.map((i) => ({ id: i.id, title: iscTitle(i.text) })),
|
|
141
|
-
isc_summary: { open: openIscs.length, done },
|
|
154
|
+
isc_summary: { open: openIscs.length, done, retired },
|
|
142
155
|
},
|
|
156
|
+
...(unlocatable
|
|
157
|
+
? { binding: binding ?? { state: "unbound", confidence: "none" } }
|
|
158
|
+
: {}),
|
|
143
159
|
});
|
|
144
160
|
}
|
|
145
161
|
|
|
@@ -206,12 +222,15 @@ function addHandoff(name: string, text: string): void {
|
|
|
206
222
|
|
|
207
223
|
// ── set-path ──────────────────────────────────────────────────────
|
|
208
224
|
|
|
225
|
+
// Where a project lives is machine-local, so this writes a binding rather than a
|
|
226
|
+
// field on the record. Unlike the save path it does not require the directory to
|
|
227
|
+
// exist yet: naming where a repo is about to be cloned is a legitimate use.
|
|
209
228
|
function cmdSetPath(args: string[]): void {
|
|
210
229
|
const [name, ...rest] = args;
|
|
211
230
|
if (!name || rest.length === 0) fail("Usage: set-path <name> <new-path>");
|
|
212
231
|
const newPath = resolve(rest.join(" ").trim());
|
|
213
232
|
const p = requireProject(name);
|
|
214
|
-
p.
|
|
233
|
+
writeBinding(p.name, newPath);
|
|
215
234
|
p.updated = now();
|
|
216
235
|
writeProject(p);
|
|
217
236
|
ok({ updated: true, name, path: newPath });
|
|
@@ -350,17 +369,34 @@ function cmdRm(args: string[]): void {
|
|
|
350
369
|
|
|
351
370
|
// ── ISC helpers ──────────────────────────────────────────────────
|
|
352
371
|
|
|
372
|
+
// Three states, not two: a retired ISC is one that stopped being valid, which the
|
|
373
|
+
// record must not report as completed work. The box character is the storage form
|
|
374
|
+
// and the id stays in it, so a retired line keeps reserving its id in nextIscId.
|
|
375
|
+
type IscStatus = "open" | "done" | "retired";
|
|
376
|
+
|
|
377
|
+
const ISC_BOX: Record<IscStatus, string> = {
|
|
378
|
+
open: "[ ]",
|
|
379
|
+
done: "[x]",
|
|
380
|
+
retired: "[~]",
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
function statusFromBox(box: string): IscStatus {
|
|
384
|
+
if (box.toLowerCase() === "x") return "done";
|
|
385
|
+
if (box === "~") return "retired";
|
|
386
|
+
return "open";
|
|
387
|
+
}
|
|
388
|
+
|
|
353
389
|
interface Isc {
|
|
354
390
|
id: number;
|
|
355
391
|
text: string;
|
|
356
|
-
|
|
392
|
+
status: IscStatus;
|
|
357
393
|
}
|
|
358
394
|
|
|
359
395
|
function parseIscs(criteria: string): Isc[] {
|
|
360
396
|
const out: Isc[] = [];
|
|
361
397
|
for (const line of criteria.split("\n")) {
|
|
362
|
-
const m = new RegExp(/^-\s+\[( |x)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
|
|
363
|
-
if (m) out.push({ id: Number(m[2]), text: m[3].trim(),
|
|
398
|
+
const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
|
|
399
|
+
if (m) out.push({ id: Number(m[2]), text: m[3].trim(), status: statusFromBox(m[1]) });
|
|
364
400
|
}
|
|
365
401
|
return out;
|
|
366
402
|
}
|
|
@@ -387,7 +423,7 @@ function removeIscLine(
|
|
|
387
423
|
): { line: string | null; rest: string } {
|
|
388
424
|
const lines = section.split("\n");
|
|
389
425
|
const idx = lines.findIndex((l) =>
|
|
390
|
-
new RegExp(String.raw`^-\s+\[[ x]\]\s+ISC-${id}:`).test(l)
|
|
426
|
+
new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`).test(l)
|
|
391
427
|
);
|
|
392
428
|
if (idx === -1) return { line: null, rest: section };
|
|
393
429
|
const [line] = lines.splice(idx, 1);
|
|
@@ -417,8 +453,12 @@ function dropEmptyArchiveHeadings(changelog: string): string {
|
|
|
417
453
|
.trim();
|
|
418
454
|
}
|
|
419
455
|
|
|
420
|
-
function archiveLine(
|
|
421
|
-
|
|
456
|
+
function archiveLine(
|
|
457
|
+
changelog: string | undefined,
|
|
458
|
+
doneLine: string,
|
|
459
|
+
kind: "Archived" | "Retired" = "Archived"
|
|
460
|
+
): string {
|
|
461
|
+
const heading = `### ${kind} ${new Date().toISOString().slice(0, 10)}`;
|
|
422
462
|
const base = (changelog ?? "").trim();
|
|
423
463
|
if (base.includes(heading)) return `${base}\n${doneLine}`;
|
|
424
464
|
return base ? `${base}\n\n${heading}\n${doneLine}` : `${heading}\n${doneLine}`;
|
|
@@ -472,7 +512,7 @@ function cmdReopenIsc(args: string[]): void {
|
|
|
472
512
|
const id = Number(args[1] ?? fail("Usage: reopen-isc <name> <id>"));
|
|
473
513
|
if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
|
|
474
514
|
const p = requireProject(name);
|
|
475
|
-
if (parseIscs(p.criteria ?? "").some((i) => i.id === id &&
|
|
515
|
+
if (parseIscs(p.criteria ?? "").some((i) => i.id === id && i.status === "open")) {
|
|
476
516
|
ok({ checked: false, id, alreadyOpen: true });
|
|
477
517
|
return;
|
|
478
518
|
}
|
|
@@ -484,16 +524,17 @@ function cmdReopenIsc(args: string[]): void {
|
|
|
484
524
|
if (removed.line) p.criteria = removed.rest;
|
|
485
525
|
}
|
|
486
526
|
if (!removed.line) fail(`ISC-${id} not found in project "${name}"`);
|
|
487
|
-
const openLine = removed.line.replace(/\[x\]/i, "[ ]");
|
|
527
|
+
const openLine = removed.line.replace(/\[[x~]\]/i, "[ ]");
|
|
488
528
|
p.criteria = p.criteria ? `${p.criteria.trimEnd()}\n${openLine}` : openLine;
|
|
489
529
|
p.updated = now();
|
|
490
530
|
writeProject(p);
|
|
491
531
|
ok({ checked: false, id });
|
|
492
532
|
}
|
|
493
533
|
|
|
494
|
-
function selectIscs(open: Isc[], done: Isc[], flags: Set<string>): Isc[] {
|
|
495
|
-
if (flags.has("--all")) return [...open, ...done];
|
|
534
|
+
function selectIscs(open: Isc[], done: Isc[], retired: Isc[], flags: Set<string>): Isc[] {
|
|
535
|
+
if (flags.has("--all")) return [...open, ...done, ...retired];
|
|
496
536
|
if (flags.has("--closed")) return done;
|
|
537
|
+
if (flags.has("--retired")) return retired;
|
|
497
538
|
return open;
|
|
498
539
|
}
|
|
499
540
|
|
|
@@ -501,17 +542,20 @@ function cmdListIsc(args: string[]): void {
|
|
|
501
542
|
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
502
543
|
const name =
|
|
503
544
|
args.find((a) => !a.startsWith("--")) ??
|
|
504
|
-
fail("Usage: list-isc <name> [--all | --closed]");
|
|
545
|
+
fail("Usage: list-isc <name> [--all | --closed | --retired]");
|
|
505
546
|
const p = requireProject(name);
|
|
506
547
|
const criteria = parseIscs(p.criteria ?? "");
|
|
507
|
-
const
|
|
508
|
-
const
|
|
548
|
+
const all = [...criteria, ...parseIscs(p.changelog ?? "")];
|
|
549
|
+
const open = all.filter((i) => i.status === "open");
|
|
550
|
+
const done = all.filter((i) => i.status === "done");
|
|
551
|
+
const retired = all.filter((i) => i.status === "retired");
|
|
509
552
|
ok({
|
|
510
553
|
name,
|
|
511
|
-
total: open.length + done.length,
|
|
554
|
+
total: open.length + done.length + retired.length,
|
|
512
555
|
open: open.length,
|
|
513
556
|
done: done.length,
|
|
514
|
-
|
|
557
|
+
retired: retired.length,
|
|
558
|
+
iscs: selectIscs(open, done, retired, flags),
|
|
515
559
|
});
|
|
516
560
|
}
|
|
517
561
|
|
|
@@ -526,7 +570,80 @@ function cmdShowIsc(args: string[]): void {
|
|
|
526
570
|
(i) => i.id === id
|
|
527
571
|
);
|
|
528
572
|
if (!isc) fail(`ISC-${id} not found in project "${name}".`);
|
|
529
|
-
ok({ name, id: isc.id, status: isc.
|
|
573
|
+
ok({ name, id: isc.id, status: isc.status, text: isc.text });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// retire-isc closes an ISC that stopped being valid, which complete-isc cannot say:
|
|
577
|
+
// completing files it as done work. The line moves to the Changelog under its own
|
|
578
|
+
// heading as [~], so it still reserves its id and never reads as finished.
|
|
579
|
+
function cmdRetireIsc(args: string[]): void {
|
|
580
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
581
|
+
const name = positional[0];
|
|
582
|
+
const id = Number(positional[1]);
|
|
583
|
+
if (!name || !Number.isInteger(id) || id < 1) {
|
|
584
|
+
fail("Usage: retire-isc <name> <id> [--by <supersedingId>]");
|
|
585
|
+
}
|
|
586
|
+
const byIndex = args.indexOf("--by");
|
|
587
|
+
const by = byIndex === -1 ? null : Number(args[byIndex + 1]);
|
|
588
|
+
if (byIndex !== -1 && (!Number.isInteger(by) || (by ?? 0) < 1)) {
|
|
589
|
+
fail("--by expects a positive ISC id");
|
|
590
|
+
}
|
|
591
|
+
const p = requireProject(name);
|
|
592
|
+
if (parseIscs(p.changelog ?? "").some((i) => i.id === id && i.status === "retired")) {
|
|
593
|
+
ok({ retired: true, id, alreadyRetired: true });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const { line, rest } = removeIscLine(p.criteria ?? "", id);
|
|
597
|
+
if (!line) fail(`ISC-${id} not found in project "${name}"`);
|
|
598
|
+
const suffix = by ? ` (superseded by ISC-${by})` : "";
|
|
599
|
+
p.criteria = rest;
|
|
600
|
+
p.changelog = archiveLine(
|
|
601
|
+
p.changelog,
|
|
602
|
+
`${line.replace(/\[[ x]\]/i, "[~]")}${suffix}`,
|
|
603
|
+
"Retired"
|
|
604
|
+
);
|
|
605
|
+
p.updated = now();
|
|
606
|
+
writeProject(p);
|
|
607
|
+
ok({ retired: true, id, supersededBy: by, archived: true });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// edit-isc rewrites one ISC's text in place, keeping its id and open/done state.
|
|
611
|
+
// The id never leaves the record, so nextIscId still reserves it. Returns the
|
|
612
|
+
// previous text because the ISA files carry no version history of their own.
|
|
613
|
+
function cmdEditIsc(args: string[]): void {
|
|
614
|
+
const name = args[0];
|
|
615
|
+
const id = Number(args[1]);
|
|
616
|
+
const text = args.slice(2).join(" ").trim();
|
|
617
|
+
if (!name || !Number.isInteger(id) || id < 1 || !text) {
|
|
618
|
+
fail('Usage: edit-isc <name> <id> "new text"');
|
|
619
|
+
}
|
|
620
|
+
const p = requireProject(name);
|
|
621
|
+
const inCriteria = parseIscs(p.criteria ?? "").find((i) => i.id === id);
|
|
622
|
+
const isc = inCriteria ?? parseIscs(p.changelog ?? "").find((i) => i.id === id);
|
|
623
|
+
if (!isc) fail(`ISC-${id} not found in project "${name}".`);
|
|
624
|
+
|
|
625
|
+
const box = ISC_BOX[isc.status];
|
|
626
|
+
const rewrite = (section: string) =>
|
|
627
|
+
section
|
|
628
|
+
.split("\n")
|
|
629
|
+
.map((l) =>
|
|
630
|
+
new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`, "i").test(l)
|
|
631
|
+
? `- ${box} ISC-${id}: ${text}`
|
|
632
|
+
: l
|
|
633
|
+
)
|
|
634
|
+
.join("\n");
|
|
635
|
+
|
|
636
|
+
if (inCriteria) p.criteria = rewrite(p.criteria ?? "");
|
|
637
|
+
else p.changelog = rewrite(p.changelog ?? "");
|
|
638
|
+
p.updated = now();
|
|
639
|
+
writeProject(p);
|
|
640
|
+
ok({
|
|
641
|
+
edited: true,
|
|
642
|
+
id,
|
|
643
|
+
status: isc.status,
|
|
644
|
+
previous: isc.text,
|
|
645
|
+
text,
|
|
646
|
+
});
|
|
530
647
|
}
|
|
531
648
|
|
|
532
649
|
// Backfill: sweep any done ISCs still sitting in Criteria (legacy projects, or
|
|
@@ -534,7 +651,7 @@ function cmdShowIsc(args: string[]): void {
|
|
|
534
651
|
function cmdPruneIsc(args: string[]): void {
|
|
535
652
|
const name = args[0] ?? fail("Usage: prune-isc <name>");
|
|
536
653
|
const p = requireProject(name);
|
|
537
|
-
const done = parseIscs(p.criteria ?? "").filter((i) => i.
|
|
654
|
+
const done = parseIscs(p.criteria ?? "").filter((i) => i.status !== "open");
|
|
538
655
|
for (const isc of done) {
|
|
539
656
|
const { line, rest } = removeIscLine(p.criteria ?? "", isc.id);
|
|
540
657
|
if (!line) continue;
|
|
@@ -545,7 +662,7 @@ function cmdPruneIsc(args: string[]): void {
|
|
|
545
662
|
p.updated = now();
|
|
546
663
|
writeProject(p);
|
|
547
664
|
}
|
|
548
|
-
const openLeft = parseIscs(p.criteria ?? "").filter((i) =>
|
|
665
|
+
const openLeft = parseIscs(p.criteria ?? "").filter((i) => i.status === "open").length;
|
|
549
666
|
ok({ pruned: done.length, name, remaining_open: openLeft });
|
|
550
667
|
}
|
|
551
668
|
|
|
@@ -628,8 +745,10 @@ Commands:
|
|
|
628
745
|
add-isc <name> "title" append a new open ISC to Criteria
|
|
629
746
|
complete-isc <name> <id> mark ISC-N as done
|
|
630
747
|
reopen-isc <name> <id> reopen ISC-N (mark not done)
|
|
631
|
-
list-isc <name> [--all | --closed]
|
|
748
|
+
list-isc <name> [--all | --closed | --retired] list open ISCs (default); --all, --closed, or --retired
|
|
632
749
|
show-isc <name> <id> print one ISC's full text
|
|
750
|
+
edit-isc <name> <id> "new text" rewrite ISC-N's text, keeping its id and state
|
|
751
|
+
retire-isc <name> <id> [--by <id>] close ISC-N as no longer valid, not as done
|
|
633
752
|
prune-isc <name> archive done ISCs from Criteria into the Changelog
|
|
634
753
|
isa-init <name> mark project as ISA-initialized
|
|
635
754
|
scaffold-task-isa <title> create a one-shot task ISA in memory/work/
|
|
@@ -725,6 +844,12 @@ function run(): void {
|
|
|
725
844
|
case "show-isc":
|
|
726
845
|
cmdShowIsc(rest);
|
|
727
846
|
return;
|
|
847
|
+
case "retire-isc":
|
|
848
|
+
cmdRetireIsc(rest);
|
|
849
|
+
return;
|
|
850
|
+
case "edit-isc":
|
|
851
|
+
cmdEditIsc(rest);
|
|
852
|
+
return;
|
|
728
853
|
case "prune-isc":
|
|
729
854
|
cmdPruneIsc(rest);
|
|
730
855
|
return;
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { resolve } from "node:path";
|
|
16
16
|
import { parseArgs } from "node:util";
|
|
17
|
+
import { encodeAnchor } from "../../hooks/lib/anchor";
|
|
18
|
+
import { loadMachine } from "../../hooks/lib/machine";
|
|
17
19
|
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
18
20
|
import { emit } from "../lib/emit";
|
|
19
21
|
|
|
@@ -22,6 +24,7 @@ import { emit } from "../lib/emit";
|
|
|
22
24
|
export interface Thread {
|
|
23
25
|
id: string;
|
|
24
26
|
cwd: string;
|
|
27
|
+
m: string;
|
|
25
28
|
title: string;
|
|
26
29
|
context: string;
|
|
27
30
|
status: "open" | "resolved";
|
|
@@ -62,10 +65,12 @@ export function writeThreads(threads: Thread[]): void {
|
|
|
62
65
|
|
|
63
66
|
// ── Operations ──
|
|
64
67
|
|
|
65
|
-
|
|
68
|
+
/** Exported so the cwd-anchor and origin-stamp wiring is directly testable. */
|
|
69
|
+
export function addThread(title: string, context: string): Thread {
|
|
66
70
|
const thread: Thread = {
|
|
67
71
|
id: generateId(),
|
|
68
|
-
cwd: process.cwd(),
|
|
72
|
+
cwd: encodeAnchor(process.cwd()),
|
|
73
|
+
m: loadMachine().id,
|
|
69
74
|
title,
|
|
70
75
|
context,
|
|
71
76
|
status: "open",
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
const USAGE = "usage: shot.ts <url> [-o <file>] [--viewport WxH] [--full-page] [--selector <css>] [--wait <ms>]";
|
|
2
|
-
export function parseArgs(argv) {
|
|
3
|
-
let url = "";
|
|
4
|
-
let out = "";
|
|
5
|
-
let viewport;
|
|
6
|
-
let fullPage = false;
|
|
7
|
-
let selector;
|
|
8
|
-
let waitMs;
|
|
9
|
-
for (let i = 0;i < argv.length; i++) {
|
|
10
|
-
const a = argv[i];
|
|
11
|
-
if (a === "-o" || a === "--out")
|
|
12
|
-
out = argv[++i] ?? "";
|
|
13
|
-
else if (a === "--viewport") {
|
|
14
|
-
const m = /^(\d+)[x,](\d+)$/.exec(argv[++i] ?? "");
|
|
15
|
-
if (!m)
|
|
16
|
-
throw new Error("--viewport expects WxH, e.g. 1440x900");
|
|
17
|
-
viewport = { width: Number(m[1]), height: Number(m[2]) };
|
|
18
|
-
} else if (a === "--full-page")
|
|
19
|
-
fullPage = true;
|
|
20
|
-
else if (a === "--selector")
|
|
21
|
-
selector = argv[++i];
|
|
22
|
-
else if (a === "--wait") {
|
|
23
|
-
const n = Number(argv[++i]);
|
|
24
|
-
if (!Number.isFinite(n))
|
|
25
|
-
throw new Error("--wait expects a number of milliseconds");
|
|
26
|
-
waitMs = n;
|
|
27
|
-
} else if (!a.startsWith("-") && !url)
|
|
28
|
-
url = a;
|
|
29
|
-
else
|
|
30
|
-
throw new Error(`unknown argument: ${a}
|
|
31
|
-
${USAGE}`);
|
|
32
|
-
}
|
|
33
|
-
if (!url)
|
|
34
|
-
throw new Error(`a URL is required
|
|
35
|
-
${USAGE}`);
|
|
36
|
-
return { url, out, viewport, fullPage, selector, waitMs };
|
|
37
|
-
}
|
|
38
|
-
export function chooseTier(opts) {
|
|
39
|
-
if (!opts.cliAvailable)
|
|
40
|
-
return "node";
|
|
41
|
-
if (opts.viewport || opts.fullPage)
|
|
42
|
-
return "node";
|
|
43
|
-
return "cli";
|
|
44
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
5
|
-
import { chooseTier, parseArgs } from "./shot-lib.mjs";
|
|
6
|
-
function playwrightCliAvailable() {
|
|
7
|
-
try {
|
|
8
|
-
return spawnSync("playwright-cli", ["--version"], { stdio: "ignore" }).status === 0;
|
|
9
|
-
} catch {
|
|
10
|
-
return false;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
function runViaCli(opts, out) {
|
|
14
|
-
const work = mkdtempSync(join(tmpdir(), "pal-pwcli-"));
|
|
15
|
-
const run = (args, quiet = false) => spawnSync("playwright-cli", args, { stdio: quiet ? "ignore" : "inherit", cwd: work });
|
|
16
|
-
try {
|
|
17
|
-
if (run(["open", opts.url]).status !== 0)
|
|
18
|
-
return false;
|
|
19
|
-
const args = ["screenshot", `--filename=${out}`];
|
|
20
|
-
if (opts.selector)
|
|
21
|
-
args.push(opts.selector);
|
|
22
|
-
const shot = run(args);
|
|
23
|
-
run(["close"], true);
|
|
24
|
-
return shot.status === 0 && existsSync(out);
|
|
25
|
-
} finally {
|
|
26
|
-
rmSync(work, { recursive: true, force: true });
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
async function runViaNode(opts, out) {
|
|
30
|
-
let chromium;
|
|
31
|
-
try {
|
|
32
|
-
({ chromium } = await import("playwright"));
|
|
33
|
-
} catch {
|
|
34
|
-
return "unavailable";
|
|
35
|
-
}
|
|
36
|
-
let browser;
|
|
37
|
-
try {
|
|
38
|
-
browser = await chromium.launch();
|
|
39
|
-
} catch (e) {
|
|
40
|
-
console.error(`chromium launch failed: ${e.message}`);
|
|
41
|
-
return "unavailable";
|
|
42
|
-
}
|
|
43
|
-
try {
|
|
44
|
-
const page = await browser.newPage(opts.viewport ? { viewport: opts.viewport } : {});
|
|
45
|
-
await page.goto(opts.url, { waitUntil: "networkidle" });
|
|
46
|
-
if (opts.waitMs)
|
|
47
|
-
await page.waitForTimeout(opts.waitMs);
|
|
48
|
-
if (opts.selector)
|
|
49
|
-
await page.locator(opts.selector).screenshot({ path: out });
|
|
50
|
-
else
|
|
51
|
-
await page.screenshot({ path: out, fullPage: opts.fullPage });
|
|
52
|
-
return existsSync(out) ? "ok" : "error";
|
|
53
|
-
} catch (e) {
|
|
54
|
-
console.error(`screenshot failed: ${e.message}`);
|
|
55
|
-
return "error";
|
|
56
|
-
} finally {
|
|
57
|
-
await browser.close();
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
async function main() {
|
|
61
|
-
let opts;
|
|
62
|
-
try {
|
|
63
|
-
opts = parseArgs(process.argv.slice(2));
|
|
64
|
-
} catch (e) {
|
|
65
|
-
console.error(e.message);
|
|
66
|
-
process.exit(2);
|
|
67
|
-
}
|
|
68
|
-
const out = opts.out ? resolve(opts.out) : join(tmpdir(), `pal-shot-${Date.now()}.png`);
|
|
69
|
-
const tier = chooseTier({
|
|
70
|
-
cliAvailable: playwrightCliAvailable(),
|
|
71
|
-
viewport: opts.viewport,
|
|
72
|
-
fullPage: opts.fullPage
|
|
73
|
-
});
|
|
74
|
-
if (tier === "cli" && runViaCli(opts, out)) {
|
|
75
|
-
console.log(out);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const result = await runViaNode(opts, out);
|
|
79
|
-
if (result === "ok") {
|
|
80
|
-
console.log(out);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (result === "unavailable") {
|
|
84
|
-
console.error("NO_PLAYWRIGHT_CLI");
|
|
85
|
-
process.exit(3);
|
|
86
|
-
}
|
|
87
|
-
process.exit(1);
|
|
88
|
-
}
|
|
89
|
-
await main();
|