mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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>
183 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
});
|