@trawlme/cli 1.18.2 → 1.18.3

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
@@ -58,12 +58,12 @@ trawl scraps banner <id> -f <file>
58
58
  trawl scraps delete <id> [--force] Alias: rm
59
59
  ```
60
60
 
61
- - `--tier` forces a proxy tier; `--force-tier` raises the proxy-tier ceiling past the auto-cap (history-gated: may be refused or cost more). `create --json`/`update --json` print the full scrap object (including the `_tierOverride` outcome) on stdout; a refused tier override exits 1 with a standard `--json` error envelope.
62
- - `scraps doctor` diagnoses the last run (error, failed selector, block status, page state, autofix outcome); `--autofix` includes the full autofix diff/dry-run/knowledge.
63
- - `scraps autofix` shows the last auto-fix attempt on its own (decision, diff, dry-run, knowledge).
61
+ - `--tier` forces a proxy tier; `--force-tier` raises the proxy-tier ceiling past the auto-cap (history-gated: may be refused or cost more). `create --json`/`update --json` print the full scrap object (including the `_tierOverride` outcome) on stdout; a refused tier override exits 1 with a standard `--json` error envelope. When a tier was requested but the server's response carries no `_tierOverride` at all (an older server that can't confirm what actually got applied), a stderr warning is printed either way, and under `--json` the emitted object also carries `"_tierUnconfirmed": true` — the machine-readable counterpart to that warning, since a `--json` caller has no reliable reason to read stderr.
62
+ - `scraps doctor` diagnoses the last run (error, failed selector, block status, page state, autofix outcome); `--autofix` includes the full autofix diff/dry-run/knowledge. A run that is still in flight (`status: null`, server-side) shows a `running` badge, never `failed`.
63
+ - `scraps autofix` shows the last auto-fix attempt on its own (decision, diff, dry-run, knowledge). `--json` on a scrap that has **never run** returns `{"status":"no_runs"}` (exit 0) — distinct from `null`, which means a run exists but had no auto-fix attempt.
64
64
  - `scraps snapshot --error` fetches the error-path snapshot instead of the normal one; `-o <file>` writes to a file instead of stdout.
65
65
  - `scraps history` lists past runs (newest first); `scraps run-info <hid>` shows details of a single run from that history.
66
- - `scraps data` returns the last persisted run payload (no execute quota); `--fresh` runs the scrap live instead (consumes execute quota); `--errors` shows the last run's error detail. `[]` on stdout means a genuine zero-item successful run — a scrap that has never run, whose last run failed, or whose payload aged out of retention returns a `--json` error envelope (exit 4/1/4 respectively) instead.
66
+ - `scraps data` returns the last persisted run payload (no execute quota); `--fresh` runs the scrap live instead (consumes execute quota); `--errors` shows the last run's error detail (`--json` on a never-run scrap returns `{"status":"no_runs"}`, exit 0, matching `doctor --json`). `[]` on stdout means a genuine zero-item successful run — a scrap that has never run, whose last run failed, or whose payload aged out of retention returns a `--json` error envelope (exit 4/1/4 respectively) instead. Two more honest states: a run still **in flight** (`status: null` server-side) returns a `kind:"in_progress"` error envelope (exit 1, "retry shortly" — never suggests `--fresh`, which would just 429 against the run already holding the lock); a run whose item count **regressed** vs baseline (`statusDetail: "regression"`) still returns the real, non-empty items on stdout (exit 0) plus a stderr warning pointing at `scraps doctor <id>` — the data itself is genuine even though the run is flagged.
67
67
  - `scraps get` (and anything reading through it, like `scraps data`'s default path) embeds only the newest 100 history rows on the returned scrap object — `scraps run-info` and `scraps doctor` fetch a single run directly and are unaffected by that cap.
68
68
 
69
69
  ### Scrap accounts
@@ -89,7 +89,7 @@ trawl skills uninstall [<skill>] [--local] Remove
89
89
  trawl skills update [<skill>] [--local] [--force] Reinstall (force sync with CLI version)
90
90
  ```
91
91
 
92
- Skills auto-update when you upgrade the CLI — no need to re-install manually — but it is not silent: it prints an honest `trawl: re-synced skill "<name>" (<scope>) <old> → <new>` line to stderr whenever it rewrites a skill dir, so a rewrite is never invisible. Opt out with `TRAWL_SKILLS_SYNC=0`.
92
+ Skills auto-update when you upgrade the CLI — no need to re-install manually — but it is not silent: it prints an honest `trawl: re-synced skill "<name>" (<scope>) <old> → <new>` line to stderr whenever it rewrites a skill dir, so a rewrite is never invisible. If a bundled skill is renamed or dropped between CLI versions, the old install is swept too — a `trawl: removed orphaned skill "<name>" (<scope>) — no longer bundled with this CLI version` line to stderr, so a stale skill teaching outdated CLI usage never lingers silently. Only marker-owned dirs (installed by trawl itself) are ever touched by either line. Opt out with `TRAWL_SKILLS_SYNC=0`.
93
93
 
