@trawlme/cli 3.7.5 → 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.
package/README.md CHANGED
@@ -29,11 +29,14 @@ trawl upgrade --check # only report if an update is available (exit 1 if so)
29
29
 
30
30
  ## Authentication
31
31
 
32
- Three methods:
32
+ Four methods:
33
33
 
34
34
  1. **Interactive** — `trawl login` (prompts for email and password)
35
35
  2. **Env var** — `TRAWL_TOKEN=<jwt> trawl list` (CI/CD, bypasses prompt)
36
36
  3. **Token flag** — `trawl login --token <jwt>` (CI/CD, direct JWT)
37
+ 4. **API key** — `TRAWL_API_KEY=trawl_xxx trawl list` (scoped, revocable one-by-one — the recommended credential for an agent driving this CLI; see [docs/agent-quickstart.md](docs/agent-quickstart.md))
38
+
39
+ A `TRAWL_API_KEY` is a `trawl_*`-prefixed credential (create/revoke one in the Trawl dashboard) sent as `Authorization: Bearer` instead of the session `Cookie: TOKEN=` a JWT uses — `trawl` picks the right one automatically based on the credential's own shape, never a flag. `TRAWL_API_KEY` wins over `TRAWL_TOKEN` when both happen to be set. It is scoped server-side — in practice to a set of scraps, since the dashboard's key-create form sets only the scrap allow-list; narrowing which *actions* a key may perform needs an explicit `scopes` array at creation over the API, and a key without one has every action granted. It works on most of the Core tier — `create`/`list`/`get`/`data`/`history`/`run-info`/`run`/`trigger`/`ping`, including the `--watch` **polling flag** on `run`/`trigger` — plus, outside Core, `scraps account status`/`scraps doctor`/`scraps autofix` (all three only ever read routes trawl_node opened to keys). It does **not** work on `whoami`, `scraps update`/`delete`, `scraps account set`/`delete`/`clear-session`/`session set`, `scraps banner`, `scraps snapshot` (the scrap lookup it starts from is dual-auth, but the `html-snapshot` route it downloads from isn't), or the standalone SSE **command** `scraps watch` (do not conflate the two: `--watch` is a flag on `run`/`trigger` and works under a key; `scraps watch` is a separate command and is JWT-only) — those stay JWT-only and fail with a `kind:"auth"` envelope (exit `3`) pointing at `trawl login` under a key. This list mirrors trawl_node's route wiring as of this writing, not a frozen guarantee — for anything not named here, trust the real `--json` envelope over this paragraph. Never send both a key and a JWT on the same request — the CLI only ever attaches one.
37
40
 
38
41
  Custom API URL: `trawl login --url https://self-hosted.example.com`
39
42
 
@@ -56,15 +59,16 @@ trawl data <id> [--json] [--fresh] [--errors] Get scrap data (last persisted ru
56
59
  trawl history <id> [--json] [-n <limit>] List past runs for a scrap (newest first)
57
60
  trawl run-info <hid> [--json] Show details of a single run
58
61
  trawl whoami [--json] Show the authenticated user's identity
59
- trawl token [--json] Print the stored session JWT (for MCP Bearer auth)
62
+ trawl token [--json] Print the stored credential — API key or session JWT (for MCP Bearer auth)
60
63
  trawl ping [--json] Health/version handshake against the Trawl API
64
+ trawl spec [--json] Print a versioned, machine-readable description of the command tree
61
65
  ```
62
66
 
63
67
  (`trawl create` AI-generates and persists a new scrap from a URL + a goal — distinct from `trawl scraps create`, which is raw manual scrap entry.)
64
68
 
65
69
  > **No breaking change:** every verb above is also still reachable under its pre-reorg path, `trawl scraps <verb>` (e.g. `trawl scraps list`, `trawl scraps run <id>`) — kept as a hidden alias so scripts written before the surface reorg keep working. `trawl --help` only shows the top-level form above; `trawl scraps --help` only shows the remaining scrap-management commands below.
66
70
 
67
- `create`/`whoami`/`ping` are fully non-interactive — all three read auth only from `TRAWL_TOKEN`/the stored login token, never prompt. `trawl create` runs the AI wizard server-side (`POST /api/ai/wizard`): generate scrap code from `--prompt` via LLM, persist the scrap, trigger its FIRST run, and auto-fix on failure (default on — `--no-autofix` disables it, sending `autoFix:false`). On a successful first run (human mode) it prints a small **data sample** (item count + first-item fields + one truncated value) as proof of value — best-effort, silent if the sample can't be fetched — and points `Next step` at `trawl data <id>` (the data), with `trawl get <id>` as the secondary detail view. `--json` skips the sample fetch and prints the raw wizard payload verbatim. `success` is an honest outcome of that first run, not "did the HTTP call succeed" — a failed first run is still a 200 response (the scrap was still created; auto-fix, when enabled, retries in the background), and the CLI exits 1 in that case (both human and `--json` modes) even though `--json` always prints the raw payload verbatim. The call legitimately takes 30–250s+ server-side (AI generation + a real run), same long-run timeout as `run`/`data --fresh`/`trigger --wait` below. `trawl whoami`/`trawl ping` mirror the MCP `trawl_whoami`/`trawl_health_ping` tools as closely as the REST surface allows (`GET /api/users/me` / `GET /api/health`) — `ping`'s `--json` payload is admin-enriched (version/uptime/db) and just `{"status":"ok"}` for anyone else.
71
+ `create`/`whoami`/`ping` are fully non-interactive — all three read auth only from `TRAWL_API_KEY`/`TRAWL_TOKEN`/the stored login token, never prompt (`whoami` is JWT-only — see [Authentication](#authentication) — so it still refuses honestly under a key instead of prompting). `trawl create` runs the AI wizard server-side (`POST /api/ai/wizard`): generate scrap code from `--prompt` via LLM, persist the scrap, trigger its FIRST run, and auto-fix on failure (default on — `--no-autofix` disables it, sending `autoFix:false`). On a successful first run (human mode) it prints a small **data sample** (item count + first-item fields + one truncated value) as proof of value — best-effort, silent if the sample can't be fetched — and points `Next step` at `trawl data <id>` (the data), with `trawl get <id>` as the secondary detail view. `--json` skips the sample fetch and prints the raw wizard payload verbatim. `success` is an honest outcome of that first run, not "did the HTTP call succeed" — a failed first run is still a 200 response (the scrap was still created; auto-fix, when enabled, retries in the background), and the CLI exits 1 in that case (both human and `--json` modes) even though `--json` always prints the raw payload verbatim. The call legitimately takes 30–250s+ server-side (AI generation + a real run), same long-run timeout as `run`/`data --fresh`/`trigger --wait` below. `trawl whoami`/`trawl ping` mirror the MCP `trawl_whoami`/`trawl_health_ping` tools as closely as the REST surface allows (`GET /api/users/me` / `GET /api/health`) — `ping`'s `--json` payload is admin-enriched (version/uptime/db) and just `{"status":"ok"}` for anyone else.
68
72
 
69
73
  > **`create` is NOT idempotent, and every wizard-created scrap runs on a DAILY cron by default.** A client-side timeout (exit `5`, a `NetworkError`) does not mean the wizard failed server-side — scrap creation + the first run keep going after the CLI gives up waiting, so the scrap may already exist. Run `trawl list` and look for a matching URL/title **before** retrying — a blind retry creates a DUPLICATE scrap and burns AI-generation quota a second time for the same goal. Separately, the scrap the wizard creates is scheduled to re-run every day at 07:00 UTC (`cron: "0 7 * * *"`, hardcoded server-side, unrelated to `--no-autofix`) — each of those recurring runs consumes execute quota like any other run. Review the generated scrap, then change or disable the schedule with `trawl scraps update <id> --cron <expr>` (or `--no-cron` to disable it). Because the call can legitimately run 250s+, also confirm `TRAWL_TIMEOUT` isn't set to something tighter than `create` needs — the env var always wins over `create`'s own 300s default (see [Environment variables](#environment-variables)), so a value set for another purpose (e.g. a tight CI smoke-test budget) silently clamps `create` too; unset it or raise it before running `create`.
70
74
 
@@ -74,6 +78,7 @@ trawl ping [--json] Health/version handshake against
74
78
  - `get` (and anything reading through it, like `data`'s default path) embeds only the newest 100 history rows on the returned scrap object — `run-info` and `scraps doctor` fetch a single run directly and are unaffected by that cap. `list`/`get` show a distinct amber `▼` "regression" badge, never the red `✗` a genuine failure gets (matches `scraps doctor`'s own badge).
75
79
  - **Long-running calls (`create`, `run`, `data --fresh`, `trigger --wait`):** these hit server-side paths that can legitimately take 30–250s+ (AI generation + scrap creation + a first run for `create`; proxy tier escalation + AI-fix retries for the other three) — the CLI arms a 300s timeout for exactly these four call sites instead of the generic 30s default. `TRAWL_TIMEOUT` (see below) still overrides ALL requests, including these — set it if you need a tighter or looser ceiling than 300s, but note a global override that tight also clamps `create`.
76
80
  - **`--watch` is poll-based, not a live stream:** the activities SSE endpoint has no backlog and, for the default async `trigger` (no `--wait`), runs in a separate cron-consumer pod whose events never reach the API pod holding the SSE connection — a naive "await the run, then open SSE" shows nothing. `run --watch` and `trigger --watch` instead poll `GET /api/scraps/:id` (terminal status) and the activities REST list until the run finishes, printing each new activity line as it appears. The watched run's outcome drives the exit code too, in BOTH human and `--json` mode: a genuinely failed terminal run, a poll timeout, or a persistently unreachable API all exit non-zero — a clean successful run is the only exit `0`. A run that never reaches a terminal status within 300s prints an honest timeout notice pointing at `scraps doctor <id>` (human mode) — see the `--json` shape below. `scraps watch <id>` (the standalone command, no trigger) is unchanged — it still opens the live SSE stream directly.
81
+ - `spec --json` prints the CLI's own command tree — `{specVersion, cliVersion, commands[], exitCodes, errorKinds, kindExitCodes}` — DERIVED at runtime by walking the live commander tree (never a hand-maintained file, which would silently drift from reality). Each entry in `commands[]` carries its full path (e.g. `"scraps account session set"`), description, `hidden` (the legacy `scraps <verb>` aliases above), `leaf` (false for a pure namespace/group node like `scraps`/`skills`/`telemetry` — invoking one directly is a guaranteed-failing tool, not a real command), `aliases`, `arguments`, and `options`; `exitCodes`/`errorKinds`/`kindExitCodes` are read from the exact same source `classifyError` uses (see [Exit codes](#exit-codes)) — never a second copy. `kindExitCodes` is the inverse of `exitCodes`: `kind -> exitCode`, since exit code `1` alone is a shared bucket (`api`/`refused`/`unknown`/`in_progress`/`run_failed`/`upgrade_failed`) that `exitCodes`' flat label can't disambiguate. Without `--json`, `spec` prints one short human line (version + visible command count) pointing at `--json`.
77
82
  - `run --json`/`trigger --json` bypass the spinner and print the raw launch/trigger payload on stdout; combined with `--watch`, every intermediate progress line stays suppressed (stdout stays pure JSON) and, once the watch reaches its outcome, exactly ONE final NDJSON line is emitted: `{"runId","status"}` (the honest terminal status — `success`/`error`/`empty`/`regression`/…), `{"runId","status":"timeout"}` on a poll timeout, or `{"runId","status":"poll_error","error"}` if the API stays unreachable for several consecutive polls — `process.exitCode` is non-zero for all three except a genuine success. `scraps watch --json` emits one raw JSON object per activity line (NDJSON) instead of the formatted `[time] message` text — there's no single final payload to wait for on a live stream.
78
83
 
79
84
  ### Scrap management
@@ -135,7 +140,9 @@ trawl login [--url <url>] [--token <jwt>] [--email <email>] [--password <pass>]
135
140
  trawl logout [--json]
136
141
  ```
137
142
 
138
- `login --json` prints `{"ok":true,"apiUrl","config",email?}` on success — never the raw token (that's `trawl token`'s job, in the [Core commands](#core-commands-agent--human) section above — non-interactive/agent-consumable, unlike `login`/`logout`). `token --json` prints `{"token","exp","expiresAt"}` instead of the bare JWT + stderr advisories. See [Non-interactive rule](#non-interactive-rule) below: a missing `--email`/`--password` refuses immediately (usage error, exit 2) instead of prompting when `--json` is set or stdin/stdout isn't a real TTY.
143
+ `login --json` prints `{"ok":true,"apiUrl","config",email?}` on success — never the raw token (that's `trawl token`'s job, in the [Core commands](#core-commands-agent--human) section above — non-interactive/agent-consumable, unlike `login`/`logout`). `token --json` prints `{"token","exp","expiresAt","mode"}` `mode` is `"apiKey"` or `"jwt"`, so a machine caller can tell which credential it just got without re-deriving the `trawl_*` prefix itself; `exp`/`expiresAt` are `null` for an API key (it has no expiry to report). Human mode prints the credential on stdout plus a stderr advisory: an expiry countdown for a JWT, or a one-line note for an API key that it does not expire on its own. See [Non-interactive rule](#non-interactive-rule) below: a missing `--email`/`--password` refuses immediately (usage error, exit 2) instead of prompting when `--json` is set or stdin/stdout isn't a real TTY.
144
+
145
+ If `TRAWL_API_KEY` or `TRAWL_TOKEN` is set in the environment, both `login` and `logout` print a stderr warning naming that variable — it outranks the config-stored token (see [Authentication](#authentication)) for every subsequent command regardless of what `login`/`logout` just did. On `logout` specifically this means access is **not actually revoked** while that variable stays set.
139
146
 
140
147
  ### Telemetry
141
148
 
@@ -204,16 +211,19 @@ Every command exits with one of these codes — scripts and agents driving the C
204
211
  | `0` | Success |
205
212
  | `1` | Unknown/generic error (an unmapped failure — API errors other than 401/404, an unhandled bug — or a business-logic refusal like `data`'s `run_failed`/`in_progress` states, or `trawl create`'s honest `success:false` first-run outcome). **Known overload:** `create`'s domain-level failure and an arbitrary unmapped bug both land on `1` — a script needs to read the `--json` payload's own `success` field (for `create`) to tell them apart; this is intentional (the REST contract's outcome states are a separate axis from the CLI's transport-error taxonomy) and documented here rather than "resolved" by inventing a new code that would only apply to one command. |
206
213
  | `2` | Usage error (bad flag/value, invalid ID, missing required argument, unknown option/command — including the [non-interactive rule](#non-interactive-rule) refusing to prompt) |
207
- | `3` | Auth error (not logged in, or the session token is expired/invalid — run `trawl login`) |
214
+ | `3` | Auth error (not logged in, the session token is expired/invalid, or a JWT-only command was run under an API key see [Authentication](#authentication)) — run `trawl login` |
208
215
  | `4` | Not found (no such resource, or — for `data` — no persisted payload to read) |
209
216
  | `5` | Network error (the API host is unreachable, DNS/connection/TLS failure, or the request timed out) |
210
217
 
211
- Under `--json`, a failing command emits a single error envelope on stdout — `{"error":{"message","status?","kind"}}` — instead of prose; the human-readable line always goes to stderr, never stdout. `kind` is the machine-readable discriminant (`"usage"`/`"auth"`/`"not_found"`/`"network"`/`"api"`/`"refused"`/`"unknown"`/…) — the short string a script should switch on (`"refused"` is a business-logic refusal the server explicitly reported back, e.g. `scraps create|update`'s tier-ceiling override rejection — distinct from `"unknown"`, which stays reserved for an unmapped bug); `status` is present only when a real HTTP response carried one (never fabricated for a local failure like an expired-locally JWT or a refused confirmation prompt).
218
+ Under `--json`, a failing command emits a single error envelope on stdout — `{"error":{"message","status?","kind","retryable","next?"}}` — instead of prose; the human-readable line always goes to stderr, never stdout. `kind` **stays the primary discriminant** — the short string a script should switch on (`"usage"`/`"auth"`/`"not_found"`/`"network"`/`"api"`/`"refused"`/`"unknown"`/`"in_progress"`/`"run_failed"`/`"upgrade_failed"`; `"refused"` is a business-logic refusal the server explicitly reported back, e.g. `scraps create|update`'s tier-ceiling override rejection — distinct from `"unknown"`, which stays reserved for an unmapped bug); `status` is present only when a real HTTP response carried one (never fabricated for a local failure like an expired-locally JWT or a refused confirmation prompt).
219
+
220
+ `retryable` and `next` are additive — a retry loop branches on `retryable`, not on parsing `message` (e.g. the pre-existing "retry shortly" text is English prose, not a machine contract). `retryable` answers one question: is retrying the SAME command, unchanged, worth it? A network failure is (`retryable:true`); a bad flag, an expired token, or a 404 isn't — retrying without changing anything just repeats the same failure. `next`, when present, lists commands worth running next, most useful first (e.g. `["trawl login --token <jwt>"]` on an auth failure — the one form of `login` that never blocks on an interactive prompt) — it is empty/absent whenever there is nothing honest to suggest, never filled in just to look helpful. Two cases where `kind` alone doesn't tell the whole retry story: a `429` response (`kind:"api"`) is retryable even though `"api"` isn't by default; `data`'s "run in progress" refusal is retryable with `next:["trawl data <id>"]` — it never suggests `--fresh`, which would just 429 against the run already holding the lock.
212
221
 
213
222
  ## Environment variables
214
223
 
215
224
  | Variable | Description |
216
225
  |-----------------------|--------------------------------------------------------------------|
226
+ | `TRAWL_API_KEY` | Scoped `trawl_*` API key — takes precedence over `TRAWL_TOKEN`; recommended for an agent driving the CLI (see [Authentication](#authentication)) |
217
227
  | `TRAWL_TOKEN` | JWT token — bypasses login prompt, useful for CI/CD |
218
228
  | `TRAWL_API_URL` | Override the API base URL for the session (takes precedence over `trawl login --url`) |
219
229
  | `TRAWL_TELEMETRY` | Set to `0` to disable telemetry for the current session |
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import config, { getApiUrl } from '../lib/config.js';
3
+ import config, { getApiUrl, getLiveAuthEnvVar } from '../lib/config.js';
4
4
  import { api } from '../lib/api.js';
5
5
  import { requireFreshJwt, requireUrl } from '../lib/validate.js';
6
6
  import { promptPassword } from '../lib/prompt.js';
@@ -32,6 +32,14 @@ async function promptEmail() {
32
32
  * structured result on stdout instead of the chalk lines — never the raw
33
33
  * token itself (that's what `trawl token` is for). */
34
34
  function reportLoginSuccess(opts, email) {
35
+ // #169 review finding 3 — stderr, both under --json and plain output: this
36
+ // is a warning about what happens AFTER login succeeds, not part of either
37
+ // payload shape, so it must never be silently dropped just because --json
38
+ // is set.
39
+ const overrideVar = getLiveAuthEnvVar();
40
+ if (overrideVar) {
41
+ console.error(chalk.yellow(`⚠ ${overrideVar} is set in your environment — it overrides the token just stored here for every subsequent command until you unset it.`));
42
+ }
35
43
  if (opts.json) {
36
44
  json({ ok: true, apiUrl: getApiUrl(), config: config.path, ...(email && { email }) });
37
45
  return;
@@ -110,6 +118,16 @@ export const logout = new Command('logout')
110
118
  .option('--json', 'Output as JSON')
111
119
  .action((opts) => {
112
120
  config.set('token', '');
121
+ // #169 review finding 3 — this is the sharpest version of the gap: an
122
+ // operator runs `logout` SPECIFICALLY to kill access, and if
123
+ // TRAWL_API_KEY/TRAWL_TOKEN is set, access is NOT killed — every
124
+ // subsequent command keeps authenticating with the env credential this
125
+ // command cannot touch. The wording below must not read as a variant of
126
+ // "logged out"; it says plainly that access is still live.
127
+ const overrideVar = getLiveAuthEnvVar();
128
+ if (overrideVar) {
129
+ console.error(chalk.yellow(`⚠ ${overrideVar} is still set in your environment — access is NOT revoked. Every subsequent command will keep authenticating with it until you unset it (or revoke the key in the dashboard).`));
130
+ }
113
131
  if (opts.json) {
114
132
  json({ ok: true });
115
133
  return;
@@ -6,7 +6,7 @@ import { table, json, formatDate } from '../lib/format.js';
6
6
  import { parseServerJson } from '../lib/json.js';
7
7
  import { promptPassword } from '../lib/prompt.js';
8
8
  import { validateObjectId, requireUrl } from '../lib/validate.js';
9
- import { classifyError, reportError, UsageError, RefusalError } from '../lib/errors.js';
9
+ import { classifyError, reportError, retryFieldsFor, UsageError, RefusalError } from '../lib/errors.js';
10
10
  import { confirmDestructive, isInteractive } from '../lib/confirm.js';
11
11
  import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
12
12
  import { renderPinch, pinchEnabled } from '../lib/pinch.js';
@@ -590,7 +590,17 @@ scraps
590
590
  .command('create')
591
591
  .description('Create a new scrap')
592
592
  .requiredOption('-t, --title <title>', 'Scrap title')
593
- .option('-u, --url <url>', 'Target URL the site the scrap targets, not necessarily the exact URL fetched at run time (a request script can compute that, e.g. a templated search query). Required.')
593
+ // #170 review F6promoted from a plain `.option()` to `.requiredOption()`:
594
+ // this is unconditionally required (no positional alternative exists on
595
+ // THIS command, unlike the top-level `trawl create <url>`, which accepts
596
+ // `[url]` OR `--url` — deliberately left untouched, that either/or needs a
597
+ // human product decision, tracked separately). Before this, the flag's
598
+ // description carried the only "Required." signal, in English prose — the
599
+ // exact thing docs/agent-quickstart.md tells an agent not to parse.
600
+ // Commander itself now enforces it, so `trawl spec --json`'s published
601
+ // `mandatory:true` is honest instead of a second, driftable copy of this
602
+ // same fact.
603
+ .requiredOption('-u, --url <url>', 'Target URL — the site the scrap targets, not necessarily the exact URL fetched at run time (a request script can compute that, e.g. a templated search query).')
594
604
  .option('-r, --request <request>', 'Request/query')
595
605
  .option('-d, --description <text>', 'Scrap description')
596
606
  .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
@@ -856,10 +866,27 @@ function renderScrapItems(items, asJson) {
856
866
  * all "usage" (2) — never-run / aged-out-of-retention are not_found (4), a
857
867
  * failed last run is a business-logic failure (1).
858
868
  */
859
- function reportDataState(message, exitCode, kind, wantsJson) {
869
+ /**
870
+ * #170 — `retryable`/`next` follow the SAME frozen `RETRY_POLICY` map
871
+ * `classifyError` reads (errors.ts), keyed by `kind` — this path never
872
+ * builds its own copy. `retryOverride` is for the one case here where the
873
+ * kind alone is genuinely wrong: `in_progress`'s caller passes
874
+ * `{retryable:true, next:['trawl data <id>']}` since retrying `data` (not
875
+ * `--fresh`, which would 429 against the lock already held) IS worth doing
876
+ * once the run finishes. Every other kind here (`not_found`, `run_failed`)
877
+ * isn't in `RETRY_POLICY` at all — `retryFieldsFor` is total, so those fall
878
+ * back to its conservative "not retryable, nothing to suggest" default.
879
+ */
880
+ function reportDataState(message, exitCode, kind, wantsJson, retryOverride) {
860
881
  console.error(chalk.red(`✗ ${message}`));
861
882
  if (wantsJson) {
862
- console.log(JSON.stringify({ error: { message, kind } }));
883
+ const { retryable, next } = retryOverride ?? retryFieldsFor(kind);
884
+ // #170 review F3 — typed as ErrorEnvelope so the compiler enforces
885
+ // `retryable` here too (this was one of two emitters a mutation test
886
+ // proved `tsc --noEmit` never actually guarded before this annotation —
887
+ // the untyped literal let `retryable` be omitted silently).
888
+ const envelope = { message, kind, retryable, ...(next ? { next } : {}) };
889
+ console.log(JSON.stringify({ error: envelope }));
863
890
  }
864
891
  process.exitCode = exitCode;
865
892
  }
@@ -975,7 +1002,10 @@ export function attachDataCommand(parent, attachOpts = {}) {
975
1002
  // run already holds the server-side distributed lock, so --fresh would
976
1003
  // just 429 against it.
977
1004
  if (last.status === null) {
978
- reportDataState(`Run in progress for ${id} retry shortly.`, 1, 'in_progress', opts.json);
1005
+ // #170 retrying `trawl data <id>` (unchanged) once the run
1006
+ // finishes IS worth it; `--fresh` never is (it would 429 against the
1007
+ // lock this very run already holds) — never suggested here.
1008
+ reportDataState(`Run in progress for ${id} — retry shortly.`, 1, 'in_progress', opts.json, { retryable: true, next: [`trawl data ${id}`] });
979
1009
  return;
980
1010
  }
981
1011
  // #86 review — node persists status=false for a GENUINE zero-item run
@@ -0,0 +1,92 @@
1
+ import { Command } from 'commander';
2
+ /**
3
+ * `trawl spec --json` (#170) — a versioned, machine-readable description of
4
+ * the command tree, so an agent can learn the CLI's surface without parsing
5
+ * ~250 lines of README prose. Everything below is DERIVED at runtime from
6
+ * the live commander `Command` tree (`buildSpec`) — never a hand-maintained
7
+ * file, which would be a second source of truth that silently drifts. See
8
+ * `tests/contracts/` for the README-vs-spec cross-check that exists
9
+ * specifically to catch that drift.
10
+ */
11
+ export interface CliSpecArgument {
12
+ name: string;
13
+ required: boolean;
14
+ variadic: boolean;
15
+ }
16
+ export interface CliSpecOption {
17
+ long: string;
18
+ short?: string;
19
+ description: string;
20
+ /**
21
+ * commander's `Option#required`/`Option#mandatory` are a false-friend pair
22
+ * that an earlier draft of this file collapsed into one `required` key —
23
+ * DON'T "simplify" it back. `Option#required` means "if this option is
24
+ * used, a value must follow" (true for any `<value>` flag, e.g. `--limit
25
+ * <n>`, whether or not the option itself is optional to pass). `mandatory`
26
+ * means "the user MUST pass this option at all" (only true for
27
+ * `.requiredOption()`). Naming the value-arity field `required` (as this
28
+ * did before) reads as "you must specify this option" — wrong for ~30 of
29
+ * the ~34 options that tripped it. See commander's option.js:15/19.
30
+ */
31
+ mandatory: boolean;
32
+ valueRequired: boolean;
33
+ negatable: boolean;
34
+ default?: unknown;
35
+ }
36
+ export interface CliSpecCommand {
37
+ /** Full path from the program root, e.g. "scraps account session set". */
38
+ name: string;
39
+ description: string;
40
+ hidden: boolean;
41
+ /**
42
+ * True when this node has no subcommands of its own — i.e. it is an
43
+ * executable leaf (`.action()` actually runs something), not a pure
44
+ * namespace/group node like `scraps`/`skills`/`telemetry`/`scraps account`.
45
+ * An agent that emits one tool per spec entry needs this to skip group
46
+ * nodes: invoking one directly (e.g. `trawl scraps`) exits non-zero with
47
+ * usage text on stderr and ZERO bytes on stdout — a guaranteed-failing
48
+ * tool, not a --json envelope. Derived the same way
49
+ * `tests/contracts/readme-commands.test.ts` independently re-derives it
50
+ * (its own `leafNames()` walk existed ONLY because this field didn't) —
51
+ * that test now consumes THIS field instead of re-walking the tree itself.
52
+ */
53
+ leaf: boolean;
54
+ aliases: string[];
55
+ arguments: CliSpecArgument[];
56
+ options: CliSpecOption[];
57
+ }
58
+ export interface CliSpec {
59
+ specVersion: 1;
60
+ cliVersion: string;
61
+ commands: CliSpecCommand[];
62
+ /** "0".."5" → a short kind label. Same constant `classifyError` reads
63
+ * (errors.ts's EXIT_CODE_LABELS) — never a second copy. Code `1` is a
64
+ * SHARED bucket (several `kind`s land there, see `kindExitCodes` below
65
+ * for the honest reverse mapping) — this label names the generic/unmapped
66
+ * case, not an exhaustive claim that `1` means only that. */
67
+ exitCodes: Record<string, string>;
68
+ /** Every `kind` a --json error envelope can carry — the classifyError
69
+ * kinds PLUS the hand-built ones (`in_progress`/`run_failed`/
70
+ * `upgrade_failed`). Same constant (errors.ts's ENVELOPE_KINDS) — never a
71
+ * second copy, and never `ERROR_KINDS` (that one omits the hand-built
72
+ * kinds by design — see its doc comment). */
73
+ errorKinds: string[];
74
+ /**
75
+ * #170 review F7 — the inverse of `exitCodes`: `kind -> exitCode`, one
76
+ * entry per `errorKinds` member. `exitCodes` alone can't tell an agent
77
+ * which of the several kinds sharing code `1` (`api`/`refused`/`unknown`/
78
+ * `in_progress`/`run_failed`/`upgrade_failed`) it actually got — this
79
+ * field answers that directly instead of requiring the reverse lookup to
80
+ * be reconstructed by hand. Same source constant as `errorKinds`
81
+ * (errors.ts's KIND_EXIT_CODES) — never a second copy.
82
+ */
83
+ kindExitCodes: Record<string, number>;
84
+ }
85
+ /**
86
+ * Build the full spec from a live, already-constructed program (e.g.
87
+ * `createProgram()`'s return value). Includes every node in the tree —
88
+ * hidden legacy aliases (`scraps list`, …) included, flagged via `hidden`,
89
+ * so a consumer that wants only the advertised surface can filter on it.
90
+ */
91
+ export declare function buildSpec(program: Command): CliSpec;
92
+ export declare const spec: Command;
@@ -0,0 +1,83 @@
1
+ import { Command, Help } from 'commander';
2
+ import { json } from '../lib/format.js';
3
+ import { EXIT_CODE_LABELS, ENVELOPE_KINDS, KIND_EXIT_CODES } from '../lib/errors.js';
4
+ function buildOption(option) {
5
+ const entry = {
6
+ long: option.long ?? '',
7
+ description: option.description,
8
+ mandatory: option.mandatory,
9
+ valueRequired: option.required,
10
+ negatable: option.negate,
11
+ };
12
+ if (option.short)
13
+ entry.short = option.short;
14
+ if (option.defaultValue !== undefined)
15
+ entry.default = option.defaultValue;
16
+ return entry;
17
+ }
18
+ function buildCommandEntry(cmd, name, hidden) {
19
+ return {
20
+ name,
21
+ description: cmd.description(),
22
+ hidden,
23
+ leaf: cmd.commands.length === 0,
24
+ aliases: [...cmd.aliases()],
25
+ arguments: cmd.registeredArguments.map((arg) => ({
26
+ name: arg.name(),
27
+ required: arg.required,
28
+ variadic: arg.variadic,
29
+ })),
30
+ options: cmd.options.map(buildOption),
31
+ };
32
+ }
33
+ /**
34
+ * Walk `cmd.commands` recursively, collecting one entry per node under its
35
+ * FULL path name. `hidden` is read via commander's own
36
+ * `Help#visibleCommands()` — the same idiom this codebase already uses
37
+ * (scraps.test.ts's #108 describe block) — rather than reaching for the
38
+ * private, untyped `_hidden` field directly.
39
+ */
40
+ function walk(cmd, prefix, out) {
41
+ const visible = new Set(new Help().visibleCommands(cmd));
42
+ for (const sub of cmd.commands) {
43
+ const name = prefix ? `${prefix} ${sub.name()}` : sub.name();
44
+ out.push(buildCommandEntry(sub, name, !visible.has(sub)));
45
+ walk(sub, name, out);
46
+ }
47
+ }
48
+ /**
49
+ * Build the full spec from a live, already-constructed program (e.g.
50
+ * `createProgram()`'s return value). Includes every node in the tree —
51
+ * hidden legacy aliases (`scraps list`, …) included, flagged via `hidden`,
52
+ * so a consumer that wants only the advertised surface can filter on it.
53
+ */
54
+ export function buildSpec(program) {
55
+ const commands = [];
56
+ walk(program, '', commands);
57
+ return {
58
+ specVersion: 1,
59
+ cliVersion: program.version() ?? 'unknown',
60
+ commands,
61
+ exitCodes: { ...EXIT_CODE_LABELS },
62
+ errorKinds: [...ENVELOPE_KINDS],
63
+ kindExitCodes: { ...KIND_EXIT_CODES },
64
+ };
65
+ }
66
+ export const spec = new Command('spec')
67
+ .description('Print a versioned, machine-readable description of the command tree')
68
+ .option('--json', 'Output as JSON')
69
+ .action((opts) => {
70
+ // `spec` is registered as a direct child of the program root
71
+ // (index.ts's createProgram), so `.parent` IS that root by the time this
72
+ // action ever runs — commander sets it in `addCommand()`. Falling back
73
+ // to `spec` itself only matters for an isolated unit invocation (e.g. a
74
+ // test driving `spec.parseAsync()` directly, unattached to a program).
75
+ const root = spec.parent ?? spec;
76
+ const cliSpec = buildSpec(root);
77
+ if (opts.json) {
78
+ json(cliSpec);
79
+ return;
80
+ }
81
+ const visibleCount = cliSpec.commands.filter((c) => !c.hidden).length;
82
+ console.log(`trawl ${cliSpec.cliVersion} — ${visibleCount} commands. Use --json for the full machine-readable spec.`);
83
+ });
@@ -1,17 +1,18 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { getToken } from '../lib/config.js';
3
+ import { getToken, getAuthMode } from '../lib/config.js';
4
4
  import { AuthError, notLoggedInError } from '../lib/api.js';
5
5
  import { reportError } from '../lib/errors.js';
6
6
  import { decodeExp } from '../lib/jwt.js';
7
7
  import { json } from '../lib/format.js';
8
8
  export const token = new Command('token')
9
- .description('Print the stored session JWT (for MCP Bearer auth)')
10
- .option('--json', 'Output as JSON ({token, exp, expiresAt}) instead of the raw JWT')
9
+ .description('Print the stored credential — a scoped API key or a session JWT (for MCP Bearer auth)')
10
+ .option('--json', 'Output as JSON ({token, exp, expiresAt, mode}) instead of the raw credential')
11
11
  .action((opts) => {
12
- // getToken() resolves TRAWL_TOKEN env first, then the stored config
13
- // token (see config.ts:47-51) — matching every other token consumer in
14
- // the CLI instead of reading the config store directly. (#86 finding 1)
12
+ // getToken() resolves TRAWL_API_KEY env first, then TRAWL_TOKEN env, then
13
+ // the stored config token (see config.ts) — matching every other token
14
+ // consumer in the CLI instead of reading the config store directly. (#86
15
+ // finding 1, #169)
15
16
  const stored = getToken();
16
17
  if (!stored) {
17
18
  // Auth-classified (ApiError 401 → exit 3, kind:"auth"), not a generic
@@ -20,6 +21,27 @@ export const token = new Command('token')
20
21
  process.exitCode = reportError(notLoggedInError(), { json: opts.json });
21
22
  return;
22
23
  }
24
+ // #169 review finding 2 — a scoped API key is not a JWT: it has no `exp`
25
+ // claim to decode (decodeExp() would return null for one anyway, since
26
+ // it isn't dot-segmented), and unlike a session JWT it does NOT expire
27
+ // on its own — it stays live until revoked in the dashboard. Branching
28
+ // on getAuthMode() explicitly (rather than silently relying on
29
+ // decodeExp()'s null-for-non-JWT behaviour) means the distinct advisory
30
+ // below is a deliberate case, not an accident of what decodeExp() happens
31
+ // to return — and it replaces the old "(could not decode expiry — verify
32
+ // the token manually)" fallback, which was written for a malformed JWT
33
+ // and, read against a key, wrongly implied something was wrong with it.
34
+ if (getAuthMode(stored) === 'apiKey') {
35
+ if (opts.json) {
36
+ json({ token: stored, exp: null, expiresAt: null, mode: 'apiKey' });
37
+ return;
38
+ }
39
+ // Print the raw key first (so it can be piped / copied) — same
40
+ // stdout-only-the-credential contract `$(trawl token)` relies on.
41
+ console.log(stored);
42
+ console.error(chalk.dim(' This is a scoped API key (trawl_*) — it does not expire on its own. Revoke it in the dashboard to end its access.'));
43
+ return;
44
+ }
23
45
  const exp = decodeExp(stored);
24
46
  const nowSeconds = Math.floor(Date.now() / 1000);
25
47
  if (exp !== null && exp < nowSeconds) {
@@ -30,7 +52,12 @@ export const token = new Command('token')
30
52
  return;
31
53
  }
32
54
  if (opts.json) {
33
- json({ token: stored, exp, expiresAt: exp !== null ? new Date(exp * 1000).toISOString() : null });
55
+ json({
56
+ token: stored,
57
+ exp,
58
+ expiresAt: exp !== null ? new Date(exp * 1000).toISOString() : null,
59
+ mode: 'jwt',
60
+ });
34
61
  return;
35
62
  }
36
63
  // Print the raw token first (so it can be piped / copied)
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { json } from '../lib/format.js';
6
+ import { retryFieldsFor } from '../lib/errors.js';
6
7
  import { PKG_NAME, currentVersion, fetchLatestVersion } from '../lib/version.js';
7
8
  const execFileP = promisify(execFile);
8
9
  export const upgrade = new Command('upgrade')
@@ -20,11 +21,16 @@ export const upgrade = new Command('upgrade')
20
21
  return;
21
22
  }
22
23
  if (opts.check) {
24
+ // #170 review F10 — set BEFORE the --json early return. Before this,
25
+ // `trawl upgrade --check` exited 1 but `trawl upgrade --check --json`
26
+ // (identical state) exited 0 — the exit code was set below the --json
27
+ // `return`, so a --json caller never saw it. README documents this gate
28
+ // as "exit 1 if so" with no --json carve-out; a CI step gating on the
29
+ // exit code alone silently never fired under --json.
30
+ process.exitCode = 1;
23
31
  if (opts.json)
24
32
  return json({ package: PKG_NAME, current, latest, upToDate: false, upgraded: false });
25
33
  console.log(`${chalk.yellow('↑')} Update available: ${chalk.bold(current)} → ${chalk.bold(latest)}. Run ${chalk.cyan('trawl upgrade')} to install.`);
26
- // Honest exit code so a script can gate on "is an update available".
27
- process.exitCode = 1;
28
34
  return;
29
35
  }
30
36
  // Install the latest globally. This shells out to the same npm the user
@@ -43,7 +49,25 @@ export const upgrade = new Command('upgrade')
43
49
  ? 'permission denied on the global npm prefix — retry with sudo, or use a Node version manager.'
44
50
  : (e.stderr?.trim() || e.message);
45
51
  if (opts.json) {
46
- console.log(JSON.stringify({ error: { message: `upgrade failed: ${hint}`, kind: 'upgrade_failed' } }));
52
+ // `upgrade_failed` is intentionally NOT in errors.ts's RETRY_POLICY
53
+ // (that map is the exhaustive set of kinds classifyError can
54
+ // produce — ERROR_KINDS derives from its keys) — retryFieldsFor is
55
+ // total, so an unmapped kind still gets the honest conservative
56
+ // default (`retryable:false`) instead of silently omitting the
57
+ // field. It IS registered in ENVELOPE_KINDS, the superset
58
+ // `spec --json`'s errorKinds actually publishes — see that
59
+ // constant's doc comment.
60
+ //
61
+ // #170 review F3 — typed as ErrorEnvelope so the compiler enforces
62
+ // `retryable` here too (this was one of the two emitters a
63
+ // mutation-test proved `tsc --noEmit` never actually guarded before
64
+ // this annotation).
65
+ const envelope = {
66
+ message: `upgrade failed: ${hint}`,
67
+ kind: 'upgrade_failed',
68
+ ...retryFieldsFor('upgrade_failed'),
69
+ };
70
+ console.log(JSON.stringify({ error: envelope }));
47
71
  }
48
72
  else {
49
73
  console.error(chalk.red(`✗ Upgrade failed: ${hint}`));
@@ -1,11 +1,21 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { api } from '../lib/api.js';
3
+ import { api, apiKeyUnsupportedError } from '../lib/api.js';
4
+ import { getAuthMode } from '../lib/config.js';
4
5
  import { json } from '../lib/format.js';
5
6
  export const whoami = new Command('whoami')
6
7
  .description("Show the authenticated user's identity")
7
8
  .option('--json', 'Output as JSON')
8
9
  .action(async (opts) => {
10
+ // #169 — JWT-only route (see WhoamiResponse's own doc comment above for
11
+ // why it stays that way). Under a scoped API key, a real request here
12
+ // would 401 and surface the generic "Session expired or invalid" text —
13
+ // wrong twice over: this route never accepts keys at all, and no
14
+ // session ever "expired". Detect it client-side, before any HTTP call,
15
+ // and say so plainly instead.
16
+ if (getAuthMode() === 'apiKey') {
17
+ throw apiKeyUnsupportedError('trawl whoami');
18
+ }
9
19
  const data = await api.get('/api/users/me');
10
20
  if (opts.json) {
11
21
  json(data);
package/dist/index.d.ts CHANGED
@@ -64,6 +64,58 @@ export declare function isHelpOrVersion(argv: string[]): boolean;
64
64
  * case so it never shows up ahead of e.g. `trawl scraps --help`.
65
65
  */
66
66
  export declare function isBareInvocation(argv: string[]): boolean;
67
+ /**
68
+ * True for `trawl spec` (with or without `--json`) — deliberately NOT folded
69
+ * into `isHelpOrVersion` above: that predicate's name and doc comment are
70
+ * about help/version queries specifically, and `spec` is a real, data-
71
+ * bearing command (see `isHelpOrVersion`'s own #170 test case), not a help
72
+ * query. This is its own narrow predicate for a DIFFERENT reason: `spec
73
+ * --json` is documented (docs/agent-quickstart.md) as the first call an AI
74
+ * agent makes against this CLI, so it must not mutate anything the caller
75
+ * did not ask it to — same requirement `--help`/`--version` already got
76
+ * from `isHelpOrVersion` (#73), extended here to cover `spec` too.
77
+ *
78
+ * Scope, stated honestly rather than aspirationally: this guard suppresses
79
+ * the skills auto-sync (an `rmSync(recursive)` + `cpSync` over the user's
80
+ * `~/.claude/skills`) and the update notifier (a config write plus a
81
+ * DETACHED CHILD that queries the npm registry — fatal in an
82
+ * egress-restricted sandbox). It does NOT suppress `initPostHog()`, which
83
+ * still creates the `conf` config file and mints a persistent
84
+ * `telemetryUserId` on first run, exactly as it does for every other
85
+ * command. That one is deliberate: `spec` is the signal that tells us
86
+ * whether agents are actually adopting the CLI, so it stays measured, and
87
+ * the caller's own controls (`TRAWL_TELEMETRY=0`, `DO_NOT_TRACK=1`) are the
88
+ * opt-out. That same first run also has `initPostHog()` write a one-time
89
+ * telemetry disclosure line (`ℹ Trawl CLI collects anonymous usage
90
+ * telemetry…`) to STDERR, ahead of the JSON payload — this guard does not
91
+ * suppress that either. `stdout` stays pure JSON either way, so this only
92
+ * bites a caller that merges the two streams (e.g. `2>&1`); whether a `spec`
93
+ * probe specifically should suppress the notice is a product call,
94
+ * deliberately not taken here. Do not upgrade this paragraph back to "no
95
+ * filesystem side effect" without also gating telemetry — a comment that
96
+ * overstates its own invariant is worse than no comment, because the next
97
+ * reader trusts it.
98
+ *
99
+ * Before this, `trawl spec --json` silently re-synced
100
+ * `~/.claude/skills` (and `./.claude/skills`) and printed `trawl: re-synced
101
+ * skill …` lines to stderr BEFORE the JSON payload — an agent's very first
102
+ * probe of the CLI mutated the user's filesystem and polluted the channel
103
+ * it was about to parse.
104
+ *
105
+ * Matches `spec` as the FIRST positional token, skipping any leading
106
+ * `-`-prefixed tokens first (a global flag ahead of the subcommand, e.g.
107
+ * `trawl --debug spec --json`) — the same argv-scan discipline `hasJsonFlag`
108
+ * below already applies. #170 review F4 — before this, a bare
109
+ * `argv.slice(2)[0] === 'spec'` check was defeated by ANY leading flag:
110
+ * `trawl --debug spec --json` read `--debug` as the first positional, missed
111
+ * the match entirely, and fell through to the normal startup path — running
112
+ * the filesystem-mutating skills auto-sync during what's documented
113
+ * (docs/agent-quickstart.md) as an agent's read-only first probe of the CLI.
114
+ * Still never a bare substring search: e.g. `trawl scraps create --title
115
+ * spec` (the literal string "spec" as a FLAG VALUE, not a leading flag) never
116
+ * matches, since `scraps` — the first non-`-`-prefixed token — isn't `spec`.
117
+ */
118
+ export declare function isSpecQuery(argv: string[]): boolean;
67
119
  /** Best-effort scan for a `--json` flag in raw argv, used only when parsing
68
120
  * itself failed before any command's own `.opts()` could be resolved (a
69
121
  * commander usage error — unknown option/command, missing required arg). Same