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>
197 lines
8.5 KiB
TypeScript
197 lines
8.5 KiB
TypeScript
/**
|
|
* @fileoverview Agent skill per CLI: which workspace directory each CLI's sessions get
|
|
* the one packaged skill in (`capabilities.agentSkillDir`), and the
|
|
* `codeman skill install|uninstall` resolution that follows the registry.
|
|
*
|
|
* The skill is read from the REAL packaged source (`skills/codeman/`), so this also
|
|
* guards that it meets the Agent Skills format every CLI (and the `npx skills`
|
|
* installer) parses: a `name` equal to its directory, lowercase with hyphens, and a
|
|
* description of at most 1024 characters.
|
|
*
|
|
* Pure filesystem and registry tests in a per-test temp dir. Port: N/A.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtemp, rm, mkdir, readFile, readdir } from 'node:fs/promises';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { agentSkillDir, agentSkillDirFor, applyAgentSkill, CLAUDE_AGENT_SKILL_DIR } from '../src/hooks-config.js';
|
|
import { getCli, listClis } from '../src/config/cli-registry/registry.js';
|
|
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
|
import { registryAgentSkillDirs, resolveSkillInstallDir } from '../src/cli.js';
|
|
|
|
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
|
const AGENTS_DIR = '.agents/skills' as const;
|
|
|
|
/** The frontmatter fields the Agent Skills format defines, with a `>-` folded description joined. */
|
|
function frontmatter(skillMd: string): { name?: string; description?: string } {
|
|
const match = /^---\n([\s\S]*?)\n---\n/.exec(skillMd);
|
|
if (!match) return {};
|
|
const lines = match[1].split('\n');
|
|
const out: { name?: string; description?: string } = {};
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const name = /^name:\s*(.+)$/.exec(lines[i]);
|
|
if (name) out.name = name[1].trim();
|
|
const desc = /^description:\s*(.*)$/.exec(lines[i]);
|
|
if (desc) {
|
|
if (desc[1].trim() === '>-' || desc[1].trim() === '>') {
|
|
const folded: string[] = [];
|
|
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) folded.push(lines[++i].trim());
|
|
out.description = folded.join(' ');
|
|
} else {
|
|
out.description = desc[1].trim();
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
let casePath: string;
|
|
|
|
beforeEach(async () => {
|
|
casePath = await mkdtemp(join(tmpdir(), 'codeman-agent-skill-dirs-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(casePath, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('the packaged skill meets the Agent Skills format', () => {
|
|
it('skills/codeman/SKILL.md: name equals the directory, description within limits', () => {
|
|
const fm = frontmatter(readFileSync(join(process.cwd(), 'skills', 'codeman', 'SKILL.md'), 'utf-8'));
|
|
expect(fm.name).toBe('codeman');
|
|
expect(fm.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
expect(fm.description, 'description').toBeTruthy();
|
|
expect(fm.description!.length).toBeLessThanOrEqual(1024);
|
|
// It refuses outside Codeman, and says so where every CLI reads first.
|
|
expect(fm.description).toContain('CODEMAN_MUX=1');
|
|
});
|
|
|
|
it('is the only packaged skill (one skill for every CLI)', async () => {
|
|
expect(await readdir(join(process.cwd(), 'skills'))).toEqual(['codeman']);
|
|
});
|
|
});
|
|
|
|
describe('applyAgentSkill with a directory', () => {
|
|
it('installs into <case>/.agents/skills/codeman, marker appended, and nothing under .claude', async () => {
|
|
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('installed');
|
|
const skillDir = join(casePath, '.agents', 'skills', 'codeman');
|
|
const skillMd = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
|
|
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
|
expect(skillMd).toContain(MARKER_PREFIX);
|
|
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
|
expect(existsSync(join(casePath, '.claude'))).toBe(false);
|
|
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
|
|
});
|
|
|
|
it('removes it again and prunes the emptied skills dir, never the CLI dir above it', async () => {
|
|
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
|
expect(await applyAgentSkill(casePath, false, AGENTS_DIR)).toBe('removed');
|
|
expect(existsSync(join(casePath, '.agents', 'skills'))).toBe(false);
|
|
expect(existsSync(join(casePath, '.agents'))).toBe(true);
|
|
});
|
|
|
|
it('two CLIs sharing one workspace each get an identical copy where they look', async () => {
|
|
await applyAgentSkill(casePath, true, CLAUDE_AGENT_SKILL_DIR);
|
|
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
|
const claudeCopy = await readFile(join(casePath, '.claude', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
|
|
const agentsCopy = await readFile(join(casePath, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
|
|
expect(agentsCopy).toBe(claudeCopy);
|
|
});
|
|
|
|
it("the default directory is still Claude Code's", async () => {
|
|
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
|
expect(existsSync(agentSkillDirFor(casePath, CLAUDE_AGENT_SKILL_DIR))).toBe(true);
|
|
});
|
|
|
|
it('writes nothing outside the target directory', async () => {
|
|
await mkdir(join(casePath, 'keep'), { recursive: true });
|
|
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
|
expect((await readdir(casePath)).sort()).toEqual(['.agents', 'keep']);
|
|
});
|
|
});
|
|
|
|
describe('agentSkillDir', () => {
|
|
it('is null when the CLI takes no skill', () => {
|
|
expect(agentSkillDir({ agentSkillInjection: false, agentSkillDir: AGENTS_DIR })).toBeNull();
|
|
expect(agentSkillDir(undefined)).toBeNull();
|
|
});
|
|
|
|
it("falls back to Claude Code's directory when only the old boolean is set (pre-field clis.json)", () => {
|
|
expect(agentSkillDir({ agentSkillInjection: true })).toBe(CLAUDE_AGENT_SKILL_DIR);
|
|
});
|
|
|
|
it('uses the declared directory otherwise', () => {
|
|
expect(agentSkillDir({ agentSkillInjection: true, agentSkillDir: AGENTS_DIR })).toBe(AGENTS_DIR);
|
|
});
|
|
});
|
|
|
|
describe('the stock registry', () => {
|
|
it('pins the per-CLI table (measured 2026-10-11, see AGENTS_DIR_SKILL in stock.ts)', () => {
|
|
const table = Object.fromEntries(
|
|
['claude', 'codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'shell'].map((id) => [
|
|
id,
|
|
agentSkillDir(getCli(id)?.capabilities),
|
|
])
|
|
);
|
|
expect(table).toEqual({
|
|
claude: '.claude/skills',
|
|
codex: AGENTS_DIR,
|
|
gemini: AGENTS_DIR,
|
|
opencode: AGENTS_DIR,
|
|
antigravity: AGENTS_DIR,
|
|
// A project-level skill makes pi ask "Trust project folder?" at startup.
|
|
pi: null,
|
|
grok: AGENTS_DIR,
|
|
deepseek: AGENTS_DIR,
|
|
omp: AGENTS_DIR,
|
|
shell: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('capabilities.agentSkillDir validation (clis.json is user-editable)', () => {
|
|
const withDir = (dir: unknown) => {
|
|
const claude = getCli('claude')!;
|
|
return CliEntrySchema.safeParse({ ...claude, capabilities: { ...claude.capabilities, agentSkillDir: dir } });
|
|
};
|
|
|
|
it('accepts a listed directory', () => {
|
|
expect(withDir('.agents/skills').success).toBe(true);
|
|
});
|
|
|
|
it('refuses a free path, traversal included', () => {
|
|
expect(withDir('../../.ssh').success).toBe(false);
|
|
expect(withDir('/etc').success).toBe(false);
|
|
expect(withDir('.pi/skills').success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('codeman skill install|uninstall follow the registry', () => {
|
|
it('--mode claude (the default) installs into .claude/skills, user-level or per case', () => {
|
|
expect(resolveSkillInstallDir({})).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
|
|
expect(resolveSkillInstallDir({ mode: 'claude', case: 'x' })).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
|
|
});
|
|
|
|
it('--mode codex installs per case into .agents/skills, and points a user-level install at npx skills', () => {
|
|
expect(resolveSkillInstallDir({ mode: 'codex', case: 'x' })).toEqual({ skillDir: AGENTS_DIR });
|
|
expect(resolveSkillInstallDir({ mode: 'codex' }).error).toContain('npx skills add Ark0N/Codeman --skill codeman -g');
|
|
});
|
|
|
|
it('refuses a mode without a skill and an unknown mode', () => {
|
|
expect(resolveSkillInstallDir({ mode: 'shell', case: 'x' }).error).toContain('takes no Codeman agent skill');
|
|
expect(resolveSkillInstallDir({ mode: 'nope', case: 'x' }).error).toContain('Unknown mode');
|
|
});
|
|
|
|
it('uninstall --case covers every distinct directory the registry can write, once each', () => {
|
|
const dirs = registryAgentSkillDirs();
|
|
expect(new Set(dirs).size).toBe(dirs.length);
|
|
expect(dirs).toContain(CLAUDE_AGENT_SKILL_DIR);
|
|
for (const cli of listClis()) {
|
|
const dir = agentSkillDir(cli.capabilities);
|
|
if (dir) expect(dirs, cli.id).toContain(dir);
|
|
}
|
|
});
|
|
});
|