@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 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
 
@@ -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
- const items = JSON.parse(detail.data)?.data;
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'}`);
@@ -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
- const activity = JSON.parse(event);
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
- items = JSON.parse(detail.data)?.data;
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: create, run, list, get, data,
95
- // history, run-info, trigger, whoami, ping. `list`/`get`/`run`/`data`/
96
- // `history`/`run-info`/`trigger` are built via scraps.ts's exported
97
- // attachXCommand() factories the SAME definition also stays wired
98
- // (hidden) under `scraps` there, so every pre-#108 `trawl scraps <verb>`
99
- // invocation keeps resolving (no breaking change).
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(), so this never duplicates it. Force exit code 2
338
- // (usage) regardless of whichever code commander suggests (it
339
- // defaults these to 1), and add the --json machine envelope when
340
- // resolvable parsing failed before any command's own --json flag
341
- // could be read off `currentCommand` (preAction never fired), so
342
- // scan raw argv instead. (#86 finding 3)
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
- console.log(JSON.stringify({ error: { message: err.message, kind: 'usage' } }));
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
- const parsed = JSON.parse(raw);
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 = JSON.parse(nested);
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 = JSON.parse(raw);
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 = JSON.parse(parsed.error);
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 = JSON.parse(text);
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 = JSON.parse(text);
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 ? JSON.parse(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 = JSON.parse(text);
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;
@@ -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;
@@ -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;
@@ -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
+ }
@@ -20,21 +20,22 @@ agent should never need them.)
20
20
 
21
21
  ## Core commands (agent + human)
22
22
 
23
- These ten commands are the CLI's agent+human surface — `--json` is
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {