@trawlme/cli 2.0.0 → 2.1.1

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
@@ -37,7 +37,8 @@ All commands accept a global `--debug` flag to show full error stack traces on f
37
37
  ### Core commands (agent + human)
38
38
 
39
39
  ```
40
- trawl create <url> --prompt <goal> [--no-autofix] [--json] Create a persistent, self-healing scrap from a URL + a goal (AI-generated)
40
+ trawl create <url> --prompt <goal> [--no-autofix] [--json] # url also accepted as --url <url> (#116)
41
+ Create a persistent, self-healing scrap from a URL + a goal (AI-generated)
41
42
  trawl run <id> [--watch] [--json] Run a scrap
42
43
  trawl list|ls [--json] [--status <success|failure|never|running|regression>] [--limit <n>] [--page <n>]
43
44
  trawl get <id> [--json] Get scrap details
@@ -1,9 +1,10 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { oraPromise } from 'ora';
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
6
  import { requireUrl, requireString } from '../lib/validate.js';
7
+ import { UsageError } from '../lib/errors.js';
7
8
  /** Best-effort, honest first-run summary — never claims a background retry
8
9
  * happened when auto-fix was disabled for this call, and never claims a
9
10
  * scrap was persisted when the response carries none (#114-F3 — a hard
@@ -51,15 +52,28 @@ function scheduleLabel(scrap) {
51
52
  }
52
53
  export const create = new Command('create')
53
54
  .description('Create a persistent, self-healing scrap from a URL + a goal (AI-generated)')
54
- .argument('<url>', 'Target public URL (http/https)')
55
- .requiredOption('--prompt <goal>', 'What to extract/scrape, in plain language')
55
+ .argument('[url]', 'Target public URL (http/https)')
56
+ .requiredOption('--prompt <goal>', 'What to extract/scrape, in plain language (required)')
57
+ .option('--url <url>', 'Target public URL — alias of the positional argument')
56
58
  .option('--no-autofix', 'Disable AI auto-fix on first-run failure (default: on)')
57
59
  .option('--json', 'Output the raw API payload')
58
60
  .action(async (rawUrl, opts) => {
59
61
  // Fast, local usage-errors (exit 2) — never a round-trip to the server
60
62
  // for something we can already tell is bad. Same fail-fast pattern the
61
63
  // former `fetch` command used for its URL argument.
62
- const url = requireUrl(rawUrl, 'url');
64
+ //
65
+ // #116 — the url is accepted BOTH as the positional argument (agent
66
+ // one-liner) and as `--url` (muscle memory from `scraps create` and the
67
+ // API body {url, goal}). Exactly one is required; both are fine only
68
+ // when identical — two DIFFERENT urls is ambiguous, refuse loudly
69
+ // rather than silently picking one.
70
+ if (rawUrl === undefined && opts.url === undefined) {
71
+ throw new UsageError("missing required argument 'url' (positional, or --url <url>)");
72
+ }
73
+ if (rawUrl !== undefined && opts.url !== undefined && rawUrl !== opts.url) {
74
+ throw new UsageError(`conflicting urls: positional "${rawUrl}" vs --url "${opts.url}" — pass only one`);
75
+ }
76
+ const url = requireUrl(rawUrl ?? opts.url, 'url');
63
77
  const goal = requireString(opts.prompt, '--prompt');
64
78
  const body = {
65
79
  url,
@@ -81,7 +95,7 @@ export const create = new Command('create')
81
95
  }
82
96
  else {
83
97
  try {
84
- data = await oraPromise(call, {
98
+ data = await spin(call, {
85
99
  text: `Creating a scrap from ${url}…`,
86
100
  // No verdict symbol here (#106-F3) — ora's success only means "the
87
101
  // HTTP call didn't throw", not "the first run succeeded". The real
@@ -1,5 +1,6 @@
1
1
  import { api } from '../lib/api.js';
2
2
  import chalk from 'chalk';
3
+ import { formatDate } from '../lib/format.js';
3
4
  /**
4
5
  * Known anti-bot vendors (+ auth) that the worker's `blockType` field may name.
5
6
  * Ordered by first-match; `blockType` is a freeform worker string, not an enum
@@ -142,7 +143,7 @@ export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
142
143
  }
143
144
  // Timestamp
144
145
  if (run.createdAt) {
145
- lines.push(chalk.dim(' Run at: ') + new Date(run.createdAt).toLocaleString());
146
+ lines.push(chalk.dim(' Run at: ') + formatDate(run.createdAt));
146
147
  }
147
148
  // Snapshot hint
148
149
  if (run.errorSnapshot?.html || run.statusDetail === 'empty' || run.statusDetail === 'error') {
@@ -17,7 +17,9 @@ export const ping = new Command('ping')
17
17
  json(data);
18
18
  return;
19
19
  }
20
- const versionSuffix = data.version ? ` (v${data.version})` : '';
20
+ // #119 label it as the API's version, not a bare `v0.4.0` that reads as
21
+ // "the platform is v0.4" (it's the server package version field).
22
+ const versionSuffix = data.version ? ` (api v${data.version})` : '';
21
23
  if (data.status === 'ok') {
22
24
  console.log(chalk.green('✓ OK') + versionSuffix);
23
25
  }
@@ -1,8 +1,8 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { oraPromise } from 'ora';
3
+ import { spin } from '../lib/spinner.js';
4
4
  import { api, LONG_RUN_TIMEOUT_MS } from '../lib/api.js';
5
- import { table, json } from '../lib/format.js';
5
+ import { table, json, formatDate } from '../lib/format.js';
6
6
  import { promptPassword } from '../lib/prompt.js';
7
7
  import { validateObjectId } from '../lib/validate.js';
8
8
  import { classifyError, reportError, UsageError, RefusalError } from '../lib/errors.js';
@@ -42,16 +42,7 @@ function lastStatus(scrap) {
42
42
  return last.status === true ? 'success' : 'failure';
43
43
  }
44
44
  function lastRun(scrap) {
45
- const last = scrap.history?.[0];
46
- if (!last?.createdAt)
47
- return '—';
48
- const d = new Date(last.createdAt);
49
- const dd = String(d.getDate()).padStart(2, '0');
50
- const mm = String(d.getMonth() + 1).padStart(2, '0');
51
- const yy = String(d.getFullYear()).slice(2);
52
- const hh = String(d.getHours()).padStart(2, '0');
53
- const min = String(d.getMinutes()).padStart(2, '0');
54
- return `${dd}/${mm}/${yy} ${hh}:${min}`;
45
+ return formatDate(scrap.history?.[0]?.createdAt); // #119 — unified DD/MM/YY HH:mm
55
46
  }
56
47
  function statusIcon(status) {
57
48
  if (status === 'success')
@@ -389,7 +380,7 @@ export function attachListCommand(parent, attachOpts = {}) {
389
380
  }
390
381
  let data;
391
382
  try {
392
- data = await oraPromise(async () => {
383
+ data = await spin(async () => {
393
384
  if (page !== undefined) {
394
385
  // Single-page mode: explicit page requested, no loop
395
386
  return api.get(`/api/scraps?perPage=50&page=${page}`);
@@ -445,7 +436,7 @@ export function attachListCommand(parent, attachOpts = {}) {
445
436
  cron: s.cron || '—',
446
437
  status: statusIcon(lastStatus(s)),
447
438
  'last run': lastRun(s),
448
- updated: new Date(s.updatedAt).toLocaleDateString(),
439
+ updated: formatDate(s.updatedAt),
449
440
  }));
450
441
  table(tableRows, ['id', 'title', 'cron', 'status', 'last run', 'updated']);
451
442
  // Print footer when --limit truncates
@@ -471,7 +462,7 @@ export function attachGetCommand(parent, attachOpts = {}) {
471
462
  console.log(chalk.dim(` Cron: `) + (data.cron || '—'));
472
463
  console.log(chalk.dim(` Status: `) + statusIcon(lastStatus(data)));
473
464
  console.log(chalk.dim(` Last run: `) + lastRun(data));
474
- console.log(chalk.dim(` Updated: `) + new Date(data.updatedAt).toLocaleString());
465
+ console.log(chalk.dim(` Updated: `) + formatDate(data.updatedAt));
475
466
  });
476
467
  }
477
468
  attachGetCommand(scraps, { hidden: true });
@@ -566,7 +557,7 @@ scraps
566
557
  usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
567
558
  return;
568
559
  }
569
- const data = await oraPromise(() => api.post('/api/scraps', {
560
+ const data = await spin(() => api.post('/api/scraps', {
570
561
  title: opts.title,
571
562
  ...(opts.url && { url: opts.url }),
572
563
  request: opts.request || '',
@@ -681,7 +672,7 @@ scraps
681
672
  console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
682
673
  return;
683
674
  }
684
- const data = await oraPromise(() => api.put(`/api/scraps/${id}`, body), {
675
+ const data = await spin(() => api.put(`/api/scraps/${id}`, body), {
685
676
  text: 'Updating scrap…',
686
677
  successText: (d) => `Scrap updated: ${chalk.bold(d._id)}`,
687
678
  });
@@ -735,7 +726,7 @@ export function attachRunCommand(parent, attachOpts = {}) {
735
726
  // all, mirroring `trawl create`'s own --json handling.
736
727
  const data = opts.json
737
728
  ? await call()
738
- : await oraPromise(call, { text: 'Launching scrap…', successText: 'Scrap launched' });
729
+ : await spin(call, { text: 'Launching scrap…', successText: 'Scrap launched' });
739
730
  if (opts.json)
740
731
  json(data);
741
732
  if (opts.watch) {
@@ -830,7 +821,7 @@ export function attachDataCommand(parent, attachOpts = {}) {
830
821
  if (opts.fresh) {
831
822
  // #91 P0 — same long-run endpoint as `run` (30-250s server-side);
832
823
  // the 30s default was aborting it mid-flight.
833
- const loaded = await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
824
+ const loaded = await spin(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
834
825
  text: 'Launching a fresh scrap run (consumes execute quota)…',
835
826
  successText: 'Fresh run complete',
836
827
  });
@@ -1050,7 +1041,7 @@ scraps
1050
1041
  json({ deleted: true, id });
1051
1042
  return;
1052
1043
  }
1053
- await oraPromise(call, { text: 'Deleting…', successText: 'Scrap deleted' });
1044
+ await spin(call, { text: 'Deleting…', successText: 'Scrap deleted' });
1054
1045
  });
1055
1046
  // banner
1056
1047
  scraps
@@ -1092,7 +1083,7 @@ scraps
1092
1083
  json(data);
1093
1084
  return;
1094
1085
  }
1095
- await oraPromise(call, {
1086
+ await spin(call, {
1096
1087
  text: 'Uploading banner…',
1097
1088
  successText: `Banner uploaded for scrap ${chalk.bold(id)}`,
1098
1089
  });
@@ -1135,7 +1126,7 @@ export function attachTriggerCommand(parent, attachOpts = {}) {
1135
1126
  // #107 — under --json the stdout path stays pure: no spinner channel.
1136
1127
  const data = opts.json
1137
1128
  ? await call()
1138
- : await oraPromise(call, {
1129
+ : await spin(call, {
1139
1130
  text: opts.wait ? 'Running worker…' : 'Triggering worker…',
1140
1131
  successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
1141
1132
  });
@@ -1214,7 +1205,7 @@ account
1214
1205
  json(data);
1215
1206
  return;
1216
1207
  }
1217
- const data = await oraPromise(call, { text: 'Saving credentials…', successText: 'Credentials saved' });
1208
+ const data = await spin(call, { text: 'Saving credentials…', successText: 'Credentials saved' });
1218
1209
  const acc = data.account;
1219
1210
  console.log(chalk.dim(' Credentials: ') + (acc.hasCredentials ? chalk.green('✓ configured') : chalk.dim('not set')));
1220
1211
  console.log(chalk.dim(' Session: ') + (acc.hasSession ? chalk.green('active') : chalk.dim('none')));
@@ -1244,7 +1235,7 @@ account
1244
1235
  json({ deleted: true, id });
1245
1236
  return;
1246
1237
  }
1247
- await oraPromise(call, {
1238
+ await spin(call, {
1248
1239
  text: 'Deleting credentials…',
1249
1240
  successText: 'Account credentials deleted',
1250
1241
  });
@@ -1262,7 +1253,7 @@ account
1262
1253
  json({ cleared: true, id });
1263
1254
  return;
1264
1255
  }
1265
- await oraPromise(call, {
1256
+ await spin(call, {
1266
1257
  text: 'Clearing session…',
1267
1258
  successText: 'Session cleared',
1268
1259
  });
@@ -1311,7 +1302,7 @@ accountSession
1311
1302
  json(data);
1312
1303
  return;
1313
1304
  }
1314
- const data = await oraPromise(call, {
1305
+ const data = await spin(call, {
1315
1306
  text: 'Uploading session cookies…',
1316
1307
  successText: `Session cookies saved for scrap ${chalk.bold(id)}`,
1317
1308
  });
@@ -1325,7 +1316,7 @@ account
1325
1316
  .option('--json', 'Output as JSON')
1326
1317
  .action(async (id, opts) => {
1327
1318
  validateObjectId(id);
1328
- const data = await oraPromise(() => api.get(`/api/scraps/${id}`), 'Fetching scrap…');
1319
+ const data = await spin(() => api.get(`/api/scraps/${id}`), 'Fetching scrap…');
1329
1320
  const acc = data.account;
1330
1321
  if (opts.json) {
1331
1322
  // #86 finding 12 — `json` is already statically imported at the top of
@@ -1,2 +1,9 @@
1
1
  export declare function table(rows: Record<string, unknown>[], columns: string[]): void;
2
2
  export declare function json(data: unknown): void;
3
+ /**
4
+ * One date format across the whole CLI: `DD/MM/YY HH:mm` (local time).
5
+ * #119 — `list` mixed `DD/MM/YY` (last-run col) with `M/D/YYYY,
6
+ * h:mm:ss AM/PM` (toLocaleString), ambiguous day/month side by side.
7
+ * Returns `—` for a missing/invalid date.
8
+ */
9
+ export declare function formatDate(value: string | number | Date | null | undefined): string;
@@ -23,3 +23,22 @@ export function table(rows, columns) {
23
23
  export function json(data) {
24
24
  console.log(JSON.stringify(data, null, 2));
25
25
  }
26
+ /**
27
+ * One date format across the whole CLI: `DD/MM/YY HH:mm` (local time).
28
+ * #119 — `list` mixed `DD/MM/YY` (last-run col) with `M/D/YYYY,
29
+ * h:mm:ss AM/PM` (toLocaleString), ambiguous day/month side by side.
30
+ * Returns `—` for a missing/invalid date.
31
+ */
32
+ export function formatDate(value) {
33
+ if (value === null || value === undefined)
34
+ return '—';
35
+ const d = new Date(value);
36
+ if (Number.isNaN(d.getTime()))
37
+ return '—';
38
+ const dd = String(d.getDate()).padStart(2, '0');
39
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
40
+ const yy = String(d.getFullYear()).slice(2);
41
+ const hh = String(d.getHours()).padStart(2, '0');
42
+ const min = String(d.getMinutes()).padStart(2, '0');
43
+ return `${dd}/${mm}/${yy} ${hh}:${min}`;
44
+ }
@@ -0,0 +1,18 @@
1
+ type Action<T> = PromiseLike<T> | (() => PromiseLike<T>);
2
+ type SpinOptions<T> = string | {
3
+ text?: string;
4
+ successText?: string | ((result: T) => string);
5
+ failText?: string | ((error: Error) => string);
6
+ [key: string]: unknown;
7
+ };
8
+ /**
9
+ * Drop-in for `oraPromise` that emits NOTHING when stderr is not a TTY.
10
+ *
11
+ * #119 — under a pipe / non-TTY, `oraPromise` still printed the start text AND
12
+ * a persisted `✔ …` line, so `trawl list | cat` showed the spinner caption
13
+ * twice. An agent/CI never wants spinner chrome; here we just run the action.
14
+ * When stderr IS a TTY, behaviour is identical to `oraPromise` (spinner writes
15
+ * to stderr, stdout stays clean either way).
16
+ */
17
+ export declare function spin<T>(action: Action<T>, options?: SpinOptions<T>): Promise<T>;
18
+ export {};
@@ -0,0 +1,17 @@
1
+ import { oraPromise } from 'ora';
2
+ /**
3
+ * Drop-in for `oraPromise` that emits NOTHING when stderr is not a TTY.
4
+ *
5
+ * #119 — under a pipe / non-TTY, `oraPromise` still printed the start text AND
6
+ * a persisted `✔ …` line, so `trawl list | cat` showed the spinner caption
7
+ * twice. An agent/CI never wants spinner chrome; here we just run the action.
8
+ * When stderr IS a TTY, behaviour is identical to `oraPromise` (spinner writes
9
+ * to stderr, stdout stays clean either way).
10
+ */
11
+ export function spin(action, options) {
12
+ if (!process.stderr.isTTY) {
13
+ return Promise.resolve(typeof action === 'function' ? action() : action);
14
+ }
15
+ // oraPromise's own overloads accept (action, string) and (action, options).
16
+ return oraPromise(action, options);
17
+ }
@@ -25,7 +25,8 @@ 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
- trawl create <url> --prompt <goal> [--no-autofix] [--json] Create a persistent, self-healing scrap from a URL + a goal (AI-generated)
28
+ trawl create <url> --prompt <goal> [--no-autofix] [--json] # url also accepted as --url <url> (#116)
29
+ Create a persistent, self-healing scrap from a URL + a goal (AI-generated)
29
30
  trawl run <id> [--watch] [--json] Run a scrap
30
31
  trawl list|ls [--json] [--status <s>] [--limit <n>] [--page <n>] List all scraps
31
32
  trawl get <id> [--json] Get scrap details
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {