@trawlme/cli 1.19.0 → 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 +11 -1
- package/dist/commands/fetch.d.ts +31 -0
- package/dist/commands/fetch.js +95 -0
- package/dist/commands/ping.d.ts +24 -0
- package/dist/commands/ping.js +29 -0
- package/dist/commands/whoami.d.ts +27 -0
- package/dist/commands/whoami.js +24 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +28 -4
- package/package.json +1 -1
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
|
```
|
|
@@ -83,7 +93,7 @@ trawl scraps account session set <id> -c <file>
|
|
|
83
93
|
|
|
84
94
|
### Claude Code skills
|
|
85
95
|
|
|
86
|
-
The CLI bundles
|
|
96
|
+
The CLI bundles 5 Claude Code skills that teach Claude how to use `trawl`. Once installed, Claude can manage scraps for you via prompts.
|
|
87
97
|
|
|
88
98
|
```
|
|
89
99
|
trawl skills list List bundled skills and install status
|
|
@@ -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.d.ts
CHANGED
|
@@ -24,7 +24,18 @@ export declare function resolveCommandName(actionCommand: Command | undefined):
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function collectCommandNames(root: Command): string[];
|
|
26
26
|
export declare function createProgram(): Command;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* True when this module is the process entrypoint (not merely imported by a test).
|
|
29
|
+
*
|
|
30
|
+
* npm installs the global bin as a SYMLINK (`bin/trawl` -> `dist/index.js`). For an
|
|
31
|
+
* ESM entrypoint Node realpaths the module URL (`import.meta.url` = the real
|
|
32
|
+
* `dist/index.js`) but leaves `process.argv[1]` as the symlink path — so a raw
|
|
33
|
+
* `moduleUrl === pathToFileURL(argv1).href` compare is FALSE for the normal global
|
|
34
|
+
* invocation, the guard never fires, and `runCli()` never runs (silent no-op, #103).
|
|
35
|
+
* Canonicalise both sides with `realpathSync` before comparing. A genuine entrypoint
|
|
36
|
+
* was just loaded by Node, so both realpath calls resolve; the catch only trips for a
|
|
37
|
+
* non-file module URL (exotic loaders) — correctly "not the entrypoint".
|
|
38
|
+
*/
|
|
28
39
|
export declare function isEntryPoint(argv1: string | undefined, moduleUrl: string): boolean;
|
|
29
40
|
/**
|
|
30
41
|
* True when the invocation is a pure `--help`/`--version` query, a bare
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, CommanderError } from 'commander';
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { fileURLToPath
|
|
3
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { login, logout } from './commands/login.js';
|
|
7
7
|
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,11 +75,32 @@ 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
|
+
/**
|
|
84
|
+
* True when this module is the process entrypoint (not merely imported by a test).
|
|
85
|
+
*
|
|
86
|
+
* npm installs the global bin as a SYMLINK (`bin/trawl` -> `dist/index.js`). For an
|
|
87
|
+
* ESM entrypoint Node realpaths the module URL (`import.meta.url` = the real
|
|
88
|
+
* `dist/index.js`) but leaves `process.argv[1]` as the symlink path — so a raw
|
|
89
|
+
* `moduleUrl === pathToFileURL(argv1).href` compare is FALSE for the normal global
|
|
90
|
+
* invocation, the guard never fires, and `runCli()` never runs (silent no-op, #103).
|
|
91
|
+
* Canonicalise both sides with `realpathSync` before comparing. A genuine entrypoint
|
|
92
|
+
* was just loaded by Node, so both realpath calls resolve; the catch only trips for a
|
|
93
|
+
* non-file module URL (exotic loaders) — correctly "not the entrypoint".
|
|
94
|
+
*/
|
|
78
95
|
export function isEntryPoint(argv1, moduleUrl) {
|
|
79
|
-
|
|
96
|
+
if (argv1 === undefined)
|
|
97
|
+
return false;
|
|
98
|
+
try {
|
|
99
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argv1);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
80
104
|
}
|
|
81
105
|
/**
|
|
82
106
|
* True when the invocation is a pure `--help`/`--version` query, a bare
|