@trawlme/cli 3.7.0 → 3.7.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 +4 -4
- package/dist/commands/create.js +5 -1
- package/dist/commands/scraps.js +25 -3
- package/dist/index.d.ts +18 -0
- package/dist/index.js +66 -17
- package/dist/lib/api.js +13 -8
- package/dist/lib/errors.d.ts +12 -0
- package/dist/lib/errors.js +14 -0
- package/dist/lib/json.d.ts +37 -0
- package/dist/lib/json.js +104 -0
- package/docs/agent-quickstart.md +4 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,14 +48,15 @@ All commands accept a global `--debug` flag to show full error stack traces on f
|
|
|
48
48
|
```
|
|
49
49
|
trawl create <url> --prompt <goal> [--no-autofix] [--json] # url also accepted as --url <url> (#116)
|
|
50
50
|
Create a persistent, self-healing scrap from a URL + a goal (AI-generated)
|
|
51
|
-
trawl run <id> [--watch] [--json] Run a scrap (blocks until finished)
|
|
52
51
|
trawl list|ls [--json] [--status <success|failure|never|running|regression>] [--limit <n>] [--page <n>]
|
|
53
52
|
trawl get <id> [--json] Get scrap details
|
|
53
|
+
trawl run <id> [--watch] [--json] Run a scrap (blocks until finished)
|
|
54
|
+
trawl trigger <id> [--watch] [--wait] [--json] Launch a scrap as a background worker (returns immediately)
|
|
54
55
|
trawl data <id> [--json] [--fresh] [--errors] Get scrap data (last persisted run — read-only, no quota; --fresh to launch one)
|
|
55
56
|
trawl history <id> [--json] [-n <limit>] List past runs for a scrap (newest first)
|
|
56
57
|
trawl run-info <hid> [--json] Show details of a single run
|
|
57
|
-
trawl trigger <id> [--watch] [--wait] [--json] Launch a scrap as a background worker (returns immediately)
|
|
58
58
|
trawl whoami [--json] Show the authenticated user's identity
|
|
59
|
+
trawl token [--json] Print the stored session JWT (for MCP Bearer auth)
|
|
59
60
|
trawl ping [--json] Health/version handshake against the Trawl API
|
|
60
61
|
```
|
|
61
62
|
|
|
@@ -131,10 +132,9 @@ You can also install skills standalone (without the CLI): `npx @trawlme/skills i
|
|
|
131
132
|
```
|
|
132
133
|
trawl login [--url <url>] [--token <jwt>] [--email <email>] [--password <pass>] [--json]
|
|
133
134
|
trawl logout [--json]
|
|
134
|
-
trawl token [--json] Print the stored session JWT (for MCP Bearer auth)
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
`login --json` prints `{"ok":true,"apiUrl","config",email?}` on success — never the raw token (that's `token`'s job). `token --json` prints `{"token","exp","expiresAt"}` instead of the bare JWT + stderr advisories. See [Non-interactive rule](#non-interactive-rule) below: a missing `--email`/`--password` refuses immediately (usage error, exit 2) instead of prompting when `--json` is set or stdin/stdout isn't a real TTY.
|
|
137
|
+
`login --json` prints `{"ok":true,"apiUrl","config",email?}` on success — never the raw token (that's `trawl token`'s job, in the [Core commands](#core-commands-agent--human) section above — non-interactive/agent-consumable, unlike `login`/`logout`). `token --json` prints `{"token","exp","expiresAt"}` instead of the bare JWT + stderr advisories. See [Non-interactive rule](#non-interactive-rule) below: a missing `--email`/`--password` refuses immediately (usage error, exit 2) instead of prompting when `--json` is set or stdin/stdout isn't a real TTY.
|
|
138
138
|
|
|
139
139
|
### Telemetry
|
|
140
140
|
|
package/dist/commands/create.js
CHANGED
|
@@ -3,6 +3,7 @@ import chalk from 'chalk';
|
|
|
3
3
|
import { spin } from '../lib/spinner.js';
|
|
4
4
|
import { api, LONG_RUN_TIMEOUT_MS, NetworkError } from '../lib/api.js';
|
|
5
5
|
import { json } from '../lib/format.js';
|
|
6
|
+
import { parseServerJson } from '../lib/json.js';
|
|
6
7
|
import { requireUrl, requireString } from '../lib/validate.js';
|
|
7
8
|
import { UsageError } from '../lib/errors.js';
|
|
8
9
|
import { renderPinch, pinchEnabled } from '../lib/pinch.js';
|
|
@@ -67,7 +68,10 @@ async function printDataSample(historyId) {
|
|
|
67
68
|
const detail = await api.get(`/api/historys/${historyId}`);
|
|
68
69
|
if (typeof detail?.data !== 'string' || !detail.data)
|
|
69
70
|
return;
|
|
70
|
-
|
|
71
|
+
// #159 — same JSON-string-within-JSON shape as scrap.history[0].data;
|
|
72
|
+
// tolerate the documented raw-control-char server quirk here too instead
|
|
73
|
+
// of silently losing the sample to the outer try/catch below.
|
|
74
|
+
const items = parseServerJson(detail.data)?.data;
|
|
71
75
|
if (!Array.isArray(items) || items.length === 0)
|
|
72
76
|
return;
|
|
73
77
|
console.log(chalk.dim(` Sample: `) + `${items.length} item${items.length === 1 ? '' : 's'}`);
|
package/dist/commands/scraps.js
CHANGED
|
@@ -3,6 +3,7 @@ import chalk from 'chalk';
|
|
|
3
3
|
import { spin } from '../lib/spinner.js';
|
|
4
4
|
import { api, LONG_RUN_TIMEOUT_MS } from '../lib/api.js';
|
|
5
5
|
import { table, json, formatDate } from '../lib/format.js';
|
|
6
|
+
import { parseServerJson } from '../lib/json.js';
|
|
6
7
|
import { promptPassword } from '../lib/prompt.js';
|
|
7
8
|
import { validateObjectId } from '../lib/validate.js';
|
|
8
9
|
import { classifyError, reportError, UsageError, RefusalError } from '../lib/errors.js';
|
|
@@ -69,7 +70,11 @@ async function watchActivities(id, asJson) {
|
|
|
69
70
|
console.log(chalk.dim('Streaming activities (Ctrl+C to stop)…\n'));
|
|
70
71
|
for await (const event of api.stream(`/api/scraps/${id}/activities/stream`)) {
|
|
71
72
|
try {
|
|
72
|
-
|
|
73
|
+
// #159 — same tolerant parse as api.ts's response-parsing seam: an SSE
|
|
74
|
+
// activity line can carry the same server-side raw-control-character
|
|
75
|
+
// quirk as any other API payload, and this feeds `--json` NDJSON
|
|
76
|
+
// output too (not just create).
|
|
77
|
+
const activity = parseServerJson(event);
|
|
73
78
|
if (asJson) {
|
|
74
79
|
console.log(JSON.stringify(activity));
|
|
75
80
|
continue;
|
|
@@ -340,6 +345,10 @@ export async function pollRunProgress(id, before, opts = {}) {
|
|
|
340
345
|
}
|
|
341
346
|
process.exitCode = 1;
|
|
342
347
|
}
|
|
348
|
+
// #149 item 3 — same shared-enum shape as VALID_TIERS below (~line 603):
|
|
349
|
+
// keeps `--status`'s allowed values and its error message in one place,
|
|
350
|
+
// mirrored from lastStatus()'s own return type so the two can never drift.
|
|
351
|
+
const VALID_LAST_STATUSES = ['success', 'failure', 'never', 'running', 'regression'];
|
|
343
352
|
// list — promoted to a top-level verb (#108, see the AttachOptions comment above)
|
|
344
353
|
export function attachListCommand(parent, attachOpts = {}) {
|
|
345
354
|
return parent
|
|
@@ -358,6 +367,14 @@ export function attachListCommand(parent, attachOpts = {}) {
|
|
|
358
367
|
.option('--limit <n>', 'Show only the first N results')
|
|
359
368
|
.option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)')
|
|
360
369
|
.action(async (opts, cmd) => {
|
|
370
|
+
// #149 item 3 — validate --status against its enum the same way
|
|
371
|
+
// --tier already validates (usageError + return, checked first, before
|
|
372
|
+
// any other guard/async work — mirrors `create`/`update`'s --tier
|
|
373
|
+
// check below).
|
|
374
|
+
if (opts.status !== undefined && !VALID_LAST_STATUSES.includes(opts.status)) {
|
|
375
|
+
usageError(`Invalid --status "${opts.status}" (allowed: ${VALID_LAST_STATUSES.join(', ')})`, { json: opts.json });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
361
378
|
// Guard: --limit and --page are mutually exclusive
|
|
362
379
|
if (opts.limit !== undefined && opts.page !== undefined) {
|
|
363
380
|
usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
|
|
@@ -927,7 +944,12 @@ export function attachDataCommand(parent, attachOpts = {}) {
|
|
|
927
944
|
let items;
|
|
928
945
|
if (typeof detail?.data === 'string' && detail.data) {
|
|
929
946
|
try {
|
|
930
|
-
|
|
947
|
+
// #159 — `detail.data` is the same JSON-string-within-JSON shape
|
|
948
|
+
// (`history.data`) the create --json bug lived in: a raw control
|
|
949
|
+
// character here would otherwise throw and silently report "aged
|
|
950
|
+
// out of retention" below for data that is actually present and
|
|
951
|
+
// recoverable, burning a --fresh re-run + quota for nothing.
|
|
952
|
+
items = parseServerJson(detail.data)?.data;
|
|
931
953
|
}
|
|
932
954
|
catch {
|
|
933
955
|
items = undefined;
|
|
@@ -1118,7 +1140,7 @@ export function attachTriggerCommand(parent, attachOpts = {}) {
|
|
|
1118
1140
|
.command('trigger <id>', attachOpts)
|
|
1119
1141
|
.description('Launch a scrap as a background worker (returns immediately)')
|
|
1120
1142
|
.option('-w, --watch', 'Poll for progress after triggering (#91 — the default async run happens in a separate cron pod; activities SSE never reaches it)')
|
|
1121
|
-
.option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
|
|
1143
|
+
.option('--wait', 'Run synchronously and wait for the result (legacy behaviour — prefer `trawl run` instead)')
|
|
1122
1144
|
.option('--json', 'Output the raw trigger payload as JSON')
|
|
1123
1145
|
.action(async (id, opts) => {
|
|
1124
1146
|
validateObjectId(id);
|
package/dist/index.d.ts
CHANGED
|
@@ -95,6 +95,24 @@ export declare function hasJsonFlag(argv: string[]): boolean;
|
|
|
95
95
|
* walks the whole tree and installs it everywhere. (#86 finding 3)
|
|
96
96
|
*/
|
|
97
97
|
export declare function applyExitOverride(cmd: Command): void;
|
|
98
|
+
/**
|
|
99
|
+
* #149 item 1 — commander's own usage-error line (unknown option, missing
|
|
100
|
+
* required arg, unknown command, …) is written via `Command#error()` ->
|
|
101
|
+
* `outputError()` straight to `process.stderr` BEFORE the exitOverride throw
|
|
102
|
+
* below is ever caught — in commander's own plain, uncolored `"error: …"`
|
|
103
|
+
* format, a visibly different convention from every other error this CLI
|
|
104
|
+
* prints (`reportError`'s `chalk.red('✗ ' + message)`). `configureOutput`'s
|
|
105
|
+
* `outputError` hook is exactly the one commander documents for reformatting
|
|
106
|
+
* the error text itself (`writeErr` also carries unrelated help output, e.g.
|
|
107
|
+
* `showHelpAfterError` — never enabled in this CLI, but out of scope to
|
|
108
|
+
* touch here). Overriding it on every node in the tree (same walk shape as
|
|
109
|
+
* applyExitOverride, for the same reason: addCommand()-attached subtrees
|
|
110
|
+
* don't otherwise inherit a parent's configureOutput) reformats that one line
|
|
111
|
+
* through the same red ✗ convention, stripping the redundant "error: " prefix
|
|
112
|
+
* first (stripCommanderErrorPrefix — the same prefix also leaks verbatim
|
|
113
|
+
* into the --json envelope's `message` field, see runCli's catch below).
|
|
114
|
+
*/
|
|
115
|
+
export declare function applyOutputConfiguration(cmd: Command): void;
|
|
98
116
|
/**
|
|
99
117
|
* #88 item 6 — best-effort command-name recovery for a commander parse error
|
|
100
118
|
* that happened BEFORE any action ran, so `currentCommand` (the preAction
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, CommanderError } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
3
4
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
@@ -14,7 +15,7 @@ import { whoami } from './commands/whoami.js';
|
|
|
14
15
|
import { ping } from './commands/ping.js';
|
|
15
16
|
import { autoUpdateInstalledSkills } from './lib/skills.js';
|
|
16
17
|
import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
|
|
17
|
-
import { classifyError, reportError } from './lib/errors.js';
|
|
18
|
+
import { classifyError, reportError, stripCommanderErrorPrefix } from './lib/errors.js';
|
|
18
19
|
import { renderPinch, pinchEnabled } from './lib/pinch.js';
|
|
19
20
|
import { maybeNotifyUpdate, scheduleUpdateCheck } from './lib/updateNotifier.js';
|
|
20
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -91,26 +92,39 @@ export function createProgram() {
|
|
|
91
92
|
.description('Trawl CLI — manage scraps from the terminal')
|
|
92
93
|
.version(pkg.version)
|
|
93
94
|
.option('--debug', 'Show full error stack traces');
|
|
94
|
-
// Core verbs (#108) — promoted/listed first
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
95
|
+
// Core verbs (#108) — promoted/listed first, registration order now
|
|
96
|
+
// follows the guided flow a new user (or agent) actually walks: create it
|
|
97
|
+
// → list/get to see it → run/trigger to execute it → data to read the
|
|
98
|
+
// result → history/run-info for deeper diagnostics → whoami/token/ping as
|
|
99
|
+
// standalone identity/credential/health utilities (#149 item 6 — the
|
|
100
|
+
// pre-#149 order had `run` wedged between `create` and `list`, and
|
|
101
|
+
// `trigger` stranded after `run-info`, both ahead of ever having listed or
|
|
102
|
+
// inspected anything). `list`/`get`/`run`/`data`/`history`/`run-info`/
|
|
103
|
+
// `trigger` are built via scraps.ts's exported attachXCommand() factories —
|
|
104
|
+
// the SAME definition also stays wired (hidden) under `scraps` there, so
|
|
105
|
+
// every pre-#108 `trawl scraps <verb>` invocation keeps resolving (no
|
|
106
|
+
// breaking change).
|
|
100
107
|
//
|
|
101
108
|
// #114 — `create` replaced `fetch` in this slot: `POST /api/ai/wizard`
|
|
102
109
|
// (AI-generate + persist + first-run + autofix), a distinct command from
|
|
103
110
|
// the still-untouched `trawl scraps create` (raw-script management verb).
|
|
111
|
+
//
|
|
112
|
+
// #149 item 5 — `token` promoted from Management into Core: it satisfies
|
|
113
|
+
// every criterion the CORE_GROUP heading itself claims (agent + human,
|
|
114
|
+
// `--json` first-class, never interactive) at least as well as `whoami`/
|
|
115
|
+
// `ping` do, and exists specifically to feed an agent's MCP Bearer auth
|
|
116
|
+
// header (`$(trawl token)`) — a Management/human-CI bucket undersells that.
|
|
104
117
|
program.commandsGroup(CORE_GROUP);
|
|
105
118
|
program.addCommand(create);
|
|
106
|
-
attachRunCommand(program);
|
|
107
119
|
attachListCommand(program);
|
|
108
120
|
attachGetCommand(program);
|
|
121
|
+
attachRunCommand(program);
|
|
122
|
+
attachTriggerCommand(program);
|
|
109
123
|
attachDataCommand(program);
|
|
110
124
|
attachHistoryCommand(program);
|
|
111
125
|
attachRunInfoCommand(program);
|
|
112
|
-
attachTriggerCommand(program);
|
|
113
126
|
program.addCommand(whoami);
|
|
127
|
+
program.addCommand(token);
|
|
114
128
|
program.addCommand(ping);
|
|
115
129
|
// Management (#108) — human/CI surface, grouped below. `scraps` still
|
|
116
130
|
// holds every pre-#108 management command (create/update/delete/banner/
|
|
@@ -120,7 +134,6 @@ export function createProgram() {
|
|
|
120
134
|
program.addCommand(skills);
|
|
121
135
|
program.addCommand(login);
|
|
122
136
|
program.addCommand(logout);
|
|
123
|
-
program.addCommand(token);
|
|
124
137
|
program.addCommand(telemetry);
|
|
125
138
|
program.addCommand(upgrade);
|
|
126
139
|
return program;
|
|
@@ -216,6 +229,33 @@ export function applyExitOverride(cmd) {
|
|
|
216
229
|
for (const sub of cmd.commands)
|
|
217
230
|
applyExitOverride(sub);
|
|
218
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* #149 item 1 — commander's own usage-error line (unknown option, missing
|
|
234
|
+
* required arg, unknown command, …) is written via `Command#error()` ->
|
|
235
|
+
* `outputError()` straight to `process.stderr` BEFORE the exitOverride throw
|
|
236
|
+
* below is ever caught — in commander's own plain, uncolored `"error: …"`
|
|
237
|
+
* format, a visibly different convention from every other error this CLI
|
|
238
|
+
* prints (`reportError`'s `chalk.red('✗ ' + message)`). `configureOutput`'s
|
|
239
|
+
* `outputError` hook is exactly the one commander documents for reformatting
|
|
240
|
+
* the error text itself (`writeErr` also carries unrelated help output, e.g.
|
|
241
|
+
* `showHelpAfterError` — never enabled in this CLI, but out of scope to
|
|
242
|
+
* touch here). Overriding it on every node in the tree (same walk shape as
|
|
243
|
+
* applyExitOverride, for the same reason: addCommand()-attached subtrees
|
|
244
|
+
* don't otherwise inherit a parent's configureOutput) reformats that one line
|
|
245
|
+
* through the same red ✗ convention, stripping the redundant "error: " prefix
|
|
246
|
+
* first (stripCommanderErrorPrefix — the same prefix also leaks verbatim
|
|
247
|
+
* into the --json envelope's `message` field, see runCli's catch below).
|
|
248
|
+
*/
|
|
249
|
+
export function applyOutputConfiguration(cmd) {
|
|
250
|
+
cmd.configureOutput({
|
|
251
|
+
outputError: (str, write) => {
|
|
252
|
+
const withoutNewline = str.endsWith('\n') ? str.slice(0, -1) : str;
|
|
253
|
+
write(chalk.red('✗ ' + stripCommanderErrorPrefix(withoutNewline)) + '\n');
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
for (const sub of cmd.commands)
|
|
257
|
+
applyOutputConfiguration(sub);
|
|
258
|
+
}
|
|
219
259
|
/** Commander's own codes for a successful --help/--version/`help` query —
|
|
220
260
|
* these already printed their own output (to stdout) via commander itself;
|
|
221
261
|
* runCli's catch must treat them as a clean exit, not an error. (#86 finding 3) */
|
|
@@ -264,6 +304,9 @@ export async function runCli(argv = process.argv) {
|
|
|
264
304
|
// including subcommands added via addCommand() that don't otherwise
|
|
265
305
|
// inherit it. (#86 finding 3)
|
|
266
306
|
applyExitOverride(program);
|
|
307
|
+
// Same tree-walk requirement as applyExitOverride, same reason — installs
|
|
308
|
+
// the unified red ✗ error formatting on every node. (#149 item 1)
|
|
309
|
+
applyOutputConfiguration(program);
|
|
267
310
|
const commandNames = collectCommandNames(program);
|
|
268
311
|
// #88 item 6 — 'unknown' is a legitimate resolveCommandName() output (the
|
|
269
312
|
// fallback for "no command resolved at all"), not free-form user input —
|
|
@@ -334,16 +377,22 @@ export async function runCli(argv = process.argv) {
|
|
|
334
377
|
else {
|
|
335
378
|
// A usage error (unknown option/command, missing required arg, …) —
|
|
336
379
|
// commander already wrote its own human-readable line to stderr via
|
|
337
|
-
// Command#error()
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
380
|
+
// Command#error() (now reformatted through the same red ✗ convention
|
|
381
|
+
// by applyOutputConfiguration, #149 item 1), so this never duplicates
|
|
382
|
+
// it. Force exit code 2 (usage) regardless of whichever code
|
|
383
|
+
// commander suggests (it defaults these to 1), and add the --json
|
|
384
|
+
// machine envelope when resolvable — parsing failed before any
|
|
385
|
+
// command's own --json flag could be read off `currentCommand`
|
|
386
|
+
// (preAction never fired), so scan raw argv instead. (#86 finding 3)
|
|
343
387
|
if (isDebug)
|
|
344
388
|
console.error(err);
|
|
345
389
|
if (hasJsonFlag(argv)) {
|
|
346
|
-
|
|
390
|
+
// #149 item 2 — err.message still carries commander's own literal
|
|
391
|
+
// "error: " prefix (set on the CommanderError itself, independent
|
|
392
|
+
// of the outputError override above, which only reformats what
|
|
393
|
+
// gets WRITTEN to stderr) — strip it so the envelope's `message`
|
|
394
|
+
// stays clean for a machine parser.
|
|
395
|
+
console.log(JSON.stringify({ error: { message: stripCommanderErrorPrefix(err.message), kind: 'usage' } }));
|
|
347
396
|
}
|
|
348
397
|
process.exitCode = 2;
|
|
349
398
|
// #88 item 6 — currentCommand is still undefined here whenever the
|
package/dist/lib/api.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { getApiUrl, getToken } from './config.js';
|
|
5
|
+
import { parseServerJson } from './json.js';
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
|
|
7
8
|
const USER_AGENT = `@trawlme/cli/${pkg.version}`;
|
|
@@ -135,13 +136,17 @@ function extractErrorMessage(raw, statusText) {
|
|
|
135
136
|
if (!raw)
|
|
136
137
|
return '';
|
|
137
138
|
try {
|
|
138
|
-
|
|
139
|
+
// #159 — an error envelope's message/description/nested `error` string can
|
|
140
|
+
// carry the same raw-control-char server quirk as a success payload;
|
|
141
|
+
// recover it here too instead of falling all the way back to the raw,
|
|
142
|
+
// unparsed blob as the "error message".
|
|
143
|
+
const parsed = parseServerJson(raw);
|
|
139
144
|
if (parsed && typeof parsed === 'object') {
|
|
140
145
|
const env = parsed;
|
|
141
146
|
const nested = typeof env.error === 'string' ? env.error : null;
|
|
142
147
|
if (nested) {
|
|
143
148
|
try {
|
|
144
|
-
const inner =
|
|
149
|
+
const inner = parseServerJson(nested);
|
|
145
150
|
const details = inner.details;
|
|
146
151
|
if (details && typeof details.message === 'string')
|
|
147
152
|
return details.message;
|
|
@@ -178,7 +183,7 @@ function extractErrorMessage(raw, statusText) {
|
|
|
178
183
|
*/
|
|
179
184
|
function extractUpgradeUrl(raw) {
|
|
180
185
|
try {
|
|
181
|
-
const parsed =
|
|
186
|
+
const parsed = parseServerJson(raw);
|
|
182
187
|
if (typeof parsed.upgradeUrl === 'string')
|
|
183
188
|
return parsed.upgradeUrl;
|
|
184
189
|
const details = parsed.details;
|
|
@@ -187,7 +192,7 @@ function extractUpgradeUrl(raw) {
|
|
|
187
192
|
}
|
|
188
193
|
if (typeof parsed.error === 'string') {
|
|
189
194
|
try {
|
|
190
|
-
const inner =
|
|
195
|
+
const inner = parseServerJson(parsed.error);
|
|
191
196
|
if (typeof inner.upgradeUrl === 'string')
|
|
192
197
|
return inner.upgradeUrl;
|
|
193
198
|
const innerDetails = inner.details;
|
|
@@ -252,7 +257,7 @@ async function request(path, options = {}, reqOpts = {}) {
|
|
|
252
257
|
try {
|
|
253
258
|
if (!text)
|
|
254
259
|
return {};
|
|
255
|
-
const parsed =
|
|
260
|
+
const parsed = parseServerJson(text);
|
|
256
261
|
// Unwrap API envelope { type, message, data: T }
|
|
257
262
|
if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
|
|
258
263
|
return parsed.data;
|
|
@@ -284,7 +289,7 @@ async function upload(path, formData, reqOpts = {}) {
|
|
|
284
289
|
try {
|
|
285
290
|
if (!text)
|
|
286
291
|
return {};
|
|
287
|
-
const parsed =
|
|
292
|
+
const parsed = parseServerJson(text);
|
|
288
293
|
// Unwrap API envelope { type, message, data: T }
|
|
289
294
|
if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
|
|
290
295
|
return parsed.data;
|
|
@@ -307,7 +312,7 @@ async function publicPost(path, body, baseUrlOverride, reqOpts = {}) {
|
|
|
307
312
|
await throwIfError(res, true);
|
|
308
313
|
const text = await res.text();
|
|
309
314
|
try {
|
|
310
|
-
const data = text ?
|
|
315
|
+
const data = text ? parseServerJson(text) : {};
|
|
311
316
|
return { data, headers: res.headers };
|
|
312
317
|
}
|
|
313
318
|
catch {
|
|
@@ -344,7 +349,7 @@ async function publicGet(path, reqOpts = {}) {
|
|
|
344
349
|
try {
|
|
345
350
|
if (!text)
|
|
346
351
|
return {};
|
|
347
|
-
const parsed =
|
|
352
|
+
const parsed = parseServerJson(text);
|
|
348
353
|
// Unwrap API envelope { type, message, data: T }
|
|
349
354
|
if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
|
|
350
355
|
return parsed.data;
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -21,6 +21,18 @@ export declare class UsageError extends Error {
|
|
|
21
21
|
export declare class RefusalError extends Error {
|
|
22
22
|
constructor(message: string);
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Commander prefixes every one of its own usage-error messages with the
|
|
26
|
+
* literal `"error: "` (see `missingArgument`/`unknownOption`/`unknownCommand`
|
|
27
|
+
* etc. in commander's `command.js`) — harmless for its own plain default
|
|
28
|
+
* line, but redundant once this CLI reuses that message itself: (1) baked
|
|
29
|
+
* into a `--json` envelope's `message` field, a human-facing "error: " prefix
|
|
30
|
+
* is dead weight for a machine parser that already reads `kind:"usage"`; (2)
|
|
31
|
+
* reformatted through the app's own `chalk.red('✗ ' + message)` convention it
|
|
32
|
+
* would double up as "✗ error: unknown option …". Idempotent — a message
|
|
33
|
+
* that never had the prefix passes through unchanged. (#149 items 1/2)
|
|
34
|
+
*/
|
|
35
|
+
export declare function stripCommanderErrorPrefix(message: string): string;
|
|
24
36
|
export interface ErrorEnvelope {
|
|
25
37
|
message: string;
|
|
26
38
|
status?: number;
|
package/dist/lib/errors.js
CHANGED
|
@@ -29,6 +29,20 @@ export class RefusalError extends Error {
|
|
|
29
29
|
this.name = 'RefusalError';
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Commander prefixes every one of its own usage-error messages with the
|
|
34
|
+
* literal `"error: "` (see `missingArgument`/`unknownOption`/`unknownCommand`
|
|
35
|
+
* etc. in commander's `command.js`) — harmless for its own plain default
|
|
36
|
+
* line, but redundant once this CLI reuses that message itself: (1) baked
|
|
37
|
+
* into a `--json` envelope's `message` field, a human-facing "error: " prefix
|
|
38
|
+
* is dead weight for a machine parser that already reads `kind:"usage"`; (2)
|
|
39
|
+
* reformatted through the app's own `chalk.red('✗ ' + message)` convention it
|
|
40
|
+
* would double up as "✗ error: unknown option …". Idempotent — a message
|
|
41
|
+
* that never had the prefix passes through unchanged. (#149 items 1/2)
|
|
42
|
+
*/
|
|
43
|
+
export function stripCommanderErrorPrefix(message) {
|
|
44
|
+
return message.startsWith('error: ') ? message.slice('error: '.length) : message;
|
|
45
|
+
}
|
|
32
46
|
/**
|
|
33
47
|
* Central status → exit-code map (#71 findings 13/14/60). Agents driving this
|
|
34
48
|
* CLI unattended need to tell "you're not logged in" (3) from "that id
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, tolerant JSON parsing for server response bodies (#159).
|
|
3
|
+
*
|
|
4
|
+
* The Trawl API is known to occasionally emit a raw, unescaped control
|
|
5
|
+
* character (U+0000–U+001F — a literal newline, NUL, BEL, …) inside a JSON
|
|
6
|
+
* string value, most often nested inside `scrap.history[0].data` (a
|
|
7
|
+
* worker-envelope blob embedded as a string field of the outer response —
|
|
8
|
+
* the exact server-side emission point is not yet pinned down; tracked in
|
|
9
|
+
* comes-io/trawl_node#1768). That is invalid per RFC 8259 — our own QA
|
|
10
|
+
* tooling already works around it by parsing raw API responses with
|
|
11
|
+
* Python's `json.loads(text, strict=False)` — but native `JSON.parse`
|
|
12
|
+
* has no equivalent lenient mode: it throws outright, and until this fix
|
|
13
|
+
* every one of `api.ts`'s response-parsing call sites (`request`, `upload`,
|
|
14
|
+
* `publicGet`, `publicPost`) turned that into a bare "Invalid JSON in server
|
|
15
|
+
* response" error, discarding the ENTIRE response — even a successful
|
|
16
|
+
* create/run whose scrap was already persisted server-side. Exactly the
|
|
17
|
+
* "payload-dependent, not a constant break" shape reported in #159: it only
|
|
18
|
+
* fires when the specific scraped content/log text happens to carry a raw
|
|
19
|
+
* control byte.
|
|
20
|
+
*
|
|
21
|
+
* `parseServerJson` recovers from that ONE known shape — nothing else. It
|
|
22
|
+
* tries a normal strict `JSON.parse` first (the common case, zero extra
|
|
23
|
+
* cost) and only falls back to a sanitizing re-parse when that throws.
|
|
24
|
+
* Genuinely malformed JSON (truncated body, stray token, …) still throws
|
|
25
|
+
* after the fallback also fails — this is a targeted recovery, not a
|
|
26
|
+
* general-purpose lenient parser.
|
|
27
|
+
*/
|
|
28
|
+
export declare function escapeRawControlCharsInJsonStrings(text: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Parse a server response body, tolerating the known #159 quirk above. Never
|
|
31
|
+
* silently drops data: a byte that reaches here as a real control character
|
|
32
|
+
* comes back out as the same character (via the escape → JSON.parse
|
|
33
|
+
* unescape round trip) once parsed, and JSON.stringify guarantees it is
|
|
34
|
+
* re-escaped correctly on the way back out through the shared `--json`
|
|
35
|
+
* output seam (`lib/format.ts#json`).
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseServerJson(text: string): unknown;
|
package/dist/lib/json.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, tolerant JSON parsing for server response bodies (#159).
|
|
3
|
+
*
|
|
4
|
+
* The Trawl API is known to occasionally emit a raw, unescaped control
|
|
5
|
+
* character (U+0000–U+001F — a literal newline, NUL, BEL, …) inside a JSON
|
|
6
|
+
* string value, most often nested inside `scrap.history[0].data` (a
|
|
7
|
+
* worker-envelope blob embedded as a string field of the outer response —
|
|
8
|
+
* the exact server-side emission point is not yet pinned down; tracked in
|
|
9
|
+
* comes-io/trawl_node#1768). That is invalid per RFC 8259 — our own QA
|
|
10
|
+
* tooling already works around it by parsing raw API responses with
|
|
11
|
+
* Python's `json.loads(text, strict=False)` — but native `JSON.parse`
|
|
12
|
+
* has no equivalent lenient mode: it throws outright, and until this fix
|
|
13
|
+
* every one of `api.ts`'s response-parsing call sites (`request`, `upload`,
|
|
14
|
+
* `publicGet`, `publicPost`) turned that into a bare "Invalid JSON in server
|
|
15
|
+
* response" error, discarding the ENTIRE response — even a successful
|
|
16
|
+
* create/run whose scrap was already persisted server-side. Exactly the
|
|
17
|
+
* "payload-dependent, not a constant break" shape reported in #159: it only
|
|
18
|
+
* fires when the specific scraped content/log text happens to carry a raw
|
|
19
|
+
* control byte.
|
|
20
|
+
*
|
|
21
|
+
* `parseServerJson` recovers from that ONE known shape — nothing else. It
|
|
22
|
+
* tries a normal strict `JSON.parse` first (the common case, zero extra
|
|
23
|
+
* cost) and only falls back to a sanitizing re-parse when that throws.
|
|
24
|
+
* Genuinely malformed JSON (truncated body, stray token, …) still throws
|
|
25
|
+
* after the fallback also fails — this is a targeted recovery, not a
|
|
26
|
+
* general-purpose lenient parser.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Escape any raw control character (U+0000–U+001F) found INSIDE a JSON
|
|
30
|
+
* string literal, leaving everything else — including legitimate raw
|
|
31
|
+
* whitespace between tokens (space/tab/CR/LF are valid there per RFC 8259) —
|
|
32
|
+
* untouched. A small state machine tracks whether the scan is currently
|
|
33
|
+
* inside a `"…"` string and whether the previous character was an unescaped
|
|
34
|
+
* backslash; it does not otherwise validate structure, so text that is
|
|
35
|
+
* invalid JSON for any OTHER reason still fails the subsequent `JSON.parse`
|
|
36
|
+
* call unchanged.
|
|
37
|
+
*/
|
|
38
|
+
const NAMED_CONTROL_CHAR_ESCAPES = {
|
|
39
|
+
'\n': '\\n',
|
|
40
|
+
'\r': '\\r',
|
|
41
|
+
'\t': '\\t',
|
|
42
|
+
'\b': '\\b',
|
|
43
|
+
'\f': '\\f',
|
|
44
|
+
};
|
|
45
|
+
export function escapeRawControlCharsInJsonStrings(text) {
|
|
46
|
+
let out = '';
|
|
47
|
+
let inString = false;
|
|
48
|
+
let escaped = false;
|
|
49
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
50
|
+
const ch = text[i];
|
|
51
|
+
const code = text.charCodeAt(i);
|
|
52
|
+
if (!inString) {
|
|
53
|
+
if (ch === '"')
|
|
54
|
+
inString = true;
|
|
55
|
+
out += ch;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (escaped) {
|
|
59
|
+
out += ch;
|
|
60
|
+
escaped = false;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (ch === '\\') {
|
|
64
|
+
out += ch;
|
|
65
|
+
escaped = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (ch === '"') {
|
|
69
|
+
inString = false;
|
|
70
|
+
out += ch;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (code <= 0x1f) {
|
|
74
|
+
// Raw control character inside a string literal — recover the same
|
|
75
|
+
// way JSON.stringify would have escaped it on the way out, instead of
|
|
76
|
+
// losing the whole response to a SyntaxError.
|
|
77
|
+
out += NAMED_CONTROL_CHAR_ESCAPES[ch] ?? `\\u${code.toString(16).padStart(4, '0')}`;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
out += ch;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Parse a server response body, tolerating the known #159 quirk above. Never
|
|
86
|
+
* silently drops data: a byte that reaches here as a real control character
|
|
87
|
+
* comes back out as the same character (via the escape → JSON.parse
|
|
88
|
+
* unescape round trip) once parsed, and JSON.stringify guarantees it is
|
|
89
|
+
* re-escaped correctly on the way back out through the shared `--json`
|
|
90
|
+
* output seam (`lib/format.ts#json`).
|
|
91
|
+
*/
|
|
92
|
+
export function parseServerJson(text) {
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(text);
|
|
95
|
+
}
|
|
96
|
+
catch (firstErr) {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(escapeRawControlCharsInJsonStrings(text));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw firstErr;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
package/docs/agent-quickstart.md
CHANGED
|
@@ -20,21 +20,22 @@ agent should never need them.)
|
|
|
20
20
|
|
|
21
21
|
## Core commands (agent + human)
|
|
22
22
|
|
|
23
|
-
These
|
|
23
|
+
These eleven commands are the CLI's agent+human surface — `--json` is
|
|
24
24
|
first-class on every one, and none of them ever blocks on a prompt (see
|
|
25
25
|
[Non-interactive contract](#non-interactive-contract) below):
|
|
26
26
|
|
|
27
27
|
```
|
|
28
28
|
trawl create <url> --prompt <goal> [--no-autofix] [--json] # url also accepted as --url <url> (#116)
|
|
29
29
|
Create a persistent, self-healing scrap from a URL + a goal (AI-generated)
|
|
30
|
-
trawl run <id> [--watch] [--json] Run a scrap (blocks until finished)
|
|
31
30
|
trawl list|ls [--json] [--status <s>] [--limit <n>] [--page <n>] List all scraps
|
|
32
31
|
trawl get <id> [--json] Get scrap details
|
|
32
|
+
trawl run <id> [--watch] [--json] Run a scrap (blocks until finished)
|
|
33
|
+
trawl trigger <id> [--watch] [--wait] [--json] Launch a scrap as a background worker
|
|
33
34
|
trawl data <id> [--json] [--fresh] [--errors] Get scrap data (last persisted run, or --fresh to launch one)
|
|
34
35
|
trawl history <id> [--json] [-n <limit>] List past runs for a scrap
|
|
35
36
|
trawl run-info <hid> [--json] Show details of a single run
|
|
36
|
-
trawl trigger <id> [--watch] [--wait] [--json] Launch a scrap as a background worker
|
|
37
37
|
trawl whoami [--json] Show the authenticated user's identity
|
|
38
|
+
trawl token [--json] Print the stored session JWT (for MCP Bearer auth)
|
|
38
39
|
trawl ping [--json] Health/version handshake against the Trawl API
|
|
39
40
|
```
|
|
40
41
|
|