Files
Codeman/test/cli-skill-target.test.ts
T
Codeman maintainer 341c7ccc59 test: cover the skill CLI, the injection call site, and endpoints.md drift
Three gaps found while auditing the agent skill.

`codeman skill install` / `uninstall` had no tests at all, including the linked-case
resolution that shipped in 1.14.2 with nothing guarding it. Covered now: global target
resolution, `--case` resolving through linked-cases.json, `--case` falling back to the
cases dir for an unlinked name, a missing or malformed registry degrading to the
fallback instead of throwing, and a nonexistent case being rejected. `resolveSkillTarget`
called `process.exit(1)` for a missing case, which would have killed the test runner, so
the pure resolution is split out and exported; CLI behavior is unchanged.

The `POST /api/sessions` injection call site was never exercised, because the shared
route mock hardcoded the gate off. The mock's gate is overridable per test now (default
still off, since other tests rely on that), and there is coverage that the path injects
when the setting is on, does not when it is off, and is claude-mode gated.

Nothing guarded skills/codeman/reference/endpoints.md against drifting from the routes
it documents, which is how it drifted in the first place. A static guard parses the
endpoints out of the markdown and asserts each is really registered, tolerating the
/api/v1 alias and path params.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:09:51 +02:00

145 lines
6.0 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 { program, resolveCliCasePath, resolveSkillTargetPath } from '../src/cli.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 });
rmSync(CASES_DIR, { recursive: true, force: true });
rmSync(LINKED_ROOT, { recursive: true, force: true });
});
afterEach(() => {
rmSync(LINKED_CASES_FILE, { force: true });
rmSync(CASES_DIR, { recursive: true, force: true });
rmSync(LINKED_ROOT, { recursive: true, force: true });
});
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', () => {
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']));
for (const name of ['install', 'uninstall']) {
const flags = skill!.commands
.find((cmd) => cmd.name() === name)!
.options.map((opt) => opt.long)
.sort();
expect(flags).toEqual(['--case', '--global']);
}
});
});