@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/commands/scraps.js
CHANGED
|
@@ -5,25 +5,30 @@ import { api } from '../lib/api.js';
|
|
|
5
5
|
import { table, json } from '../lib/format.js';
|
|
6
6
|
import { promptPassword } from '../lib/prompt.js';
|
|
7
7
|
import { validateObjectId } from '../lib/validate.js';
|
|
8
|
-
import { classifyError } from '../lib/errors.js';
|
|
8
|
+
import { classifyError, reportError, UsageError } from '../lib/errors.js';
|
|
9
9
|
import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
|
|
10
10
|
/**
|
|
11
|
-
* Print a usage/validation error consistently: human text to stderr
|
|
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];
|
|
26
|
-
if (!last
|
|
23
|
+
if (!last)
|
|
24
|
+
return 'never';
|
|
25
|
+
// status:null means a run is IN FLIGHT (node persists {status:null,
|
|
26
|
+
// statusDetail:null, inFlight:true} the moment a run starts) — that is NOT
|
|
27
|
+
// the same thing as "this scrap has never run" (no history row at all).
|
|
28
|
+
// (#88 item 1)
|
|
29
|
+
if (last.status === null)
|
|
30
|
+
return 'running';
|
|
31
|
+
if (last.status === undefined)
|
|
27
32
|
return 'never';
|
|
28
33
|
return last.status === true ? 'success' : 'failure';
|
|
29
34
|
}
|
|
@@ -44,6 +49,8 @@ function statusIcon(status) {
|
|
|
44
49
|
return chalk.green('✓');
|
|
45
50
|
if (status === 'failure')
|
|
46
51
|
return chalk.red('✗');
|
|
52
|
+
if (status === 'running')
|
|
53
|
+
return chalk.cyan('↻');
|
|
47
54
|
return chalk.dim('—');
|
|
48
55
|
}
|
|
49
56
|
export const scraps = new Command('scraps').description('Manage scraps');
|
|
@@ -70,33 +77,55 @@ scraps
|
|
|
70
77
|
.alias('ls')
|
|
71
78
|
.description('List all scraps')
|
|
72
79
|
.option('--json', 'Output as JSON')
|
|
73
|
-
.option('--status <status>', 'Filter by last run status (success|failure|never)')
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
.
|
|
80
|
+
.option('--status <status>', 'Filter by last run status (success|failure|never|running)')
|
|
81
|
+
// #88 item 8 — no custom parser here (unlike the old `(v) => parseInt(v,
|
|
82
|
+
// 10)`): a bad value like "abc" used to silently become NaN, which then
|
|
83
|
+
// sailed straight through `Number.isInteger`-less checks and into
|
|
84
|
+
// `.slice(0, NaN)` (silently truncates to 0 rows) or `?page=NaN` (silently
|
|
85
|
+
// sent to the server) — never a usage error. Keeping the raw string here
|
|
86
|
+
// lets the validation below mirror `history`'s own --limit check exactly
|
|
87
|
+
// (~line 660) and report the actual bad input in the error message.
|
|
88
|
+
.option('--limit <n>', 'Show only the first N results')
|
|
89
|
+
.option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)')
|
|
90
|
+
.action(async (opts, cmd) => {
|
|
77
91
|
// Guard: --limit and --page are mutually exclusive
|
|
78
92
|
if (opts.limit !== undefined && opts.page !== undefined) {
|
|
79
93
|
usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
|
|
80
94
|
return;
|
|
81
95
|
}
|
|
96
|
+
let limit;
|
|
97
|
+
if (opts.limit !== undefined) {
|
|
98
|
+
limit = Number(opts.limit);
|
|
99
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
100
|
+
usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let page;
|
|
105
|
+
if (opts.page !== undefined) {
|
|
106
|
+
page = Number(opts.page);
|
|
107
|
+
if (!Number.isInteger(page) || page <= 0) {
|
|
108
|
+
usageError(`Invalid --page "${opts.page}" (expected a positive integer)`, { json: opts.json });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
82
112
|
let data;
|
|
83
113
|
try {
|
|
84
114
|
data = await oraPromise(async () => {
|
|
85
|
-
if (
|
|
115
|
+
if (page !== undefined) {
|
|
86
116
|
// Single-page mode: explicit page requested, no loop
|
|
87
|
-
|
|
88
|
-
return api.get(`/api/scraps?perPage=50&page=${pageNum}`);
|
|
117
|
+
return api.get(`/api/scraps?perPage=50&page=${page}`);
|
|
89
118
|
}
|
|
90
119
|
// Fetch-all mode: paginate until a page returns < 200 items
|
|
91
120
|
const perPage = 200;
|
|
92
121
|
let result = [];
|
|
93
|
-
let
|
|
122
|
+
let pageNum = 1;
|
|
94
123
|
while (true) {
|
|
95
|
-
const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${
|
|
124
|
+
const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${pageNum}`);
|
|
96
125
|
result = result.concat(batch);
|
|
97
126
|
if (batch.length < perPage)
|
|
98
127
|
break;
|
|
99
|
-
|
|
128
|
+
pageNum++;
|
|
100
129
|
}
|
|
101
130
|
return result;
|
|
102
131
|
}, 'Fetching scraps…');
|
|
@@ -105,12 +134,22 @@ scraps
|
|
|
105
134
|
// Spinner already failed by oraPromise — report with the scrap-specific
|
|
106
135
|
// prefix kept, but route through the shared classifier so exit code +
|
|
107
136
|
// --json envelope stay consistent with every other command. (#71)
|
|
137
|
+
//
|
|
138
|
+
// This bespoke catch (kept for the "Failed to fetch scraps:" prefix,
|
|
139
|
+
// which the shared reportError() can't add) used to silently swallow
|
|
140
|
+
// --debug: unlike the central index.ts catch, it never printed the raw
|
|
141
|
+
// stack trace. optsWithGlobals() reads --debug off the ROOT command
|
|
142
|
+
// (this leaf has no --debug of its own) so it can honor the flag
|
|
143
|
+
// locally instead. (#86 finding 9)
|
|
144
|
+
const isDebug = Boolean(cmd.optsWithGlobals().debug || process.env['DEBUG']);
|
|
108
145
|
const { exitCode, envelope } = classifyError(err);
|
|
109
146
|
const message = `Failed to fetch scraps: ${envelope.message}`;
|
|
147
|
+
if (isDebug)
|
|
148
|
+
console.error(err);
|
|
110
149
|
if (opts.json) {
|
|
111
150
|
console.log(JSON.stringify({ error: { ...envelope, message } }));
|
|
112
151
|
}
|
|
113
|
-
else {
|
|
152
|
+
else if (!isDebug) {
|
|
114
153
|
console.error(chalk.red(`✗ ${message}`));
|
|
115
154
|
}
|
|
116
155
|
process.exitCode = exitCode;
|
|
@@ -119,7 +158,6 @@ scraps
|
|
|
119
158
|
if (opts.status)
|
|
120
159
|
data = data.filter((s) => lastStatus(s) === opts.status);
|
|
121
160
|
const totalMatched = data.length;
|
|
122
|
-
const limit = opts.limit;
|
|
123
161
|
const rows = limit !== undefined ? data.slice(0, limit) : data;
|
|
124
162
|
if (opts.json)
|
|
125
163
|
return json(rows);
|
|
@@ -155,6 +193,75 @@ scraps
|
|
|
155
193
|
console.log(chalk.dim(` Updated: `) + new Date(data.updatedAt).toLocaleString());
|
|
156
194
|
});
|
|
157
195
|
const VALID_TIERS = ['tier0', 'tier1', 'tier2', 'tier3', 'tier4'];
|
|
196
|
+
/**
|
|
197
|
+
* #86 findings 4/5 — shared honest-tier renderer for `create --tier` and
|
|
198
|
+
* `update --tier/--force-tier`. Reads the typed `_tierOverride` echoed back
|
|
199
|
+
* by the server (#1559) so both commands show the SAME truth: a refusal, an
|
|
200
|
+
* allowed ceiling raise (+ spend warning), or a silently clamped proxyTier.
|
|
201
|
+
* Human-mode output only — a --json caller gets the same truth for free from
|
|
202
|
+
* the full scrap object (which already includes `_tierOverride`).
|
|
203
|
+
*/
|
|
204
|
+
function renderTierOverrideHuman(data) {
|
|
205
|
+
const ov = data._tierOverride;
|
|
206
|
+
if (!ov)
|
|
207
|
+
return;
|
|
208
|
+
if (ov.refused) {
|
|
209
|
+
console.error(chalk.red(` ✗ tier ceiling override refused: ${ov.reason ?? 'unknown'}`)
|
|
210
|
+
+ chalk.dim(` (requested ${ov.requestedMaxTier ?? '—'}; kept the registry cap)`));
|
|
211
|
+
}
|
|
212
|
+
else if (ov.effectiveMaxTier) {
|
|
213
|
+
console.log(chalk.green(` ✓ tier ceiling: ${ov.effectiveMaxTier}`)
|
|
214
|
+
+ chalk.dim(` (${ov.reason ?? ''}${ov.provider ? `, ${ov.provider}` : ''})`));
|
|
215
|
+
if (ov.warning)
|
|
216
|
+
console.log(chalk.yellow(` ⚠ ${ov.warning}`));
|
|
217
|
+
}
|
|
218
|
+
if (ov.proxyTier) {
|
|
219
|
+
if (ov.proxyTier.clamped) {
|
|
220
|
+
console.log(chalk.yellow(` ⚠ proxyTier requested ${ov.proxyTier.requested} → applied ${ov.proxyTier.effective}`)
|
|
221
|
+
+ chalk.dim(` (${ov.proxyTier.reason ?? 'capped'})`));
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
console.log(chalk.dim(` proxyTier: `) + ov.proxyTier.effective);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* #86 finding 4b — old-server fallback. When a tier was requested but the
|
|
230
|
+
* response carries no `_tierOverride` at all, the server is too old to
|
|
231
|
+
* confirm what actually got applied. Echoing the REQUESTED value as if it
|
|
232
|
+
* were the outcome is exactly the silent-clamp lie #1559 fixed — warn
|
|
233
|
+
* instead, on stderr (safe under --json too; stdout purity is untouched).
|
|
234
|
+
*/
|
|
235
|
+
function warnIfUnconfirmedTier(data, tierWasRequested, id) {
|
|
236
|
+
if (data._tierOverride || !tierWasRequested)
|
|
237
|
+
return;
|
|
238
|
+
console.error(chalk.yellow(` ⚠ Server did not confirm the tier change (older server) — verify with: trawl scraps get ${id}`));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* #88 item 3 — the --json machine-readable counterpart to
|
|
242
|
+
* warnIfUnconfirmedTier's stderr warning above. A --json caller (an agent
|
|
243
|
+
* scripting this CLI) has no reliable reason to read stderr — that channel
|
|
244
|
+
* is advisory-only everywhere else in this CLI, and stdout must stay the
|
|
245
|
+
* sole payload. Without this, the ONLY signal that the server never
|
|
246
|
+
* confirmed the tier change was a string on stderr, invisible to any --json
|
|
247
|
+
* consumer parsing stdout alone. Adds `_tierUnconfirmed: true` to the
|
|
248
|
+
* emitted object under EXACTLY the same condition warnIfUnconfirmedTier
|
|
249
|
+
* warns on (tier requested, response carries no `_tierOverride`) — never
|
|
250
|
+
* fabricated, never present otherwise.
|
|
251
|
+
*/
|
|
252
|
+
function withTierUnconfirmed(data, tierWasRequested) {
|
|
253
|
+
if (data._tierOverride || !tierWasRequested)
|
|
254
|
+
return data;
|
|
255
|
+
return { ...data, _tierUnconfirmed: true };
|
|
256
|
+
}
|
|
257
|
+
/** #86 finding 5 — the standard error envelope for a refused tier override,
|
|
258
|
+
* routed through the same reportError() central formatting path used
|
|
259
|
+
* everywhere else (exit 1: a business-logic refusal, not a usage error). */
|
|
260
|
+
function reportTierRefusal(data, wantsJson) {
|
|
261
|
+
const ov = data._tierOverride;
|
|
262
|
+
const message = `Tier ceiling override refused: ${ov?.reason ?? 'unknown'} (requested ${ov?.requestedMaxTier ?? '—'}; kept the registry cap)`;
|
|
263
|
+
return reportError(new Error(message), { json: wantsJson });
|
|
264
|
+
}
|
|
158
265
|
// create
|
|
159
266
|
scraps
|
|
160
267
|
.command('create')
|
|
@@ -164,9 +271,10 @@ scraps
|
|
|
164
271
|
.option('-r, --request <request>', 'Request/query')
|
|
165
272
|
.option('-d, --description <text>', 'Scrap description')
|
|
166
273
|
.option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
|
|
274
|
+
.option('--json', 'Output as JSON')
|
|
167
275
|
.action(async (opts) => {
|
|
168
276
|
if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
|
|
169
|
-
usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})
|
|
277
|
+
usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
|
|
170
278
|
return;
|
|
171
279
|
}
|
|
172
280
|
const data = await oraPromise(() => api.post('/api/scraps', {
|
|
@@ -176,7 +284,24 @@ scraps
|
|
|
176
284
|
...(opts.description !== undefined && { description: opts.description }),
|
|
177
285
|
...(opts.tier !== undefined && { proxyTier: opts.tier }),
|
|
178
286
|
}), { text: 'Creating scrap…', successText: (d) => `Scrap created: ${chalk.bold(d._id)}` });
|
|
287
|
+
// #86 finding 4a — read _tierOverride back from the POST response and
|
|
288
|
+
// render it exactly like `update` does; never echo the requested tier as
|
|
289
|
+
// if it were applied when an older server doesn't confirm it.
|
|
290
|
+
const tierWasRequested = opts.tier !== undefined;
|
|
291
|
+
warnIfUnconfirmedTier(data, tierWasRequested, data._id);
|
|
292
|
+
const refused = Boolean(data._tierOverride?.refused);
|
|
293
|
+
if (opts.json) {
|
|
294
|
+
if (refused) {
|
|
295
|
+
process.exitCode = reportTierRefusal(data, true);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
json(withTierUnconfirmed(data, tierWasRequested));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
179
301
|
console.log(chalk.dim(` Title: ${data.title}`));
|
|
302
|
+
renderTierOverrideHuman(data);
|
|
303
|
+
if (refused)
|
|
304
|
+
process.exitCode = 1;
|
|
180
305
|
});
|
|
181
306
|
// update
|
|
182
307
|
scraps
|
|
@@ -196,14 +321,15 @@ scraps
|
|
|
196
321
|
.option('--params-file <path>', 'Runtime params from a JSON file')
|
|
197
322
|
.option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
|
|
198
323
|
.option('--force-tier <tier>', `Raise the proxy-tier ceiling PAST the auto-cap (${VALID_TIERS.join('|')}) — history-gated: may be refused or cost more`)
|
|
324
|
+
.option('--json', 'Output as JSON')
|
|
199
325
|
.action(async (id, opts) => {
|
|
200
326
|
validateObjectId(id);
|
|
201
327
|
if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
|
|
202
|
-
usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})
|
|
328
|
+
usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
|
|
203
329
|
return;
|
|
204
330
|
}
|
|
205
331
|
if (opts.forceTier !== undefined && !VALID_TIERS.includes(opts.forceTier)) {
|
|
206
|
-
usageError(`Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})
|
|
332
|
+
usageError(`Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
|
|
207
333
|
return;
|
|
208
334
|
}
|
|
209
335
|
const body = {};
|
|
@@ -241,11 +367,11 @@ scraps
|
|
|
241
367
|
parsed = JSON.parse(raw);
|
|
242
368
|
}
|
|
243
369
|
catch (e) {
|
|
244
|
-
usageError(`Invalid JSON for --params: ${e.message}
|
|
370
|
+
usageError(`Invalid JSON for --params: ${e.message}`, { json: opts.json });
|
|
245
371
|
return;
|
|
246
372
|
}
|
|
247
373
|
if (!Array.isArray(parsed)) {
|
|
248
|
-
usageError('--params must be a JSON array of objects');
|
|
374
|
+
usageError('--params must be a JSON array of objects', { json: opts.json });
|
|
249
375
|
return;
|
|
250
376
|
}
|
|
251
377
|
body.params = parsed;
|
|
@@ -259,6 +385,10 @@ scraps
|
|
|
259
385
|
body.proxyTier = opts.forceTier;
|
|
260
386
|
}
|
|
261
387
|
if (Object.keys(body).length === 0) {
|
|
388
|
+
if (opts.json) {
|
|
389
|
+
process.exitCode = reportError(new UsageError('Nothing to update. Provide at least one option.'), { json: true });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
262
392
|
console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
|
|
263
393
|
return;
|
|
264
394
|
}
|
|
@@ -266,42 +396,35 @@ scraps
|
|
|
266
396
|
text: 'Updating scrap…',
|
|
267
397
|
successText: (d) => `Scrap updated: ${chalk.bold(d._id)}`,
|
|
268
398
|
});
|
|
269
|
-
// #1559 — surface the effective tier + clamp/refuse
|
|
270
|
-
// silent-clamp: the server may persist a lower tier
|
|
271
|
-
|
|
399
|
+
// #1559 / #86 findings 4b/5 — surface the effective tier + clamp/refuse
|
|
400
|
+
// reason (fixes the silent-clamp: the server may persist a lower tier
|
|
401
|
+
// than requested), and NEVER echo the requested value as applied when
|
|
402
|
+
// the server doesn't confirm it (old-server fallback below).
|
|
403
|
+
const tierWasRequested = opts.tier !== undefined || opts.forceTier !== undefined;
|
|
404
|
+
warnIfUnconfirmedTier(data, tierWasRequested, id);
|
|
405
|
+
const refused = Boolean(data._tierOverride?.refused);
|
|
406
|
+
if (opts.json) {
|
|
407
|
+
if (refused) {
|
|
408
|
+
process.exitCode = reportTierRefusal(data, true);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
json(withTierUnconfirmed(data, tierWasRequested));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
272
414
|
const shown = data;
|
|
273
415
|
for (const key of Object.keys(body)) {
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
if (
|
|
416
|
+
// Tier keys are rendered exclusively by renderTierOverrideHuman /
|
|
417
|
+
// warnIfUnconfirmedTier above — never echo them here, whether or not
|
|
418
|
+
// _tierOverride came back (an old-server echo of the REQUESTED value
|
|
419
|
+
// is exactly the silent-clamp lie #1559 fixed).
|
|
420
|
+
if (key === 'proxyTier' || key === 'proxyMaxTier')
|
|
279
421
|
continue;
|
|
280
422
|
const src = key in shown ? shown[key] : body[key];
|
|
281
423
|
console.log(chalk.dim(` ${key}: `) + String(src ?? '—'));
|
|
282
424
|
}
|
|
283
|
-
|
|
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
|
-
}
|
|
425
|
+
renderTierOverrideHuman(data);
|
|
426
|
+
if (refused)
|
|
427
|
+
process.exitCode = 1;
|
|
305
428
|
});
|
|
306
429
|
// run
|
|
307
430
|
scraps
|
|
@@ -332,6 +455,25 @@ function renderScrapItems(items, asJson) {
|
|
|
332
455
|
}
|
|
333
456
|
console.log(chalk.dim(' Use --json for full output.'));
|
|
334
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* #86 finding 6 — `data`'s honest empty-vs-error distinction, for both --json
|
|
460
|
+
* and human prose. Before this, EVERY non-array outcome (never run, last run
|
|
461
|
+
* failed, or the payload aged out of retention) collapsed to the SAME `[]` /
|
|
462
|
+
* "No data yet." — indistinguishable from a genuine zero-item successful run.
|
|
463
|
+
* That's a lie under --json: an agent can't tell "nothing to show" from "go
|
|
464
|
+
* look at what actually happened". Mirrors reportError's dual json/human
|
|
465
|
+
* shape (human line to stderr always; --json ALSO gets a machine envelope on
|
|
466
|
+
* stdout) with a caller-chosen exit code + kind, since these states are not
|
|
467
|
+
* all "usage" (2) — never-run / aged-out-of-retention are not_found (4), a
|
|
468
|
+
* failed last run is a business-logic failure (1).
|
|
469
|
+
*/
|
|
470
|
+
function reportDataState(message, exitCode, kind, wantsJson) {
|
|
471
|
+
console.error(chalk.red(`✗ ${message}`));
|
|
472
|
+
if (wantsJson) {
|
|
473
|
+
console.log(JSON.stringify({ error: { message, kind } }));
|
|
474
|
+
}
|
|
475
|
+
process.exitCode = exitCode;
|
|
476
|
+
}
|
|
335
477
|
// data
|
|
336
478
|
scraps
|
|
337
479
|
.command('data <id>')
|
|
@@ -346,8 +488,12 @@ scraps
|
|
|
346
488
|
if (opts.errors) {
|
|
347
489
|
const result = await fetchRunAndFix(id);
|
|
348
490
|
if (!result) {
|
|
491
|
+
// #88 item 7 — unified no-runs shape with `doctor --json`: a bare
|
|
492
|
+
// `null` was indistinguishable from any other absent-payload state
|
|
493
|
+
// (a scrap CAN legitimately have a null-ish result elsewhere); an
|
|
494
|
+
// explicit `{status:"no_runs"}` object is unambiguous everywhere.
|
|
349
495
|
if (opts.json) {
|
|
350
|
-
json(
|
|
496
|
+
json({ status: 'no_runs' });
|
|
351
497
|
return;
|
|
352
498
|
}
|
|
353
499
|
console.log(chalk.dim('No runs yet.'));
|
|
@@ -397,17 +543,48 @@ scraps
|
|
|
397
543
|
// it's just pulled from the most recent history row instead of a fresh
|
|
398
544
|
// run. Retention keeps this only for the newest row per (scrap, status)
|
|
399
545
|
// bucket (config.trawl.keepData, default 1); older rows null it out.
|
|
546
|
+
//
|
|
547
|
+
// #86 finding 6 — [] is reserved for a GENUINE zero-item successful run.
|
|
548
|
+
// Every other outcome below is an honest error envelope instead: never
|
|
549
|
+
// run (not_found/4), last run failed (1), or the payload aged out of
|
|
550
|
+
// retention (not_found/4) all used to collapse into the same silent [].
|
|
400
551
|
const scrap = await api.get(`/api/scraps/${id}`);
|
|
401
|
-
const
|
|
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.'));
|
|
552
|
+
const last = scrap.history?.[0];
|
|
553
|
+
if (!last?._id) {
|
|
554
|
+
reportDataState(`Scrap ${id} has never run. Run it first (trawl scraps run ${id}) or pass --fresh to launch one now.`, 4, 'not_found', opts.json);
|
|
408
555
|
return;
|
|
409
556
|
}
|
|
410
|
-
|
|
557
|
+
// #88 item 1 — status:null is an IN-FLIGHT run (node persists
|
|
558
|
+
// {status:null, statusDetail:null, inFlight:true} the moment a run
|
|
559
|
+
// starts, and only flips status/statusDetail once it finishes). That is
|
|
560
|
+
// neither "never run" nor "the last run failed" — a caller reading data
|
|
561
|
+
// mid-run needs an honest "wait" signal. Never suggest --fresh here: a
|
|
562
|
+
// run already holds the server-side distributed lock, so --fresh would
|
|
563
|
+
// just 429 against it.
|
|
564
|
+
if (last.status === null) {
|
|
565
|
+
reportDataState(`Run in progress for ${id} — retry shortly.`, 1, 'in_progress', opts.json);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
// #86 review — node persists status=false for a GENUINE zero-item run
|
|
569
|
+
// too (historys schema: status boolean|null + statusDetail
|
|
570
|
+
// success/error/empty/regression; a zero-item run is status=false +
|
|
571
|
+
// statusDetail='empty', and the embedded history rows from GET
|
|
572
|
+
// /api/scraps/:id include statusDetail via the repository populate
|
|
573
|
+
// select). An 'empty' run is the one case [] is FOR — only a real
|
|
574
|
+
// failure (error/unknown detail) gets the run_failed envelope.
|
|
575
|
+
//
|
|
576
|
+
// #88 item 2 — statusDetail='regression' is ALSO status=false (an async
|
|
577
|
+
// patch flips it after item count dropped vs baseline), but the row's
|
|
578
|
+
// `data` still holds REAL, non-empty items — the write that persisted
|
|
579
|
+
// them succeeded before the regression was even detected. Treating it as
|
|
580
|
+
// run_failed would hide genuine data behind a false negative.
|
|
581
|
+
const isEmptyRun = last.status === false && last.statusDetail === 'empty';
|
|
582
|
+
const isRegression = last.status === false && last.statusDetail === 'regression';
|
|
583
|
+
if (last.status === false && !isEmptyRun && !isRegression) {
|
|
584
|
+
reportDataState(`Last run failed — see: trawl scraps data ${id} --errors`, 1, 'run_failed', opts.json);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const detail = await api.get(`/api/historys/${last._id}`);
|
|
411
588
|
let items;
|
|
412
589
|
if (typeof detail?.data === 'string' && detail.data) {
|
|
413
590
|
try {
|
|
@@ -418,14 +595,27 @@ scraps
|
|
|
418
595
|
}
|
|
419
596
|
}
|
|
420
597
|
if (!Array.isArray(items)) {
|
|
421
|
-
if (
|
|
422
|
-
|
|
598
|
+
if (isEmptyRun) {
|
|
599
|
+
// A genuine zero-item run whose payload is '[]' or absent — both are
|
|
600
|
+
// the SAME honest answer: no items, exit 0. Never the retention
|
|
601
|
+
// message (nothing aged out; there was nothing to persist).
|
|
602
|
+
renderScrapItems([], opts.json);
|
|
423
603
|
return;
|
|
424
604
|
}
|
|
425
|
-
|
|
426
|
-
|
|
605
|
+
// #88 item 2 — a regression row whose payload aged out of retention has
|
|
606
|
+
// nothing left to show either; fall through to the SAME honest
|
|
607
|
+
// aged-out envelope a normal successful row would get (never fabricate
|
|
608
|
+
// items, never silently succeed).
|
|
609
|
+
reportDataState(`No persisted data for the last run of ${id} — it aged out of retention. Pass --fresh to launch a new run.`, 4, 'not_found', opts.json);
|
|
427
610
|
return;
|
|
428
611
|
}
|
|
612
|
+
// #88 item 2 — a regression row's items are REAL (the write succeeded
|
|
613
|
+
// before the async patch flagged the drop) — return them on stdout
|
|
614
|
+
// (exit 0, both modes) with an honest stderr warning pointing at the
|
|
615
|
+
// diagnostic command, instead of hiding genuine data behind run_failed.
|
|
616
|
+
if (isRegression) {
|
|
617
|
+
console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
|
|
618
|
+
}
|
|
429
619
|
renderScrapItems(items, opts.json);
|
|
430
620
|
});
|
|
431
621
|
// history — list past runs for a scrap
|
|
@@ -708,8 +898,9 @@ account
|
|
|
708
898
|
const data = await oraPromise(() => api.get(`/api/scraps/${id}`), 'Fetching scrap…');
|
|
709
899
|
const acc = data.account;
|
|
710
900
|
if (opts.json) {
|
|
711
|
-
|
|
712
|
-
|
|
901
|
+
// #86 finding 12 — `json` is already statically imported at the top of
|
|
902
|
+
// this file; the dynamic import here was pure dead weight.
|
|
903
|
+
return json(acc ?? null);
|
|
713
904
|
}
|
|
714
905
|
if (!acc) {
|
|
715
906
|
console.log(chalk.dim('No account data available.'));
|
|
@@ -770,14 +961,19 @@ scraps
|
|
|
770
961
|
validateObjectId(id);
|
|
771
962
|
const result = await fetchRunAndFix(id);
|
|
772
963
|
if (!result) {
|
|
964
|
+
// #88 item 7 — unified no-runs shape with `doctor --json` / `data
|
|
965
|
+
// --errors --json`: a never-run scrap is a distinct, nameable state,
|
|
966
|
+
// not the same bare `null` a run-with-no-fix-attempt returns below.
|
|
773
967
|
if (opts.json) {
|
|
774
|
-
json(
|
|
968
|
+
json({ status: 'no_runs' });
|
|
775
969
|
return;
|
|
776
970
|
}
|
|
777
971
|
console.log(chalk.dim('No runs yet.'));
|
|
778
972
|
return;
|
|
779
973
|
}
|
|
780
974
|
if (!result.fix) {
|
|
975
|
+
// A run DID happen, it just had no autofix attempt — genuinely "no
|
|
976
|
+
// data", unlike the never-run case above.
|
|
781
977
|
if (opts.json) {
|
|
782
978
|
json(null);
|
|
783
979
|
return;
|
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,30 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import
|
|
3
|
+
import { getToken } from '../lib/config.js';
|
|
4
|
+
import { AuthError, notLoggedInError } from '../lib/api.js';
|
|
5
|
+
import { reportError } from '../lib/errors.js';
|
|
4
6
|
import { decodeExp } from '../lib/jwt.js';
|
|
5
7
|
export const token = new Command('token')
|
|
6
8
|
.description('Print the stored session JWT (for MCP Bearer auth)')
|
|
7
9
|
.action(() => {
|
|
8
|
-
|
|
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
|
-
|
|
24
|
+
// Decoded entirely client-side (no HTTP call made) — AuthError, not a
|
|
25
|
+
// fabricated ApiError(401): the server never actually said this. (#88
|
|
26
|
+
// item 4)
|
|
27
|
+
process.exitCode = reportError(new AuthError('Session token expired. Run: trawl login to refresh.'));
|
|
19
28
|
return;
|
|
20
29
|
}
|
|
21
30
|
// Print the raw token first (so it can be piped / copied)
|