@hunt-labs/bytekit-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,123 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@hunt-labs/bytekit-cli` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ This file records only the delta between versions. For how to use the package
9
+ today, see [README.md](./README.md), which remains the authoritative usage guide.
10
+
11
+ ## [0.3.0] - 2026-07-21
12
+
13
+ ### Removed (**BREAKING**)
14
+
15
+ - The legacy `RAPIDCRAWL_API_KEY` and `RAPIDCRAWL_BASE_URL` environment-variable
16
+ fallbacks (kept as a one-minor-version bridge in #2421) are removed. Use
17
+ `BYTEKIT_API_KEY` and `BYTEKIT_BASE_URL` (or the `--key` / `--base-url` flags).
18
+ (#2473)
19
+
20
+ ### Fixed
21
+
22
+ - `bytekit fetch get` / `fetch create` no longer crash on non-JSON bodies. The
23
+ transport JSON-parsed every 2xx response, but `/v1/fetch` returns the fetched
24
+ content AS the body. The fetch command now prints the raw body by default, or
25
+ the `X-Fetch-*` metadata as JSON with `--json`; `fetch bulk` and every other
26
+ command still parse JSON. (#2467)
27
+ - `204 No Content` / empty-body responses (e.g. `monitors delete`,
28
+ `account api-keys revoke`) no longer throw a JSON-parse error, including after
29
+ a 429 retry. (#2467, #2468)
30
+ - `search --type images` no longer prints an `undefined` snippet line for
31
+ results that omit the field. (#2479)
32
+
33
+ ### Added
34
+
35
+ - `scrape create` accepts the full `ScrapeRequest` surface: `--country`,
36
+ `--timeout-ms`, `--markdown-mode`, `--async`, `--webhook-url`,
37
+ `--token-budget`, `--clean-markdown`, `--include-tags`, `--exclude-tags`,
38
+ `--mobile`. Each flag is omitted from the request when absent. (#2480)
39
+ - `screenshots create` accepts `--device`, `--viewport WxH`, `--format`,
40
+ `--quality`, `--wait-until`, `--clip x,y,w,h`, and `--async`; `-o <file>`
41
+ on `screenshots create`/`get` downloads the captured image bytes (`-o -` for
42
+ stdout). `-o` cannot be combined with `--json` on screenshots. (#2480)
43
+ - `monitors list` accepts `--limit`, `--cursor`, and `--status` query
44
+ filters. (#2480)
45
+ - `account api-keys reveal <ak_id>`: prints the key masked by default
46
+ (`sk_live_…2345`); pass `--show` for the full plaintext. (#2480)
47
+ - Per-attempt transport timeout (120s, mirroring the SDK default): a hung
48
+ request now fails with `code: 'timeout'` instead of hanging forever. Network
49
+ failures are mapped onto the SDK's connection-error taxonomy
50
+ (`timeout` / `aborted` / `connection`), and error output renders the API or
51
+ connection error code. (#2479)
52
+ - A queued async job (HTTP 202) under `--raw` prints the job envelope with its
53
+ `sc_` id instead of an empty body. (#2479)
54
+
55
+ ### Changed (**BREAKING**)
56
+
57
+ - `fetch --format` is validated locally against the spec union
58
+ `'markdown' | 'html'`; off-spec values are rejected with a clear error before
59
+ a request is sent (previously forwarded as-is). (#2475)
60
+
61
+ ## [0.2.2] - 2026-07-16
62
+
63
+ ### Changed
64
+
65
+ - Re-release of 0.2.1. The 0.2.1 artifacts never reached npm — the prod publish's
66
+ `publish-sdk` step failed on a CI test-isolation flake (`cjs-require.test.ts`
67
+ rebuilding the shared `dist`), which is now fixed. No consumer-facing changes
68
+ beyond 0.2.1.
69
+
70
+ ## [0.2.1] - 2026-07-16
71
+
72
+ ### Changed
73
+
74
+ - `BYTEKIT_API_KEY` / `BYTEKIT_BASE_URL` are now the primary environment
75
+ variables (precedence: flag > `BYTEKIT_*` > legacy `RAPIDCRAWL_*`).
76
+ `RAPIDCRAWL_API_KEY` / `RAPIDCRAWL_BASE_URL` continue to work as silent
77
+ fallbacks. `--help` text, the missing-key error, and the README now reference
78
+ `BYTEKIT_*`. (#2421)
79
+
80
+ ### Removed
81
+
82
+ - The `recordings` command (the endpoint is permanently `410` deactivated). (#2421)
83
+
84
+ ## [0.2.0] - 2026-07-15
85
+
86
+ Additive, lower-risk release — no breaking changes to existing commands.
87
+
88
+ ### Added
89
+
90
+ - `scrape create --raw` / `-o, --output <file>`: write the raw response body to
91
+ stdout or a file (#2425).
92
+ - Local `search --limit` validation via a commander `argParser`, so an invalid limit
93
+ is rejected before a request is sent (#2425).
94
+ - One automatic retry on HTTP 429, honoring a clamped `Retry-After` header, in the
95
+ CLI transport (#2425).
96
+
97
+ ### Fixed
98
+
99
+ - Entrypoint guard canonicalized so the `bytekit` bin runs correctly when invoked
100
+ through an npm/npx symlink (#2418).
101
+
102
+ ### Changed
103
+
104
+ - Package renamed to `@hunt-labs/bytekit-cli` (the `@bytekit` npm scope was
105
+ unobtainable); the `bin` name stays `bytekit`. The prior `rapidcrawl-cli` name
106
+ remains installable at its last version but is deprecated.
107
+ - Consumer call sites propagate the SDK's narrowed, spec-derived request types (#2426).
108
+ - Publish-ready packaging metadata: dropped `main`, unpinned the SDK dependency to
109
+ `workspace:^`, added `engines`, `keywords`, `homepage`, `bugs`, `README.md`,
110
+ `LICENSE`, and an MIT `license` field.
111
+
112
+ ## [0.1.0] - 2026
113
+
114
+ ### Added
115
+
116
+ - Initial release of the ByteKit CLI, wrapping the ByteKit SDK as shell
117
+ subcommands, published under the original `rapidcrawl-cli` name.
118
+
119
+ [0.3.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
120
+ [0.2.2]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
121
+ [0.2.1]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
122
+ [0.2.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
123
+ [0.1.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
package/README.md CHANGED
@@ -32,7 +32,7 @@ Create an API key at [app.bytekit.com](https://app.bytekit.com) (keys are
32
32
  prefixed `sk_live_`), then either export it:
33
33
 
34
34
  ```bash
