mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 17:19:33 +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>
186 lines
8.1 KiB
TypeScript
186 lines
8.1 KiB
TypeScript
/**
|
|
* @fileoverview Tests for `codeman skill install|uninstall` target resolution
|
|
* (`resolveCliCasePath` / `resolveSkillTargetPath` in src/cli.ts).
|
|
*
|
|
* The linked-cases lookup shipped in 1.14.2 with no guard: before it, `--case`
|
|
* rejected every case linked in from outside `~/codeman-cases` with "Case not
|
|
* found" even though the server resolved the same name fine. These tests pin both
|
|
* halves of that resolution (registry first, cases dir as fallback) and the
|
|
* tolerance rules around a missing or malformed registry.
|
|
*
|
|
* `test/setup.ts` gives this file its own temporary HOME, so `homedir()` and
|
|
* `dataPath()` already point into a per-file fixture: no os mocking needed.
|
|
* Port: N/A (pure path resolution, no server).
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { dataPath } from '../src/config/instance.js';
|
|
import { safeRmHomeTree } from './mocks/index.js';
|
|
import { program, resolveCliCasePath, resolveSkillTargetPath } from '../src/cli.js';
|
|
import { getCasesDir } from '../src/config/cases-dir.js';
|
|
|
|
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
|
const CASES_DIR = join(homedir(), 'codeman-cases');
|
|
const LINKED_ROOT = join(homedir(), 'elsewhere');
|
|
|
|
/** Where the packaged skill lands under a case. Mirrors `applyAgentSkill()`. */
|
|
function skillDirIn(casePath: string): string {
|
|
return join(casePath, '.claude', 'skills', 'codeman');
|
|
}
|
|
|
|
function writeLinkedCases(content: string): void {
|
|
mkdirSync(dataPath(), { recursive: true });
|
|
writeFileSync(LINKED_CASES_FILE, content, 'utf-8');
|
|
}
|
|
|
|
beforeEach(() => {
|
|
rmSync(LINKED_CASES_FILE, { force: true });
|
|
safeRmHomeTree(CASES_DIR);
|
|
safeRmHomeTree(LINKED_ROOT);
|
|
});
|
|
|
|
afterEach(() => {
|
|
// LINKED_CASES_FILE is dataPath('linked-cases.json'), which test/setup.ts
|
|
// sandboxes (temp HOME, and an inherited CODEMAN_DATA_DIR is stripped), so a
|
|
// plain delete is safe here. The case trees still go through the containment
|
|
// gate as defense in depth.
|
|
rmSync(LINKED_CASES_FILE, { force: true });
|
|
safeRmHomeTree(CASES_DIR);
|
|
safeRmHomeTree(LINKED_ROOT);
|
|
});
|
|
|
|
describe('resolveSkillTargetPath (global)', () => {
|
|
it('targets the user-scope skill dir when no --case is given', () => {
|
|
expect(resolveSkillTargetPath({})).toEqual({
|
|
target: join(homedir(), '.claude', 'skills', 'codeman'),
|
|
});
|
|
});
|
|
|
|
it('never consults the linked-cases registry for the global target', () => {
|
|
// A registry entry named after nothing in particular must not divert the
|
|
// global install, which is not case-scoped at all.
|
|
writeLinkedCases(JSON.stringify({ anything: join(LINKED_ROOT, 'anything') }));
|
|
expect(resolveSkillTargetPath({}).target).toBe(join(homedir(), '.claude', 'skills', 'codeman'));
|
|
});
|
|
});
|
|
|
|
describe('resolveCliCasePath / resolveSkillTargetPath (--case)', () => {
|
|
it('resolves a LINKED case through linked-cases.json, not the cases dir', () => {
|
|
// The 1.14.2 regression: a case linked in from outside ~/codeman-cases was
|
|
// resolved to a cases-dir path that does not exist, so install refused it.
|
|
const linkedPath = join(LINKED_ROOT, 'my-repo');
|
|
mkdirSync(linkedPath, { recursive: true });
|
|
writeLinkedCases(JSON.stringify({ 'my-repo': linkedPath }));
|
|
|
|
expect(resolveCliCasePath('my-repo')).toBe(linkedPath);
|
|
expect(resolveSkillTargetPath({ case: 'my-repo' })).toEqual({ target: skillDirIn(linkedPath) });
|
|
expect(existsSync(join(CASES_DIR, 'my-repo'))).toBe(false);
|
|
});
|
|
|
|
it('falls back to the cases dir for a name the registry does not list', () => {
|
|
const casePath = join(CASES_DIR, 'plain-case');
|
|
mkdirSync(casePath, { recursive: true });
|
|
writeLinkedCases(JSON.stringify({ 'other-case': join(LINKED_ROOT, 'other-case') }));
|
|
|
|
expect(resolveCliCasePath('plain-case')).toBe(casePath);
|
|
expect(resolveSkillTargetPath({ case: 'plain-case' })).toEqual({ target: skillDirIn(casePath) });
|
|
});
|
|
|
|
it('reports the resolved path instead of exiting when the case does not exist', () => {
|
|
// process.exit(1) lives in the CLI wrapper on purpose: calling it here would
|
|
// kill the test runner.
|
|
expect(resolveSkillTargetPath({ case: 'nope' })).toEqual({ missingCase: join(CASES_DIR, 'nope') });
|
|
});
|
|
|
|
it('reports the LINKED path when the registry points at a directory that is gone', () => {
|
|
const linkedPath = join(LINKED_ROOT, 'moved-away');
|
|
writeLinkedCases(JSON.stringify({ 'moved-away': linkedPath }));
|
|
|
|
expect(resolveSkillTargetPath({ case: 'moved-away' })).toEqual({ missingCase: linkedPath });
|
|
});
|
|
});
|
|
|
|
describe('linked-cases.json tolerance', () => {
|
|
const casePath = () => join(CASES_DIR, 'tolerant');
|
|
|
|
beforeEach(() => {
|
|
mkdirSync(casePath(), { recursive: true });
|
|
});
|
|
|
|
it('degrades to the cases dir when the registry file is absent', () => {
|
|
expect(existsSync(LINKED_CASES_FILE)).toBe(false);
|
|
expect(resolveSkillTargetPath({ case: 'tolerant' })).toEqual({ target: skillDirIn(casePath()) });
|
|
});
|
|
|
|
it('degrades to the cases dir on malformed JSON rather than throwing', () => {
|
|
writeLinkedCases('{ not json at all');
|
|
expect(() => resolveCliCasePath('tolerant')).not.toThrow();
|
|
expect(resolveSkillTargetPath({ case: 'tolerant' })).toEqual({ target: skillDirIn(casePath()) });
|
|
});
|
|
|
|
it('degrades to the cases dir when the registry is valid JSON of the wrong shape', () => {
|
|
// A null / array / non-string-valued entry must read as "no linked case",
|
|
// never as a target path.
|
|
for (const body of ['null', '[]', JSON.stringify({ tolerant: 42 }), JSON.stringify({ tolerant: '' })]) {
|
|
writeLinkedCases(body);
|
|
expect(resolveCliCasePath('tolerant')).toBe(casePath());
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('skill command wiring', () => {
|
|
it('registers install and uninstall, both accepting --case and --global, install also --mode', () => {
|
|
const skill = program.commands.find((cmd) => cmd.name() === 'skill');
|
|
expect(skill).toBeDefined();
|
|
|
|
const subcommands = skill!.commands.map((cmd) => cmd.name());
|
|
expect(subcommands).toEqual(expect.arrayContaining(['install', 'uninstall']));
|
|
|
|
const flagsOf = (name: string) =>
|
|
skill!.commands
|
|
.find((cmd) => cmd.name() === name)!
|
|
.options.map((opt) => opt.long)
|
|
.sort();
|
|
// --mode picks the skill and directory that run mode reads (capabilities.agentSkill);
|
|
// uninstall needs none, since --case removes every Codeman-managed copy.
|
|
expect(flagsOf('install')).toEqual(['--case', '--global', '--mode']);
|
|
expect(flagsOf('uninstall')).toEqual(['--case', '--global']);
|
|
});
|
|
});
|
|
|
|
describe('cases dir override (CODEMAN_CASES_PATH)', () => {
|
|
// The Docker Compose deployment points Codeman at a host-absolute bind mount
|
|
// so a Docker case resolves to the same path inside the container and on the
|
|
// host daemon. The override shipped on the server's CASES_DIR only, which left
|
|
// the CLI looking in the home default: `codeman skill install --case <name>`
|
|
// then reported "Case not found" on exactly the deployment it exists for.
|
|
const saved = process.env.CODEMAN_CASES_PATH;
|
|
afterEach(() => {
|
|
if (saved === undefined) delete process.env.CODEMAN_CASES_PATH;
|
|
else process.env.CODEMAN_CASES_PATH = saved;
|
|
});
|
|
|
|
it('moves the CLI and the server together', () => {
|
|
process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases';
|
|
expect(getCasesDir()).toBe('/srv/codeman-cases');
|
|
expect(resolveCliCasePath('demo')).toBe(join('/srv/codeman-cases', 'demo'));
|
|
});
|
|
|
|
it('falls back to the home default when unset', () => {
|
|
delete process.env.CODEMAN_CASES_PATH;
|
|
expect(getCasesDir()).toBe(CASES_DIR);
|
|
expect(resolveCliCasePath('demo')).toBe(join(CASES_DIR, 'demo'));
|
|
});
|
|
|
|
it('still lets a linked case win over the override', () => {
|
|
// The registry lookup runs first, so a case linked in from outside the cases
|
|
// dir keeps resolving to its real location under Compose too.
|
|
process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases';
|
|
writeLinkedCases(JSON.stringify({ linked: join(LINKED_ROOT, 'linked') }));
|
|
expect(resolveCliCasePath('linked')).toBe(join(LINKED_ROOT, 'linked'));
|
|
});
|
|
});
|