@trawlme/cli 1.20.0 → 1.21.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.
@@ -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
- .action(() => {
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
- const version = getBundledSkillsVersion();
30
- console.log(chalk.dim(`@trawlme/skills@${version}\n`));
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
- removeOrphanedSkills(scope);
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
- .action(() => {
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
- .action(() => {
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
- .action(() => {
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
@@ -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
- .action(() => {
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)
@@ -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
+ }
@@ -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;
@@ -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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {