Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
profile?: string;
endpointUrl?: string;
dryRun?: boolean;
debug?: boolean;
planTemplate?: boolean;
};
const commandPath = commandPathOf(actionCommand);
Expand Down Expand Up @@ -245,6 +246,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
profile: globals.profile ?? 'default',
cwd: process.cwd(),
env: process.env,
debug: globals.debug ?? false,
});
}

Expand Down
100 changes: 98 additions & 2 deletions src/lib/skill-nudge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -130,8 +166,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);
});
Expand Down Expand Up @@ -194,6 +230,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({
Expand Down
39 changes: 35 additions & 4 deletions src/lib/skill-nudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,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;
}

/**
Expand All @@ -83,7 +85,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;
Expand Down Expand Up @@ -115,6 +124,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;
}
Expand All @@ -129,9 +140,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;
Expand All @@ -147,20 +160,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.
}
}

Expand Down
Loading