@hunt-labs/bytekit-cli 0.2.2 → 0.5.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,6 +1,19 @@
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, parseNumberFlag, parseJsonFlag } from '../lib/parse-flags.js';
5
+ /** The `listMonitors` `status` query enum (5-value list FILTER). */
6
+ const MONITOR_STATUSES = ['active', 'processing', 'paused', 'cancelled', 'suspended'];
7
+ /**
8
+ * The `MonitorUpdateRequest.status` enum — DISTINCT from {@link MONITOR_STATUSES}. Only `active`
9
+ * (resume) and `paused` (pause) are valid update targets (openapi.yaml MonitorUpdateRequest.status);
10
+ * the other three list-filter states are server-managed lifecycle states, not settable via PATCH.
11
+ */
12
+ const MONITOR_UPDATE_STATUSES = ['active', 'paused'];
13
+ /** The `notify_on` enum shared by MonitorCreateRequest / MonitorUpdateRequest. */
14
+ const NOTIFY_ONS = ['change', 'every'];
15
+ /** The create-only `type` enum (PATCH rejects `type` with 422 — immutable, issue #1984). */
16
+ const MONITOR_TYPES = ['screenshot', 'scrape'];
4
17
  export function makeMonitorsCommand(getGlobal) {
5
18
  const cmd = new Command('monitors').description('Page-change monitor operations');
6
19
  const capturesCmd = new Command('captures').description('Monitor capture sub-resources');
@@ -26,9 +39,21 @@ export function makeMonitorsCommand(getGlobal) {
26
39
  .requiredOption('--interval-type <type>', 'Check interval: hourly, daily, weekly, or cron')
27
40
  .requiredOption('--webhook-url <url>', 'HTTPS webhook URL notified on change')
28
41
  .option('--cron <expr>', 'Cron expression (required when --interval-type is cron, e.g. "0 * * * *")')
42
+ // `--type` is CREATE-ONLY (a monitor's type is immutable per #1984); the PATCH builder below
43
+ // deliberately omits it.
44
+ .option('--type <type>', `Monitor type: ${MONITOR_TYPES.join(', ')} (omitted → server default screenshot)`, (v) => parseEnum('--type', MONITOR_TYPES, v))
45
+ .option('--change-threshold <pct>', 'Change percentage that triggers a notification (float; omitted → server default 5)', (v) => parseNumberFlag('--change-threshold', v))
46
+ .option('--notify-on <event>', `When to notify: ${NOTIFY_ONS.join(', ')} (omitted → server default change)`, (v) => parseEnum('--notify-on', NOTIFY_ONS, v))
47
+ .option('--webhook-secret <secret>', 'HMAC secret for webhook signature verification')
48
+ .option('--webhook-headers <json>', `Extra webhook headers as a JSON object, e.g. '{"X-Sig":"abc"}' (omitted → none)`, (v) => parseJsonFlag('--webhook-headers', v, 'object'))
49
+ .option('--options <json>', `Screenshot-monitor options as a JSON object (omitted → server defaults)`, (v) => parseJsonFlag('--options', v, 'object'))
50
+ .option('--scrape-options <json>', `Scrape-monitor options as a JSON object (type=scrape only; omitted → server defaults)`, (v) => parseJsonFlag('--scrape-options', v, 'object'))
29
51
  .action(async (opts) => {
30
52
  const g = getGlobal();
31
53
  const client = buildClient(g);
54
+ // Omit-when-absent body (same rule as scrape.ts): each key is written ONLY when its flag is
55
+ // present, so a bare create never materializes a server default (#2480 E1). Typed via an
56
+ // assertion at the call site — the wire body is pinned by request-shapes.test.ts.
32
57
  const body = {
33
58
  url: opts.url,
34
59
  interval_type: opts.intervalType,
@@ -36,6 +61,20 @@ export function makeMonitorsCommand(getGlobal) {
36
61
  };
37
62
  if (opts.cron !== undefined)
38
63
  body['cron_expression'] = opts.cron;
64
+ if (opts.type !== undefined)
65
+ body['type'] = opts.type;
66
+ if (opts.changeThreshold !== undefined)
67
+ body['change_threshold'] = opts.changeThreshold;
68
+ if (opts.notifyOn !== undefined)
69
+ body['notify_on'] = opts.notifyOn;
70
+ if (opts.webhookSecret !== undefined)
71
+ body['webhook_secret'] = opts.webhookSecret;
72
+ if (opts.webhookHeaders !== undefined)
73
+ body['webhook_headers'] = opts.webhookHeaders;
74
+ if (opts.options !== undefined)
75
+ body['options'] = opts.options;
76
+ if (opts.scrapeOptions !== undefined)
77
+ body['scrape_options'] = opts.scrapeOptions;
39
78
  try {
40
79
  const result = await client.monitors.create(body);
41
80
  printResult(result, g.json);
@@ -47,11 +86,29 @@ export function makeMonitorsCommand(getGlobal) {
47
86
  cmd
48
87
  .command('list')
49
88
  .description('List all monitors')
50
- .action(async () => {
89
+ .option('--limit <n>', 'Maximum number of monitors to return', (v) => parseIntFlag('--limit', v))
90
+ .option('--cursor <cursor>', 'Pagination cursor from a previous page')
91
+ .option('--status <status>', `Filter by status: ${MONITOR_STATUSES.join(', ')} (default active)`, (v) => parseEnum('--status', MONITOR_STATUSES, v))
92
+ // `status` is the SDK's spec-derived `MonitorStatus` union, not a bare string (#2547) —
93
+ // `parseEnum` above already returns exactly that union, so this is a type-only narrowing
94
+ // with no runtime change.
95
+ .action(async (opts) => {
51
96
  const g = getGlobal();
52
97
  const client = buildClient(g);
98
+ // Build the params object with the same omit-when-absent rule as the body builders. When NO
99
+ // query flag is passed, call list() with ZERO arguments (never list({}) / list(undefined)) so
100
+ // the inverse-case request-shape assertion `monitors.list` args length 0 stays green (#2480).
101
+ const params = {};
102
+ if (opts.limit !== undefined)
103
+ params.limit = opts.limit;
104
+ if (opts.cursor !== undefined)
105
+ params.cursor = opts.cursor;
106
+ if (opts.status !== undefined)
107
+ params.status = opts.status;
53
108
  try {
54
- const result = await client.monitors.list();
109
+ const result = Object.keys(params).length > 0
110
+ ? await client.monitors.list(params)
111
+ : await client.monitors.list();
55
112
  printResult(result, g.json);
56
113
  }
57
114
  catch (err) {
@@ -74,10 +131,19 @@ export function makeMonitorsCommand(getGlobal) {
74
131
  });
75
132
  cmd
76
133
  .command('update <id>')
77
- .description('Update a monitor')
134
+ .description('Update a monitor (including pause/resume via --status)')
78
135
  .option('--interval-type <type>', 'New check interval: hourly, daily, weekly, or cron')
79
136
  .option('--cron <expr>', 'New cron expression (when interval-type is cron, e.g. "0 * * * *")')
80
137
  .option('--webhook-url <url>', 'New HTTPS webhook URL')
138
+ // `--status` is the 2-value MonitorUpdateRequest.status enum — NOT the 5-value list filter.
139
+ // Deliberately NO `--type`: PATCH /v1/monitors/{id} rejects `type` with 422 (immutable, #1984).
140
+ .option('--status <status>', `Pause or resume the monitor: ${MONITOR_UPDATE_STATUSES.join(', ')}`, (v) => parseEnum('--status', MONITOR_UPDATE_STATUSES, v))
141
+ .option('--change-threshold <pct>', 'New change percentage that triggers a notification (float)', (v) => parseNumberFlag('--change-threshold', v))
142
+ .option('--notify-on <event>', `When to notify: ${NOTIFY_ONS.join(', ')}`, (v) => parseEnum('--notify-on', NOTIFY_ONS, v))
143
+ .option('--webhook-secret <secret>', 'New HMAC secret for webhook signature verification')
144
+ .option('--webhook-headers <json>', `New webhook headers as a JSON object, e.g. '{"X-Sig":"abc"}'`, (v) => parseJsonFlag('--webhook-headers', v, 'object'))
145
+ .option('--options <json>', `New screenshot-monitor options as a JSON object`, (v) => parseJsonFlag('--options', v, 'object'))
146
+ .option('--scrape-options <json>', `New scrape-monitor options as a JSON object`, (v) => parseJsonFlag('--scrape-options', v, 'object'))
81
147
  .action(async (id, opts) => {
82
148
  const g = getGlobal();
83
149
  const client = buildClient(g);
@@ -88,6 +154,20 @@ export function makeMonitorsCommand(getGlobal) {
88
154
  body['cron_expression'] = opts.cron;
89
155
  if (opts.webhookUrl !== undefined)
90
156
  body['webhook_url'] = opts.webhookUrl;
157
+ if (opts.status !== undefined)
158
+ body['status'] = opts.status;
159
+ if (opts.changeThreshold !== undefined)
160
+ body['change_threshold'] = opts.changeThreshold;
161
+ if (opts.notifyOn !== undefined)
162
+ body['notify_on'] = opts.notifyOn;
163
+ if (opts.webhookSecret !== undefined)
164
+ body['webhook_secret'] = opts.webhookSecret;
165
+ if (opts.webhookHeaders !== undefined)
166
+ body['webhook_headers'] = opts.webhookHeaders;
167
+ if (opts.options !== undefined)
168
+ body['options'] = opts.options;
169
+ if (opts.scrapeOptions !== undefined)
170
+ body['scrape_options'] = opts.scrapeOptions;
91
171
  try {
92
172
  const result = await client.monitors.update(id, body);
93
173
  printResult(result, g.json);
@@ -2,6 +2,43 @@ import { Command } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError, resolveOutputMode, emitResult } from '../output.js';
4
4
  import { parseUrlLines } from '../lib/url-lines.js';
5
+ import { parseIntFlag, parseEnum, parseCsv, parseCacheTtl, parseJsonFlag, collectFormats, } from '../lib/parse-flags.js';
6
+ /** The `ScrapeRequest.markdown_mode` spec enum. */
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'];
14
+ /**
15
+ * Exit code when a queued (202 async) envelope reaches a non-JSON output mode.
16
+ *
17
+ * Mirrors the transport-hardening ticket's `QUEUED_RAW_EXIT_CODE` in `output.ts` (PR #2508)
18
+ * EXACTLY — deliberately a local constant, not an import, so this branch carries no dependency on
19
+ * that PR's symbols while the two guards still compose idempotently post-merge. Deliberately NOT 0
20
+ * (a content-body success) and NOT 1 (a usage/API error): the job WAS accepted and billed, there
21
+ * is simply no content body yet, and a distinct code lets a caller branch on "queued" without
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.
27
+ */
28
+ const QUEUED_EXIT_CODE = 2;
29
+ /**
30
+ * A 202 ScrapeQueuedEnvelope: `status: 'queued'` plus a stable `sc_` id (openapi.yaml).
31
+ *
32
+ * The strict discriminator — identical to #2508's `output.ts` guard, so both guards agree on
33
+ * every input post-merge. A malformed queued-ish shape (no id, or a non-'queued' status) falls
34
+ * through to the normal output path and surfaces `output.ts`'s own diagnostics (#2480 E4s).
35
+ */
36
+ function isQueuedEnvelope(result) {
37
+ if (typeof result !== 'object' || result === null)
38
+ return false;
39
+ const obj = result;
40
+ return obj.status === 'queued' && typeof obj.id === 'string' && obj.id.length > 0;
41
+ }
5
42
  export function makeScrapeCommand(getGlobal) {
6
43
  const cmd = new Command('scrape').description('Scrape URL content');
7
44
  const bulkCmd = new Command('bulk').description('Bulk scrape operations');
@@ -42,18 +79,113 @@ export function makeScrapeCommand(getGlobal) {
42
79
  .command('create')
43
80
  .description('Create a scrape job')
44
81
  .requiredOption('--url <url>', 'URL to scrape')
45
- .option('--format <format>', 'Output format (e.g. markdown, html); sent as the formats array')
82
+ // Repeatable AND CSV-aware: `--format raw_html --format markdown` and `--format raw_html,markdown`
83
+ // both send formats: ['raw_html','markdown']. Sent as the `formats` array; omitted → server
84
+ // default. `--raw`/`-o` require EXACTLY ONE format (validated pre-request; see the action).
85
+ .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)
86
+ .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
87
+ .option('--timeout-ms <n>', 'Per-request timeout in milliseconds', (v) => parseIntFlag('--timeout-ms', v))
88
+ .option('--markdown-mode <mode>', `Markdown extraction mode: ${MARKDOWN_MODES.join(', ')}`, (v) => parseEnum('--markdown-mode', MARKDOWN_MODES, v))
89
+ .option('--markdown-query <q>', 'BM25 query string for relevance-ranked markdown filtering (omitted → disabled)')
90
+ .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))
91
+ .option('--cookies <json>', `Cookies as a JSON array, e.g. '[{"name":"a","value":"b"}]' (omitted → none)`, (v) => parseJsonFlag('--cookies', v, 'array'))
92
+ .option('--headers <json>', `Extra request headers as a JSON object, e.g. '{"X-A":"b"}' (omitted → none)`, (v) => parseJsonFlag('--headers', v, 'object'))
93
+ .option('--delay-ms <n>', 'Post-load delay in milliseconds before capture', (v) => parseIntFlag('--delay-ms', v))
94
+ .option('--events <csv>', `Webhook events (comma-separated): ${SCRAPE_EVENTS.join(', ')} (omitted → server default)`, (v) => parseCsv(v).map((e) => parseEnum('--events', SCRAPE_EVENTS, e)))
95
+ .option('--token-encoding <enc>', `Tokenizer for --token-budget: ${TOKEN_ENCODINGS.join(', ')} (omitted → server default)`, (v) => parseEnum('--token-encoding', TOKEN_ENCODINGS, v))
96
+ .option('--custom <json>', `User payload echoed on the envelope as a JSON object, e.g. '{"job":"1"}' (omitted → none)`, (v) => parseJsonFlag('--custom', v, 'object'))
97
+ // Server default is TRUE, so this is a tri-state pair: --remove-base64-images → true,
98
+ // --no-remove-base64-images → false, neither → key omitted (never materialize the default, E6).
99
+ .option('--remove-base64-images', 'Strip base64 data: images before conversion (server default)')
100
+ .option('--no-remove-base64-images', 'Preserve base64 data: images in the pipeline output')
101
+ .option('--markdown-links <mode>', `Link rendering: ${MARKDOWN_RENDER_MODES.join(', ')}`, (v) => parseEnum('--markdown-links', MARKDOWN_RENDER_MODES, v))
102
+ .option('--markdown-images <mode>', `Image retention: ${MARKDOWN_RENDER_MODES.join(', ')}`, (v) => parseEnum('--markdown-images', MARKDOWN_RENDER_MODES, v))
103
+ .option('--with-links-summary', 'Append a Links footer to the markdown output')
104
+ .option('--with-images-summary', 'Append an Images footer to the markdown output')
105
+ .option('--markdown-compact', 'Collapse excessive whitespace in the markdown output')
106
+ .option('--markdown-filter-images', 'Filter low-signal images from the markdown output')
107
+ .option('--markdown-include-media', 'Return rich link/image objects and a tables array')
108
+ .option('--markdown-include-warnings', 'Include markdown-pipeline and tag-filter warnings')
109
+ .option('--markdown-include-stats', 'Include a top-level stats object (chars, tokens, blocks)')
110
+ .option('--async', 'Queue the scrape and return a job envelope immediately (no wait)')
111
+ .option('--webhook-url <url>', 'HTTPS webhook URL notified when an async scrape completes')
112
+ .option('--token-budget <n>', 'Maximum tokens of markdown to return', (v) => parseIntFlag('--token-budget', v))
113
+ .option('--clean-markdown', 'Post-process markdown with the LLM cleaner (billed 3x)')
114
+ .option('--include-tags <csv>', 'Comma-separated HTML tags to keep (e.g. article,main)')
115
+ .option('--exclude-tags <csv>', 'Comma-separated HTML tags to drop (e.g. nav,footer)')
116
+ .option('--mobile', 'Render with a mobile viewport/user-agent')
46
117
  .option('--raw', 'Print the requested format body raw, with no JSON wrapper')
47
118
  .option('-o, --output <file>', 'Write the output to the given file path (implies --raw unless --json is set)')
48
119
  .action(async (opts) => {
49
120
  const g = getGlobal();
50
121
  const client = buildClient(g);
51
122
  const body = { url: opts.url };
52
- // The v1 scrape validator expects `formats` (array), not `format` (string).
53
- // Omit the key entirely when --format is not provided so we never send
54
- // formats: ["undefined"].
55
- if (opts.format !== undefined)
56
- body['formats'] = [opts.format];
123
+ // The v1 scrape validator expects `formats` (array). The --format collector already
124
+ // accumulates repeated/CSV occurrences into an array; write it verbatim, omitting the key
125
+ // entirely when --format is absent so we never send formats: [] or ["undefined"].
126
+ const formats = opts.format;
127
+ if (formats !== undefined)
128
+ body['formats'] = formats;
129
+ // Each optional flag maps 1:1 to its snake_case ScrapeRequest field and is written ONLY
130
+ // when the flag is present — a boolean must never serialize `false` (#2480 E1).
131
+ if (opts.country !== undefined)
132
+ body['country'] = opts.country;
133
+ if (opts.timeoutMs !== undefined)
134
+ body['timeout_ms'] = opts.timeoutMs;
135
+ if (opts.markdownMode !== undefined)
136
+ body['markdown_mode'] = opts.markdownMode;
137
+ if (opts.markdownQuery !== undefined)
138
+ body['markdown_query'] = opts.markdownQuery;
139
+ if (opts.cacheTtl !== undefined)
140
+ body['cache_ttl'] = opts.cacheTtl;
141
+ if (opts.cookies !== undefined)
142
+ body['cookies'] = opts.cookies;
143
+ if (opts.headers !== undefined)
144
+ body['headers'] = opts.headers;
145
+ if (opts.delayMs !== undefined)
146
+ body['delay_ms'] = opts.delayMs;
147
+ if (opts.events !== undefined)
148
+ body['events'] = opts.events;
149
+ if (opts.tokenEncoding !== undefined)
150
+ body['token_encoding'] = opts.tokenEncoding;
151
+ if (opts.custom !== undefined)
152
+ body['custom'] = opts.custom;
153
+ // Tri-state boolean: commander yields `undefined` when neither --remove-base64-images nor
154
+ // --no-remove-base64-images is passed, so the key is omitted and the server default holds.
155
+ if (opts.removeBase64Images !== undefined)
156
+ body['remove_base64_images'] = opts.removeBase64Images;
157
+ if (opts.markdownLinks !== undefined)
158
+ body['markdown_links'] = opts.markdownLinks;
159
+ if (opts.markdownImages !== undefined)
160
+ body['markdown_images'] = opts.markdownImages;
161
+ if (opts.withLinksSummary === true)
162
+ body['with_links_summary'] = true;
163
+ if (opts.withImagesSummary === true)
164
+ body['with_images_summary'] = true;
165
+ if (opts.markdownCompact === true)
166
+ body['markdown_compact'] = true;
167
+ if (opts.markdownFilterImages === true)
168
+ body['markdown_filter_images'] = true;
169
+ if (opts.markdownIncludeMedia === true)
170
+ body['markdown_include_media'] = true;
171
+ if (opts.markdownIncludeWarnings === true)
172
+ body['markdown_include_warnings'] = true;
173
+ if (opts.markdownIncludeStats === true)
174
+ body['markdown_include_stats'] = true;
175
+ if (opts.async === true)
176
+ body['async'] = true;
177
+ if (opts.webhookUrl !== undefined)
178
+ body['webhook_url'] = opts.webhookUrl;
179
+ if (opts.tokenBudget !== undefined)
180
+ body['token_budget'] = opts.tokenBudget;
181
+ if (opts.cleanMarkdown === true)
182
+ body['clean_markdown'] = true;
183
+ if (opts.includeTags !== undefined)
184
+ body['include_tags'] = parseCsv(opts.includeTags);
185
+ if (opts.excludeTags !== undefined)
186
+ body['exclude_tags'] = parseCsv(opts.excludeTags);
187
+ if (opts.mobile === true)
188
+ body['mobile'] = true;
57
189
  try {
58
190
  // FIRST statement inside the try — deliberately NOT hoisted next to buildClient() where
59
191
  // the other handlers put pre-request logic. index.ts drives commander with .parse(), not
@@ -62,8 +194,47 @@ export function makeScrapeCommand(getGlobal) {
62
194
  // handleError()'s clean one-liner. buildClient() does no network I/O, so rejecting the
63
195
  // --json/--raw conflict here still happens before any request is issued.
64
196
  const mode = resolveOutputMode({ json: g.json, raw: opts.raw, output: opts.output });
197
+ // Raw mode (--raw or a bare -o) emits a SINGLE format body; more than one format is
198
+ // ambiguous, so reject before the request (E2). Thrown inside the try so handleError()
199
+ // prints the clean one-liner and no scrape.create call is ever recorded.
200
+ if (mode.kind === 'raw' && formats !== undefined && formats.length > 1) {
201
+ throw new Error('--raw requires exactly one --format');
202
+ }
65
203
  const result = await client.scrape.create(body);
66
- emitResult(result, mode, { file: opts.output }, opts.format);
204
+ // Queued-envelope precedence (#2480 E4s): a `--async` scrape returns a 202
205
+ // `{ id, status: 'queued' }` envelope with NO formats map. Detect that shape here —
206
+ // AFTER the request returns, BEFORE raw-format extraction — per the contract the
207
+ // transport-hardening ticket (#2508) establishes in `output.ts`, mirrored verbatim so
208
+ // the two guards compose idempotently post-merge (this one runs first and must be
209
+ // indistinguishable from that one):
210
+ // - raw mode (`--raw` or a bare `-o`): the bare sc_ id on STDOUT (a usable
211
+ // `$(bytekit scrape create … --raw)` handle), a human poll note on STDERR, exit
212
+ // QUEUED_EXIT_CODE via soft `process.exitCode` (#2544 — a hard `process.exit` runs
213
+ // before Node drains an ASYNC piped stdout and would discard the very id this
214
+ // branch exists to deliver). The `-o` file is deliberately NEVER written — there is
215
+ // no content body yet, and a file holding a bare id would masquerade as content.
216
+ // - json mode (`--json`, with or without `-o`): OUTSIDE the raw rule — fall through
217
+ // to emitResult, which emits the envelope verbatim (exit 0).
218
+ // - human default: keep the readable id/status dump, but signal queued-not-complete
219
+ // with the same exit code 2 — via soft `process.exitCode` (not `process.exit`) so
220
+ // the dump is guaranteed to flush before the process ends naturally.
221
+ if (isQueuedEnvelope(result)) {
222
+ if (mode.kind === 'raw') {
223
+ console.error(`Note: scrape ${result.id} was queued (async) — no content body is available yet. ` +
224
+ `Poll it with: bytekit scrape get ${result.id}`);
225
+ process.stdout.write(`${result.id}\n`);
226
+ process.exitCode = QUEUED_EXIT_CODE;
227
+ return;
228
+ }
229
+ if (mode.kind === 'human') {
230
+ printResult(result, false);
231
+ process.exitCode = QUEUED_EXIT_CODE;
232
+ return;
233
+ }
234
+ }
235
+ // At this point raw mode has at most one format (E2 guard above), so formats[0] is the
236
+ // single requested body selector; json/human modes ignore the argument.
237
+ emitResult(result, mode, { file: opts.output }, formats?.[0]);
67
238
  }
68
239
  catch (err) {
69
240
  handleError(err);
@@ -71,13 +242,22 @@ export function makeScrapeCommand(getGlobal) {
71
242
  });
72
243
  cmd
73
244
  .command('get <id>')
74
- .description('Get a scrape job by ID')
75
- .action(async (id) => {
245
+ .description('Get a scrape job by ID (use --raw/-o/--format to retrieve a completed body)')
246
+ .option('--raw', 'Print the requested format body raw, with no JSON wrapper')
247
+ .option('-o, --output <file>', 'Write the output to the given file path (implies --raw unless --json is set)')
248
+ .option('--format <format>', 'Which persisted format to emit raw when several are present')
249
+ .action(async (id, opts) => {
76
250
  const g = getGlobal();
77
251
  const client = buildClient(g);
78
252
  try {
253
+ // Mirror `scrape create`'s output path: resolveOutputMode FIRST inside the try (so a
254
+ // --json/--raw conflict rejects before the request), then emitResult. The request body is
255
+ // unchanged — scrape.get(id) takes no options — so the inverse-case arg-length assertion in
256
+ // request-shapes.test.ts stays green. A still-queued job flows through emitResult's
257
+ // queued-envelope branch (id + soft exit 2, #2508/#2544); a completed job emits its body.
258
+ const mode = resolveOutputMode({ json: g.json, raw: opts.raw, output: opts.output });
79
259
  const result = await client.scrape.get(id);
80
- printResult(result, g.json);
260
+ emitResult(result, mode, { file: opts.output }, opts.format);
81
261
  }
82
262
  catch (err) {
83
263
  handleError(err);
@@ -1,5 +1,17 @@
1
1
  import { Command } from 'commander';
2
2
  import { type GlobalOptions } from '../client.js';
3
+ /** Parse `--viewport WxH` (e.g. `1280x720`) into `{ width, height }`; reject malformed input. */
4
+ export declare function parseViewport(value: string): {
5
+ width: number;
6
+ height: number;
7
+ };
8
+ /** Parse `--clip x,y,w,h` into `{ x, y, width, height }`; reject malformed input. */
9
+ export declare function parseClip(value: string): {
10
+ x: number;
11
+ y: number;
12
+ width: number;
13
+ height: number;
14
+ };
3
15
  export declare function makeScreenshotsCommand(getGlobal: () => GlobalOptions & {
4
16
  json: boolean;
5
17
  }): Command;
@@ -1,6 +1,66 @@
1
- import { Command } from 'commander';
1
+ import { Command, InvalidArgumentError } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError } from '../output.js';
4
+ import { downloadArtifact } from '../lib/download-artifact.js';
5
+ import { parseEnum, parseIntFlag } from '../lib/parse-flags.js';
6
+ /** `ScreenshotRequest` spec enums. */
7
+ const DEVICES = ['desktop', 'mobile'];
8
+ const FORMATS = ['jpeg', 'png'];
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'];
12
+ /** Parse `--viewport WxH` (e.g. `1280x720`) into `{ width, height }`; reject malformed input. */
13
+ export function parseViewport(value) {
14
+ const m = /^(\d+)x(\d+)$/.exec(value);
15
+ if (!m)
16
+ throw new InvalidArgumentError('--viewport must be WIDTHxHEIGHT (e.g. 1280x720).');
17
+ return { width: Number(m[1]), height: Number(m[2]) };
18
+ }
19
+ /** Parse `--clip x,y,w,h` into `{ x, y, width, height }`; reject malformed input. */
20
+ export function parseClip(value) {
21
+ const m = /^(\d+),(\d+),(\d+),(\d+)$/.exec(value);
22
+ if (!m)
23
+ throw new InvalidArgumentError('--clip must be X,Y,WIDTH,HEIGHT (e.g. 0,0,800,600).');
24
+ return { x: Number(m[1]), y: Number(m[2]), width: Number(m[3]), height: Number(m[4]) };
25
+ }
26
+ /**
27
+ * Extract a non-empty string `image_url` from a screenshot envelope, or throw an actionable error.
28
+ *
29
+ * A pending/processing envelope has no `image_url` yet; a terminal-`failed` screenshot returns the
30
+ * unified `Error` envelope on a 200 (#2027). Either way the artifact was never materialized, so we
31
+ * throw rather than fetching `undefined` (#2480 E5).
32
+ */
33
+ function extractImageUrl(result) {
34
+ if (typeof result === 'object' && result !== null) {
35
+ const obj = result;
36
+ if (obj.error) {
37
+ throw new Error(`screenshot failed: ${obj.error.message ?? obj.error.code ?? 'unknown error'}`);
38
+ }
39
+ if (typeof obj.image_url === 'string' && obj.image_url.length > 0) {
40
+ return obj.image_url;
41
+ }
42
+ const status = typeof obj.status === 'string' ? obj.status : 'unknown';
43
+ throw new Error(`not ready yet: screenshot is ${status}`);
44
+ }
45
+ throw new Error('not ready yet: screenshot is unknown');
46
+ }
47
+ /**
48
+ * Render a screenshot result: with `-o` download the `image_url` artifact bytes (to a file, or to
49
+ * stdout with `-o -`); otherwise print the envelope. `--async` + `-o` prints the queued envelope
50
+ * and skips the download (queued-envelope precedence, #2480 E4). `--json` + `-o` is rejected by the
51
+ * caller BEFORE any request so `--json` can never emit binary (#2480 E2).
52
+ */
53
+ async function emitScreenshot(result, opts) {
54
+ if (opts.isAsync === true) {
55
+ printResult(result, opts.json);
56
+ return;
57
+ }
58
+ if (opts.output !== undefined) {
59
+ await downloadArtifact(extractImageUrl(result), opts.output);
60
+ return;
61
+ }
62
+ printResult(result, opts.json);
63
+ }
4
64
  export function makeScreenshotsCommand(getGlobal) {
5
65
  const cmd = new Command('screenshots').description('Screenshot operations');
6
66
  cmd
@@ -8,15 +68,91 @@ export function makeScreenshotsCommand(getGlobal) {
8
68
  .description('Capture a screenshot')
9
69
  .requiredOption('--url <url>', 'URL to screenshot')
10
70
  .option('--full-page', 'Capture the full page (default: viewport only)')
71
+ .option('--device <device>', `Device profile: ${DEVICES.join(', ')}`, (v) => parseEnum('--device', DEVICES, v))
72
+ .option('--viewport <WxH>', 'Viewport size WIDTHxHEIGHT (e.g. 1280x720)', parseViewport)
73
+ .option('--format <format>', `Image format: ${FORMATS.join(', ')}`, (v) => parseEnum('--format', FORMATS, v))
74
+ .option('--quality <n>', 'JPEG quality (1-100)', (v) => parseIntFlag('--quality', v))
75
+ .option('--wait-until <event>', `Wait condition: ${WAIT_UNTILS.join(', ')}`, (v) => parseEnum('--wait-until', WAIT_UNTILS, v))
76
+ .option('--clip <x,y,w,h>', 'Clip region X,Y,WIDTH,HEIGHT (e.g. 0,0,800,600)', parseClip)
77
+ // Tri-state pairs — server default is TRUE, so define BOTH --x and --no-x: commander yields
78
+ // `undefined` when neither is passed, and the key is omitted (never materialize the default, E6).
79
+ .option('--block-ads', 'Block ads before capture (server default)')
80
+ .option('--no-block-ads', 'Do not block ads')
81
+ .option('--block-cookie-banners', 'Block cookie-consent banners before capture (server default)')
82
+ .option('--no-block-cookie-banners', 'Do not block cookie-consent banners')
83
+ .option('--scroll', 'Scroll the page before capture to trigger lazy content (server default)')
84
+ .option('--no-scroll', 'Do not scroll the page before capture')
85
+ .option('--wait-for-selector <sel>', 'Wait until this CSS selector is present before capture')
86
+ .option('--delay-ms <n>', 'Post-load delay in milliseconds before capture', (v) => parseIntFlag('--delay-ms', v))
87
+ .option('--dark-mode', 'Emulate prefers-color-scheme: dark')
88
+ .option('--device-scale-factor <n>', `Device pixel ratio: ${DEVICE_SCALE_FACTORS.join(', ')}`, (v) => parseEnum('--device-scale-factor', DEVICE_SCALE_FACTORS, v))
89
+ .option('--include-html', 'Include the rendered HTML alongside the screenshot')
90
+ .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
91
+ .option('--language <lang>', 'Accept-Language override (e.g. en-US)')
92
+ .option('--async', 'Queue the screenshot and return a job envelope immediately (no wait)')
93
+ .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
11
94
  .action(async (opts) => {
12
95
  const g = getGlobal();
13
96
  const client = buildClient(g);
14
97
  const body = { url: opts.url };
98
+ // Each optional flag maps 1:1 to its snake_case ScreenshotRequest field, written ONLY when
99
+ // present — a boolean never serializes `false` (#2480 E1).
15
100
  if (opts.fullPage === true)
16
- body.full_page = true;
101
+ body['full_page'] = true;
102
+ if (opts.device !== undefined)
103
+ body['device'] = opts.device;
104
+ if (opts.viewport !== undefined)
105
+ body['viewport'] = opts.viewport;
106
+ if (opts.format !== undefined)
107
+ body['format'] = opts.format;
108
+ if (opts.quality !== undefined)
109
+ body['quality'] = opts.quality;
110
+ if (opts.waitUntil !== undefined)
111
+ body['wait_until'] = opts.waitUntil;
112
+ if (opts.clip !== undefined)
113
+ body['clip'] = opts.clip;
114
+ // Tri-state booleans: commander yields `undefined` when neither --x nor --no-x is passed,
115
+ // so the key is omitted and the server default (true) holds (E6).
116
+ if (opts.blockAds !== undefined)
117
+ body['block_ads'] = opts.blockAds;
118
+ if (opts.blockCookieBanners !== undefined)
119
+ body['block_cookie_banners'] = opts.blockCookieBanners;
120
+ if (opts.scroll !== undefined)
121
+ body['scroll'] = opts.scroll;
122
+ if (opts.waitForSelector !== undefined)
123
+ body['wait_for_selector'] = opts.waitForSelector;
124
+ if (opts.delayMs !== undefined)
125
+ body['delay_ms'] = opts.delayMs;
126
+ if (opts.darkMode === true)
127
+ body['dark_mode'] = true;
128
+ // device_scale_factor is a spec integer enum [1, 2]; the parser validates the string, and
129
+ // the wire value is numeric.
130
+ if (opts.deviceScaleFactor !== undefined)
131
+ body['device_scale_factor'] = Number(opts.deviceScaleFactor);
132
+ if (opts.includeHtml === true)
133
+ body['include_html'] = true;
134
+ if (opts.country !== undefined)
135
+ body['country'] = opts.country;
136
+ if (opts.language !== undefined)
137
+ body['language'] = opts.language;
17
138
  try {
18
- const result = await client.screenshots.create(body);
19
- printResult(result, g.json);
139
+ // Reject --json + -o BEFORE issuing the request: -o downloads BINARY artifact bytes, so
140
+ // it can never coexist with --json (which must stay a text envelope). Thrown here so no
141
+ // request — and therefore no download fetch — is ever issued (#2480 E2).
142
+ if (g.json && opts.output !== undefined) {
143
+ throw new Error('-o cannot be used with --json');
144
+ }
145
+ const typedBody = body;
146
+ // --async is a QUERY param; pass the second arg ONLY when present so existing single-arg
147
+ // callers stay byte-identical (#2480 inverse case).
148
+ const result = opts.async === true
149
+ ? await client.screenshots.create(typedBody, { async: true })
150
+ : await client.screenshots.create(typedBody);
151
+ await emitScreenshot(result, {
152
+ output: opts.output,
153
+ json: g.json,
154
+ isAsync: opts.async === true,
155
+ });
20
156
  }
21
157
  catch (err) {
22
158
  handleError(err);
@@ -25,12 +161,16 @@ export function makeScreenshotsCommand(getGlobal) {
25
161
  cmd
26
162
  .command('get <id>')
27
163
  .description('Get a screenshot by ID')
28
- .action(async (id) => {
164
+ .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
165
+ .action(async (id, opts) => {
29
166
  const g = getGlobal();
30
167
  const client = buildClient(g);
31
168
  try {
169
+ if (g.json && opts.output !== undefined) {
170
+ throw new Error('-o cannot be used with --json');
171
+ }
32
172
  const result = await client.screenshots.get(id);
33
- printResult(result, g.json);
173
+ await emitScreenshot(result, { output: opts.output, json: g.json });
34
174
  }
35
175
  catch (err) {
36
176
  handleError(err);
@@ -11,7 +11,11 @@ function renderTable(response) {
11
11
  for (const result of response.results) {
12
12
  console.log(`${result.position}. ${result.title}`);
13
13
  console.log(` ${result.url}`);
14
- console.log(` ${result.snippet}`);
14
+ // `snippet` is typed as required but is OMITTED for `--type images` results (openapi.yaml),
15
+ // so guard against printing a literal `undefined` line when it is absent or empty.
16
+ if (typeof result.snippet === 'string' && result.snippet.length > 0) {
17
+ console.log(` ${result.snippet}`);
18
+ }
15
19
  }
16
20
  }
17
21
  const LIMIT_MIN = 1;
@@ -1,17 +1,33 @@
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 } 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))
10
16
  .action(async (opts) => {
11
17
  const g = getGlobal();
12
18
  const client = buildClient(g);
19
+ // Omit-when-absent body; the remaining SitemapRequest fields keep their server defaults.
20
+ // The generated request type marks defaulted fields required, so the call needs an explicit
21
+ // assertion — type-level only; the wire body is pinned by request-shapes.test.ts.
22
+ const body = { url: opts.url };
23
+ if (opts.strategy !== undefined)
24
+ body['strategy'] = opts.strategy;
25
+ if (opts.maxDepth !== undefined)
26
+ body['max_depth'] = opts.maxDepth;
27
+ if (opts.maxUrls !== undefined)
28
+ body['max_urls'] = opts.maxUrls;
13
29
  try {
14
- const result = await client.sitemap.create({ url: opts.url });
30
+ const result = await client.sitemap.create(body);
15
31
  printResult(result, g.json);
16
32
  }
17
33
  catch (err) {
@@ -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;