@trawlme/cli 1.18.1 → 1.18.2

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
@@ -10,6 +10,8 @@ Command-line client for [Trawl](https://trawl.me) — manage your scraps from th
10
10
 
11
11
  ## Install
12
12
 
13
+ Requires Node.js >= 20.
14
+
13
15
  ```bash
14
16
  npm install -g @trawlme/cli
15
17
  ```
@@ -41,8 +43,8 @@ trawl token Print the stored session JWT (for M
41
43
  ```
42
44
  trawl scraps list [--json] [--status <success|failure|never>] [--limit <n>] [--page <n>]
43
45
  trawl scraps get <id> [--json]
44
- trawl scraps create -t <title> [-u <url>] [-r <request>] [-d <description>] [--tier <tier0|tier1|tier2|tier3|tier4>]
45
- trawl scraps update <id> [-t <title>] [-u <url>] [-r <request>] [-d <description>] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [--autofix|--no-autofix] [-p <json>|--params-file <path>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--force-tier <tier0|tier1|tier2|tier3|tier4>]
46
+ trawl scraps create -t <title> [-u <url>] [-r <request>] [-d <description>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--json]
47
+ trawl scraps update <id> [-t <title>] [-u <url>] [-r <request>] [-d <description>] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [--autofix|--no-autofix] [-p <json>|--params-file <path>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--force-tier <tier0|tier1|tier2|tier3|tier4>] [--json]
46
48
  trawl scraps run <id> [--watch]
47
49
  trawl scraps trigger <id> [--watch] [--wait]
48
50
  trawl scraps watch <id>
@@ -53,15 +55,16 @@ trawl scraps doctor <id> [--json] [--autofix]
53
55
  trawl scraps autofix <id> [--json]
54
56
  trawl scraps snapshot <id> [--error] [-o <file>]
55
57
  trawl scraps banner <id> -f <file>
56
- trawl scraps rm <id> [--force]
58
+ trawl scraps delete <id> [--force] Alias: rm
57
59
  ```
58
60
 
59
- - `--tier` forces a proxy tier; `--force-tier` raises the proxy-tier ceiling past the auto-cap (history-gated: may be refused or cost more).
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.
60
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.
61
63
  - `scraps autofix` shows the last auto-fix attempt on its own (decision, diff, dry-run, knowledge).
62
64
  - `scraps snapshot --error` fetches the error-path snapshot instead of the normal one; `-o <file>` writes to a file instead of stdout.
63
65
  - `scraps history` lists past runs (newest first); `scraps run-info <hid>` shows details of a single run from that history.
64
- - `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.
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.
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.
65
68
 
66
69
  ### Scrap accounts
67
70
 
@@ -80,13 +83,15 @@ trawl scraps account session set <id> -c <file>
80
83
  The CLI bundles a Claude Code skill that teaches Claude how to use `trawl`. Once installed, Claude can manage scraps for you via prompts.
81
84
 
82
85
  ```
83
- trawl skills list List bundled skills and install status
84
- trawl skills install [<skill>] [--local] Install all (or one). Default: ~/.claude/skills/
85
- trawl skills uninstall [<skill>] [--local] Remove
86
- trawl skills update [<skill>] [--local] Reinstall (force sync with CLI version)
86
+ trawl skills list List bundled skills and install status
87
+ trawl skills install [<skill>] [--local] [--force] Install all (or one). Default: ~/.claude/skills/
88
+ trawl skills uninstall [<skill>] [--local] Remove
89
+ trawl skills update [<skill>] [--local] [--force] Reinstall (force sync with CLI version)
87
90
  ```
88
91
 
89
- Skills auto-update silently when you upgrade the CLI — no need to re-install manually.
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`.
93
+
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).
90
95
 
91
96
  You can also install skills standalone (without the CLI): `npx @trawlme/skills install`.
92
97
 
@@ -108,7 +113,7 @@ trawl telemetry status Show current state, telemetry ID, and opt-out instructi
108
113
  - Node.js version
109
114
  - Platform (e.g. `darwin`, `linux`, `win32`)
110
115
  - Command duration (`duration_ms`)
111
- - Exit code (`0` = success, `1` = error)
116
+ - Exit code — the full 0–5 range below, not just success/failure (see [Exit codes](#exit-codes))
112
117
  - Error name if the command failed (e.g. `ApiError`) — never the error message or stack
113
118
  - An opaque, randomly-generated telemetry ID (`cli_<uuid>`) stored locally in your config file
114
119
 
@@ -131,13 +136,32 @@ TRAWL_TELEMETRY=0 trawl scraps list
131
136
 
132
137
  **Why:** usage data helps us prioritise CLI features and catch silent errors before users report them.
133
138
 
139
+ ## Exit codes
140
+
141
+ Every command exits with one of these codes — scripts and agents driving the CLI unattended can branch on the exact failure kind instead of a uniform pass/fail:
142
+
143
+ | Code | Meaning |
144
+ |------|--------------------------------------------------------------------------|
145
+ | `0` | Success |
146
+ | `1` | Unknown/generic error (an unmapped failure — API errors other than 401/404, an unhandled bug) |
147
+ | `2` | Usage error (bad flag/value, invalid ID, missing required argument, unknown option/command) |
148
+ | `3` | Auth error (not logged in, or the session token is expired/invalid — run `trawl login`) |
149
+ | `4` | Not found (no such resource, or — for `scraps data` — no persisted payload to read) |
150
+ | `5` | Network error (the API host is unreachable, DNS/connection/TLS failure, or the request timed out) |
151
+
152
+ 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.
153
+
134
154
  ## Environment variables
135
155
 
136
- | Variable | Description |
137
- |--------------------|-------------------------------------------------------------------|
138
- | `TRAWL_TOKEN` | JWT token — bypasses login prompt, useful for CI/CD |
139
- | `TRAWL_API_URL` | Override the API base URL for the session (takes precedence over `trawl login --url`) |
140
- | `TRAWL_TELEMETRY` | Set to `0` to disable telemetry for the current session |
156
+ | Variable | Description |
157
+ |-----------------------|--------------------------------------------------------------------|
158
+ | `TRAWL_TOKEN` | JWT token — bypasses login prompt, useful for CI/CD |
159
+ | `TRAWL_API_URL` | Override the API base URL for the session (takes precedence over `trawl login --url`) |
160
+ | `TRAWL_TELEMETRY` | Set to `0` to disable telemetry for the current session |
161
+ | `DO_NOT_TRACK` | Set to `1` to disable telemetry (cross-vendor convention, https://consoledonottrack.com) — same effect as `TRAWL_TELEMETRY=0` |
162
+ | `TRAWL_CONFIG_DIR` | Override where the config file (token, API URL, telemetry state) is stored — useful for hermetic CI runs or concurrent `trawl login`s that must not share one on-disk file |
163
+ | `TRAWL_TIMEOUT` | Override the per-request fetch timeout in milliseconds (default `30000`) |
164
+ | `TRAWL_SKILLS_SYNC` | Set to `0` to disable the startup skills auto-sync entirely |
141
165
 
142
166
  Session override (no `trawl login` mutation, ideal for CI/QA against another env):
143
167
 
@@ -5,21 +5,18 @@ 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];
@@ -73,7 +70,7 @@ scraps
73
70
  .option('--status <status>', 'Filter by last run status (success|failure|never)')
74
71
  .option('--limit <n>', 'Show only the first N results', (v) => parseInt(v, 10))
75
72
  .option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)', (v) => parseInt(v, 10))
76
- .action(async (opts) => {
73
+ .action(async (opts, cmd) => {
77
74
  // Guard: --limit and --page are mutually exclusive
78
75
  if (opts.limit !== undefined && opts.page !== undefined) {
79
76
  usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
@@ -105,12 +102,22 @@ scraps
105
102
  // Spinner already failed by oraPromise — report with the scrap-specific
106
103
  // prefix kept, but route through the shared classifier so exit code +
107
104
  // --json envelope stay consistent with every other command. (#71)
105
+ //
106
+ // This bespoke catch (kept for the "Failed to fetch scraps:" prefix,
107
+ // which the shared reportError() can't add) used to silently swallow
108
+ // --debug: unlike the central index.ts catch, it never printed the raw
109
+ // stack trace. optsWithGlobals() reads --debug off the ROOT command
110
+ // (this leaf has no --debug of its own) so it can honor the flag
111
+ // locally instead. (#86 finding 9)
112
+ const isDebug = Boolean(cmd.optsWithGlobals().debug || process.env['DEBUG']);
108
113
  const { exitCode, envelope } = classifyError(err);
109
114
  const message = `Failed to fetch scraps: ${envelope.message}`;
115
+ if (isDebug)
116
+ console.error(err);
110
117
  if (opts.json) {
111
118
  console.log(JSON.stringify({ error: { ...envelope, message } }));
112
119
  }
113
- else {
120
+ else if (!isDebug) {
114
121
  console.error(chalk.red(`✗ ${message}`));
115
122
  }
116
123
  process.exitCode = exitCode;
@@ -155,6 +162,58 @@ scraps
155
162
  console.log(chalk.dim(` Updated: `) + new Date(data.updatedAt).toLocaleString());
156
163
  });
157
164
  const VALID_TIERS = ['tier0', 'tier1', 'tier2', 'tier3', 'tier4'];
165
+ /**
166
+ * #86 findings 4/5 — shared honest-tier renderer for `create --tier` and
167
+ * `update --tier/--force-tier`. Reads the typed `_tierOverride` echoed back
168
+ * by the server (#1559) so both commands show the SAME truth: a refusal, an
169
+ * allowed ceiling raise (+ spend warning), or a silently clamped proxyTier.
170
+ * Human-mode output only — a --json caller gets the same truth for free from
171
+ * the full scrap object (which already includes `_tierOverride`).
172
+ */
173
+ function renderTierOverrideHuman(data) {
174
+ const ov = data._tierOverride;
175
+ if (!ov)
176
+ return;
177
+ if (ov.refused) {
178
+ console.error(chalk.red(` ✗ tier ceiling override refused: ${ov.reason ?? 'unknown'}`)
179
+ + chalk.dim(` (requested ${ov.requestedMaxTier ?? '—'}; kept the registry cap)`));
180
+ }
181
+ else if (ov.effectiveMaxTier) {
182
+ console.log(chalk.green(` ✓ tier ceiling: ${ov.effectiveMaxTier}`)
183
+ + chalk.dim(` (${ov.reason ?? ''}${ov.provider ? `, ${ov.provider}` : ''})`));
184
+ if (ov.warning)
185
+ console.log(chalk.yellow(` ⚠ ${ov.warning}`));
186
+ }
187
+ if (ov.proxyTier) {
188
+ if (ov.proxyTier.clamped) {
189
+ console.log(chalk.yellow(` ⚠ proxyTier requested ${ov.proxyTier.requested} → applied ${ov.proxyTier.effective}`)
190
+ + chalk.dim(` (${ov.proxyTier.reason ?? 'capped'})`));
191
+ }
192
+ else {
193
+ console.log(chalk.dim(` proxyTier: `) + ov.proxyTier.effective);
194
+ }
195
+ }
196
+ }
197
+ /**
198
+ * #86 finding 4b — old-server fallback. When a tier was requested but the
199
+ * response carries no `_tierOverride` at all, the server is too old to
200
+ * confirm what actually got applied. Echoing the REQUESTED value as if it
201
+ * were the outcome is exactly the silent-clamp lie #1559 fixed — warn
202
+ * instead, on stderr (safe under --json too; stdout purity is untouched).
203
+ */
204
+ function warnIfUnconfirmedTier(data, tierWasRequested, id) {
205
+ if (data._tierOverride || !tierWasRequested)
206
+ return;
207
+ console.error(chalk.yellow(` ⚠ Server did not confirm the tier change (older server) — verify with: trawl scraps get ${id}`));
208
+ }
209
+ /** #86 finding 5 — the standard error envelope for a refused tier override,
210
+ * routed through the same reportError() central formatting path used
211
+ * everywhere else (exit 1: a business-logic refusal, not a usage error). */
212
+ function reportTierRefusal(data, wantsJson) {
213
+ const ov = data._tierOverride;
214
+ const message = `Tier ceiling override refused: ${ov?.reason ?? 'unknown'} (requested ${ov?.requestedMaxTier ?? '—'}; kept the registry cap)`;
215
+ return reportError(new Error(message), { json: wantsJson });
216
+ }
158
217
  // create
159
218
  scraps
160
219
  .command('create')
@@ -164,9 +223,10 @@ scraps
164
223
  .option('-r, --request <request>', 'Request/query')
165
224
  .option('-d, --description <text>', 'Scrap description')
166
225
  .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
226
+ .option('--json', 'Output as JSON')
167
227
  .action(async (opts) => {
168
228
  if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
169
- usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`);
229
+ usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
170
230
  return;
171
231
  }
172
232
  const data = await oraPromise(() => api.post('/api/scraps', {
@@ -176,7 +236,24 @@ scraps
176
236
  ...(opts.description !== undefined && { description: opts.description }),
177
237
  ...(opts.tier !== undefined && { proxyTier: opts.tier }),
178
238
  }), { text: 'Creating scrap…', successText: (d) => `Scrap created: ${chalk.bold(d._id)}` });
239
+ // #86 finding 4a — read _tierOverride back from the POST response and
240
+ // render it exactly like `update` does; never echo the requested tier as
241
+ // if it were applied when an older server doesn't confirm it.
242
+ const tierWasRequested = opts.tier !== undefined;
243
+ warnIfUnconfirmedTier(data, tierWasRequested, data._id);
244
+ const refused = Boolean(data._tierOverride?.refused);
245
+ if (opts.json) {
246
+ if (refused) {
247
+ process.exitCode = reportTierRefusal(data, true);
248
+ return;
249
+ }
250
+ json(data);
251
+ return;
252
+ }
179
253
  console.log(chalk.dim(` Title: ${data.title}`));
254
+ renderTierOverrideHuman(data);
255
+ if (refused)
256
+ process.exitCode = 1;
180
257
  });
181
258
  // update
182
259
  scraps
@@ -196,14 +273,15 @@ scraps
196
273
  .option('--params-file <path>', 'Runtime params from a JSON file')
197
274
  .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
198
275
  .option('--force-tier <tier>', `Raise the proxy-tier ceiling PAST the auto-cap (${VALID_TIERS.join('|')}) — history-gated: may be refused or cost more`)
276
+ .option('--json', 'Output as JSON')
199
277
  .action(async (id, opts) => {
200
278
  validateObjectId(id);
201
279
  if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
202
- usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`);
280
+ usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
203
281
  return;
204
282
  }
205
283
  if (opts.forceTier !== undefined && !VALID_TIERS.includes(opts.forceTier)) {
206
- usageError(`Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})`);
284
+ usageError(`Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
207
285
  return;
208
286
  }
209
287
  const body = {};
@@ -241,11 +319,11 @@ scraps
241
319
  parsed = JSON.parse(raw);
242
320
  }
243
321
  catch (e) {
244
- usageError(`Invalid JSON for --params: ${e.message}`);
322
+ usageError(`Invalid JSON for --params: ${e.message}`, { json: opts.json });
245
323
  return;
246
324
  }
247
325
  if (!Array.isArray(parsed)) {
248
- usageError('--params must be a JSON array of objects');
326
+ usageError('--params must be a JSON array of objects', { json: opts.json });
249
327
  return;
250
328
  }
251
329
  body.params = parsed;
@@ -259,6 +337,10 @@ scraps
259
337
  body.proxyTier = opts.forceTier;
260
338
  }
261
339
  if (Object.keys(body).length === 0) {
340
+ if (opts.json) {
341
+ process.exitCode = reportError(new UsageError('Nothing to update. Provide at least one option.'), { json: true });
342
+ return;
343
+ }
262
344
  console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
263
345
  return;
264
346
  }
@@ -266,42 +348,35 @@ scraps
266
348
  text: 'Updating scrap…',
267
349
  successText: (d) => `Scrap updated: ${chalk.bold(d._id)}`,
268
350
  });
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;
351
+ // #1559 / #86 findings 4b/5 — surface the effective tier + clamp/refuse
352
+ // reason (fixes the silent-clamp: the server may persist a lower tier
353
+ // than requested), and NEVER echo the requested value as applied when
354
+ // the server doesn't confirm it (old-server fallback below).
355
+ const tierWasRequested = opts.tier !== undefined || opts.forceTier !== undefined;
356
+ warnIfUnconfirmedTier(data, tierWasRequested, id);
357
+ const refused = Boolean(data._tierOverride?.refused);
358
+ if (opts.json) {
359
+ if (refused) {
360
+ process.exitCode = reportTierRefusal(data, true);
361
+ return;
362
+ }
363
+ json(data);
364
+ return;
365
+ }
272
366
  const shown = data;
273
367
  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)
368
+ // Tier keys are rendered exclusively by renderTierOverrideHuman /
369
+ // warnIfUnconfirmedTier above never echo them here, whether or not
370
+ // _tierOverride came back (an old-server echo of the REQUESTED value
371
+ // is exactly the silent-clamp lie #1559 fixed).
372
+ if (key === 'proxyTier' || key === 'proxyMaxTier')
279
373
  continue;
280
374
  const src = key in shown ? shown[key] : body[key];
281
375
  console.log(chalk.dim(` ${key}: `) + String(src ?? '—'));
282
376
  }
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
- }
377
+ renderTierOverrideHuman(data);
378
+ if (refused)
379
+ process.exitCode = 1;
305
380
  });
306
381
  // run
307
382
  scraps
@@ -332,6 +407,25 @@ function renderScrapItems(items, asJson) {
332
407
  }
333
408
  console.log(chalk.dim(' Use --json for full output.'));
334
409
  }
410
+ /**
411
+ * #86 finding 6 — `data`'s honest empty-vs-error distinction, for both --json
412
+ * and human prose. Before this, EVERY non-array outcome (never run, last run
413
+ * failed, or the payload aged out of retention) collapsed to the SAME `[]` /
414
+ * "No data yet." — indistinguishable from a genuine zero-item successful run.
415
+ * That's a lie under --json: an agent can't tell "nothing to show" from "go
416
+ * look at what actually happened". Mirrors reportError's dual json/human
417
+ * shape (human line to stderr always; --json ALSO gets a machine envelope on
418
+ * stdout) with a caller-chosen exit code + kind, since these states are not
419
+ * all "usage" (2) — never-run / aged-out-of-retention are not_found (4), a
420
+ * failed last run is a business-logic failure (1).
421
+ */
422
+ function reportDataState(message, exitCode, kind, wantsJson) {
423
+ console.error(chalk.red(`✗ ${message}`));
424
+ if (wantsJson) {
425
+ console.log(JSON.stringify({ error: { message, kind } }));
426
+ }
427
+ process.exitCode = exitCode;
428
+ }
335
429
  // data
336
430
  scraps
337
431
  .command('data <id>')
@@ -397,17 +491,31 @@ scraps
397
491
  // it's just pulled from the most recent history row instead of a fresh
398
492
  // run. Retention keeps this only for the newest row per (scrap, status)
399
493
  // bucket (config.trawl.keepData, default 1); older rows null it out.
494
+ //
495
+ // #86 finding 6 — [] is reserved for a GENUINE zero-item successful run.
496
+ // Every other outcome below is an honest error envelope instead: never
497
+ // run (not_found/4), last run failed (1), or the payload aged out of
498
+ // retention (not_found/4) all used to collapse into the same silent [].
400
499
  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.'));
500
+ const last = scrap.history?.[0];
501
+ if (!last?._id) {
502
+ 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
503
  return;
409
504
  }
410
- const detail = await api.get(`/api/historys/${hid}`);
505
+ // #86 review — node persists status=false for a GENUINE zero-item run
506
+ // too (historys schema: status boolean|null + statusDetail
507
+ // success/error/empty/regression; a zero-item run is status=false +
508
+ // statusDetail='empty', and the embedded history rows from GET
509
+ // /api/scraps/:id include statusDetail via the repository populate
510
+ // 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.
513
+ const isEmptyRun = last.status === false && last.statusDetail === 'empty';
514
+ if (last.status === false && !isEmptyRun) {
515
+ reportDataState(`Last run failed — see: trawl scraps data ${id} --errors`, 1, 'run_failed', opts.json);
516
+ return;
517
+ }
518
+ const detail = await api.get(`/api/historys/${last._id}`);
411
519
  let items;
412
520
  if (typeof detail?.data === 'string' && detail.data) {
413
521
  try {
@@ -418,12 +526,14 @@ scraps
418
526
  }
419
527
  }
420
528
  if (!Array.isArray(items)) {
421
- if (opts.json) {
422
- json([]);
529
+ if (isEmptyRun) {
530
+ // A genuine zero-item run whose payload is '[]' or absent — both are
531
+ // the SAME honest answer: no items, exit 0. Never the retention
532
+ // message (nothing aged out; there was nothing to persist).
533
+ renderScrapItems([], opts.json);
423
534
  return;
424
535
  }
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).'));
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);
427
537
  return;
428
538
  }
429
539
  renderScrapItems(items, opts.json);
@@ -708,8 +818,9 @@ account
708
818
  const data = await oraPromise(() => api.get(`/api/scraps/${id}`), 'Fetching scrap…');
709
819
  const acc = data.account;
710
820
  if (opts.json) {
711
- const { json: jsonFn } = await import('../lib/format.js');
712
- return jsonFn(acc ?? null);
821
+ // #86 finding 12 — `json` is already statically imported at the top of
822
+ // this file; the dynamic import here was pure dead weight.
823
+ return json(acc ?? null);
713
824
  }
714
825
  if (!acc) {
715
826
  console.log(chalk.dim('No account data available.'));
@@ -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,27 @@
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 { ApiError, 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
+ process.exitCode = reportError(new ApiError(401, 'Session token expired. Run: trawl login to refresh.'));
19
25
  return;
20
26
  }
21
27
  // Print the raw token first (so it can be piped / copied)
package/dist/index.d.ts CHANGED
@@ -18,10 +18,36 @@ export declare function createProgram(): Command;
18
18
  /** True when this module is the process entrypoint (not merely imported by a test). */
19
19
  export declare function isEntryPoint(argv1: string | undefined, moduleUrl: string): boolean;
20
20
  /**
21
- * True when the invocation is a pure `--help`/`--version` query. These must not
22
- * trigger the skills auto-sync (a filesystem-mutating startup side effect) — a
23
- * user running `trawl --version` never expects it to rewrite their skills dirs.
24
- * (#73)
21
+ * True when the invocation is a pure `--help`/`--version` query, a bare
22
+ * `trawl` with no subcommand (commander prints top-level help and exits), or
23
+ * `trawl help [command]`. None of these should trigger the skills auto-sync
24
+ * (a filesystem-mutating startup side effect) — a user running `trawl
25
+ * --version` (or just `trawl`) never expects it to rewrite their skills
26
+ * dirs. (#73, extended #86 finding 7 for the bare-invocation + `help`
27
+ * subcommand cases)
25
28
  */
26
29
  export declare function isHelpOrVersion(argv: string[]): boolean;
30
+ /** Best-effort scan for a `--json` flag in raw argv, used only when parsing
31
+ * itself failed before any command's own `.opts()` could be resolved (a
32
+ * commander usage error — unknown option/command, missing required arg). Same
33
+ * "argv scan, never trust flag values" caveat as isHelpOrVersion: positional
34
+ * values are never mistaken for `--json` since they don't equal the literal
35
+ * string. (#86 finding 3)
36
+ */
37
+ export declare function hasJsonFlag(argv: string[]): boolean;
38
+ /**
39
+ * Commander's default (no exitOverride) calls `process.exit()` directly for
40
+ * a usage error (unknown option/command, missing required arg) or a
41
+ * --help/--version/`help` query — bypassing runCli's try/catch/finally
42
+ * entirely, so the telemetry shutdown() flush below never runs and a usage
43
+ * error exits 1 (the generic bug bucket) instead of its own distinct code.
44
+ * `program.exitOverride()` on the root command alone does NOT fix this for
45
+ * subcommands added via `addCommand()` (login/scraps/skills/telemetry/token
46
+ * are each built as standalone Command instances in their own module and
47
+ * only ever copy inherited settings — including exitOverride — from a parent
48
+ * at `.command()` construction time, which for these root-level modules never
49
+ * happens). Every node in the tree needs its own exitOverride() call, so this
50
+ * walks the whole tree and installs it everywhere. (#86 finding 3)
51
+ */
52
+ export declare function applyExitOverride(cmd: Command): void;
27
53
  export declare function runCli(argv?: string[]): Promise<void>;
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from 'commander';
3
- import chalk from 'chalk';
2
+ import { Command, CommanderError } from 'commander';
4
3
  import { readFileSync } from 'node:fs';
5
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
5
  import { dirname, join } from 'node:path';
@@ -11,7 +10,7 @@ import { telemetry } from './commands/telemetry.js';
11
10
  import { token } from './commands/token.js';
12
11
  import { autoUpdateInstalledSkills } from './lib/skills.js';
13
12
  import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
14
- import { classifyError } from './lib/errors.js';
13
+ import { classifyError, reportError } from './lib/errors.js';
15
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
15
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
17
16
  /**
@@ -63,19 +62,66 @@ export function isEntryPoint(argv1, moduleUrl) {
63
62
  return argv1 !== undefined && moduleUrl === pathToFileURL(argv1).href;
64
63
  }
65
64
  /**
66
- * True when the invocation is a pure `--help`/`--version` query. These must not
67
- * trigger the skills auto-sync (a filesystem-mutating startup side effect) — a
68
- * user running `trawl --version` never expects it to rewrite their skills dirs.
69
- * (#73)
65
+ * True when the invocation is a pure `--help`/`--version` query, a bare
66
+ * `trawl` with no subcommand (commander prints top-level help and exits), or
67
+ * `trawl help [command]`. None of these should trigger the skills auto-sync
68
+ * (a filesystem-mutating startup side effect) — a user running `trawl
69
+ * --version` (or just `trawl`) never expects it to rewrite their skills
70
+ * dirs. (#73, extended #86 finding 7 for the bare-invocation + `help`
71
+ * subcommand cases)
70
72
  */
71
73
  export function isHelpOrVersion(argv) {
72
- return argv.some((a) => a === '-h' || a === '--help' || a === '-V' || a === '--version');
74
+ if (argv.some((a) => a === '-h' || a === '--help' || a === '-V' || a === '--version'))
75
+ return true;
76
+ const args = argv.slice(2);
77
+ if (args.length === 0)
78
+ return true;
79
+ if (args[0] === 'help')
80
+ return true;
81
+ return false;
73
82
  }
83
+ /** Best-effort scan for a `--json` flag in raw argv, used only when parsing
84
+ * itself failed before any command's own `.opts()` could be resolved (a
85
+ * commander usage error — unknown option/command, missing required arg). Same
86
+ * "argv scan, never trust flag values" caveat as isHelpOrVersion: positional
87
+ * values are never mistaken for `--json` since they don't equal the literal
88
+ * string. (#86 finding 3)
89
+ */
90
+ export function hasJsonFlag(argv) {
91
+ return argv.includes('--json');
92
+ }
93
+ /**
94
+ * Commander's default (no exitOverride) calls `process.exit()` directly for
95
+ * a usage error (unknown option/command, missing required arg) or a
96
+ * --help/--version/`help` query — bypassing runCli's try/catch/finally
97
+ * entirely, so the telemetry shutdown() flush below never runs and a usage
98
+ * error exits 1 (the generic bug bucket) instead of its own distinct code.
99
+ * `program.exitOverride()` on the root command alone does NOT fix this for
100
+ * subcommands added via `addCommand()` (login/scraps/skills/telemetry/token
101
+ * are each built as standalone Command instances in their own module and
102
+ * only ever copy inherited settings — including exitOverride — from a parent
103
+ * at `.command()` construction time, which for these root-level modules never
104
+ * happens). Every node in the tree needs its own exitOverride() call, so this
105
+ * walks the whole tree and installs it everywhere. (#86 finding 3)
106
+ */
107
+ export function applyExitOverride(cmd) {
108
+ cmd.exitOverride();
109
+ for (const sub of cmd.commands)
110
+ applyExitOverride(sub);
111
+ }
112
+ /** Commander's own codes for a successful --help/--version/`help` query —
113
+ * these already printed their own output (to stdout) via commander itself;
114
+ * runCli's catch must treat them as a clean exit, not an error. (#86 finding 3) */
115
+ const HELP_OR_VERSION_CODES = new Set(['commander.helpDisplayed', 'commander.help', 'commander.version']);
74
116
  export async function runCli(argv = process.argv) {
75
117
  if (!isHelpOrVersion(argv))
76
118
  autoUpdateInstalledSkills();
77
119
  initPostHog();
78
120
  const program = createProgram();
121
+ // Must run before parseAsync — installs on every node in the tree,
122
+ // including subcommands added via addCommand() that don't otherwise
123
+ // inherit it. (#86 finding 3)
124
+ applyExitOverride(program);
79
125
  registerAllowedCommands(collectCommandNames(program));
80
126
  // Track start times + the currently-resolved command per instance, so the
81
127
  // catch handler below can derive the exact same safe name the success path
@@ -102,31 +148,60 @@ export async function runCli(argv = process.argv) {
102
148
  await program.parseAsync(argv);
103
149
  }
104
150
  catch (err) {
105
- // Map the error to a distinct exit code + machine envelope instead of a
106
- // uniform 1 — agents driving this CLI unattended need to tell
107
- // auth-expired (3) from not-found (4) from network-down (5) from a bad
108
- // flag (2) apart from an arbitrary bug (1). (#71)
109
- const { exitCode, envelope } = classifyError(err);
110
- // Capture error telemetry from the resolved command only — never argv.
111
- void captureCommand(resolveCommandName(currentCommand), {
112
- exit_code: exitCode,
113
- error: err.name,
114
- });
115
151
  const { debug } = program.opts();
116
152
  const isDebug = Boolean(debug || process.env['DEBUG']);
117
- // A --json subcommand must keep stdout pure JSON even on failure — read
118
- // the resolved command's own --json flag (never argv) so the error
119
- // envelope lands on the same channel the success path would have used.
120
- const wantsJson = Boolean(currentCommand?.opts()?.json);
121
- if (isDebug)
122
- console.error(err);
123
- if (wantsJson) {
124
- console.log(JSON.stringify({ error: envelope }));
153
+ if (err instanceof CommanderError) {
154
+ // Commander's own parse-time errors (exitOverride, #86 finding 3)
155
+ // a distinct family from our ApiError/NetworkError/UsageError/generic
156
+ // Error taxonomy, so classifyError/reportError don't apply here.
157
+ if (HELP_OR_VERSION_CODES.has(err.code)) {
158
+ // --help / --version / `trawl help` already printed their own
159
+ // output via commander itself — nothing else to print, just adopt
160
+ // commander's suggested exit code (0) and fall through to the
161
+ // shared shutdown() flush below.
162
+ process.exitCode = err.exitCode;
163
+ }
164
+ else {
165
+ // A usage error (unknown option/command, missing required arg, …) —
166
+ // commander already wrote its own human-readable line to stderr via
167
+ // Command#error(), so this never duplicates it. Force exit code 2
168
+ // (usage) regardless of whichever code commander suggests (it
169
+ // defaults these to 1), and add the --json machine envelope when
170
+ // resolvable — parsing failed before any command's own --json flag
171
+ // could be read off `currentCommand` (preAction never fired), so
172
+ // scan raw argv instead. (#86 finding 3)
173
+ if (isDebug)
174
+ console.error(err);
175
+ if (hasJsonFlag(argv)) {
176
+ console.log(JSON.stringify({ error: { message: err.message, kind: 'usage' } }));
177
+ }
178
+ process.exitCode = 2;
179
+ void captureCommand(resolveCommandName(currentCommand), { exit_code: 2, error: 'CommanderError' });
180
+ }
125
181
  }
126
- else if (!isDebug) {
127
- console.error(chalk.red('✗ ' + envelope.message));
182
+ else {
183
+ // Map the error to a distinct exit code + machine envelope instead of a
184
+ // uniform 1 — agents driving this CLI unattended need to tell
185
+ // auth-expired (3) from not-found (4) from network-down (5) from a bad
186
+ // flag (2) apart from an arbitrary bug (1). (#71)
187
+ const { exitCode } = classifyError(err);
188
+ // Capture error telemetry from the resolved command only — never argv.
189
+ void captureCommand(resolveCommandName(currentCommand), {
190
+ exit_code: exitCode,
191
+ error: err.name,
192
+ });
193
+ // A --json subcommand must keep stdout pure JSON even on failure — read
194
+ // the resolved command's own --json flag (never argv) so the error
195
+ // envelope lands on the same channel the success path would have used.
196
+ const wantsJson = Boolean(currentCommand?.opts()?.json);
197
+ if (isDebug)
198
+ console.error(err);
199
+ // reportError is the single formatting path (#86 finding 9) — prints
200
+ // EITHER the --json envelope (stdout) OR the human "✗ message" line
201
+ // (stderr), never both; `quiet` skips the human line when the raw
202
+ // stack was already dumped above under --debug.
203
+ process.exitCode = reportError(err, { json: wantsJson, quiet: isDebug });
128
204
  }
129
- process.exitCode = exitCode;
130
205
  }
131
206
  finally {
132
207
  // Flush + close telemetry before the process exits. A `process.on('exit')`
package/dist/lib/api.d.ts CHANGED
@@ -11,6 +11,17 @@ export declare class ApiError extends Error {
11
11
  export declare class NetworkError extends Error {
12
12
  constructor(message: string);
13
13
  }
14
+ /**
15
+ * The single "no token available" error — every call site in this file that
16
+ * needs a token (request/upload/getText/stream) used to throw its own copy
17
+ * of `new Error('Not logged in. Run: trawl login')`, which fell through
18
+ * 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.
21
+ * `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)
23
+ */
24
+ export declare function notLoggedInError(): ApiError;
14
25
  export declare const api: {
15
26
  get: <T>(path: string) => Promise<T>;
16
27
  getText: (path: string) => Promise<string>;
package/dist/lib/api.js CHANGED
@@ -25,6 +25,19 @@ export class NetworkError extends Error {
25
25
  this.name = 'NetworkError';
26
26
  }
27
27
  }
28
+ /**
29
+ * The single "no token available" error — every call site in this file that
30
+ * needs a token (request/upload/getText/stream) used to throw its own copy
31
+ * of `new Error('Not logged in. Run: trawl login')`, which fell through
32
+ * 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.
35
+ * `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)
37
+ */
38
+ export function notLoggedInError() {
39
+ return new ApiError(401, 'Not logged in. Run: trawl login');
40
+ }
28
41
  const DEFAULT_TIMEOUT_MS = 30_000;
29
42
  /** Effective fetch timeout — TRAWL_TIMEOUT env override (ms), default 30s. (#71) */
30
43
  function getTimeoutMs() {
@@ -166,7 +179,7 @@ async function throwIfError(res, isPublic = false) {
166
179
  async function request(path, options = {}) {
167
180
  const token = getToken();
168
181
  if (!token)
169
- throw new Error('Not logged in. Run: trawl login');
182
+ throw notLoggedInError();
170
183
  const url = `${getApiUrl()}${path}`;
171
184
  const res = await safeFetch(url, {
172
185
  ...options,
@@ -197,7 +210,7 @@ async function request(path, options = {}) {
197
210
  async function upload(path, formData) {
198
211
  const token = getToken();
199
212
  if (!token)
200
- throw new Error('Not logged in. Run: trawl login');
213
+ throw notLoggedInError();
201
214
  const url = `${getApiUrl()}${path}`;
202
215
  // Do NOT set Content-Type — fetch sets it automatically with the correct multipart boundary
203
216
  const res = await safeFetch(url, {
@@ -246,7 +259,7 @@ async function publicPost(path, body, baseUrlOverride) {
246
259
  async function getText(path) {
247
260
  const token = getToken();
248
261
  if (!token)
249
- throw new Error('Not logged in. Run: trawl login');
262
+ throw notLoggedInError();
250
263
  const url = `${getApiUrl()}${path}`;
251
264
  const res = await safeFetch(url, {
252
265
  headers: {
@@ -275,7 +288,7 @@ export const api = {
275
288
  stream: async function* (path) {
276
289
  const token = getToken();
277
290
  if (!token)
278
- throw new Error('Not logged in. Run: trawl login');
291
+ throw notLoggedInError();
279
292
  const url = `${getApiUrl()}${path}`;
280
293
  // No AbortSignal.timeout here — a long-running `watch`/`--watch` stream is
281
294
  // expected to sit open indefinitely; only connection-level failures
@@ -1,14 +1,44 @@
1
1
  export declare function getBundledSkillsVersion(): string;
2
2
  export declare function listBundledSkills(): string[];
3
- export declare function installSkill(name: string, scope: 'user' | 'local'): string;
3
+ /**
4
+ * Ownership guard (#73, extended #86 finding 7): this does `rmSync(recursive)`
5
+ * on the target dir before reinstalling, so it must never do that to a dir
6
+ * the CLI didn't install. `autoUpdateInstalledSkills()` already checks this
7
+ * itself before ever calling here (it skips marker-less dirs outright), but
8
+ * the explicit `trawl skills install`/`update` commands used to call straight
9
+ * through with no such check — a pre-existing user-authored
10
+ * `.claude/skills/<name>` dir that happens to collide with a bundled skill
11
+ * name would get silently deleted and overwritten. A missing `.version`
12
+ * marker on an EXISTING dest now refuses the install/reinstall unless
13
+ * `force` is passed.
14
+ */
15
+ export declare function installSkill(name: string, scope: 'user' | 'local', opts?: {
16
+ force?: boolean;
17
+ }): string;
4
18
  export declare function uninstallSkill(name: string, scope: 'user' | 'local'): string | null;
5
19
  export declare function getInstalledVersion(name: string, scope: 'user' | 'local'): string | null;
6
20
  export declare function isSkillInstalled(name: string, scope: 'user' | 'local'): boolean;
21
+ /**
22
+ * #86 review — orphan cleanup. The re-sync loop in autoUpdateInstalledSkills
23
+ * iterates listBundledSkills() — the NEW package's names only. When a bundled
24
+ * skill is RENAMED between package versions (1.0.0 shipped `trawl`, 1.3.1
25
+ * renamed it `trawl-cli`), the old marker-owned dir is never visited again: a
26
+ * stale ghost skill teaching outdated CLI usage stays installed forever,
27
+ * alongside the new one. This sweeps each scope's skills base dir for
28
+ * installed dirs that (a) carry a `.version` marker — the same ownership
29
+ * proof as everywhere else; a marker-less user-authored dir is NEVER touched,
30
+ * whatever its name — and (b) are no longer in the bundled set, and removes
31
+ * them with one honest stderr line (same style as the re-sync line).
32
+ * Returns the removed names (for the explicit `skills update` path to
33
+ * summarize).
34
+ */
35
+ export declare function removeOrphanedSkills(scope: 'user' | 'local'): string[];
7
36
  /**
8
37
  * Re-installs any CLI-owned skill whose installed version doesn't match the
9
- * bundled one. Called on CLI startup to keep skills in sync with the CLI
10
- * version. Never throws failures are silent so they don't break unrelated
11
- * commands.
38
+ * bundled one, and removes CLI-owned skills that are no longer bundled at all
39
+ * (renamed/dropped upstreamsee removeOrphanedSkills). Called on CLI
40
+ * startup to keep skills in sync with the CLI version. Never throws —
41
+ * failures are silent so they don't break unrelated commands.
12
42
  *
13
43
  * Ownership guard (#73): `installSkill` does `rmSync(recursive)` on the target
14
44
  * dir, so this MUST only ever touch dirs the CLI itself installed. Proof of
@@ -24,14 +24,32 @@ function getSkillsBase(scope) {
24
24
  const base = scope === 'local' ? join(process.cwd(), '.claude') : join(homedir(), '.claude');
25
25
  return join(base, 'skills');
26
26
  }
27
- export function installSkill(name, scope) {
27
+ /**
28
+ * Ownership guard (#73, extended #86 finding 7): this does `rmSync(recursive)`
29
+ * on the target dir before reinstalling, so it must never do that to a dir
30
+ * the CLI didn't install. `autoUpdateInstalledSkills()` already checks this
31
+ * itself before ever calling here (it skips marker-less dirs outright), but
32
+ * the explicit `trawl skills install`/`update` commands used to call straight
33
+ * through with no such check — a pre-existing user-authored
34
+ * `.claude/skills/<name>` dir that happens to collide with a bundled skill
35
+ * name would get silently deleted and overwritten. A missing `.version`
36
+ * marker on an EXISTING dest now refuses the install/reinstall unless
37
+ * `force` is passed.
38
+ */
39
+ export function installSkill(name, scope, opts = {}) {
28
40
  const src = join(getSkillsPackageRoot(), 'skills', name);
29
41
  if (!existsSync(src)) {
30
42
  throw new Error(`Skill "${name}" not found in @trawlme/skills`);
31
43
  }
32
44
  const dest = join(getSkillsBase(scope), name);
33
- if (existsSync(dest))
45
+ if (existsSync(dest)) {
46
+ const owned = getInstalledVersion(name, scope) !== null;
47
+ if (!owned && !opts.force) {
48
+ throw new Error(`Refusing to overwrite "${dest}" — it was not installed by trawl (no .version marker). ` +
49
+ `Pass --force to overwrite it anyway.`);
50
+ }
34
51
  rmSync(dest, { recursive: true, force: true });
52
+ }
35
53
  mkdirSync(dest, { recursive: true });
36
54
  cpSync(src, dest, { recursive: true });
37
55
  writeFileSync(join(dest, '.version'), getBundledSkillsVersion(), 'utf8');
@@ -53,11 +71,52 @@ export function getInstalledVersion(name, scope) {
53
71
  export function isSkillInstalled(name, scope) {
54
72
  return existsSync(join(getSkillsBase(scope), name));
55
73
  }
74
+ /**
75
+ * #86 review — orphan cleanup. The re-sync loop in autoUpdateInstalledSkills
76
+ * iterates listBundledSkills() — the NEW package's names only. When a bundled
77
+ * skill is RENAMED between package versions (1.0.0 shipped `trawl`, 1.3.1
78
+ * renamed it `trawl-cli`), the old marker-owned dir is never visited again: a
79
+ * stale ghost skill teaching outdated CLI usage stays installed forever,
80
+ * alongside the new one. This sweeps each scope's skills base dir for
81
+ * installed dirs that (a) carry a `.version` marker — the same ownership
82
+ * proof as everywhere else; a marker-less user-authored dir is NEVER touched,
83
+ * whatever its name — and (b) are no longer in the bundled set, and removes
84
+ * them with one honest stderr line (same style as the re-sync line).
85
+ * Returns the removed names (for the explicit `skills update` path to
86
+ * summarize).
87
+ */
88
+ export function removeOrphanedSkills(scope) {
89
+ const base = getSkillsBase(scope);
90
+ if (!existsSync(base))
91
+ return [];
92
+ const bundled = new Set(listBundledSkills());
93
+ const removed = [];
94
+ for (const name of readdirSync(base)) {
95
+ const dir = join(base, name);
96
+ try {
97
+ if (!statSync(dir).isDirectory())
98
+ continue;
99
+ }
100
+ catch {
101
+ continue; // raced away / unreadable — nothing to clean
102
+ }
103
+ if (bundled.has(name))
104
+ continue;
105
+ // No `.version` marker → not ours → never delete it.
106
+ if (getInstalledVersion(name, scope) === null)
107
+ continue;
108
+ rmSync(dir, { recursive: true, force: true });
109
+ process.stderr.write(`trawl: removed orphaned skill "${name}" (${scope}) — no longer bundled with this CLI version\n`);
110
+ removed.push(name);
111
+ }
112
+ return removed;
113
+ }
56
114
  /**
57
115
  * Re-installs any CLI-owned skill whose installed version doesn't match the
58
- * bundled one. Called on CLI startup to keep skills in sync with the CLI
59
- * version. Never throws failures are silent so they don't break unrelated
60
- * commands.
116
+ * bundled one, and removes CLI-owned skills that are no longer bundled at all
117
+ * (renamed/dropped upstreamsee removeOrphanedSkills). Called on CLI
118
+ * startup to keep skills in sync with the CLI version. Never throws —
119
+ * failures are silent so they don't break unrelated commands.
61
120
  *
62
121
  * Ownership guard (#73): `installSkill` does `rmSync(recursive)` on the target
63
122
  * dir, so this MUST only ever touch dirs the CLI itself installed. Proof of
@@ -89,6 +148,11 @@ export function autoUpdateInstalledSkills() {
89
148
  process.stderr.write(`trawl: re-synced skill "${name}" (${scope}) ${installed} → ${bundledVersion}\n`);
90
149
  }
91
150
  }
151
+ // Migration gap (#86 review): also drop marker-owned dirs whose skill
152
+ // was renamed/removed upstream, or they linger as stale ghosts forever.
153
+ for (const scope of ['user', 'local']) {
154
+ removeOrphanedSkills(scope);
155
+ }
92
156
  }
93
157
  catch {
94
158
  // Silent: skill auto-update should never block the CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.18.1",
3
+ "version": "1.18.2",
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.0.0",
43
+ "@trawlme/skills": "1.3.1",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.3",
46
46
  "conf": "^15.1.0",