diff --git a/src/index.ts b/src/index.ts old mode 100644 new mode 100755 index 3371eb4..5a812d7 --- a/src/index.ts +++ b/src/index.ts @@ -262,6 +262,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => { profile?: string; endpointUrl?: string; dryRun?: boolean; + debug?: boolean; planTemplate?: boolean; local?: string; }; @@ -295,6 +296,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => { profile: globals.profile ?? 'default', cwd: process.cwd(), env: process.env, + debug: globals.debug ?? false, }); } diff --git a/src/lib/skill-nudge.test.ts b/src/lib/skill-nudge.test.ts index 22617b0..d1e709f 100644 --- a/src/lib/skill-nudge.test.ts +++ b/src/lib/skill-nudge.test.ts @@ -68,6 +68,42 @@ describe('isVerifySkillInstalled', () => { expect(isVerifySkillInstalled('/proj', { existsSync, readFileSync })).toBe(false); }); + it('reports an unreadable managed target through the optional diagnostic callback', () => { + const errors: Array<{ path: string; error: unknown }> = []; + const existsSync = (p: string) => p.endsWith('AGENTS.md'); + const readFileSync = () => { + throw new Error('EACCES'); + }; + + expect( + isVerifySkillInstalled('/proj', { + existsSync, + readFileSync, + onReadError: (path, error) => errors.push({ path, error }), + }), + ).toBe(false); + expect(errors).toHaveLength(1); + expect(errors[0]?.path).toContain('AGENTS.md'); + expect(errors[0]?.error).toBeInstanceOf(Error); + }); + + it('never lets a failing diagnostic callback break the presence probe', () => { + const existsSync = (p: string) => p.endsWith('AGENTS.md'); + const readFileSync = () => { + throw new Error('EACCES'); + }; + + expect(() => + isVerifySkillInstalled('/proj', { + existsSync, + readFileSync, + onReadError: () => { + throw new Error('diagnostic sink failed'); + }, + }), + ).not.toThrow(); + }); + it('false when nothing is present', () => { expect(isVerifySkillInstalled('/proj', { existsSync: () => false })).toBe(false); }); @@ -158,8 +194,8 @@ describe('maybeEmitSkillNudge', () => { } }); - it('is silent in JSON mode (never pollutes a machine-readable stream)', () => { - const { ctx, lines } = makeCtx({ output: 'json' as OutputMode }); + it('is silent in JSON mode even with debug enabled', () => { + const { ctx, lines } = makeCtx({ output: 'json' as OutputMode, debug: true }); maybeEmitSkillNudge(ctx); expect(lines).toHaveLength(0); }); @@ -222,6 +258,66 @@ 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'); + expect(lines[0]).toContain('AGENTS.md'); + expect(lines[0]).toContain('EACCES'); + expect(lines[1]).toContain('[warn] No TestSprite verification skill is installed'); + }); + + it('never lets a failing debug stderr sink break the command', () => { + const { ctx } = makeCtx({ + debug: true, + stderr: () => { + throw new Error('stderr unavailable'); + }, + readProfileImpl: () => { + throw new Error('credentials unavailable'); + }, + }); + + expect(() => maybeEmitSkillNudge(ctx)).not.toThrow(); + }); + 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 5ac21c9..a0d334d 100644 --- a/src/lib/skill-nudge.ts +++ b/src/lib/skill-nudge.ts @@ -67,6 +67,8 @@ export function isPlanTemplateInvocation( 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; /** * Narrow the check to specific agents. Pass the agents actually calling: a * skill installed for some OTHER agent is not one this caller can read, and @@ -101,7 +103,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; @@ -133,6 +142,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; } @@ -147,9 +158,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; @@ -161,7 +174,18 @@ export function maybeEmitSkillNudge(ctx: SkillNudgeContext): void { const profile = lookup(ctx.profile, { path: credsPath }); if (!profile?.apiKey) return; - const presence = { existsSync: ctx.existsSync, readFileSync: ctx.readFileSync }; + const presence = { + existsSync: ctx.existsSync, + readFileSync: ctx.readFileSync, + onReadError: ctx.debug + ? (path: string, error: unknown) => + emitDebug( + write, + `skill nudge could not read ${path}; treating target as absent`, + error, + ) + : undefined, + }; // When the environment names the calling agent, only that agent's skill // counts — an install for a different agent is one this caller cannot read. // @@ -182,7 +206,6 @@ export function maybeEmitSkillNudge(ctx: SkillNudgeContext): void { return; } - const write = ctx.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); // Names the callers actually missing a skill, not every caller detected — // with one of two satisfied, "not for cursor" is the actionable half and // "not for claude or cursor" would be wrong about claude. @@ -195,9 +218,20 @@ export function maybeEmitSkillNudge(ctx: SkillNudgeContext): void { '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. } }