@trawlme/cli 3.9.1 → 3.10.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
@@ -134,6 +134,8 @@ Skills auto-update when you upgrade the CLI — no need to re-install manually
134
134
 
135
135
  A pre-existing skill directory that trawl did not install itself (no `.version` marker) is never touched — `install`/`update` refuse to overwrite it and require `--force` to proceed. This applies to the CLI-upgrade auto-sync too (it silently skips marker-less dirs rather than refusing, since there is no interactive user to show a refusal to).
136
136
 
137
+ **`trawl login` bootstraps any bundled skill you don't have yet** — the one command that does, since it's the one intentional human setup moment (interactive, a real TTY, not `--json`). It prints exactly what it installed and where, plus a fact you need to act on yourself: **Claude Code must be restarted to see them** — skills are loaded at session start, so anything installed mid-session stays invisible until then. Every other command only ever *suggests* running `trawl skills install` (throttled, never under `--json`/non-TTY/`TRAWL_SKILLS_SYNC=0`) — it never writes to `~/.claude/skills` as a side effect of something else you asked for. If you already have every bundled skill, or you're logging in non-interactively (CI, `--json`, `TRAWL_TOKEN=... trawl login`), nothing is written either way.
138
+
137
139
  You can also install skills standalone (without the CLI): `npx @trawlme/skills install`.
138
140
 
139
141
  ### Auth
@@ -69,6 +69,31 @@ export interface Run {
69
69
  * response can still be the pre-#1950 flat shape for a while.
70
70
  */
71
71
  export declare function detectWallVendor(run: Pick<Run, 'status' | 'statusDetail' | 'block' | 'blockType'>): string | null;
72
+ /**
73
+ * #184 — true for a run currently carrying a live (non-stale) login-wall
74
+ * verdict. Extracted out of `formatDoctor`'s own local `authWall` const so
75
+ * `commands/scraps.ts`'s `doctor`/`run-info` actions can reuse the EXACT
76
+ * same guard to decide whether to fire the skills-install safety-net nudge
77
+ * (lib/skillsNudge.ts `maybeSuggestSkillsForAuthWall`) — one rule, not two
78
+ * copies that could quietly drift apart.
79
+ *
80
+ * Same staleness guard as `detectWallVendor` above and for the same reason:
81
+ * `failureKind` is a terminal classification trawl_node stamps once, but
82
+ * `patchForRegression` can flip `status`/`statusDetail` to success/
83
+ * regression LATER without ever clearing it — so a run that ultimately
84
+ * succeeded or degraded must never still read as an active auth wall.
85
+ *
86
+ * Typed structurally loose (not `Pick<Run, ...>`) on purpose: `Run.status`/
87
+ * `statusDetail` are required fields, but `scraps.ts`'s own `HistoryRun`
88
+ * (the run-info command's shape) declares the same three fields OPTIONAL —
89
+ * a `Pick<Run, ...>` parameter type would reject that caller at compile
90
+ * time even though every field it actually reads is present at runtime.
91
+ */
92
+ export declare function isAuthWall(run: {
93
+ failureKind?: string | null;
94
+ status?: boolean | null;
95
+ statusDetail?: string | null;
96
+ }): boolean;
72
97
  /**
73
98
  * Autofix activity metadata — from the persisted ai_fix_end activity.
74
99
  * aiUsage (cost) is stripped server-side; all diagnostics are kept.
@@ -69,6 +69,29 @@ export function detectWallVendor(run) {
69
69
  }
70
70
  return null;
71
71
  }
72
+ /**
73
+ * #184 — true for a run currently carrying a live (non-stale) login-wall
74
+ * verdict. Extracted out of `formatDoctor`'s own local `authWall` const so
75
+ * `commands/scraps.ts`'s `doctor`/`run-info` actions can reuse the EXACT
76
+ * same guard to decide whether to fire the skills-install safety-net nudge
77
+ * (lib/skillsNudge.ts `maybeSuggestSkillsForAuthWall`) — one rule, not two
78
+ * copies that could quietly drift apart.
79
+ *
80
+ * Same staleness guard as `detectWallVendor` above and for the same reason:
81
+ * `failureKind` is a terminal classification trawl_node stamps once, but
82
+ * `patchForRegression` can flip `status`/`statusDetail` to success/
83
+ * regression LATER without ever clearing it — so a run that ultimately
84
+ * succeeded or degraded must never still read as an active auth wall.
85
+ *
86
+ * Typed structurally loose (not `Pick<Run, ...>`) on purpose: `Run.status`/
87
+ * `statusDetail` are required fields, but `scraps.ts`'s own `HistoryRun`
88
+ * (the run-info command's shape) declares the same three fields OPTIONAL —
89
+ * a `Pick<Run, ...>` parameter type would reject that caller at compile
90
+ * time even though every field it actually reads is present at runtime.
91
+ */
92
+ export function isAuthWall(run) {
93
+ return run.failureKind === 'auth' && run.status !== true && run.statusDetail !== 'regression';
94
+ }
72
95
  const TIER_LABELS = {
73
96
  tier0: 'Tier 0',
74
97
  tier1: 'Tier 1',
@@ -194,7 +217,7 @@ export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
194
217
  // `detectWallVendor`'s guard already exists to prevent for `block.kind`.
195
218
  // One rule, not two coincidences: both checks guard the same two fields
196
219
  // against the same after-the-fact patch.
197
- const authWall = run.failureKind === 'auth' && run.status !== true && run.statusDetail !== 'regression';
220
+ const authWall = isAuthWall(run);
198
221
  const authHintWillRender = authWall && Boolean(scrapId);
199
222
  const conflictingSignals = Boolean(wallVendor) && authWall;
200
223
  if (conflictingSignals) {
@@ -4,8 +4,9 @@ import config, { getApiUrl, getLiveAuthEnvVar } from '../lib/config.js';
4
4
  import { api } from '../lib/api.js';
5
5
  import { requireFreshJwt, requireUrl } from '../lib/validate.js';
6
6
  import { promptPassword } from '../lib/prompt.js';
7
- import { requireInteractive } from '../lib/confirm.js';
7
+ import { requireInteractive, isInteractive } from '../lib/confirm.js';
8
8
  import { json } from '../lib/format.js';
9
+ import { bootstrapSkillsOnLogin, RESTART_CLAUDE_CODE_NOTE } from '../lib/skills.js';
9
10
  async function promptEmail() {
10
11
  const { createInterface } = await import('readline');
11
12
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -40,6 +41,62 @@ function reportLoginSuccess(opts, email) {
40
41
  if (overrideVar) {
41
42
  console.error(chalk.yellow(`⚠ ${overrideVar} is set in your environment — it overrides the token just stored here for every subsequent command until you unset it.`));
42
43
  }
44
+ // #184 — `login` is the ONE intentional human setup moment: bootstrap any
45
+ // bundled Claude skill the user doesn't have yet. Gated exactly like
46
+ // every other filesystem-mutating side effect in this CLI
47
+ // (json/TTY/opt-out — see lib/skills.ts's isSkillsActionAllowed), so a
48
+ // CI/agent `TRAWL_TOKEN=x trawl login --json` never writes into
49
+ // ~/.claude/skills on a machine with no Claude Code session to discover
50
+ // them. `isInteractive` is the exact TTY definition login already uses
51
+ // for its own email/password prompt gate (lib/confirm.ts) — stdin AND
52
+ // stdout, not just stdout. `bootstrapped` is `null` ONLY when gated out
53
+ // (json/non-TTY/opted-out) or when every bundled skill is already owned
54
+ // by trawl — the genuine "nothing to do" case, where the lines below
55
+ // naturally never print. It is non-null whenever anything was attempted,
56
+ // whether or not any of it actually landed — see the block below.
57
+ //
58
+ // stderr, same convention as the `overrideVar` warning right above (and
59
+ // for the same reason): this is orthogonal to the `--json` envelope's
60
+ // fixed shape below, so it must never risk landing on stdout — a defense
61
+ // that holds even if `bootstrapSkillsOnLogin`'s own json/TTY gate above it
62
+ // were ever wrong, not just a style match.
63
+ //
64
+ // #184 review (BLOCK + MAJOR) — `bootstrapped` is non-null whenever there
65
+ // was anything to attempt, whether or not any of it actually landed, so
66
+ // both halves below must be checked independently: `installed` prints the
67
+ // success line, `skipped` prints one honest line per skill that was
68
+ // requested but did NOT land (a permission error, or a pre-existing
69
+ // marker-less dir the ownership guard refused to overwrite) and WHY —
70
+ // `reason` is already a relayable, fact-only string (SkillOwnershipRefusalError's
71
+ // `.relayableReason`, or a raw fs error message) with no imperative, so it
72
+ // is safe to print verbatim on this channel. A total failure (installed
73
+ // empty, skipped non-empty) must read as "attempted and failed" — never
74
+ // fall through to silence, which is indistinguishable from "never
75
+ // attempted" (the exact false-success shape this issue exists to
76
+ // prevent). The restart note only applies to what actually landed, so it
77
+ // stays scoped to that branch.
78
+ //
79
+ // #184 defect 2 — `error` is the THIRD case: distinct from both "nothing
80
+ // to do" (bootstrapped is `null`, nothing prints) and "some/all skills
81
+ // failed" (`skipped`, above) — it means bootstrapSkillsOnLogin could not
82
+ // even determine which skills to install (the bundled skills package
83
+ // looks missing/corrupted). Stated as a fact, same convention as every
84
+ // other line here.
85
+ const bootstrapped = bootstrapSkillsOnLogin({ json: opts.json, isTTY: isInteractive(opts) });
86
+ if (bootstrapped) {
87
+ if (bootstrapped.error) {
88
+ console.error(chalk.yellow(`⚠ Claude skills bootstrap: ${bootstrapped.error}`));
89
+ }
90
+ if (bootstrapped.installed.length > 0) {
91
+ console.error(chalk.green(`✓ Installed Claude skill${bootstrapped.installed.length === 1 ? '' : 's'}: `) +
92
+ `${bootstrapped.installed.join(', ')}` +
93
+ chalk.dim(` at ${bootstrapped.dest}`));
94
+ console.error(chalk.yellow(RESTART_CLAUDE_CODE_NOTE));
95
+ }
96
+ for (const { name, reason } of bootstrapped.skipped) {
97
+ console.error(chalk.yellow(`⚠ Claude skill "${name}" install attempted but failed: ${reason}`));
98
+ }
99
+ }
43
100
  if (opts.json) {
44
101
  json({ ok: true, apiUrl: getApiUrl(), config: config.path, ...(email && { email }) });
45
102
  return;
@@ -8,9 +8,10 @@ import { promptPassword } from '../lib/prompt.js';
8
8
  import { validateObjectId, requireUrl } from '../lib/validate.js';
9
9
  import { classifyError, reportError, retryFieldsFor, UsageError, RefusalError } from '../lib/errors.js';
10
10
  import { confirmDestructive, isInteractive } from '../lib/confirm.js';
11
- import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
11
+ import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix, isAuthWall } from './doctor.js';
12
12
  import { renderPinch, pinchEnabled } from '../lib/pinch.js';
13
13
  import { maybeShowReferralTip } from '../lib/tips.js';
14
+ import { maybeSuggestSkillsForAuthWall } from '../lib/skillsNudge.js';
14
15
  /**
15
16
  * Print a usage/validation error consistently: human text to stderr, or a
16
17
  * machine envelope on stdout under --json (never both — reportError is the
@@ -1276,6 +1277,13 @@ export function attachRunInfoCommand(parent, attachOpts = {}) {
1276
1277
  }
1277
1278
  // The blob is an object; the table needs one line. --json keeps it whole.
1278
1279
  table([{ ...info, emptyContext: summarizeEmptyContext(info.emptyContext) }], ['hid', 'status', 'time', 'tier', 'failureKind', 'blockType', 'errorMessage', 'selector', 'emptyContext', 'createdAt']);
1280
+ // #184 safety-net — the run just shown carries a live login-wall
1281
+ // verdict; if Claude's skills aren't installed either, point at the
1282
+ // command that installs them too (never under --json, see the early
1283
+ // return above).
1284
+ if (isAuthWall(h)) {
1285
+ maybeSuggestSkillsForAuthWall();
1286
+ }
1279
1287
  });
1280
1288
  }
1281
1289
  attachRunInfoCommand(scraps, { hidden: true });
@@ -1660,6 +1668,13 @@ scraps
1660
1668
  if (opts.autofix && result.fix) {
1661
1669
  console.log('\n' + formatAutofix(result.fix));
1662
1670
  }
1671
+ // #184 safety-net — formatDoctor already prints the login-wall hint
1672
+ // (#182) above when this is a live auth wall; if Claude's skills aren't
1673
+ // installed either, point at the command that installs them too (never
1674
+ // under --json, see the early return above).
1675
+ if (isAuthWall(result.run)) {
1676
+ maybeSuggestSkillsForAuthWall();
1677
+ }
1663
1678
  });
1664
1679
  // autofix — show full auto-fix attempt detail (diff, dry-run, knowledge)
1665
1680
  scraps
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { whoami } from './commands/whoami.js';
15
15
  import { ping } from './commands/ping.js';
16
16
  import { spec } from './commands/spec.js';
17
17
  import { autoUpdateInstalledSkills } from './lib/skills.js';
18
+ import { maybeSuggestSkillsInstall } from './lib/skillsNudge.js';
18
19
  import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
19
20
  import { classifyError, reportError, stripCommanderErrorPrefix, retryFieldsFor } from './lib/errors.js';
20
21
  import { renderPinch, pinchEnabled } from './lib/pinch.js';
@@ -537,6 +538,27 @@ export async function runCli(argv = process.argv) {
537
538
  catch {
538
539
  // swallow — background refresh must never affect this invocation.
539
540
  }
541
+ // #184 — "elsewhere: suggest, never install" half of the skills
542
+ // bootstrap story (lib/skills.ts's bootstrapSkillsOnLogin is the
543
+ // "install on login" half). Only on a clean exit — mirrors the
544
+ // referral tip's own success-only precedent (lib/tips.ts, called only
545
+ // after a run genuinely succeeds) and sidesteps a whole class of
546
+ // parse-error edge cases where `currentCommand` never resolved (so
547
+ // its own `--json` flag can't be read reliably here either). Running
548
+ // AFTER the command completed also means a `login` that just
549
+ // bootstrapped is already reflected in this nudge's own "any skill
550
+ // installed?" read — no separate command-name exclusion needed, and
551
+ // no double-message risk with the line `login` may have just printed.
552
+ if (process.exitCode === undefined || process.exitCode === 0) {
553
+ try {
554
+ maybeSuggestSkillsInstall({
555
+ json: Boolean(currentCommand?.opts()?.json),
556
+ });
557
+ }
558
+ catch {
559
+ // swallow — see skillsNudge.ts, this is already self-guarded too.
560
+ }
561
+ }
540
562
  }
541
563
  }
542
564
  }
@@ -13,6 +13,7 @@ interface TrawlConfig {
13
13
  * Same optional/absent-means-enabled shape as `updateNotifier` above. */
14
14
  tips?: boolean;
15
15
  referralTipShownAt: number;
16
+ skillsNudgeShownAt: number;
16
17
  }
17
18
  declare const config: Conf<TrawlConfig>;
18
19
  /**
@@ -17,6 +17,7 @@ const config = new Conf({
17
17
  telemetry: true,
18
18
  telemetryUserId: '',
19
19
  referralTipShownAt: 0,
20
+ skillsNudgeShownAt: 0,
20
21
  },
21
22
  });
22
23
  /**
@@ -1,5 +1,34 @@
1
1
  export declare function getBundledSkillsVersion(): string;
2
2
  export declare function listBundledSkills(): string[];
3
+ /**
4
+ * #184 defect 1 — the ownership-refusal thrown below serves two different
5
+ * audiences on two different channels, and one string can't correctly serve
6
+ * both:
7
+ *
8
+ * - A human who just typed `trawl skills install`/`update` reaches this via
9
+ * an uncaught throw (index.ts's generic error path prints `.message`
10
+ * verbatim) — that reader can decide whether to add `--force`, so the
11
+ * guidance belongs on this channel. `.message` (below) keeps it.
12
+ * - `bootstrapSkillsOnLogin` also catches this exact throw and relays its
13
+ * text into `skipped[].reason`, which `login.ts` prints to stderr — a
14
+ * channel this feature's own doc comments say must never carry a command
15
+ * phrased as an instruction, because this CLI is driven by AI agents and
16
+ * this platform can feed a CLI's own stderr back into an agent's own
17
+ * context. `Pass --force to overwrite it anyway` is exactly that kind of
18
+ * instruction: an agent "obeying" it calls `installSkill`'s own
19
+ * `rmSync(recursive)` on a directory the user owns and trawl did not
20
+ * create — the destroy-the-user's-files incident this class of bug
21
+ * produces.
22
+ *
23
+ * `.relayableReason` carries the identical fact — this path exists, trawl
24
+ * did not create it, so it was left untouched — with the imperative sentence
25
+ * removed, for every channel that is not a direct, synchronous reply to a
26
+ * human's own typed command.
27
+ */
28
+ export declare class SkillOwnershipRefusalError extends Error {
29
+ readonly relayableReason: string;
30
+ constructor(dest: string);
31
+ }
3
32
  /**
4
33
  * Ownership guard (#73, extended #86 finding 7): this does `rmSync(recursive)`
5
34
  * on the target dir before reinstalling, so it must never do that to a dir
@@ -50,3 +79,130 @@ export declare function removeOrphanedSkills(scope: 'user' | 'local'): string[];
50
79
  * `TRAWL_TELEMETRY=0`), for users who manage their skills by hand.
51
80
  */
52
81
  export declare function autoUpdateInstalledSkills(): void;
82
+ /**
83
+ * #184 — one line, reused everywhere the CLI installs a skill mid-invocation
84
+ * (login's bootstrap below, lib/skillsNudge.ts's two nudges): skills are
85
+ * discovered at Claude Code SESSION START, so a skill written to disk right
86
+ * now is invisible to whatever session is already running. Stated as a
87
+ * fact, never an imperative ("restart Claude Code") — this text can be
88
+ * relayed into an AI agent's own context (this CLI's whole incident was an
89
+ * agent driving it), and a command phrased there must never read as an
90
+ * instruction the agent is being told to obey.
91
+ */
92
+ export declare const RESTART_CLAUDE_CODE_NOTE = "Claude Code must be restarted to see them \u2014 skills are loaded at session start, not mid-session.";
93
+ /**
94
+ * #184 — pure gate shared by every skills-related write/print that must
95
+ * never fire under `--json` (a machine consumer needs pure stdout and there
96
+ * is no human reading a suggestion anyway), on a non-TTY invocation (a CI
97
+ * runner or an agent driving this CLI as a subprocess has no Claude Code
98
+ * session to discover a newly-installed skill in the first place), or when
99
+ * `TRAWL_SKILLS_SYNC=0` (the existing auto-sync opt-out, extended here: a
100
+ * user who manages skills by hand does not want the CLI touching that dir
101
+ * for ANY reason — install or nudge alike). Every signal is a parameter,
102
+ * exactly like lib/tips.ts's `isReferralTipDue`, so this is testable
103
+ * without mocking env/TTY. `isTTY`'s exact definition (stdout-only vs
104
+ * stdin+stdout) is the CALLER's call — see bootstrapSkillsOnLogin below vs
105
+ * lib/skillsNudge.ts for the two different answers this codebase already
106
+ * gives elsewhere (confirm.ts's `isInteractive` vs tips.ts's own check).
107
+ */
108
+ export declare function isSkillsActionAllowed(opts: {
109
+ json?: boolean;
110
+ isTTY: boolean;
111
+ optedOut: boolean;
112
+ }): boolean;
113
+ /**
114
+ * #184 — the "install on login, and only there" half of the never-installed
115
+ * bootstrap (see the module doc comment on `autoUpdateInstalledSkills`
116
+ * above for the "elsewhere: suggest, never install" half, which lives in
117
+ * lib/skillsNudge.ts instead). `autoUpdateInstalledSkills` deliberately
118
+ * never installs (`if (!isSkillInstalled) continue`) — it only keeps an
119
+ * EXISTING install in sync. A user who has never run any `trawl skills`
120
+ * command and never logged in before this shipped has nothing installed at
121
+ * all, and nothing in the CLI's startup path ever puts anything there: the
122
+ * CLI is competent, the agent reading its skills is not — the incident this
123
+ * issue exists to prevent. `login` is the one intentional human setup
124
+ * moment (the only place a human types credentials), so it is the ONLY
125
+ * place this function is ever called from (see commands/login.ts) — never
126
+ * from the generic startup path.
127
+ *
128
+ * Gated by `isSkillsActionAllowed` exactly like every other skills-related
129
+ * side effect: a non-interactive `trawl login` (CI's `TRAWL_TOKEN=x trawl
130
+ * login --json`, or any non-TTY invocation) writes nothing — that machine
131
+ * has no Claude Code session to discover a skill in, and `--json`'s stdout
132
+ * contract has no room for a plain-text confirmation line anyway. Also
133
+ * skipped entirely under `TRAWL_SKILLS_SYNC=0`.
134
+ *
135
+ * Installs every bundled skill NOT YET OWNED by trawl at `scope` (default
136
+ * 'user' — the global location a fresh `npx @trawlme/cli login` writes to;
137
+ * 'local' is opt-in via the same --local convention `trawl skills install`
138
+ * already uses everywhere else, kept for parity/tests). "Owned" means a
139
+ * readable `.version` marker (#184 review MAJOR — NOT mere path existence:
140
+ * see below for why that distinction matters here). One skill's install
141
+ * throwing must never blank out the others (#91's posture, applied here):
142
+ * each is wrapped individually, and the function reports exactly what
143
+ * landed. Returns `null` (nothing written, nothing to report) ONLY when
144
+ * gated out, or when every bundled skill is already owned (the re-sync
145
+ * loop above already keeps an existing install's version current) — the
146
+ * genuine "nothing to do" case. Never throws — a broken bootstrap must
147
+ * never turn a successful login into a failed one.
148
+ *
149
+ * #184 review (BLOCK + MAJOR) — two cases used to collapse into the exact
150
+ * same `null`/silence as genuine "nothing to do":
151
+ *
152
+ * 1. (BLOCK) Every install attempt failing outright (an unwritable
153
+ * `~/.claude/skills`, e.g.) used to return `null` — indistinguishable
154
+ * from "already installed" or "never attempted at all" on a first-run
155
+ * machine, the precise false-success shape #184 exists to prevent.
156
+ * 2. (MAJOR) A bundled skill name colliding with a PRE-EXISTING,
157
+ * marker-less directory the CLI doesn't own. The action set here used
158
+ * to be computed from `isSkillInstalled` (mere `existsSync`), which
159
+ * can't tell "we already installed this" from "something else already
160
+ * lives at this path" — a foreign dir was silently read as "already
161
+ * installed, nothing to do", so `installSkill`'s ownership-refusal
162
+ * guard was never even reached and the collision went unreported
163
+ * anywhere. The action set below is computed from *ownership*
164
+ * (`getInstalledVersion(...) !== null`) instead, so a foreign
165
+ * collision is genuinely attempted — hits the same guard `installSkill`
166
+ * already enforces elsewhere, throws, and is captured below — rather
167
+ * than silently skipped as if it were a prior trawl install.
168
+ *
169
+ * Whenever there was anything to attempt, the result is now ALWAYS a
170
+ * non-null object reporting both what landed (`installed`) and what didn't
171
+ * (`skipped`, each with a RELAYABLE reason — see `SkillOwnershipRefusalError`
172
+ * above: the ownership-refusal case reports `.relayableReason` [fact only,
173
+ * no `--force` imperative], every other throw [skill-not-found, raw fs
174
+ * errors like EACCES] reports `.message` as before, since those were never
175
+ * imperative to begin with), so `login.ts` can print an honest, distinct
176
+ * line for a skip instead of falling through to the generic "not installed"
177
+ * nudge as if login had done nothing, or silently omitting a name from the
178
+ * success line as if it had never been requested.
179
+ *
180
+ * #184 defect 2 — `error` (present whenever the function returns non-null)
181
+ * distinguishes a THIRD case from both "nothing to do" (`null`) and "one or
182
+ * more skills failed" (`skipped`): "could not even determine what to
183
+ * install" — `listBundledSkills()` returning an empty list (its own
184
+ * contract silently swallows a missing/renamed `skills/` dir into `[]`; see
185
+ * its doc comment) or `getSkillsPackageRoot()` throwing outright (the
186
+ * `@trawlme/skills` package itself unresolvable — a broken node_modules
187
+ * entry). Both used to fall through to `notOwned.length === 0` or the outer
188
+ * catch below, landing on the exact same `null` as a fully-up-to-date
189
+ * install — a genuinely corrupted bundle produced ZERO signal, not even a
190
+ * failed-attempt line, because nothing was ever attempted. `error` is
191
+ * `null` on every ordinary path (including genuine "all already owned",
192
+ * which still short-circuits to the `null` return below) and non-null only
193
+ * for this diagnostic-failure case, where `installed`/`skipped` are both
194
+ * empty because no skill name was ever known to attempt.
195
+ */
196
+ export declare function bootstrapSkillsOnLogin(opts?: {
197
+ json?: boolean;
198
+ isTTY?: boolean;
199
+ scope?: 'user' | 'local';
200
+ }): {
201
+ installed: string[];
202
+ skipped: {
203
+ name: string;
204
+ reason: string;
205
+ }[];
206
+ dest: string;
207
+ error: string | null;
208
+ } | null;
@@ -24,6 +24,40 @@ function getSkillsBase(scope) {
24
24
  const base = scope === 'local' ? join(process.cwd(), '.claude') : join(homedir(), '.claude');
25
25
  return join(base, 'skills');
26
26
  }
27
+ /**
28
+ * #184 defect 1 — the ownership-refusal thrown below serves two different
29
+ * audiences on two different channels, and one string can't correctly serve
30
+ * both:
31
+ *
32
+ * - A human who just typed `trawl skills install`/`update` reaches this via
33
+ * an uncaught throw (index.ts's generic error path prints `.message`
34
+ * verbatim) — that reader can decide whether to add `--force`, so the
35
+ * guidance belongs on this channel. `.message` (below) keeps it.
36
+ * - `bootstrapSkillsOnLogin` also catches this exact throw and relays its
37
+ * text into `skipped[].reason`, which `login.ts` prints to stderr — a
38
+ * channel this feature's own doc comments say must never carry a command
39
+ * phrased as an instruction, because this CLI is driven by AI agents and
40
+ * this platform can feed a CLI's own stderr back into an agent's own
41
+ * context. `Pass --force to overwrite it anyway` is exactly that kind of
42
+ * instruction: an agent "obeying" it calls `installSkill`'s own
43
+ * `rmSync(recursive)` on a directory the user owns and trawl did not
44
+ * create — the destroy-the-user's-files incident this class of bug
45
+ * produces.
46
+ *
47
+ * `.relayableReason` carries the identical fact — this path exists, trawl
48
+ * did not create it, so it was left untouched — with the imperative sentence
49
+ * removed, for every channel that is not a direct, synchronous reply to a
50
+ * human's own typed command.
51
+ */
52
+ export class SkillOwnershipRefusalError extends Error {
53
+ relayableReason;
54
+ constructor(dest) {
55
+ super(`Refusing to overwrite "${dest}" — it was not installed by trawl (no .version marker). ` +
56
+ `Pass --force to overwrite it anyway.`);
57
+ this.name = 'SkillOwnershipRefusalError';
58
+ this.relayableReason = `"${dest}" already exists and was not installed by trawl (no .version marker) — left untouched.`;
59
+ }
60
+ }
27
61
  /**
28
62
  * Ownership guard (#73, extended #86 finding 7): this does `rmSync(recursive)`
29
63
  * on the target dir before reinstalling, so it must never do that to a dir
@@ -58,8 +92,7 @@ export function installSkill(name, scope, opts = {}) {
58
92
  }
59
93
  const owned = installedVersion !== null;
60
94
  if (!owned && !opts.force) {
61
- throw new Error(`Refusing to overwrite "${dest}" — it was not installed by trawl (no .version marker). ` +
62
- `Pass --force to overwrite it anyway.`);
95
+ throw new SkillOwnershipRefusalError(dest);
63
96
  }
64
97
  rmSync(dest, { recursive: true, force: true });
65
98
  }
@@ -196,3 +229,195 @@ export function autoUpdateInstalledSkills() {
196
229
  // Silent: skill auto-update should never block the CLI
197
230
  }
198
231
  }
232
+ /**
233
+ * #184 — one line, reused everywhere the CLI installs a skill mid-invocation
234
+ * (login's bootstrap below, lib/skillsNudge.ts's two nudges): skills are
235
+ * discovered at Claude Code SESSION START, so a skill written to disk right
236
+ * now is invisible to whatever session is already running. Stated as a
237
+ * fact, never an imperative ("restart Claude Code") — this text can be
238
+ * relayed into an AI agent's own context (this CLI's whole incident was an
239
+ * agent driving it), and a command phrased there must never read as an
240
+ * instruction the agent is being told to obey.
241
+ */
242
+ export const RESTART_CLAUDE_CODE_NOTE = 'Claude Code must be restarted to see them — skills are loaded at session start, not mid-session.';
243
+ /**
244
+ * #184 — pure gate shared by every skills-related write/print that must
245
+ * never fire under `--json` (a machine consumer needs pure stdout and there
246
+ * is no human reading a suggestion anyway), on a non-TTY invocation (a CI
247
+ * runner or an agent driving this CLI as a subprocess has no Claude Code
248
+ * session to discover a newly-installed skill in the first place), or when
249
+ * `TRAWL_SKILLS_SYNC=0` (the existing auto-sync opt-out, extended here: a
250
+ * user who manages skills by hand does not want the CLI touching that dir
251
+ * for ANY reason — install or nudge alike). Every signal is a parameter,
252
+ * exactly like lib/tips.ts's `isReferralTipDue`, so this is testable
253
+ * without mocking env/TTY. `isTTY`'s exact definition (stdout-only vs
254
+ * stdin+stdout) is the CALLER's call — see bootstrapSkillsOnLogin below vs
255
+ * lib/skillsNudge.ts for the two different answers this codebase already
256
+ * gives elsewhere (confirm.ts's `isInteractive` vs tips.ts's own check).
257
+ */
258
+ export function isSkillsActionAllowed(opts) {
259
+ if (opts.json)
260
+ return false;
261
+ if (!opts.isTTY)
262
+ return false;
263
+ if (opts.optedOut)
264
+ return false;
265
+ return true;
266
+ }
267
+ /**
268
+ * #184 — the "install on login, and only there" half of the never-installed
269
+ * bootstrap (see the module doc comment on `autoUpdateInstalledSkills`
270
+ * above for the "elsewhere: suggest, never install" half, which lives in
271
+ * lib/skillsNudge.ts instead). `autoUpdateInstalledSkills` deliberately
272
+ * never installs (`if (!isSkillInstalled) continue`) — it only keeps an
273
+ * EXISTING install in sync. A user who has never run any `trawl skills`
274
+ * command and never logged in before this shipped has nothing installed at
275
+ * all, and nothing in the CLI's startup path ever puts anything there: the
276
+ * CLI is competent, the agent reading its skills is not — the incident this
277
+ * issue exists to prevent. `login` is the one intentional human setup
278
+ * moment (the only place a human types credentials), so it is the ONLY
279
+ * place this function is ever called from (see commands/login.ts) — never
280
+ * from the generic startup path.
281
+ *
282
+ * Gated by `isSkillsActionAllowed` exactly like every other skills-related
283
+ * side effect: a non-interactive `trawl login` (CI's `TRAWL_TOKEN=x trawl
284
+ * login --json`, or any non-TTY invocation) writes nothing — that machine
285
+ * has no Claude Code session to discover a skill in, and `--json`'s stdout
286
+ * contract has no room for a plain-text confirmation line anyway. Also
287
+ * skipped entirely under `TRAWL_SKILLS_SYNC=0`.
288
+ *
289
+ * Installs every bundled skill NOT YET OWNED by trawl at `scope` (default
290
+ * 'user' — the global location a fresh `npx @trawlme/cli login` writes to;
291
+ * 'local' is opt-in via the same --local convention `trawl skills install`
292
+ * already uses everywhere else, kept for parity/tests). "Owned" means a
293
+ * readable `.version` marker (#184 review MAJOR — NOT mere path existence:
294
+ * see below for why that distinction matters here). One skill's install
295
+ * throwing must never blank out the others (#91's posture, applied here):
296
+ * each is wrapped individually, and the function reports exactly what
297
+ * landed. Returns `null` (nothing written, nothing to report) ONLY when
298
+ * gated out, or when every bundled skill is already owned (the re-sync
299
+ * loop above already keeps an existing install's version current) — the
300
+ * genuine "nothing to do" case. Never throws — a broken bootstrap must
301
+ * never turn a successful login into a failed one.
302
+ *
303
+ * #184 review (BLOCK + MAJOR) — two cases used to collapse into the exact
304
+ * same `null`/silence as genuine "nothing to do":
305
+ *
306
+ * 1. (BLOCK) Every install attempt failing outright (an unwritable
307
+ * `~/.claude/skills`, e.g.) used to return `null` — indistinguishable
308
+ * from "already installed" or "never attempted at all" on a first-run
309
+ * machine, the precise false-success shape #184 exists to prevent.
310
+ * 2. (MAJOR) A bundled skill name colliding with a PRE-EXISTING,
311
+ * marker-less directory the CLI doesn't own. The action set here used
312
+ * to be computed from `isSkillInstalled` (mere `existsSync`), which
313
+ * can't tell "we already installed this" from "something else already
314
+ * lives at this path" — a foreign dir was silently read as "already
315
+ * installed, nothing to do", so `installSkill`'s ownership-refusal
316
+ * guard was never even reached and the collision went unreported
317
+ * anywhere. The action set below is computed from *ownership*
318
+ * (`getInstalledVersion(...) !== null`) instead, so a foreign
319
+ * collision is genuinely attempted — hits the same guard `installSkill`
320
+ * already enforces elsewhere, throws, and is captured below — rather
321
+ * than silently skipped as if it were a prior trawl install.
322
+ *
323
+ * Whenever there was anything to attempt, the result is now ALWAYS a
324
+ * non-null object reporting both what landed (`installed`) and what didn't
325
+ * (`skipped`, each with a RELAYABLE reason — see `SkillOwnershipRefusalError`
326
+ * above: the ownership-refusal case reports `.relayableReason` [fact only,
327
+ * no `--force` imperative], every other throw [skill-not-found, raw fs
328
+ * errors like EACCES] reports `.message` as before, since those were never
329
+ * imperative to begin with), so `login.ts` can print an honest, distinct
330
+ * line for a skip instead of falling through to the generic "not installed"
331
+ * nudge as if login had done nothing, or silently omitting a name from the
332
+ * success line as if it had never been requested.
333
+ *
334
+ * #184 defect 2 — `error` (present whenever the function returns non-null)
335
+ * distinguishes a THIRD case from both "nothing to do" (`null`) and "one or
336
+ * more skills failed" (`skipped`): "could not even determine what to
337
+ * install" — `listBundledSkills()` returning an empty list (its own
338
+ * contract silently swallows a missing/renamed `skills/` dir into `[]`; see
339
+ * its doc comment) or `getSkillsPackageRoot()` throwing outright (the
340
+ * `@trawlme/skills` package itself unresolvable — a broken node_modules
341
+ * entry). Both used to fall through to `notOwned.length === 0` or the outer
342
+ * catch below, landing on the exact same `null` as a fully-up-to-date
343
+ * install — a genuinely corrupted bundle produced ZERO signal, not even a
344
+ * failed-attempt line, because nothing was ever attempted. `error` is
345
+ * `null` on every ordinary path (including genuine "all already owned",
346
+ * which still short-circuits to the `null` return below) and non-null only
347
+ * for this diagnostic-failure case, where `installed`/`skipped` are both
348
+ * empty because no skill name was ever known to attempt.
349
+ */
350
+ export function bootstrapSkillsOnLogin(opts = {}) {
351
+ try {
352
+ const optedOut = process.env['TRAWL_SKILLS_SYNC'] === '0';
353
+ if (!isSkillsActionAllowed({ json: opts.json, isTTY: opts.isTTY ?? false, optedOut }))
354
+ return null;
355
+ const scope = opts.scope ?? 'user';
356
+ const dest = getSkillsBase(scope);
357
+ let bundled;
358
+ try {
359
+ bundled = listBundledSkills();
360
+ }
361
+ catch (err) {
362
+ // getSkillsPackageRoot() threw (require.resolve failed — the
363
+ // `@trawlme/skills` package itself isn't resolvable). Report as a
364
+ // diagnostic failure, not the outer catch's generic `null`.
365
+ return {
366
+ installed: [],
367
+ skipped: [],
368
+ dest,
369
+ error: `could not read the bundled Claude skills package — ${err instanceof Error ? err.message : String(err)}`,
370
+ };
371
+ }
372
+ if (bundled.length === 0) {
373
+ // The package resolved but reports zero skills — in practice this
374
+ // package always ships at least one, so an empty list here is
375
+ // corruption (e.g. its `skills/` dir renamed/deleted underneath it),
376
+ // not a legitimate "nothing to do".
377
+ return {
378
+ installed: [],
379
+ skipped: [],
380
+ dest,
381
+ error: 'the bundled Claude skills package reports no skills to install — it may be missing or corrupted',
382
+ };
383
+ }
384
+ const notOwned = bundled.filter((name) => {
385
+ try {
386
+ return getInstalledVersion(name, scope) === null;
387
+ }
388
+ catch {
389
+ // An unreadable `.version` (e.g. EISDIR) is no more proof of
390
+ // ownership than a missing one — treat it as actionable too, same
391
+ // as installSkill's own owned-check does (#91's posture).
392
+ return true;
393
+ }
394
+ });
395
+ if (notOwned.length === 0)
396
+ return null;
397
+ const installed = [];
398
+ const skipped = [];
399
+ for (const name of notOwned) {
400
+ try {
401
+ installSkill(name, scope);
402
+ installed.push(name);
403
+ }
404
+ catch (err) {
405
+ // One bad skill (e.g. a marker-less foreign dir refusing overwrite,
406
+ // or an EACCES on an unwritable skills dir) must not blank the
407
+ // others — skip it, keep going, but never drop WHY silently: the
408
+ // caller needs this to tell "attempted and failed" apart from
409
+ // "nothing to do".
410
+ const reason = err instanceof SkillOwnershipRefusalError
411
+ ? err.relayableReason
412
+ : err instanceof Error
413
+ ? err.message
414
+ : String(err);
415
+ skipped.push({ name, reason });
416
+ }
417
+ }
418
+ return { installed, skipped, dest, error: null };
419
+ }
420
+ catch {
421
+ return null;
422
+ }
423
+ }
@@ -0,0 +1,33 @@
1
+ export declare const SKILLS_NUDGE_TEXT = "Claude skills not installed \u2014 `trawl skills install` installs them (Claude Code must be restarted to see them \u2014 skills are loaded at session start, not mid-session.)";
2
+ export declare const AUTH_WALL_SKILLS_NUDGE_TEXT = "This looks like a login wall and Claude skills are not installed \u2014 `trawl skills install` installs the guidance for it too (Claude Code must be restarted to see them \u2014 skills are loaded at session start, not mid-session.)";
3
+ /**
4
+ * Pure gate — true when the throttled, generic "elsewhere" suggestion
5
+ * should print. Every external signal is a parameter, exactly like
6
+ * isReferralTipDue, so this is testable without mocking fs/env/Date.
7
+ */
8
+ export declare function isSkillsNudgeDue(opts: {
9
+ json?: boolean;
10
+ isTTY: boolean;
11
+ optedOut: boolean;
12
+ skillsPresent: boolean;
13
+ lastShownAt: number;
14
+ now: number;
15
+ }): boolean;
16
+ /**
17
+ * Best-effort generic nudge (#184 point 2) — called once per CLI invocation
18
+ * from index.ts's runCli, on every command. Never throws.
19
+ */
20
+ export declare function maybeSuggestSkillsInstall(opts?: {
21
+ json?: boolean;
22
+ }): void;
23
+ /**
24
+ * Safety-net nudge (#184 point 5) — called from `doctor`/`run-info` right
25
+ * when the run they just showed carries a live `failureKind:'auth'` verdict
26
+ * (see commands/doctor.ts's `isAuthWall`). Deliberately unthrottled by the
27
+ * 7-day timestamp — see this module's doc comment for why — but still
28
+ * capped to one nudge per process via the same `nudgedThisProcess` flag the
29
+ * generic nudge sets. Never throws.
30
+ */
31
+ export declare function maybeSuggestSkillsForAuthWall(opts?: {
32
+ json?: boolean;
33
+ }): void;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * #184 — the "elsewhere: suggest, never install" half of the skills
3
+ * bootstrap story (lib/skills.ts's `bootstrapSkillsOnLogin` is the "install
4
+ * on login" half). Writing into `~/.claude/skills` as a side effect of an
5
+ * unrelated command is a filesystem mutation nobody asked for — this module
6
+ * only ever PRINTS a line naming the command that installs them. Never
7
+ * installs anything itself.
8
+ *
9
+ * Two nudges, one shared "at most once per process" guard:
10
+ * - `maybeSuggestSkillsInstall` — the generic, throttled nudge (once per
11
+ * NUDGE_THROTTLE_MS across ALL commands, mirrors lib/tips.ts's referral
12
+ * tip throttle exactly).
13
+ * - `maybeSuggestSkillsForAuthWall` — the safety-net nudge (#184 point 5):
14
+ * fires from `doctor`/`run-info` when the run they just showed carries a
15
+ * live `failureKind:'auth'` verdict and skills are absent. Deliberately
16
+ * UNTHROTTLED by the 7-day timestamp: sharing that throttle would mean a
17
+ * routine command on day 0 (stamping the generic nudge's timestamp)
18
+ * silently suppresses THIS nudge on day 1 when the user actually hits
19
+ * the auth wall the whole issue exists for — the exact failure this
20
+ * safety net is supposed to catch. It mirrors trawl_cli#182's own
21
+ * login-wall hint in `formatDoctor`, which also prints unthrottled every
22
+ * time the condition is true.
23
+ * - `nudgedThisProcess` still caps the two at "one nudge per invocation"
24
+ * combined: `doctor` on an auth-walled, skills-absent run can print the
25
+ * auth-wall nudge inside its own action, and index.ts's generic
26
+ * post-command nudge (which does not know `doctor` already said
27
+ * something) would otherwise print a second, redundant line right after
28
+ * it. Whichever fires first wins; reset per test via a fresh module
29
+ * import (see skillsNudge.test.ts's `freshImport` helper, mirroring
30
+ * tips.test.ts).
31
+ *
32
+ * Gate shape mirrors lib/tips.ts's isReferralTipDue/maybeShowReferralTip
33
+ * throughout (json/isTTY/opt-out/throttle, pure gate separated from the
34
+ * side-effecting caller, cheap checks before any filesystem read) — same
35
+ * reasoning applies verbatim.
36
+ */
37
+ import chalk from 'chalk';
38
+ import config from './config.js';
39
+ import { listBundledSkills, isSkillInstalled, isSkillsActionAllowed, RESTART_CLAUDE_CODE_NOTE } from './skills.js';
40
+ /** Max once per 7 days — same window as lib/tips.ts's referral tip. */
41
+ const NUDGE_THROTTLE_MS = 7 * 24 * 60 * 60 * 1000;
42
+ // #184 constraint A — a FACT about CLI state plus the command name, never an
43
+ // instruction to the reader ("install the skills"/"you should run…"). This
44
+ // output is not only read by a human at a terminal: this platform can relay
45
+ // a CLI's own stdout/stderr into an AI agent's context (the incident this
46
+ // issue exists to prevent was exactly an agent driving this CLI), and
47
+ // third-party content returned into agent context must never read as an
48
+ // instruction the agent is being told to obey. State the fact, name the
49
+ // command, stop there.
50
+ export const SKILLS_NUDGE_TEXT = `Claude skills not installed — \`trawl skills install\` installs them (${RESTART_CLAUDE_CODE_NOTE})`;
51
+ export const AUTH_WALL_SKILLS_NUDGE_TEXT = `This looks like a login wall and Claude skills are not installed — \`trawl skills install\` installs the guidance for it too (${RESTART_CLAUDE_CODE_NOTE})`;
52
+ /** One nudge per process, whichever of the two below fires first. */
53
+ let nudgedThisProcess = false;
54
+ /**
55
+ * Pure gate — true when the throttled, generic "elsewhere" suggestion
56
+ * should print. Every external signal is a parameter, exactly like
57
+ * isReferralTipDue, so this is testable without mocking fs/env/Date.
58
+ */
59
+ export function isSkillsNudgeDue(opts) {
60
+ if (!isSkillsActionAllowed({ json: opts.json, isTTY: opts.isTTY, optedOut: opts.optedOut }))
61
+ return false;
62
+ if (opts.skillsPresent)
63
+ return false;
64
+ return opts.now - opts.lastShownAt >= NUDGE_THROTTLE_MS;
65
+ }
66
+ /**
67
+ * Read-only — checks both scopes, mirrors `trawl skills list`'s own
68
+ * "installed anywhere" question. Failing toward `false` (not installed) on
69
+ * an unreadable skills dir is the safe default here: the worst case is one
70
+ * extra nudge line, never a missed one — the opposite failure (silently
71
+ * assuming "present" and staying quiet) is the exact gap this issue exists
72
+ * to close.
73
+ */
74
+ function anyBundledSkillInstalled() {
75
+ try {
76
+ return listBundledSkills().some((name) => isSkillInstalled(name, 'user') || isSkillInstalled(name, 'local'));
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * Shared body for both exported nudges below: the cheap json/TTY/opt-out
84
+ * gate, the "one nudge per process" cap, the skills-presence check, and the
85
+ * actual print — everything the two nudges have in common. `useThrottle`
86
+ * is the one real difference between them (see the module doc comment for
87
+ * why the safety-net nudge deliberately opts out of it), so it stays an
88
+ * explicit parameter here rather than two near-identical function bodies.
89
+ * Never throws.
90
+ */
91
+ function tryPrintNudge(opts, text, useThrottle) {
92
+ try {
93
+ if (nudgedThisProcess)
94
+ return;
95
+ const json = Boolean(opts.json);
96
+ const isTTY = Boolean(process.stdout.isTTY);
97
+ const optedOut = process.env['TRAWL_SKILLS_SYNC'] === '0';
98
+ // Cheap checks first (mirrors tips.ts #153's "never pay when not due")
99
+ // — only touch the filesystem once json/TTY/opt-out already say "maybe".
100
+ if (!isSkillsActionAllowed({ json, isTTY, optedOut }))
101
+ return;
102
+ if (useThrottle) {
103
+ const lastShownAt = config.get('skillsNudgeShownAt') || 0;
104
+ const now = Date.now();
105
+ const due = isSkillsNudgeDue({ json, isTTY, optedOut, skillsPresent: anyBundledSkillInstalled(), lastShownAt, now });
106
+ if (!due)
107
+ return;
108
+ config.set('skillsNudgeShownAt', now);
109
+ }
110
+ else if (anyBundledSkillInstalled()) {
111
+ return;
112
+ }
113
+ nudgedThisProcess = true;
114
+ console.error(chalk.dim(text));
115
+ }
116
+ catch {
117
+ // silent — a broken nudge must never break a command
118
+ }
119
+ }
120
+ /**
121
+ * Best-effort generic nudge (#184 point 2) — called once per CLI invocation
122
+ * from index.ts's runCli, on every command. Never throws.
123
+ */
124
+ export function maybeSuggestSkillsInstall(opts = {}) {
125
+ tryPrintNudge(opts, SKILLS_NUDGE_TEXT, true);
126
+ }
127
+ /**
128
+ * Safety-net nudge (#184 point 5) — called from `doctor`/`run-info` right
129
+ * when the run they just showed carries a live `failureKind:'auth'` verdict
130
+ * (see commands/doctor.ts's `isAuthWall`). Deliberately unthrottled by the
131
+ * 7-day timestamp — see this module's doc comment for why — but still
132
+ * capped to one nudge per process via the same `nudgedThisProcess` flag the
133
+ * generic nudge sets. Never throws.
134
+ */
135
+ export function maybeSuggestSkillsForAuthWall(opts = {}) {
136
+ tryPrintNudge(opts, AUTH_WALL_SKILLS_NUDGE_TEXT, false);
137
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "3.9.1",
3
+ "version": "3.10.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {