@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.
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;
@@ -1,12 +1,12 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { spin } from '../lib/spinner.js';
3
+ import { spin, confirmNonTTY } from '../lib/spinner.js';
4
4
  import { api, LONG_RUN_TIMEOUT_MS } from '../lib/api.js';
5
5
  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('|')})`)
@@ -786,6 +796,24 @@ export function attachRunCommand(parent, attachOpts = {}) {
786
796
  : await spin(call, { text: 'Launching scrap…', successText: 'Scrap launched' });
787
797
  if (opts.json)
788
798
  json(data);
799
+ else if (!opts.watch) {
800
+ /* #166 — see confirmNonTTY. Two deliberate choices here:
801
+ *
802
+ * "run complete", not "launched": `run` is the SYNCHRONOUS verb —
803
+ * GET /api/scraps/load/:id executes the scrap server-side (30-250s)
804
+ * and returns the finished result, so by the time this prints the run
805
+ * is over. The spinner's successText says "Scrap launched", but that
806
+ * is stderr chrome for a human watching it happen; THIS line is the
807
+ * machine surface, where a script reading "launched" would poll for a
808
+ * completion that already happened. `trigger` already draws the same
809
+ * distinction ("Worker triggered" async vs "Worker run complete" under
810
+ * --wait) — this matches that vocabulary.
811
+ *
812
+ * `--watch` is excluded because pollRunProgress below does its own
813
+ * reporting; without that guard a watched run would print this line
814
+ * and then narrate the same run. */
815
+ confirmNonTTY(`Scrap ${id} run complete`);
816
+ }
789
817
  if (opts.watch) {
790
818
  // #107 — under --json, pollRunProgress suppresses its own intermediate
791
819
  // console.log calls and instead emits exactly ONE final NDJSON outcome
@@ -838,10 +866,27 @@ function renderScrapItems(items, asJson) {
838
866
  * all "usage" (2) — never-run / aged-out-of-retention are not_found (4), a
839
867
  * failed last run is a business-logic failure (1).
840
868
  */
841
- 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) {
842
881
  console.error(chalk.red(`✗ ${message}`));
843
882
  if (wantsJson) {
844
- 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 }));
845
890
  }
846
891
  process.exitCode = exitCode;
847
892
  }
@@ -957,7 +1002,10 @@ export function attachDataCommand(parent, attachOpts = {}) {
957
1002
  // run already holds the server-side distributed lock, so --fresh would
958
1003
  // just 429 against it.
959
1004
  if (last.status === null) {
960
- 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}`] });
961
1009
  return;
962
1010
  }
963
1011
  // #86 review — node persists status=false for a GENUINE zero-item run
@@ -1118,6 +1166,7 @@ scraps
1118
1166
  return;
1119
1167
  }
1120
1168
  await spin(call, { text: 'Deleting…', successText: 'Scrap deleted' });
1169
+ confirmNonTTY(`Scrap ${id} deleted`); // #166
1121
1170
  });
1122
1171
  // banner
1123
1172
  scraps
@@ -1163,6 +1212,8 @@ scraps
1163
1212
  text: 'Uploading banner…',
1164
1213
  successText: `Banner uploaded for scrap ${chalk.bold(id)}`,
1165
1214
  });
1215
+ // #166 — plain, unstyled: chalk.bold would emit escape codes into a pipe.
1216
+ confirmNonTTY(`Banner uploaded for scrap ${id}`);
1166
1217
  });
1167
1218
  // watch (stream activities)
1168
1219
  scraps
@@ -1209,24 +1260,13 @@ export function attachTriggerCommand(parent, attachOpts = {}) {
1209
1260
  if (opts.json) {
1210
1261
  json(data);
1211
1262
  }
1212
- else if (!process.stdout.isTTY) {
1213
- // #160 spin() (lib/spinner.ts, #119) intentionally emits NOTHING
1214
- // when stderr isn't a TTY, so a piped/redirected `trigger` wrote zero
1215
- // bytes to both stdout AND stderr while exiting 0: a caller driving
1216
- // this from a script has no way to tell success from a no-op. Gated
1217
- // on STDOUT specifically that's the stream a script actually reads
1218
- // (`$(trawl trigger …)`, `> out.txt`, `| jq`), independent of
1219
- // whatever spin() decides about stderr — so an interactive TTY
1220
- // session, where the ora spinner already confirmed this on stderr,
1221
- // never sees a duplicate line here. Keeps the exact "Worker
1222
- // triggered"/"Worker run complete" substrings the trawl-internal QA
1223
- // runbook asserts on, plus the scrap id so a script has something to
1224
- // parse. Known residual: stdout attached to a real terminal while
1225
- // stderr is separately redirected (rare) still prints nothing on
1226
- // either stream, same as pre-#160 — not the reported/common shape
1227
- // (full redirection or stdout-only capture), left alone to keep this
1228
- // fix narrowly scoped to the stream it actually touches.
1229
- console.log(opts.wait ? `Worker run complete for scrap ${id}` : `Worker triggered for scrap ${id}`);
1263
+ else {
1264
+ /* #160, folded onto the shared helper by #166 the rationale and the
1265
+ * known residual now live once, on `confirmNonTTY` itself, instead of
1266
+ * in the one call site that happened to be fixed first. Wording is
1267
+ * unchanged on purpose: the trawl-internal QA runbook asserts on the
1268
+ * exact "Worker triggered" / "Worker run complete" substrings. */
1269
+ confirmNonTTY(opts.wait ? `Worker run complete for scrap ${id}` : `Worker triggered for scrap ${id}`);
1230
1270
  }
1231
1271
  // #107 — see the matching comment on `run`'s --watch call above (review
1232
1272
  // F1): honest final NDJSON line + exit code under --json, human mode
@@ -1335,6 +1375,7 @@ account
1335
1375
  text: 'Deleting credentials…',
1336
1376
  successText: 'Account credentials deleted',
1337
1377
  });
1378
+ confirmNonTTY(`Account credentials deleted for scrap ${id}`); // #166
1338
1379
  });
1339
1380
  // account clear-session
1340
1381
  account
@@ -1353,6 +1394,7 @@ account
1353
1394
  text: 'Clearing session…',
1354
1395
  successText: 'Session cleared',
1355
1396
  });
1397
+ confirmNonTTY(`Session cleared for scrap ${id}`); // #166
1356
1398
  });
1357
1399
  // account session subcommand group
1358
1400
  const accountSession = account
@@ -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);