@hunt-labs/bytekit-cli 0.3.0 → 0.6.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.
@@ -1,10 +1,16 @@
1
1
  import { Command } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError, resolveOutputMode, emitResult } from '../output.js';
4
- import { parseUrlLines } from '../lib/url-lines.js';
5
- import { parseIntFlag, parseEnum, parseCsv } from '../lib/parse-flags.js';
4
+ import { parseBulkLines, toBulkItems } from '../lib/url-lines.js';
5
+ import { parseIntFlag, parseEnum, parseCsv, parseCacheTtl, parseJsonFlag, collectFormats, } from '../lib/parse-flags.js';
6
6
  /** The `ScrapeRequest.markdown_mode` spec enum. */
7
7
  const MARKDOWN_MODES = ['article', 'raw', 'llm'];
8
+ /** The `ScrapeRequest.events` item enum. */
9
+ const SCRAPE_EVENTS = ['queued', 'completed', 'failed'];
10
+ /** The `ScrapeRequest.token_encoding` spec enum. */
11
+ const TOKEN_ENCODINGS = ['cl100k_base', 'o200k_base'];
12
+ /** The `markdown_links` / `markdown_images` render-mode enum. */
13
+ const MARKDOWN_RENDER_MODES = ['inline', 'references', 'none', 'text'];
8
14
  /**
9
15
  * Exit code when a queued (202 async) envelope reaches a non-JSON output mode.
10
16
  *
@@ -13,8 +19,11 @@ const MARKDOWN_MODES = ['article', 'raw', 'llm'];
13
19
  * that PR's symbols while the two guards still compose idempotently post-merge. Deliberately NOT 0
14
20
  * (a content-body success) and NOT 1 (a usage/API error): the job WAS accepted and billed, there
15
21
  * is simply no content body yet, and a distinct code lets a caller branch on "queued" without
16
- * parsing stderr. Human mode shares the same code (soft `process.exitCode`) so "queued, not
17
- * complete" is branchable there too, for consistency with the raw-mode signal.
22
+ * parsing stderr.
23
+ *
24
+ * BOTH modes carry it as a soft `process.exitCode` (#2544): raw mode used to hard-`process.exit`,
25
+ * which runs before Node drains an ASYNC piped stdout and could discard the id it had just
26
+ * written — the exact `$(…)` capture the raw branch exists for. Human mode was already soft.
18
27
  */
19
28
  const QUEUED_EXIT_CODE = 2;
20
29
  /**
@@ -36,15 +45,34 @@ export function makeScrapeCommand(getGlobal) {
36
45
  bulkCmd
37
46
  .command('create')
38
47
  .description('Create a bulk scrape job')
39
- .requiredOption('--file <path>', 'Path to newline-delimited file of URLs (plain URL or {"url":"..."} per line)')
48
+ .requiredOption('--file <path>', 'Path to a newline-delimited file. Each line is a plain URL, or a JSON object carrying "url" plus per-item config. A line whose only key is "url" behaves exactly as before (sent as a bare URL string); any line with additional keys switches the payload to the per-item shape.')
40
49
  .requiredOption('--webhook-url <url>', 'HTTPS webhook URL notified when the job completes')
50
+ .option('--webhook-secret <secret>', 'HMAC secret used to sign the completion webhook (omitted → unsigned)')
51
+ .option('--defaults <json>', 'Defaults applied to every entry, as a JSON object (omitted → none). Unknown keys are rejected by /v1/bulk (422) and ignored by scrape bulk / fetch bulk; fetch bulk additionally rejects browser-only keys (cookies, wait_until, wait_for_selector, delay_ms) with a 400.', (v) => parseJsonFlag('--defaults', v, 'object'))
52
+ .option('--metadata <json>', `Arbitrary metadata echoed on the job envelope, as a JSON object, e.g. '{"run":"7"}' (omitted → none)`, (v) => parseJsonFlag('--metadata', v, 'object'))
53
+ // /v1/scrape/bulk is the ONE bulk endpoint with a top-level `custom`
54
+ // (validators/scrape-bulk.ts:88). Per-entry `custom` arrives through --file lines instead.
55
+ .option('--custom <json>', `User payload echoed on every scrape entry, as a JSON object, e.g. '{"job":"1"}' (omitted → none; a per-entry value in --file wins)`, (v) => parseJsonFlag('--custom', v, 'object'))
41
56
  .action(async (opts) => {
42
57
  const g = getGlobal();
43
58
  const client = buildClient(g);
44
59
  try {
45
60
  // readFileSync throws ENOENT on missing file — caught by handleError below
46
- const urls = parseUrlLines(opts.file);
47
- const result = await client.scrape.bulk.create({ urls, webhook_url: opts.webhookUrl });
61
+ const lines = parseBulkLines(opts.file);
62
+ // `urls` XOR `items` (validators/scrape-bulk.ts:91-98) — send exactly one, keeping the
63
+ // pre-#2596 `{ urls, webhook_url }` body byte-identical for url-only files (E2).
64
+ const body = lines.every((l) => typeof l === 'string')
65
+ ? { urls: lines, webhook_url: opts.webhookUrl }
66
+ : { items: toBulkItems(lines), webhook_url: opts.webhookUrl };
67
+ if (opts.webhookSecret !== undefined)
68
+ body['webhook_secret'] = opts.webhookSecret;
69
+ if (opts.defaults !== undefined)
70
+ body['defaults'] = opts.defaults;
71
+ if (opts.metadata !== undefined)
72
+ body['metadata'] = opts.metadata;
73
+ if (opts.custom !== undefined)
74
+ body['custom'] = opts.custom;
75
+ const result = await client.scrape.bulk.create(body);
48
76
  printResult(result, g.json);
49
77
  }
50
78
  catch (err) {
@@ -70,10 +98,34 @@ export function makeScrapeCommand(getGlobal) {
70
98
  .command('create')
71
99
  .description('Create a scrape job')
72
100
  .requiredOption('--url <url>', 'URL to scrape')
73
- .option('--format <format>', 'Output format (e.g. markdown, html); sent as the formats array')
101
+ // Repeatable AND CSV-aware: `--format raw_html --format markdown` and `--format raw_html,markdown`
102
+ // both send formats: ['raw_html','markdown']. Sent as the `formats` array; omitted → server
103
+ // default. `--raw`/`-o` require EXACTLY ONE format (validated pre-request; see the action).
104
+ .option('--format <format>', 'Output format; repeat or comma-separate for multiple (e.g. --format raw_html --format markdown, or --format raw_html,markdown). Sent as the formats array; omitted → server default.', collectFormats)
74
105
  .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
75
106
  .option('--timeout-ms <n>', 'Per-request timeout in milliseconds', (v) => parseIntFlag('--timeout-ms', v))
76
107
  .option('--markdown-mode <mode>', `Markdown extraction mode: ${MARKDOWN_MODES.join(', ')}`, (v) => parseEnum('--markdown-mode', MARKDOWN_MODES, v))
108
+ .option('--markdown-query <q>', 'BM25 query string for relevance-ranked markdown filtering (omitted → disabled)')
109
+ .option('--cache-ttl <ttl>', 'Cache freshness: 0, Nh (hours), or Nd (days), e.g. 48h, 2d, 0 (omitted → server default)', (v) => parseCacheTtl('--cache-ttl', v))
110
+ .option('--cookies <json>', `Cookies as a JSON array, e.g. '[{"name":"a","value":"b"}]' (omitted → none)`, (v) => parseJsonFlag('--cookies', v, 'array'))
111
+ .option('--headers <json>', `Extra request headers as a JSON object, e.g. '{"X-A":"b"}' (omitted → none)`, (v) => parseJsonFlag('--headers', v, 'object'))
112
+ .option('--delay-ms <n>', 'Post-load delay in milliseconds before capture', (v) => parseIntFlag('--delay-ms', v))
113
+ .option('--events <csv>', `Webhook events (comma-separated): ${SCRAPE_EVENTS.join(', ')} (omitted → server default)`, (v) => parseCsv(v).map((e) => parseEnum('--events', SCRAPE_EVENTS, e)))
114
+ .option('--token-encoding <enc>', `Tokenizer for --token-budget: ${TOKEN_ENCODINGS.join(', ')} (omitted → server default)`, (v) => parseEnum('--token-encoding', TOKEN_ENCODINGS, v))
115
+ .option('--custom <json>', `User payload echoed on the envelope as a JSON object, e.g. '{"job":"1"}' (omitted → none)`, (v) => parseJsonFlag('--custom', v, 'object'))
116
+ // Server default is TRUE, so this is a tri-state pair: --remove-base64-images → true,
117
+ // --no-remove-base64-images → false, neither → key omitted (never materialize the default, E6).
118
+ .option('--remove-base64-images', 'Strip base64 data: images before conversion (server default)')
119
+ .option('--no-remove-base64-images', 'Preserve base64 data: images in the pipeline output')
120
+ .option('--markdown-links <mode>', `Link rendering: ${MARKDOWN_RENDER_MODES.join(', ')}`, (v) => parseEnum('--markdown-links', MARKDOWN_RENDER_MODES, v))
121
+ .option('--markdown-images <mode>', `Image retention: ${MARKDOWN_RENDER_MODES.join(', ')}`, (v) => parseEnum('--markdown-images', MARKDOWN_RENDER_MODES, v))
122
+ .option('--with-links-summary', 'Append a Links footer to the markdown output')
123
+ .option('--with-images-summary', 'Append an Images footer to the markdown output')
124
+ .option('--markdown-compact', 'Collapse excessive whitespace in the markdown output')
125
+ .option('--markdown-filter-images', 'Filter low-signal images from the markdown output')
126
+ .option('--markdown-include-media', 'Return rich link/image objects and a tables array')
127
+ .option('--markdown-include-warnings', 'Include markdown-pipeline and tag-filter warnings')
128
+ .option('--markdown-include-stats', 'Include a top-level stats object (chars, tokens, blocks)')
77
129
  .option('--async', 'Queue the scrape and return a job envelope immediately (no wait)')
78
130
  .option('--webhook-url <url>', 'HTTPS webhook URL notified when an async scrape completes')
79
131
  .option('--token-budget <n>', 'Maximum tokens of markdown to return', (v) => parseIntFlag('--token-budget', v))
@@ -87,11 +139,12 @@ export function makeScrapeCommand(getGlobal) {
87
139
  const g = getGlobal();
88
140
  const client = buildClient(g);
89
141
  const body = { url: opts.url };
90
- // The v1 scrape validator expects `formats` (array), not `format` (string).
91
- // Omit the key entirely when --format is not provided so we never send
92
- // formats: ["undefined"].
93
- if (opts.format !== undefined)
94
- body['formats'] = [opts.format];
142
+ // The v1 scrape validator expects `formats` (array). The --format collector already
143
+ // accumulates repeated/CSV occurrences into an array; write it verbatim, omitting the key
144
+ // entirely when --format is absent so we never send formats: [] or ["undefined"].
145
+ const formats = opts.format;
146
+ if (formats !== undefined)
147
+ body['formats'] = formats;
95
148
  // Each optional flag maps 1:1 to its snake_case ScrapeRequest field and is written ONLY
96
149
  // when the flag is present — a boolean must never serialize `false` (#2480 E1).
97
150
  if (opts.country !== undefined)
@@ -100,6 +153,44 @@ export function makeScrapeCommand(getGlobal) {
100
153
  body['timeout_ms'] = opts.timeoutMs;
101
154
  if (opts.markdownMode !== undefined)
102
155
  body['markdown_mode'] = opts.markdownMode;
156
+ if (opts.markdownQuery !== undefined)
157
+ body['markdown_query'] = opts.markdownQuery;
158
+ if (opts.cacheTtl !== undefined)
159
+ body['cache_ttl'] = opts.cacheTtl;
160
+ if (opts.cookies !== undefined)
161
+ body['cookies'] = opts.cookies;
162
+ if (opts.headers !== undefined)
163
+ body['headers'] = opts.headers;
164
+ if (opts.delayMs !== undefined)
165
+ body['delay_ms'] = opts.delayMs;
166
+ if (opts.events !== undefined)
167
+ body['events'] = opts.events;
168
+ if (opts.tokenEncoding !== undefined)
169
+ body['token_encoding'] = opts.tokenEncoding;
170
+ if (opts.custom !== undefined)
171
+ body['custom'] = opts.custom;
172
+ // Tri-state boolean: commander yields `undefined` when neither --remove-base64-images nor
173
+ // --no-remove-base64-images is passed, so the key is omitted and the server default holds.
174
+ if (opts.removeBase64Images !== undefined)
175
+ body['remove_base64_images'] = opts.removeBase64Images;
176
+ if (opts.markdownLinks !== undefined)
177
+ body['markdown_links'] = opts.markdownLinks;
178
+ if (opts.markdownImages !== undefined)
179
+ body['markdown_images'] = opts.markdownImages;
180
+ if (opts.withLinksSummary === true)
181
+ body['with_links_summary'] = true;
182
+ if (opts.withImagesSummary === true)
183
+ body['with_images_summary'] = true;
184
+ if (opts.markdownCompact === true)
185
+ body['markdown_compact'] = true;
186
+ if (opts.markdownFilterImages === true)
187
+ body['markdown_filter_images'] = true;
188
+ if (opts.markdownIncludeMedia === true)
189
+ body['markdown_include_media'] = true;
190
+ if (opts.markdownIncludeWarnings === true)
191
+ body['markdown_include_warnings'] = true;
192
+ if (opts.markdownIncludeStats === true)
193
+ body['markdown_include_stats'] = true;
103
194
  if (opts.async === true)
104
195
  body['async'] = true;
105
196
  if (opts.webhookUrl !== undefined)
@@ -122,6 +213,12 @@ export function makeScrapeCommand(getGlobal) {
122
213
  // handleError()'s clean one-liner. buildClient() does no network I/O, so rejecting the
123
214
  // --json/--raw conflict here still happens before any request is issued.
124
215
  const mode = resolveOutputMode({ json: g.json, raw: opts.raw, output: opts.output });
216
+ // Raw mode (--raw or a bare -o) emits a SINGLE format body; more than one format is
217
+ // ambiguous, so reject before the request (E2). Thrown inside the try so handleError()
218
+ // prints the clean one-liner and no scrape.create call is ever recorded.
219
+ if (mode.kind === 'raw' && formats !== undefined && formats.length > 1) {
220
+ throw new Error('--raw requires exactly one --format');
221
+ }
125
222
  const result = await client.scrape.create(body);
126
223
  // Queued-envelope precedence (#2480 E4s): a `--async` scrape returns a 202
127
224
  // `{ id, status: 'queued' }` envelope with NO formats map. Detect that shape here —
@@ -131,8 +228,10 @@ export function makeScrapeCommand(getGlobal) {
131
228
  // indistinguishable from that one):
132
229
  // - raw mode (`--raw` or a bare `-o`): the bare sc_ id on STDOUT (a usable
133
230
  // `$(bytekit scrape create … --raw)` handle), a human poll note on STDERR, exit
134
- // QUEUED_EXIT_CODE. The `-o` file is deliberately NEVER written — there is no
135
- // content body yet, and a file holding a bare id would masquerade as content.
231
+ // QUEUED_EXIT_CODE via soft `process.exitCode` (#2544 — a hard `process.exit` runs
232
+ // before Node drains an ASYNC piped stdout and would discard the very id this
233
+ // branch exists to deliver). The `-o` file is deliberately NEVER written — there is
234
+ // no content body yet, and a file holding a bare id would masquerade as content.
136
235
  // - json mode (`--json`, with or without `-o`): OUTSIDE the raw rule — fall through
137
236
  // to emitResult, which emits the envelope verbatim (exit 0).
138
237
  // - human default: keep the readable id/status dump, but signal queued-not-complete
@@ -143,7 +242,8 @@ export function makeScrapeCommand(getGlobal) {
143
242
  console.error(`Note: scrape ${result.id} was queued (async) — no content body is available yet. ` +
144
243
  `Poll it with: bytekit scrape get ${result.id}`);
145
244
  process.stdout.write(`${result.id}\n`);
146
- process.exit(QUEUED_EXIT_CODE);
245
+ process.exitCode = QUEUED_EXIT_CODE;
246
+ return;
147
247
  }
148
248
  if (mode.kind === 'human') {
149
249
  printResult(result, false);
@@ -151,7 +251,9 @@ export function makeScrapeCommand(getGlobal) {
151
251
  return;
152
252
  }
153
253
  }
154
- emitResult(result, mode, { file: opts.output }, opts.format);
254
+ // At this point raw mode has at most one format (E2 guard above), so formats[0] is the
255
+ // single requested body selector; json/human modes ignore the argument.
256
+ emitResult(result, mode, { file: opts.output }, formats?.[0]);
155
257
  }
156
258
  catch (err) {
157
259
  handleError(err);
@@ -159,13 +261,22 @@ export function makeScrapeCommand(getGlobal) {
159
261
  });
160
262
  cmd
161
263
  .command('get <id>')
162
- .description('Get a scrape job by ID')
163
- .action(async (id) => {
264
+ .description('Get a scrape job by ID (use --raw/-o/--format to retrieve a completed body)')
265
+ .option('--raw', 'Print the requested format body raw, with no JSON wrapper')
266
+ .option('-o, --output <file>', 'Write the output to the given file path (implies --raw unless --json is set)')
267
+ .option('--format <format>', 'Which persisted format to emit — only takes effect with --raw/-o; ignored otherwise')
268
+ .action(async (id, opts) => {
164
269
  const g = getGlobal();
165
270
  const client = buildClient(g);
166
271
  try {
272
+ // Mirror `scrape create`'s output path: resolveOutputMode FIRST inside the try (so a
273
+ // --json/--raw conflict rejects before the request), then emitResult. The request body is
274
+ // unchanged — scrape.get(id) takes no options — so the inverse-case arg-length assertion in
275
+ // request-shapes.test.ts stays green. A still-queued job flows through emitResult's
276
+ // queued-envelope branch (id + soft exit 2, #2508/#2544); a completed job emits its body.
277
+ const mode = resolveOutputMode({ json: g.json, raw: opts.raw, output: opts.output });
167
278
  const result = await client.scrape.get(id);
168
- printResult(result, g.json);
279
+ emitResult(result, mode, { file: opts.output }, opts.format);
169
280
  }
170
281
  catch (err) {
171
282
  handleError(err);
@@ -2,11 +2,13 @@ import { Command, InvalidArgumentError } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError } from '../output.js';
4
4
  import { downloadArtifact } from '../lib/download-artifact.js';
5
- import { parseEnum, parseIntFlag } from '../lib/parse-flags.js';
5
+ import { parseEnum, parseIntFlag, parseJsonFlag } from '../lib/parse-flags.js';
6
6
  /** `ScreenshotRequest` spec enums. */
7
7
  const DEVICES = ['desktop', 'mobile'];
8
8
  const FORMATS = ['jpeg', 'png'];
9
9
  const WAIT_UNTILS = ['load', 'domcontentloaded', 'networkidle'];
10
+ /** `device_scale_factor` accepts only 1 or 2 (spec enum [1, 2, null]); parsed as a string, sent numeric. */
11
+ const DEVICE_SCALE_FACTORS = ['1', '2'];
10
12
  /** Parse `--viewport WxH` (e.g. `1280x720`) into `{ width, height }`; reject malformed input. */
11
13
  export function parseViewport(value) {
12
14
  const m = /^(\d+)x(\d+)$/.exec(value);
@@ -21,6 +23,24 @@ export function parseClip(value) {
21
23
  throw new InvalidArgumentError('--clip must be X,Y,WIDTH,HEIGHT (e.g. 0,0,800,600).');
22
24
  return { x: Number(m[1]), y: Number(m[2]), width: Number(m[3]), height: Number(m[4]) };
23
25
  }
26
+ /**
27
+ * Exit code when a queued (202 async) envelope reaches human output (#2596).
28
+ *
29
+ * Mirrors `scrape create`'s own `QUEUED_EXIT_CODE` (`commands/scrape.ts:40`) EXACTLY, and is
30
+ * likewise a LOCAL constant rather than an import — the two commands agree on the value, not on a
31
+ * shared symbol, and the guard stays where the ticket pins it (the screenshots async branch)
32
+ * instead of migrating into the shared printer, where it would silently set exit 2 for every
33
+ * command that prints a result.
34
+ *
35
+ * Deliberately NOT 0 (a completed capture) and NOT 1 (a usage/API error): the job WAS accepted and
36
+ * billed, the artifact simply does not exist yet, and a distinct code lets a caller branch on
37
+ * "queued" without parsing stderr.
38
+ *
39
+ * BREAKING (#2596): `screenshots create --async` previously exited 0. See the README exit-code
40
+ * table and the CHANGELOG `[0.6.0]` note (this version's own section — `[Unreleased]` is kept
41
+ * deliberately empty per `packaging.test.ts:203`; see #2620).
42
+ */
43
+ const QUEUED_EXIT_CODE = 2;
24
44
  /**
25
45
  * Extract a non-empty string `image_url` from a screenshot envelope, or throw an actionable error.
26
46
  *
@@ -51,6 +71,21 @@ function extractImageUrl(result) {
51
71
  async function emitScreenshot(result, opts) {
52
72
  if (opts.isAsync === true) {
53
73
  printResult(result, opts.json);
74
+ // Discriminate on the --async FLAG, never on the response body. The screenshot 202 reports a
75
+ // PENDING status (gateway src/routes/screenshots.ts:138) — not the queued status that
76
+ // `scrape create`'s envelope guard (commands/scrape.ts:49-53) tests for. Reusing that guard
77
+ // here would never fire in production and this branch would silently no-op. Grep gate G11
78
+ // (#2596) enforces the absence of a response-status discriminator in this file, which is why
79
+ // neither the symbol nor the comparison is spelled out above.
80
+ //
81
+ // The assignment sits INSIDE this branch, ahead of the `-o` return below, so `--async -o`
82
+ // carries the same exit code as bare `--async` (#2596 DoD 15 / E20). `--json` keeps exit 0:
83
+ // the envelope is a complete machine-readable payload, identical to scrape's split
84
+ // (scrape.ts:313-317). Soft `process.exitCode`, never `process.exit` — a hard exit runs
85
+ // before Node drains an async piped stdout and would truncate the envelope just written
86
+ // (#2544).
87
+ if (!opts.json)
88
+ process.exitCode = QUEUED_EXIT_CODE;
54
89
  return;
55
90
  }
56
91
  if (opts.output !== undefined) {
@@ -72,6 +107,24 @@ export function makeScreenshotsCommand(getGlobal) {
72
107
  .option('--quality <n>', 'JPEG quality (1-100)', (v) => parseIntFlag('--quality', v))
73
108
  .option('--wait-until <event>', `Wait condition: ${WAIT_UNTILS.join(', ')}`, (v) => parseEnum('--wait-until', WAIT_UNTILS, v))
74
109
  .option('--clip <x,y,w,h>', 'Clip region X,Y,WIDTH,HEIGHT (e.g. 0,0,800,600)', parseClip)
110
+ // Tri-state pairs — server default is TRUE, so define BOTH --x and --no-x: commander yields
111
+ // `undefined` when neither is passed, and the key is omitted (never materialize the default, E6).
112
+ .option('--block-ads', 'Block ads before capture (server default)')
113
+ .option('--no-block-ads', 'Do not block ads')
114
+ .option('--block-cookie-banners', 'Block cookie-consent banners before capture (server default)')
115
+ .option('--no-block-cookie-banners', 'Do not block cookie-consent banners')
116
+ .option('--scroll', 'Scroll the page before capture to trigger lazy content (server default)')
117
+ .option('--no-scroll', 'Do not scroll the page before capture')
118
+ .option('--wait-for-selector <sel>', 'Wait until this CSS selector is present before capture')
119
+ .option('--delay-ms <n>', 'Post-load delay in milliseconds before capture', (v) => parseIntFlag('--delay-ms', v))
120
+ .option('--dark-mode', 'Emulate prefers-color-scheme: dark')
121
+ .option('--device-scale-factor <n>', `Device pixel ratio: ${DEVICE_SCALE_FACTORS.join(', ')}`, (v) => parseEnum('--device-scale-factor', DEVICE_SCALE_FACTORS, v))
122
+ .option('--include-html', 'Include the rendered HTML alongside the screenshot')
123
+ .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
124
+ .option('--language <lang>', 'Accept-Language override (e.g. en-US)')
125
+ .option('--headers <json>', `Extra request headers as a JSON object, e.g. '{"X-A":"b"}' (omitted → none)`, (v) => parseJsonFlag('--headers', v, 'object'))
126
+ .option('--cookies <json>', `Cookies as a JSON array of {name,value,domain}, e.g. '[{"name":"a","value":"b","domain":"example.com"}]' (omitted → none)`, (v) => parseJsonFlag('--cookies', v, 'array'))
127
+ .option('--metadata <json>', `Arbitrary metadata echoed on the screenshot envelope, as a JSON object, e.g. '{"run":"7"}' (omitted → none)`, (v) => parseJsonFlag('--metadata', v, 'object'))
75
128
  .option('--async', 'Queue the screenshot and return a job envelope immediately (no wait)')
76
129
  .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
77
130
  .action(async (opts) => {
@@ -94,6 +147,38 @@ export function makeScreenshotsCommand(getGlobal) {
94
147
  body['wait_until'] = opts.waitUntil;
95
148
  if (opts.clip !== undefined)
96
149
  body['clip'] = opts.clip;
150
+ // Tri-state booleans: commander yields `undefined` when neither --x nor --no-x is passed,
151
+ // so the key is omitted and the server default (true) holds (E6).
152
+ if (opts.blockAds !== undefined)
153
+ body['block_ads'] = opts.blockAds;
154
+ if (opts.blockCookieBanners !== undefined)
155
+ body['block_cookie_banners'] = opts.blockCookieBanners;
156
+ if (opts.scroll !== undefined)
157
+ body['scroll'] = opts.scroll;
158
+ if (opts.waitForSelector !== undefined)
159
+ body['wait_for_selector'] = opts.waitForSelector;
160
+ if (opts.delayMs !== undefined)
161
+ body['delay_ms'] = opts.delayMs;
162
+ if (opts.darkMode === true)
163
+ body['dark_mode'] = true;
164
+ // device_scale_factor is a spec integer enum [1, 2]; the parser validates the string, and
165
+ // the wire value is numeric.
166
+ if (opts.deviceScaleFactor !== undefined)
167
+ body['device_scale_factor'] = Number(opts.deviceScaleFactor);
168
+ if (opts.includeHtml === true)
169
+ body['include_html'] = true;
170
+ if (opts.country !== undefined)
171
+ body['country'] = opts.country;
172
+ if (opts.language !== undefined)
173
+ body['language'] = opts.language;
174
+ // The server defaults headers/cookies/metadata to {} / [] / {}; writing an empty
175
+ // container on a bare create would materialize those defaults (#2480 E1).
176
+ if (opts.headers !== undefined)
177
+ body['headers'] = opts.headers;
178
+ if (opts.cookies !== undefined)
179
+ body['cookies'] = opts.cookies;
180
+ if (opts.metadata !== undefined)
181
+ body['metadata'] = opts.metadata;
97
182
  try {
98
183
  // Reject --json + -o BEFORE issuing the request: -o downloads BINARY artifact bytes, so
99
184
  // it can never coexist with --json (which must stay a text envelope). Thrown here so no
@@ -1,22 +1,54 @@
1
1
  import { Command } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError } from '../output.js';
4
+ import { parseEnum, parseIntFlag, parseCacheTtl, parseJsonFlag } from '../lib/parse-flags.js';
5
+ /** The `SitemapRequest.strategy` spec enum. */
6
+ const SITEMAP_STRATEGIES = ['quick', 'standard', 'deep'];
4
7
  export function makeSitemapCommand(getGlobal) {
5
8
  const cmd = new Command('sitemap').description('Sitemap crawl operations');
6
9
  cmd
7
10
  .command('create')
8
11
  .description('Start a sitemap crawl')
9
12
  .requiredOption('--url <url>', 'Root URL to crawl')
13
+ .option('--strategy <strategy>', `Crawl strategy: ${SITEMAP_STRATEGIES.join(', ')} (omitted → server default standard)`, (v) => parseEnum('--strategy', SITEMAP_STRATEGIES, v))
14
+ .option('--max-depth <n>', 'Maximum crawl depth 1-5 (omitted → server default 2)', (v) => parseIntFlag('--max-depth', v))
15
+ .option('--max-urls <n>', 'Maximum URLs to collect (omitted → server default 5000)', (v) => parseIntFlag('--max-urls', v))
16
+ .option('--webhook-url <url>', 'HTTPS webhook URL notified when the crawl completes (omitted → poll-only, no delivery)')
17
+ .option('--webhook-secret <secret>', 'HMAC secret used to sign the completion webhook (omitted → unsigned)')
18
+ // Grammar-only validation, deliberately: sitemap caps cache_ttl at 90 DAYS
19
+ // (validators/sitemap.ts MAX_CACHE_TTL_SECONDS), not the 7d used by scrape/fetch. Mirroring
20
+ // the cap client-side would fork a second source of truth for a number only the server knows.
21
+ .option('--cache-ttl <ttl>', 'Cache freshness: 0, Nh (hours), or Nd (days), e.g. 48h, 30d, 0 (omitted → server default 30d; max 90d, enforced server-side)', (v) => parseCacheTtl('--cache-ttl', v))
22
+ .option('--compact', 'Return a compact result payload (omitted → server default off)')
23
+ .option('--process <json>', `Post-crawl processing directive as a JSON object, e.g. '{"action":"summarize"}' (omitted → none)`, (v) => parseJsonFlag('--process', v, 'object'))
24
+ .option('--metadata <json>', `Arbitrary metadata echoed on the job envelope, as a JSON object, e.g. '{"run":"7"}' (omitted → none)`, (v) => parseJsonFlag('--metadata', v, 'object'))
10
25
  .action(async (opts) => {
11
26
  const g = getGlobal();
12
27
  const client = buildClient(g);
13
- // Key-checked subset of the SDK's SitemapCreateRequest (same pattern as monitors.ts).
14
- // The CLI intentionally sends only `url` and relies on the server-side defaults the spec
15
- // declares for the remaining SitemapRequest fields (strategy, max_depth, max_urls, …).
16
- // The generated request type marks those fields required, so the final call needs an
17
- // explicit assertion — type-level only; the wire body is unchanged (pinned by
18
- // __tests__/request-shapes.test.ts).
28
+ // Omit-when-absent body; the remaining SitemapRequest fields keep their server defaults.
29
+ // The generated request type marks defaulted fields required, so the call needs an explicit
30
+ // assertion — type-level only; the wire body is pinned by request-shapes.test.ts.
19
31
  const body = { url: opts.url };
32
+ if (opts.strategy !== undefined)
33
+ body['strategy'] = opts.strategy;
34
+ if (opts.maxDepth !== undefined)
35
+ body['max_depth'] = opts.maxDepth;
36
+ if (opts.maxUrls !== undefined)
37
+ body['max_urls'] = opts.maxUrls;
38
+ if (opts.webhookUrl !== undefined)
39
+ body['webhook_url'] = opts.webhookUrl;
40
+ if (opts.webhookSecret !== undefined)
41
+ body['webhook_secret'] = opts.webhookSecret;
42
+ if (opts.cacheTtl !== undefined)
43
+ body['cache_ttl'] = opts.cacheTtl;
44
+ // `compact` server-defaults to FALSE, so it is written ONLY on `=== true` — writing
45
+ // `false` on a bare create would materialize a default (#2480 E1).
46
+ if (opts.compact === true)
47
+ body['compact'] = true;
48
+ if (opts.process !== undefined)
49
+ body['process'] = opts.process;
50
+ if (opts.metadata !== undefined)
51
+ body['metadata'] = opts.metadata;
20
52
  try {
21
53
  const result = await client.sitemap.create(body);
22
54
  printResult(result, g.json);
@@ -0,0 +1,13 @@
1
+ import { Command } from 'commander';
2
+ import { type GlobalOptions } from '../client.js';
3
+ /**
4
+ * The `usage` command group (#2545) — the `x-sdk-scope: v0.1` usage endpoints, previously curl-only.
5
+ *
6
+ * `daily` / `by-endpoint` accept the same three optional query flags (`--from`, `--to`,
7
+ * `--api-key`). Each maps 1:1 to its snake_case query param and is written ONLY when present; when
8
+ * NO flag is passed the SDK method is invoked with ZERO arguments (the `monitors list` pattern), so
9
+ * the bare `/v1/usage/daily` URL is serialized unchanged.
10
+ */
11
+ export declare function makeUsageCommand(getGlobal: () => GlobalOptions & {
12
+ json: boolean;
13
+ }): Command;
@@ -0,0 +1,81 @@
1
+ import { Command } from 'commander';
2
+ import { buildClient } from '../client.js';
3
+ import { printResult, handleError } from '../output.js';
4
+ /**
5
+ * The `usage` command group (#2545) — the `x-sdk-scope: v0.1` usage endpoints, previously curl-only.
6
+ *
7
+ * `daily` / `by-endpoint` accept the same three optional query flags (`--from`, `--to`,
8
+ * `--api-key`). Each maps 1:1 to its snake_case query param and is written ONLY when present; when
9
+ * NO flag is passed the SDK method is invoked with ZERO arguments (the `monitors list` pattern), so
10
+ * the bare `/v1/usage/daily` URL is serialized unchanged.
11
+ */
12
+ export function makeUsageCommand(getGlobal) {
13
+ const cmd = new Command('usage').description('Account usage and billing-period reporting');
14
+ cmd
15
+ .command('get')
16
+ .description('Get current billing-period usage summary')
17
+ .action(async () => {
18
+ const g = getGlobal();
19
+ const client = buildClient(g);
20
+ try {
21
+ const result = await client.usage.get();
22
+ printResult(result, g.json);
23
+ }
24
+ catch (err) {
25
+ handleError(err);
26
+ }
27
+ });
28
+ cmd
29
+ .command('daily')
30
+ .description('Get daily usage breakdown (default last 30 days; max 366)')
31
+ .option('--from <date>', 'Start date YYYY-MM-DD (omitted → server default, 30 days ago)')
32
+ .option('--to <date>', 'End date YYYY-MM-DD (omitted → server default, today)')
33
+ .option('--api-key <ak_id>', 'Filter by API key prefixed ID (omitted → all keys)')
34
+ .action(async (opts) => {
35
+ const g = getGlobal();
36
+ const client = buildClient(g);
37
+ const params = {};
38
+ if (opts.from !== undefined)
39
+ params.from = opts.from;
40
+ if (opts.to !== undefined)
41
+ params.to = opts.to;
42
+ if (opts.apiKey !== undefined)
43
+ params.api_key = opts.apiKey;
44
+ try {
45
+ const result = Object.keys(params).length > 0
46
+ ? await client.usage.daily(params)
47
+ : await client.usage.daily();
48
+ printResult(result, g.json);
49
+ }
50
+ catch (err) {
51
+ handleError(err);
52
+ }
53
+ });
54
+ cmd
55
+ .command('by-endpoint')
56
+ .description('Get per-endpoint bandwidth breakdown (default last 30 days; max 366)')
57
+ .option('--from <date>', 'Start date YYYY-MM-DD (omitted → server default, 30 days ago)')
58
+ .option('--to <date>', 'End date YYYY-MM-DD (omitted → server default, today)')
59
+ .option('--api-key <ak_id>', 'Filter by API key prefixed ID (omitted → all keys)')
60
+ .action(async (opts) => {
61
+ const g = getGlobal();
62
+ const client = buildClient(g);
63
+ const params = {};
64
+ if (opts.from !== undefined)
65
+ params.from = opts.from;
66
+ if (opts.to !== undefined)
67
+ params.to = opts.to;
68
+ if (opts.apiKey !== undefined)
69
+ params.api_key = opts.apiKey;
70
+ try {
71
+ const result = Object.keys(params).length > 0
72
+ ? await client.usage.byEndpoint(params)
73
+ : await client.usage.byEndpoint();
74
+ printResult(result, g.json);
75
+ }
76
+ catch (err) {
77
+ handleError(err);
78
+ }
79
+ });
80
+ return cmd;
81
+ }
@@ -0,0 +1,11 @@
1
+ import { Command } from 'commander';
2
+ import { type GlobalOptions } from '../client.js';
3
+ /**
4
+ * The `webhook-deliveries` command group (#2545) — inspect and requeue webhook delivery attempts.
5
+ *
6
+ * `list` flags map 1:1 to their query params and are omitted-when-absent; a bare `list` calls the
7
+ * SDK with ZERO args so the plain `/v1/webhook-deliveries` URL is serialized unchanged.
8
+ */
9
+ export declare function makeWebhookDeliveriesCommand(getGlobal: () => GlobalOptions & {
10
+ json: boolean;
11
+ }): Command;
@@ -0,0 +1,59 @@
1
+ import { Command } from 'commander';
2
+ import { buildClient } from '../client.js';
3
+ import { printResult, handleError } from '../output.js';
4
+ import { parseEnum, parseIntFlag } from '../lib/parse-flags.js';
5
+ /** The `listWebhookDeliveries` `status` query enum (DISTINCT from the monitor status enum). */
6
+ const DELIVERY_STATUSES = ['pending', 'delivered', 'failed', 'exhausted'];
7
+ /**
8
+ * The `webhook-deliveries` command group (#2545) — inspect and requeue webhook delivery attempts.
9
+ *
10
+ * `list` flags map 1:1 to their query params and are omitted-when-absent; a bare `list` calls the
11
+ * SDK with ZERO args so the plain `/v1/webhook-deliveries` URL is serialized unchanged.
12
+ */
13
+ export function makeWebhookDeliveriesCommand(getGlobal) {
14
+ const cmd = new Command('webhook-deliveries').description('Webhook delivery operations');
15
+ cmd
16
+ .command('list')
17
+ .description('List webhook deliveries')
18
+ .option('--limit <n>', 'Maximum number of deliveries to return', (v) => parseIntFlag('--limit', v))
19
+ .option('--cursor <cursor>', 'Pagination cursor from a previous page')
20
+ .option('--status <status>', `Filter by delivery status: ${DELIVERY_STATUSES.join(', ')}`, (v) => parseEnum('--status', DELIVERY_STATUSES, v))
21
+ .option('--since <iso>', 'Only deliveries created after this ISO date-time')
22
+ .action(async (opts) => {
23
+ const g = getGlobal();
24
+ const client = buildClient(g);
25
+ const params = {};
26
+ if (opts.limit !== undefined)
27
+ params.limit = opts.limit;
28
+ if (opts.cursor !== undefined)
29
+ params.cursor = opts.cursor;
30
+ if (opts.status !== undefined)
31
+ params.status = opts.status;
32
+ if (opts.since !== undefined)
33
+ params.since = opts.since;
34
+ try {
35
+ const result = Object.keys(params).length > 0
36
+ ? await client.webhooks.list(params)
37
+ : await client.webhooks.list();
38
+ printResult(result, g.json);
39
+ }
40
+ catch (err) {
41
+ handleError(err);
42
+ }
43
+ });
44
+ cmd
45
+ .command('retry <id>')
46
+ .description('Requeue a failed or exhausted webhook delivery')
47
+ .action(async (id) => {
48
+ const g = getGlobal();
49
+ const client = buildClient(g);
50
+ try {
51
+ const result = await client.webhooks.retry(id);
52
+ printResult(result, g.json);
53
+ }
54
+ catch (err) {
55
+ handleError(err);
56
+ }
57
+ });
58
+ return cmd;
59
+ }
package/dist/index.js CHANGED
@@ -11,6 +11,8 @@ import { makeMonitorsCommand } from './commands/monitors.js';
11
11
  import { makeSitemapCommand } from './commands/sitemap.js';
12
12
  import { makeSearchCommand } from './commands/search.js';
13
13
  import { makeAccountCommand } from './commands/account.js';
14
+ import { makeUsageCommand } from './commands/usage.js';
15
+ import { makeWebhookDeliveriesCommand } from './commands/webhook-deliveries.js';
14
16
  // Resolve version from package.json at runtime (supports compiled output).
15
17
  const require = createRequire(import.meta.url);
16
18
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -44,6 +46,8 @@ export function buildProgram() {
44
46
  program.addCommand(makeSitemapCommand(getGlobal));
45
47
  program.addCommand(makeSearchCommand(getGlobal));
46
48
  program.addCommand(makeAccountCommand(getGlobal));
49
+ program.addCommand(makeUsageCommand(getGlobal));
50
+ program.addCommand(makeWebhookDeliveriesCommand(getGlobal));
47
51
  return program;
48
52
  }
49
53
  /** Resolve symlinks and `..` segments, falling back to the raw path if it cannot be resolved. */