diff --git a/src/index.ts b/src/index.ts index 31d3399..fa598d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -166,6 +166,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => { output?: string; profile?: string; dryRun?: boolean; + debug?: boolean; }; const commandPath = commandPathOf(actionCommand); maybeEmitSkillNudge({ @@ -175,6 +176,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => { profile: globals.profile ?? 'default', cwd: process.cwd(), env: process.env, + debug: globals.debug ?? false, }); // Best-effort update notice (see lib/update-check.ts): self-gates on the diff --git a/src/lib/skill-nudge.test.ts b/src/lib/skill-nudge.test.ts index 2b26c0e..38def18 100644 --- a/src/lib/skill-nudge.test.ts +++ b/src/lib/skill-nudge.test.ts @@ -193,6 +193,51 @@ describe('maybeEmitSkillNudge', () => { expect(lines).toHaveLength(0); }); + it('reports a swallowed profile lookup error only in debug mode', () => { + const { ctx, lines } = makeCtx({ + debug: true, + readProfileImpl: () => { + throw new Error('credentials unavailable'); + }, + }); + + maybeEmitSkillNudge(ctx); + + expect(lines).toEqual(['[debug] skill nudge skipped: credentials unavailable']); + }); + + it('keeps an unreadable managed target byte-identical without debug', () => { + const normal = makeCtx(); + maybeEmitSkillNudge(normal.ctx); + + const unreadable = makeCtx({ + existsSync: p => p.endsWith('AGENTS.md'), + readFileSync: () => { + throw new Error('EACCES'); + }, + }); + maybeEmitSkillNudge(unreadable.ctx); + + expect(unreadable.lines).toEqual(normal.lines); + }); + + it('reports an unreadable managed target only in debug mode, then preserves the warning', () => { + const { ctx, lines } = makeCtx({ + debug: true, + existsSync: p => p.endsWith('AGENTS.md'), + readFileSync: () => { + throw new Error('EACCES'); + }, + }); + + maybeEmitSkillNudge(ctx); + + expect(lines).toHaveLength(2); + expect(lines[0]).toContain('[debug] skill nudge could not read /proj/AGENTS.md'); + expect(lines[0]).toContain('EACCES'); + expect(lines[1]).toContain('[warn] No TestSprite verification skill is installed'); + }); + it('passes the cwd through to the presence check', () => { const probed: string[] = []; const { ctx } = makeCtx({ diff --git a/src/lib/skill-nudge.ts b/src/lib/skill-nudge.ts index 0f67572..925b9ef 100644 --- a/src/lib/skill-nudge.ts +++ b/src/lib/skill-nudge.ts @@ -38,6 +38,8 @@ export const SKILL_NUDGE_OPT_OUT_ENV = 'TESTSPRITE_NO_SKILL_WARNING'; export interface SkillPresenceDeps { existsSync?: (p: string) => boolean; readFileSync?: (p: string) => string; + /** Best-effort diagnostic hook for an unreadable managed-section target. */ + onReadError?: (path: string, error: unknown) => void; } /** @@ -60,7 +62,14 @@ export function isVerifySkillInstalled(dir: string, deps: SkillPresenceDeps = {} if (spec.mode === 'managed-section') { try { if (hasCompleteManagedSection(read(full))) return true; - } catch { + } catch (error) { + // A diagnostic callback must not change this best-effort probe's + // behavior, even if the caller's stderr sink itself is unavailable. + try { + deps.onReadError?.(full, error); + } catch { + // ignore diagnostic delivery failures + } // unreadable AGENTS.md → treat this target as absent, keep checking } continue; @@ -92,6 +101,8 @@ export interface SkillNudgeContext { readProfileImpl?: (profile: string, opts: { path: string }) => { apiKey?: string } | undefined; /** Sink for the hint line; defaults to `process.stderr`. */ stderr?: (line: string) => void; + /** Emit best-effort diagnostics for swallowed nudge errors. */ + debug?: boolean; existsSync?: (p: string) => boolean; readFileSync?: (p: string) => string; } @@ -106,9 +117,11 @@ export interface SkillNudgeContext { * not `--dry-run`, the command is in {@link SKILL_NUDGE_COMMANDS}, the opt-out * env is unset, the active profile has an api key (un-configured callers hit an * auth error that already points at setup), and the skill is not already - * installed. Never throws and never blocks the command — any error is swallowed. + * installed. Never throws and never blocks the command — any error is swallowed; + * `--debug` callers receive the swallowed reason on stderr. */ export function maybeEmitSkillNudge(ctx: SkillNudgeContext): void { + const write = ctx.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); try { if (ctx.output !== 'text') return; if (ctx.dryRun) return; @@ -124,20 +137,38 @@ export function maybeEmitSkillNudge(ctx: SkillNudgeContext): void { isVerifySkillInstalled(ctx.cwd, { existsSync: ctx.existsSync, readFileSync: ctx.readFileSync, + onReadError: ctx.debug + ? (path, error) => + emitDebug( + write, + `skill nudge could not read ${path}; treating target as absent`, + error, + ) + : undefined, }) ) { return; } - const write = ctx.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); write( '[warn] No TestSprite verification skill is installed in this project — your coding ' + 'agent will not verify its changes against TestSprite. Run `testsprite setup` (or ' + `\`testsprite agent install\`) to set it up. Silence: ${SKILL_NUDGE_OPT_OUT_ENV}=1`, ); - } catch { + } catch (error) { // A nudge must never break, delay, or alter the exit status of a real // command. Swallow everything (missing creds file, fs races, etc.). + if (ctx.debug) emitDebug(write, 'skill nudge skipped', error); + } +} + +/** Emit a diagnostic without letting the diagnostic path break the command. */ +function emitDebug(write: (line: string) => void, context: string, error: unknown): void { + try { + const reason = error instanceof Error ? error.message : String(error); + write(`[debug] ${context}: ${reason}`); + } catch { + // A broken stderr sink must not turn a best-effort nudge into a failure. } }