Skip to content

Commit 011208c

Browse files
authored
feat(agent): stamp installed skills with a version/hash marker and add "agent status" (#177)
1 parent 9457583 commit 011208c

5 files changed

Lines changed: 645 additions & 11 deletions

File tree

src/commands/agent.test.ts

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,21 @@ import {
99
MANAGED_SECTION_END,
1010
ONBOARD_CODEX_LINE,
1111
SKILLS,
12+
buildSkillMarker,
1213
pathFor,
1314
renderForTarget,
15+
renderOwnFileWithMarker,
1416
TARGETS,
1517
type AgentTarget,
1618
} from '../lib/agent-targets.js';
17-
import type { AgentDeps, AgentFs, InstallResult, ListResult } from './agent.js';
18-
import { AGENTS_MD_CODEX_BUDGET_BYTES, createAgentCommand, runInstall, runList } from './agent.js';
19+
import type { AgentDeps, AgentFs, InstallResult, ListResult, StatusResult } from './agent.js';
20+
import {
21+
AGENTS_MD_CODEX_BUDGET_BYTES,
22+
createAgentCommand,
23+
runInstall,
24+
runList,
25+
runStatus,
26+
} from './agent.js';
1927

2028
// ---------------------------------------------------------------------------
2129
// In-memory AgentFs backed by a Map
@@ -2430,3 +2438,120 @@ describe('runInstall — SKILLS registry / DEFAULT_SKILLS contract', () => {
24302438
expect(ONBOARD_CODEX_LINE).toContain('**First-time setup:**');
24312439
});
24322440
});
2441+
2442+
// ---------------------------------------------------------------------------
2443+
// runStatus — `agent status` (issue #123)
2444+
// ---------------------------------------------------------------------------
2445+
2446+
describe('runStatus — agent status (issue #123)', () => {
2447+
const statusOpts = {
2448+
profile: 'default' as const,
2449+
output: 'json' as const,
2450+
debug: false,
2451+
dryRun: false,
2452+
};
2453+
2454+
/** Run status against the given fs and return the printed rows. */
2455+
async function statusRows(agentFs: AgentFs): Promise<{ rows: StatusResult[]; thrown: unknown }> {
2456+
const { capture, deps } = makeCapture();
2457+
let thrown: unknown;
2458+
try {
2459+
await runStatus(statusOpts, { cwd: CWD, fs: agentFs, ...deps });
2460+
} catch (err) {
2461+
thrown = err;
2462+
}
2463+
return { rows: JSON.parse(capture.stdout.join('')) as StatusResult[], thrown };
2464+
}
2465+
2466+
it('nothing installed: every row is absent and the command exits 0', async () => {
2467+
const { fs: agentFs } = makeMemFs();
2468+
const { rows, thrown } = await statusRows(agentFs);
2469+
expect(thrown).toBeUndefined();
2470+
expect(rows).toHaveLength(Object.keys(TARGETS).length * DEFAULT_SKILLS.length);
2471+
expect(rows.every(row => row.state === 'absent')).toBe(true);
2472+
});
2473+
2474+
it('fresh installs read ok (own-file and codex managed section), exit 0', async () => {
2475+
const { fs: agentFs } = makeMemFs();
2476+
const { deps } = makeCapture();
2477+
await runInstall(
2478+
{
2479+
profile: 'default',
2480+
output: 'text',
2481+
debug: false,
2482+
dryRun: false,
2483+
target: ['claude', 'codex'],
2484+
skills: [...DEFAULT_SKILLS],
2485+
force: false,
2486+
},
2487+
{ cwd: CWD, fs: agentFs, ...deps },
2488+
);
2489+
2490+
const { rows, thrown } = await statusRows(agentFs);
2491+
expect(thrown).toBeUndefined();
2492+
for (const skill of DEFAULT_SKILLS) {
2493+
expect(rows.find(r => r.target === 'claude' && r.skill === skill)?.state).toBe('ok');
2494+
expect(rows.find(r => r.target === 'codex' && r.skill === skill)?.state).toBe('ok');
2495+
expect(rows.find(r => r.target === 'cursor' && r.skill === skill)?.state).toBe('absent');
2496+
}
2497+
});
2498+
2499+
it('stale: a marker whose hash matches an OLDER body reads stale and exits 1', async () => {
2500+
const { fs: agentFs, seedFile } = makeMemFs();
2501+
const oldBody = '# TestSprite Verification Loop\n\nold body from a previous CLI release\n';
2502+
seedFile(
2503+
path.resolve(CWD, pathFor('claude', 'testsprite-verify')),
2504+
renderOwnFileWithMarker(
2505+
'claude',
2506+
'testsprite-verify',
2507+
buildSkillMarker('testsprite-verify', oldBody),
2508+
oldBody,
2509+
),
2510+
);
2511+
2512+
const { rows, thrown } = await statusRows(agentFs);
2513+
expect(rows.find(r => r.target === 'claude' && r.skill === 'testsprite-verify')?.state).toBe(
2514+
'stale',
2515+
);
2516+
expect(thrown).toBeInstanceOf(CLIError);
2517+
expect((thrown as CLIError).exitCode).toBe(1);
2518+
expect((thrown as CLIError).message).toContain('need attention');
2519+
});
2520+
2521+
it('modified: current hash but edited bytes reads modified and exits 1', async () => {
2522+
const { fs: agentFs, seedFile } = makeMemFs();
2523+
const canonical = renderForTarget('claude', 'testsprite-verify').content;
2524+
seedFile(
2525+
path.resolve(CWD, pathFor('claude', 'testsprite-verify')),
2526+
`${canonical}\n<!-- my local tweak -->\n`,
2527+
);
2528+
2529+
const { rows, thrown } = await statusRows(agentFs);
2530+
expect(rows.find(r => r.target === 'claude' && r.skill === 'testsprite-verify')?.state).toBe(
2531+
'modified',
2532+
);
2533+
expect((thrown as CLIError).exitCode).toBe(1);
2534+
});
2535+
2536+
it('unmarked: an artifact without a marker line reads unmarked and exits 1', async () => {
2537+
const { fs: agentFs, seedFile } = makeMemFs();
2538+
seedFile(
2539+
path.resolve(CWD, pathFor('claude', 'testsprite-verify')),
2540+
'# hand-rolled skill file with no marker\n',
2541+
);
2542+
2543+
const { rows, thrown } = await statusRows(agentFs);
2544+
expect(rows.find(r => r.target === 'claude' && r.skill === 'testsprite-verify')?.state).toBe(
2545+
'unmarked',
2546+
);
2547+
expect((thrown as CLIError).exitCode).toBe(1);
2548+
});
2549+
2550+
it('rejects an explicit empty --dir (exit 5), matching the resolve-to-cwd hazard', async () => {
2551+
const { fs: agentFs } = makeMemFs();
2552+
const { deps } = makeCapture();
2553+
await expect(
2554+
runStatus({ ...statusOpts, dir: ' ' }, { cwd: CWD, fs: agentFs, ...deps }),
2555+
).rejects.toMatchObject({ exitCode: 5 });
2556+
});
2557+
});

0 commit comments

Comments
 (0)