94
94
  A pre-existing skill directory that trawl did not install itself (no `.version` marker) is never touched — `install`/`update` refuse to overwrite it and require `--force` to proceed. This applies to the CLI-upgrade auto-sync too (it silently skips marker-less dirs rather than refusing, since there is no interactive user to show a refusal to).
95
95
 
@@ -143,7 +143,7 @@ Every command exits with one of these codes — scripts and agents driving the C
143
143
  | Code | Meaning |
144
144
  |------|--------------------------------------------------------------------------|
145
145
  | `0` | Success |
146
- | `1` | Unknown/generic error (an unmapped failure — API errors other than 401/404, an unhandled bug) |
146
+ | `1` | Unknown/generic error (an unmapped failure — API errors other than 401/404, an unhandled bug — or a business-logic refusal like `scraps data`'s `run_failed`/`in_progress` states) |
147
147
  | `2` | Usage error (bad flag/value, invalid ID, missing required argument, unknown option/command) |
148
148
  | `3` | Auth error (not logged in, or the session token is expired/invalid — run `trawl login`) |
149
149
  | `4` | Not found (no such resource, or — for `scraps data` — no persisted payload to read) |
@@ -83,13 +83,17 @@ export function pickFix(fix) {
83
83
  */
84
84
  export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
85
85
  const lines = [];
86
- // Header + status badge
86
+ // Header + status badge. status:null means a run is IN FLIGHT (node
87
+ // persists {status:null, statusDetail:null, inFlight:true} the moment a
88
+ // run starts) — that must never render as "failed". (#88 item 1)
87
89
  const ok = run.status === true;
88
90
  const badge = ok
89
91
  ? chalk.green('● success')
90
- : run.statusDetail === 'empty'
91
- ? chalk.yellow('● empty')
92
- : chalk.red('● failed');
92
+ : run.status === null
93
+ ? chalk.cyan('● running')
94
+ : run.statusDetail === 'empty'
95
+ ? chalk.yellow('● empty')
96
+ : chalk.red('● failed');
93
97
  lines.push(`${chalk.bold(scrapTitle)} ${badge}${run.statusDetail ? ` (${run.statusDetail})` : ''}`);
94
98
  lines.push(chalk.dim(` Run ID: ${run._id}`));
95
99
  // Error message — an honest accept-wall string for known-walled scraps
@@ -20,7 +20,15 @@ function usageError(message, opts = {}) {
20
20
  }
21
21
  function lastStatus(scrap) {
22
22
  const last = scrap.history?.[0];
23
- if (!last || last.status === null || last.status === undefined)
23
+ if (!last)
24
+ return 'never';
25
+ // status:null means a run is IN FLIGHT (node persists {status:null,
26
+ // statusDetail:null, inFlight:true} the moment a run starts) — that is NOT
27
+ // the same thing as "this scrap has never run" (no history row at all).
28
+ // (#88 item 1)
29
+ if (last.status === null)
30
+ return 'running';
31
+ if (last.status === undefined)
24
32
  return 'never';
25
33
  return last.status === true ? 'success' : 'failure';
26
34
  }
@@ -41,6 +49,8 @@ function statusIcon(status) {
41
49
  return chalk.green('✓');
42
50
  if (status === 'failure')
43
51
  return chalk.red('✗');
52
+ if (status === 'running')
53
+ return chalk.cyan('↻');
44
54
  return chalk.dim('—');
45
55
  }
46
56
  export const scraps = new Command('scraps').description('Manage scraps');
@@ -67,33 +77,55 @@ scraps
67
77
  .alias('ls')
68
78
  .description('List all scraps')
69
79
  .option('--json', 'Output as JSON')
70
- .option('--status <status>', 'Filter by last run status (success|failure|never)')
71
- .option('--limit <n>', 'Show only the first N results', (v) => parseInt(v, 10))
72
- .option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)', (v) => parseInt(v, 10))
80
+ .option('--status <status>', 'Filter by last run status (success|failure|never|running)')
81
+ // #88 item 8 no custom parser here (unlike the old `(v) => parseInt(v,
82
+ // 10)`): a bad value like "abc" used to silently become NaN, which then
83
+ // sailed straight through `Number.isInteger`-less checks and into
84
+ // `.slice(0, NaN)` (silently truncates to 0 rows) or `?page=NaN` (silently
85
+ // sent to the server) — never a usage error. Keeping the raw string here
86
+ // lets the validation below mirror `history`'s own --limit check exactly
87
+ // (~line 660) and report the actual bad input in the error message.
88
+ .option('--limit <n>', 'Show only the first N results')
89
+ .option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)')
73
90
  .action(async (opts, cmd) => {
74
91
  // Guard: --limit and --page are mutually exclusive
75
92
  if (opts.limit !== undefined && opts.page !== undefined) {
76
93
  usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
77
94
  return;
78
95
  }
96
+ let limit;
97
+ if (opts.limit !== undefined) {
98
+ limit = Number(opts.limit);
99
+ if (!Number.isInteger(limit) || limit <= 0) {
100
+ usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
101
+ return;
102
+ }
103
+ }
104
+ let page;
105
+ if (opts.page !== undefined) {
106
+ page = Number(opts.page);
107
+ if (!Number.isInteger(page) || page <= 0) {
108
+ usageError(`Invalid --page "${opts.page}" (expected a positive integer)`, { json: opts.json });
109
+ return;
110
+ }
111
+ }
79
112
  let data;
80
113
  try {
81
114
  data = await oraPromise(async () => {
82
- if (opts.page !== undefined) {
115
+ if (page !== undefined) {
83
116
  // Single-page mode: explicit page requested, no loop
84
- const pageNum = opts.page;
85
- return api.get(`/api/scraps?perPage=50&page=${pageNum}`);
117
+ return api.get(`/api/scraps?perPage=50&page=${page}`);
86
118
  }
87
119
  // Fetch-all mode: paginate until a page returns < 200 items
88
120
  const perPage = 200;
89
121
  let result = [];
90
- let page = 1;
122
+ let pageNum = 1;
91
123
  while (true) {
92
- const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${page}`);
124
+ const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${pageNum}`);
93
125
  result = result.concat(batch);
94
126
  if (batch.length < perPage)
95
127
  break;
96
- page++;
128
+ pageNum++;
97
129
  }
98
130
  return result;
99
131
  }, 'Fetching scraps…');
@@ -126,7 +158,6 @@ scraps
126
158
  if (opts.status)
127
159
  data = data.filter((s) => lastStatus(s) === opts.status);
128
160
  const totalMatched = data.length;
129
- const limit = opts.limit;
130
161
  const rows = limit !== undefined ? data.slice(0, limit) : data;
131
162
  if (opts.json)
132
163
  return json(rows);
@@ -206,6 +237,23 @@ function warnIfUnconfirmedTier(data, tierWasRequested, id) {
206
237
  return;
207
238
  console.error(chalk.yellow(` ⚠ Server did not confirm the tier change (older server) — verify with: trawl scraps get ${id}`));
208
239
  }
240
+ /**
241
+ * #88 item 3 — the --json machine-readable counterpart to
242
+ * warnIfUnconfirmedTier's stderr warning above. A --json caller (an agent
243
+ * scripting this CLI) has no reliable reason to read stderr — that channel
244
+ * is advisory-only everywhere else in this CLI, and stdout must stay the
245
+ * sole payload. Without this, the ONLY signal that the server never
246
+ * confirmed the tier change was a string on stderr, invisible to any --json
247
+ * consumer parsing stdout alone. Adds `_tierUnconfirmed: true` to the
248
+ * emitted object under EXACTLY the same condition warnIfUnconfirmedTier
249
+ * warns on (tier requested, response carries no `_tierOverride`) — never
250
+ * fabricated, never present otherwise.
251
+ */
252
+ function withTierUnconfirmed(data, tierWasRequested) {
253
+ if (data._tierOverride || !tierWasRequested)
254
+ return data;
255
+ return { ...data, _tierUnconfirmed: true };
256
+ }
209
257
  /** #86 finding 5 — the standard error envelope for a refused tier override,
210
258
  * routed through the same reportError() central formatting path used
211
259
  * everywhere else (exit 1: a business-logic refusal, not a usage error). */
@@ -247,7 +295,7 @@ scraps
247
295
  process.exitCode = reportTierRefusal(data, true);
248
296
  return;
249
297
  }
250
- json(data);
298
+ json(withTierUnconfirmed(data, tierWasRequested));
251
299
  return;
252
300
  }
253
301
  console.log(chalk.dim(` Title: ${data.title}`));
@@ -360,7 +408,7 @@ scraps
360
408
  process.exitCode = reportTierRefusal(data, true);
361
409
  return;
362
410
  }
363
- json(data);
411
+ json(withTierUnconfirmed(data, tierWasRequested));
364
412
  return;
365
413
  }
366
414
  const shown = data;
@@ -440,8 +488,12 @@ scraps
440
488
  if (opts.errors) {
441
489
  const result = await fetchRunAndFix(id);
442
490
  if (!result) {
491
+ // #88 item 7 — unified no-runs shape with `doctor --json`: a bare
492
+ // `null` was indistinguishable from any other absent-payload state
493
+ // (a scrap CAN legitimately have a null-ish result elsewhere); an
494
+ // explicit `{status:"no_runs"}` object is unambiguous everywhere.
443
495
  if (opts.json) {
444
- json(null);
496
+ json({ status: 'no_runs' });
445
497
  return;
446
498
  }
447
499
  console.log(chalk.dim('No runs yet.'));
@@ -502,16 +554,33 @@ scraps
502
554
  reportDataState(`Scrap ${id} has never run. Run it first (trawl scraps run ${id}) or pass --fresh to launch one now.`, 4, 'not_found', opts.json);
503
555
  return;
504
556
  }
557
+ // #88 item 1 — status:null is an IN-FLIGHT run (node persists
558
+ // {status:null, statusDetail:null, inFlight:true} the moment a run
559
+ // starts, and only flips status/statusDetail once it finishes). That is
560
+ // neither "never run" nor "the last run failed" — a caller reading data
561
+ // mid-run needs an honest "wait" signal. Never suggest --fresh here: a
562
+ // run already holds the server-side distributed lock, so --fresh would
563
+ // just 429 against it.
564
+ if (last.status === null) {
565
+ reportDataState(`Run in progress for ${id} — retry shortly.`, 1, 'in_progress', opts.json);
566
+ return;
567
+ }
505
568
  // #86 review — node persists status=false for a GENUINE zero-item run
506
569
  // too (historys schema: status boolean|null + statusDetail
507
570
  // success/error/empty/regression; a zero-item run is status=false +
508
571
  // statusDetail='empty', and the embedded history rows from GET
509
572
  // /api/scraps/:id include statusDetail via the repository populate
510
573
  // select). An 'empty' run is the one case [] is FOR — only a real
511
- // failure (error/regression/unknown detail) gets the run_failed
512
- // envelope.
574
+ // failure (error/unknown detail) gets the run_failed envelope.
575
+ //
576
+ // #88 item 2 — statusDetail='regression' is ALSO status=false (an async
577
+ // patch flips it after item count dropped vs baseline), but the row's
578
+ // `data` still holds REAL, non-empty items — the write that persisted
579
+ // them succeeded before the regression was even detected. Treating it as
580
+ // run_failed would hide genuine data behind a false negative.
513
581
  const isEmptyRun = last.status === false && last.statusDetail === 'empty';
514
- if (last.status === false && !isEmptyRun) {
582
+ const isRegression = last.status === false && last.statusDetail === 'regression';
583
+ if (last.status === false && !isEmptyRun && !isRegression) {
515
584
  reportDataState(`Last run failed — see: trawl scraps data ${id} --errors`, 1, 'run_failed', opts.json);
516
585
  return;
517
586
  }
@@ -533,9 +602,20 @@ scraps
533
602
  renderScrapItems([], opts.json);
534
603
  return;
535
604
  }
536
- reportDataState(`No persisted data for the last successful run of ${id} — it aged out of retention. Pass --fresh to launch a new run.`, 4, 'not_found', opts.json);
605
+ // #88 item 2 a regression row whose payload aged out of retention has
606
+ // nothing left to show either; fall through to the SAME honest
607
+ // aged-out envelope a normal successful row would get (never fabricate
608
+ // items, never silently succeed).
609
+ reportDataState(`No persisted data for the last run of ${id} — it aged out of retention. Pass --fresh to launch a new run.`, 4, 'not_found', opts.json);
537
610
  return;
538
611
  }
612
+ // #88 item 2 — a regression row's items are REAL (the write succeeded
613
+ // before the async patch flagged the drop) — return them on stdout
614
+ // (exit 0, both modes) with an honest stderr warning pointing at the
615
+ // diagnostic command, instead of hiding genuine data behind run_failed.
616
+ if (isRegression) {
617
+ console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
618
+ }
539
619
  renderScrapItems(items, opts.json);
540
620
  });
541
621
  // history — list past runs for a scrap
@@ -881,14 +961,19 @@ scraps
881
961
  validateObjectId(id);
882
962
  const result = await fetchRunAndFix(id);
883
963
  if (!result) {
964
+ // #88 item 7 — unified no-runs shape with `doctor --json` / `data
965
+ // --errors --json`: a never-run scrap is a distinct, nameable state,
966
+ // not the same bare `null` a run-with-no-fix-attempt returns below.
884
967
  if (opts.json) {
885
- json(null);
968
+ json({ status: 'no_runs' });
886
969
  return;
887
970
  }
888
971
  console.log(chalk.dim('No runs yet.'));
889
972
  return;
890
973
  }
891
974
  if (!result.fix) {
975
+ // A run DID happen, it just had no autofix attempt — genuinely "no
976
+ // data", unlike the never-run case above.
892
977
  if (opts.json) {
893
978
  json(null);
894
979
  return;
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { getToken } from '../lib/config.js';
4
- import { ApiError, notLoggedInError } from '../lib/api.js';
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
  export const token = new Command('token')
@@ -21,7 +21,10 @@ export const token = new Command('token')
21
21
  const exp = decodeExp(stored);
22
22
  const nowSeconds = Math.floor(Date.now() / 1000);
23
23
  if (exp !== null && exp < nowSeconds) {
24
- process.exitCode = reportError(new ApiError(401, 'Session token expired. Run: trawl login to refresh.'));
24
+ // Decoded entirely client-side (no HTTP call made) AuthError, not a
25
+ // fabricated ApiError(401): the server never actually said this. (#88
26
+ // item 4)
27
+ process.exitCode = reportError(new AuthError('Session token expired. Run: trawl login to refresh.'));
25
28
  return;
26
29
  }
27
30
  // Print the raw token first (so it can be piped / copied)
package/dist/index.d.ts CHANGED
@@ -6,6 +6,15 @@ import { Command } from 'commander';
6
6
  * --url) don't start with '-' and would otherwise survive an argv filter and
7
7
  * leak to PostHog (#67). Falls back to 'unknown' when no command resolved
8
8
  * (e.g. an error thrown before any action ran).
9
+ *
10
+ * #88 item 9 — walks the FULL parent chain, not just the immediate parent:
11
+ * the old one-level join (`${parent.name()} ${own.name()}`) resolved a
12
+ * 4-deep command like `scraps account session set` down to just "session
13
+ * set", silently dropping "scraps account". The root program node (the
14
+ * 'trawl' Command itself, which has no `.parent`) is excluded from the
15
+ * chain — matching the pre-existing convention that a direct child of the
16
+ * root (e.g. `scraps list`, `telemetry on`) is named relative to its
17
+ * immediate group, never prefixed with the program name.
9
18
  */
10
19
  export declare function resolveCommandName(actionCommand: Command | undefined): string;
11
20
  /**
@@ -33,6 +42,14 @@ export declare function isHelpOrVersion(argv: string[]): boolean;
33
42
  * "argv scan, never trust flag values" caveat as isHelpOrVersion: positional
34
43
  * values are never mistaken for `--json` since they don't equal the literal
35
44
  * string. (#86 finding 3)
45
+ *
46
+ * #88 item 5 — only scans tokens BEFORE the first bare `--`. Commander treats
47
+ * `--` as "end of options": everything after it is a positional operand, not
48
+ * a flag, even if the literal text is `--json`. `scraps list -- --json`
49
+ * passes `--json` as an (excess) positional argument, not the flag — an
50
+ * unscoped `argv.includes('--json')` would still match it and wrongly emit a
51
+ * JSON envelope for what is actually a plain usage error with no --json
52
+ * requested at all.
36
53
  */
37
54
  export declare function hasJsonFlag(argv: string[]): boolean;
38
55
  /**
@@ -50,4 +67,22 @@ export declare function hasJsonFlag(argv: string[]): boolean;
50
67
  * walks the whole tree and installs it everywhere. (#86 finding 3)
51
68
  */
52
69
  export declare function applyExitOverride(cmd: Command): void;
70
+ /**
71
+ * #88 item 6 — best-effort command-name recovery for a commander parse error
72
+ * that happened BEFORE any action ran, so `currentCommand` (the preAction
73
+ * hook's resolved command) is still undefined — e.g. an unknown option on an
74
+ * otherwise-valid subcommand, or excess arguments. Without this, EVERY such
75
+ * failure previously collapsed to resolveCommandName(undefined) === 'unknown'
76
+ * — and since 'unknown' was never itself allowlisted at the
77
+ * registerAllowedCommands call site (see runCli below), that capture was
78
+ * silently dropped by captureCommand's allowlist check: a dead branch that
79
+ * looked like it reported telemetry but never actually did.
80
+ *
81
+ * Matches ONLY a name already present in `allowedNames` (the real command
82
+ * tree, from collectCommandNames) — never invents one from raw argv text.
83
+ * Checks the two-token form first (`scraps boom`) since most usage errors
84
+ * happen on a nested leaf command; falls back to the single top-level token,
85
+ * then to 'unknown' (now itself allowlisted, so that capture fires too).
86
+ */
87
+ export declare function bestEffortCommandName(argv: string[], allowedNames: readonly string[]): string;
53
88
  export declare function runCli(argv?: string[]): Promise<void>;
package/dist/index.js CHANGED
@@ -19,13 +19,29 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8
19
19
  * --url) don't start with '-' and would otherwise survive an argv filter and
20
20
  * leak to PostHog (#67). Falls back to 'unknown' when no command resolved
21
21
  * (e.g. an error thrown before any action ran).
22
+ *
23
+ * #88 item 9 — walks the FULL parent chain, not just the immediate parent:
24
+ * the old one-level join (`${parent.name()} ${own.name()}`) resolved a
25
+ * 4-deep command like `scraps account session set` down to just "session
26
+ * set", silently dropping "scraps account". The root program node (the
27
+ * 'trawl' Command itself, which has no `.parent`) is excluded from the
28
+ * chain — matching the pre-existing convention that a direct child of the
29
+ * root (e.g. `scraps list`, `telemetry on`) is named relative to its
30
+ * immediate group, never prefixed with the program name.
22
31
  */
23
32
  export function resolveCommandName(actionCommand) {
24
33
  if (!actionCommand)
25
34
  return 'unknown';
26
- return actionCommand.parent
27
- ? `${actionCommand.parent.name()} ${actionCommand.name()}`
28
- : actionCommand.name();
35
+ const chain = [];
36
+ let current = actionCommand;
37
+ while (current && current.parent) {
38
+ chain.unshift(current.name());
39
+ current = current.parent;
40
+ }
41
+ // `current` is now the root (no parent) — its name is excluded by design.
42
+ // If the chain came up empty, actionCommand itself IS the root (no parent
43
+ // at all) — fall back to its own name so the function never returns ''.
44
+ return chain.length > 0 ? chain.join(' ') : actionCommand.name();
29
45
  }
