browser-automation-skill 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/SECURITY.md +39 -0
- package/SKILL.md +206 -0
- package/bin/cli.mjs +55 -0
- package/install.sh +143 -0
- package/package.json +54 -0
- package/references/adapter-candidates.md +40 -0
- package/references/browser-mcp-cheatsheet.md +132 -0
- package/references/browser-stats-cheatsheet.md +155 -0
- package/references/chrome-devtools-mcp-cheatsheet.md +232 -0
- package/references/midscene-integration.md +359 -0
- package/references/obscura-cheatsheet.md +103 -0
- package/references/playwright-cli-cheatsheet.md +64 -0
- package/references/playwright-lib-cheatsheet.md +90 -0
- package/references/recipes/add-a-tool-adapter.md +134 -0
- package/references/recipes/agent-workflows/README.md +37 -0
- package/references/recipes/agent-workflows/cache-driven-bulk-operation.md +110 -0
- package/references/recipes/agent-workflows/flow-record-and-replay.md +102 -0
- package/references/recipes/agent-workflows/incremental-pattern-discovery.md +125 -0
- package/references/recipes/agent-workflows/login-then-scrape.md +100 -0
- package/references/recipes/anti-patterns-tool-extension.md +182 -0
- package/references/recipes/body-bytes-not-body.md +139 -0
- package/references/recipes/cache-write-security.md +210 -0
- package/references/recipes/fingerprint-rescue.md +154 -0
- package/references/recipes/model-routing.md +143 -0
- package/references/recipes/path-security.md +138 -0
- package/references/recipes/privacy-canary.md +96 -0
- package/references/recipes/visual-rescue-hook.md +182 -0
- package/references/stats-prices.json +42 -0
- package/references/stats-schema.json +77 -0
- package/references/tool-versions.md +8 -0
- package/scripts/browser-add-site.sh +113 -0
- package/scripts/browser-assert.sh +106 -0
- package/scripts/browser-audit.sh +68 -0
- package/scripts/browser-baseline.sh +135 -0
- package/scripts/browser-click.sh +100 -0
- package/scripts/browser-creds-add.sh +254 -0
- package/scripts/browser-creds-list.sh +67 -0
- package/scripts/browser-creds-migrate.sh +122 -0
- package/scripts/browser-creds-remove.sh +69 -0
- package/scripts/browser-creds-rotate-totp.sh +109 -0
- package/scripts/browser-creds-show.sh +82 -0
- package/scripts/browser-creds-totp.sh +94 -0
- package/scripts/browser-do.sh +630 -0
- package/scripts/browser-doctor.sh +365 -0
- package/scripts/browser-drag.sh +90 -0
- package/scripts/browser-extract.sh +192 -0
- package/scripts/browser-fill.sh +142 -0
- package/scripts/browser-flow.sh +316 -0
- package/scripts/browser-history.sh +187 -0
- package/scripts/browser-hover.sh +92 -0
- package/scripts/browser-inspect.sh +188 -0
- package/scripts/browser-list-sessions.sh +78 -0
- package/scripts/browser-list-sites.sh +42 -0
- package/scripts/browser-login.sh +279 -0
- package/scripts/browser-mcp.sh +65 -0
- package/scripts/browser-migrate.sh +195 -0
- package/scripts/browser-open.sh +134 -0
- package/scripts/browser-press.sh +80 -0
- package/scripts/browser-remove-session.sh +72 -0
- package/scripts/browser-remove-site.sh +68 -0
- package/scripts/browser-replay.sh +206 -0
- package/scripts/browser-route.sh +174 -0
- package/scripts/browser-select.sh +122 -0
- package/scripts/browser-show-session.sh +57 -0
- package/scripts/browser-show-site.sh +37 -0
- package/scripts/browser-snapshot.sh +176 -0
- package/scripts/browser-stats.sh +522 -0
- package/scripts/browser-tab-close.sh +112 -0
- package/scripts/browser-tab-list.sh +70 -0
- package/scripts/browser-tab-switch.sh +111 -0
- package/scripts/browser-upload.sh +132 -0
- package/scripts/browser-use.sh +60 -0
- package/scripts/browser-vlm.sh +707 -0
- package/scripts/browser-wait.sh +97 -0
- package/scripts/install-git-hooks.sh +16 -0
- package/scripts/lib/capture.sh +356 -0
- package/scripts/lib/common.sh +262 -0
- package/scripts/lib/credential.sh +237 -0
- package/scripts/lib/fingerprint-rescue.js +123 -0
- package/scripts/lib/flow.sh +448 -0
- package/scripts/lib/flow_record.sh +210 -0
- package/scripts/lib/mask.sh +49 -0
- package/scripts/lib/memory.sh +427 -0
- package/scripts/lib/migrate.sh +390 -0
- package/scripts/lib/migrators/README.md +23 -0
- package/scripts/lib/migrators/memory/v1_to_v2.sh +15 -0
- package/scripts/lib/migrators/recent_urls/README.md +13 -0
- package/scripts/lib/migrators/stats/README.md +24 -0
- package/scripts/lib/node/chrome-devtools-bridge.mjs +1812 -0
- package/scripts/lib/node/mcp-server.mjs +531 -0
- package/scripts/lib/node/mcp-tools.json +68 -0
- package/scripts/lib/node/playwright-driver.mjs +1104 -0
- package/scripts/lib/node/totp-core.mjs +52 -0
- package/scripts/lib/node/totp.mjs +52 -0
- package/scripts/lib/node/url-pattern-cluster.mjs +102 -0
- package/scripts/lib/node/url-pattern-resolver.mjs +77 -0
- package/scripts/lib/output.sh +79 -0
- package/scripts/lib/router.sh +342 -0
- package/scripts/lib/sanitize.sh +107 -0
- package/scripts/lib/secret/keychain.sh +91 -0
- package/scripts/lib/secret/libsecret.sh +74 -0
- package/scripts/lib/secret/plaintext.sh +75 -0
- package/scripts/lib/secret_backend_select.sh +57 -0
- package/scripts/lib/session.sh +153 -0
- package/scripts/lib/site.sh +126 -0
- package/scripts/lib/stats.sh +419 -0
- package/scripts/lib/tool/.gitkeep +0 -0
- package/scripts/lib/tool/chrome-devtools-mcp.sh +349 -0
- package/scripts/lib/tool/obscura.sh +249 -0
- package/scripts/lib/tool/playwright-cli.sh +155 -0
- package/scripts/lib/tool/playwright-lib.sh +106 -0
- package/scripts/lib/verb_helpers.sh +222 -0
- package/scripts/lib/visual-rescue-default.sh +145 -0
- package/scripts/regenerate-docs.sh +99 -0
- package/uninstall.sh +51 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// scripts/lib/node/totp-core.mjs — pure-node RFC 6238 TOTP primitives.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from totp.mjs (phase-5 part 4-ii) so playwright-driver.mjs can
|
|
4
|
+
// import the same logic for auto-replay (phase-5 part 4-iii). totp.mjs
|
|
5
|
+
// remains the CLI shim that reads stdin and calls totpAt().
|
|
6
|
+
//
|
|
7
|
+
// No external deps — uses node's built-in crypto module.
|
|
8
|
+
|
|
9
|
+
import { createHmac } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
12
|
+
|
|
13
|
+
export function base32Decode(b32) {
|
|
14
|
+
const cleaned = b32.toUpperCase().replace(/=+$/g, '').replace(/\s+/g, '');
|
|
15
|
+
let bits = '';
|
|
16
|
+
for (const ch of cleaned) {
|
|
17
|
+
const i = BASE32_ALPHABET.indexOf(ch);
|
|
18
|
+
if (i < 0) throw new Error(`invalid base32 character: '${ch}'`);
|
|
19
|
+
bits += i.toString(2).padStart(5, '0');
|
|
20
|
+
}
|
|
21
|
+
const bytes = [];
|
|
22
|
+
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
|
23
|
+
bytes.push(parseInt(bits.slice(i, i + 8), 2));
|
|
24
|
+
}
|
|
25
|
+
return Buffer.from(bytes);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// totpAt — produce a TOTP code for a given timestamp (seconds since epoch).
|
|
29
|
+
// Defaults: 6 digits, 30s period, HMAC-SHA1 (per common TOTP issuer practice).
|
|
30
|
+
export function totpAt(secretBase32, timestampSec, digits = 6, period = 30, alg = 'sha1') {
|
|
31
|
+
const counter = Math.floor(timestampSec / period);
|
|
32
|
+
const counterBuf = Buffer.alloc(8);
|
|
33
|
+
// Big-endian 64-bit counter. Math.floor + lossy 32-bit handling for the
|
|
34
|
+
// upper word — acceptable for any timestamp before year ~2106.
|
|
35
|
+
counterBuf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
|
|
36
|
+
counterBuf.writeUInt32BE(counter >>> 0, 4);
|
|
37
|
+
const key = base32Decode(secretBase32);
|
|
38
|
+
const hmac = createHmac(alg, key).update(counterBuf).digest();
|
|
39
|
+
const offset = hmac[hmac.length - 1] & 0x0f;
|
|
40
|
+
const truncated =
|
|
41
|
+
((hmac[offset] & 0x7f) << 24) |
|
|
42
|
+
(hmac[offset + 1] << 16) |
|
|
43
|
+
(hmac[offset + 2] << 8) |
|
|
44
|
+
hmac[offset + 3];
|
|
45
|
+
const code = truncated % Math.pow(10, digits);
|
|
46
|
+
return String(code).padStart(digits, '0');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// totpNow — convenience wrapper using Date.now().
|
|
50
|
+
export function totpNow(secretBase32, digits = 6, period = 30, alg = 'sha1') {
|
|
51
|
+
return totpAt(secretBase32, Math.floor(Date.now() / 1000), digits, period, alg);
|
|
52
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/node/totp.mjs — CLI shim around scripts/lib/node/totp-core.mjs.
|
|
3
|
+
//
|
|
4
|
+
// Reads base32-encoded shared secret from stdin (typical TOTP issuer output:
|
|
5
|
+
// "JBSWY3DPEHPK3PXP" etc.), produces the 6-digit code for the current 30s
|
|
6
|
+
// window. Core logic lives in totp-core.mjs so playwright-driver.mjs can
|
|
7
|
+
// import the same primitives for auto-replay (phase-5 part 4-iii).
|
|
8
|
+
//
|
|
9
|
+
// CLI:
|
|
10
|
+
// echo -n 'BASE32SECRET' | node totp.mjs
|
|
11
|
+
// → 6-digit code on stdout
|
|
12
|
+
//
|
|
13
|
+
// Optional env vars:
|
|
14
|
+
// TOTP_TIME_T (integer seconds since epoch) — override "now" for tests.
|
|
15
|
+
// Lets bats verify against RFC 6238 §A test vectors.
|
|
16
|
+
// TOTP_DIGITS (default 6) — code length.
|
|
17
|
+
// TOTP_PERIOD (default 30) — time-step in seconds.
|
|
18
|
+
// TOTP_ALG (default SHA1) — HMAC algorithm. Most providers use SHA1.
|
|
19
|
+
|
|
20
|
+
import { totpAt } from './totp-core.mjs';
|
|
21
|
+
|
|
22
|
+
async function readAllStdin() {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
let data = '';
|
|
25
|
+
process.stdin.setEncoding('utf-8');
|
|
26
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
27
|
+
process.stdin.on('end', () => resolve(data));
|
|
28
|
+
process.stdin.on('error', reject);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const secret = (await readAllStdin()).trim();
|
|
33
|
+
if (!secret) {
|
|
34
|
+
process.stderr.write('totp: empty secret on stdin\n');
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const t = process.env.TOTP_TIME_T
|
|
39
|
+
? parseInt(process.env.TOTP_TIME_T, 10)
|
|
40
|
+
: Math.floor(Date.now() / 1000);
|
|
41
|
+
const digits = parseInt(process.env.TOTP_DIGITS || '6', 10);
|
|
42
|
+
const period = parseInt(process.env.TOTP_PERIOD || '30', 10);
|
|
43
|
+
const alg = (process.env.TOTP_ALG || 'sha1').toLowerCase();
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const code = totpAt(secret, t, digits, period, alg);
|
|
47
|
+
process.stdout.write(code + '\n');
|
|
48
|
+
process.exit(0);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
process.stderr.write(`totp: ${err && err.message ? err.message : err}\n`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/node/url-pattern-cluster.mjs
|
|
3
|
+
//
|
|
4
|
+
// Phase 11 part 2-ii. Cluster URLs by templated pathname.
|
|
5
|
+
//
|
|
6
|
+
// Stdin: {"urls": ["https://...", ...]}
|
|
7
|
+
// Stdout: {"clusters": [{"templated": "/devices/:id", "urls": [...], "count": N}, ...]}
|
|
8
|
+
//
|
|
9
|
+
// Heuristic:
|
|
10
|
+
// numeric segment (^[0-9]+$) → :id
|
|
11
|
+
// UUID segment (8-4-4-4-12 hex) → :uuid
|
|
12
|
+
// slug segment (^[a-z0-9_]+(-[a-z0-9_]+)+$/i + length ≥ 5) → :slug
|
|
13
|
+
// other segments → verbatim
|
|
14
|
+
//
|
|
15
|
+
// Slug heuristic (Pick A2) — locked decision:
|
|
16
|
+
// - Requires at least ONE hyphen separating alphanumeric groups.
|
|
17
|
+
// - Each side of every hyphen must be ≥1 char of [a-zA-Z0-9_].
|
|
18
|
+
// - Total segment length ≥ 5 chars (filters short codes like `a-b` or
|
|
19
|
+
// `1-2` which are more likely to be opaque identifiers than slugs).
|
|
20
|
+
// - All-numeric is already caught by the numeric branch above (which
|
|
21
|
+
// fires before slug detection in this `if`-chain order).
|
|
22
|
+
//
|
|
23
|
+
// Cross-site clustering not in scope (caller passes per-site URLs).
|
|
24
|
+
|
|
25
|
+
let stdin = "";
|
|
26
|
+
process.stdin.setEncoding("utf8");
|
|
27
|
+
for await (const chunk of process.stdin) stdin += chunk;
|
|
28
|
+
|
|
29
|
+
let payload;
|
|
30
|
+
try {
|
|
31
|
+
payload = JSON.parse(stdin || "{}");
|
|
32
|
+
} catch {
|
|
33
|
+
process.stdout.write(JSON.stringify({ clusters: [] }));
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const urls = Array.isArray(payload.urls) ? payload.urls : [];
|
|
38
|
+
|
|
39
|
+
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
40
|
+
const NUMERIC_RE = /^[0-9]+$/;
|
|
41
|
+
const SLUG_RE = /^[a-z0-9_]+(-[a-z0-9_]+)+$/i;
|
|
42
|
+
const MIN_SLUG_LEN = 5;
|
|
43
|
+
|
|
44
|
+
function templatePathname(pathname) {
|
|
45
|
+
// Split preserves the leading "/" as an empty first element.
|
|
46
|
+
const parts = pathname.split("/");
|
|
47
|
+
const templated = parts.map((seg) => {
|
|
48
|
+
if (seg === "") return seg;
|
|
49
|
+
if (UUID_RE.test(seg)) return ":uuid";
|
|
50
|
+
if (NUMERIC_RE.test(seg)) return ":id";
|
|
51
|
+
// Pick A2: slug heuristic. Fires after numeric/UUID; only on hyphenated
|
|
52
|
+
// multi-group segments of length >= MIN_SLUG_LEN.
|
|
53
|
+
if (seg.length >= MIN_SLUG_LEN && SLUG_RE.test(seg)) return ":slug";
|
|
54
|
+
return seg;
|
|
55
|
+
});
|
|
56
|
+
return templated.join("/");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const buckets = new Map();
|
|
60
|
+
for (const url of urls) {
|
|
61
|
+
if (typeof url !== "string") continue;
|
|
62
|
+
let pathname;
|
|
63
|
+
try {
|
|
64
|
+
pathname = new URL(url, "https://placeholder.local").pathname;
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const templated = templatePathname(pathname);
|
|
69
|
+
// Skip URLs whose templated form is identical to the original (no
|
|
70
|
+
// numeric/UUID segment matched) AND that haven't been seen before. This
|
|
71
|
+
// is what filters slug-shaped segments out of cluster proposals.
|
|
72
|
+
// (Identical templates DO still get bucketed if they collide; only the
|
|
73
|
+
// single-occurrence non-template URLs get suppressed below by the
|
|
74
|
+
// threshold filter.)
|
|
75
|
+
const bucket = buckets.get(templated) || [];
|
|
76
|
+
bucket.push(url);
|
|
77
|
+
buckets.set(templated, bucket);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Only emit clusters where the templated form differs from at least one
|
|
81
|
+
// constituent URL's pathname — otherwise it's just N copies of the same
|
|
82
|
+
// literal URL, which isn't a "pattern".
|
|
83
|
+
const clusters = [];
|
|
84
|
+
for (const [templated, urlList] of buckets) {
|
|
85
|
+
let hasTemplating = false;
|
|
86
|
+
for (const url of urlList) {
|
|
87
|
+
let pathname;
|
|
88
|
+
try {
|
|
89
|
+
pathname = new URL(url, "https://placeholder.local").pathname;
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (pathname !== templated) {
|
|
94
|
+
hasTemplating = true;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!hasTemplating) continue;
|
|
99
|
+
clusters.push({ templated, urls: urlList, count: urlList.length });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
process.stdout.write(JSON.stringify({ clusters }));
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lib/node/url-pattern-resolver.mjs
|
|
3
|
+
//
|
|
4
|
+
// Phase 11 part 1-i. URL → archetype resolution.
|
|
5
|
+
//
|
|
6
|
+
// Stdin: {"patterns":[{"url_pattern":"/devices/:id","archetype_id":"…"}], "url":"https://…"}
|
|
7
|
+
// Stdout: {"matched_pattern":"/devices/:id","archetype_id":"devices-detail"} on hit
|
|
8
|
+
// null on miss
|
|
9
|
+
//
|
|
10
|
+
// First-match-wins (callers reorder patterns to express priority).
|
|
11
|
+
// Pattern is a pathname pattern; matched against the URL's pathname (URL
|
|
12
|
+
// parsed with a placeholder origin so relative URLs work).
|
|
13
|
+
//
|
|
14
|
+
// Matcher subset (deliberate; v1):
|
|
15
|
+
// :name → matches one path segment (non-slash chars)
|
|
16
|
+
// * → matches any chars including slashes
|
|
17
|
+
// literal → matched verbatim
|
|
18
|
+
//
|
|
19
|
+
// Why not the URLPattern web standard? Because the global `URLPattern` is
|
|
20
|
+
// only stable in Node 23.8+; GitHub Actions runners still default to Node
|
|
21
|
+
// 20 (see https://github.blog/changelog/2025-09-19-...). A hand-rolled
|
|
22
|
+
// matcher keeps behavior deterministic across all supported Node versions
|
|
23
|
+
// and removes the npm-polyfill cost. URLPattern can replace this when the
|
|
24
|
+
// CI baseline lifts to Node 24+ (target: mid-2026).
|
|
25
|
+
|
|
26
|
+
let stdin = "";
|
|
27
|
+
process.stdin.setEncoding("utf8");
|
|
28
|
+
for await (const chunk of process.stdin) stdin += chunk;
|
|
29
|
+
|
|
30
|
+
let payload;
|
|
31
|
+
try {
|
|
32
|
+
payload = JSON.parse(stdin || "{}");
|
|
33
|
+
} catch {
|
|
34
|
+
process.stdout.write("null");
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const patterns = Array.isArray(payload.patterns) ? payload.patterns : [];
|
|
39
|
+
const url = typeof payload.url === "string" ? payload.url : "";
|
|
40
|
+
|
|
41
|
+
let pathname;
|
|
42
|
+
try {
|
|
43
|
+
pathname = new URL(url, "https://placeholder.local").pathname;
|
|
44
|
+
} catch {
|
|
45
|
+
process.stdout.write("null");
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Compile pattern → RegExp. :name matches one segment; * matches anything.
|
|
50
|
+
function compile(pattern) {
|
|
51
|
+
// Escape regex metachars EXCEPT the two we re-introduce (`:` and `*`).
|
|
52
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
// :name → [^/]+ (one segment)
|
|
54
|
+
const withNamed = escaped.replace(/:[A-Za-z_][\w$]*/g, "[^/]+");
|
|
55
|
+
// * → .* (anything, slashes included)
|
|
56
|
+
const withStar = withNamed.replace(/\*/g, ".*");
|
|
57
|
+
return new RegExp("^" + withStar + "/?$");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const p of patterns) {
|
|
61
|
+
if (typeof p?.url_pattern !== "string") continue;
|
|
62
|
+
let re;
|
|
63
|
+
try {
|
|
64
|
+
re = compile(p.url_pattern);
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (re.test(pathname)) {
|
|
69
|
+
process.stdout.write(JSON.stringify({
|
|
70
|
+
matched_pattern: p.url_pattern,
|
|
71
|
+
archetype_id: p.archetype_id,
|
|
72
|
+
}));
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
process.stdout.write("null");
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# scripts/lib/output.sh
|
|
2
|
+
# Token-efficient adapter output helpers. Implements the contract from
|
|
3
|
+
# docs/superpowers/specs/2026-05-01-token-efficient-adapter-output-design.md §3.
|
|
4
|
+
#
|
|
5
|
+
# Verbs and adapters MUST emit through these helpers — never hand-roll JSON.
|
|
6
|
+
# Lint tier 3 (tests/lint.sh, Phase 3 Task 13) enforces it.
|
|
7
|
+
|
|
8
|
+
[ -n "${BROWSER_SKILL_OUTPUT_LOADED:-}" ] && return 0
|
|
9
|
+
readonly BROWSER_SKILL_OUTPUT_LOADED=1
|
|
10
|
+
|
|
11
|
+
# Canonical status values (spec §3.1). Reject anything else at the helper boundary
|
|
12
|
+
# so output stays parseable by jq routing logic.
|
|
13
|
+
readonly _OUTPUT_STATUSES_OK="ok partial empty error aborted"
|
|
14
|
+
|
|
15
|
+
# emit_summary key=value [key=value ...]
|
|
16
|
+
# Required keys: verb, tool, why, status. duration_ms auto-fills from
|
|
17
|
+
# SUMMARY_T0 (set by caller via `SUMMARY_T0=$(now_ms)` at verb entry).
|
|
18
|
+
# Wraps summary_json from common.sh; adds key-presence + status-enum guards.
|
|
19
|
+
emit_summary() {
|
|
20
|
+
local has_verb=0 has_tool=0 has_why=0 has_status=0 has_duration=0
|
|
21
|
+
local arg value
|
|
22
|
+
for arg in "$@"; do
|
|
23
|
+
case "${arg}" in
|
|
24
|
+
verb=*) has_verb=1 ;;
|
|
25
|
+
tool=*) has_tool=1 ;;
|
|
26
|
+
why=*) has_why=1 ;;
|
|
27
|
+
status=*)
|
|
28
|
+
has_status=1
|
|
29
|
+
value="${arg#status=}"
|
|
30
|
+
if ! [[ " ${_OUTPUT_STATUSES_OK} " == *" ${value} "* ]]; then
|
|
31
|
+
die "${EXIT_USAGE_ERROR}" "emit_summary: status='${value}' not in {${_OUTPUT_STATUSES_OK// /, }}"
|
|
32
|
+
fi
|
|
33
|
+
;;
|
|
34
|
+
duration_ms=*) has_duration=1 ;;
|
|
35
|
+
esac
|
|
36
|
+
done
|
|
37
|
+
|
|
38
|
+
[ "${has_verb}" = "1" ] || die "${EXIT_USAGE_ERROR}" "emit_summary: missing required key 'verb'"
|
|
39
|
+
[ "${has_tool}" = "1" ] || die "${EXIT_USAGE_ERROR}" "emit_summary: missing required key 'tool'"
|
|
40
|
+
[ "${has_why}" = "1" ] || die "${EXIT_USAGE_ERROR}" "emit_summary: missing required key 'why'"
|
|
41
|
+
[ "${has_status}" = "1" ] || die "${EXIT_USAGE_ERROR}" "emit_summary: missing required key 'status'"
|
|
42
|
+
|
|
43
|
+
if [ "${has_duration}" = "0" ] && [ -n "${SUMMARY_T0:-}" ]; then
|
|
44
|
+
local now elapsed
|
|
45
|
+
now="$(now_ms)"
|
|
46
|
+
elapsed=$((now - SUMMARY_T0))
|
|
47
|
+
summary_json "$@" "duration_ms=${elapsed}"
|
|
48
|
+
return
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
summary_json "$@"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# emit_event EVENT_NAME [key=value ...]
|
|
55
|
+
# Streaming JSON line with `.event = EVENT_NAME`. Spec §3.3.
|
|
56
|
+
emit_event() {
|
|
57
|
+
local event="${1:-}"
|
|
58
|
+
shift || true
|
|
59
|
+
[ -n "${event}" ] || die "${EXIT_USAGE_ERROR}" "emit_event: empty event name"
|
|
60
|
+
summary_json "event=${event}" "$@"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# capture_path CATEGORY SITE EXT
|
|
64
|
+
# Returns ${CAPTURES_DIR}/<category>/<site>--<ts>.<ext> and mkdir -p's the parent.
|
|
65
|
+
# CATEGORY: snapshots | screenshots | hars | traces | videos | pdfs (spec §6).
|
|
66
|
+
# SITE: must pass assert_safe_name (no traversal).
|
|
67
|
+
# EXT: file extension without dot (e.g. png, har, yaml, webm, pdf, zip).
|
|
68
|
+
capture_path() {
|
|
69
|
+
local category="$1" site="$2" ext="$3"
|
|
70
|
+
assert_safe_name "${category}" "capture-category"
|
|
71
|
+
assert_safe_name "${site}" "site-name"
|
|
72
|
+
assert_safe_name "${ext}" "capture-extension"
|
|
73
|
+
|
|
74
|
+
local ts
|
|
75
|
+
ts="$(date -u +%Y-%m-%dT%H%M%SZ)"
|
|
76
|
+
local dir="${CAPTURES_DIR:?CAPTURES_DIR not set; call init_paths first}/${category}"
|
|
77
|
+
mkdir -p "${dir}"
|
|
78
|
+
printf '%s/%s--%s.%s\n' "${dir}" "${site}" "${ts}" "${ext}"
|
|
79
|
+
}
|