Files
Codeman/test/routes/session-routes-agent-skill.test.ts
T
Codeman maintainer ef071ed485 feat(agent-skill): give the codeman skill to every agent CLI, not just Claude
The agent skill only reached Claude Code: injection wrote
.claude/skills/codeman, which no other CLI reads, and the per-session
preamble was seeded for claude alone. A Codex, Gemini, OpenCode,
Antigravity, Grok, DeepSeek or OMP session inside Codeman never learned it
could start and drive workers.

- Registry field capabilities.agentSkillDir (a closed enum, since
  clis.json can set it and it decides where the server writes) names the
  skills directory each CLI scans. Claude keeps .claude/skills; those seven
  CLIs get the same skill in <case>/.agents/skills/codeman (each measured to
  scan it; Claude Code does not, so it never sees two copies). pi gets none:
  a project skill makes pi ask "Trust project folder?" at startup.
- The preamble is seeded for every local session whose CLI takes the
  skill; nothing in it is claude-specific.
- The skill text is CLI-neutral and names the two CLI-side failures to
  report instead of retrying: a sandbox that blocks loopback (Codex's
  default), and commands that do not carry this session's environment
  (Codex 0.162 runs them in a shared app-server daemon).
- POST /api/sessions injects after the launch pre-flight, so a CLI that
  cannot start leaves nothing in the workspace.
- codeman skill install gains --mode; uninstall --case removes the copy
  from every directory the registry can write.
- The settings toggle moves to Agents & CLIs > All agent CLIs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:33:47 +02:00

183 lines
6.9 KiB
TypeScript

/**
* @fileoverview Agent-skill injection on POST /api/sessions (docs/agent-control-plan.md §2).
*
* The quick-start half of this is covered end-to-end in test/quick-start.test.ts;
* the plain create path had NO coverage, because the shared mock context hardcoded
* `getAgentSkillEnabled` to false and nothing could flip it. This exercises the real
* `applyAgentSkill` against a temp working dir, so it asserts bytes on disk rather
* than a spy call.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { resolveCliLaunchError } from '../../src/utils/cli-launcher.js';
// External CLIs are pre-flighted (is the binary there?) before anything touches the
// workspace; CI has none installed, so the pre-flight is stubbed to "can start" and one
// test flips it to a refusal.
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(agentSkillEnabled: boolean): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ agentSkillEnabled });
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions agent-skill injection', () => {
let workingDir: string;
let harness: Harness | undefined;
const skillDir = () => join(workingDir, '.claude', 'skills', 'codeman');
beforeEach(async () => {
workingDir = await mkdtemp(join(tmpdir(), 'codeman-create-skill-'));
});
afterEach(async () => {
await harness?.app.close();
harness = undefined;
await rm(workingDir, { recursive: true, force: true });
});
it('injects the packaged skill into the working dir when the gate is ON', async () => {
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-on', mode: 'claude', workingDir },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.getAgentSkillEnabled).toHaveBeenCalled();
const skillMd = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
// The marker is what makes the copy ours: without it, uninstall refuses to
// remove what this create wrote.
expect(skillMd).toContain(MARKER_PREFIX);
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(true);
});
it('leaves the working dir untouched when the gate is OFF', async () => {
harness = await createHarness(false);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-off', mode: 'claude', workingDir },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.getAgentSkillEnabled).toHaveBeenCalled();
expect(existsSync(skillDir())).toBe(false);
});
it('does not inject for a non-claude mode even when the gate is ON', async () => {
// `.claude/skills/` is read by Claude Code only, so the gate is never even
// consulted for the other backends.
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-shell', mode: 'shell', workingDir },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.getAgentSkillEnabled).not.toHaveBeenCalled();
expect(existsSync(skillDir())).toBe(false);
});
it('gives a codex session the skill in .agents/skills, where codex looks, and none in .claude', async () => {
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-codex', mode: 'codex', workingDir },
});
expect(res.statusCode).toBe(200);
const skillMd = await readFile(join(workingDir, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
expect(skillMd).toContain(MARKER_PREFIX);
expect(existsSync(join(workingDir, '.agents', 'skills', 'codeman', 'reference', 'endpoints.md'))).toBe(true);
expect(existsSync(skillDir())).toBe(false);
// The preamble cache is seeded for it too, so the skill's §0 is the two-line loader
// in codex as in claude (nothing in the preamble is claude-specific).
const sessionId = (JSON.parse(res.body) as { session: { id: string } }).session.id;
const preamble = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), `codeman-agent-${sessionId}.sh`);
expect(existsSync(preamble)).toBe(true);
});
it('gives a pi session nothing: a project skill would put up pi\'s trust prompt', async () => {
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-pi', mode: 'pi', workingDir },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
expect(existsSync(join(workingDir, '.pi'))).toBe(false);
expect(harness.ctx.getAgentSkillEnabled).not.toHaveBeenCalled();
});
it('writes nothing when the CLI cannot start (pre-flight refusal)', async () => {
vi.mocked(resolveCliLaunchError).mockResolvedValueOnce('codex not found');
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-codex-missing', mode: 'codex', workingDir },
});
expect(JSON.parse(res.body).success).toBe(false);
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
});
it('defaults an omitted mode to claude and injects', async () => {
// The frontend omits `mode` for a plain claude create, so the default branch
// is the common path, not an edge case.
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-default-mode', workingDir },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
});
});