import { describe, it, expect, beforeEach } from 'vitest'; import { execFileSync, spawn } from 'child_process'; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'fs'; import { removeTempDir } from './temp-dir.js'; import { createServer } from 'net'; import { homedir, tmpdir } from 'os'; import { join } from 'path'; import { pathToFileURL } from 'url'; import { configDir, settingsLine } from '../server/settings.js'; function run(args) { try { return { code: 0, out: execFileSync('node', ['bin/agent-007.js', ...args], { encoding: 'utf8', stdio: 'pipe' }) }; } catch (err) { return { code: err.status, out: err.stdout + err.stderr }; } } const VERSION = readFileSync('VERSION', 'utf8').trim(); describe('agent-007 CLI', () => { it('--help prints usage and exits 0', () => { const { code, out } = run(['--help']); expect(code).toBe(0); expect(out).toMatch(/Usage: agent007/); }); it('--version prints VERSION', () => { expect(run(['--version'])).toEqual({ code: 0, out: `${VERSION}\n` }); }); it('rejects a bad port, an unknown flag and an unknown command without starting', () => { for (const args of [['--port', 'abc'], ['--port', '70000'], ['--nope'], ['serve']]) { expect(run(args).code).toBe(2); } }); }); // CONTRIBUTING.md "Versions": package.json carries VERSION's first three // parts (npm needs semver). .github/workflows/release.yml checks the same. describe('package.json version', () => { it('is VERSION without its MICRO part', () => { const pkg = JSON.parse(readFileSync('package.json', 'utf8')); expect(pkg.version).toBe(VERSION.split('.').slice(0, 3).join('.')); }); }); // Settings files (server/settings.js). Every test gets its own HOME, config // dir and working directory, never the real ~/.agent-007. describe('settings', () => { const ROOT = process.cwd(); let home, cfg, cwd; beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'a007-home-')); cfg = join(home, '.agent-007'); cwd = mkdtempSync(join(tmpdir(), 'a007-cwd-')); mkdirSync(cfg); }); const env = (extra = {}) => ({ ...process.env, HOME: home, USERPROFILE: home, AGENT007_CONFIG_DIR: cfg, ...extra }); const cli = (args, extra) => { try { return { code: 0, out: execFileSync('node', [join(ROOT, 'bin/agent-007.js'), ...args], { cwd, env: env(extra), encoding: 'utf8', stdio: 'pipe' }) }; } catch (err) { return { code: err.status, out: err.stdout + err.stderr }; } }; it('--help lists init and every setting .env.example documents', () => { const { out } = run(['--help']); expect(out).toMatch(/agent007 init/); expect(out).toMatch(/agent007 doctor/); for (const sub of ['install', 'uninstall', 'status', 'restart', 'logs', 'update']) expect(out).toMatch(new RegExp(`agent007 .*${sub}`)); const keys = [...readFileSync('.env.example', 'utf8').matchAll(/^#?\s*([A-Z][A-Z0-9_]*)=/gm)].map(m => m[1]); expect(keys.length).toBeGreaterThan(5); for (const key of keys) expect(out).toContain(key); }); // Value: protects=`agent007 skills sync`: a bad subcommand exits 2, --dry-run moves nothing and records nothing, a real run moves, links and records its result for Settings; fails_when=--dry-run is not passed through, the state file is not written, or a typo runs a sync; why_new=nothing ran the CLI branch; seam=none it('skills sync: refuses an unknown subcommand, --dry-run changes nothing, a real run moves and records', () => { const extra = { CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEX_HOME: join(home, '.codex') }; // A Claude skill: a Codex one would wait while any Codex runs on this machine. const skillDir = join(home, '.claude', 'skills', 'seo-audit'); mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: seo-audit\n---\nbody\n'); try { expect(cli(['skills'], extra).code).toBe(2); expect(cli(['skills', 'snyc'], extra).code).toBe(2); expect(cli(['skills', 'sync', 'seo-audit'], extra).code).toBe(2); expect(existsSync(join(home, '.agents'))).toBe(false); const dry = cli(['skills', 'sync', '--dry-run'], extra); expect(dry.code).toBe(0); expect(dry.out).toMatch(/Dry run, nothing changed\. Would move 1 skill into the store/); expect(existsSync(join(skillDir, 'SKILL.md'))).toBe(true); expect(existsSync(join(home, '.agents'))).toBe(false); expect(existsSync(join(cfg, 'skill-store.json'))).toBe(false); const real = cli(['skills', 'sync'], extra); expect(real.code).toBe(0); expect(real.out).toMatch(/Moved 1 skill into the store/); expect(existsSync(join(home, '.agents', 'skills', 'seo-audit', 'SKILL.md'))).toBe(true); expect(lstatSync(skillDir).isSymbolicLink()).toBe(true); // Recorded for Settings, without turning the automatic sync on. expect(JSON.parse(readFileSync(join(cfg, 'skill-store.json'), 'utf8'))).toMatchObject({ enabled: false, last: { moved: ['seo-audit'] } }); } finally { removeTempDir(home); removeTempDir(cwd); } }); it('init writes .env.example, all commented out, and never overwrites', () => { const file = join(cfg, '.env'); const first = cli(['init']); expect(first.code).toBe(0); expect(first.out).toContain(file); const template = readFileSync('.env.example', 'utf8'); expect(readFileSync(file, 'utf8')).toBe(template); expect(template.split(/\r?\n/).filter(l => l.trim() && !l.startsWith('#'))).toEqual([]); writeFileSync(file, 'BILLION=0\n'); const second = cli(['init']); expect(second.code).toBe(0); expect(second.out).toMatch(/already exists/); expect(second.out).toContain(file); expect(readFileSync(file, 'utf8')).toBe('BILLION=0\n'); }); it('environment > ./.env > config-dir .env', () => { writeFileSync(join(cwd, '.env'), 'A=cwd\nB=cwd\n'); writeFileSync(join(cfg, '.env'), 'A=cfg\nB=cfg\nC=cfg\n'); const script = `import { loadSettings } from ${JSON.stringify(pathToFileURL(join(ROOT, 'server/settings.js')).href)}; const files = loadSettings(); const { A, B, C } = process.env; console.log(JSON.stringify({ files, A, B, C }));`; const out = execFileSync('node', ['--input-type=module', '-e', script], { cwd, env: env({ A: 'env' }), encoding: 'utf8' }); const { files, ...vars } = JSON.parse(out); expect(vars).toEqual({ A: 'env', B: 'cwd', C: 'cfg' }); // cwd as the child saw it (a macOS /private/var or a Windows 8.3 path). expect(files).toEqual([expect.stringMatching(/[\\/]a007-cwd-[^\\/]+[\\/]\.env$/), join(cfg, '.env')]); }); it('the startup line names the loaded files, or says how to create one', () => { expect(settingsLine([], 'npx @bill10/agent-007 init')) .toBe(`Settings: defaults (run \`npx @bill10/agent-007 init\` to create ${join(configDir(), '.env').replace(homedir(), '~')})`); expect(settingsLine([join(homedir(), '.agent-007', '.env'), '/w/.env'], 'x')) .toBe(`Settings: ${join('~', '.agent-007', '.env')}, /w/.env`); }); it('--port beats the environment and both files; the server says which files it loaded', async () => { const port = await new Promise((res) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); }); writeFileSync(join(cwd, '.env'), 'PORT=1\n'); writeFileSync(join(cfg, '.env'), 'PORT=2\n'); const child = spawn('node', [join(ROOT, 'bin/agent-007.js'), '--port', String(port)], { cwd, env: env({ PORT: '3', BILLION: '0' }) }); let out = ''; try { await new Promise((res, rej) => { const timer = setTimeout(() => rej(new Error(`no startup line:\n${out}`)), 20000); const onData = (d) => { out += d; if (out.includes('is running at')) { clearTimeout(timer); res(); } }; child.stdout.on('data', onData); child.stderr.on('data', onData); child.on('exit', () => { clearTimeout(timer); rej(new Error(`exited:\n${out}`)); }); }); } finally { child.kill(); } expect(out).toMatch(new RegExp(`Settings: \\S*a007-cwd-\\S+\\.env, ~[\\\\/]\\.agent-007[\\\\/]\\.env\\n`)); expect(out).toContain(`http://127.0.0.1:${port}`); }, 30000); // Value: protects=a start prints the fast check's ✗ lines and the doctor command; fails_when=bin stops awaiting or printing them; why_new=only formatStartup was unit-tested; seam=none it('a start prints the quick check\'s problems, ending with the doctor command', async () => { const held = createServer(); const port = await new Promise((res) => held.listen(0, '127.0.0.1', () => res(held.address().port))); const bin = mkdtempSync(join(tmpdir(), 'a007-bin-')); const child = spawn(process.execPath, [join(ROOT, 'bin/agent-007.js'), '--port', String(port)], { cwd, env: env({ PATH: bin, BILLION: '0' }) }); let out = ''; try { await new Promise((res, rej) => { const timer = setTimeout(() => rej(new Error(`no doctor block:\n${out}`)), 20000); const onData = (d) => { out += d; if (out.includes('for details.')) { clearTimeout(timer); res(); } }; child.stdout.on('data', onData); child.stderr.on('data', onData); child.on('exit', () => { clearTimeout(timer); out.includes('for details.') ? res() : rej(new Error(`exited:\n${out}`)); }); }); } finally { child.kill(); held.close(); } expect(out).toContain(` ✗ port ${port} is in use by another program\n Stop it, or start Agent 007 with --port ${port + 1}`); expect(out).toMatch(new RegExp(` {2}Run \`[^\`]*doctor --port ${port}\` for details\\.`)); expect(out).not.toContain('✓'); }, 30000); // A PATH holding only a fake git (never run: there are no repos), so no // real CLI and no network: npm, claude, codex and gh are all missing. it('doctor exits 1 on a ✗ with its fix, 0 when nothing is ✗', async () => { const port = await new Promise((res) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); }); const bin = mkdtempSync(join(tmpdir(), 'a007-bin-')); writeFileSync(join(bin, process.platform === 'win32' ? 'git.cmd' : 'git'), '#!/bin/sh\nexit 0\n'); if (process.platform !== 'win32') chmodSync(join(bin, 'git'), 0o755); const doctor = (extra) => { try { return { code: 0, out: execFileSync(process.execPath, [join(ROOT, 'bin/agent-007.js'), 'doctor', '--port', String(port)], { cwd, env: env({ PATH: bin, CLAUDE_CONFIG_DIR: join(home, '.claude'), ...extra }), encoding: 'utf8', stdio: 'pipe' }) }; } catch (err) { return { code: err.status, out: err.stdout + err.stderr }; } }; const broken = doctor({}); expect(broken.code).toBe(1); expect(broken.out).toMatch(/✗ claude is not installed; Billion runs on it\n {4}fix: /); const fine = doctor({ BILLION: '0' }); expect(fine.out).not.toContain('✗'); expect(fine.out).toContain('– claude not installed (nothing uses it)'); expect(fine.code).toBe(0); // Piped: headings and a summary, no ANSI. FORCE_COLOR paints it. expect(fine.out).toMatch(/^System\n/); expect(fine.out).toMatch(/\n\nAll good, \d+ notes?\n$/); expect(fine.out).not.toContain('\x1b['); expect(broken.out).toMatch(/\n\d+ problems?, \d+ notes?\n$/); expect(doctor({ BILLION: '0', FORCE_COLOR: '1' }).out).toContain('\x1b[1mSystem\x1b[0m'); expect(doctor({ BILLION: '0', FORCE_COLOR: '1', NO_COLOR: '1' }).out).toContain('\x1b['); }, 30000); });