30
46
  /**
31
47
  * Walk the full command tree and produce every valid resolveCommandName()
@@ -86,9 +102,19 @@ export function isHelpOrVersion(argv) {
86
102
  * "argv scan, never trust flag values" caveat as isHelpOrVersion: positional
87
103
  * values are never mistaken for `--json` since they don't equal the literal
88
104
  * string. (#86 finding 3)
105
+ *
106
+ * #88 item 5 — only scans tokens BEFORE the first bare `--`. Commander treats
107
+ * `--` as "end of options": everything after it is a positional operand, not
108
+ * a flag, even if the literal text is `--json`. `scraps list -- --json`
109
+ * passes `--json` as an (excess) positional argument, not the flag — an
110
+ * unscoped `argv.includes('--json')` would still match it and wrongly emit a
111
+ * JSON envelope for what is actually a plain usage error with no --json
112
+ * requested at all.
89
113
  */
90
114
  export function hasJsonFlag(argv) {
91
- return argv.includes('--json');
115
+ const dashDashIdx = argv.indexOf('--');
116
+ const scanned = dashDashIdx === -1 ? argv : argv.slice(0, dashDashIdx);
117
+ return scanned.includes('--json');
92
118
  }
93
119
  /**
94
120
  * Commander's default (no exitOverride) calls `process.exit()` directly for
@@ -113,6 +139,35 @@ export function applyExitOverride(cmd) {
113
139
  * these already printed their own output (to stdout) via commander itself;
114
140
  * runCli's catch must treat them as a clean exit, not an error. (#86 finding 3) */
115
141
  const HELP_OR_VERSION_CODES = new Set(['commander.helpDisplayed', 'commander.help', 'commander.version']);
