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 @@ -166,6 +166,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
output?: string;
profile?: string;
dryRun?: boolean;
debug?: boolean;
};
const commandPath = commandPathOf(actionCommand);
maybeEmitSkillNudge({
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions src/lib/skill-nudge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
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 @@ -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;
}

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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.
}
}

Expand Down
Loading