@trawlme/cli 3.7.4 → 3.8.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.
@@ -37,11 +37,122 @@ export interface ErrorEnvelope {
37
37
  message: string;
38
38
  status?: number;
39
39
  kind: string;
40
+ /** Is retrying the SAME command, unchanged, worth it? */
41
+ retryable: boolean;
42
+ /** Commands worth running next, most useful first. Empty when there is
43
+ * nothing honest to suggest — never filled to look helpful. */
44
+ next?: string[];
40
45
  }
41
46
  export interface ClassifiedError {
42
47
  exitCode: number;
43
48
  envelope: ErrorEnvelope;
44
49
  }
50
+ /**
51
+ * The exit-code taxonomy (#71 findings 13/14/60), named once so
52
+ * `classifyError` below and `spec.ts`'s `buildSpec()` (#170) read the SAME
53
+ * numbers instead of `spec.ts` hand-listing its own copy — exactly the kind
54
+ * of second source of truth `tests/contracts/` exists to catch drifting.
55
+ */
56
+ export declare const EXIT_CODES: Readonly<{
57
+ SUCCESS: 0;
58
+ UNKNOWN: 1;
59
+ USAGE: 2;
60
+ AUTH: 3;
61
+ NOT_FOUND: 4;
62
+ NETWORK: 5;
63
+ }>;
64
+ /** `trawl spec --json`'s `exitCodes` field — a short label per code. Code
65
+ * `1` is shared by several `kind`s (`api`, `refused`, `unknown`); the label
66
+ * names the generic/unmapped bucket it represents, not an exhaustive list. */
67
+ export declare const EXIT_CODE_LABELS: Readonly<Record<string, string>>;
68
+ /**
69
+ * #170 — ONE frozen map, keyed by the existing `kind`, driving the
70
+ * envelope's new `retryable`/`next` fields. This is also the exhaustive set
71
+ * of `kind`s `classifyError` can produce — `ERROR_KINDS` below derives its
72
+ * list from these keys rather than hand-listing them a second time, and
73
+ * `spec.ts`'s `buildSpec()` reads `ERROR_KINDS`, never its own copy.
74
+ *
75
+ * `network` is the one kind worth retrying unchanged. `auth` isn't
76
+ * retryable but has an honest next step (`trawl login --token <jwt>`).
77
+ * Everything else (`usage`/`not_found`/`api`/`refused`/`unknown`) is neither
78
+ * — retrying a bad flag, a missing resource, or an unmapped bug with no new
79
+ * information just repeats the same failure.
80
+ *
81
+ * #170 review F8 — `next` must be an EXECUTABLE next command, not just a verb
82
+ * name: bare `trawl login` still blocks on an interactive prompt (email then
83
+ * password) — for a non-interactive caller (stdin closed, the exact
84
+ * situation an auth failure implies) it fails immediately with its OWN usage
85
+ * error instead of the login the agent was told to run. `trawl login --token
86
+ * <jwt>` is the one form of `login` that never prompts.
87
+ */
88
+ export declare const RETRY_POLICY: Readonly<Record<string, {
89
+ retryable: boolean;
90
+ next?: readonly string[];
91
+ }>>;
92
+ /** Every `kind` string `classifyError` can produce — derived from
93
+ * `RETRY_POLICY`'s keys (see its doc comment), never a second hand list. */
94
+ export declare const ERROR_KINDS: readonly string[];
95
+ /**
96
+ * Every `kind` a real `--json` error envelope can carry — NOT the same set
97
+ * as `ERROR_KINDS`. `ERROR_KINDS` is deliberately narrow (exactly what
98
+ * `classifyError` produces, see its own doc comment — `RETRY_POLICY` must
99
+ * keep meaning that, `retryFieldsFor` relies on it), but three more kinds
100
+ * reach a real envelope from hand-built emitters that never go through
101
+ * `classifyError` at all:
102
+ * - `in_progress` — `src/commands/scraps.ts`, `data <id>`'s `reportDataState`
103
+ * call for a run still in flight
104
+ * - `run_failed` — same file, same helper, for a last run that failed
105
+ * - `upgrade_failed` — `src/commands/upgrade.ts`, when `npm install -g` itself fails
106
+ *
107
+ * `spec.ts`'s `errorKinds` field publishes THIS constant, never
108
+ * `ERROR_KINDS` — an agent that builds its allow-list from the published
109
+ * spec must not reject a perfectly valid `{"error":{"kind":"in_progress",…}}`
110
+ * envelope just because it was hand-built instead of classified. Built as a
111
+ * union (spread `ERROR_KINDS` + list the hand-built kinds) rather than a
112
+ * second hand-copy of the first group, so the classifyError kinds are
113
+ * PROVABLY a subset — see errors.test.ts's exhaustiveness check.
114
+ *
115
+ * Adding a new hand-built `kind:` literal anywhere in `src/` means
116
+ * registering it here too, or the published spec will lie about it exactly
117
+ * like this constant exists to prevent.
118
+ */
119
+ export declare const ENVELOPE_KINDS: readonly string[];
120
+ /**
121
+ * #170 review F7 — `spec --json`'s flat `exitCodes` map (`EXIT_CODE_LABELS`)
122
+ * publishes ONE label per code, which is honest about code `1` being a
123
+ * generic/unmapped bucket but says nothing about which `kind`s actually land
124
+ * there. An agent building an `exitCode -> kind` table off `exitCodes` alone
125
+ * reads `"1":"unknown"` and treats every other kind sharing that code
126
+ * (`api`/`refused`/`in_progress`/`run_failed`/`upgrade_failed`) as an
127
+ * unmapped bug it should give up on — instead of honouring the very
128
+ * `retryable`/`next` fields those envelopes correctly carry.
129
+ *
130
+ * This is the inverse direction, `kind -> exitCode`, one entry per
131
+ * `ENVELOPE_KINDS` member — additive (published as a SIBLING field,
132
+ * `kindExitCodes`, never replacing `exitCodes`) and keyed off the same
133
+ * `EXIT_CODES` constants every real call site already uses, so a future exit
134
+ * code change here can't silently drift from `classifyError`/
135
+ * `reportDataState`/`upgrade.ts`'s actual behaviour without also changing the
136
+ * single source those all draw from. errors.test.ts asserts every entry here
137
+ * against classifyError's REAL returned exitCode for that kind — the closest
138
+ * an exhaustive hand-map can get to "derived", short of `classifyError`
139
+ * itself being rewritten to loop over a shared table (out of scope here).
140
+ */
141
+ export declare const KIND_EXIT_CODES: Readonly<Record<string, number>>;
142
+ /**
143
+ * Look up the frozen default `retryable`/`next` for a `kind`. Total (never
144
+ * throws) — a `kind` outside `RETRY_POLICY` (e.g. `scraps data`'s own
145
+ * `run_failed`/`in_progress` states, which aren't part of `classifyError`'s
146
+ * taxonomy) falls back to the conservative "not retryable, nothing to
147
+ * suggest" default. Callers with a genuine kind-is-wrong override (an
148
+ * `ApiError` 429, `scraps data`'s `in_progress` refusal) pass their own
149
+ * `retryable`/`next` instead of trusting this lookup — see classifyError and
150
+ * scraps.ts's `reportDataState`.
151
+ */
152
+ export declare function retryFieldsFor(kind: string): {
153
+ retryable: boolean;
154
+ next?: string[];
155
+ };
45
156
  /**
46
157
  * Central status → exit-code map (#71 findings 13/14/60). Agents driving this
47
158
  * CLI unattended need to tell "you're not logged in" (3) from "that id
@@ -53,8 +164,8 @@ export declare function classifyError(err: unknown): ClassifiedError;
53
164
  /**
54
165
  * Print a classified error to the correct stream and return its exit code.
55
166
  * stdout is reserved for payload — under --json the error itself IS the
56
- * payload (`{"error":{message,status,kind}}`); otherwise the human-readable
57
- * line goes to stderr, never stdout. (#71 findings 13/14/60)
167
+ * payload (`{"error":{message,status,kind,retryable,next?}}`); otherwise the
168
+ * human-readable line goes to stderr, never stdout. (#71 findings 13/14/60)
58
169
  *
59
170
  * `quiet` skips the human-readable stderr line (used when the caller already
60
171
  * printed a fuller diagnostic, e.g. a raw stack trace under --debug) while
@@ -43,6 +43,145 @@ export class RefusalError extends Error {
43
43
  export function stripCommanderErrorPrefix(message) {
44
44
  return message.startsWith('error: ') ? message.slice('error: '.length) : message;
45
45
  }
46
+ /**
47
+ * The exit-code taxonomy (#71 findings 13/14/60), named once so
48
+ * `classifyError` below and `spec.ts`'s `buildSpec()` (#170) read the SAME
49
+ * numbers instead of `spec.ts` hand-listing its own copy — exactly the kind
50
+ * of second source of truth `tests/contracts/` exists to catch drifting.
51
+ */
52
+ export const EXIT_CODES = Object.freeze({
53
+ SUCCESS: 0,
54
+ UNKNOWN: 1,
55
+ USAGE: 2,
56
+ AUTH: 3,
57
+ NOT_FOUND: 4,
58
+ NETWORK: 5,
59
+ });
60
+ /** `trawl spec --json`'s `exitCodes` field — a short label per code. Code
61
+ * `1` is shared by several `kind`s (`api`, `refused`, `unknown`); the label
62
+ * names the generic/unmapped bucket it represents, not an exhaustive list. */
63
+ export const EXIT_CODE_LABELS = Object.freeze({
64
+ [EXIT_CODES.SUCCESS]: 'success',
65
+ [EXIT_CODES.UNKNOWN]: 'unknown',
66
+ [EXIT_CODES.USAGE]: 'usage',
67
+ [EXIT_CODES.AUTH]: 'auth',
68
+ [EXIT_CODES.NOT_FOUND]: 'not_found',
69
+ [EXIT_CODES.NETWORK]: 'network',
70
+ });
71
+ /**
72
+ * #170 — ONE frozen map, keyed by the existing `kind`, driving the
73
+ * envelope's new `retryable`/`next` fields. This is also the exhaustive set
74
+ * of `kind`s `classifyError` can produce — `ERROR_KINDS` below derives its
75
+ * list from these keys rather than hand-listing them a second time, and
76
+ * `spec.ts`'s `buildSpec()` reads `ERROR_KINDS`, never its own copy.
77
+ *
78
+ * `network` is the one kind worth retrying unchanged. `auth` isn't
79
+ * retryable but has an honest next step (`trawl login --token <jwt>`).
80
+ * Everything else (`usage`/`not_found`/`api`/`refused`/`unknown`) is neither
81
+ * — retrying a bad flag, a missing resource, or an unmapped bug with no new
82
+ * information just repeats the same failure.
83
+ *
84
+ * #170 review F8 — `next` must be an EXECUTABLE next command, not just a verb
85
+ * name: bare `trawl login` still blocks on an interactive prompt (email then
86
+ * password) — for a non-interactive caller (stdin closed, the exact
87
+ * situation an auth failure implies) it fails immediately with its OWN usage
88
+ * error instead of the login the agent was told to run. `trawl login --token
89
+ * <jwt>` is the one form of `login` that never prompts.
90
+ */
91
+ export const RETRY_POLICY = Object.freeze({
92
+ auth: { retryable: false, next: ['trawl login --token <jwt>'] },
93
+ network: { retryable: true },
94
+ usage: { retryable: false },
95
+ not_found: { retryable: false },
96
+ api: { retryable: false },
97
+ refused: { retryable: false },
98
+ unknown: { retryable: false },
99
+ });
100
+ /** Every `kind` string `classifyError` can produce — derived from
101
+ * `RETRY_POLICY`'s keys (see its doc comment), never a second hand list. */
102
+ export const ERROR_KINDS = Object.freeze(Object.keys(RETRY_POLICY));
103
+ /**
104
+ * Every `kind` a real `--json` error envelope can carry — NOT the same set
105
+ * as `ERROR_KINDS`. `ERROR_KINDS` is deliberately narrow (exactly what
106
+ * `classifyError` produces, see its own doc comment — `RETRY_POLICY` must
107
+ * keep meaning that, `retryFieldsFor` relies on it), but three more kinds
108
+ * reach a real envelope from hand-built emitters that never go through
109
+ * `classifyError` at all:
110
+ * - `in_progress` — `src/commands/scraps.ts`, `data <id>`'s `reportDataState`
111
+ * call for a run still in flight
112
+ * - `run_failed` — same file, same helper, for a last run that failed
113
+ * - `upgrade_failed` — `src/commands/upgrade.ts`, when `npm install -g` itself fails
114
+ *
115
+ * `spec.ts`'s `errorKinds` field publishes THIS constant, never
116
+ * `ERROR_KINDS` — an agent that builds its allow-list from the published
117
+ * spec must not reject a perfectly valid `{"error":{"kind":"in_progress",…}}`
118
+ * envelope just because it was hand-built instead of classified. Built as a
119
+ * union (spread `ERROR_KINDS` + list the hand-built kinds) rather than a
120
+ * second hand-copy of the first group, so the classifyError kinds are
121
+ * PROVABLY a subset — see errors.test.ts's exhaustiveness check.
122
+ *
123
+ * Adding a new hand-built `kind:` literal anywhere in `src/` means
124
+ * registering it here too, or the published spec will lie about it exactly
125
+ * like this constant exists to prevent.
126
+ */
127
+ export const ENVELOPE_KINDS = Object.freeze([
128
+ ...ERROR_KINDS,
129
+ 'in_progress',
130
+ 'run_failed',
131
+ 'upgrade_failed',
132
+ ]);
133
+ /**
134
+ * #170 review F7 — `spec --json`'s flat `exitCodes` map (`EXIT_CODE_LABELS`)
135
+ * publishes ONE label per code, which is honest about code `1` being a
136
+ * generic/unmapped bucket but says nothing about which `kind`s actually land
137
+ * there. An agent building an `exitCode -> kind` table off `exitCodes` alone
138
+ * reads `"1":"unknown"` and treats every other kind sharing that code
139
+ * (`api`/`refused`/`in_progress`/`run_failed`/`upgrade_failed`) as an
140
+ * unmapped bug it should give up on — instead of honouring the very
141
+ * `retryable`/`next` fields those envelopes correctly carry.
142
+ *
143
+ * This is the inverse direction, `kind -> exitCode`, one entry per
144
+ * `ENVELOPE_KINDS` member — additive (published as a SIBLING field,
145
+ * `kindExitCodes`, never replacing `exitCodes`) and keyed off the same
146
+ * `EXIT_CODES` constants every real call site already uses, so a future exit
147
+ * code change here can't silently drift from `classifyError`/
148
+ * `reportDataState`/`upgrade.ts`'s actual behaviour without also changing the
149
+ * single source those all draw from. errors.test.ts asserts every entry here
150
+ * against classifyError's REAL returned exitCode for that kind — the closest
151
+ * an exhaustive hand-map can get to "derived", short of `classifyError`
152
+ * itself being rewritten to loop over a shared table (out of scope here).
153
+ */
154
+ export const KIND_EXIT_CODES = Object.freeze({
155
+ auth: EXIT_CODES.AUTH,
156
+ network: EXIT_CODES.NETWORK,
157
+ usage: EXIT_CODES.USAGE,
158
+ not_found: EXIT_CODES.NOT_FOUND,
159
+ api: EXIT_CODES.UNKNOWN,
160
+ refused: EXIT_CODES.UNKNOWN,
161
+ unknown: EXIT_CODES.UNKNOWN,
162
+ // Hand-built kinds (never routed through classifyError) — in_progress/
163
+ // run_failed both set via reportDataState's literal `1` (scraps.ts),
164
+ // upgrade_failed via its own literal `process.exitCode = 1` (upgrade.ts).
165
+ in_progress: EXIT_CODES.UNKNOWN,
166
+ run_failed: EXIT_CODES.UNKNOWN,
167
+ upgrade_failed: EXIT_CODES.UNKNOWN,
168
+ });
169
+ /**
170
+ * Look up the frozen default `retryable`/`next` for a `kind`. Total (never
171
+ * throws) — a `kind` outside `RETRY_POLICY` (e.g. `scraps data`'s own
172
+ * `run_failed`/`in_progress` states, which aren't part of `classifyError`'s
173
+ * taxonomy) falls back to the conservative "not retryable, nothing to
174
+ * suggest" default. Callers with a genuine kind-is-wrong override (an
175
+ * `ApiError` 429, `scraps data`'s `in_progress` refusal) pass their own
176
+ * `retryable`/`next` instead of trusting this lookup — see classifyError and
177
+ * scraps.ts's `reportDataState`.
178
+ */
179
+ export function retryFieldsFor(kind) {
180
+ const policy = RETRY_POLICY[kind];
181
+ if (!policy)
182
+ return { retryable: false };
183
+ return policy.next ? { retryable: policy.retryable, next: [...policy.next] } : { retryable: policy.retryable };
184
+ }
46
185
  /**
47
186
  * Central status → exit-code map (#71 findings 13/14/60). Agents driving this
48
187
  * CLI unattended need to tell "you're not logged in" (3) from "that id
@@ -57,32 +196,59 @@ export function classifyError(err) {
57
196
  // `status:401` — that would claim a server response that never happened.
58
197
  // Same exit code / kind as a real server 401 (ApiError below); only the
59
198
  // envelope shape differs.
199
+ //
200
+ // #169 review round 2, finding 2 — `err.next` is only ever set (in api.ts,
201
+ // via authNextSteps()) for an apiKey-mode failure, where the frozen
202
+ // `next` default ("trawl login --token <jwt>") is inert until a live
203
+ // TRAWL_API_KEY/TRAWL_TOKEN is unset first. Spread AFTER retryFieldsFor so
204
+ // it overrides that default's `next` key; every other AuthError/ApiError
205
+ // 401 (jwt mode, notLoggedInError — no credential at all) leaves `err.next`
206
+ // undefined and keeps the unmodified frozen default, unchanged from before.
60
207
  if (err instanceof AuthError) {
61
- return { exitCode: 3, envelope: { message, kind: 'auth' } };
208
+ return {
209
+ exitCode: EXIT_CODES.AUTH,
210
+ envelope: { message, kind: 'auth', ...retryFieldsFor('auth'), ...(err.next ? { next: err.next } : {}) },
211
+ };
62
212
  }
63
213
  if (err instanceof ApiError) {
64
- if (err.status === 401)
65
- return { exitCode: 3, envelope: { message, status: 401, kind: 'auth' } };
66
- if (err.status === 404)
67
- return { exitCode: 4, envelope: { message, status: 404, kind: 'not_found' } };
68
- return { exitCode: 1, envelope: { message, status: err.status, kind: 'api' } };
214
+ if (err.status === 401) {
215
+ return {
216
+ exitCode: EXIT_CODES.AUTH,
217
+ envelope: {
218
+ message,
219
+ status: 401,
220
+ kind: 'auth',
221
+ ...retryFieldsFor('auth'),
222
+ ...(err.next ? { next: err.next } : {}),
223
+ },
224
+ };
225
+ }
226
+ if (err.status === 404) {
227
+ return { exitCode: EXIT_CODES.NOT_FOUND, envelope: { message, status: 404, kind: 'not_found', ...retryFieldsFor('not_found') } };
228
+ }
229
+ // #170 — a 429 (rate-limited / quota-exhausted) is the one `api`-kind
230
+ // response worth retrying, even though the frozen map's default for
231
+ // `api` is not retryable. Genuinely kind-is-wrong override, not a new
232
+ // kind — `kind` stays `"api"`.
233
+ const retry = err.status === 429 ? { retryable: true } : retryFieldsFor('api');
234
+ return { exitCode: EXIT_CODES.UNKNOWN, envelope: { message, status: err.status, kind: 'api', ...retry } };
69
235
  }
70
236
  if (err instanceof NetworkError) {
71
- return { exitCode: 5, envelope: { message, kind: 'network' } };
237
+ return { exitCode: EXIT_CODES.NETWORK, envelope: { message, kind: 'network', ...retryFieldsFor('network') } };
72
238
  }
73
239
  if (err instanceof UsageError) {
74
- return { exitCode: 2, envelope: { message, kind: 'usage' } };
240
+ return { exitCode: EXIT_CODES.USAGE, envelope: { message, kind: 'usage', ...retryFieldsFor('usage') } };
75
241
  }
76
242
  if (err instanceof RefusalError) {
77
- return { exitCode: 1, envelope: { message, kind: 'refused' } };
243
+ return { exitCode: EXIT_CODES.UNKNOWN, envelope: { message, kind: 'refused', ...retryFieldsFor('refused') } };
78
244
  }
79
- return { exitCode: 1, envelope: { message, kind: 'unknown' } };
245
+ return { exitCode: EXIT_CODES.UNKNOWN, envelope: { message, kind: 'unknown', ...retryFieldsFor('unknown') } };
80
246
  }
81
247
  /**
82
248
  * Print a classified error to the correct stream and return its exit code.
83
249
  * stdout is reserved for payload — under --json the error itself IS the
84
- * payload (`{"error":{message,status,kind}}`); otherwise the human-readable
85
- * line goes to stderr, never stdout. (#71 findings 13/14/60)
250
+ * payload (`{"error":{message,status,kind,retryable,next?}}`); otherwise the
251
+ * human-readable line goes to stderr, never stdout. (#71 findings 13/14/60)
86
252
  *
87
253
  * `quiet` skips the human-readable stderr line (used when the caller already
88
254
  * printed a fuller diagnostic, e.g. a raw stack trace under --debug) while
@@ -15,4 +15,35 @@ type SpinOptions<T> = string | {
15
15
  * to stderr, stdout stays clean either way).
16
16
  */
17
17
  export declare function spin<T>(action: Action<T>, options?: SpinOptions<T>): Promise<T>;
18
+ /**
19
+ * The counterpart to `spin()`'s silence — print a plain-text confirmation on
20
+ * stdout when, and only when, stdout is not a TTY.
21
+ *
22
+ * #160 then #166. `spin()` above deliberately emits nothing when stderr is not
23
+ * a TTY, so for every command whose only non-`--json` feedback was its
24
+ * `successText`, a piped or redirected invocation wrote **zero bytes to both
25
+ * streams while exiting 0**. Our ICP is agent builders, and agents pipe stdout:
26
+ * a verb that writes nothing on success is unusable from a script, because the
27
+ * caller cannot tell success from a no-op. Two of the affected commands were
28
+ * deletes, where that ambiguity is at its worst.
29
+ *
30
+ * Lives here, next to the silence it compensates for, because #160 fixed
31
+ * `trigger` by inlining this rule and #166 then found five siblings carrying
32
+ * the identical defect — five more inlined copies is how the next one gets
33
+ * missed. Callers pass the message; the gate lives in one place.
34
+ *
35
+ * Gated on **stdout**, not stderr: stdout is the stream a script actually reads
36
+ * (`$(trawl …)`, `> out.txt`, `| jq`), independent of whatever `spin()` decides
37
+ * about stderr. So an interactive session, where the ora spinner already
38
+ * confirmed on stderr, never gets a duplicate line here.
39
+ *
40
+ * Never call this on a `--json` path: under `--json`, stdout must stay exactly
41
+ * one parseable document.
42
+ *
43
+ * Known residual, unchanged from #160: stdout attached to a real terminal while
44
+ * stderr is separately redirected still prints nothing on either stream. That is
45
+ * not the reported or common shape (full redirection, or stdout-only capture),
46
+ * and widening the gate would put a duplicate line in front of interactive users.
47
+ */
48
+ export declare function confirmNonTTY(message: string): void;
18
49
  export {};
@@ -15,3 +15,37 @@ export function spin(action, options) {
15
15
  // oraPromise's own overloads accept (action, string) and (action, options).
16
16
  return oraPromise(action, options);
17
17
  }
18
+ /**
19
+ * The counterpart to `spin()`'s silence — print a plain-text confirmation on
20
+ * stdout when, and only when, stdout is not a TTY.
21
+ *
22
+ * #160 then #166. `spin()` above deliberately emits nothing when stderr is not
23
+ * a TTY, so for every command whose only non-`--json` feedback was its
24
+ * `successText`, a piped or redirected invocation wrote **zero bytes to both
25
+ * streams while exiting 0**. Our ICP is agent builders, and agents pipe stdout:
26
+ * a verb that writes nothing on success is unusable from a script, because the
27
+ * caller cannot tell success from a no-op. Two of the affected commands were
28
+ * deletes, where that ambiguity is at its worst.
29
+ *
30
+ * Lives here, next to the silence it compensates for, because #160 fixed
31
+ * `trigger` by inlining this rule and #166 then found five siblings carrying
32
+ * the identical defect — five more inlined copies is how the next one gets
33
+ * missed. Callers pass the message; the gate lives in one place.
34
+ *
35
+ * Gated on **stdout**, not stderr: stdout is the stream a script actually reads
36
+ * (`$(trawl …)`, `> out.txt`, `| jq`), independent of whatever `spin()` decides
37
+ * about stderr. So an interactive session, where the ora spinner already
38
+ * confirmed on stderr, never gets a duplicate line here.
39
+ *
40
+ * Never call this on a `--json` path: under `--json`, stdout must stay exactly
41
+ * one parseable document.
42
+ *
43
+ * Known residual, unchanged from #160: stdout attached to a real terminal while
44
+ * stderr is separately redirected still prints nothing on either stream. That is
45
+ * not the reported or common shape (full redirection, or stdout-only capture),
46
+ * and widening the gate would put a duplicate line in front of interactive users.
47
+ */
48
+ export function confirmNonTTY(message) {
49
+ if (!process.stdout.isTTY)
50
+ console.log(message);
51
+ }
@@ -6,21 +6,75 @@ telemetry, etc.) see the [main README](../README.md) — the human/CI guide.
6
6
 
7
7
  ## Auth — zero prompts
8
8
 
9
- Set `TRAWL_TOKEN` and every command authenticates without ever touching a
10
- prompt:
9
+ **Use `TRAWL_API_KEY`, not `TRAWL_TOKEN`, if you can.** A `trawl_*` API key
10
+ is revocable on its own and can be restricted to a set of scraps; a JWT
11
+ (`TRAWL_TOKEN`) is the human's own session — expiring, full-privilege, and
12
+ impossible to revoke without logging that human out everywhere.
13
+
14
+ Be precise about what "scoped" buys you, because the two axes are not equal
15
+ in practice. A key can carry a **scrap allow-list** (set in the dashboard at
16
+ creation) and an **action scope list** (`trawl:scraps:read` and friends).
17
+ Only the first is reachable from the web app; the second must be passed as an
18
+ explicit `scopes` array when the key is created over the API, and a key
19
+ created without one has **every action granted**. So unless someone minted it
20
+ deliberately through the API, the key you are holding is restricted by scrap,
21
+ not by action. `trawl` picks the right
22
+ `Authorization`/`Cookie` shape automatically based on which one you set —
23
+ never a flag:
24
+
25
+ ```bash
26
+ export TRAWL_API_KEY=trawl_xxx
27
+ trawl list --json
28
+ ```
29
+
30
+ `TRAWL_TOKEN` (a human session JWT) still works — set it only if you don't
31
+ have a key yet — and `TRAWL_API_KEY` always wins when both happen to be set:
11
32
 
12
33
  ```bash
13
34
  export TRAWL_TOKEN=<jwt>
14
- trawl whoami --json
35
+ trawl list --json
15
36
  ```
16
37
 
38
+ **What a key cannot do.** Of the twelve core commands below, a scoped key
39
+ covers all of them except `whoami`. Outside the core tier, a key also can't
40
+ reach `scraps update`/`delete`, `scraps account set`/`delete`/`clear-session`,
41
+ `scraps account session set`, `scraps banner`, or `scraps snapshot` (its
42
+ underlying `html-snapshot` route is JWT-only even though the scrap lookup it
43
+ starts from isn't) — nor the standalone SSE **command** `scraps watch`.
44
+ Those, plus `whoami`, stay JWT-only. `scraps account status`, `scraps
45
+ doctor`, and `scraps autofix` are the exceptions inside that same
46
+ management-tier group: all three only ever read routes trawl_node opened to
47
+ keys (`GET /api/scraps/:id`, `GET /api/historys/:hid`, `GET
48
+ /api/scraps/:id/activities`), so all three work fine under a key. (`scraps
49
+ watch` is a separate command, not the same thing as the `--watch` polling
50
+ flag on `run`/`trigger` below, which works fine under a key — do not
51
+ conflate the two.)
52
+
53
+ This list mirrors trawl_node's route wiring as of this writing, not a
54
+ permanent contract — a route's auth mode can change on either side without
55
+ this doc catching up same-day. Don't trust the list blind for a command not
56
+ named above: the reliable check is the real `--json` envelope, every time —
57
+ a JWT-only command run under a key exits `3` with a `kind:"auth"` envelope
58
+ pointing at `trawl login`, never a silent wrong answer, whether or not this
59
+ paragraph is current. `whoami` catches this client-side and names itself in
60
+ the message; every other JWT-only route above is caught by the real server
61
+ 401 instead, so the message says a session is required without naming which
62
+ command hit it.
63
+
64
+ `trawl token` also needs a word here: `getToken()` (the same resolver every
65
+ command above uses) can hand back `TRAWL_API_KEY` just as easily as a
66
+ session token, so `trawl token`/`$(trawl token)` may print a scoped,
67
+ **non-expiring** API key instead of an expiring JWT — check `--json`'s
68
+ `mode` field (`"apiKey"` or `"jwt"`) if your script branches on which one it
69
+ got.
70
+
17
71
  (Interactive `trawl login` and the `--url`/config-file flow are documented in
18
72
  the README's [Authentication](../README.md#authentication) section — an
19
73
  agent should never need them.)
20
74
 
21
75
  ## Core commands (agent + human)
22
76
 
23
- These eleven commands are the CLI's agent+human surface — `--json` is
77
+ These twelve commands are the CLI's agent+human surface — `--json` is
24
78
  first-class on every one, and none of them ever blocks on a prompt (see
25
79
  [Non-interactive contract](#non-interactive-contract) below):
26
80
 
@@ -35,8 +89,9 @@ trawl data <id> [--json] [--fresh] [--errors] Get scrap data (last persisted ru
35
89
  trawl history <id> [--json] [-n <limit>] List past runs for a scrap
36
90
  trawl run-info <hid> [--json] Show details of a single run
37
91
  trawl whoami [--json] Show the authenticated user's identity
38
- trawl token [--json] Print the stored session JWT (for MCP Bearer auth)
92
+ trawl token [--json] Print the stored credential — API key or session JWT (for MCP Bearer auth)
39
93
  trawl ping [--json] Health/version handshake against the Trawl API
94
+ trawl spec [--json] Print a versioned, machine-readable description of the command tree
40
95
  ```
41
96
 
42
97
  `trawl create` is the closest primitive to "turn this URL + goal into a
@@ -85,11 +140,33 @@ reaches a terminal state (not the whole progress stream), and there is no
85
140
  JSON form of an HTML page (irrelevant to the core verbs above — that only
86
141
  applies to the management-only `scraps snapshot`).
87
142
 
143
+ Capture **stdout alone** — never `2>&1`. Stderr legitimately carries
144
+ human-facing lines even in `--json` mode (the one-time telemetry disclosure
145
+ on first run, the skills re-sync notice, warnings); merging the two streams
146
+ corrupts the payload you're about to parse.
147
+
88
148
  On failure, `--json` emits a single error envelope on stdout instead of
89
- prose — `{"error":{"message","status?","kind"}}` — and the human-readable
90
- line goes to stderr, never stdout. `kind` is the machine-readable
91
- discriminant (`"usage"`/`"auth"`/`"not_found"`/`"network"`/`"api"`/
92
- `"refused"`/`"unknown"`) a script should switch on.
149
+ prose — `{"error":{"message","status?","kind","retryable","next?"}}` — and
150
+ the human-readable line goes to stderr, never stdout. `kind` is the
151
+ machine-readable discriminant (`"usage"`/`"auth"`/`"not_found"`/`"network"`/
152
+ `"api"`/`"refused"`/`"unknown"`/`"in_progress"`/`"run_failed"`/
153
+ `"upgrade_failed"`) a script should switch on — run `trawl spec --json` for
154
+ the exhaustive, versioned list.
155
+
156
+ `retryable` is the field a retry loop actually branches on — not `kind`, and
157
+ never a regex over `message` (e.g. "retry shortly" is prose, not a
158
+ contract): `true` means retrying the exact same command, unchanged, is
159
+ worth it (a network blip, a `429`, a run still in progress); `false` means
160
+ it isn't (a bad flag, an expired token, a missing resource — nothing about
161
+ retrying without changes would help). `next` is advisory only — commands
162
+ worth running next, most useful first (e.g. `["trawl login --token <jwt>"]`
163
+ on an auth failure — the one form of `login` that never blocks on an
164
+ interactive prompt) — and **may be empty or absent**: it is never filled in
165
+ just to look helpful, so don't treat its absence as an error.
166
+
167
+ For the full, versioned machine description of every command (arguments,
168
+ options, aliases, exit codes, error kinds) — instead of parsing this doc —
169
+ run `trawl spec --json`.
93
170
 
94
171
  ## Non-interactive contract
95
172
 
@@ -107,7 +184,7 @@ hanging. Full rule + rationale: README's
107
184
  | `0` | Success |
108
185
  | `1` | Unknown/generic error, or a business-logic outcome (e.g. `create`'s honest `success:false` first-run outcome, `data`'s `run_failed`/`in_progress`) |
109
186
  | `2` | Usage error (bad flag/value, invalid ID, missing required argument, or the non-interactive guard refusing to prompt) |
110
- | `3` | Auth error (not logged in, or the session token is expired/invalid) |
187
+ | `3` | Auth error (not logged in, the session token is expired/invalid, or a JWT-only command — see [above](#auth--zero-prompts) — was run under an API key) |
111
188
  | `4` | Not found (no such resource, or no persisted payload to read) |
112
189
  | `5` | Network error (API host unreachable, DNS/connection/TLS failure, or timeout) |
113
190
 
@@ -119,5 +196,5 @@ section — treat that as canonical if the two ever seem to disagree.
119
196
  ## Minimal example
120
197
 
121
198
  ```bash
122
- TRAWL_TOKEN=<jwt> trawl create https://example.com --prompt "Extract the article title and body text" --json
199
+ TRAWL_API_KEY=trawl_xxx trawl create https://example.com --prompt "Extract the article title and body text" --json
123
200
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "3.7.4",
3
+ "version": "3.8.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,6 +50,7 @@
50
50
  "strip-ansi": "^7.2.0"
51
51
  },
52
52
  "devDependencies": {
53
+ "@semantic-release/commit-analyzer": "13.0.1",
53
54
  "@types/node": "^25.5.2",
54
55
  "@vitest/coverage-v8": "^4.1.2",
55
56
  "typescript": "^6.0.2",