@trawlme/cli 1.18.1 → 1.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -18
- package/dist/commands/doctor.js +8 -4
- package/dist/commands/scraps.js +271 -75
- package/dist/commands/skills.js +16 -4
- package/dist/commands/token.js +15 -6
- package/dist/index.d.ts +65 -4
- package/dist/index.js +177 -33
- package/dist/lib/api.d.ts +26 -0
- package/dist/lib/api.js +35 -4
- package/dist/lib/errors.js +9 -1
- package/dist/lib/skills.d.ts +34 -4
- package/dist/lib/skills.js +80 -5
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,15 @@ import { Command } from 'commander';
|
|
|
6
6
|
* --url) don't start with '-' and would otherwise survive an argv filter and
|
|
7
7
|
* leak to PostHog (#67). Falls back to 'unknown' when no command resolved
|
|
8
8
|
* (e.g. an error thrown before any action ran).
|
|
9
|
+
*
|
|
10
|
+
* #88 item 9 — walks the FULL parent chain, not just the immediate parent:
|
|
11
|
+
* the old one-level join (`${parent.name()} ${own.name()}`) resolved a
|
|
12
|
+
* 4-deep command like `scraps account session set` down to just "session
|
|
13
|
+
* set", silently dropping "scraps account". The root program node (the
|
|
14
|
+
* 'trawl' Command itself, which has no `.parent`) is excluded from the
|
|
15
|
+
* chain — matching the pre-existing convention that a direct child of the
|
|
16
|
+
* root (e.g. `scraps list`, `telemetry on`) is named relative to its
|
|
17
|
+
* immediate group, never prefixed with the program name.
|
|
9
18
|
*/
|
|
10
19
|
export declare function resolveCommandName(actionCommand: Command | undefined): string;
|
|
11
20
|
/**
|
|
@@ -18,10 +27,62 @@ export declare function createProgram(): Command;
|
|
|
18
27
|
/** True when this module is the process entrypoint (not merely imported by a test). */
|
|
19
28
|
export declare function isEntryPoint(argv1: string | undefined, moduleUrl: string): boolean;
|
|
20
29
|
/**
|
|
21
|
-
* True when the invocation is a pure `--help`/`--version` query
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* (
|
|
30
|
+
* True when the invocation is a pure `--help`/`--version` query, a bare
|
|
31
|
+
* `trawl` with no subcommand (commander prints top-level help and exits), or
|
|
32
|
+
* `trawl help [command]`. None of these should trigger the skills auto-sync
|
|
33
|
+
* (a filesystem-mutating startup side effect) — a user running `trawl
|
|
34
|
+
* --version` (or just `trawl`) never expects it to rewrite their skills
|
|
35
|
+
* dirs. (#73, extended #86 finding 7 for the bare-invocation + `help`
|
|
36
|
+
* subcommand cases)
|
|
25
37
|
*/
|
|
26
38
|
export declare function isHelpOrVersion(argv: string[]): boolean;
|
|
39
|
+
/** Best-effort scan for a `--json` flag in raw argv, used only when parsing
|
|
40
|
+
* itself failed before any command's own `.opts()` could be resolved (a
|
|
41
|
+
* commander usage error — unknown option/command, missing required arg). Same
|
|
42
|
+
* "argv scan, never trust flag values" caveat as isHelpOrVersion: positional
|
|
43
|
+
* values are never mistaken for `--json` since they don't equal the literal
|
|
44
|
+
* string. (#86 finding 3)
|
|
45
|
+
*
|
|
46
|
+
* #88 item 5 — only scans tokens BEFORE the first bare `--`. Commander treats
|
|
47
|
+
* `--` as "end of options": everything after it is a positional operand, not
|
|
48
|
+
* a flag, even if the literal text is `--json`. `scraps list -- --json`
|
|
49
|
+
* passes `--json` as an (excess) positional argument, not the flag — an
|
|
50
|
+
* unscoped `argv.includes('--json')` would still match it and wrongly emit a
|
|
51
|
+
* JSON envelope for what is actually a plain usage error with no --json
|
|
52
|
+
* requested at all.
|
|
53
|
+
*/
|
|
54
|
+
export declare function hasJsonFlag(argv: string[]): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Commander's default (no exitOverride) calls `process.exit()` directly for
|
|
57
|
+
* a usage error (unknown option/command, missing required arg) or a
|
|
58
|
+
* --help/--version/`help` query — bypassing runCli's try/catch/finally
|
|
59
|
+
* entirely, so the telemetry shutdown() flush below never runs and a usage
|
|
60
|
+
* error exits 1 (the generic bug bucket) instead of its own distinct code.
|
|
61
|
+
* `program.exitOverride()` on the root command alone does NOT fix this for
|
|
62
|
+
* subcommands added via `addCommand()` (login/scraps/skills/telemetry/token
|
|
63
|
+
* are each built as standalone Command instances in their own module and
|
|
64
|
+
* only ever copy inherited settings — including exitOverride — from a parent
|
|
65
|
+
* at `.command()` construction time, which for these root-level modules never
|
|
66
|
+
* happens). Every node in the tree needs its own exitOverride() call, so this
|
|
67
|
+
* walks the whole tree and installs it everywhere. (#86 finding 3)
|
|
68
|
+
*/
|
|
69
|
+
export declare function applyExitOverride(cmd: Command): void;
|
|
70
|
+
/**
|
|
71
|
+
* #88 item 6 — best-effort command-name recovery for a commander parse error
|
|
72
|
+
* that happened BEFORE any action ran, so `currentCommand` (the preAction
|
|
73
|
+
* hook's resolved command) is still undefined — e.g. an unknown option on an
|
|
74
|
+
* otherwise-valid subcommand, or excess arguments. Without this, EVERY such
|
|
75
|
+
* failure previously collapsed to resolveCommandName(undefined) === 'unknown'
|
|
76
|
+
* — and since 'unknown' was never itself allowlisted at the
|
|
77
|
+
* registerAllowedCommands call site (see runCli below), that capture was
|
|
78
|
+
* silently dropped by captureCommand's allowlist check: a dead branch that
|
|
79
|
+
* looked like it reported telemetry but never actually did.
|
|
80
|
+
*
|
|
81
|
+
* Matches ONLY a name already present in `allowedNames` (the real command
|
|
82
|
+
* tree, from collectCommandNames) — never invents one from raw argv text.
|
|
83
|
+
* Checks the two-token form first (`scraps boom`) since most usage errors
|
|
84
|
+
* happen on a nested leaf command; falls back to the single top-level token,
|
|
85
|
+
* then to 'unknown' (now itself allowlisted, so that capture fires too).
|
|
86
|
+
*/
|
|
87
|
+
export declare function bestEffortCommandName(argv: string[], allowedNames: readonly string[]): string;
|
|
27
88
|
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
|
/**
|
|
@@ -20,13 +19,29 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8
|
|
|
20
19
|
* --url) don't start with '-' and would otherwise survive an argv filter and
|
|
21
20
|
* leak to PostHog (#67). Falls back to 'unknown' when no command resolved
|
|
22
21
|
* (e.g. an error thrown before any action ran).
|
|
22
|
+
*
|
|
23
|
+
* #88 item 9 — walks the FULL parent chain, not just the immediate parent:
|
|
24
|
+
* the old one-level join (`${parent.name()} ${own.name()}`) resolved a
|
|
25
|
+
* 4-deep command like `scraps account session set` down to just "session
|
|
26
|
+
* set", silently dropping "scraps account". The root program node (the
|
|
27
|
+
* 'trawl' Command itself, which has no `.parent`) is excluded from the
|
|
28
|
+
* chain — matching the pre-existing convention that a direct child of the
|
|
29
|
+
* root (e.g. `scraps list`, `telemetry on`) is named relative to its
|
|
30
|
+
* immediate group, never prefixed with the program name.
|
|
23
31
|
*/
|
|
24
32
|
export function resolveCommandName(actionCommand) {
|
|
25
33
|
if (!actionCommand)
|
|
26
34
|
return 'unknown';
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
const chain = [];
|
|
36
|
+
let current = actionCommand;
|
|
37
|
+
while (current && current.parent) {
|
|
38
|
+
chain.unshift(current.name());
|
|
39
|
+
current = current.parent;
|
|
40
|
+
}
|
|
41
|
+
// `current` is now the root (no parent) — its name is excluded by design.
|
|
42
|
+
// If the chain came up empty, actionCommand itself IS the root (no parent
|
|
43
|
+
// at all) — fall back to its own name so the function never returns ''.
|
|
44
|
+
return chain.length > 0 ? chain.join(' ') : actionCommand.name();
|
|
30
45
|
}
|
|
31
46
|
/**
|
|
32
47
|
* Walk the full command tree and produce every valid resolveCommandName()
|
|
@@ -63,20 +78,112 @@ export function isEntryPoint(argv1, moduleUrl) {
|
|
|
63
78
|
return argv1 !== undefined && moduleUrl === pathToFileURL(argv1).href;
|
|
64
79
|
}
|
|
65
80
|
/**
|
|
66
|
-
* True when the invocation is a pure `--help`/`--version` query
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* (
|
|
81
|
+
* True when the invocation is a pure `--help`/`--version` query, a bare
|
|
82
|
+
* `trawl` with no subcommand (commander prints top-level help and exits), or
|
|
83
|
+
* `trawl help [command]`. None of these should trigger the skills auto-sync
|
|
84
|
+
* (a filesystem-mutating startup side effect) — a user running `trawl
|
|
85
|
+
* --version` (or just `trawl`) never expects it to rewrite their skills
|
|
86
|
+
* dirs. (#73, extended #86 finding 7 for the bare-invocation + `help`
|
|
87
|
+
* subcommand cases)
|
|
70
88
|
*/
|
|
71
89
|
export function isHelpOrVersion(argv) {
|
|
72
|
-
|
|
90
|
+
if (argv.some((a) => a === '-h' || a === '--help' || a === '-V' || a === '--version'))
|
|
91
|
+
return true;
|
|
92
|
+
const args = argv.slice(2);
|
|
93
|
+
if (args.length === 0)
|
|
94
|
+
return true;
|
|
95
|
+
if (args[0] === 'help')
|
|
96
|
+
return true;
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
/** Best-effort scan for a `--json` flag in raw argv, used only when parsing
|
|
100
|
+
* itself failed before any command's own `.opts()` could be resolved (a
|
|
101
|
+
* commander usage error — unknown option/command, missing required arg). Same
|
|
102
|
+
* "argv scan, never trust flag values" caveat as isHelpOrVersion: positional
|
|
103
|
+
* values are never mistaken for `--json` since they don't equal the literal
|
|
104
|
+
* string. (#86 finding 3)
|
|
105
|
+
*
|
|
106
|
+
* #88 item 5 — only scans tokens BEFORE the first bare `--`. Commander treats
|
|
107
|
+
* `--` as "end of options": everything after it is a positional operand, not
|
|
108
|
+
* a flag, even if the literal text is `--json`. `scraps list -- --json`
|
|
109
|
+
* passes `--json` as an (excess) positional argument, not the flag — an
|
|
110
|
+
* unscoped `argv.includes('--json')` would still match it and wrongly emit a
|
|
111
|
+
* JSON envelope for what is actually a plain usage error with no --json
|
|
112
|
+
* requested at all.
|
|
113
|
+
*/
|
|
114
|
+
export function hasJsonFlag(argv) {
|
|
115
|
+
const dashDashIdx = argv.indexOf('--');
|
|
116
|
+
const scanned = dashDashIdx === -1 ? argv : argv.slice(0, dashDashIdx);
|
|
117
|
+
return scanned.includes('--json');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Commander's default (no exitOverride) calls `process.exit()` directly for
|
|
121
|
+
* a usage error (unknown option/command, missing required arg) or a
|
|
122
|
+
* --help/--version/`help` query — bypassing runCli's try/catch/finally
|
|
123
|
+
* entirely, so the telemetry shutdown() flush below never runs and a usage
|
|
124
|
+
* error exits 1 (the generic bug bucket) instead of its own distinct code.
|
|
125
|
+
* `program.exitOverride()` on the root command alone does NOT fix this for
|
|
126
|
+
* subcommands added via `addCommand()` (login/scraps/skills/telemetry/token
|
|
127
|
+
* are each built as standalone Command instances in their own module and
|
|
128
|
+
* only ever copy inherited settings — including exitOverride — from a parent
|
|
129
|
+
* at `.command()` construction time, which for these root-level modules never
|
|
130
|
+
* happens). Every node in the tree needs its own exitOverride() call, so this
|
|
131
|
+
* walks the whole tree and installs it everywhere. (#86 finding 3)
|
|
132
|
+
*/
|
|
133
|
+
export function applyExitOverride(cmd) {
|
|
134
|
+
cmd.exitOverride();
|
|
135
|
+
for (const sub of cmd.commands)
|
|
136
|
+
applyExitOverride(sub);
|
|
137
|
+
}
|
|
138
|
+
/** Commander's own codes for a successful --help/--version/`help` query —
|
|
139
|
+
* these already printed their own output (to stdout) via commander itself;
|
|
140
|
+
* runCli's catch must treat them as a clean exit, not an error. (#86 finding 3) */
|
|
141
|
+
const HELP_OR_VERSION_CODES = new Set(['commander.helpDisplayed', 'commander.help', 'commander.version']);
|
|
142
|
+
/**
|
|
143
|
+
* #88 item 6 — best-effort command-name recovery for a commander parse error
|
|
144
|
+
* that happened BEFORE any action ran, so `currentCommand` (the preAction
|
|
145
|
+
* hook's resolved command) is still undefined — e.g. an unknown option on an
|
|
146
|
+
* otherwise-valid subcommand, or excess arguments. Without this, EVERY such
|
|
147
|
+
* failure previously collapsed to resolveCommandName(undefined) === 'unknown'
|
|
148
|
+
* — and since 'unknown' was never itself allowlisted at the
|
|
149
|
+
* registerAllowedCommands call site (see runCli below), that capture was
|
|
150
|
+
* silently dropped by captureCommand's allowlist check: a dead branch that
|
|
151
|
+
* looked like it reported telemetry but never actually did.
|
|
152
|
+
*
|
|
153
|
+
* Matches ONLY a name already present in `allowedNames` (the real command
|
|
154
|
+
* tree, from collectCommandNames) — never invents one from raw argv text.
|
|
155
|
+
* Checks the two-token form first (`scraps boom`) since most usage errors
|
|
156
|
+
* happen on a nested leaf command; falls back to the single top-level token,
|
|
157
|
+
* then to 'unknown' (now itself allowlisted, so that capture fires too).
|
|
158
|
+
*/
|
|
159
|
+
export function bestEffortCommandName(argv, allowedNames) {
|
|
160
|
+
const a2 = argv[2];
|
|
161
|
+
const a3 = argv[3];
|
|
162
|
+
if (a2 && a3) {
|
|
163
|
+
const combined = `${a2} ${a3}`;
|
|
164
|
+
if (allowedNames.includes(combined))
|
|
165
|
+
return combined;
|
|
166
|
+
}
|
|
167
|
+
if (a2 && allowedNames.includes(a2))
|
|
168
|
+
return a2;
|
|
169
|
+
return 'unknown';
|
|
73
170
|
}
|
|
74
171
|
export async function runCli(argv = process.argv) {
|
|
75
172
|
if (!isHelpOrVersion(argv))
|
|
76
173
|
autoUpdateInstalledSkills();
|
|
77
174
|
initPostHog();
|
|
78
175
|
const program = createProgram();
|
|
79
|
-
|
|
176
|
+
// Must run before parseAsync — installs on every node in the tree,
|
|
177
|
+
// including subcommands added via addCommand() that don't otherwise
|
|
178
|
+
// inherit it. (#86 finding 3)
|
|
179
|
+
applyExitOverride(program);
|
|
180
|
+
const commandNames = collectCommandNames(program);
|
|
181
|
+
// #88 item 6 — 'unknown' is a legitimate resolveCommandName() output (the
|
|
182
|
+
// fallback for "no command resolved at all"), not free-form user input —
|
|
183
|
+
// it must be explicitly allowlisted here or every capture that falls back
|
|
184
|
+
// to it is silently dropped by captureCommand's allowlist check (a dead
|
|
185
|
+
// branch that looks like it reports telemetry but never does).
|
|
186
|
+
registerAllowedCommands([...commandNames, 'unknown']);
|
|
80
187
|
// Track start times + the currently-resolved command per instance, so the
|
|
81
188
|
// catch handler below can derive the exact same safe name the success path
|
|
82
189
|
// uses — it must never re-derive anything from argv.
|
|
@@ -102,31 +209,68 @@ export async function runCli(argv = process.argv) {
|
|
|
102
209
|
await program.parseAsync(argv);
|
|
103
210
|
}
|
|
104
211
|
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
212
|
const { debug } = program.opts();
|
|
116
213
|
const isDebug = Boolean(debug || process.env['DEBUG']);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
214
|
+
if (err instanceof CommanderError) {
|
|
215
|
+
// Commander's own parse-time errors (exitOverride, #86 finding 3) —
|
|
216
|
+
// a distinct family from our ApiError/NetworkError/UsageError/generic
|
|
217
|
+
// Error taxonomy, so classifyError/reportError don't apply here.
|
|
218
|
+
if (HELP_OR_VERSION_CODES.has(err.code)) {
|
|
219
|
+
// --help / --version / `trawl help` already printed their own
|
|
220
|
+
// output via commander itself — nothing else to print, just adopt
|
|
221
|
+
// commander's suggested exit code (0) and fall through to the
|
|
222
|
+
// shared shutdown() flush below.
|
|
223
|
+
process.exitCode = err.exitCode;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// A usage error (unknown option/command, missing required arg, …) —
|
|
227
|
+
// commander already wrote its own human-readable line to stderr via
|
|
228
|
+
// Command#error(), so this never duplicates it. Force exit code 2
|
|
229
|
+
// (usage) regardless of whichever code commander suggests (it
|
|
230
|
+
// defaults these to 1), and add the --json machine envelope when
|
|
231
|
+
// resolvable — parsing failed before any command's own --json flag
|
|
232
|
+
// could be read off `currentCommand` (preAction never fired), so
|
|
233
|
+
// scan raw argv instead. (#86 finding 3)
|
|
234
|
+
if (isDebug)
|
|
235
|
+
console.error(err);
|
|
236
|
+
if (hasJsonFlag(argv)) {
|
|
237
|
+
console.log(JSON.stringify({ error: { message: err.message, kind: 'usage' } }));
|
|
238
|
+
}
|
|
239
|
+
process.exitCode = 2;
|
|
240
|
+
// #88 item 6 — currentCommand is still undefined here whenever the
|
|
241
|
+
// parse error happened before preAction fired (the common case for a
|
|
242
|
+
// usage error — commander validates flags/arity before invoking the
|
|
243
|
+
// action). Try a best-effort match against the real command tree
|
|
244
|
+
// instead of collapsing straight to 'unknown'.
|
|
245
|
+
const commandName = currentCommand
|
|
246
|
+
? resolveCommandName(currentCommand)
|
|
247
|
+
: bestEffortCommandName(argv, commandNames);
|
|
248
|
+
void captureCommand(commandName, { exit_code: 2, error: 'CommanderError' });
|
|
249
|
+
}
|
|
125
250
|
}
|
|
126
|
-
else
|
|
127
|
-
|
|
251
|
+
else {
|
|
252
|
+
// Map the error to a distinct exit code + machine envelope instead of a
|
|
253
|
+
// uniform 1 — agents driving this CLI unattended need to tell
|
|
254
|
+
// auth-expired (3) from not-found (4) from network-down (5) from a bad
|
|
255
|
+
// flag (2) apart from an arbitrary bug (1). (#71)
|
|
256
|
+
const { exitCode } = classifyError(err);
|
|
257
|
+
// Capture error telemetry from the resolved command only — never argv.
|
|
258
|
+
void captureCommand(resolveCommandName(currentCommand), {
|
|
259
|
+
exit_code: exitCode,
|
|
260
|
+
error: err.name,
|
|
261
|
+
});
|
|
262
|
+
// A --json subcommand must keep stdout pure JSON even on failure — read
|
|
263
|
+
// the resolved command's own --json flag (never argv) so the error
|
|
264
|
+
// envelope lands on the same channel the success path would have used.
|
|
265
|
+
const wantsJson = Boolean(currentCommand?.opts()?.json);
|
|
266
|
+
if (isDebug)
|
|
267
|
+
console.error(err);
|
|
268
|
+
// reportError is the single formatting path (#86 finding 9) — prints
|
|
269
|
+
// EITHER the --json envelope (stdout) OR the human "✗ message" line
|
|
270
|
+
// (stderr), never both; `quiet` skips the human line when the raw
|
|
271
|
+
// stack was already dumped above under --debug.
|
|
272
|
+
process.exitCode = reportError(err, { json: wantsJson, quiet: isDebug });
|
|
128
273
|
}
|
|
129
|
-
process.exitCode = exitCode;
|
|
130
274
|
}
|
|
131
275
|
finally {
|
|
132
276
|
// Flush + close telemetry before the process exits. A `process.on('exit')`
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -11,6 +11,32 @@ export declare class ApiError extends Error {
|
|
|
11
11
|
export declare class NetworkError extends Error {
|
|
12
12
|
constructor(message: string);
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* A LOCAL auth failure — no token available, or a locally-decoded token
|
|
16
|
+
* that's provably expired, discovered entirely client-side before any HTTP
|
|
17
|
+
* call was ever made. Distinguished from ApiError(401) (a real server-issued
|
|
18
|
+
* 401 response) so the --json envelope never claims `status:401` for
|
|
19
|
+
* something the server never said — that would be a fabricated fact,
|
|
20
|
+
* indistinguishable from an actual server round-trip to a machine consumer.
|
|
21
|
+
* Both classify to the same exit code (3) / kind "auth" in classifyError
|
|
22
|
+
* (errors.ts); only the envelope's `status` field differs (present for
|
|
23
|
+
* ApiError, absent here). (#88 item 4)
|
|
24
|
+
*/
|
|
25
|
+
export declare class AuthError extends Error {
|
|
26
|
+
constructor(message: string);
|
|
27
|
+
}
|
|
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 puts it on the exact same
|
|
34
|
+
* exit-3 / kind:"auth" path a real 401 response already takes — but as an
|
|
35
|
+
* AuthError (no HTTP call happened here), never a fabricated ApiError(401).
|
|
36
|
+
* `trawl token` (src/commands/token.ts) reuses this too, so "no token" means
|
|
37
|
+
* the same thing everywhere it can be observed. (#86 findings 1/2, #88 item 4)
|
|
38
|
+
*/
|
|
39
|
+
export declare function notLoggedInError(): AuthError;
|
|
14
40
|
export declare const api: {
|
|
15
41
|
get: <T>(path: string) => Promise<T>;
|
|
16
42
|
getText: (path: string) => Promise<string>;
|
package/dist/lib/api.js
CHANGED
|
@@ -25,6 +25,37 @@ export class NetworkError extends Error {
|
|
|
25
25
|
this.name = 'NetworkError';
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* A LOCAL auth failure — no token available, or a locally-decoded token
|
|
30
|
+
* that's provably expired, discovered entirely client-side before any HTTP
|
|
31
|
+
* call was ever made. Distinguished from ApiError(401) (a real server-issued
|
|
32
|
+
* 401 response) so the --json envelope never claims `status:401` for
|
|
33
|
+
* something the server never said — that would be a fabricated fact,
|
|
34
|
+
* indistinguishable from an actual server round-trip to a machine consumer.
|
|
35
|
+
* Both classify to the same exit code (3) / kind "auth" in classifyError
|
|
36
|
+
* (errors.ts); only the envelope's `status` field differs (present for
|
|
37
|
+
* ApiError, absent here). (#88 item 4)
|
|
38
|
+
*/
|
|
39
|
+
export class AuthError extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'AuthError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The single "no token available" error — every call site in this file that
|
|
47
|
+
* needs a token (request/upload/getText/stream) used to throw its own copy
|
|
48
|
+
* of `new Error('Not logged in. Run: trawl login')`, which fell through
|
|
49
|
+
* classifyError's generic branch (exit 1, kind:"unknown") — indistinguishable
|
|
50
|
+
* from an arbitrary bug. Auth-classifying it puts it on the exact same
|
|
51
|
+
* exit-3 / kind:"auth" path a real 401 response already takes — but as an
|
|
52
|
+
* AuthError (no HTTP call happened here), never a fabricated ApiError(401).
|
|
53
|
+
* `trawl token` (src/commands/token.ts) reuses this too, so "no token" means
|
|
54
|
+
* the same thing everywhere it can be observed. (#86 findings 1/2, #88 item 4)
|
|
55
|
+
*/
|
|
56
|
+
export function notLoggedInError() {
|
|
57
|
+
return new AuthError('Not logged in. Run: trawl login');
|
|
58
|
+
}
|
|
28
59
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
29
60
|
/** Effective fetch timeout — TRAWL_TIMEOUT env override (ms), default 30s. (#71) */
|
|
30
61
|
function getTimeoutMs() {
|
|
@@ -166,7 +197,7 @@ async function throwIfError(res, isPublic = false) {
|
|
|
166
197
|
async function request(path, options = {}) {
|
|
167
198
|
const token = getToken();
|
|
168
199
|
if (!token)
|
|
169
|
-
throw
|
|
200
|
+
throw notLoggedInError();
|
|
170
201
|
const url = `${getApiUrl()}${path}`;
|
|
171
202
|
const res = await safeFetch(url, {
|
|
172
203
|
...options,
|
|
@@ -197,7 +228,7 @@ async function request(path, options = {}) {
|
|
|
197
228
|
async function upload(path, formData) {
|
|
198
229
|
const token = getToken();
|
|
199
230
|
if (!token)
|
|
200
|
-
throw
|
|
231
|
+
throw notLoggedInError();
|
|
201
232
|
const url = `${getApiUrl()}${path}`;
|
|
202
233
|
// Do NOT set Content-Type — fetch sets it automatically with the correct multipart boundary
|
|
203
234
|
const res = await safeFetch(url, {
|
|
@@ -246,7 +277,7 @@ async function publicPost(path, body, baseUrlOverride) {
|
|
|
246
277
|
async function getText(path) {
|
|
247
278
|
const token = getToken();
|
|
248
279
|
if (!token)
|
|
249
|
-
throw
|
|
280
|
+
throw notLoggedInError();
|
|
250
281
|
const url = `${getApiUrl()}${path}`;
|
|
251
282
|
const res = await safeFetch(url, {
|
|
252
283
|
headers: {
|
|
@@ -275,7 +306,7 @@ export const api = {
|
|
|
275
306
|
stream: async function* (path) {
|
|
276
307
|
const token = getToken();
|
|
277
308
|
if (!token)
|
|
278
|
-
throw
|
|
309
|
+
throw notLoggedInError();
|
|
279
310
|
const url = `${getApiUrl()}${path}`;
|
|
280
311
|
// No AbortSignal.timeout here — a long-running `watch`/`--watch` stream is
|
|
281
312
|
// expected to sit open indefinitely; only connection-level failures
|
package/dist/lib/errors.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { ApiError, NetworkError } from './api.js';
|
|
2
|
+
import { ApiError, AuthError, NetworkError } from './api.js';
|
|
3
3
|
/**
|
|
4
4
|
* Thrown for CLI usage / input-validation failures (bad flag value, malformed
|
|
5
5
|
* JSON, invalid ObjectId, missing required prompt input, …). Distinguished
|
|
@@ -22,6 +22,14 @@ export class UsageError extends Error {
|
|
|
22
22
|
*/
|
|
23
23
|
export function classifyError(err) {
|
|
24
24
|
const message = err instanceof Error ? err.message : String(err);
|
|
25
|
+
// #88 item 4 — a LOCAL auth failure (no token, or a locally-decoded expired
|
|
26
|
+
// token) never made an HTTP call, so its envelope must never carry
|
|
27
|
+
// `status:401` — that would claim a server response that never happened.
|
|
28
|
+
// Same exit code / kind as a real server 401 (ApiError below); only the
|
|
29
|
+
// envelope shape differs.
|
|
30
|
+
if (err instanceof AuthError) {
|
|
31
|
+
return { exitCode: 3, envelope: { message, kind: 'auth' } };
|
|
32
|
+
}
|
|
25
33
|
if (err instanceof ApiError) {
|
|
26
34
|
if (err.status === 401)
|
|
27
35
|
return { exitCode: 3, envelope: { message, status: 401, kind: 'auth' } };
|
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,63 @@ 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. Read is wrapped:
|
|
106
|
+
// a weird `.version` (e.g. a directory instead of a file → EISDIR on
|
|
107
|
+
// readFileSync) must skip only THIS entry, not blow up the whole sweep —
|
|
108
|
+
// a single malformed install must never leave every other entry
|
|
109
|
+
// unswept. (#88 item 12)
|
|
110
|
+
let installedVersion;
|
|
111
|
+
try {
|
|
112
|
+
installedVersion = getInstalledVersion(name, scope);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (installedVersion === null)
|
|
118
|
+
continue;
|
|
119
|
+
rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
process.stderr.write(`trawl: removed orphaned skill "${name}" (${scope}) — no longer bundled with this CLI version\n`);
|
|
121
|
+
removed.push(name);
|
|
122
|
+
}
|
|
123
|
+
return removed;
|
|
124
|
+
}
|
|
56
125
|
/**
|
|
57
126
|
* Re-installs any CLI-owned skill whose installed version doesn't match the
|
|
58
|
-
* bundled one
|
|
59
|
-
*
|
|
60
|
-
*
|
|
127
|
+
* bundled one, and removes CLI-owned skills that are no longer bundled at all
|
|
128
|
+
* (renamed/dropped upstream — see removeOrphanedSkills). Called on CLI
|
|
129
|
+
* startup to keep skills in sync with the CLI version. Never throws —
|
|
130
|
+
* failures are silent so they don't break unrelated commands.
|
|
61
131
|
*
|
|
62
132
|
* Ownership guard (#73): `installSkill` does `rmSync(recursive)` on the target
|
|
63
133
|
* dir, so this MUST only ever touch dirs the CLI itself installed. Proof of
|
|
@@ -89,6 +159,11 @@ export function autoUpdateInstalledSkills() {
|
|
|
89
159
|
process.stderr.write(`trawl: re-synced skill "${name}" (${scope}) ${installed} → ${bundledVersion}\n`);
|
|
90
160
|
}
|
|
91
161
|
}
|
|
162
|
+
// Migration gap (#86 review): also drop marker-owned dirs whose skill
|
|
163
|
+
// was renamed/removed upstream, or they linger as stale ghosts forever.
|
|
164
|
+
for (const scope of ['user', 'local']) {
|
|
165
|
+
removeOrphanedSkills(scope);
|
|
166
|
+
}
|
|
92
167
|
}
|
|
93
168
|
catch {
|
|
94
169
|
// 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.3",
|
|
4
4
|
"description": "Trawl CLI — manage scraps from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"url": "https://github.com/comes-io/trawl_cli/issues"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@trawlme/skills": "
|
|
43
|
+
"@trawlme/skills": "1.3.2",
|
|
44
44
|
"chalk": "^5.6.2",
|
|
45
45
|
"commander": "^14.0.3",
|
|
46
46
|
"conf": "^15.1.0",
|