Skip to content

Commit 665d7a2

Browse files
committed
fix(skill-nudge): log swallowed errors in debug mode
1 parent fe07bc9 commit 665d7a2

3 files changed

Lines changed: 82 additions & 4 deletions

File tree

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
166166
output?: string;
167167
profile?: string;
168168
dryRun?: boolean;
169+
debug?: boolean;
169170
};
170171
const commandPath = commandPathOf(actionCommand);
171172
maybeEmitSkillNudge({
@@ -175,6 +176,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
175176
profile: globals.profile ?? 'default',
176177
cwd: process.cwd(),
177178
env: process.env,
179+
debug: globals.debug ?? false,
178180
});
179181

180182
// Best-effort update notice (see lib/update-check.ts): self-gates on the

src/lib/skill-nudge.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,51 @@ describe('maybeEmitSkillNudge', () => {
193193
expect(lines).toHaveLength(0);
194194
});
195195

196+
it('reports a swallowed profile lookup error only in debug mode', () => {
197+
const { ctx, lines } = makeCtx({
198+
debug: true,
199+
readProfileImpl: () => {
200+
throw new Error('credentials unavailable');
201+
},
202+
});
203+
204+
maybeEmitSkillNudge(ctx);
205+
206+
expect(lines).toEqual(['[debug] skill nudge skipped: credentials unavailable']);
207+
});
208+
209+
it('keeps an unreadable managed target byte-identical without debug', () => {
210+
const normal = makeCtx();
211+
maybeEmitSkillNudge(normal.ctx);
212+
213+
const unreadable = makeCtx({
214+
existsSync: p => p.endsWith('AGENTS.md'),
215+
readFileSync: () => {
216+
throw new Error('EACCES');
217+
},
218+
});
219+
maybeEmitSkillNudge(unreadable.ctx);
220+
221+
expect(unreadable.lines).toEqual(normal.lines);
222+
});
223+
224+
it('reports an unreadable managed target only in debug mode, then preserves the warning', () => {
225+
const { ctx, lines } = makeCtx({
226+
debug: true,
227+
existsSync: p => p.endsWith('AGENTS.md'),
228+
readFileSync: () => {
229+
throw new Error('EACCES');
230+
},
231+
});
232+
233+
maybeEmitSkillNudge(ctx);
234+
235+
expect(lines).toHaveLength(2);
236+
expect(lines[0]).toContain('[debug] skill nudge could not read /proj/AGENTS.md');
237+
expect(lines[0]).toContain('EACCES');
238+
expect(lines[1]).toContain('[warn] No TestSprite verification skill is installed');
239+
});
240+
196241
it('passes the cwd through to the presence check', () => {
197242
const probed: string[] = [];
198243
const { ctx } = makeCtx({

src/lib/skill-nudge.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export const SKILL_NUDGE_OPT_OUT_ENV = 'TESTSPRITE_NO_SKILL_WARNING';
3838
export interface SkillPresenceDeps {
3939
existsSync?: (p: string) => boolean;
4040
readFileSync?: (p: string) => string;
41+
/** Best-effort diagnostic hook for an unreadable managed-section target. */
42+
onReadError?: (path: string, error: unknown) => void;
4143
}
4244

4345
/**
@@ -60,7 +62,14 @@ export function isVerifySkillInstalled(dir: string, deps: SkillPresenceDeps = {}
6062
if (spec.mode === 'managed-section') {
6163
try {
6264
if (hasCompleteManagedSection(read(full))) return true;
63-
} catch {
65+
} catch (error) {
66+
// A diagnostic callback must not change this best-effort probe's
67+
// behavior, even if the caller's stderr sink itself is unavailable.
68+
try {
69+
deps.onReadError?.(full, error);
70+
} catch {
71+
// ignore diagnostic delivery failures
72+
}
6473
// unreadable AGENTS.md → treat this target as absent, keep checking
6574
}
6675
continue;
@@ -92,6 +101,8 @@ export interface SkillNudgeContext {
92101
readProfileImpl?: (profile: string, opts: { path: string }) => { apiKey?: string } | undefined;
93102
/** Sink for the hint line; defaults to `process.stderr`. */
94103
stderr?: (line: string) => void;
104+
/** Emit best-effort diagnostics for swallowed nudge errors. */
105+
debug?: boolean;
95106
existsSync?: (p: string) => boolean;
96107
readFileSync?: (p: string) => string;
97108
}
@@ -106,9 +117,11 @@ export interface SkillNudgeContext {
106117
* not `--dry-run`, the command is in {@link SKILL_NUDGE_COMMANDS}, the opt-out
107118
* env is unset, the active profile has an api key (un-configured callers hit an
108119
* auth error that already points at setup), and the skill is not already
109-
* installed. Never throws and never blocks the command — any error is swallowed.
120+
* installed. Never throws and never blocks the command — any error is swallowed;
121+
* `--debug` callers receive the swallowed reason on stderr.
110122
*/
111123
export function maybeEmitSkillNudge(ctx: SkillNudgeContext): void {
124+
const write = ctx.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
112125
try {
113126
if (ctx.output !== 'text') return;
114127
if (ctx.dryRun) return;
@@ -124,20 +137,38 @@ export function maybeEmitSkillNudge(ctx: SkillNudgeContext): void {
124137
isVerifySkillInstalled(ctx.cwd, {
125138
existsSync: ctx.existsSync,
126139
readFileSync: ctx.readFileSync,
140+
onReadError: ctx.debug
141+
? (path, error) =>
142+
emitDebug(
143+
write,
144+
`skill nudge could not read ${path}; treating target as absent`,
145+
error,
146+
)
147+
: undefined,
127148
})
128149
) {
129150
return;
130151
}
131152

132-
const write = ctx.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
133153
write(
134154
'[warn] No TestSprite verification skill is installed in this project — your coding ' +
135155
'agent will not verify its changes against TestSprite. Run `testsprite setup` (or ' +
136156
`\`testsprite agent install\`) to set it up. Silence: ${SKILL_NUDGE_OPT_OUT_ENV}=1`,
137157
);
138-
} catch {
158+
} catch (error) {
139159
// A nudge must never break, delay, or alter the exit status of a real
140160
// command. Swallow everything (missing creds file, fs races, etc.).
161+
if (ctx.debug) emitDebug(write, 'skill nudge skipped', error);
162+
}
163+
}
164+
165+
/** Emit a diagnostic without letting the diagnostic path break the command. */
166+
function emitDebug(write: (line: string) => void, context: string, error: unknown): void {
167+
try {
168+
const reason = error instanceof Error ? error.message : String(error);
169+
write(`[debug] ${context}: ${reason}`);
170+
} catch {
171+
// A broken stderr sink must not turn a best-effort nudge into a failure.
141172
}
142173
}
143174

0 commit comments

Comments
 (0)