35
- export RAPIDCRAWL_API_KEY=sk_live_xxx
35
+ export BYTEKIT_API_KEY=sk_live_xxx
36
36
  bytekit scrape create --url https://example.com
37
37
  ```
38
38
 
@@ -43,26 +43,28 @@ over the environment variable):
43
43
  bytekit --key sk_live_xxx scrape create --url https://example.com
44
44
  ```
45
45
 
46
- Without a key, the CLI exits with `Error: API key required. Pass --key or set RAPIDCRAWL_API_KEY.`
46
+ Precedence is `--key` flag → `BYTEKIT_API_KEY`.
47
+
48
+ Without a key, the CLI exits with `Error: API key required. Pass --key or set BYTEKIT_API_KEY.`
47
49
 
48
50
  ## Base-URL override
49
51
 
50
52
  Requests go to `https://api.bytekit.com` by default. Point the CLI at staging, a
51
53
  local gateway, or a proxy with the global `--base-url` flag or the
52
- `RAPIDCRAWL_BASE_URL` environment variable (the flag wins):
54
+ `BYTEKIT_BASE_URL` environment variable (the flag wins):
53
55
 
54
56
  ```bash
55
57
  bytekit --base-url https://api-stg.bytekit.com scrape create --url https://example.com
56
- export RAPIDCRAWL_BASE_URL=http://localhost:3001
58
+ export BYTEKIT_BASE_URL=http://localhost:3001
57
59
  ```
58
60
 
59
61
  ## Global options
60
62
 
61
- | Flag | Env var | Purpose |
62
- | ------------------ | --------------------- | -------------------------------------------------------------------- |
63
- | `--key <key>` | `RAPIDCRAWL_API_KEY` | API key used for every request. |
64
- | `--base-url <url>` | `RAPIDCRAWL_BASE_URL` | API base URL (default `https://api.bytekit.com`). |
65
- | `--json` | — | Print the raw JSON response instead of the human-readable rendering. |
63
+ | Flag | Env var | Purpose |
64
+ | ------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `--key <key>` | `BYTEKIT_API_KEY` | API key used for every request. |
66
+ | `--base-url <url>` | `BYTEKIT_BASE_URL` | API base URL (default `https://api.bytekit.com`). |
67
+ | `--json` | — | Print the raw JSON response instead of the human-readable rendering (for `fetch`, prints the `X-Fetch-*` metadata instead of the body). |
66
68
 
67
69
  ## Commands
68
70
 
@@ -74,6 +76,27 @@ bytekit scrape get sc_123abc
74
76
  bytekit scrape bulk create --file urls.txt --webhook-url https://your.app/hooks/bytekit
75
77
  ```
76
78
 
79
+ `scrape create` also accepts the full `ScrapeRequest` surface, each flag omitted
80
+ from the request when absent (a boolean flag never sends `false`):
81
+
82
+ | Flag | Effect |
83
+ | ------------------------ | ------------------------------------------------------- |
84
+ | `--country <cc>` | Route through a country (e.g. `us`, `de`). |
85
+ | `--timeout-ms <n>` | Per-request timeout in milliseconds. |
86
+ | `--markdown-mode <mode>` | Markdown extraction mode: `article`, `raw`, `llm`. |
87
+ | `--async` | Queue the scrape and return a job envelope immediately. |
88
+ | `--webhook-url <url>` | Webhook notified when an async scrape completes. |
89
+ | `--token-budget <n>` | Cap the markdown returned to `n` tokens. |
90
+ | `--clean-markdown` | LLM-clean the markdown (billed 3x). |
91
+ | `--include-tags <csv>` | Comma-separated HTML tags to keep. |
92
+ | `--exclude-tags <csv>` | Comma-separated HTML tags to drop. |
93
+ | `--mobile` | Render with a mobile viewport/user-agent. |
94
+
95
+ `--async` combined with `--raw` (or a bare `-o`) prints the queued job envelope
96
+ (the `sc_` id + status) rather than a format body, since a queued scrape has no
97
+ body yet. On `scrape`, `-o` writes the same text rendering the terminal would
98
+ show, so it is legal alongside `--json`.
99
+
77
100
  ### `fetch` — raw, unprocessed response bodies
78
101
 
79
102
  ```bash
@@ -91,9 +114,22 @@ bytekit search "ai web scraping" --limit 10 --country us
91
114
 
92
115
  ```bash
93
116
  bytekit screenshots create --url https://example.com --full-page
94
- bytekit screenshots get ss_123abc
117
+ bytekit screenshots create --url https://example.com --device mobile --format png -o shot.png
118
+ bytekit screenshots get ss_123abc -o shot.png
95
119
  ```
96
120
 
121
+ `screenshots create` accepts `--device` (`desktop`, `mobile`), `--viewport WxH`
122
+ (e.g. `1280x720`), `--format` (`jpeg`, `png`), `--quality <n>`, `--wait-until`
123
+ (`load`, `domcontentloaded`, `networkidle`), `--clip x,y,w,h`, and `--async`
124
+ (queue and return a job envelope). Each flag is omitted from the request when
125
+ absent.
126
+
127
+ `-o <file>` downloads the captured image bytes to that file (use `-o -` for
128
+ stdout). Because `-o` writes BINARY on `screenshots`, it cannot be combined with
129
+ `--json` (that would emit binary into a JSON pipeline) — pass one or the other.
130
+ `--async` + `-o` prints the queued job envelope and skips the download (the
131
+ image is not ready yet).
132
+
97
133
  ### `bulk` — bulk screenshot jobs
98
134
 
99
135
  ```bash
@@ -110,11 +146,15 @@ bytekit bulk screenshots list "$bulk_id"
110
146
  ```bash
111
147
  bytekit monitors create --url https://example.com/pricing --interval-type daily \
112
148
  --webhook-url https://your.app/hooks/bytekit
113
- bytekit monitors list
149
+ bytekit monitors list --limit 25 --status active
114
150
  bytekit monitors captures list mon_123abc
115
151
  bytekit monitors delete mon_123abc
116
152
  ```
117
153
 
154
+ `monitors list` accepts `--limit <n>`, `--cursor <cursor>`, and `--status`
155
+ (`active`, `processing`, `paused`, `cancelled`, `suspended`) as query filters,
156
+ each omitted when absent.
157
+
118
158
  ### `sitemap` — crawl a site's URL graph
119
159
 
120
160
  ```bash
@@ -128,16 +168,13 @@ bytekit sitemap get sm_123abc
128
168
  bytekit account get
129
169
  bytekit account api-keys list
130
170
  bytekit account api-keys create --name "ci-key"
171
+ bytekit account api-keys reveal ak_123abc # masked by default
172
+ bytekit account api-keys reveal ak_123abc --show # full plaintext
131
173
  ```
132
174
 
133
- ### `recordings` — deactivated
134
-
135
- ```bash
136
- bytekit recordings get rec_123abc
137
- ```
138
-
139
- The `/v1/recordings` endpoints are deactivated server-side and return HTTP 410;
140
- the subcommand remains only so existing scripts fail loudly rather than silently.
175
+ `account api-keys reveal` prints a live secret, so it MASKS the key by default
176
+ (`sk_live_…2345`). Pass `--show` for the full plaintext, or `--json` for the
177
+ full envelope (for scripts).
141
178
 
142
179
  ## Documentation
143
180
 
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { RapidCrawl } from '@hunt-labs/bytekit-sdk';
1
+ import { ByteKit } from '@hunt-labs/bytekit-sdk';
2
2
  export interface GlobalOptions {
3
3
  key?: string | undefined;
4
4
  baseUrl?: string | undefined;
@@ -22,6 +22,17 @@ export declare const MIN_RETRY_AFTER_MS = 1000;
22
22
  export declare const MAX_RETRY_AFTER_MS = 60000;
23
23
  /** Exactly one retry. A second 429 is terminal — no loop. */
24
24
  export declare const MAX_RETRIES = 1;
25
+ /**
26
+ * Default CLIENT-SIDE abort budget for a single fetch attempt, in ms.
27
+ *
28
+ * Mirrors the SDK's own `DEFAULT_TIMEOUT_MS` (120s). Overriding `client.request` with this
29
+ * transport had discarded the SDK's default, so a hung server hung the CLI forever; this restores
30
+ * the same bound. The budget is **per attempt**, not per command: the single 429 retry (and its
31
+ * backoff sleep) gets a fresh budget, so the retry can never be cut short by time already spent on
32
+ * the first attempt. A future `--timeout` flag would override this default; today only the default
33
+ * exists.
34
+ */
35
+ export declare const DEFAULT_TIMEOUT_MS = 120000;
25
36
  /**
26
37
  * Parse a `Retry-After` header into a clamped millisecond delay.
27
38
  *
@@ -34,7 +45,7 @@ export declare function parseRetryAfterMs(raw: string | null): number;
34
45
  * Build the CLI's HTTP transport: the SDK's `request` contract, plus a single bounded retry on 429.
35
46
  *
36
47
  * Why the CLI owns the `fetch` instead of wrapping the SDK's `request()`: the SDK throws a
37
- * header-less `RapidCrawlError` and discards the `Response`, so a delegating wrapper can see
48
+ * header-less `ByteKitError` and discards the `Response`, so a delegating wrapper can see
38
49
  * `status === 429` but can never read `Retry-After`. Owning the fetch is the only way to honor the
39
50
  * server's advertised backoff without editing the SDK.
40
51
  *
@@ -43,14 +54,16 @@ export declare function parseRetryAfterMs(raw: string | null): number;
43
54
  *
44
55
  * Retries apply to 429 only. Non-429 statuses and network rejections propagate untouched.
45
56
  */
46
- export declare function createRetryingRequest(apiKey: string, baseUrl: string): RapidCrawl['request'];
57
+ export declare function createRetryingRequest(apiKey: string, baseUrl: string, timeoutMs?: number): ByteKit['request'];
47
58
  /**
48
59
  * Build an authenticated SDK client from global CLI options.
49
60
  *
50
- * Resolution order for apiKey: --key flag → RAPIDCRAWL_API_KEY env var.
61
+ * Resolution order (explicit input beats environment):
62
+ * apiKey: --key flag → BYTEKIT_API_KEY.
63
+ * baseUrl: --base-url flag → BYTEKIT_BASE_URL → DEFAULT_BASE_URL.
51
64
  * Exits with a descriptive error when no key is available.
52
65
  *
53
66
  * The returned client's `request` is replaced by the retrying transport, so every command inherits
54
67
  * the 429 retry from this one seam.
55
68
  */
56
- export declare function buildClient(opts: GlobalOptions): RapidCrawl;
69
+ export declare function buildClient(opts: GlobalOptions): ByteKit;
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { RapidCrawl, RapidCrawlError } from '@hunt-labs/bytekit-sdk';
1
+ import { ByteKit, ByteKitError, ByteKitConnectionError } from '@hunt-labs/bytekit-sdk';
2
2
  /**
3
3
  * The API host the SDK itself defaults to. The SDK's copy is module-private, so the CLI declares
4
4
  * its own and always passes an explicit `baseUrl` to the constructor — that way the SDK client and
@@ -18,6 +18,69 @@ export const MIN_RETRY_AFTER_MS = 1_000;
18
18
  export const MAX_RETRY_AFTER_MS = 60_000;
19
19
  /** Exactly one retry. A second 429 is terminal — no loop. */
20
20
  export const MAX_RETRIES = 1;
21
+ /**
22
+ * Default CLIENT-SIDE abort budget for a single fetch attempt, in ms.
23
+ *
24
+ * Mirrors the SDK's own `DEFAULT_TIMEOUT_MS` (120s). Overriding `client.request` with this
25
+ * transport had discarded the SDK's default, so a hung server hung the CLI forever; this restores
26
+ * the same bound. The budget is **per attempt**, not per command: the single 429 retry (and its
27
+ * backoff sleep) gets a fresh budget, so the retry can never be cut short by time already spent on
28
+ * the first attempt. A future `--timeout` flag would override this default; today only the default
29
+ * exists.
30
+ */
31
+ export const DEFAULT_TIMEOUT_MS = 120_000;
32
+ /** The abort reason used when a per-attempt budget elapses; `name` maps it to `code: 'timeout'`. */
33
+ function timeoutReason(ms) {
34
+ const reason = new Error(`ByteKit: request timed out after ${ms}ms`);
35
+ reason.name = 'TimeoutError';
36
+ return reason;
37
+ }
38
+ /**
39
+ * A per-attempt abort budget: an `AbortController` fired by a `setTimeout`.
40
+ *
41
+ * Deliberately NOT `AbortSignal.timeout` (which the SDK prefers): that schedules on a timer the
42
+ * test runner's fake timers cannot advance, so the transport's timeout would be untestable under
43
+ * `vi.useFakeTimers()`. CLI requests are short-lived and single-shot, so the GC advantage
44
+ * `AbortSignal.timeout` buys the long-running SDK does not apply here. The timer is `unref`'d so a
45
+ * pending budget never keeps the process alive, and `cleanup()` clears it on every exit path.
46
+ */
47
+ function createAttemptBudget(ms) {
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(timeoutReason(ms)), ms);
50
+ timer.unref?.();
51
+ return {
52
+ signal: controller.signal,
53
+ cleanup: () => clearTimeout(timer),
54
+ };
55
+ }
56
+ /**
57
+ * Map a `fetch` rejection (no HTTP response was produced) onto the SDK's `ByteKitConnectionError`
58
+ * taxonomy, so a CLI failure carries the same `{name, code, cause}` a direct SDK call would.
59
+ *
60
+ * `timedOut` is true when this attempt's own budget fired the abort, which is the reliable
61
+ * discriminator (undici rejects with the signal's reason, whose `name` we also inspect as a
62
+ * fallback for a caller-style AbortError).
63
+ */
64
+ function toConnectionError(err, method, path, timedOut) {
65
+ const rawName = typeof err === 'object' && err !== null && 'name' in err ? err.name : undefined;
66
+ const name = typeof rawName === 'string' ? rawName : '';
67
+ let code;
68
+ if (timedOut || name === 'TimeoutError') {
69
+ code = 'timeout';
70
+ }
71
+ else if (name === 'AbortError') {
72
+ code = 'aborted';
73
+ }
74
+ else {
75
+ code = 'connection';
76
+ }
77
+ const detail = code === 'timeout'
78
+ ? 'request timed out'
79
+ : code === 'aborted'
80
+ ? 'request aborted'
81
+ : 'connection failed before a response was received';
82
+ return new ByteKitConnectionError(code, `ByteKit: ${method} ${path} — ${detail}`, { cause: err });
83
+ }
21
84
  /**
22
85
  * Parse a `Retry-After` header into a clamped millisecond delay.
23
86
  *
@@ -44,7 +107,7 @@ function sleep(ms) {
44
107
  /**
45
108
  * Shape a failed response into the SDK's OWN error class, using the SDK's own body parsing.
46
109
  *
47
- * Reusing `RapidCrawlError` keeps `{status, code, message}` identical to what a direct SDK call
110
+ * Reusing `ByteKitError` keeps `{status, code, message}` identical to what a direct SDK call
48
111
  * would throw, so `handleError()`'s `'status' in err` branch still renders
49
112
  * `Error: <message> (status: <n>)` and exits 1.
50
113
  */
@@ -59,13 +122,41 @@ async function toApiError(response) {
59
122
  catch {
60
123
  // leave defaults
61
124
  }
62
- return new RapidCrawlError(response.status, code, message);
125
+ return new ByteKitError(response.status, code, message);
126
+ }
127
+ /**
128
+ * Is this request targeting a `/v1/fetch` CONTENT endpoint (POST or GET), whose body IS the fetched
129
+ * content rather than a JSON envelope?
130
+ *
131
+ * Keyed off the request TARGET, never the response content-type: a fetched page can itself serve
132
+ * `application/json`, and that body must still be returned raw, not parsed. The `/v1/fetch/bulk`
133
+ * job endpoints are NOT content endpoints — they return a JSON envelope and keep parsing as JSON.
134
+ */
135
+ function isFetchContentPath(path) {
136
+ return (path.split('?')[0] ?? path) === '/v1/fetch';
137
+ }
138
+ /** Title-Case an HTTP header name segment-by-segment: `x-fetch-status` → `X-Fetch-Status`. */
139
+ function titleCaseHeader(name) {
140
+ return name
141
+ .split('-')
142
+ .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).toLowerCase())
143
+ .join('-');
144
+ }
145
+ /** Collect the `X-Fetch-*` response headers into a Title-Cased metadata object. */
146
+ function collectFetchMetadata(headers) {
147
+ const meta = {};
148
+ headers.forEach((value, key) => {
149
+ if (key.toLowerCase().startsWith('x-fetch-')) {
150
+ meta[titleCaseHeader(key)] = value;
151
+ }
152
+ });
153
+ return meta;
63
154
  }
64
155
  /**
65
156
  * Build the CLI's HTTP transport: the SDK's `request` contract, plus a single bounded retry on 429.
66
157
  *
67
158
  * Why the CLI owns the `fetch` instead of wrapping the SDK's `request()`: the SDK throws a
68
- * header-less `RapidCrawlError` and discards the `Response`, so a delegating wrapper can see
159
+ * header-less `ByteKitError` and discards the `Response`, so a delegating wrapper can see
69
160
  * `status === 429` but can never read `Retry-After`. Owning the fetch is the only way to honor the
70
161
  * server's advertised backoff without editing the SDK.
71
162
  *
@@ -74,7 +165,7 @@ async function toApiError(response) {
74
165
  *
75
166
  * Retries apply to 429 only. Non-429 statuses and network rejections propagate untouched.
76
167
  */
77
- export function createRetryingRequest(apiKey, baseUrl) {
168
+ export function createRetryingRequest(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
78
169
  return async function request(method, path, body) {
79
170
  for (let attempt = 0;; attempt++) {
80
171
  const init = {
@@ -87,9 +178,44 @@ export function createRetryingRequest(apiKey, baseUrl) {
87
178
  if (body !== undefined) {
88
179
  init.body = JSON.stringify(body);
89
180
  }
90
- const response = await fetch(`${baseUrl}${path}`, init);
181
+ // Per-attempt abort budget: a fresh timer for THIS fetch. A budget of 0 disables it.
182
+ const budget = timeoutMs > 0 ? createAttemptBudget(timeoutMs) : undefined;
183
+ if (budget !== undefined) {
184
+ init.signal = budget.signal;
185
+ }
186
+ let response;
187
+ try {
188
+ response = await fetch(`${baseUrl}${path}`, init);
189
+ }
190
+ catch (err) {
191
+ // No HTTP response was produced (timeout, abort, socket/DNS/TLS). Re-throw as the SDK's
192
+ // own ByteKitConnectionError so a hung server can never hang the CLI and a network failure
193
+ // no longer surfaces as a bare `Error: fetch failed`.
194
+ throw toConnectionError(err, method, path, budget?.signal.aborted === true);
195
+ }
196
+ finally {
197
+ // Clear the timer on every exit path — success, HTTP error, or abort — so a healthy
198
+ // request leaves no dangling 120s timer.
199
+ budget?.cleanup();
200
+ }
91
201
  if (response.ok) {
92
- return (await response.json());
202
+ // A /v1/fetch content response body IS the fetched content (HTML/markdown/binary), with its
203
+ // metadata in X-Fetch-* headers. Return the raw bytes untouched — JSON-parsing it is the
204
+ // #2467 DOA crash. Every other endpoint returns a JSON envelope, parsed below.
205
+ if (isFetchContentPath(path)) {
206
+ const bodyBytes = Buffer.from(await response.arrayBuffer());
207
+ const fetchResult = {
208
+ __bytekitFetchBody: true,
209
+ body: bodyBytes,
210
+ metadata: collectFetchMetadata(response.headers),
211
+ };
212
+ return fetchResult;
213
+ }
214
+ const text = await response.text();
215
+ if (response.status === 204 || text.trim().length === 0) {
216
+ return undefined;
217
+ }
218
+ return JSON.parse(text);
93
219
  }
94
220
  if (response.status === 429 && attempt < MAX_RETRIES) {
95
221
  await sleep(parseRetryAfterMs(response.headers.get('Retry-After')));
@@ -102,20 +228,22 @@ export function createRetryingRequest(apiKey, baseUrl) {
102
228
  /**
103
229
  * Build an authenticated SDK client from global CLI options.
104
230
  *
105
- * Resolution order for apiKey: --key flag → RAPIDCRAWL_API_KEY env var.
231
+ * Resolution order (explicit input beats environment):
232
+ * apiKey: --key flag → BYTEKIT_API_KEY.
233
+ * baseUrl: --base-url flag → BYTEKIT_BASE_URL → DEFAULT_BASE_URL.
106
234
  * Exits with a descriptive error when no key is available.
107
235
  *
108
236
  * The returned client's `request` is replaced by the retrying transport, so every command inherits
109
237
  * the 429 retry from this one seam.
110
238
  */
111
239
  export function buildClient(opts) {
112
- const apiKey = opts.key ?? process.env['RAPIDCRAWL_API_KEY'];
240
+ const apiKey = opts.key ?? process.env['BYTEKIT_API_KEY'];
113
241
  if (!apiKey) {
114
- console.error('Error: API key required. Pass --key or set RAPIDCRAWL_API_KEY.');
242
+ console.error('Error: API key required. Pass --key or set BYTEKIT_API_KEY.');
115
243
  process.exit(1);
116
244
  }
117
- const baseUrl = opts.baseUrl ?? process.env['RAPIDCRAWL_BASE_URL'] ?? DEFAULT_BASE_URL;
118
- const client = new RapidCrawl({ apiKey, baseUrl });
245
+ const baseUrl = opts.baseUrl ?? process.env['BYTEKIT_BASE_URL'] ?? DEFAULT_BASE_URL;
246
+ const client = new ByteKit({ apiKey, baseUrl });
119
247
  client.request = createRetryingRequest(apiKey, baseUrl);
120
248
  return client;
121
249
  }
@@ -1,6 +1,28 @@
1
1
  import { Command } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError } from '../output.js';
4
+ /** Visible lead-in of the masked form: the `sk_live_`/`sk_test_` prefix (or first 8 chars). */
5
+ const MASK_PREFIX_CHARS = 8;
6
+ /** Visible tail of the masked form, shown only when enough of the middle stays hidden. */
7
+ const MASK_SUFFIX_CHARS = 4;
8
+ /** Minimum number of characters the `…` must actually hide before a suffix may be shown. */
9
+ const MIN_MASKED_CHARS = 8;
10
+ /**
11
+ * Mask a plaintext API key for human display: show the `sk_live_`/`sk_test_` prefix (or the first
12
+ * 8 chars) and the last 4, with the secret middle replaced by `…`. The last-4 suffix is shown ONLY
13
+ * when at least MIN_MASKED_CHARS of the middle stay hidden (key length >= 8 + 4 + 8 = 20): on a
14
+ * 13–19-char key, `slice(0, 8) + slice(-4)` would hide as little as one character, and at 12 or
15
+ * fewer the prefix and suffix overlap outright — either way the "mask" would leak most of a live
16
+ * secret, so everything after the recognized prefix is masked instead (#2480).
17
+ */
18
+ function maskKey(key) {
19
+ const prefixMatch = /^(sk_(?:live|test)_)/.exec(key);
20
+ if (key.length < MASK_PREFIX_CHARS + MASK_SUFFIX_CHARS + MIN_MASKED_CHARS) {
21
+ const prefix = prefixMatch ? prefixMatch[1] : key.slice(0, MASK_PREFIX_CHARS);
22
+ return `${prefix}…`;
23
+ }
24
+ return `${key.slice(0, MASK_PREFIX_CHARS)}…${key.slice(-MASK_SUFFIX_CHARS)}`;
25
+ }
4
26
  export function makeAccountCommand(getGlobal) {
5
27
  const cmd = new Command('account').description('Account and API key operations');
6
28
  const apiKeysCmd = new Command('api-keys').description('API key management');
@@ -67,6 +89,33 @@ export function makeAccountCommand(getGlobal) {
67
89
  handleError(err);
68
90
  }
69
91
  });
92
+ apiKeysCmd
93
+ .command('reveal <id>')
94
+ .description('Reveal an API key value (masked by default; use --show for plaintext)')
95
+ .option('--show', 'Print the full plaintext key instead of the masked form')
96
+ .action(async (id, opts) => {
97
+ const g = getGlobal();
98
+ const client = buildClient(g);
99
+ try {
100
+ const result = await client.account.apiKeys.reveal(id);
101
+ // Masking decision (#2480 E10): `reveal` prints a LIVE secret to a terminal that may be
102
+ // logged or screen-shared, so plaintext is opt-in. Default (human) mode masks the key
103
+ // (prefix + last-4, e.g. `sk_live_…2345`); `--show` prints the full plaintext; `--json`
104
+ // emits the full envelope verbatim for machine consumption.
105
+ if (g.json) {
106
+ printResult(result, true);
107
+ }
108
+ else if (opts.show === true) {
109
+ console.log(result.key);
110
+ }
111
+ else {
112
+ console.log(maskKey(result.key));
113
+ }
114
+ }
115
+ catch (err) {
116
+ handleError(err);
117
+ }
118
+ });
70
119
  apiKeysCmd
71
120
  .command('revoke <id>')
72
121
  .description('Revoke an API key (idempotent)')
@@ -1,5 +1,19 @@
1
1
  import { Command } from 'commander';
2
2
  import { type GlobalOptions } from '../client.js';
3
+ /** The `/v1/fetch` spec union for `format` — mirrors `FetchOpts['format']` in the SDK. */
4
+ type FetchFormat = 'markdown' | 'html';
5
+ /**
6
+ * Validate `--format` locally, before any HTTP request is issued.
7
+ *
8
+ * The SDK's `FetchOpts.format` is narrowed to the spec union `'markdown' | 'html'`
9
+ * (#2475), so an off-spec value like `pdf` is a compile-time error at the call site.
10
+ * Commander runs an option's argParser during parsing — ahead of the action callback —
11
+ * so rejecting here means the client is never built and no request is issued, and the
12
+ * caller gets an actionable message instead of a server 422 (same convention as
13
+ * `parseLimit` in search.ts).
14
+ */
15
+ export declare function parseFormat(value: string): FetchFormat;
3
16
  export declare function makeFetchCommand(getGlobal: () => GlobalOptions & {
4
17
  json: boolean;
5
18
  }): Command;
19
+ export {};
@@ -1,7 +1,24 @@
1
- import { Command } from 'commander';
1
+ import { Command, InvalidArgumentError } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
- import { printResult, handleError } from '../output.js';
3
+ import { printResult, emitFetchResult, handleError } from '../output.js';
4
4
  import { parseUrlLines } from '../lib/url-lines.js';
5
+ const FETCH_FORMATS = ['markdown', 'html'];
6
+ /**
7
+ * Validate `--format` locally, before any HTTP request is issued.
8
+ *
9
+ * The SDK's `FetchOpts.format` is narrowed to the spec union `'markdown' | 'html'`
10
+ * (#2475), so an off-spec value like `pdf` is a compile-time error at the call site.
11
+ * Commander runs an option's argParser during parsing — ahead of the action callback —
12
+ * so rejecting here means the client is never built and no request is issued, and the
13
+ * caller gets an actionable message instead of a server 422 (same convention as
14
+ * `parseLimit` in search.ts).
15
+ */
16
+ export function parseFormat(value) {
17
+ if (!FETCH_FORMATS.includes(value)) {
18
+ throw new InvalidArgumentError(`--format must be one of: ${FETCH_FORMATS.join(', ')}.`);
19
+ }
20
+ return value;
21
+ }
5
22
  export function makeFetchCommand(getGlobal) {
6
23
  const cmd = new Command('fetch').description('Fetch URL content operations');
7
24
  const bulkCmd = new Command('bulk').description('Bulk fetch operations');
@@ -41,7 +58,7 @@ export function makeFetchCommand(getGlobal) {
41
58
  .command('create')
42
59
  .description('Fetch URL content (POST)')
43
60
  .requiredOption('--url <url>', 'URL to fetch')
44
- .option('--format <format>', 'Response format')
61
+ .option('--format <format>', 'Response format (markdown or html)', parseFormat)
45
62
  .action(async (opts) => {
46
63
  const g = getGlobal();
47
64
  const client = buildClient(g);
@@ -50,7 +67,7 @@ export function makeFetchCommand(getGlobal) {
50
67
  url: opts.url,
51
68
  ...(opts.format !== undefined ? { format: opts.format } : {}),
52
69
  });
53
- printResult(result, g.json);
70
+ emitFetchResult(result, g.json);
54
71
  }
55
72
  catch (err) {
56
73
  handleError(err);
@@ -60,7 +77,7 @@ export function makeFetchCommand(getGlobal) {
60
77
  .command('get')
61
78
  .description('Fetch URL content (GET via query params)')
62
79
  .requiredOption('--url <url>', 'URL to fetch')
63
- .option('--format <format>', 'Response format')
80
+ .option('--format <format>', 'Response format (markdown or html)', parseFormat)
64
81
  .action(async (opts) => {
65
82
  const g = getGlobal();
66
83
  const client = buildClient(g);
@@ -69,7 +86,7 @@ export function makeFetchCommand(getGlobal) {
69
86
  url: opts.url,
70
87
  ...(opts.format !== undefined ? { format: opts.format } : {}),
71
88
  });
72
- printResult(result, g.json);
89
+ emitFetchResult(result, g.json);
73
90
  }
74
91
  catch (err) {
75
92
  handleError(err);