@trawlme/cli 1.18.1 → 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.
@@ -5,25 +5,30 @@ import { api } from '../lib/api.js';
5
5
  import { table, json } from '../lib/format.js';
6
6
  import { promptPassword } from '../lib/prompt.js';
7
7
  import { validateObjectId } from '../lib/validate.js';
8
- import { classifyError } from '../lib/errors.js';
8
+ import { classifyError, reportError, UsageError } from '../lib/errors.js';
9
9
  import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
10
10
  /**
11
- * Print a usage/validation error consistently: human text to stderr always;
12
- * when the invoking command supports --json, ALSO emit a machine envelope on
13
- * stdout instead of leaving stdout silent/prose. Sets exit code 2 (usage) —
14
- * distinct from a business-logic refusal (which stays 1) or an unmapped
15
- * ApiError/NetworkError (handled centrally in index.ts). (#71)
11
+ * Print a usage/validation error consistently: human text to stderr, or a
12
+ * machine envelope on stdout under --json (never both reportError is the
13
+ * single formatting path shared with index.ts's central catch, #86 finding
14
+ * 9). Sets exit code 2 (usage) — distinct from a business-logic refusal
15
+ * (which stays 1) or an unmapped ApiError/NetworkError (handled centrally in
16
+ * index.ts). (#71)
16
17
  */
17
18
  function usageError(message, opts = {}) {
18
- console.error(chalk.red(`✗ ${message}`));
19
- if (opts.json) {
20
- console.log(JSON.stringify({ error: { message, kind: 'usage' } }));
21
- }
22
- process.exitCode = 2;
19
+ process.exitCode = reportError(new UsageError(message), { json: opts.json });
23
20
  }
24
21
  function lastStatus(scrap) {
25
22
  const last = scrap.history?.[0];
26
- 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)
27
32
  return 'never';
28
33
  return last.status === true ? 'success' : 'failure';
29
34
  }
@@ -44,6 +49,8 @@ function statusIcon(status) {
44
49
  return chalk.green('✓');
45
50
  if (status === 'failure')
46
51
  return chalk.red('✗');
52
+ if (status === 'running')
53
+ return chalk.cyan('↻');
47
54
  return chalk.dim('—');
48
55
  }
49
56
  export const scraps = new Command('scraps').description('Manage scraps');
@@ -70,33 +77,55 @@ scraps
70
77
  .alias('ls')
71
78
  .description('List all scraps')
72
79
  .option('--json', 'Output as JSON')