142
+ /**
143
+ * #88 item 6 — best-effort command-name recovery for a commander parse error
144
+ * that happened BEFORE any action ran, so `currentCommand` (the preAction
145
+ * hook's resolved command) is still undefined — e.g. an unknown option on an
146
+ * otherwise-valid subcommand, or excess arguments. Without this, EVERY such
147
+ * failure previously collapsed to resolveCommandName(undefined) === 'unknown'
148
+ * — and since 'unknown' was never itself allowlisted at the
149
+ * registerAllowedCommands call site (see runCli below), that capture was
150
+ * silently dropped by captureCommand's allowlist check: a dead branch that
151
+ * looked like it reported telemetry but never actually did.
152
+ *
153
+ * Matches ONLY a name already present in `allowedNames` (the real command
154
+ * tree, from collectCommandNames) — never invents one from raw argv text.
155
+ * Checks the two-token form first (`scraps boom`) since most usage errors
156
+ * happen on a nested leaf command; falls back to the single top-level token,
157
+ * then to 'unknown' (now itself allowlisted, so that capture fires too).
158
+ */
159
+ export function bestEffortCommandName(argv, allowedNames) {
160
+ const a2 = argv[2];
161
+ const a3 = argv[3];
162
+ if (a2 && a3) {
163
+ const combined = `${a2} ${a3}`;
164
+ if (allowedNames.includes(combined))
165
+ return combined;
166
+ }
167
+ if (a2 && allowedNames.includes(a2))
168
+ return a2;
169
+ return 'unknown';
170
+ }
116
171
  export async function runCli(argv = process.argv) {
117
172
  if (!isHelpOrVersion(argv))
118
173
  autoUpdateInstalledSkills();
@@ -122,7 +177,13 @@ export async function runCli(argv = process.argv) {
122
177
  // including subcommands added via addCommand() that don't otherwise
123
178
  // inherit it. (#86 finding 3)
124
179
  applyExitOverride(program);
125
- registerAllowedCommands(collectCommandNames(program));
180
+ const commandNames = collectCommandNames(program);
181
+ // #88 item 6 — 'unknown' is a legitimate resolveCommandName() output (the
182
+ // fallback for "no command resolved at all"), not free-form user input —
183
+ // it must be explicitly allowlisted here or every capture that falls back
184
+ // to it is silently dropped by captureCommand's allowlist check (a dead
185
+ // branch that looks like it reports telemetry but never does).
186
+ registerAllowedCommands([...commandNames, 'unknown']);
126
187
  // Track start times + the currently-resolved command per instance, so the
127
188
  // catch handler below can derive the exact same safe name the success path
128
189
  // uses — it must never re-derive anything from argv.
@@ -176,7 +237,15 @@ export async function runCli(argv = process.argv) {
176
237
  console.log(JSON.stringify({ error: { message: err.message, kind: 'usage' } }));
177
238
  }
178
239
  process.exitCode = 2;
179
- void captureCommand(resolveCommandName(currentCommand), { exit_code: 2, error: 'CommanderError' });
240
+ // #88 item 6 — currentCommand is still undefined here whenever the
241
+ // parse error happened before preAction fired (the common case for a
242
+ // usage error — commander validates flags/arity before invoking the
243
+ // action). Try a best-effort match against the real command tree
244
+ // instead of collapsing straight to 'unknown'.
245
+ const commandName = currentCommand
246
+ ? resolveCommandName(currentCommand)
247
+ : bestEffortCommandName(argv, commandNames);
248
+ void captureCommand(commandName, { exit_code: 2, error: 'CommanderError' });
180
249
  }
181
250
  }
182
251
  else {
package/dist/lib/api.d.ts CHANGED
@@ -11,17 +11,32 @@ export declare class ApiError extends Error {
11
11
  export declare class NetworkError extends Error {
12
12
  constructor(message: string);
13
13
  }
14
+ /**
15
+ * A LOCAL auth failure — no token available, or a locally-decoded token
16
+ * that's provably expired, discovered entirely client-side before any HTTP
17
+ * call was ever made. Distinguished from ApiError(401) (a real server-issued
18
+ * 401 response) so the --json envelope never claims `status:401` for
19
+ * something the server never said — that would be a fabricated fact,
20
+ * indistinguishable from an actual server round-trip to a machine consumer.
21
+ * Both classify to the same exit code (3) / kind "auth" in classifyError
22
+ * (errors.ts); only the envelope's `status` field differs (present for
23
+ * ApiError, absent here). (#88 item 4)
24
+ */
25
+ export declare class AuthError extends Error {
26
+ constructor(message: string);
27
+ }
14
28
  /**
15
29
  * The single "no token available" error — every call site in this file that
16
30
  * needs a token (request/upload/getText/stream) used to throw its own copy
17
31
  * of `new Error('Not logged in. Run: trawl login')`, which fell through
18
32
  * classifyError's generic branch (exit 1, kind:"unknown") — indistinguishable
19
- * from an arbitrary bug. Auth-classifying it as an ApiError(401) puts it on
20
- * the exact same exit-3 / kind:"auth" path a real 401 response already takes.
33
+ * from an arbitrary bug. Auth-classifying it puts it on the exact same
34
+ * exit-3 / kind:"auth" path a real 401 response already takes — but as an
35
+ * AuthError (no HTTP call happened here), never a fabricated ApiError(401).
21
36
  * `trawl token` (src/commands/token.ts) reuses this too, so "no token" means
22
- * the same thing everywhere it can be observed. (#86 findings 1/2)
37
+ * the same thing everywhere it can be observed. (#86 findings 1/2, #88 item 4)
23
38
  */
24
- export declare function notLoggedInError(): ApiError;
39
+ export declare function notLoggedInError(): AuthError;
25
40
  export declare const api: {
26
41
  get: <T>(path: string) => Promise<T>;
27
42
  getText: (path: string) => Promise<string>;
package/dist/lib/api.js CHANGED
@@ -25,18 +25,36 @@ export class NetworkError extends Error {
25
25
  this.name = 'NetworkError';
26
26
  }
27
27
  }
28
+ /**
29
+ * A LOCAL auth failure — no token available, or a locally-decoded token
30
+ * that's provably expired, discovered entirely client-side before any HTTP
31
+ * call was ever made. Distinguished from ApiError(401) (a real server-issued
32
+ * 401 response) so the --json envelope never claims `status:401` for
33
+ * something the server never said — that would be a fabricated fact,
34
+ * indistinguishable from an actual server round-trip to a machine consumer.
35
+ * Both classify to the same exit code (3) / kind "auth" in classifyError
36
+ * (errors.ts); only the envelope's `status` field differs (present for
37
+ * ApiError, absent here). (#88 item 4)
38
+ */
39
+ export class AuthError extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = 'AuthError';
43
+ }
44
+ }
28
45
  /**
29
46
  * The single "no token available" error — every call site in this file that
30
47
  * needs a token (request/upload/getText/stream) used to throw its own copy
31
48
  * of `new Error('Not logged in. Run: trawl login')`, which fell through
32
49
  * classifyError's generic branch (exit 1, kind:"unknown") — indistinguishable
33
- * from an arbitrary bug. Auth-classifying it as an ApiError(401) puts it on
34
- * the exact same exit-3 / kind:"auth" path a real 401 response already takes.
50
+ * from an arbitrary bug. Auth-classifying it puts it on the exact same
51
+ * exit-3 / kind:"auth" path a real 401 response already takes — but as an
52
+ * AuthError (no HTTP call happened here), never a fabricated ApiError(401).
35
53
  * `trawl token` (src/commands/token.ts) reuses this too, so "no token" means
36
- * the same thing everywhere it can be observed. (#86 findings 1/2)
54
+ * the same thing everywhere it can be observed. (#86 findings 1/2, #88 item 4)
37
55
  */
38
56
  export function notLoggedInError() {
39
- return new ApiError(401, 'Not logged in. Run: trawl login');
57
+ return new AuthError('Not logged in. Run: trawl login');
40
58
  }
41
59
  const DEFAULT_TIMEOUT_MS = 30_000;
42
60
  /** Effective fetch timeout — TRAWL_TIMEOUT env override (ms), default 30s. (#71) */
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { ApiError, NetworkError } from './api.js';
2
+ import { ApiError, AuthError, NetworkError } from './api.js';
3
3
  /**
4
4
  * Thrown for CLI usage / input-validation failures (bad flag value, malformed
5
5
  * JSON, invalid ObjectId, missing required prompt input, …). Distinguished
@@ -22,6 +22,14 @@ export class UsageError extends Error {
22
22
  */
23
23
  export function classifyError(err) {
24
24
  const message = err instanceof Error ? err.message : String(err);
25
+ // #88 item 4 — a LOCAL auth failure (no token, or a locally-decoded expired
26
+ // token) never made an HTTP call, so its envelope must never carry
27
+ // `status:401` — that would claim a server response that never happened.
28
+ // Same exit code / kind as a real server 401 (ApiError below); only the
29
+ // envelope shape differs.
30
+ if (err instanceof AuthError) {
31
+ return { exitCode: 3, envelope: { message, kind: 'auth' } };
32
+ }
25
33
  if (err instanceof ApiError) {
26
34
  if (err.status === 401)
27
35
  return { exitCode: 3, envelope: { message, status: 401, kind: 'auth' } };
@@ -102,8 +102,19 @@ export function removeOrphanedSkills(scope) {
102
102
  }
103
103
  if (bundled.has(name))
104
104
  continue;
105
- // No `.version` marker → not ours → never delete it.
106
- if (getInstalledVersion(name, scope) === null)
105
+ // No `.version` marker → not ours → never delete it. Read is wrapped:
106
+ // a weird `.version` (e.g. a directory instead of a file → EISDIR on
107
+ // readFileSync) must skip only THIS entry, not blow up the whole sweep —
108
+ // a single malformed install must never leave every other entry
109
+ // unswept. (#88 item 12)
110
+ let installedVersion;
111
+ try {
112
+ installedVersion = getInstalledVersion(name, scope);
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ if (installedVersion === null)
107
118
  continue;
108
119
  rmSync(dir, { recursive: true, force: true });
109
120
  process.stderr.write(`trawl: removed orphaned skill "${name}" (${scope}) — no longer bundled with this CLI version\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.18.2",
3
+ "version": "1.18.3",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "url": "https://github.com/comes-io/trawl_cli/issues"
41
41
  },
42
42
  "dependencies": {
43
- "@trawlme/skills": "1.3.1",
43
+ "@trawlme/skills": "1.3.2",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.3",
46
46
  "conf": "^15.1.0",