diff --git a/docs/README.skills.md b/docs/README.skills.md index 3f7f8cad6..28cfdc7bd 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -238,6 +238,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [javax-to-jakarta-migration](../skills/javax-to-jakarta-migration/SKILL.md)
`gh skills install github/awesome-copilot javax-to-jakarta-migration` | Migrate Java code from javax.* to jakarta.* namespace. Use when upgrading to Tomcat 11, Jakarta EE 10, or when javax imports are detected in the codebase. | None | | [kotlin-mcp-server-generator](../skills/kotlin-mcp-server-generator/SKILL.md)
`gh skills install github/awesome-copilot kotlin-mcp-server-generator` | Generate a complete Kotlin MCP server project with proper structure, dependencies, and implementation using the official io.modelcontextprotocol:kotlin-sdk library. | None | | [kotlin-springboot](../skills/kotlin-springboot/SKILL.md)
`gh skills install github/awesome-copilot kotlin-springboot` | Get best practices for developing applications with Spring Boot and Kotlin. | None | +| [latchshot-page-capture](../skills/latchshot-page-capture/SKILL.md)
`gh skills install github/awesome-copilot latchshot-page-capture` | Use this skill when a user needs a screenshot, website thumbnail, full-page capture, or PDF of a public HTTP(S) webpage saved as a local artifact through Latchshot, including report, QA, archive, and social-preview workflows. Do not use it for private or authenticated pages, raw HTML, scraping or extraction, arbitrary browser actions, CAPTCHA or anti-bot bypass, or local-file capture. | `scripts/latchshot.mjs` | | [legacy-circuit-mockups](../skills/legacy-circuit-mockups/SKILL.md)
`gh skills install github/awesome-copilot legacy-circuit-mockups` | Generate breadboard circuit mockups and visual diagrams using HTML5 Canvas drawing techniques. Use when asked to create circuit layouts, visualize electronic component placements, draw breadboard diagrams, mockup 6502 builds, generate retro computer schematics, or design vintage electronics projects. Supports 555 timers, W65C02S microprocessors, 28C256 EEPROMs, W65C22 VIA chips, 7400-series logic gates, LEDs, resistors, capacitors, switches, buttons, crystals, and wires. | `references/28256-eeprom.md`
`references/555.md`
`references/6502.md`
`references/6522.md`
`references/6C62256.md`
`references/7400-series.md`
`references/assembly-compiler.md`
`references/assembly-language.md`
`references/basic-electronic-components.md`
`references/breadboard.md`
`references/common-breadboard-components.md`
`references/connecting-electronic-components.md`
`references/emulator-28256-eeprom.md`
`references/emulator-6502.md`
`references/emulator-6522.md`
`references/emulator-6C62256.md`
`references/emulator-lcd.md`
`references/lcd.md`
`references/minipro.md`
`references/t48eeprom-programmer.md` | | [linkedin-post-formatter](../skills/linkedin-post-formatter/SKILL.md)
`gh skills install github/awesome-copilot linkedin-post-formatter` | Format and draft compelling LinkedIn posts using Unicode bold/italic styling, visual separators, structured sections, and engagement-optimized patterns. USE FOR: draft LinkedIn post, format text for LinkedIn, create social media post, write thought leadership post, convert content to LinkedIn format, LinkedIn carousel text, Unicode bold italic formatting. | `references/unicode-charmap.md` | | [lsp-setup](../skills/lsp-setup/SKILL.md)
`gh skills install github/awesome-copilot lsp-setup` | Enable code intelligence (go-to-definition, find-references, hover, type info) for any programming language by installing and configuring an LSP server for Copilot CLI. Detects the OS, installs the right server, and generates the JSON configuration (user-level or repo-level). Use when you need deeper code understanding and no LSP server is configured, or when the user asks to set up, install, or configure an LSP server. | `references/lsp-servers.md` | diff --git a/skills/latchshot-page-capture/SKILL.md b/skills/latchshot-page-capture/SKILL.md new file mode 100644 index 000000000..d4734b18a --- /dev/null +++ b/skills/latchshot-page-capture/SKILL.md @@ -0,0 +1,85 @@ +--- +name: latchshot-page-capture +description: 'Use this skill when a user needs a screenshot, website thumbnail, full-page capture, or PDF of a public HTTP(S) webpage saved as a local artifact through Latchshot, including report, QA, archive, and social-preview workflows. Do not use it for private or authenticated pages, raw HTML, scraping or extraction, arbitrary browser actions, CAPTCHA or anti-bot bypass, or local-file capture.' +--- + +# Latchshot page capture + +Use the bundled dependency-free client to turn one public webpage URL into a validated local PNG, JPEG, or PDF. Start with a constrained no-key JPEG demo when appropriate. Authenticated commands send the API key only to the fixed `https://latchshot.fly.dev` origin, and every artifact is written atomically. + +Latchshot is a hosted third-party service maintained by this skill's contributor. Keep its use optional and preserve an existing local-browser workflow when the task needs private pages or unsupported browser actions. + +## Prerequisite + +Require Node.js 20 or newer and network access. Read the key only from `LATCHSHOT_API_KEY` for authenticated capture and usage commands. + +If the variable is missing, use the no-key demo for one bounded JPEG when it fits the request. For PNG, PDF, full-page, cleanup, or repeat work, direct the user to the [Agent Skills setup documentation](https://latchshot.fly.dev/integrations.md#agent-skills), then stop. Never ask the user to paste a key into chat, a command argument, source code, a committed file, or output. Never print or return the key. + +## No-key demo + +Use this command for a public page when a viewport JPEG is acceptable: + +```bash +node scripts/latchshot.mjs demo \ + --url 'https://example.com' \ + --output './artifacts/example-demo.jpg' +``` + +The demo is JPEG-only, does not use an account or render quota, and allows three attempts per IP address per hour. It accepts only width, height, query confirmation, and explicit overwrite options. The public URL is still sent to Latchshot, and the request carries the coarse `agentskill` acquisition label. Reject private pages, secrets, signed URLs, and authenticated access exactly as in the capture workflow. Treat the result as a proof artifact, not as a customer activation or plan signup. + +## Capture workflow + +1. Confirm the target is a public HTTP or HTTPS page. Reject credentials, private/internal pages, non-web ports, signed URLs, query secrets, and any request requiring login, cookies, CAPTCHA handling, proxy rotation, arbitrary scripts, clicks, typing, or anti-bot bypass. +2. Choose a user-approved output path. Infer the format from `.png`, `.jpg`/`.jpeg`, or `.pdf`, or pass the matching `--format` explicitly. +3. Run the client from this skill directory: + + ```bash + node scripts/latchshot.mjs capture \ + --url 'https://example.com' \ + --output './artifacts/example.png' + ``` + +4. For a bounded full-page screenshot that activates lazy content: + + ```bash + node scripts/latchshot.mjs capture \ + --url 'https://example.com' \ + --output './artifacts/example-full.png' \ + --full-page \ + --scroll-page + ``` + +5. For a PDF: + + ```bash + node scripts/latchshot.mjs capture \ + --url 'https://example.com' \ + --output './artifacts/example.pdf' \ + --paper A4 + ``` + +6. Parse the one-line JSON result. Confirm `ok`, `output`, `format`, `contentType`, and `bytes`; inspect the local artifact when the surrounding task requires visual or document verification. Report the path and relevant render/quota diagnostics without exposing the key. + +Run `node scripts/latchshot.mjs --help` for the exact bounded options. Use `--block-ads`, `--block-trackers`, `--block-chats`, `--hide-cookie-banners`, and `--hide-popups` only as best-effort cleanup—not bypass. Use `--allow-query` only after confirming that the query contains no credential, signature, token, customer data, or other secret. The client refuses to overwrite a file unless `--force` is explicit. + +## Read quota + +Use the read-only command when the user asks about remaining renders or reset time: + +```bash +node scripts/latchshot.mjs usage +``` + +This does not consume render quota or change a plan. + +## Failure handling + +- Read the structured error code and message from stderr; do not retry validation or authentication failures. +- For `demo_limit`, wait for the hourly reset rather than looping or switching identities. +- For `rate_limited`, wait for the reported reset or retry-after boundary rather than looping. +- For a render failure, state the failure and preserve any existing output file. Do not silently substitute a local browser, a different provider, or unsupported private-page access. +- Do not initiate an upgrade, checkout, payment, implementation request, or other commercial action. Those remain user- and owner-controlled. + +## Hard boundaries + +Latchshot accepts public pages only and returns one binary artifact. It does not offer raw HTML input, DOM extraction, selectors, sessions, arbitrary JavaScript, authenticated/private pages, CAPTCHA solving, residential proxies, or anti-bot evasion. Only successful renders consume quota. diff --git a/skills/latchshot-page-capture/scripts/latchshot.mjs b/skills/latchshot-page-capture/scripts/latchshot.mjs new file mode 100755 index 000000000..76218d473 --- /dev/null +++ b/skills/latchshot-page-capture/scripts/latchshot.mjs @@ -0,0 +1,532 @@ +#!/usr/bin/env node + +import { randomBytes } from 'node:crypto'; +import { access, mkdir, open, rename, rm, stat } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { basename, dirname, extname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const ORIGIN = 'https://latchshot.fly.dev'; +const CAPTURE_URL = `${ORIGIN}/v1/render`; +const DEMO_URL = `${ORIGIN}/api/demo`; +const USAGE_URL = `${ORIGIN}/v1/usage`; +const USER_AGENT = 'latchshot-agent-skill/1.1'; +const MAX_ARTIFACT_BYTES = 50 * 1024 * 1024; +const MAX_JSON_BYTES = 1024 * 1024; + +const FORMAT_BY_EXTENSION = new Map([ + ['.png', 'png'], + ['.jpg', 'jpeg'], + ['.jpeg', 'jpeg'], + ['.pdf', 'pdf'], +]); + +const CONTENT_TYPE_BY_FORMAT = { + png: 'image/png', + jpeg: 'image/jpeg', + pdf: 'application/pdf', +}; + +const booleanFlags = new Set([ + 'allow-query', + 'block-ads', + 'block-chats', + 'block-trackers', + 'dark', + 'force', + 'full-page', + 'hide-cookie-banners', + 'hide-popups', + 'landscape', + 'scroll-page', +]); + +const valueFlags = new Set([ + 'delay-ms', + 'format', + 'height', + 'output', + 'paper', + 'quality', + 'scale', + 'timeout-ms', + 'url', + 'wait-until', + 'width', +]); + +class CliError extends Error { + constructor(message, code = 'invalid_arguments', exitCode = 2) { + super(message); + this.name = 'CliError'; + this.code = code; + this.exitCode = exitCode; + } +} + +function help() { + return `Usage: + node scripts/latchshot.mjs capture --url URL --output FILE [OPTIONS] + node scripts/latchshot.mjs demo --url URL --output FILE [OPTIONS] + node scripts/latchshot.mjs usage + +Capture one public HTTP(S) webpage as a validated local artifact. The demo +command needs no key and returns JPEG only, limited to 3 attempts per IP/hour. +Capture and usage read LATCHSHOT_API_KEY; keys are never accepted as arguments. + +Demo options: + --width 320..2560 Viewport width (default: 1440) + --height 240..1440 Viewport height (default: 900) + --allow-query Confirm a query string contains no secret + --force Replace an existing output file atomically + +Capture options: + --format png|jpeg|pdf Infer from output extension when omitted + --width 320..2560 Viewport width (default: 1440) + --height 240..1440 Viewport height (default: 900) + --scale 1|2 Device scale factor (default: 1) + --quality 1..100 JPEG only (default: 85) + --full-page Capture bounded full page (screenshots only) + --scroll-page Bounded lazy-content scroll; requires --full-page + --wait-until load|domcontentloaded|networkidle + --delay-ms 0..3000 Additional post-load wait + --timeout-ms 3000..30000 Navigation timeout + --dark Prefer dark color scheme + --block-ads Best-effort known ad-host blocking + --block-trackers Best-effort known tracker-host blocking + --block-chats Best-effort known chat-host blocking + --hide-cookie-banners Hide common consent overlays without clicking + --hide-popups Hide common signup/newsletter/discount overlays + --paper A4|Letter|Legal PDF only (default: A4) + --landscape PDF only + --allow-query Confirm a query string contains no secret + --force Replace an existing output file atomically + --help Show this help + +Examples: + node scripts/latchshot.mjs demo --url https://example.com --output demo.jpg + node scripts/latchshot.mjs capture --url https://example.com --output page.png + node scripts/latchshot.mjs capture --url https://example.com --output page.pdf --paper A4 + node scripts/latchshot.mjs usage + +Exit codes: 0 success, 2 invalid input, 3 missing key, 4 API/network failure, +5 invalid artifact or filesystem failure.`; +} + +function parseFlags(tokens) { + const flags = new Map(); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token.startsWith('--')) { + throw new CliError(`unexpected argument: ${token}`); + } + + const separator = token.indexOf('='); + const name = token.slice(2, separator === -1 ? undefined : separator); + if (flags.has(name)) throw new CliError(`duplicate option: --${name}`); + + if (name === 'help') { + flags.set(name, true); + continue; + } + if (booleanFlags.has(name)) { + if (separator !== -1) throw new CliError(`--${name} does not accept a value`); + flags.set(name, true); + continue; + } + if (!valueFlags.has(name)) throw new CliError(`unknown option: --${name}`); + + const value = separator === -1 ? tokens[++index] : token.slice(separator + 1); + if (value === undefined || value.startsWith('--') || value === '') { + throw new CliError(`--${name} requires a value`); + } + flags.set(name, value); + } + return flags; +} + +function integerFlag(flags, name, fallback, minimum, maximum) { + const raw = flags.get(name); + const value = raw === undefined ? fallback : Number(raw); + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new CliError(`--${name} must be an integer from ${minimum} to ${maximum}`); + } + return value; +} + +function choiceFlag(flags, name, fallback, allowed) { + const value = flags.get(name) ?? fallback; + if (!allowed.includes(value)) { + throw new CliError(`--${name} must be one of: ${allowed.join(', ')}`); + } + return value; +} + +function targetUrl(raw, allowQuery) { + if (!raw) throw new CliError('--url is required'); + let target; + try { + target = new URL(raw); + } catch { + throw new CliError('--url must be a valid absolute HTTP or HTTPS URL', 'invalid_target'); + } + if (!['http:', 'https:'].includes(target.protocol)) { + throw new CliError('--url must use http or https', 'invalid_target'); + } + if (target.username || target.password) { + throw new CliError('--url must not contain credentials', 'invalid_target'); + } + if (target.port && !['80', '443'].includes(target.port)) { + throw new CliError('--url may use only web ports 80 or 443', 'invalid_target'); + } + if (target.search && !allowQuery) { + throw new CliError('--url has a query string; verify it contains no secret, then pass --allow-query', 'query_confirmation_required'); + } + if (target.hash) { + throw new CliError('--url must not contain a fragment', 'invalid_target'); + } + return target.href; +} + +function apiKey(env) { + const key = env.LATCHSHOT_API_KEY; + if (!key || typeof key !== 'string' || /\s/.test(key)) { + throw new CliError('set LATCHSHOT_API_KEY to a Latchshot key; do not pass it as an argument', 'missing_api_key', 3); + } + return key; +} + +function outputContract(flags) { + const raw = flags.get('output'); + if (!raw || raw === '-') throw new CliError('--output is required and must be a file path'); + const output = resolve(raw); + const inferred = FORMAT_BY_EXTENSION.get(extname(output).toLowerCase()); + if (!inferred) throw new CliError('--output must end in .png, .jpg, .jpeg, or .pdf'); + const format = choiceFlag(flags, 'format', inferred, ['png', 'jpeg', 'pdf']); + if (format !== inferred) { + throw new CliError(`--format ${format} does not match output file ${basename(output)}`); + } + return { output, format }; +} + +function capturePayload(flags) { + const { output, format } = outputContract(flags); + const fullPage = flags.has('full-page'); + const scrollPage = flags.has('scroll-page'); + if (scrollPage && !fullPage) throw new CliError('--scroll-page requires --full-page'); + if (format === 'pdf' && (fullPage || scrollPage)) { + throw new CliError('--full-page and --scroll-page are screenshot options, not PDF options'); + } + if (flags.has('quality') && format !== 'jpeg') { + throw new CliError('--quality is supported only for JPEG output'); + } + if (format !== 'pdf' && (flags.has('paper') || flags.has('landscape'))) { + throw new CliError('--paper and --landscape are supported only for PDF output'); + } + + const payload = { + url: targetUrl(flags.get('url'), flags.has('allow-query')), + format, + width: integerFlag(flags, 'width', 1440, 320, 2560), + height: integerFlag(flags, 'height', 900, 240, 1440), + scale: integerFlag(flags, 'scale', 1, 1, 2), + fullPage, + scrollPage, + waitUntil: choiceFlag(flags, 'wait-until', 'domcontentloaded', ['load', 'domcontentloaded', 'networkidle']), + delayMs: integerFlag(flags, 'delay-ms', 0, 0, 3000), + timeoutMs: integerFlag(flags, 'timeout-ms', 15000, 3000, 30000), + darkMode: flags.has('dark'), + blockAds: flags.has('block-ads'), + blockTrackers: flags.has('block-trackers'), + blockChats: flags.has('block-chats'), + hideCookieBanners: flags.has('hide-cookie-banners'), + hidePopups: flags.has('hide-popups'), + }; + + if (format === 'jpeg') payload.quality = integerFlag(flags, 'quality', 85, 1, 100); + if (format === 'pdf') { + payload.paper = choiceFlag(flags, 'paper', 'A4', ['A4', 'Letter', 'Legal']); + payload.landscape = flags.has('landscape'); + } + return { output, format, payload, force: flags.has('force') }; +} + +function demoPayload(flags) { + const allowed = new Set(['allow-query', 'force', 'height', 'output', 'url', 'width']); + for (const name of flags.keys()) { + if (!allowed.has(name)) throw new CliError(`demo does not accept --${name}`); + } + const { output, format } = outputContract(flags); + if (format !== 'jpeg') throw new CliError('demo output must end in .jpg or .jpeg'); + return { + output, + format, + payload: { + url: targetUrl(flags.get('url'), flags.has('allow-query')), + width: integerFlag(flags, 'width', 1440, 320, 2560), + height: integerFlag(flags, 'height', 900, 240, 1440), + }, + force: flags.has('force'), + }; +} + +async function readBounded(response, maximum) { + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > maximum) { + throw new CliError(`response exceeds ${maximum} bytes`, 'response_too_large', 5); + } + if (!response.body) return Buffer.alloc(0); + + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maximum) { + await reader.cancel(); + throw new CliError(`response exceeds ${maximum} bytes`, 'response_too_large', 5); + } + chunks.push(Buffer.from(value)); + } + return Buffer.concat(chunks, total); +} + +function redact(value) { + return String(value || '') + .replace(/ls_live_[A-Za-z0-9_-]+/g, '[redacted-key]') + .replace(/https?:\/\/[^\s"']+/g, '[redacted-url]') + .replace(/[\r\n]+/g, ' ') + .slice(0, 500); +} + +function apiFailure(status, body) { + let code = `http_${status}`; + let message = `Latchshot returned HTTP ${status}`; + try { + const parsed = JSON.parse(body.toString('utf8')); + code = redact(parsed?.error?.code || code); + message = redact(parsed?.error?.message || message); + } catch { + const text = redact(body.toString('utf8')); + if (text) message = text; + } + throw new CliError(message, code, 4); +} + +function validateArtifact(format, contentType, body) { + const expected = CONTENT_TYPE_BY_FORMAT[format]; + if (contentType !== expected) { + throw new CliError(`expected ${expected} but received ${contentType || 'no content type'}`, 'invalid_content_type', 5); + } + const valid = format === 'png' + ? body.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) + : format === 'jpeg' + ? body.length >= 4 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff + : body.subarray(0, 5).equals(Buffer.from('%PDF-')); + if (!valid) throw new CliError(`response body is not a valid ${format} artifact`, 'invalid_artifact', 5); +} + +async function exists(path) { + try { + await access(path, fsConstants.F_OK); + return true; + } catch { + return false; + } +} + +async function writeAtomic(output, body, force) { + if (!force && await exists(output)) { + throw new CliError(`output already exists: ${output}; choose another path or pass --force`, 'output_exists', 5); + } + await mkdir(dirname(output), { recursive: true }); + const temporary = `${output}.latchshot-${process.pid}-${randomBytes(6).toString('hex')}.tmp`; + let handle; + try { + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(body); + await handle.sync(); + await handle.close(); + handle = null; + await rename(temporary, output); + } catch (error) { + if (handle) await handle.close().catch(() => {}); + await rm(temporary, { force: true }).catch(() => {}); + if (error instanceof CliError) throw error; + throw new CliError(`could not write output: ${redact(error.message)}`, 'output_failed', 5); + } +} + +function numericHeader(headers, name) { + const value = headers.get(name); + if (value === null) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +async function capture(flags, env, fetchImpl) { + const key = apiKey(env); + const { output, format, payload, force } = capturePayload(flags); + if (!force && await exists(output)) { + throw new CliError(`output already exists: ${output}; choose another path or pass --force`, 'output_exists', 5); + } + let response; + try { + response = await fetchImpl(CAPTURE_URL, { + method: 'POST', + headers: { + authorization: `Bearer ${key}`, + 'content-type': 'application/json', + 'user-agent': USER_AGENT, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(payload.timeoutMs + 15000), + }); + } catch (error) { + throw new CliError(`request failed: ${redact(error.message)}`, 'request_failed', 4); + } + + const body = await readBounded(response, response.ok ? MAX_ARTIFACT_BYTES : MAX_JSON_BYTES); + if (!response.ok) apiFailure(response.status, body); + const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); + validateArtifact(format, contentType, body); + await writeAtomic(output, body, force); + const file = await stat(output); + + return { + ok: true, + operation: 'capture', + output, + format, + contentType, + bytes: file.size, + render: { + durationMs: numericHeader(response.headers, 'x-latchshot-render-ms'), + navigation: response.headers.get('x-latchshot-navigation'), + fonts: response.headers.get('x-latchshot-fonts'), + scripts: response.headers.get('x-latchshot-scripts'), + scroll: response.headers.get('x-latchshot-scroll'), + }, + quota: { + limit: numericHeader(response.headers, 'x-quota-limit'), + remaining: numericHeader(response.headers, 'x-quota-remaining'), + resetAt: response.headers.get('x-quota-reset'), + }, + }; +} + +async function demo(flags, fetchImpl) { + const { output, format, payload, force } = demoPayload(flags); + if (!force && await exists(output)) { + throw new CliError(`output already exists: ${output}; choose another path or pass --force`, 'output_exists', 5); + } + let response; + try { + response = await fetchImpl(DEMO_URL, { + method: 'POST', + headers: { + accept: 'image/jpeg', + 'content-type': 'application/json', + 'user-agent': USER_AGENT, + 'x-latchshot-acquisition': 'agentskill', + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(30000), + }); + } catch (error) { + throw new CliError(`request failed: ${redact(error.message)}`, 'request_failed', 4); + } + + const body = await readBounded(response, response.ok ? MAX_ARTIFACT_BYTES : MAX_JSON_BYTES); + if (!response.ok) apiFailure(response.status, body); + const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); + validateArtifact(format, contentType, body); + await writeAtomic(output, body, force); + const file = await stat(output); + + return { + ok: true, + operation: 'demo', + output, + format, + contentType, + bytes: file.size, + render: { + durationMs: numericHeader(response.headers, 'x-latchshot-render-ms'), + navigation: response.headers.get('x-latchshot-navigation'), + fonts: response.headers.get('x-latchshot-fonts'), + scripts: response.headers.get('x-latchshot-scripts'), + }, + demo: { + authenticationRequired: false, + attemptsPerIpPerHour: 3, + }, + }; +} + +async function usage(env, fetchImpl) { + const key = apiKey(env); + let response; + try { + response = await fetchImpl(USAGE_URL, { + headers: { + authorization: `Bearer ${key}`, + accept: 'application/json', + 'user-agent': USER_AGENT, + }, + signal: AbortSignal.timeout(15000), + }); + } catch (error) { + throw new CliError(`request failed: ${redact(error.message)}`, 'request_failed', 4); + } + const body = await readBounded(response, MAX_JSON_BYTES); + if (!response.ok) apiFailure(response.status, body); + try { + return { ok: true, operation: 'usage', usage: JSON.parse(body.toString('utf8')) }; + } catch { + throw new CliError('usage response was not valid JSON', 'invalid_usage_response', 5); + } +} + +export async function run(argv, options = {}) { + const env = options.env || process.env; + const fetchImpl = options.fetchImpl || globalThis.fetch; + const stdout = options.stdout || ((line) => console.log(line)); + const stderr = options.stderr || ((line) => console.error(line)); + + try { + if (argv.length === 0 || argv.includes('--help')) { + stdout(help()); + return 0; + } + const [command, ...tokens] = argv; + const flags = parseFlags(tokens); + if (flags.has('help')) { + stdout(help()); + return 0; + } + let result; + if (command === 'capture') result = await capture(flags, env, fetchImpl); + else if (command === 'demo') result = await demo(flags, fetchImpl); + else if (command === 'usage') { + if (flags.size > 0) throw new CliError('usage does not accept options'); + result = await usage(env, fetchImpl); + } else { + throw new CliError(`command must be capture, demo, or usage; received: ${command}`); + } + stdout(JSON.stringify(result)); + return 0; + } catch (error) { + const safe = error instanceof CliError + ? error + : new CliError(redact(error.message), 'unexpected_error', 5); + stderr(JSON.stringify({ ok: false, error: { code: safe.code, message: redact(safe.message) } })); + return safe.exitCode; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + process.exitCode = await run(process.argv.slice(2)); +}