@trawlme/cli 1.22.0 → 2.1.0

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
@@ -8,7 +8,7 @@ Command-line client for [Trawl](https://trawl.me) — manage your scraps from th
8
8
 
9
9
  > ⚠️ **Early stage.** APIs may change before `1.0`. Pin a version if you depend on this in CI.
10
10
 
11
- > 🤖 **Driving this CLI from an AI agent?** See [docs/agent-quickstart.md](docs/agent-quickstart.md) for the minimal surface (`fetch`, `--json`, `TRAWL_TOKEN`, exit codes) — this README is the full human/CI guide.
11
+ > 🤖 **Driving this CLI from an AI agent?** See [docs/agent-quickstart.md](docs/agent-quickstart.md) for the minimal surface (`create`, `--json`, `TRAWL_TOKEN`, exit codes) — this README is the full human/CI guide.
12
12
 
13
13
  ## Install
14
14
 
@@ -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 fetch <url> [--json] [--reason <text>] One-shot fetch + extract readable content from a public URL (no scrap needed)
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
@@ -51,13 +52,15 @@ trawl ping [--json] Health/version handshake against
51
52
 
52
53
  > **No breaking change:** every verb above is also still reachable under its pre-reorg path, `trawl scraps <verb>` (e.g. `trawl scraps list`, `trawl scraps run <id>`) — kept as a hidden alias so scripts written before the surface reorg keep working. `trawl --help` only shows the top-level form above; `trawl scraps --help` only shows the remaining scrap-management commands below.
53
54
 
54
- `fetch`/`whoami`/`ping` are fully non-interactive — all three read auth only from `TRAWL_TOKEN`/the stored login token, never prompt. `trawl fetch` is the REST counterpart of the MCP `trawl_fetch_url` tool (same shared engine, `POST /api/scraps/fetch-url`): `status` is an honest outcome (`completed`/`failed`/`empty`/`blocked`), not "did the HTTP call succeed" — a failed fetch is still a 200 response with `status:'failed'` + `error`, and the CLI exits 1 in that case (both human and `--json` modes) even though `--json` always prints the raw payload verbatim. `truncated:true` means the 100KB response cap tripped; `result` is dropped and `url` repoints at the full history row instead. `trawl whoami`/`trawl ping` mirror the MCP `trawl_whoami`/`trawl_health_ping` tools as closely as the REST surface allows (`GET /api/users/me` / `GET /api/health`) — `ping`'s `--json` payload is admin-enriched (version/uptime/db) and just `{"status":"ok"}` for anyone else.
55
+ `create`/`whoami`/`ping` are fully non-interactive — all three read auth only from `TRAWL_TOKEN`/the stored login token, never prompt. `trawl create` runs the AI wizard server-side (`POST /api/ai/wizard`): generate scrap code from `--prompt` via LLM, persist the scrap, trigger its FIRST run, and auto-fix on failure (default on — `--no-autofix` disables it, sending `autoFix:false`). `success` is an honest outcome of that first run, not "did the HTTP call succeed" — a failed first run is still a 200 response (the scrap was still created; auto-fix, when enabled, retries in the background), and the CLI exits 1 in that case (both human and `--json` modes) even though `--json` always prints the raw payload verbatim. The call legitimately takes 30–250s+ server-side (AI generation + a real run), same long-run timeout as `run`/`data --fresh`/`trigger --wait` below. `trawl whoami`/`trawl ping` mirror the MCP `trawl_whoami`/`trawl_health_ping` tools as closely as the REST surface allows (`GET /api/users/me` / `GET /api/health`) — `ping`'s `--json` payload is admin-enriched (version/uptime/db) and just `{"status":"ok"}` for anyone else.
56
+
57
+ > **`create` is NOT idempotent, and every wizard-created scrap runs on a DAILY cron by default.** A client-side timeout (exit `5`, a `NetworkError`) does not mean the wizard failed server-side — scrap creation + the first run keep going after the CLI gives up waiting, so the scrap may already exist. Run `trawl list` and look for a matching URL/title **before** retrying — a blind retry creates a DUPLICATE scrap and burns AI-generation quota a second time for the same goal. Separately, the scrap the wizard creates is scheduled to re-run every day at 07:00 UTC (`cron: "0 7 * * *"`, hardcoded server-side, unrelated to `--no-autofix`) — each of those recurring runs consumes execute quota like any other run. Review the generated scrap, then change or disable the schedule with `trawl scraps update <id> --cron <expr>` (or `--no-cron` to disable it). Because the call can legitimately run 250s+, also confirm `TRAWL_TIMEOUT` isn't set to something tighter than `create` needs — the env var always wins over `create`'s own 300s default (see [Environment variables](#environment-variables)), so a value set for another purpose (e.g. a tight CI smoke-test budget) silently clamps `create` too; unset it or raise it before running `create`.
55
58
 
56
59
  - `list` has a short alias, `ls` (matches `trawl --help`'s `list|ls`).
57
60
  - `history` lists past runs (newest first); `run-info <hid>` shows details of a single run from that history.
58
61
  - `data` returns the last persisted run payload (no execute quota); `--fresh` runs the scrap live instead (consumes execute quota); `--errors` shows the last run's error detail (`--json` on a never-run scrap returns `{"status":"no_runs"}`, exit 0, matching `scraps doctor --json`). `[]` on stdout means a genuine zero-item successful run — a scrap that has never run, whose last run failed, or whose payload aged out of retention returns a `--json` error envelope (exit 4/1/4 respectively) instead. Two more honest states: a run still **in flight** (`status: null` server-side) returns a `kind:"in_progress"` error envelope (exit 1, "retry shortly" — never suggests `--fresh`, which would just 429 against the run already holding the lock); a run whose item count **regressed** vs baseline (`statusDetail: "regression"`) still returns the real, non-empty items on stdout (exit 0) plus a stderr warning pointing at `scraps doctor <id>` — the data itself is genuine even though the run is flagged.
59
62
  - `get` (and anything reading through it, like `data`'s default path) embeds only the newest 100 history rows on the returned scrap object — `run-info` and `scraps doctor` fetch a single run directly and are unaffected by that cap. `list`/`get` show a distinct amber `▼` "regression" badge, never the red `✗` a genuine failure gets (matches `scraps doctor`'s own badge).
60
- - **Long-running scrap execute (`run`, `data --fresh`, `trigger --wait`):** these hit the same server-side scrap-execute path, which can legitimately take 30–250s (proxy tier escalation, AI-fix retries) — the CLI arms a 300s timeout for exactly these three call sites instead of the generic 30s default. `TRAWL_TIMEOUT` (see below) still overrides ALL requests, including these — set it if you need a tighter or looser ceiling than 300s for a long-running scrap.
63
+ - **Long-running calls (`create`, `run`, `data --fresh`, `trigger --wait`):** these hit server-side paths that can legitimately take 30–250s+ (AI generation + scrap creation + a first run for `create`; proxy tier escalation + AI-fix retries for the other three) — the CLI arms a 300s timeout for exactly these four call sites instead of the generic 30s default. `TRAWL_TIMEOUT` (see below) still overrides ALL requests, including these — set it if you need a tighter or looser ceiling than 300s, but note a global override that tight also clamps `create`.
61
64
  - **`--watch` is poll-based, not a live stream:** the activities SSE endpoint has no backlog and, for the default async `trigger` (no `--wait`), runs in a separate cron-consumer pod whose events never reach the API pod holding the SSE connection — a naive "await the run, then open SSE" shows nothing. `run --watch` and `trigger --watch` instead poll `GET /api/scraps/:id` (terminal status) and the activities REST list until the run finishes, printing each new activity line as it appears. The watched run's outcome drives the exit code too, in BOTH human and `--json` mode: a genuinely failed terminal run, a poll timeout, or a persistently unreachable API all exit non-zero — a clean successful run is the only exit `0`. A run that never reaches a terminal status within 300s prints an honest timeout notice pointing at `scraps doctor <id>` (human mode) — see the `--json` shape below. `scraps watch <id>` (the standalone command, no trigger) is unchanged — it still opens the live SSE stream directly.
62
65
  - `run --json`/`trigger --json` bypass the spinner and print the raw launch/trigger payload on stdout; combined with `--watch`, every intermediate progress line stays suppressed (stdout stays pure JSON) and, once the watch reaches its outcome, exactly ONE final NDJSON line is emitted: `{"runId","status"}` (the honest terminal status — `success`/`error`/`empty`/`regression`/…), `{"runId","status":"timeout"}` on a poll timeout, or `{"runId","status":"poll_error","error"}` if the API stays unreachable for several consecutive polls — `process.exitCode` is non-zero for all three except a genuine success. `scraps watch --json` emits one raw JSON object per activity line (NDJSON) instead of the formatted `[time] message` text — there's no single final payload to wait for on a live stream.
63
66
 
@@ -179,7 +182,7 @@ Every command exits with one of these codes — scripts and agents driving the C
179
182
  | Code | Meaning |
180
183
  |------|--------------------------------------------------------------------------|
181
184
  | `0` | Success |
182
- | `1` | Unknown/generic error (an unmapped failure — API errors other than 401/404, an unhandled bug — or a business-logic refusal like `data`'s `run_failed`/`in_progress` states, or `trawl fetch`'s honest `status:'failed'`/`'blocked'` outcome). **Known overload:** `fetch`'s domain-level failure and an arbitrary unmapped bug both land on `1` — a script needs to read the `--json` payload's own `status`/`error` field (for `fetch`) to tell them apart; this is intentional (the REST contract's outcome states are a separate axis from the CLI's transport-error taxonomy) and documented here rather than "resolved" by inventing a new code that would only apply to one command. |
185
+ | `1` | Unknown/generic error (an unmapped failure — API errors other than 401/404, an unhandled bug — or a business-logic refusal like `data`'s `run_failed`/`in_progress` states, or `trawl create`'s honest `success:false` first-run outcome). **Known overload:** `create`'s domain-level failure and an arbitrary unmapped bug both land on `1` — a script needs to read the `--json` payload's own `success` field (for `create`) to tell them apart; this is intentional (the REST contract's outcome states are a separate axis from the CLI's transport-error taxonomy) and documented here rather than "resolved" by inventing a new code that would only apply to one command. |
183
186
  | `2` | Usage error (bad flag/value, invalid ID, missing required argument, unknown option/command — including the [non-interactive rule](#non-interactive-rule) refusing to prompt) |
184
187
  | `3` | Auth error (not logged in, or the session token is expired/invalid — run `trawl login`) |
185
188
  | `4` | Not found (no such resource, or — for `data` — no persisted payload to read) |
@@ -196,7 +199,7 @@ Under `--json`, a failing command emits a single error envelope on stdout — `{
196
199
  | `TRAWL_TELEMETRY` | Set to `0` to disable telemetry for the current session |
197
200
  | `DO_NOT_TRACK` | Set to `1` to disable telemetry (cross-vendor convention, https://consoledonottrack.com) — same effect as `TRAWL_TELEMETRY=0` |
198
201
  | `TRAWL_CONFIG_DIR` | Override where the config file (token, API URL, telemetry state) is stored — useful for hermetic CI runs or concurrent `trawl login`s that must not share one on-disk file |
199
- | `TRAWL_TIMEOUT` | Override the per-request fetch timeout in milliseconds (default `30000`; `300000` for `run`/`data --fresh`/`trigger --wait` — this env var always wins over that longer default too) |
202
+ | `TRAWL_TIMEOUT` | Override the per-request fetch timeout in milliseconds (default `30000`; `300000` for `create`/`run`/`data --fresh`/`trigger --wait` — this env var always wins over that longer default too) |
200
203
  | `TRAWL_SKILLS_SYNC` | Set to `0` to disable the startup skills auto-sync entirely |
201
204
 
202
205
  Session override (no `trawl login` mutation, ideal for CI/QA against another env):
@@ -0,0 +1,41 @@
1
+ import { Command } from 'commander';
2
+ /**
3
+ * `POST /api/ai/wizard` response contract (#114, trawl_node —
4
+ * ai.wizard.service.js#runWizard, shipped S1, contract LOCKED). The wizard
5
+ * chains, entirely server-side: AI code generation -> scrap creation ->
6
+ * the FIRST run (ScrapsService.load) -> auto-fix on failure (autoFix
7
+ * defaults true unless `--no-autofix` maps to `autoFix:false`).
8
+ *
9
+ * - `success` is an HONEST outcome of that first run, not "did the HTTP
10
+ * call succeed" — a failed first run is still a 200 response (the scrap
11
+ * itself was created either way; auto-fix, when enabled, retries in the
12
+ * background). Callers must branch on `success`, never assume 2xx means
13
+ * "the scrap works".
14
+ * - `scrap` is the full created Scrap object (present whenever creation got
15
+ * far enough to persist it — a hard failure before that point surfaces as
16
+ * a real HTTP error instead, handled by the shared error path).
17
+ * - `historyId` is best-effort (a failed server-side lookup leaves it
18
+ * `null`, never breaks the response) — it points at the first run just
19
+ * executed.
20
+ */
21
+ export interface WizardResponse {
22
+ success: boolean;
23
+ scrap?: {
24
+ _id: string;
25
+ title: string;
26
+ /**
27
+ * Cron expression the wizard schedules this scrap on. As of #114/S1
28
+ * (trawl_node ai.wizard.service.js#runWizard's `scrapBody`) this is
29
+ * hardcoded server-side to a DAILY run — `'0 7 * * *'` / `cronTimezone:
30
+ * 'UTC'` — unconditionally, regardless of `--prompt`/`--no-autofix`.
31
+ * Present on the scrap object returned here (the wizard controller
32
+ * passes the created scrap straight through, no stripping) — change or
33
+ * disable it with `trawl scraps update <id> --cron <expr>` / `--no-cron`.
34
+ */
35
+ cron?: string | null;
36
+ cronTimezone?: string;
37
+ [key: string]: unknown;
38
+ };
39
+ historyId?: string | null;
40
+ }
41
+ export declare const create: Command;
@@ -0,0 +1,160 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { oraPromise } from 'ora';
4
+ import { api, LONG_RUN_TIMEOUT_MS, NetworkError } from '../lib/api.js';
5
+ import { json } from '../lib/format.js';
6
+ import { requireUrl, requireString } from '../lib/validate.js';
7
+ import { UsageError } from '../lib/errors.js';
8
+ /** Best-effort, honest first-run summary — never claims a background retry
9
+ * happened when auto-fix was disabled for this call, and never claims a
10
+ * scrap was persisted when the response carries none (#114-F3 — a hard
11
+ * failure before persistence still comes back as `success:false` with no
12
+ * `scrap` at all; claiming "auto-fix retrying in the background" then would
13
+ * be fabricated — there is nothing to retry). */
14
+ function firstRunLabel(data, autoFixEnabled) {
15
+ if (data.success)
16
+ return 'succeeded';
17
+ if (!data.scrap)
18
+ return 'failed (no scrap was persisted)';
19
+ if (autoFixEnabled)
20
+ return 'failed (auto-fix retrying in the background)';
21
+ return 'failed';
22
+ }
23
+ /** Best-effort human description of a daily cron (`M H * * *`) — the only
24
+ * shape the wizard's server-side default currently produces. Falls back to
25
+ * printing the raw expression for anything else rather than guessing at a
26
+ * schedule the CLI can't actually parse. */
27
+ function describeCron(cron) {
28
+ const match = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(cron.trim());
29
+ if (!match)
30
+ return cron;
31
+ const [, min, hour] = match;
32
+ return `daily ${hour.padStart(2, '0')}:${min.padStart(2, '0')}`;
33
+ }
34
+ /**
35
+ * #114-F2 — a wizard-created scrap runs on a DAILY cron by default
36
+ * server-side; surface that up front rather than leaving it to be
37
+ * discovered later as an unexpected recurring quota charge. Reads the real
38
+ * `cron`/`cronTimezone` field off the response scrap when present; falls
39
+ * back to the known wizard default wording ONLY when the field is missing
40
+ * from the response (an older server, or a future rename) — never invents a
41
+ * schedule value that might not match what the server actually applied.
42
+ * Returns null when there's no scrap to schedule at all.
43
+ */
44
+ function scheduleLabel(scrap) {
45
+ if (!scrap)
46
+ return null;
47
+ if (typeof scrap.cron === 'string' && scrap.cron.trim() !== '') {
48
+ const tz = typeof scrap.cronTimezone === 'string' && scrap.cronTimezone ? scrap.cronTimezone : 'UTC';
49
+ return `${describeCron(scrap.cron)} ${tz} (cron ${scrap.cron})`;
50
+ }
51
+ return 'scheduled daily by default';
52
+ }
53
+ export const create = new Command('create')
54
+ .description('Create a persistent, self-healing scrap from a URL + a goal (AI-generated)')
55
+ .argument('[url]', 'Target public URL (http/https)')
56
+ .requiredOption('--prompt <goal>', 'What to extract/scrape, in plain language')
57
+ .option('--url <url>', 'Target public URL — alias of the positional argument (#116)')
58
+ .option('--no-autofix', 'Disable AI auto-fix on first-run failure (default: on)')
59
+ .option('--json', 'Output the raw API payload')
60
+ .action(async (rawUrl, opts) => {
61
+ // Fast, local usage-errors (exit 2) — never a round-trip to the server
62
+ // for something we can already tell is bad. Same fail-fast pattern the
63
+ // former `fetch` command used for its URL argument.
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');
77
+ const goal = requireString(opts.prompt, '--prompt');
78
+ const body = {
79
+ url,
80
+ goal,
81
+ ...(opts.autofix === false && { autoFix: false }),
82
+ };
83
+ // #91/#106-F1 long-run pattern — the wizard runs AI generation + scrap
84
+ // creation + a real FIRST run (+ autofix retries) entirely server-side,
85
+ // legitimately 30-250s+. The 30s DEFAULT_TIMEOUT_MS would abort it
86
+ // mid-flight and fabricate a NetworkError timeout for a request that was
87
+ // always going to succeed.
88
+ const call = () => api.post('/api/ai/wizard', body, { timeoutMs: LONG_RUN_TIMEOUT_MS });
89
+ // #106-F2 pattern carried over from `fetch` — under --json stdout must
90
+ // be provably pure: no spinner channel at all. Only the human path gets
91
+ // the ora progress indicator; --json calls the API directly.
92
+ let data;
93
+ if (opts.json) {
94
+ data = await call();
95
+ }
96
+ else {
97
+ try {
98
+ data = await oraPromise(call, {
99
+ text: `Creating a scrap from ${url}…`,
100
+ // No verdict symbol here (#106-F3) — ora's success only means "the
101
+ // HTTP call didn't throw", not "the first run succeeded". The real
102
+ // outcome renders below via the icon + the First run: line.
103
+ successText: 'Request complete',
104
+ });
105
+ }
106
+ catch (err) {
107
+ // #114-F1 — a client-side timeout (NetworkError, "timed out after
108
+ // …ms" per api.ts's safeFetch) does NOT mean the wizard failed
109
+ // server-side: the scrap creation + first run keep going on the
110
+ // server after the CLI gives up waiting, so the scrap may already
111
+ // exist (or land moments later). Warn BEFORE rethrowing so a retry
112
+ // isn't the first instinct — a blind retry creates a duplicate scrap
113
+ // and burns AI-generation quota a second time for the same goal.
114
+ // Only a NetworkError whose message identifies it as the timeout
115
+ // branch qualifies — a DNS/connection-refused NetworkError never
116
+ // reached the server at all, so there's nothing to warn about here.
117
+ if (err instanceof NetworkError && /timed out/i.test(err.message)) {
118
+ console.error(chalk.yellow('⚠ The request timed out client-side, but the scrap may STILL have been created server-side — run `trawl list` before retrying (a retry creates a DUPLICATE scrap + burns quota).'));
119
+ }
120
+ // Rethrow unchanged so index.ts's classifyError/exit-code taxonomy
121
+ // stays intact (this stays a NetworkError -> exit 5, same as before).
122
+ throw err;
123
+ }
124
+ }
125
+ if (opts.json) {
126
+ json(data);
127
+ }
128
+ else {
129
+ const scrapId = data.scrap?._id;
130
+ const title = data.scrap?.title ?? url;
131
+ const autoFixEnabled = opts.autofix !== false;
132
+ const schedule = scheduleLabel(data.scrap);
133
+ console.log(`${data.success ? chalk.green('✓') : chalk.red('✗')} ${chalk.bold(title)}`);
134
+ console.log(chalk.dim(` Scrap ID: `) + (scrapId ?? '—'));
135
+ if (data.historyId)
136
+ console.log(chalk.dim(` History ID: `) + data.historyId);
137
+ console.log(chalk.dim(` First run: `) + firstRunLabel(data, autoFixEnabled));
138
+ if (schedule)
139
+ console.log(chalk.dim(` Schedule: `) + schedule);
140
+ if (scrapId)
141
+ console.log(chalk.dim(` Next step: `) + `trawl get ${scrapId}`);
142
+ // #114-F3 — only claim a background retry is happening when a scrap
143
+ // actually exists to retry (never fabricate progress that isn't real);
144
+ // `firstRunLabel` above already covers the !scrap / autofix-disabled
145
+ // wording, this adds the actionable poll target on top.
146
+ if (!data.success && data.scrap && autoFixEnabled) {
147
+ const pollTarget = data.historyId
148
+ ? `\`trawl data ${scrapId}\` or \`trawl run-info ${data.historyId}\``
149
+ : `\`trawl data ${scrapId}\``;
150
+ console.log(chalk.yellow(` Note: `) +
151
+ `Auto-fix is retrying in the background — do NOT re-run create; poll ${pollTarget}.`);
152
+ }
153
+ }
154
+ // Honest exit code alongside the honest payload — a --json caller gets
155
+ // the raw body regardless (never wrapped/altered), but a script checking
156
+ // the exit code alone must be able to tell "first run failed" from "ran
157
+ // fine" without parsing. Same contract the former `fetch` command used.
158
+ if (!data.success)
159
+ process.exitCode = 1;
160
+ });
@@ -2,7 +2,8 @@ import { Command } from 'commander';
2
2
  /**
3
3
  * #108 — surface reorg. `run`/`list`/`get`/`data`/`history`/`run-info`/
4
4
  * `trigger` are promoted to top-level verbs (see index.ts's `createProgram`)
5
- * alongside `fetch`/`whoami`/`ping`. Each is built by an exported
5
+ * alongside `create`/`whoami`/`ping` (#114 `create` replaced `fetch` in
6
+ * that group). Each is built by an exported
6
7
  * `attachXCommand(parent, attachOpts)` factory instead of a fixed
7
8
  * `scraps.command(...)` chain, so it can be attached TWICE with a single
8
9
  * source-of-truth definition: once to `program` (the new canonical
@@ -732,7 +732,7 @@ export function attachRunCommand(parent, attachOpts = {}) {
732
732
  // server-side (30-250s); the 30s default was aborting it mid-flight.
733
733
  const call = () => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS });
734
734
  // #107 — under --json the stdout path stays pure: no spinner channel at
735
- // all, mirroring `trawl fetch`'s own --json handling.
735
+ // all, mirroring `trawl create`'s own --json handling.
736
736
  const data = opts.json
737
737
  ? await call()
738
738
  : await oraPromise(call, { text: 'Launching scrap…', successText: 'Scrap launched' });
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { scraps, attachListCommand, attachGetCommand, attachRunCommand, attachDa
8
8
  import { skills } from './commands/skills.js';
9
9
  import { telemetry } from './commands/telemetry.js';
10
10
  import { token } from './commands/token.js';
11
- import { fetchUrl } from './commands/fetch.js';
11
+ import { create } from './commands/create.js';
12
12
  import { whoami } from './commands/whoami.js';
13
13
  import { ping } from './commands/ping.js';
14
14
  import { autoUpdateInstalledSkills } from './lib/skills.js';
@@ -89,14 +89,18 @@ export function createProgram() {
89
89
  .description('Trawl CLI — manage scraps from the terminal')
90
90
  .version(pkg.version)
91
91
  .option('--debug', 'Show full error stack traces');
92
- // Core verbs (#108) — promoted/listed first: fetch, run, list, get, data,
92
+ // Core verbs (#108) — promoted/listed first: create, run, list, get, data,
93
93
  // history, run-info, trigger, whoami, ping. `list`/`get`/`run`/`data`/
94
94
  // `history`/`run-info`/`trigger` are built via scraps.ts's exported
95
95
  // attachXCommand() factories — the SAME definition also stays wired
96
96
  // (hidden) under `scraps` there, so every pre-#108 `trawl scraps <verb>`
97
97
  // invocation keeps resolving (no breaking change).
98
+ //
99
+ // #114 — `create` replaced `fetch` in this slot: `POST /api/ai/wizard`
100
+ // (AI-generate + persist + first-run + autofix), a distinct command from
101
+ // the still-untouched `trawl scraps create` (raw-script management verb).
98
102
  program.commandsGroup(CORE_GROUP);
99
- program.addCommand(fetchUrl);
103
+ program.addCommand(create);
100
104
  attachRunCommand(program);
101
105
  attachListCommand(program);
102
106
  attachGetCommand(program);
package/dist/lib/api.d.ts CHANGED
@@ -43,12 +43,15 @@ export declare function notLoggedInError(): AuthError;
43
43
  * dry-run retries). The generic 30s default was aborting those mid-flight
44
44
  * and surfacing a fabricated `NetworkError timed out` (exit 5) for a request
45
45
  * that was always going to succeed given enough time. Passed as the per-call
46
- * `{timeoutMs}` override at exactly the 3 call sites that hit those
47
- * endpoints (src/commands/scraps.ts): `scraps run` / `data --fresh`
48
- * (GET /api/scraps/load/:id) and `scraps trigger --wait`
49
- * (POST /api/scraps/worker/:id, synchronous branch only — the default async
50
- * `?wait=false` POST returns almost immediately and keeps the 30s default).
51
- * 300s leaves margin over the ~250s worst case without being unboundedly long.
46
+ * `{timeoutMs}` override at exactly the 4 call sites that hit those
47
+ * endpoints: `scraps run` / `data --fresh` (GET /api/scraps/load/:id) and
48
+ * `scraps trigger --wait` (POST /api/scraps/worker/:id, synchronous branch
49
+ * only — the default async `?wait=false` POST returns almost immediately and
50
+ * keeps the 30s default) all three in src/commands/scraps.ts — plus (#114)
51
+ * `create` (POST /api/ai/wizard, src/commands/create.ts), whose AI-generation
52
+ * + scrap creation + first run + autofix pipeline runs the same 30–250s+
53
+ * server-side. 300s leaves margin over the ~250s worst case without being
54
+ * unboundedly long.
52
55
  */
53
56
  export declare const LONG_RUN_TIMEOUT_MS = 300000;
54
57
  export interface RequestOptions {
package/dist/lib/api.js CHANGED
@@ -63,12 +63,15 @@ const DEFAULT_TIMEOUT_MS = 30_000;
63
63
  * dry-run retries). The generic 30s default was aborting those mid-flight
64
64
  * and surfacing a fabricated `NetworkError timed out` (exit 5) for a request
65
65
  * that was always going to succeed given enough time. Passed as the per-call
66
- * `{timeoutMs}` override at exactly the 3 call sites that hit those
67
- * endpoints (src/commands/scraps.ts): `scraps run` / `data --fresh`
68
- * (GET /api/scraps/load/:id) and `scraps trigger --wait`
69
- * (POST /api/scraps/worker/:id, synchronous branch only — the default async
70
- * `?wait=false` POST returns almost immediately and keeps the 30s default).
71
- * 300s leaves margin over the ~250s worst case without being unboundedly long.
66
+ * `{timeoutMs}` override at exactly the 4 call sites that hit those
67
+ * endpoints: `scraps run` / `data --fresh` (GET /api/scraps/load/:id) and
68
+ * `scraps trigger --wait` (POST /api/scraps/worker/:id, synchronous branch
69
+ * only — the default async `?wait=false` POST returns almost immediately and
70
+ * keeps the 30s default) all three in src/commands/scraps.ts — plus (#114)
71
+ * `create` (POST /api/ai/wizard, src/commands/create.ts), whose AI-generation
72
+ * + scrap creation + first run + autofix pipeline runs the same 30–250s+
73
+ * server-side. 300s leaves margin over the ~250s worst case without being
74
+ * unboundedly long.
72
75
  */
73
76
  export const LONG_RUN_TIMEOUT_MS = 300_000;
74
77
  /**
@@ -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 fetch <url> [--json] [--reason <text>] One-shot fetch + extract readable content from a public URL (no scrap needed)
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
@@ -37,21 +38,39 @@ trawl whoami [--json] Show the authenticated user's ide
37
38
  trawl ping [--json] Health/version handshake against the Trawl API
38
39
  ```
39
40
 
40
- `trawl fetch` is the one command with no persisted scrap behind it a
41
- one-shot fetch + extract for any public URL, the closest primitive to
42
- "just get me this page's content." It's the REST counterpart of the MCP
43
- `trawl_fetch_url` tool (same shared engine): `status` is an honest outcome
44
- (`completed`/`failed`/`empty`/`blocked`), not "did the HTTP call succeed" — a
45
- failed fetch is still a 200 response with `status:'failed'` + `error`, and
46
- the CLI exits `1` in that case even though `--json` always prints the raw
47
- payload verbatim.
41
+ `trawl create` is the closest primitive to "turn this URL + goal into a
42
+ working scrap" it runs the AI wizard server-side (`POST /api/ai/wizard`):
43
+ generate scrap code from `--prompt` via LLM, persist the scrap, trigger its
44
+ first run, and auto-fix on failure (default on; `--no-autofix` disables it).
45
+ `success` is an honest outcome of that first run, not "did the HTTP call
46
+ succeed" — a failed first run is still a 200 response (the scrap was still
47
+ created), and the CLI exits `1` in that case even though `--json` always
48
+ prints the raw payload verbatim. The call legitimately runs 30–250s+
49
+ server-side (AI generation + a real run) — the CLI arms the same long-run
50
+ timeout `run`/`data --fresh`/`trigger --wait` use instead of the generic 30s
51
+ default.
52
+
53
+ > **Not idempotent, and not a one-off run.** A client-side timeout (exit `5`,
54
+ > a `NetworkError`) does not mean the wizard failed server-side — scrap
55
+ > creation + the first run keep going after the CLI gives up waiting, so the
56
+ > scrap may already exist. Check `trawl list --json` for a matching URL/title
57
+ > **before** retrying — a blind retry creates a duplicate scrap and burns
58
+ > quota a second time for the same goal. Separately, the created scrap is
59
+ > scheduled to re-run every day at 07:00 UTC by default
60
+ > (`cron: "0 7 * * *"`, unrelated to `--no-autofix`) — each recurring run
61
+ > consumes execute quota. Disable or change it once you've reviewed the
62
+ > scrap: `trawl scraps update <id> --no-cron` (or `--cron <expr>`). Finally,
63
+ > if `TRAWL_TIMEOUT` is set globally for a tighter budget than 300s, it
64
+ > clamps `create`'s ceiling too (env always wins) — unset it or raise it
65
+ > before calling `create`.
48
66
 
49
67
  > **No breaking change:** every verb above is also still reachable under its
50
68
  > pre-reorg path, `trawl scraps <verb>` (e.g. `trawl scraps list`) — kept as
51
69
  > a hidden alias. Prefer the bare top-level form above; it's what
52
70
  > `trawl --help` now shows.
53
71
 
54
- For the full flag reference (tier overrides on `create`/`update`, the
72
+ For the full flag reference (tier overrides on `scraps create`/`scraps
73
+ update` — the core `create` verb above has no `--tier` of its own, the
55
74
  `--watch` polling mechanics, retention/regression semantics on `data`, …)
56
75
  see the README's [Core commands](../README.md#core-commands-agent--human) section
57
76
  — this doc intentionally stays minimal.
@@ -85,19 +104,19 @@ hanging. Full rule + rationale: README's
85
104
  | Code | Meaning |
86
105
  |------|---------|
87
106
  | `0` | Success |
88
- | `1` | Unknown/generic error, or a business-logic outcome (e.g. `fetch`'s honest `status:'failed'`/`'blocked'`, `data`'s `run_failed`/`in_progress`) |
107
+ | `1` | Unknown/generic error, or a business-logic outcome (e.g. `create`'s honest `success:false` first-run outcome, `data`'s `run_failed`/`in_progress`) |
89
108
  | `2` | Usage error (bad flag/value, invalid ID, missing required argument, or the non-interactive guard refusing to prompt) |
90
109
  | `3` | Auth error (not logged in, or the session token is expired/invalid) |
91
110
  | `4` | Not found (no such resource, or no persisted payload to read) |
92
111
  | `5` | Network error (API host unreachable, DNS/connection/TLS failure, or timeout) |
93
112
 
94
113
  This table is the stable contract; per-command nuance and overloads (e.g.
95
- `fetch`'s domain-level failure sharing exit `1` with an unmapped bug) are
114
+ `create`'s domain-level failure sharing exit `1` with an unmapped bug) are
96
115
  documented once, in the README's [Exit codes](../README.md#exit-codes)
97
116
  section — treat that as canonical if the two ever seem to disagree.
98
117
 
99
118
  ## Minimal example
100
119
 
101
120
  ```bash
102
- TRAWL_TOKEN=<jwt> trawl fetch https://example.com --json
121
+ TRAWL_TOKEN=<jwt> trawl create https://example.com --prompt "Extract the article title and body text" --json
103
122
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.22.0",
3
+ "version": "2.1.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,31 +0,0 @@
1
- import { Command } from 'commander';
2
- /**
3
- * `POST /api/scraps/fetch-url` response contract (#1697, trawl_node —
4
- * shipped, contract LOCKED). REST counterpart of the MCP `trawl_fetch_url`
5
- * tool: both run through the same shared engine
6
- * (scraps.fetchUrl.service.js#runEphemeralUrlFetch), so the outcome shape is
7
- * identical regardless of transport.
8
- *
9
- * - `status`: 'completed' | 'failed' | 'empty' | 'blocked' — an HONEST
10
- * outcome, not a bare "it ran". A failed/blocked/empty run is still a
11
- * 200 response (this is not an HTTP error), so callers must branch on
12
- * `status`, never assume 2xx means "got data".
13
- * - `error` is present ONLY when `status === 'failed'`.
14
- * - `truncated: true` when the 100KB response-payload cap tripped — the
15
- * server drops `result` in that case and repoints `url` at the full
16
- * history row (`/api/historys/:runId`) instead of echoing the fetched
17
- * target.
18
- */
19
- export interface FetchUrlResponse {
20
- url: string;
21
- status: 'completed' | 'failed' | 'empty' | 'blocked';
22
- runId?: string | null;
23
- statusDetail?: string | null;
24
- blocked?: boolean;
25
- length?: number | null;
26
- result?: unknown;
27
- truncated?: boolean;
28
- reason?: string;
29
- error?: string;
30
- }
31
- export declare const fetchUrl: Command;
@@ -1,95 +0,0 @@
1
- import { Command } from 'commander';
2
- import chalk from 'chalk';
3
- import { oraPromise } from 'ora';
4
- import { api, LONG_RUN_TIMEOUT_MS } from '../lib/api.js';
5
- import { json } from '../lib/format.js';
6
- import { requireUrl } from '../lib/validate.js';
7
- function statusIcon(status) {
8
- if (status === 'completed')
9
- return chalk.green('✓');
10
- if (status === 'failed')
11
- return chalk.red('✗');
12
- if (status === 'blocked')
13
- return chalk.yellow('⚠');
14
- return chalk.dim('•'); // empty
15
- }
16
- /** Best-effort human summary of `result` — same "count + first-item keys"
17
- * shape scraps.ts's renderScrapItems uses, never a full dump (that's what
18
- * --json is for). */
19
- function renderResultSummary(result) {
20
- if (!Array.isArray(result))
21
- return;
22
- console.log(chalk.dim(` Items: `) + result.length);
23
- const first = result[0];
24
- if (result.length > 0 && first && typeof first === 'object') {
25
- console.log(chalk.dim(` First keys: `) + Object.keys(first).join(', '));
26
- }
27
- }
28
- export const fetchUrl = new Command('fetch')
29
- .description('One-shot fetch + extract readable content from a public URL (no scrap needed)')
30
- .argument('<url>', 'Target public HTTPS URL')
31
- .option('--json', 'Output the raw API payload')
32
- .option('--reason <reason>', 'Audit-trail reason for this fetch (logged server-side, max 500 chars)')
33
- .action(async (rawUrl, opts) => {
34
- // Fast, local usage-error (exit 2) on an obviously malformed URL — never
35
- // a round-trip to the server for something we can already tell is bad.
36
- // The server still re-validates (SSRF guard) — this is a UX fast-path,
37
- // not a security boundary.
38
- const url = requireUrl(rawUrl, 'url');
39
- const body = {
40
- url,
41
- ...(opts.reason !== undefined && { reason: opts.reason }),
42
- };
43
- // #106 review F1 — this endpoint runs the FULL worker pipeline (browser
44
- // `goto` networkidle2 + extraction + persistence), legitimately 30-250s
45
- // server-side — same #91 P0 pattern as `scraps run` / `data --fresh` /
46
- // `trigger --wait` (src/commands/scraps.ts). The 30s DEFAULT_TIMEOUT_MS
47
- // was aborting it mid-flight and would have surfaced a fabricated
48
- // NetworkError timeout for a request that was always going to succeed.
49
- const call = () => api.post('/api/scraps/fetch-url', body, { timeoutMs: LONG_RUN_TIMEOUT_MS });
50
- // #106 review F2 — under --json the stdout path must be provably pure:
51
- // no spinner channel at all. Only the human path gets the ora progress
52
- // indicator; --json calls the API directly.
53
- const data = opts.json
54
- ? await call()
55
- : await oraPromise(call, {
56
- text: `Fetching ${url}…`,
57
- // #106 review F3 — no successText verdict here. ora's success
58
- // symbol only means "the HTTP call didn't throw", not "the fetch
59
- // succeeded" — a failed/blocked domain `status` is still a 200
60
- // response. The real outcome is rendered below via statusIcon +
61
- // the Status: line; a green check here would contradict a
62
- // red/yellow icon printed right after it.
63
- successText: 'Request complete',
64
- });
65
- if (opts.json) {
66
- json(data);
67
- }
68
- else {
69
- console.log(`${statusIcon(data.status)} ${chalk.bold(data.url)}`);
70
- console.log(chalk.dim(` Status: `) + data.status);
71
- if (data.runId)
72
- console.log(chalk.dim(` Run ID: `) + data.runId);
73
- if (data.statusDetail)
74
- console.log(chalk.dim(` Detail: `) + data.statusDetail);
75
- if (data.length != null)
76
- console.log(chalk.dim(` Length: `) + data.length);
77
- if (data.truncated) {
78
- console.log(chalk.yellow(` ⚠ Truncated (payload too large) — full result: ${data.url}`));
79
- }
80
- if (data.status === 'failed' && data.error) {
81
- console.log(chalk.red(` Error: `) + data.error);
82
- }
83
- renderResultSummary(data.result);
84
- }
85
- // Honest exit code alongside the honest payload — a --json caller gets
86
- // the raw body regardless (never wrapped/altered), but a script checking
87
- // the exit code alone must be able to tell "no usable data" from "ran
88
- // fine" without parsing. `blocked` (#106 review F4 — an antibot wall) is
89
- // a failure to get data exactly like `failed`: an agent scripting
90
- // `trawl fetch ... || handle` must see non-zero for either. `empty`
91
- // stays 0 on purpose — the fetch genuinely ran to completion, there was
92
- // just nothing extractable at that URL; that's not an error.
93
- if (data.status === 'failed' || data.status === 'blocked')
94
- process.exitCode = 1;
95
- });