@trawlme/cli 3.5.0 → 3.6.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
@@ -6,7 +6,7 @@
6
6
 
7
7
  Command-line client for [Trawl](https://trawl.me) — manage your scraps from the terminal.
8
8
 
9
- > ⚠️ **Early stage.** APIs may change before `1.0`. Pin a version if you depend on this in CI.
9
+ > 📌 **Versioned with semantic-release** breaking CLI-surface changes always ship as a new major. Pin a major (`@trawlme/cli@^3`) if you depend on the CLI in CI.
10
10
 
11
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
 
@@ -48,7 +48,7 @@ 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
51
+ trawl run <id> [--watch] [--json] Run a scrap (blocks until finished)
52
52
  trawl list|ls [--json] [--status <success|failure|never|running|regression>] [--limit <n>] [--page <n>]
53
53
  trawl get <id> [--json] Get scrap details
54
54
  trawl data <id> [--json] [--fresh] [--errors] Get scrap data (last persisted run — read-only, no quota; --fresh to launch one)
@@ -59,6 +59,8 @@ trawl whoami [--json] Show the authenticated user's ide
59
59
  trawl ping [--json] Health/version handshake against the Trawl API
60
60
  ```
61
61
 
62
+ (`trawl create` AI-generates and persists a new scrap from a URL + a goal — distinct from `trawl scraps create`, which is raw manual scrap entry.)
63
+
62
64
  > **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.
63
65
 
64
66
  `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`). On a successful first run (human mode) it prints a small **data sample** (item count + first-item fields + one truncated value) as proof of value — best-effort, silent if the sample can't be fetched — and points `Next step` at `trawl data <id>` (the data), with `trawl get <id>` as the secondary detail view. `--json` skips the sample fetch and prints the raw wizard payload verbatim. `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.
@@ -243,6 +245,8 @@ This project uses `legacy-peer-deps` (see `.npmrc`) due to a transitive peer-dep
243
245
 
244
246
  Issues and PRs are welcome — please open an issue first for non-trivial changes so we can align on direction.
245
247
 
248
+ Releases: a merge to `main` publishes to the npm **`next`** dist-tag (candidate channel — try it with `npm i -g @trawlme/cli@next`). The **`latest`** tag is promoted manually after QA, so a merge never ships to default installs directly.
249
+
246
250
  ## License
247
251
 
248
252
  MIT — see [LICENSE](./LICENSE).
@@ -88,7 +88,7 @@ async function printDataSample(historyId) {
88
88
  }
89
89
  }
90
90
  export const create = new Command('create')
91
- .description('Create a persistent, self-healing scrap from a URL + a goal (AI-generated)')
91
+ .description('Create a persistent, self-healing scrap from a URL + a goal (AI-generated) — distinct from `trawl scraps create`, which is raw manual entry')
92
92
  .argument('[url]', 'Target public URL (http/https)')
93
93
  .requiredOption('--prompt <goal>', 'What to extract/scrape, in plain language (required)')
94
94
  .option('--url <url>', 'Target public URL — alias of the positional argument')
@@ -6,7 +6,14 @@ export const ping = new Command('ping')
6
6
  .description('Health/version handshake against the Trawl API')
7
7
  .option('--json', 'Output as JSON')
8
8
  .action(async (opts) => {
9
- const data = await api.get('/api/health');
9
+ // #148 the server route is optionalAuth (public, enriched for an admin
10
+ // JWT — see the PingResponse doc above); `api.publicGet` mirrors that:
11
+ // it attaches a stored/env token when one happens to be available but
12
+ // never REQUIRES one, so `ping` works as a zero-config sanity check even
13
+ // with no `trawl login` ever run. `api.get` (the authenticated path)
14
+ // would throw notLoggedInError() locally before this ever reached the
15
+ // network, defeating the whole point of a pre-login handshake.
16
+ const data = await api.publicGet('/api/health');
10
17
  // #106 review (kimi) — honest exit code: a soft-degraded 200 (status !== 'ok')
11
18
  // must exit non-zero so `trawl ping || handle_degraded` doesn't treat a
12
19
  // degraded API as healthy. Applies in BOTH --json and human modes, alongside
@@ -9,6 +9,7 @@ import { classifyError, reportError, UsageError, RefusalError } from '../lib/err
9
9
  import { confirmDestructive, isInteractive } from '../lib/confirm.js';
10
10
  import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
11
11
  import { renderPinch, pinchEnabled } from '../lib/pinch.js';
12
+ import { maybeShowReferralTip } from '../lib/tips.js';
12
13
  /**
13
14
  * Print a usage/validation error consistently: human text to stderr, or a
14
15
  * machine envelope on stdout under --json (never both — reportError is the
@@ -710,7 +711,7 @@ scraps
710
711
  export function attachRunCommand(parent, attachOpts = {}) {
711
712
  return parent
712
713
  .command('run <id>', attachOpts)
713
- .description('Run a scrap')
714
+ .description('Run a scrap (blocks until finished)')
714
715
  .option('-w, --watch', 'Show progress after launching (polls — see `trawl trigger --watch`, #91)')
715
716
  .option('--json', 'Output the raw launch payload as JSON')
716
717
  .action(async (id, opts) => {
@@ -738,6 +739,16 @@ export function attachRunCommand(parent, attachOpts = {}) {
738
739
  // of what the watched run actually did.
739
740
  await pollRunProgress(id, beforeRun, { json: opts.json });
740
741
  }
742
+ // #151 — throttled post-run referral tip. Reached only when the
743
+ // launch call above didn't throw (a hard failure rejects it, unwinding
744
+ // straight to index.ts's central catch before this line ever runs)
745
+ // — and, when --watch polled to a terminal state, only when
746
+ // pollRunProgress didn't flag a genuine run failure/timeout/poll-error
747
+ // via a non-zero process.exitCode (#107 review F1). See lib/tips.ts
748
+ // for the throttle/TTY/json/opt-out gating itself.
749
+ const runFailed = typeof process.exitCode === 'number' && process.exitCode !== 0;
750
+ if (!runFailed)
751
+ maybeShowReferralTip({ json: opts.json });
741
752
  });
742
753
  }
743
754
  attachRunCommand(scraps, { hidden: true });
package/dist/index.js CHANGED
@@ -293,7 +293,29 @@ export async function runCli(argv = process.argv) {
293
293
  }
294
294
  });
295
295
  try {
296
- await program.parseAsync(argv);
296
+ // #148 — a bare `trawl` (no args at all) reads as "let me look around",
297
+ // not a failure: it should behave exactly like `trawl --help` (same
298
+ // text, stdout, exit 0). Left to commander's own default, a program with
299
+ // subcommands and no root action handler treats zero args as "probably
300
+ // missing subcommand" and calls `this.help({ error: true })` internally
301
+ // (see node_modules/commander/lib/command.js `_parseCommand`) — which
302
+ // writes the SAME help text to stderr and throws a CommanderError with
303
+ // exitCode 1 (caught below, bucketed into HELP_OR_VERSION_CODES since its
304
+ // code is 'commander.help', so only the exit code carries the mistake
305
+ // through — text already went to the wrong stream by then). Intercepting
306
+ // here, before parseAsync ever runs, avoids fighting that internal
307
+ // decision entirely and can't affect a genuine unknown top-level command
308
+ // (`trawl frobnicate`) — that path only runs when `isBareInvocation` is
309
+ // false, so it still reaches `this.unknownCommand()` unchanged (stderr,
310
+ // exit 2, code 'commander.unknownCommand' — never in
311
+ // HELP_OR_VERSION_CODES).
312
+ if (isBareInvocation(argv)) {
313
+ program.outputHelp();
314
+ process.exitCode = 0;
315
+ }
316
+ else {
317
+ await program.parseAsync(argv);
318
+ }
297
319
  }
298
320
  catch (err) {
299
321
  const { debug } = program.opts();
package/dist/lib/api.d.ts CHANGED
@@ -60,6 +60,7 @@ export interface RequestOptions {
60
60
  }
61
61
  export declare const api: {
62
62
  get: <T>(path: string, opts?: RequestOptions) => Promise<T>;
63
+ publicGet: <T>(path: string, opts?: RequestOptions) => Promise<T>;
63
64
  getText: (path: string, opts?: RequestOptions) => Promise<string>;
64
65
  post: <T>(path: string, body?: unknown, opts?: RequestOptions) => Promise<T>;
65
66
  put: <T>(path: string, body?: unknown, opts?: RequestOptions) => Promise<T>;
package/dist/lib/api.js CHANGED
@@ -314,6 +314,47 @@ async function publicPost(path, body, baseUrlOverride, reqOpts = {}) {
314
314
  throw new Error('Invalid JSON in server response');
315
315
  }
316
316
  }
317
+ /**
318
+ * Unauthenticated GET — for endpoints the server itself treats as public/
319
+ * optional-auth (e.g. `GET /api/health`, home.route.js's `optionalAuth`
320
+ * middleware: enriches the response for an admin JWT, passes through
321
+ * otherwise). Unlike `request()`, this NEVER throws `notLoggedInError()` for
322
+ * a missing token — that guard exists for endpoints that genuinely require
323
+ * auth, and applying it here defeated the whole point of a pre-login
324
+ * sanity-check (#148). The token is still attached as a best-effort Cookie
325
+ * when one happens to be stored/env-set, so an already-authenticated caller
326
+ * still gets the admin-enriched payload — it's just never REQUIRED.
327
+ * `throwIfError(res, true)` mirrors `publicPost`'s "public endpoint" 401
328
+ * wording, even though this route's optionalAuth middleware never actually
329
+ * rejects a request for lacking/invalid credentials.
330
+ */
331
+ async function publicGet(path, reqOpts = {}) {
332
+ const token = getToken();
333
+ const url = `${getApiUrl()}${path}`;
334
+ const timeoutMs = getTimeoutMs(reqOpts.timeoutMs);
335
+ const res = await safeFetch(url, {
336
+ headers: {
337
+ 'User-Agent': USER_AGENT,
338
+ ...(token ? { Cookie: `TOKEN=${token}` } : {}),
339
+ },
340
+ signal: AbortSignal.timeout(timeoutMs),
341
+ }, timeoutMs);
342
+ await throwIfError(res, true);
343
+ const text = await res.text();
344
+ try {
345
+ if (!text)
346
+ return {};
347
+ const parsed = JSON.parse(text);
348
+ // Unwrap API envelope { type, message, data: T }
349
+ if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
350
+ return parsed.data;
351
+ }
352
+ return parsed;
353
+ }
354
+ catch {
355
+ throw new Error('Invalid JSON in server response');
356
+ }
357
+ }
317
358
  async function getText(path, reqOpts = {}) {
318
359
  const token = getToken();
319
360
  if (!token)
@@ -332,6 +373,7 @@ async function getText(path, reqOpts = {}) {
332
373
  }
333
374
  export const api = {
334
375
  get: (path, opts) => request(path, {}, opts),
376
+ publicGet: (path, opts) => publicGet(path, opts),
335
377
  getText: (path, opts) => getText(path, opts),
336
378
  post: (path, body, opts) => request(path, {
337
379
  method: 'POST',
@@ -9,6 +9,10 @@ interface TrawlConfig {
9
9
  * only an explicit `false` disables it. No default entry needed since it's
10
10
  * optional. (#129) */
11
11
  updateNotifier?: boolean;
12
+ /** Opt-out switch for the throttled post-run referral tip (lib/tips.ts).
13
+ * Same optional/absent-means-enabled shape as `updateNotifier` above. */
14
+ tips?: boolean;
15
+ referralTipShownAt: number;
12
16
  }
13
17
  declare const config: Conf<TrawlConfig>;
14
18
  /**
@@ -16,6 +16,7 @@ const config = new Conf({
16
16
  token: '',
17
17
  telemetry: true,
18
18
  telemetryUserId: '',
19
+ referralTipShownAt: 0,
19
20
  },
20
21
  });
21
22
  /**
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pure gate: true when the tip should print. Every external signal (json
3
+ * flag, TTY-ness, opt-out, persisted timestamp, current time) is a
4
+ * parameter rather than read internally, so this is trivially testable
5
+ * without mocking process.env/stdout/Date/config.
6
+ */
7
+ export declare function isReferralTipDue(opts: {
8
+ json?: boolean;
9
+ isTTY: boolean;
10
+ optedOut: boolean;
11
+ lastShownAt: number;
12
+ now: number;
13
+ }): boolean;
14
+ /**
15
+ * Best-effort: print one subtle line inviting the user to refer a builder,
16
+ * throttled to once per 7 days via a timestamp persisted in the CLI's
17
+ * existing Conf-backed config store (lib/config.ts — same store as
18
+ * apiUrl/token/telemetry/updateNotifier).
19
+ *
20
+ * This only gates on throttle/TTY/`--json`/opt-out — it does NOT know
21
+ * whether the underlying command actually succeeded. Callers must only
22
+ * invoke it on a genuine success (see commands/scraps.ts `run <id>`'s
23
+ * action, which also checks pollRunProgress's exit code under `--watch`
24
+ * before calling this). Any state read/write failure (corrupt config file,
25
+ * disk error, …) is swallowed silently — a broken tip must never break a
26
+ * run.
27
+ */
28
+ export declare function maybeShowReferralTip(opts?: {
29
+ json?: boolean;
30
+ }): void;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Throttled post-run referral tip (#151) — after a successful `trawl run`,
3
+ * occasionally invite the user to refer a builder. Mirrors
4
+ * lib/updateNotifier.ts's shape: synchronous, instant, self-guarded so a
5
+ * broken tip can never turn a successful command into a failed one.
6
+ */
7
+ import chalk from 'chalk';
8
+ import config from './config.js';
9
+ /** Max once per 7 days. */
10
+ const TIP_THROTTLE_MS = 7 * 24 * 60 * 60 * 1000;
11
+ const REFERRAL_TIP_TEXT = 'Invite a builder — they get 500, you get 1,000 compute → trawl.me → account → Referrals';
12
+ /**
13
+ * Opted out via env (presence-based, mirrors NO_COLOR / the update
14
+ * notifier's TRAWL_NO_UPDATE_NOTIFIER — see lib/updateNotifier.ts) or an
15
+ * explicit `false` config value. Absent/undefined config key means enabled.
16
+ */
17
+ function isOptedOut() {
18
+ if (process.env['TRAWL_NO_TIPS'] !== undefined)
19
+ return true;
20
+ return config.get('tips') === false;
21
+ }
22
+ /**
23
+ * Pure gate: true when the tip should print. Every external signal (json
24
+ * flag, TTY-ness, opt-out, persisted timestamp, current time) is a
25
+ * parameter rather than read internally, so this is trivially testable
26
+ * without mocking process.env/stdout/Date/config.
27
+ */
28
+ export function isReferralTipDue(opts) {
29
+ if (opts.json)
30
+ return false;
31
+ if (!opts.isTTY)
32
+ return false;
33
+ if (opts.optedOut)
34
+ return false;
35
+ return opts.now - opts.lastShownAt >= TIP_THROTTLE_MS;
36
+ }
37
+ /**
38
+ * Best-effort: print one subtle line inviting the user to refer a builder,
39
+ * throttled to once per 7 days via a timestamp persisted in the CLI's
40
+ * existing Conf-backed config store (lib/config.ts — same store as
41
+ * apiUrl/token/telemetry/updateNotifier).
42
+ *
43
+ * This only gates on throttle/TTY/`--json`/opt-out — it does NOT know
44
+ * whether the underlying command actually succeeded. Callers must only
45
+ * invoke it on a genuine success (see commands/scraps.ts `run <id>`'s
46
+ * action, which also checks pollRunProgress's exit code under `--watch`
47
+ * before calling this). Any state read/write failure (corrupt config file,
48
+ * disk error, …) is swallowed silently — a broken tip must never break a
49
+ * run.
50
+ */
51
+ export function maybeShowReferralTip(opts = {}) {
52
+ try {
53
+ const lastShownAt = config.get('referralTipShownAt') || 0;
54
+ const due = isReferralTipDue({
55
+ json: opts.json,
56
+ isTTY: Boolean(process.stdout.isTTY),
57
+ optedOut: isOptedOut(),
58
+ lastShownAt,
59
+ now: Date.now(),
60
+ });
61
+ if (!due)
62
+ return;
63
+ console.log(chalk.dim(REFERRAL_TIP_TEXT));
64
+ config.set('referralTipShownAt', Date.now());
65
+ }
66
+ catch {
67
+ // silent — #151: a broken tip must never break a run
68
+ }
69
+ }
@@ -27,7 +27,7 @@ first-class on every one, and none of them ever blocks on a prompt (see
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
30
+ trawl run <id> [--watch] [--json] Run a scrap (blocks until finished)
31
31
  trawl list|ls [--json] [--status <s>] [--limit <n>] [--page <n>] List all scraps
32
32
  trawl get <id> [--json] Get scrap details
33
33
  trawl data <id> [--json] [--fresh] [--errors] Get scrap data (last persisted run, or --fresh to launch one)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {