@trawlme/cli 1.20.0 → 1.22.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 +74 -51
- package/dist/commands/login.js +35 -11
- package/dist/commands/scraps.d.ts +61 -0
- package/dist/commands/scraps.js +635 -437
- package/dist/commands/skills.js +52 -6
- package/dist/commands/telemetry.js +23 -3
- package/dist/commands/token.js +9 -3
- package/dist/index.d.ts +8 -0
- package/dist/index.js +45 -7
- package/dist/lib/confirm.d.ts +70 -0
- package/dist/lib/confirm.js +79 -0
- package/dist/lib/errors.d.ts +13 -0
- package/dist/lib/errors.js +19 -0
- package/docs/agent-quickstart.md +103 -0
- package/package.json +2 -1
package/dist/commands/skills.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Command } from 'commander';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { listBundledSkills, installSkill, uninstallSkill, getBundledSkillsVersion, getInstalledVersion, isSkillInstalled, removeOrphanedSkills, } from '../lib/skills.js';
|
|
4
4
|
import { UsageError } from '../lib/errors.js';
|
|
5
|
+
import { json } from '../lib/format.js';
|
|
5
6
|
function pickScope(opts) {
|
|
6
7
|
return opts.local ? 'local' : 'user';
|
|
7
8
|
}
|
|
@@ -20,14 +21,21 @@ export const skills = new Command('skills').description('Manage Claude Code skil
|
|
|
20
21
|
skills
|
|
21
22
|
.command('list')
|
|
22
23
|
.description('List bundled skills and their installed status')
|
|
23
|
-
.
|
|
24
|
+
.option('--json', 'Output as JSON')
|
|
25
|
+
.action((opts) => {
|
|
24
26
|
const bundled = listBundledSkills();
|
|
27
|
+
const version = getBundledSkillsVersion();
|
|
25
28
|
if (!bundled.length) {
|
|
29
|
+
if (opts.json) {
|
|
30
|
+
json({ version, skills: [] });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
26
33
|
console.log(chalk.dim('No bundled skills.'));
|
|
27
34
|
return;
|
|
28
35
|
}
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
if (!opts.json)
|
|
37
|
+
console.log(chalk.dim(`@trawlme/skills@${version}\n`));
|
|
38
|
+
const rows = [];
|
|
31
39
|
for (const name of bundled) {
|
|
32
40
|
const userInstalled = isSkillInstalled(name, 'user');
|
|
33
41
|
const localInstalled = isSkillInstalled(name, 'local');
|
|
@@ -59,7 +67,16 @@ skills
|
|
|
59
67
|
installedVersion = null;
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
|
-
const stale = installedVersion && installedVersion !== version;
|
|
70
|
+
const stale = Boolean(installedVersion && installedVersion !== version);
|
|
71
|
+
if (opts.json) {
|
|
72
|
+
rows.push({
|
|
73
|
+
name,
|
|
74
|
+
installed: { user: userInstalled, local: localInstalled },
|
|
75
|
+
installedVersion,
|
|
76
|
+
outdated: stale,
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
63
80
|
const tag = userInstalled
|
|
64
81
|
? localInstalled
|
|
65
82
|
? '(user + local)'
|
|
@@ -70,46 +87,72 @@ skills
|
|
|
70
87
|
const staleNote = stale ? chalk.yellow(` outdated: ${installedVersion} → ${version}`) : '';
|
|
71
88
|
console.log(` ${name} ${chalk.dim(tag)}${staleNote}`);
|
|
72
89
|
}
|
|
90
|
+
if (opts.json)
|
|
91
|
+
json({ version, skills: rows });
|
|
73
92
|
});
|
|
74
93
|
skills
|
|
75
94
|
.command('install [skill]')
|
|
76
95
|
.description('Install one or all bundled skills')
|
|
77
96
|
.option('--local', 'Install at project level (./.claude/skills) instead of user level (~/.claude/skills)')
|
|
78
97
|
.option('--force', 'Overwrite a pre-existing dir even if trawl did not install it (no .version marker)')
|
|
98
|
+
.option('--json', 'Output as JSON')
|
|
79
99
|
.action((skill, opts) => {
|
|
80
100
|
const scope = pickScope(opts);
|
|
81
101
|
const targets = pickSkills(skill);
|
|
102
|
+
const results = [];
|
|
82
103
|
for (const name of targets) {
|
|
83
104
|
const dest = installSkill(name, scope, { force: opts.force });
|
|
105
|
+
if (opts.json) {
|
|
106
|
+
results.push({ name, scope, dest });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
84
109
|
console.log(chalk.green(`✓ Installed "${name}"`) + chalk.dim(` at ${dest}`));
|
|
85
110
|
}
|
|
111
|
+
if (opts.json) {
|
|
112
|
+
json({ installed: results });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
86
115
|
console.log(chalk.dim(' Restart Claude Code if it was already running.'));
|
|
87
116
|
});
|
|
88
117
|
skills
|
|
89
118
|
.command('uninstall [skill]')
|
|
90
119
|
.description('Remove one or all bundled skills')
|
|
91
120
|
.option('--local', 'Remove from project level (./.claude/skills)')
|
|
121
|
+
.option('--json', 'Output as JSON')
|
|
92
122
|
.action((skill, opts) => {
|
|
93
123
|
const scope = pickScope(opts);
|
|
94
124
|
const targets = pickSkills(skill);
|
|
125
|
+
const results = [];
|
|
95
126
|
for (const name of targets) {
|
|
96
127
|
const dest = uninstallSkill(name, scope);
|
|
128
|
+
if (opts.json) {
|
|
129
|
+
results.push({ name, scope, dest, removed: dest !== null });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
97
132
|
if (dest)
|
|
98
133
|
console.log(chalk.green(`✓ Removed "${name}"`) + chalk.dim(` from ${dest}`));
|
|
99
134
|
else
|
|
100
135
|
console.log(chalk.dim(` No "${name}" found`));
|
|
101
136
|
}
|
|
137
|
+
if (opts.json)
|
|
138
|
+
json({ uninstalled: results });
|
|
102
139
|
});
|
|
103
140
|
skills
|
|
104
141
|
.command('update [skill]')
|
|
105
142
|
.description('Reinstall over the existing skill (force sync with CLI version)')
|
|
106
143
|
.option('--local', 'Update at project level')
|
|
107
144
|
.option('--force', 'Overwrite a pre-existing dir even if trawl did not install it (no .version marker)')
|
|
145
|
+
.option('--json', 'Output as JSON')
|
|
108
146
|
.action((skill, opts) => {
|
|
109
147
|
const scope = pickScope(opts);
|
|
110
148
|
const targets = pickSkills(skill);
|
|
149
|
+
const results = [];
|
|
111
150
|
for (const name of targets) {
|
|
112
151
|
const dest = installSkill(name, scope, { force: opts.force });
|
|
152
|
+
if (opts.json) {
|
|
153
|
+
results.push({ name, scope, dest });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
113
156
|
console.log(chalk.green(`✓ Updated "${name}"`) + chalk.dim(` at ${dest}`));
|
|
114
157
|
}
|
|
115
158
|
// #86 review — same orphan sweep as the startup auto-sync: an explicit
|
|
@@ -117,6 +160,9 @@ skills
|
|
|
117
160
|
// or removed upstream (e.g. 1.0.0's `trawl` → 1.3.1's `trawl-cli`),
|
|
118
161
|
// instead of leaving a stale ghost teaching outdated usage. Only the
|
|
119
162
|
// scope being updated is swept; marker-less dirs are never touched.
|
|
120
|
-
// removeOrphanedSkills prints its own honest stderr line per removal
|
|
121
|
-
|
|
163
|
+
// removeOrphanedSkills prints its own honest stderr line per removal
|
|
164
|
+
// (stderr, so it's safe under --json too).
|
|
165
|
+
const orphansRemoved = removeOrphanedSkills(scope);
|
|
166
|
+
if (opts.json)
|
|
167
|
+
json({ updated: results, orphansRemoved });
|
|
122
168
|
});
|
|
@@ -2,33 +2,53 @@ import { Command } from 'commander';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import config from '../lib/config.js';
|
|
4
4
|
import { initPostHog } from '../lib/posthog.js';
|
|
5
|
+
import { json } from '../lib/format.js';
|
|
5
6
|
export const telemetry = new Command('telemetry').description('Manage CLI telemetry (usage data collection)');
|
|
6
7
|
telemetry
|
|
7
8
|
.command('on')
|
|
8
9
|
.description('Enable telemetry (default)')
|
|
9
|
-
.
|
|
10
|
+
.option('--json', 'Output as JSON')
|
|
11
|
+
.action((opts) => {
|
|
10
12
|
config.set('telemetry', true);
|
|
11
13
|
// Ensure a telemetryUserId is created on opt-in
|
|
12
14
|
initPostHog();
|
|
15
|
+
if (opts.json) {
|
|
16
|
+
json({ telemetry: true });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
13
19
|
console.log(chalk.green('✓ Telemetry enabled'));
|
|
14
20
|
console.log(chalk.dim(' Anonymous usage data will be collected. Run `trawl telemetry status` to view.'));
|
|
15
21
|
});
|
|
16
22
|
telemetry
|
|
17
23
|
.command('off')
|
|
18
24
|
.description('Disable telemetry')
|
|
19
|
-
.
|
|
25
|
+
.option('--json', 'Output as JSON')
|
|
26
|
+
.action((opts) => {
|
|
20
27
|
config.set('telemetry', false);
|
|
28
|
+
if (opts.json) {
|
|
29
|
+
json({ telemetry: false });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
21
32
|
console.log(chalk.green('✓ Telemetry disabled'));
|
|
22
33
|
console.log(chalk.dim(' No data will be sent. Your telemetry ID is kept for if you re-enable later.'));
|
|
23
34
|
});
|
|
24
35
|
telemetry
|
|
25
36
|
.command('status')
|
|
26
37
|
.description('Show current telemetry state')
|
|
27
|
-
.
|
|
38
|
+
.option('--json', 'Output as JSON')
|
|
39
|
+
.action((opts) => {
|
|
28
40
|
const enabled = config.get('telemetry') !== false;
|
|
29
41
|
const userId = config.get('telemetryUserId') || '(not yet generated)';
|
|
30
42
|
const envOverride = process.env['TRAWL_TELEMETRY'] === '0';
|
|
31
43
|
const doNotTrack = process.env['DO_NOT_TRACK'] === '1';
|
|
44
|
+
// The effective on/off state factoring in both env overrides — matches
|
|
45
|
+
// the human-mode "State:" line's own precedence (DO_NOT_TRACK >
|
|
46
|
+
// TRAWL_TELEMETRY > config).
|
|
47
|
+
const effectiveEnabled = enabled && !envOverride && !doNotTrack;
|
|
48
|
+
if (opts.json) {
|
|
49
|
+
json({ enabled: effectiveEnabled, configEnabled: enabled, telemetryUserId: config.get('telemetryUserId') || null, envOverride, doNotTrack });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
32
52
|
console.log(chalk.bold('Telemetry status'));
|
|
33
53
|
console.log(chalk.dim(' State: ') +
|
|
34
54
|
(doNotTrack
|
package/dist/commands/token.js
CHANGED
|
@@ -4,9 +4,11 @@ import { getToken } from '../lib/config.js';
|
|
|
4
4
|
import { AuthError, notLoggedInError } from '../lib/api.js';
|
|
5
5
|
import { reportError } from '../lib/errors.js';
|
|
6
6
|
import { decodeExp } from '../lib/jwt.js';
|
|
7
|
+
import { json } from '../lib/format.js';
|
|
7
8
|
export const token = new Command('token')
|
|
8
9
|
.description('Print the stored session JWT (for MCP Bearer auth)')
|
|
9
|
-
.
|
|
10
|
+
.option('--json', 'Output as JSON ({token, exp, expiresAt}) instead of the raw JWT')
|
|
11
|
+
.action((opts) => {
|
|
10
12
|
// getToken() resolves TRAWL_TOKEN env first, then the stored config
|
|
11
13
|
// token (see config.ts:47-51) — matching every other token consumer in
|
|
12
14
|
// the CLI instead of reading the config store directly. (#86 finding 1)
|
|
@@ -15,7 +17,7 @@ export const token = new Command('token')
|
|
|
15
17
|
// Auth-classified (ApiError 401 → exit 3, kind:"auth"), not a generic
|
|
16
18
|
// exit 1 — an agent scripting `trawl token` needs to tell "not logged
|
|
17
19
|
// in" apart from an arbitrary bug. (#86 finding 1)
|
|
18
|
-
process.exitCode = reportError(notLoggedInError());
|
|
20
|
+
process.exitCode = reportError(notLoggedInError(), { json: opts.json });
|
|
19
21
|
return;
|
|
20
22
|
}
|
|
21
23
|
const exp = decodeExp(stored);
|
|
@@ -24,7 +26,11 @@ export const token = new Command('token')
|
|
|
24
26
|
// Decoded entirely client-side (no HTTP call made) — AuthError, not a
|
|
25
27
|
// fabricated ApiError(401): the server never actually said this. (#88
|
|
26
28
|
// item 4)
|
|
27
|
-
process.exitCode = reportError(new AuthError('Session token expired. Run: trawl login to refresh.'));
|
|
29
|
+
process.exitCode = reportError(new AuthError('Session token expired. Run: trawl login to refresh.'), { json: opts.json });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (opts.json) {
|
|
33
|
+
json({ token: stored, exp, expiresAt: exp !== null ? new Date(exp * 1000).toISOString() : null });
|
|
28
34
|
return;
|
|
29
35
|
}
|
|
30
36
|
// Print the raw token first (so it can be piped / copied)
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,14 @@ import { Command } from 'commander';
|
|
|
15
15
|
* chain — matching the pre-existing convention that a direct child of the
|
|
16
16
|
* root (e.g. `scraps list`, `telemetry on`) is named relative to its
|
|
17
17
|
* immediate group, never prefixed with the program name.
|
|
18
|
+
*
|
|
19
|
+
* #108 note: promoting a verb to a top-level command (see `createProgram`
|
|
20
|
+
* below) renamed ITS resolved telemetry name from `scraps <verb>` to
|
|
21
|
+
* `<verb>` — the canonical top-level attach and the legacy hidden
|
|
22
|
+
* `scraps <verb>` attach are two separate Command instances (scraps.ts's
|
|
23
|
+
* double-attach factories), each with its own parent chain, so they
|
|
24
|
+
* resolve to two different names here even though they run the same
|
|
25
|
+
* handler. Intentional (the canonical command IS now `<verb>`), not a bug.
|
|
18
26
|
*/
|
|
19
27
|
export declare function resolveCommandName(actionCommand: Command | undefined): string;
|
|
20
28
|
/**
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync, realpathSync } from 'node:fs';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { login, logout } from './commands/login.js';
|
|
7
|
-
import { scraps } from './commands/scraps.js';
|
|
7
|
+
import { scraps, attachListCommand, attachGetCommand, attachRunCommand, attachDataCommand, attachHistoryCommand, attachRunInfoCommand, attachTriggerCommand, } 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';
|
|
@@ -32,6 +32,14 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8
|
|
|
32
32
|
* chain — matching the pre-existing convention that a direct child of the
|
|
33
33
|
* root (e.g. `scraps list`, `telemetry on`) is named relative to its
|
|
34
34
|
* immediate group, never prefixed with the program name.
|
|
35
|
+
*
|
|
36
|
+
* #108 note: promoting a verb to a top-level command (see `createProgram`
|
|
37
|
+
* below) renamed ITS resolved telemetry name from `scraps <verb>` to
|
|
38
|
+
* `<verb>` — the canonical top-level attach and the legacy hidden
|
|
39
|
+
* `scraps <verb>` attach are two separate Command instances (scraps.ts's
|
|
40
|
+
* double-attach factories), each with its own parent chain, so they
|
|
41
|
+
* resolve to two different names here even though they run the same
|
|
42
|
+
* handler. Intentional (the canonical command IS now `<verb>`), not a bug.
|
|
35
43
|
*/
|
|
36
44
|
export function resolveCommandName(actionCommand) {
|
|
37
45
|
if (!actionCommand)
|
|
@@ -63,21 +71,51 @@ export function collectCommandNames(root) {
|
|
|
63
71
|
walk(root);
|
|
64
72
|
return names;
|
|
65
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* #108 — surface reorg into two `trawl --help` tiers. Core verbs are
|
|
76
|
+
* agent+human, `--json` first-class, non-interactive; Management is the
|
|
77
|
+
* existing human/CI surface, kept but grouped so top-level help reads
|
|
78
|
+
* simple. Commander v14's native per-command help group (`.commandsGroup()`
|
|
79
|
+
* sets the default a subsequently-registered command inherits via
|
|
80
|
+
* `.helpGroup()`) drives the section headings — group ORDER in the printed
|
|
81
|
+
* help follows first-seen insertion order into `program.commands`, so every
|
|
82
|
+
* Core command is registered below before any Management one.
|
|
83
|
+
*/
|
|
84
|
+
const CORE_GROUP = 'Core commands (agent + human):';
|
|
85
|
+
const MANAGEMENT_GROUP = 'Management commands (human/CI):';
|
|
66
86
|
export function createProgram() {
|
|
67
87
|
const program = new Command()
|
|
68
88
|
.name('trawl')
|
|
69
89
|
.description('Trawl CLI — manage scraps from the terminal')
|
|
70
90
|
.version(pkg.version)
|
|
71
91
|
.option('--debug', 'Show full error stack traces');
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
92
|
+
// Core verbs (#108) — promoted/listed first: fetch, run, list, get, data,
|
|
93
|
+
// history, run-info, trigger, whoami, ping. `list`/`get`/`run`/`data`/
|
|
94
|
+
// `history`/`run-info`/`trigger` are built via scraps.ts's exported
|
|
95
|
+
// attachXCommand() factories — the SAME definition also stays wired
|
|
96
|
+
// (hidden) under `scraps` there, so every pre-#108 `trawl scraps <verb>`
|
|
97
|
+
// invocation keeps resolving (no breaking change).
|
|
98
|
+
program.commandsGroup(CORE_GROUP);
|
|
78
99
|
program.addCommand(fetchUrl);
|
|
100
|
+
attachRunCommand(program);
|
|
101
|
+
attachListCommand(program);
|
|
102
|
+
attachGetCommand(program);
|
|
103
|
+
attachDataCommand(program);
|
|
104
|
+
attachHistoryCommand(program);
|
|
105
|
+
attachRunInfoCommand(program);
|
|
106
|
+
attachTriggerCommand(program);
|
|
79
107
|
program.addCommand(whoami);
|
|
80
108
|
program.addCommand(ping);
|
|
109
|
+
// Management (#108) — human/CI surface, grouped below. `scraps` still
|
|
110
|
+
// holds every pre-#108 management command (create/update/delete/banner/
|
|
111
|
+
// watch/account.*/session.*/doctor/autofix/snapshot) exactly as before.
|
|
112
|
+
program.commandsGroup(MANAGEMENT_GROUP);
|
|
113
|
+
program.addCommand(scraps);
|
|
114
|
+
program.addCommand(skills);
|
|
115
|
+
program.addCommand(login);
|
|
116
|
+
program.addCommand(logout);
|
|
117
|
+
program.addCommand(token);
|
|
118
|
+
program.addCommand(telemetry);
|
|
81
119
|
return program;
|
|
82
120
|
}
|
|
83
121
|
/**
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #107 — the machine contract's non-interactive rule, in one place. True only
|
|
3
|
+
* when a blocking interactive prompt is safe to show: NOT `--json` (a machine
|
|
4
|
+
* consumer needs pure stdout, and there's no human reading a prompt anyway)
|
|
5
|
+
* AND both stdin/stdout are real TTYs. A pipe/redirect/CI runner — including
|
|
6
|
+
* an agent driving this CLI as a subprocess — has nowhere for a human to type
|
|
7
|
+
* an answer; a blocking `readline` prompt in that situation hangs forever
|
|
8
|
+
* instead of ever returning.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isInteractive(opts?: {
|
|
11
|
+
json?: boolean;
|
|
12
|
+
}): boolean;
|
|
13
|
+
export interface ConfirmOutcome {
|
|
14
|
+
/** True when the caller should proceed with the guarded action. */
|
|
15
|
+
proceed: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* True when refused because the invocation is non-interactive (`--json`,
|
|
18
|
+
* or stdin/stdout isn't a real TTY) — a structured usage error has ALREADY
|
|
19
|
+
* been reported (the stderr line and/or the `--json` envelope) and
|
|
20
|
+
* `process.exitCode` set to 2. The caller must return immediately without
|
|
21
|
+
* printing anything else (mirrors every other `usageError()` call site).
|
|
22
|
+
*/
|
|
23
|
+
blocked: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Shared guard for every destructive y/N confirmation in the CLI (`scraps
|
|
27
|
+
* delete`, `scraps account delete`, …). #107 — before this, each call site
|
|
28
|
+
* hand-rolled its own `readline` question with no non-interactive escape
|
|
29
|
+
* hatch, so a scripted/agent invocation with no `-f`/`--force` would hang
|
|
30
|
+
* forever waiting for a y/N answer that could never arrive.
|
|
31
|
+
*
|
|
32
|
+
* `-f`/`--force` (`opts.force`) always pre-confirms, interactive or not — the
|
|
33
|
+
* caller already gave explicit consent up front. Otherwise:
|
|
34
|
+
* - Non-interactive (`--json`, or stdin/stdout isn't a real TTY): NEVER
|
|
35
|
+
* prompts. Reports a structured usage error (exit 2) instead, via the same
|
|
36
|
+
* central `reportError` formatting path every other error in this CLI
|
|
37
|
+
* uses (stderr human line, or the `--json` envelope on stdout).
|
|
38
|
+
* - Interactive: prompts via `readline` exactly like the pre-#107 call
|
|
39
|
+
* sites did.
|
|
40
|
+
*
|
|
41
|
+
* `message` MUST be plain, unstyled text — it feeds both the refusal
|
|
42
|
+
* UsageError's message (human stderr line AND the `--json` error envelope on
|
|
43
|
+
* stdout) and the fallback prompt text. Before this, call sites passed a
|
|
44
|
+
* `chalk.bold(id)`-styled string as `message`, which leaked raw ANSI escape
|
|
45
|
+
* bytes into the `--json` envelope (e.g. `scraps delete X --json` on the
|
|
46
|
+
* refusal path emitted a message with the raw bold-on/off escape sequence
|
|
47
|
+
* wrapped around the id) — unusable for a script/agent parsing that string.
|
|
48
|
+
* `promptMessage` is the OPTIONAL styled variant shown only for the
|
|
49
|
+
* interactive TTY `[y/N]` prompt (chalk is safe there — no machine ever
|
|
50
|
+
* reads it); it defaults to `message` when omitted. (#107 review F2)
|
|
51
|
+
*/
|
|
52
|
+
export declare function confirmDestructive(message: string, opts?: {
|
|
53
|
+
force?: boolean;
|
|
54
|
+
json?: boolean;
|
|
55
|
+
promptMessage?: string;
|
|
56
|
+
}): Promise<ConfirmOutcome>;
|
|
57
|
+
/**
|
|
58
|
+
* Guard for a required interactive value that has no flag-supplied value yet
|
|
59
|
+
* (login's email prompt, …) — for call sites that propagate errors by
|
|
60
|
+
* `throw`ing and let the top-level handler in index.ts classify + report
|
|
61
|
+
* (login.ts's existing convention), as opposed to `scraps.ts`'s local
|
|
62
|
+
* exitCode-setting `usageError()` style (which should check `isInteractive`
|
|
63
|
+
* directly instead of using this). Same non-interactive contract as
|
|
64
|
+
* `confirmDestructive`: never invokes a blocking prompt when `--json` is set
|
|
65
|
+
* or stdin/stdout isn't a real TTY — throws a `UsageError` naming the flag to
|
|
66
|
+
* pass instead. (#107)
|
|
67
|
+
*/
|
|
68
|
+
export declare function requireInteractive(message: string, opts?: {
|
|
69
|
+
json?: boolean;
|
|
70
|
+
}): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { UsageError } from './errors.js';
|
|
3
|
+
import { reportError } from './errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* #107 — the machine contract's non-interactive rule, in one place. True only
|
|
6
|
+
* when a blocking interactive prompt is safe to show: NOT `--json` (a machine
|
|
7
|
+
* consumer needs pure stdout, and there's no human reading a prompt anyway)
|
|
8
|
+
* AND both stdin/stdout are real TTYs. A pipe/redirect/CI runner — including
|
|
9
|
+
* an agent driving this CLI as a subprocess — has nowhere for a human to type
|
|
10
|
+
* an answer; a blocking `readline` prompt in that situation hangs forever
|
|
11
|
+
* instead of ever returning.
|
|
12
|
+
*/
|
|
13
|
+
export function isInteractive(opts = {}) {
|
|
14
|
+
if (opts.json)
|
|
15
|
+
return false;
|
|
16
|
+
return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Shared guard for every destructive y/N confirmation in the CLI (`scraps
|
|
20
|
+
* delete`, `scraps account delete`, …). #107 — before this, each call site
|
|
21
|
+
* hand-rolled its own `readline` question with no non-interactive escape
|
|
22
|
+
* hatch, so a scripted/agent invocation with no `-f`/`--force` would hang
|
|
23
|
+
* forever waiting for a y/N answer that could never arrive.
|
|
24
|
+
*
|
|
25
|
+
* `-f`/`--force` (`opts.force`) always pre-confirms, interactive or not — the
|
|
26
|
+
* caller already gave explicit consent up front. Otherwise:
|
|
27
|
+
* - Non-interactive (`--json`, or stdin/stdout isn't a real TTY): NEVER
|
|
28
|
+
* prompts. Reports a structured usage error (exit 2) instead, via the same
|
|
29
|
+
* central `reportError` formatting path every other error in this CLI
|
|
30
|
+
* uses (stderr human line, or the `--json` envelope on stdout).
|
|
31
|
+
* - Interactive: prompts via `readline` exactly like the pre-#107 call
|
|
32
|
+
* sites did.
|
|
33
|
+
*
|
|
34
|
+
* `message` MUST be plain, unstyled text — it feeds both the refusal
|
|
35
|
+
* UsageError's message (human stderr line AND the `--json` error envelope on
|
|
36
|
+
* stdout) and the fallback prompt text. Before this, call sites passed a
|
|
37
|
+
* `chalk.bold(id)`-styled string as `message`, which leaked raw ANSI escape
|
|
38
|
+
* bytes into the `--json` envelope (e.g. `scraps delete X --json` on the
|
|
39
|
+
* refusal path emitted a message with the raw bold-on/off escape sequence
|
|
40
|
+
* wrapped around the id) — unusable for a script/agent parsing that string.
|
|
41
|
+
* `promptMessage` is the OPTIONAL styled variant shown only for the
|
|
42
|
+
* interactive TTY `[y/N]` prompt (chalk is safe there — no machine ever
|
|
43
|
+
* reads it); it defaults to `message` when omitted. (#107 review F2)
|
|
44
|
+
*/
|
|
45
|
+
export async function confirmDestructive(message, opts = {}) {
|
|
46
|
+
if (opts.force)
|
|
47
|
+
return { proceed: true, blocked: false };
|
|
48
|
+
if (!isInteractive(opts)) {
|
|
49
|
+
process.exitCode = reportError(new UsageError(`${message} requires confirmation — refusing to block on a prompt (non-interactive). Pass -f/--force to confirm.`), { json: opts.json });
|
|
50
|
+
return { proceed: false, blocked: true };
|
|
51
|
+
}
|
|
52
|
+
const { createInterface } = await import('readline');
|
|
53
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
54
|
+
try {
|
|
55
|
+
const answer = await new Promise((resolve) => {
|
|
56
|
+
rl.question(`${opts.promptMessage ?? message} ${chalk.dim('[y/N]')} `, (a) => resolve(a.trim().toLowerCase()));
|
|
57
|
+
});
|
|
58
|
+
return { proceed: answer === 'y' || answer === 'yes', blocked: false };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
rl.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Guard for a required interactive value that has no flag-supplied value yet
|
|
66
|
+
* (login's email prompt, …) — for call sites that propagate errors by
|
|
67
|
+
* `throw`ing and let the top-level handler in index.ts classify + report
|
|
68
|
+
* (login.ts's existing convention), as opposed to `scraps.ts`'s local
|
|
69
|
+
* exitCode-setting `usageError()` style (which should check `isInteractive`
|
|
70
|
+
* directly instead of using this). Same non-interactive contract as
|
|
71
|
+
* `confirmDestructive`: never invokes a blocking prompt when `--json` is set
|
|
72
|
+
* or stdin/stdout isn't a real TTY — throws a `UsageError` naming the flag to
|
|
73
|
+
* pass instead. (#107)
|
|
74
|
+
*/
|
|
75
|
+
export function requireInteractive(message, opts = {}) {
|
|
76
|
+
if (!isInteractive(opts)) {
|
|
77
|
+
throw new UsageError(message);
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
export declare class UsageError extends Error {
|
|
9
9
|
constructor(message: string);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Thrown for a business-logic REFUSAL the server explicitly reported back
|
|
13
|
+
* (e.g. a tier-ceiling override the registry cap rejected) — distinct from
|
|
14
|
+
* an arbitrary unmapped bug. Before this, `reportTierRefusal` routed a bare
|
|
15
|
+
* `new Error(message)` through here, which fell through to the generic
|
|
16
|
+
* `kind:"unknown"` bucket — indistinguishable from a genuine crash, even
|
|
17
|
+
* though the README sells `kind` as the machine discriminant an agent
|
|
18
|
+
* branches on. Same exit code (1: a business-logic refusal, not a usage
|
|
19
|
+
* error) as before — only the `kind` differs. (#107 review F3)
|
|
20
|
+
*/
|
|
21
|
+
export declare class RefusalError extends Error {
|
|
22
|
+
constructor(message: string);
|
|
23
|
+
}
|
|
11
24
|
export interface ErrorEnvelope {
|
|
12
25
|
message: string;
|
|
13
26
|
status?: number;
|
package/dist/lib/errors.js
CHANGED
|
@@ -13,6 +13,22 @@ export class UsageError extends Error {
|
|
|
13
13
|
this.name = 'UsageError';
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Thrown for a business-logic REFUSAL the server explicitly reported back
|
|
18
|
+
* (e.g. a tier-ceiling override the registry cap rejected) — distinct from
|
|
19
|
+
* an arbitrary unmapped bug. Before this, `reportTierRefusal` routed a bare
|
|
20
|
+
* `new Error(message)` through here, which fell through to the generic
|
|
21
|
+
* `kind:"unknown"` bucket — indistinguishable from a genuine crash, even
|
|
22
|
+
* though the README sells `kind` as the machine discriminant an agent
|
|
23
|
+
* branches on. Same exit code (1: a business-logic refusal, not a usage
|
|
24
|
+
* error) as before — only the `kind` differs. (#107 review F3)
|
|
25
|
+
*/
|
|
26
|
+
export class RefusalError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'RefusalError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
16
32
|
/**
|
|
17
33
|
* Central status → exit-code map (#71 findings 13/14/60). Agents driving this
|
|
18
34
|
* CLI unattended need to tell "you're not logged in" (3) from "that id
|
|
@@ -43,6 +59,9 @@ export function classifyError(err) {
|
|
|
43
59
|
if (err instanceof UsageError) {
|
|
44
60
|
return { exitCode: 2, envelope: { message, kind: 'usage' } };
|
|
45
61
|
}
|
|
62
|
+
if (err instanceof RefusalError) {
|
|
63
|
+
return { exitCode: 1, envelope: { message, kind: 'refused' } };
|
|
64
|
+
}
|
|
46
65
|
return { exitCode: 1, envelope: { message, kind: 'unknown' } };
|
|
47
66
|
}
|
|
48
67
|
/**
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Trawl CLI — Agent Quickstart
|
|
2
|
+
|
|
3
|
+
The minimal surface an AI agent needs to drive `@trawlme/cli` non-interactively.
|
|
4
|
+
For the full command reference (management surface, Claude Code skills,
|
|
5
|
+
telemetry, etc.) see the [main README](../README.md) — the human/CI guide.
|
|
6
|
+
|
|
7
|
+
## Auth — zero prompts
|
|
8
|
+
|
|
9
|
+
Set `TRAWL_TOKEN` and every command authenticates without ever touching a
|
|
10
|
+
prompt:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
export TRAWL_TOKEN=<jwt>
|
|
14
|
+
trawl whoami --json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
(Interactive `trawl login` and the `--url`/config-file flow are documented in
|
|
18
|
+
the README's [Authentication](../README.md#authentication) section — an
|
|
19
|
+
agent should never need them.)
|
|
20
|
+
|
|
21
|
+
## Core commands (agent + human)
|
|
22
|
+
|
|
23
|
+
These ten commands are the CLI's agent+human surface — `--json` is
|
|
24
|
+
first-class on every one, and none of them ever blocks on a prompt (see
|
|
25
|
+
[Non-interactive contract](#non-interactive-contract) below):
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
trawl fetch <url> [--json] [--reason <text>] One-shot fetch + extract readable content from a public URL (no scrap needed)
|
|
29
|
+
trawl run <id> [--watch] [--json] Run a scrap
|
|
30
|
+
trawl list|ls [--json] [--status <s>] [--limit <n>] [--page <n>] List all scraps
|
|
31
|
+
trawl get <id> [--json] Get scrap details
|
|
32
|
+
trawl data <id> [--json] [--fresh] [--errors] Get scrap data (last persisted run, or --fresh to launch one)
|
|
33
|
+
trawl history <id> [--json] [-n <limit>] List past runs for a scrap
|
|
34
|
+
trawl run-info <hid> [--json] Show details of a single run
|
|
35
|
+
trawl trigger <id> [--watch] [--wait] [--json] Launch a scrap as a background worker
|
|
36
|
+
trawl whoami [--json] Show the authenticated user's identity
|
|
37
|
+
trawl ping [--json] Health/version handshake against the Trawl API
|
|
38
|
+
```
|
|
39
|
+
|
|
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.
|
|
48
|
+
|
|
49
|
+
> **No breaking change:** every verb above is also still reachable under its
|
|
50
|
+
> pre-reorg path, `trawl scraps <verb>` (e.g. `trawl scraps list`) — kept as
|
|
51
|
+
> a hidden alias. Prefer the bare top-level form above; it's what
|
|
52
|
+
> `trawl --help` now shows.
|
|
53
|
+
|
|
54
|
+
For the full flag reference (tier overrides on `create`/`update`, the
|
|
55
|
+
`--watch` polling mechanics, retention/regression semantics on `data`, …)
|
|
56
|
+
see the README's [Core commands](../README.md#core-commands-agent--human) section
|
|
57
|
+
— this doc intentionally stays minimal.
|
|
58
|
+
|
|
59
|
+
## `--json` contract
|
|
60
|
+
|
|
61
|
+
Every command above supports `--json`: a single structured payload on
|
|
62
|
+
stdout, nothing else. Two narrow exceptions carried over from the human
|
|
63
|
+
surface: a `--watch` poll emits exactly one final NDJSON line once the run
|
|
64
|
+
reaches a terminal state (not the whole progress stream), and there is no
|
|
65
|
+
JSON form of an HTML page (irrelevant to the core verbs above — that only
|
|
66
|
+
applies to the management-only `scraps snapshot`).
|
|
67
|
+
|
|
68
|
+
On failure, `--json` emits a single error envelope on stdout instead of
|
|
69
|
+
prose — `{"error":{"message","status?","kind"}}` — and the human-readable
|
|
70
|
+
line goes to stderr, never stdout. `kind` is the machine-readable
|
|
71
|
+
discriminant (`"usage"`/`"auth"`/`"not_found"`/`"network"`/`"api"`/
|
|
72
|
+
`"refused"`/`"unknown"`) a script should switch on.
|
|
73
|
+
|
|
74
|
+
## Non-interactive contract
|
|
75
|
+
|
|
76
|
+
No core verb ever blocks waiting for a prompt. When stdin/stdout isn't a
|
|
77
|
+
real TTY (any subprocess-driven invocation) — or `--json` is set — any
|
|
78
|
+
command that would otherwise ask a `[y/N]` confirmation or a missing value
|
|
79
|
+
instead fails fast with a structured usage error (exit `2`) rather than
|
|
80
|
+
hanging. Full rule + rationale: README's
|
|
81
|
+
[Non-interactive rule](../README.md#non-interactive-rule).
|
|
82
|
+
|
|
83
|
+
## Exit codes
|
|
84
|
+
|
|
85
|
+
| Code | Meaning |
|
|
86
|
+
|------|---------|
|
|
87
|
+
| `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`) |
|
|
89
|
+
| `2` | Usage error (bad flag/value, invalid ID, missing required argument, or the non-interactive guard refusing to prompt) |
|
|
90
|
+
| `3` | Auth error (not logged in, or the session token is expired/invalid) |
|
|
91
|
+
| `4` | Not found (no such resource, or no persisted payload to read) |
|
|
92
|
+
| `5` | Network error (API host unreachable, DNS/connection/TLS failure, or timeout) |
|
|
93
|
+
|
|
94
|
+
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
|
|
96
|
+
documented once, in the README's [Exit codes](../README.md#exit-codes)
|
|
97
|
+
section — treat that as canonical if the two ever seem to disagree.
|
|
98
|
+
|
|
99
|
+
## Minimal example
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
TRAWL_TOKEN=<jwt> trawl fetch https://example.com --json
|
|
103
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trawlme/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "Trawl CLI — manage scraps from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
|
+
"docs",
|
|
11
12
|
"README.md",
|
|
12
13
|
"LICENSE"
|
|
13
14
|
],
|