@trawlme/cli 1.19.1 → 1.20.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
@@ -38,6 +38,16 @@ trawl logout
38
38
  trawl token Print the stored session JWT (for MCP Bearer auth)
39
39
  ```
40
40
 
41
+ ### Agent core verbs
42
+
43
+ ```
44
+ trawl fetch <url> [--json] [--reason <text>] One-shot fetch + extract readable content from a public URL (no scrap needed)
45
+ trawl whoami [--json] Show the authenticated user's identity
46
+ trawl ping [--json] Health/version handshake against the Trawl API
47
+ ```
48
+
49
+ 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.
50
+
41
51
  ### Scraps
42
52
 
43
53
  ```
@@ -0,0 +1,31 @@
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;
@@ -0,0 +1,95 @@
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
+ });
@@ -0,0 +1,24 @@
1
+ import { Command } from 'commander';
2
+ /**
3
+ * `GET /api/health` response shape (home.controller.js#health,
4
+ * home.service.js#getHealthStatus) — MCP `trawl_health_ping` parity
5
+ * (`{ok, version, uptime}`). The REST route enriches the payload with
6
+ * `version`/`uptime`/`db`/`memory` ONLY for an admin caller
7
+ * (home.controller.js's `isAdmin` gate); a non-admin JWT gets `{status:'ok'}`
8
+ * alone on a healthy server. A non-2xx response (degraded, `status:503`)
9
+ * throws an ApiError like any other failed request — but a 2xx response is
10
+ * NOT a guarantee that `status === 'ok'` (#106 review F5): a soft-degraded
11
+ * signal (e.g. a partial dependency outage) could still come back as a 200
12
+ * with a non-"ok" `status`. The HTTP call not throwing only means the
13
+ * transport succeeded, never that the reported health is good — so the
14
+ * human-mode `✓ OK` line is gated on the actual field, not assumed from a
15
+ * successful round-trip.
16
+ */
17
+ export interface PingResponse {
18
+ status: string;
19
+ db?: string;
20
+ uptime?: number;
21
+ version?: string;
22
+ memory?: unknown;
23
+ }
24
+ export declare const ping: Command;
@@ -0,0 +1,29 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { api } from '../lib/api.js';
4
+ import { json } from '../lib/format.js';
5
+ export const ping = new Command('ping')
6
+ .description('Health/version handshake against the Trawl API')
7
+ .option('--json', 'Output as JSON')
8
+ .action(async (opts) => {
9
+ const data = await api.get('/api/health');
10
+ // #106 review (kimi) — honest exit code: a soft-degraded 200 (status !== 'ok')
11
+ // must exit non-zero so `trawl ping || handle_degraded` doesn't treat a
12
+ // degraded API as healthy. Applies in BOTH --json and human modes, alongside
13
+ // the raw/decorated output below.
14
+ if (data.status !== 'ok')
15
+ process.exitCode = 1;
16
+ if (opts.json) {
17
+ json(data);
18
+ return;
19
+ }
20
+ const versionSuffix = data.version ? ` (v${data.version})` : '';
21
+ if (data.status === 'ok') {
22
+ console.log(chalk.green('✓ OK') + versionSuffix);
23
+ }
24
+ else {
25
+ // #106 review F5 — never green-light a degraded API just because the
26
+ // HTTP call didn't throw; show the real reported status instead.
27
+ console.log(chalk.yellow(`⚠ ${data.status}`) + versionSuffix);
28
+ }
29
+ });
@@ -0,0 +1,27 @@
1
+ import { Command } from 'commander';
2
+ /**
3
+ * `GET /api/users/me` response shape (users.account.controller.js#me).
4
+ *
5
+ * Chosen over the MCP `trawl_whoami` tool's own payload
6
+ * (userId/organizationId/organizationName/scrapIdsAllowlist/mode) because
7
+ * that shape lives entirely in the MCP per-call auth-bridge context
8
+ * (developers.mcp.js) — there is no equivalent REST route exposing
9
+ * organization/scope/mode for a session-JWT caller today. `/api/users/me`
10
+ * is the closest REST parity match: real identity, already used by every
11
+ * other trawl_node client. `roles` is the nearest analogue to "scopes";
12
+ * there is currently no "plan" field on this endpoint.
13
+ */
14
+ export interface WhoamiResponse {
15
+ id?: string;
16
+ email?: string;
17
+ provider?: string;
18
+ roles?: string[];
19
+ firstName?: string;
20
+ lastName?: string;
21
+ avatar?: string;
22
+ complementary?: unknown;
23
+ bio?: string;
24
+ position?: string;
25
+ terms?: unknown;
26
+ }
27
+ export declare const whoami: Command;
@@ -0,0 +1,24 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { api } from '../lib/api.js';
4
+ import { json } from '../lib/format.js';
5
+ export const whoami = new Command('whoami')
6
+ .description("Show the authenticated user's identity")
7
+ .option('--json', 'Output as JSON')
8
+ .action(async (opts) => {
9
+ const data = await api.get('/api/users/me');
10
+ if (opts.json) {
11
+ json(data);
12
+ return;
13
+ }
14
+ if (!data) {
15
+ console.log(chalk.dim('No identity information available.'));
16
+ return;
17
+ }
18
+ const name = [data.firstName, data.lastName].filter(Boolean).join(' ');
19
+ console.log(chalk.bold(name || data.email || '(unknown)'));
20
+ console.log(chalk.dim(` ID: `) + (data.id ?? '—'));
21
+ console.log(chalk.dim(` Email: `) + (data.email ?? '—'));
22
+ console.log(chalk.dim(` Provider: `) + (data.provider ?? '—'));
23
+ console.log(chalk.dim(` Roles: `) + (data.roles?.length ? data.roles.join(', ') : '—'));
24
+ });
package/dist/index.js CHANGED
@@ -8,6 +8,9 @@ import { scraps } from './commands/scraps.js';
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';
12
+ import { whoami } from './commands/whoami.js';
13
+ import { ping } from './commands/ping.js';
11
14
  import { autoUpdateInstalledSkills } from './lib/skills.js';
12
15
  import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
13
16
  import { classifyError, reportError } from './lib/errors.js';
@@ -72,6 +75,9 @@ export function createProgram() {
72
75
  program.addCommand(skills);
73
76
  program.addCommand(telemetry);
74
77
  program.addCommand(token);
78
+ program.addCommand(fetchUrl);
79
+ program.addCommand(whoami);
80
+ program.addCommand(ping);
75
81
  return program;
76
82
  }
77
83
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.19.1",
3
+ "version": "1.20.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {