@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 +40 -16
- package/dist/commands/scraps.js +173 -62
- package/dist/commands/skills.js +16 -4
- package/dist/commands/token.js +12 -6
- package/dist/index.d.ts +30 -4
- package/dist/index.js +104 -29
- package/dist/lib/api.d.ts +11 -0
- package/dist/lib/api.js +17 -4
- package/dist/lib/skills.d.ts +34 -4
- package/dist/lib/skills.js +69 -5
- package/package.json +2 -2
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
|
|
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
|
|
84
|
-
trawl skills install [<skill>] [--local]
|
|
85
|
-
trawl skills uninstall [<skill>] [--local]
|
|
86
|
-
trawl skills update [<skill>] [--local]
|
|
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
|
|
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
|
|
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
|
|
137
|
-
|
|
138
|
-
| `TRAWL_TOKEN`
|
|
139
|
-
| `TRAWL_API_URL`
|
|
140
|
-
| `TRAWL_TELEMETRY`
|
|
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
|
|
package/dist/commands/scraps.js
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* distinct from a business-logic refusal
|
|
15
|
-
* ApiError/NetworkError (handled centrally in
|
|
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
|
-
|
|
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
|
|
270
|
-
// silent-clamp: the server may persist a lower tier
|
|
271
|
-
|
|
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
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
if (
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
402
|
-
if (!
|
|
403
|
-
|
|
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
|
-
|
|
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 (
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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.'));
|
package/dist/commands/skills.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/dist/commands/token.js
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* (
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* (
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
127
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/lib/skills.d.ts
CHANGED
|
@@ -1,14 +1,44 @@
|
|
|
1
1
|
export declare function getBundledSkillsVersion(): string;
|
|
2
2
|
export declare function listBundledSkills(): string[];
|
|
3
|
-
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
38
|
+
* bundled one, and removes CLI-owned skills that are no longer bundled at all
|
|
39
|
+
* (renamed/dropped upstream — see 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
|
package/dist/lib/skills.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
116
|
+
* bundled one, and removes CLI-owned skills that are no longer bundled at all
|
|
117
|
+
* (renamed/dropped upstream — see 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.
|
|
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": "
|
|
43
|
+
"@trawlme/skills": "1.3.1",
|
|
44
44
|
"chalk": "^5.6.2",
|
|
45
45
|
"commander": "^14.0.3",
|
|
46
46
|
"conf": "^15.1.0",
|