73
- .option('--status <status>', 'Filter by last run status (success|failure|never)')
74
- .option('--limit <n>', 'Show only the first N results', (v) => parseInt(v, 10))
75
- .option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)', (v) => parseInt(v, 10))
76
- .action(async (opts) => {
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)')
90
+ .action(async (opts, cmd) => {
77
91
  // Guard: --limit and --page are mutually exclusive
78
92
  if (opts.limit !== undefined && opts.page !== undefined) {
79
93
  usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
80
94
  return;
81
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
+ }
82
112
  let data;
83
113
  try {
84
114
  data = await oraPromise(async () => {
85
- if (opts.page !== undefined) {
115
+ if (page !== undefined) {
86
116
  // Single-page mode: explicit page requested, no loop
87
- const pageNum = opts.page;
88
- return api.get(`/api/scraps?perPage=50&page=${pageNum}`);
117
+ return api.get(`/api/scraps?perPage=50&page=${page}`);
89
118
  }
90
119
  // Fetch-all mode: paginate until a page returns < 200 items
91
120
  const perPage = 200;
92
121
  let result = [];
93
- let page = 1;
122
+ let pageNum = 1;
94
123
  while (true) {
95
- const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${page}`);
124
+ const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${pageNum}`);
96
125
  result = result.concat(batch);
97
126
  if (batch.length < perPage)
98
127
  break;
99
- page++;
128
+ pageNum++;
100
129
  }
101
130
  return result;
102
131
  }, 'Fetching scraps…');
@@ -105,12 +134,22 @@ scraps
105
134
  // Spinner already failed by oraPromise — report with the scrap-specific
106
135
  // prefix kept, but route through the shared classifier so exit code +
107
136
  // --json envelope stay consistent with every other command. (#71)
137
+ //
138
+ // This bespoke catch (kept for the "Failed to fetch scraps:" prefix,
139
+ // which the shared reportError() can't add) used to silently swallow
140
+ // --debug: unlike the central index.ts catch, it never printed the raw
141
+ // stack trace. optsWithGlobals() reads --debug off the ROOT command
142
+ // (this leaf has no --debug of its own) so it can honor the flag
143
+ // locally instead. (#86 finding 9)
144
+ const isDebug = Boolean(cmd.optsWithGlobals().debug || process.env['DEBUG']);
108
145
  const { exitCode, envelope } = classifyError(err);
109
146
  const message = `Failed to fetch scraps: ${envelope.message}`;
147
+ if (isDebug)
148
+ console.error(err);
110
149
  if (opts.json) {
111
150
  console.log(JSON.stringify({ error: { ...envelope, message } }));
112
151
  }
113
- else {
152
+ else if (!isDebug) {
114
153
  console.error(chalk.red(`✗ ${message}`));
115
154
  }
116
155
  process.exitCode = exitCode;
@@ -119,7 +158,6 @@ scraps
119
158
  if (opts.status)
120
159
  data = data.filter((s) => lastStatus(s) === opts.status);
121
160
  const totalMatched = data.length;
122
- const limit = opts.limit;
123
161
  const rows = limit !== undefined ? data.slice(0, limit) : data;
124
162
  if (opts.json)
125
163
  return json(rows);
@@ -155,6 +193,75 @@ scraps
155
193
  console.log(chalk.dim(` Updated: `) + new Date(data.updatedAt).toLocaleString());
156
194
  });
157
195
  const VALID_TIERS = ['tier0', 'tier1', 'tier2', 'tier3', 'tier4'];
196
+ /**
197
+ * #86 findings 4/5 — shared honest-tier renderer for `create --tier` and
198
+ * `update --tier/--force-tier`. Reads the typed `_tierOverride` echoed back
199
+ * by the server (#1559) so both commands show the SAME truth: a refusal, an
200
+ * allowed ceiling raise (+ spend warning), or a silently clamped proxyTier.
201
+ * Human-mode output only — a --json caller gets the same truth for free from
202
+ * the full scrap object (which already includes `_tierOverride`).
203
+ */
204
+ function renderTierOverrideHuman(data) {
205
+ const ov = data._tierOverride;
206
+ if (!ov)
207
+ return;
208
+ if (ov.refused) {
209
+ console.error(chalk.red(` ✗ tier ceiling override refused: ${ov.reason ?? 'unknown'}`)
210
+ + chalk.dim(` (requested ${ov.requestedMaxTier ?? '—'}; kept the registry cap)`));
211
+ }
212
+ else if (ov.effectiveMaxTier) {
213
+ console.log(chalk.green(` ✓ tier ceiling: ${ov.effectiveMaxTier}`)
214
+ + chalk.dim(` (${ov.reason ?? ''}${ov.provider ? `, ${ov.provider}` : ''})`));
215
+ if (ov.warning)
216
+ console.log(chalk.yellow(` ⚠ ${ov.warning}`));
217
+ }
218
+ if (ov.proxyTier) {
219
+ if (ov.proxyTier.clamped) {
220
+ console.log(chalk.yellow(` ⚠ proxyTier requested ${ov.proxyTier.requested} → applied ${ov.proxyTier.effective}`)
221
+ + chalk.dim(` (${ov.proxyTier.reason ?? 'capped'})`));
222
+ }
223
+ else {
224
+ console.log(chalk.dim(` proxyTier: `) + ov.proxyTier.effective);
225
+ }
226
+ }
227
+ }
228
+ /**
229
+ * #86 finding 4b — old-server fallback. When a tier was requested but the
230
+ * response carries no `_tierOverride` at all, the server is too old to
231
+ * confirm what actually got applied. Echoing the REQUESTED value as if it
232
+ * were the outcome is exactly the silent-clamp lie #1559 fixed — warn
233
+ * instead, on stderr (safe under --json too; stdout purity is untouched).
234
+ */
235
+ function warnIfUnconfirmedTier(data, tierWasRequested, id) {
236
+ if (data._tierOverride || !tierWasRequested)
237
+ return;
238
+ console.error(chalk.yellow(` ⚠ Server did not confirm the tier change (older server) — verify with: trawl scraps get ${id}`));
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
+ }
257
+ /** #86 finding 5 — the standard error envelope for a refused tier override,
258
+ * routed through the same reportError() central formatting path used
259
+ * everywhere else (exit 1: a business-logic refusal, not a usage error). */
260
+ function reportTierRefusal(data, wantsJson) {
261
+ const ov = data._tierOverride;
262
+ const message = `Tier ceiling override refused: ${ov?.reason ?? 'unknown'} (requested ${ov?.requestedMaxTier ?? '—'}; kept the registry cap)`;
263
+ return reportError(new Error(message), { json: wantsJson });
264
+ }
158
265
  // create
159
266
  scraps
160
267
  .command('create')
@@ -164,9 +271,10 @@ scraps
164
271
  .option('-r, --request <request>', 'Request/query')
165
272
  .option('-d, --description <text>', 'Scrap description')
166
273
  .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
274
+ .option('--json', 'Output as JSON')
167
275
  .action(async (opts) => {
168
276
  if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
169
- usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`);
277
+ usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
170
278
  return;
171
279
  }
172
280
  const data = await oraPromise(() => api.post('/api/scraps', {
@@ -176,7 +284,24 @@ scraps
176
284
  ...(opts.description !== undefined && { description: opts.description }),
177
285
  ...(opts.tier !== undefined && { proxyTier: opts.tier }),
178
286
  }), { text: 'Creating scrap…', successText: (d) => `Scrap created: ${chalk.bold(d._id)}` });
287
+ // #86 finding 4a — read _tierOverride back from the POST response and
288
+ // render it exactly like `update` does; never echo the requested tier as
289
+ // if it were applied when an older server doesn't confirm it.
290
+ const tierWasRequested = opts.tier !== undefined;
291
+ warnIfUnconfirmedTier(data, tierWasRequested, data._id);
292
+ const refused = Boolean(data._tierOverride?.refused);
293
+ if (opts.json) {
294
+ if (refused) {
295
+ process.exitCode = reportTierRefusal(data, true);
296
+ return;
297
+ }
298
+ json(withTierUnconfirmed(data, tierWasRequested));
299
+ return;
300
+ }
179
301
  console.log(chalk.dim(` Title: ${data.title}`));
302
+ renderTierOverrideHuman(data);
303
+ if (refused)
304
+ process.exitCode = 1;
180
305
  });
181
306
  // update
182
307
  scraps
@@ -196,14 +321,15 @@ scraps
196
321
  .option('--params-file <path>', 'Runtime params from a JSON file')
197
322
  .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
198
323
  .option('--force-tier <tier>', `Raise the proxy-tier ceiling PAST the auto-cap (${VALID_TIERS.join('|')}) — history-gated: may be refused or cost more`)
324
+ .option('--json', 'Output as JSON')
199
325
  .action(async (id, opts) => {
200
326
  validateObjectId(id);
201
327
  if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
202
- usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`);
328
+ usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
203
329
  return;
204
330
  }
205
331
  if (opts.forceTier !== undefined && !VALID_TIERS.includes(opts.forceTier)) {
206
- usageError(`Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})`);
332
+ usageError(`Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
207
333
  return;
208
334
  }
209
335
  const body = {};
@@ -241,11 +367,11 @@ scraps
241
367
  parsed = JSON.parse(raw);
242
368
  }
243
369
  catch (e) {
244
- usageError(`Invalid JSON for --params: ${e.message}`);
370
+ usageError(`Invalid JSON for --params: ${e.message}`, { json: opts.json });
245
371
  return;
246
372
  }
247
373
  if (!Array.isArray(parsed)) {
248
- usageError('--params must be a JSON array of objects');
374
+ usageError('--params must be a JSON array of objects', { json: opts.json });
249
375
  return;
250
376
  }
251
377
  body.params = parsed;
@@ -259,6 +385,10 @@ scraps
259
385
  body.proxyTier = opts.forceTier;
260
386
  }
261
387
  if (Object.keys(body).length === 0) {
388
+ if (opts.json) {
389
+ process.exitCode = reportError(new UsageError('Nothing to update. Provide at least one option.'), { json: true });
390
+ return;
391
+ }
262
392
  console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
263
393
  return;
264
394
  }
@@ -266,42 +396,35 @@ scraps
266
396
  text: 'Updating scrap…',
267
397
  successText: (d) => `Scrap updated: ${chalk.bold(d._id)}`,
268
398
  });
269
- // #1559 — surface the effective tier + clamp/refuse reason (fixes the
270
- // silent-clamp: the server may persist a lower tier than requested).
271
- const ov = data._tierOverride;
399
+ // #1559 / #86 findings 4b/5 — surface the effective tier + clamp/refuse
400
+ // reason (fixes the silent-clamp: the server may persist a lower tier
401
+ // than requested), and NEVER echo the requested value as applied when
402
+ // the server doesn't confirm it (old-server fallback below).
403
+ const tierWasRequested = opts.tier !== undefined || opts.forceTier !== undefined;
404
+ warnIfUnconfirmedTier(data, tierWasRequested, id);
405
+ const refused = Boolean(data._tierOverride?.refused);
406
+ if (opts.json) {
407
+ if (refused) {
408
+ process.exitCode = reportTierRefusal(data, true);
409
+ return;
410
+ }
411
+ json(withTierUnconfirmed(data, tierWasRequested));
412
+ return;
413
+ }
272
414
  const shown = data;
273
415
  for (const key of Object.keys(body)) {
274
- // Only suppress the raw proxyTier/proxyMaxTier echo when _tierOverride is
275
- // present to report the truth. Against an older server (no _tierOverride)
276
- // fall back to echoing the requested value from the body, so the tier is
277
- // never silently dropped (worse than a misleading echo).
278
- if ((key === 'proxyTier' || key === 'proxyMaxTier') && ov)
416
+ // Tier keys are rendered exclusively by renderTierOverrideHuman /
417
+ // warnIfUnconfirmedTier above never echo them here, whether or not
418
+ // _tierOverride came back (an old-server echo of the REQUESTED value
419
+ // is exactly the silent-clamp lie #1559 fixed).
420
+ if (key === 'proxyTier' || key === 'proxyMaxTier')
279
421
  continue;
280
422
  const src = key in shown ? shown[key] : body[key];
281
423
  console.log(chalk.dim(` ${key}: `) + String(src ?? '—'));
282
424
  }
283
- if (ov) {
284
- if (ov.refused) {
285
- console.error(chalk.red(` ✗ tier ceiling override refused: ${ov.reason ?? 'unknown'}`)
286
- + chalk.dim(` (requested ${ov.requestedMaxTier ?? '—'}; kept the registry cap)`));
287
- process.exitCode = 1;
288
- }
289
- else if (ov.effectiveMaxTier) {
290
- console.log(chalk.green(` ✓ tier ceiling: ${ov.effectiveMaxTier}`)
291
- + chalk.dim(` (${ov.reason ?? ''}${ov.provider ? `, ${ov.provider}` : ''})`));
292
- if (ov.warning)
293
- console.log(chalk.yellow(` ⚠ ${ov.warning}`));
294
- }
295
- if (ov.proxyTier) {
296
- if (ov.proxyTier.clamped) {
297
- console.log(chalk.yellow(` ⚠ proxyTier requested ${ov.proxyTier.requested} → applied ${ov.proxyTier.effective}`)
298
- + chalk.dim(` (${ov.proxyTier.reason ?? 'capped'})`));
299
- }
300
- else {
301
- console.log(chalk.dim(` proxyTier: `) + ov.proxyTier.effective);
302
- }
303
- }
304
- }
425
+ renderTierOverrideHuman(data);
426
+ if (refused)
427
+ process.exitCode = 1;
305
428
  });
306
429
  // run
307
430
  scraps
@@ -332,6 +455,25 @@ function renderScrapItems(items, asJson) {
332
455
  }
333
456
  console.log(chalk.dim(' Use --json for full output.'));
334
457
  }
458
+ /**
459
+ * #86 finding 6 — `data`'s honest empty-vs-error distinction, for both --json
460
+ * and human prose. Before this, EVERY non-array outcome (never run, last run
461
+ * failed, or the payload aged out of retention) collapsed to the SAME `[]` /
462
+ * "No data yet." — indistinguishable from a genuine zero-item successful run.
463
+ * That's a lie under --json: an agent can't tell "nothing to show" from "go
464
+ * look at what actually happened". Mirrors reportError's dual json/human
465
+ * shape (human line to stderr always; --json ALSO gets a machine envelope on
466
+ * stdout) with a caller-chosen exit code + kind, since these states are not
467
+ * all "usage" (2) — never-run / aged-out-of-retention are not_found (4), a
468
+ * failed last run is a business-logic failure (1).
469
+ */
470
+ function reportDataState(message, exitCode, kind, wantsJson) {
471
+ console.error(chalk.red(`✗ ${message}`));
472
+ if (wantsJson) {
473
+ console.log(JSON.stringify({ error: { message, kind } }));
474
+ }
475
+ process.exitCode = exitCode;
476
+ }
335
477
  // data
336
478
  scraps
337
479
  .command('data <id>')
@@ -346,8 +488,12 @@ scraps
346
488
  if (opts.errors) {
347
489
  const result = await fetchRunAndFix(id);
348
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.
349
495
  if (opts.json) {
350
- json(null);
496
+ json({ status: 'no_runs' });
351
497
  return;
352
498
  }
353
499
  console.log(chalk.dim('No runs yet.'));
@@ -397,17 +543,48 @@ scraps
397
543
  // it's just pulled from the most recent history row instead of a fresh
398
544
  // run. Retention keeps this only for the newest row per (scrap, status)
399
545
  // bucket (config.trawl.keepData, default 1); older rows null it out.
546
+ //
547
+ // #86 finding 6 — [] is reserved for a GENUINE zero-item successful run.
548
+ // Every other outcome below is an honest error envelope instead: never
549
+ // run (not_found/4), last run failed (1), or the payload aged out of
550
+ // retention (not_found/4) all used to collapse into the same silent [].
400
551
  const scrap = await api.get(`/api/scraps/${id}`);
401
- const hid = scrap.history?.[0]?._id;
402
- if (!hid) {
403
- if (opts.json) {
404
- json([]);
405
- return;
406
- }
407
- console.log(chalk.dim('No data yet. Run the scrap first, or pass --fresh to launch one now.'));
552
+ const last = scrap.history?.[0];
553
+ if (!last?._id) {
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);
408
555
  return;
409
556
  }
410
- const detail = await api.get(`/api/historys/${hid}`);
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
+ }
568
+ // #86 review — node persists status=false for a GENUINE zero-item run
569
+ // too (historys schema: status boolean|null + statusDetail
570
+ // success/error/empty/regression; a zero-item run is status=false +
571
+ // statusDetail='empty', and the embedded history rows from GET
572
+ // /api/scraps/:id include statusDetail via the repository populate
573
+ // select). An 'empty' run is the one case [] is FOR — only a real
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.
581
+ const isEmptyRun = last.status === false && last.statusDetail === 'empty';
582
+ const isRegression = last.status === false && last.statusDetail === 'regression';
583
+ if (last.status === false && !isEmptyRun && !isRegression) {
584
+ reportDataState(`Last run failed — see: trawl scraps data ${id} --errors`, 1, 'run_failed', opts.json);
585
+ return;
586
+ }
587
+ const detail = await api.get(`/api/historys/${last._id}`);
411
588
  let items;
412
589
  if (typeof detail?.data === 'string' && detail.data) {
413
590
  try {
@@ -418,14 +595,27 @@ scraps
418
595
  }
419
596
  }
420
597
  if (!Array.isArray(items)) {
421
- if (opts.json) {
422
- json([]);
598
+ if (isEmptyRun) {
599
+ // A genuine zero-item run whose payload is '[]' or absent — both are
600
+ // the SAME honest answer: no items, exit 0. Never the retention
601
+ // message (nothing aged out; there was nothing to persist).
602
+ renderScrapItems([], opts.json);
423
603
  return;
424
604
  }
425
- console.log(chalk.dim('No persisted data for the last run (it may have failed, or aged out of retention). '
426
- + 'Pass --fresh to launch a new run (consumes execute quota).'));
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);
427
610
  return;
428
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
+ }
429
619
  renderScrapItems(items, opts.json);
430
620
  });
431
621
  // history — list past runs for a scrap
@@ -708,8 +898,9 @@ account
708
898
  const data = await oraPromise(() => api.get(`/api/scraps/${id}`), 'Fetching scrap…');
709
899
  const acc = data.account;
710
900
  if (opts.json) {
711
- const { json: jsonFn } = await import('../lib/format.js');
712
- return jsonFn(acc ?? null);
901
+ // #86 finding 12 — `json` is already statically imported at the top of
902
+ // this file; the dynamic import here was pure dead weight.
903
+ return json(acc ?? null);
713
904
  }
714
905
  if (!acc) {
715
906
  console.log(chalk.dim('No account data available.'));
@@ -770,14 +961,19 @@ scraps
770
961
  validateObjectId(id);
771
962
  const result = await fetchRunAndFix(id);
772
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.
773
967
  if (opts.json) {
774
- json(null);
968
+ json({ status: 'no_runs' });
775
969
  return;
776
970
  }
777
971
  console.log(chalk.dim('No runs yet.'));
778
972
  return;
779
973
  }
780
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.
781
977
  if (opts.json) {
782
978
  json(null);
783
979
  return;
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { listBundledSkills, installSkill, uninstallSkill, getBundledSkillsVersion, getInstalledVersion, isSkillInstalled, } from '../lib/skills.js';
3
+ import { listBundledSkills, installSkill, uninstallSkill, getBundledSkillsVersion, getInstalledVersion, isSkillInstalled, removeOrphanedSkills, } from '../lib/skills.js';
4
+ import { UsageError } from '../lib/errors.js';
4
5
  function pickScope(opts) {
5
6
  return opts.local ? 'local' : 'user';
6
7
  }
@@ -9,7 +10,9 @@ function pickSkills(arg) {
9
10
  if (!arg || arg === 'all')
10
11
  return all;
11
12
  if (!all.includes(arg)) {
12
- throw new Error(`Unknown skill "${arg}". Available: ${all.join(', ') || '(none)'}`);
13
+ // Usage error (exit 2), not a generic bug (exit 1) the caller typed a
14
+ // skill name that doesn't exist. (#86 finding 3)
15
+ throw new UsageError(`Unknown skill "${arg}". Available: ${all.join(', ') || '(none)'}`);
13
16
  }
14
17
  return [arg];
15
18
  }
@@ -45,11 +48,12 @@ skills
45
48
  .command('install [skill]')
46
49
  .description('Install one or all bundled skills')
47
50
  .option('--local', 'Install at project level (./.claude/skills) instead of user level (~/.claude/skills)')
51
+ .option('--force', 'Overwrite a pre-existing dir even if trawl did not install it (no .version marker)')
48
52
  .action((skill, opts) => {
49
53
  const scope = pickScope(opts);
50
54
  const targets = pickSkills(skill);
51
55
  for (const name of targets) {
52
- const dest = installSkill(name, scope);
56
+ const dest = installSkill(name, scope, { force: opts.force });
53
57
  console.log(chalk.green(`✓ Installed "${name}"`) + chalk.dim(` at ${dest}`));
54
58
  }
55
59
  console.log(chalk.dim(' Restart Claude Code if it was already running.'));
@@ -73,11 +77,19 @@ skills
73
77
  .command('update [skill]')
74
78
  .description('Reinstall over the existing skill (force sync with CLI version)')
75
79
  .option('--local', 'Update at project level')
80
+ .option('--force', 'Overwrite a pre-existing dir even if trawl did not install it (no .version marker)')
76
81
  .action((skill, opts) => {
77
82
  const scope = pickScope(opts);
78
83
  const targets = pickSkills(skill);
79
84
  for (const name of targets) {
80
- const dest = installSkill(name, scope);
85
+ const dest = installSkill(name, scope, { force: opts.force });
81
86
  console.log(chalk.green(`✓ Updated "${name}"`) + chalk.dim(` at ${dest}`));
82
87
  }
88
+ // #86 review — same orphan sweep as the startup auto-sync: an explicit
89
+ // `skills update` must also drop CLI-owned dirs whose skill was renamed
90
+ // or removed upstream (e.g. 1.0.0's `trawl` → 1.3.1's `trawl-cli`),
91
+ // instead of leaving a stale ghost teaching outdated usage. Only the
92
+ // scope being updated is swept; marker-less dirs are never touched.
93
+ // removeOrphanedSkills prints its own honest stderr line per removal.
94
+ removeOrphanedSkills(scope);
83
95
  });
@@ -1,21 +1,30 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import config from '../lib/config.js';
3
+ import { getToken } from '../lib/config.js';
4
+ import { AuthError, notLoggedInError } from '../lib/api.js';
5
+ import { reportError } from '../lib/errors.js';
4
6
  import { decodeExp } from '../lib/jwt.js';
5
7
  export const token = new Command('token')
6
8
  .description('Print the stored session JWT (for MCP Bearer auth)')
7
9
  .action(() => {
8
- const stored = config.get('token');
10
+ // getToken() resolves TRAWL_TOKEN env first, then the stored config
11
+ // token (see config.ts:47-51) — matching every other token consumer in
12
+ // the CLI instead of reading the config store directly. (#86 finding 1)
13
+ const stored = getToken();
9
14
  if (!stored) {
10
- console.error(chalk.red('✗ Not logged in. Run: trawl login'));
11
- process.exitCode = 1;
15
+ // Auth-classified (ApiError 401 exit 3, kind:"auth"), not a generic
16
+ // exit 1 — an agent scripting `trawl token` needs to tell "not logged
17
+ // in" apart from an arbitrary bug. (#86 finding 1)
18
+ process.exitCode = reportError(notLoggedInError());
12
19
  return;
13
20
  }
14
21
  const exp = decodeExp(stored);
15
22
  const nowSeconds = Math.floor(Date.now() / 1000);
16
23
  if (exp !== null && exp < nowSeconds) {
17
- console.error(chalk.red('✗ Session token expired. Run: trawl login to refresh.'));
18
- process.exitCode = 1;
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.'));
19
28
  return;
20
29
  }
21
30
  // Print the raw token first (so it can be piped / copied)