@trawlme/cli 3.9.0 → 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 +2 -0
- package/dist/commands/doctor.d.ts +50 -7
- package/dist/commands/doctor.js +152 -30
- package/dist/commands/login.js +58 -1
- package/dist/commands/scraps.js +22 -3
- package/dist/index.js +22 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.js +1 -0
- package/dist/lib/skills.d.ts +156 -0
- package/dist/lib/skills.js +227 -2
- package/dist/lib/skillsNudge.d.ts +33 -0
- package/dist/lib/skillsNudge.js +137 -0
- package/package.json +1 -1
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
|
|
@@ -25,19 +25,31 @@ export interface Run {
|
|
|
25
25
|
selectors?: Record<string, number>;
|
|
26
26
|
} | null;
|
|
27
27
|
blocked?: boolean;
|
|
28
|
+
block?: {
|
|
29
|
+
kind?: string | null;
|
|
30
|
+
} | null;
|
|
31
|
+
/** @deprecated trawl_node#1950 renamed this to `block.kind`. Kept as a read
|
|
32
|
+
* fallback (see detectWallVendor) for the window where this CLI is
|
|
33
|
+
* published ahead of the trawl_node prod tag — a prod backend served from
|
|
34
|
+
* the pre-#1950 tag still returns this flat field, not `block.kind`. Drop
|
|
35
|
+
* once prod is confirmed on a tag containing #1950. */
|
|
28
36
|
blockType?: string | null;
|
|
29
37
|
proxyTier?: string | null;
|
|
30
|
-
regressionDetected?: boolean;
|
|
31
38
|
baselineLength?: number | null;
|
|
32
39
|
fixVersionId?: string | null;
|
|
33
40
|
createdAt?: string;
|
|
34
41
|
time?: number | null;
|
|
35
42
|
triggeredBy?: string | null;
|
|
43
|
+
/** trawl_node#1975 — freeform failure classification (`'auth'` = login-wall
|
|
44
|
+
* empty run, cookies are the fix). Not a TS union — trawl_node's own set
|
|
45
|
+
* is additive/open, so this CLI must stay read-safe against a future
|
|
46
|
+
* value it doesn't know about yet, same posture as `block.kind` above. */
|
|
47
|
+
failureKind?: string | null;
|
|
36
48
|
}
|
|
37
49
|
/**
|
|
38
|
-
* Resolve a known anti-bot vendor
|
|
39
|
-
* string. Returns null when the run succeeded, when there is no `
|
|
40
|
-
* signal at all, or when `
|
|
50
|
+
* Resolve a known anti-bot vendor name from a worker `block.kind`
|
|
51
|
+
* string. Returns null when the run succeeded, when there is no `block.kind`
|
|
52
|
+
* signal at all, or when `block.kind` names something other than a known
|
|
41
53
|
* vendor (e.g. `proxy-domain-gate`, `rate_limited_per_host`) — those stay on
|
|
42
54
|
* the genuine-error path since we can't honestly attribute them to a specific
|
|
43
55
|
* "no reliable bypass" wall.
|
|
@@ -45,12 +57,43 @@ export interface Run {
|
|
|
45
57
|
* `blocked` is deliberately NOT consulted here (#177): it is a mid-run,
|
|
46
58
|
* attempt-level signal the worker only stamps on one envelope shape, so it
|
|
47
59
|
* reads `false` on the great majority of genuinely walled runs (the early-block
|
|
48
|
-
* throw path sets `
|
|
60
|
+
* throw path sets `block.kind` but never `blocked` — 63 of 64 walled runs
|
|
49
61
|
* measured on prod). `status === true` wins instead: a run that ultimately
|
|
50
|
-
* returned data was not walled, even if an earlier tier's `
|
|
62
|
+
* returned data was not walled, even if an earlier tier's `block.kind` stamp
|
|
51
63
|
* survived on the row.
|
|
64
|
+
*
|
|
65
|
+
* trawl_node#1950 renamed the flat `blockType` field to nested `block.kind`.
|
|
66
|
+
* Read `block?.kind` first, falling back to the deprecated flat `blockType` —
|
|
67
|
+
* this CLI can be published (and talk to prod) before the trawl_node prod tag
|
|
68
|
+
* containing #1950 is cut (see the `Run.blockType` doc comment), so a prod
|
|
69
|
+
* response can still be the pre-#1950 flat shape for a while.
|
|
70
|
+
*/
|
|
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.
|
|
52
91
|
*/
|
|
53
|
-
export declare function
|
|
92
|
+
export declare function isAuthWall(run: {
|
|
93
|
+
failureKind?: string | null;
|
|
94
|
+
status?: boolean | null;
|
|
95
|
+
statusDetail?: string | null;
|
|
96
|
+
}): boolean;
|
|
54
97
|
/**
|
|
55
98
|
* Autofix activity metadata — from the persisted ai_fix_end activity.
|
|
56
99
|
* aiUsage (cost) is stripped server-side; all diagnostics are kept.
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,23 +2,25 @@ import { api } from '../lib/api.js';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { formatDate } from '../lib/format.js';
|
|
4
4
|
/**
|
|
5
|
-
* Known anti-bot vendors
|
|
6
|
-
* Ordered by first-match;
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Known anti-bot vendors that the worker's `block.kind` field may name
|
|
6
|
+
* (ex-flat `blockType`, trawl_node#1950). Ordered by first-match;
|
|
7
|
+
* `block.kind` is a freeform worker string, not an enum (see the `Run.block`
|
|
8
|
+
* doc comment), so this is a best-effort substring match against the real
|
|
9
|
+
* field — never an invented/mocked value.
|
|
9
10
|
*
|
|
10
11
|
* `datadome`/`perimeterx`/`akamai`/`cloudflare` are literal substrings the
|
|
11
|
-
* current worker emits (detect.js). `kasada`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* automatically if/when a future worker classifier does, without a CLI
|
|
12
|
+
* current worker emits (detect.js). `kasada` is forward-compatible: the issue
|
|
13
|
+
* (#62 / wall-registry WS-7) names it as a genuine wall, but the current
|
|
14
|
+
* worker vocabulary does not yet emit it — this branch lights up
|
|
15
|
+
* automatically if/when a future worker classifier does, without a CLI
|
|
16
|
+
* change.
|
|
15
17
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* trawl_cli#182 — an `auth` entry used to live here, rendering a login wall
|
|
19
|
+
* as "walled: auth — no reliable bypass". Removed: unlike a real anti-bot
|
|
20
|
+
* vendor, a login wall's own reliable bypass is the user's session cookies,
|
|
21
|
+
* so that verdict was wrong, not just early. `failureKind==='auth'`
|
|
22
|
+
* (trawl_node#1975) is the honest signal for a login wall — see the
|
|
23
|
+
* dedicated branch in `formatDoctor` below.
|
|
22
24
|
*/
|
|
23
25
|
const WALL_VENDOR_PATTERNS = [
|
|
24
26
|
[/datadome/i, 'DataDome'],
|
|
@@ -26,12 +28,11 @@ const WALL_VENDOR_PATTERNS = [
|
|
|
26
28
|
[/perimeterx/i, 'PerimeterX'],
|
|
27
29
|
[/akamai/i, 'Akamai'],
|
|
28
30
|
[/cloudflare/i, 'Cloudflare'],
|
|
29
|
-
[/(?:^|[-_:])auth(?:$|[-_:])/i, 'auth'],
|
|
30
31
|
];
|
|
31
32
|
/**
|
|
32
|
-
* Resolve a known anti-bot vendor
|
|
33
|
-
* string. Returns null when the run succeeded, when there is no `
|
|
34
|
-
* signal at all, or when `
|
|
33
|
+
* Resolve a known anti-bot vendor name from a worker `block.kind`
|
|
34
|
+
* string. Returns null when the run succeeded, when there is no `block.kind`
|
|
35
|
+
* signal at all, or when `block.kind` names something other than a known
|
|
35
36
|
* vendor (e.g. `proxy-domain-gate`, `rate_limited_per_host`) — those stay on
|
|
36
37
|
* the genuine-error path since we can't honestly attribute them to a specific
|
|
37
38
|
* "no reliable bypass" wall.
|
|
@@ -39,28 +40,58 @@ const WALL_VENDOR_PATTERNS = [
|
|
|
39
40
|
* `blocked` is deliberately NOT consulted here (#177): it is a mid-run,
|
|
40
41
|
* attempt-level signal the worker only stamps on one envelope shape, so it
|
|
41
42
|
* reads `false` on the great majority of genuinely walled runs (the early-block
|
|
42
|
-
* throw path sets `
|
|
43
|
+
* throw path sets `block.kind` but never `blocked` — 63 of 64 walled runs
|
|
43
44
|
* measured on prod). `status === true` wins instead: a run that ultimately
|
|
44
|
-
* returned data was not walled, even if an earlier tier's `
|
|
45
|
+
* returned data was not walled, even if an earlier tier's `block.kind` stamp
|
|
45
46
|
* survived on the row.
|
|
47
|
+
*
|
|
48
|
+
* trawl_node#1950 renamed the flat `blockType` field to nested `block.kind`.
|
|
49
|
+
* Read `block?.kind` first, falling back to the deprecated flat `blockType` —
|
|
50
|
+
* this CLI can be published (and talk to prod) before the trawl_node prod tag
|
|
51
|
+
* containing #1950 is cut (see the `Run.blockType` doc comment), so a prod
|
|
52
|
+
* response can still be the pre-#1950 flat shape for a while.
|
|
46
53
|
*/
|
|
47
54
|
export function detectWallVendor(run) {
|
|
48
55
|
// A run that returned data was not walled. `status === true` is not the whole
|
|
49
56
|
// predicate: trawl_node flips a degraded-but-non-empty run to
|
|
50
57
|
// `status:false, statusDetail:'regression'` (scraps.service.js, #1112) via a
|
|
51
|
-
// patch that never clears `
|
|
58
|
+
// patch that never clears `block.kind` — so keying on `status` alone would
|
|
52
59
|
// print "no reliable bypass" next to "Regression: length N vs baseline M",
|
|
53
60
|
// which is the same contradiction this guard exists to remove.
|
|
54
61
|
if (run.status === true || run.statusDetail === 'regression')
|
|
55
62
|
return null;
|
|
56
|
-
|
|
63
|
+
const kind = run.block?.kind ?? run.blockType ?? null;
|
|
64
|
+
if (!kind)
|
|
57
65
|
return null; // no signal at all
|
|
58
66
|
for (const [pattern, label] of WALL_VENDOR_PATTERNS) {
|
|
59
|
-
if (pattern.test(
|
|
67
|
+
if (pattern.test(kind))
|
|
60
68
|
return label;
|
|
61
69
|
}
|
|
62
70
|
return null;
|
|
63
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
|
+
}
|
|
64
95
|
const TIER_LABELS = {
|
|
65
96
|
tier0: 'Tier 0',
|
|
66
97
|
tier1: 'Tier 1',
|
|
@@ -70,8 +101,8 @@ const TIER_LABELS = {
|
|
|
70
101
|
};
|
|
71
102
|
const RUN_ALLOWLIST = [
|
|
72
103
|
'_id', 'status', 'statusDetail', 'length', 'errorMessage', 'errorSnapshot',
|
|
73
|
-
'emptyContext', 'blocked', '
|
|
74
|
-
'fixVersionId', 'createdAt', 'time', 'triggeredBy',
|
|
104
|
+
'emptyContext', 'blocked', 'block', 'blockType', 'proxyTier', 'baselineLength',
|
|
105
|
+
'fixVersionId', 'createdAt', 'time', 'triggeredBy', 'failureKind',
|
|
75
106
|
];
|
|
76
107
|
const FIX_ALLOWLIST = [
|
|
77
108
|
'outcome', 'classification', 'reason', 'fixDiff', 'dryRunResults',
|
|
@@ -124,14 +155,81 @@ export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
|
|
|
124
155
|
lines.push(`${chalk.bold(scrapTitle)} ${badge}${run.statusDetail ? ` (${run.statusDetail})` : ''}`);
|
|
125
156
|
lines.push(chalk.dim(` Run ID: ${run._id}`));
|
|
126
157
|
// Error message — an honest accept-wall string for known-walled scraps
|
|
127
|
-
// (DataDome/Kasada/PerimeterX/Akamai
|
|
158
|
+
// (DataDome/Kasada/PerimeterX/Akamai terminal verdict from the worker),
|
|
128
159
|
// otherwise the real error (genuine transient failure).
|
|
160
|
+
//
|
|
161
|
+
// trawl_cli#182 — the wall-vendor verdict (this CLI's own
|
|
162
|
+
// WALL_VENDOR_PATTERNS matched against block.kind/blockType) and the
|
|
163
|
+
// login-wall hint (node's failureKind==='auth', trawl_node#1975) are two
|
|
164
|
+
// INDEPENDENT server-derived signals with no shared contract: node's block
|
|
165
|
+
// classifier (modules/historys/helpers/failureKind.js BLOCK_TYPE_PATTERN)
|
|
166
|
+
// and this CLI's vendor table live in different repos and can legitimately
|
|
167
|
+
// disagree. Concretely, `kasada` matches this CLI's table but is absent
|
|
168
|
+
// from node's block pattern, and the deprecated flat `blockType` fallback
|
|
169
|
+
// is a field node's classifier no longer reads at all — so a run can come
|
|
170
|
+
// back `failureKind:'auth'` (the server says login wall) while also
|
|
171
|
+
// matching a vendor here (this CLI says Kasada/DataDome/etc), both true
|
|
172
|
+
// signals about the same run, neither one wrong.
|
|
173
|
+
//
|
|
174
|
+
// trawl_cli#169 precedent: never assert client-side what only the server
|
|
175
|
+
// knows. Silently picking a winner between two disagreeing signals is
|
|
176
|
+
// exactly that — printing "no reliable bypass" alone asserts the run is
|
|
177
|
+
// NOT an auth wall (may stop someone from trying a fixable cookie
|
|
178
|
+
// problem); printing the cookie hint alone asserts the run is NOT a
|
|
179
|
+
// vendor wall (may send someone chasing cookies against a wall no cookie
|
|
180
|
+
// fixes). This CLI cannot tell which is true without either importing
|
|
181
|
+
// node's BLOCK_TYPE_PATTERN (a second source of truth for the same list —
|
|
182
|
+
// how this defect got here) or re-detecting the login URL itself (the
|
|
183
|
+
// client-side re-derivation this fix deliberately rejects). So when both
|
|
184
|
+
// fire, render ONE hedged line naming both readings instead of picking:
|
|
185
|
+
// never emit the bare "no reliable bypass" verdict in that case — it is
|
|
186
|
+
// exactly the false precision this branch exists to avoid.
|
|
187
|
+
//
|
|
188
|
+
// `conflictingSignals` is deliberately NOT gated on `scrapId` the way the
|
|
189
|
+
// plain hint below is: the false "no reliable bypass" verdict is wrong
|
|
190
|
+
// regardless of whether we also have an id to build an actionable command
|
|
191
|
+
// for, so a scrapId-less conflicting run must still avoid it. Only the
|
|
192
|
+
// action line degrades (to a generic Settings pointer) when there's no id.
|
|
193
|
+
//
|
|
194
|
+
// Single-signal cases are untouched: a plain vendor match (no
|
|
195
|
+
// failureKind:'auth') still gets "no reliable bypass"; a plain
|
|
196
|
+
// failureKind:'auth' (no vendor match) still gets the plain hint block
|
|
197
|
+
// below. `errMsg` stays suppressed whenever any hint form (conflicting or
|
|
198
|
+
// plain) is about to render — node's own `errorMessage` for an auth run
|
|
199
|
+
// (historys.service.js ~line 774) IS the hint copy verbatim, literal
|
|
200
|
+
// `<id>` placeholder and all, so printing it here would just be a second
|
|
201
|
+
// rendering of the same information. If neither hint form renders (no
|
|
202
|
+
// vendor conflict and no scrapId for the plain hint), fall back to node's
|
|
203
|
+
// raw message rather than dropping the error entirely.
|
|
129
204
|
const wallVendor = detectWallVendor(run);
|
|
130
205
|
const errMsg = run.errorMessage ?? run.errorSnapshot?.errorMessage;
|
|
131
|
-
|
|
206
|
+
// trawl_cli#182 — same `status`/`statusDetail` guard as `detectWallVendor`
|
|
207
|
+
// above, and for the same reason (see its doc comment): `failureKind` is a
|
|
208
|
+
// terminal classification trawl_node stamps once
|
|
209
|
+
// (modules/historys/services/historys.service.js ~line 837), but
|
|
210
|
+
// `patchForRegression` (modules/scraps/services/scraps.service.js ~line
|
|
211
|
+
// 1972) can flip `status`/`statusDetail` to success/regression LATER,
|
|
212
|
+
// without ever clearing `failureKind`. Left unguarded, a run that
|
|
213
|
+
// ultimately succeeded or degraded could still carry a stale
|
|
214
|
+
// `failureKind:'auth'` and render the login-wall hint (or the
|
|
215
|
+
// conflicting-signals hedge) right next to a green "success" badge or a
|
|
216
|
+
// "Regression: length N vs baseline M" line — the exact contradiction
|
|
217
|
+
// `detectWallVendor`'s guard already exists to prevent for `block.kind`.
|
|
218
|
+
// One rule, not two coincidences: both checks guard the same two fields
|
|
219
|
+
// against the same after-the-fact patch.
|
|
220
|
+
const authWall = isAuthWall(run);
|
|
221
|
+
const authHintWillRender = authWall && Boolean(scrapId);
|
|
222
|
+
const conflictingSignals = Boolean(wallVendor) && authWall;
|
|
223
|
+
if (conflictingSignals) {
|
|
224
|
+
lines.push(chalk.dim(' Error: ') + chalk.yellow(`conflicting signals — the server reported ${wallVendor} on this run, but also classified it as a login wall; can't tell which is true here`));
|
|
225
|
+
lines.push(chalk.dim(scrapId
|
|
226
|
+
? ` → cookies might help, but this may still be a genuine ${wallVendor} block: trawl scraps account session set ${scrapId} -c cookies.json`
|
|
227
|
+
: ` → cookies might help, but this may still be a genuine ${wallVendor} block (see app Settings → Account)`));
|
|
228
|
+
}
|
|
229
|
+
else if (wallVendor) {
|
|
132
230
|
lines.push(chalk.dim(' Error: ') + chalk.red(`walled: ${wallVendor} — no reliable bypass`));
|
|
133
231
|
}
|
|
134
|
-
else if (errMsg) {
|
|
232
|
+
else if (errMsg && !authHintWillRender) {
|
|
135
233
|
lines.push(chalk.dim(' Error: ') + chalk.red(errMsg));
|
|
136
234
|
}
|
|
137
235
|
// Failed selector
|
|
@@ -149,8 +247,32 @@ export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
|
|
|
149
247
|
const page = run.emptyContext.page;
|
|
150
248
|
lines.push(chalk.dim(' Empty context: ') + `url=${page.url ?? '?'} anchors=${page.totalAnchors ?? '?'}`);
|
|
151
249
|
}
|
|
152
|
-
//
|
|
153
|
-
|
|
250
|
+
// Login-wall hint (trawl_node#1975 `failureKind==='auth'`) — trust node's
|
|
251
|
+
// verdict, never re-detect the login URL client-side. Hypothesis-framed
|
|
252
|
+
// ("likely fix"), not a promise: measured cases (Reddit/X/Instagram) had
|
|
253
|
+
// valid cookies and stayed walled. Gated on `scrapId` alone (via
|
|
254
|
+
// `authHintWillRender` above, shared with the generic error line so the
|
|
255
|
+
// two can't drift apart), NOT the `scrapId ?? run._id` fallback used
|
|
256
|
+
// elsewhere here — `run._id` is a history id, and the session-set command
|
|
257
|
+
// on one silently targets the wrong document.
|
|
258
|
+
//
|
|
259
|
+
// trawl_cli#182 — excludes `conflictingSignals`: when a wall-vendor match
|
|
260
|
+
// also fired, the combined hedged line above already covers the cookie
|
|
261
|
+
// suggestion. Rendering this plain, unhedged block too would stack a
|
|
262
|
+
// second verdict under the first (and directly contradict it, since this
|
|
263
|
+
// block asserts the run IS a login wall with no caveat) — exactly the
|
|
264
|
+
// two-verdicts-for-one-run defect this fix removes. Exactly one verdict
|
|
265
|
+
// block renders per run.
|
|
266
|
+
if (authHintWillRender && !conflictingSignals) {
|
|
267
|
+
lines.push('');
|
|
268
|
+
lines.push(chalk.yellow(' Login wall (hypothesis): ') + 'this looks like a login redirect — your own session cookies are the likely fix.');
|
|
269
|
+
lines.push(chalk.dim(` → trawl scraps account session set ${scrapId} -c cookies.json (or app Settings → Account)`));
|
|
270
|
+
}
|
|
271
|
+
// Regression detail. trawl_node#1950 dropped the redundant `regressionDetected`
|
|
272
|
+
// boolean — it was `true` on every row iff `statusDetail === 'regression'`,
|
|
273
|
+
// so read that directly (works against a pre- or post-#1950 backend alike,
|
|
274
|
+
// no CLI/backend sequencing gap: `statusDetail` isn't part of this rename).
|
|
275
|
+
if (run.statusDetail === 'regression') {
|
|
154
276
|
lines.push(chalk.dim(' Regression: ') + `length ${run.length ?? '?'} vs baseline ${run.baselineLength ?? '?'}`);
|
|
155
277
|
}
|
|
156
278
|
// Autofix summary (when fix exists)
|
package/dist/commands/login.js
CHANGED
|
@@ -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;
|
package/dist/commands/scraps.js
CHANGED
|
@@ -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
|
|
@@ -1234,7 +1235,9 @@ export function attachHistoryCommand(parent, attachOpts = {}) {
|
|
|
1234
1235
|
time: h.time ?? null,
|
|
1235
1236
|
tier: h.proxyTier ?? null,
|
|
1236
1237
|
failureKind: h.failureKind ?? null,
|
|
1237
|
-
blockType
|
|
1238
|
+
// trawl_node#1950 renamed blockType to block.kind; fall back to the
|
|
1239
|
+
// deprecated flat field for a prod backend not yet on that tag.
|
|
1240
|
+
blockType: h.block?.kind ?? h.blockType ?? null,
|
|
1238
1241
|
createdAt: h.createdAt ?? null,
|
|
1239
1242
|
}));
|
|
1240
1243
|
if (opts.json) {
|
|
@@ -1260,7 +1263,9 @@ export function attachRunInfoCommand(parent, attachOpts = {}) {
|
|
|
1260
1263
|
time: h.time ?? null,
|
|
1261
1264
|
tier: h.proxyTier ?? null,
|
|
1262
1265
|
failureKind: h.failureKind ?? null,
|
|
1263
|
-
blockType
|
|
1266
|
+
// trawl_node#1950 renamed blockType to block.kind; fall back to the
|
|
1267
|
+
// deprecated flat field for a prod backend not yet on that tag.
|
|
1268
|
+
blockType: h.block?.kind ?? h.blockType ?? null,
|
|
1264
1269
|
errorMessage: h.errorSnapshot?.errorMessage ?? null,
|
|
1265
1270
|
selector: h.errorSnapshot?.selector ?? null,
|
|
1266
1271
|
emptyContext: h.emptyContext ?? null,
|
|
@@ -1272,6 +1277,13 @@ export function attachRunInfoCommand(parent, attachOpts = {}) {
|
|
|
1272
1277
|
}
|
|
1273
1278
|
// The blob is an object; the table needs one line. --json keeps it whole.
|
|
1274
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
|
+
}
|
|
1275
1287
|
});
|
|
1276
1288
|
}
|
|
1277
1289
|
attachRunInfoCommand(scraps, { hidden: true });
|
|
@@ -1656,6 +1668,13 @@ scraps
|
|
|
1656
1668
|
if (opts.autofix && result.fix) {
|
|
1657
1669
|
console.log('\n' + formatAutofix(result.fix));
|
|
1658
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
|
+
}
|
|
1659
1678
|
});
|
|
1660
1679
|
// autofix — show full auto-fix attempt detail (diff, dry-run, knowledge)
|
|
1661
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
|
}
|
package/dist/lib/config.d.ts
CHANGED
package/dist/lib/config.js
CHANGED
package/dist/lib/skills.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/skills.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|