diff --git a/bin/cli.js b/bin/cli.js index 19a7a3c..859866a 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -53,7 +53,7 @@ function showWelcome() { ${pc.green('aspens doc init --recommended')} Install the full recommended setup ${pc.green('aspens doc init --dry-run')} Preview without writing ${pc.green('aspens doc init --mode chunked')} One domain at a time (large repos) - ${pc.green('aspens doc init --target all')} Generate Claude + Codex docs together + ${pc.green('aspens doc init --target all')} Generate docs for every configured target ${pc.green('aspens doc init --model haiku')} Use a specific backend model ${pc.green('aspens doc init --verbose')} See backend activity in real time ${pc.green('aspens doc sync')} Update generated docs from recent commits @@ -79,7 +79,7 @@ function showWelcome() { ${pc.yellow('--force')} Overwrite existing files ${pc.yellow('--model')} ${pc.dim('')} Choose backend model ${pc.yellow('--mode')} ${pc.dim('')} all, chunked, base-only ${pc.yellow('--timeout')} ${pc.dim('')} Seconds per call ${pc.yellow('--strategy')} ${pc.dim('')} improve, rewrite, skip ${pc.yellow('--json')} JSON output (scan) - ${pc.yellow('--target')} ${pc.dim('')} claude, codex, all ${pc.yellow('--backend')} ${pc.dim('')} Generate with claude or codex + ${pc.yellow('--target')} ${pc.dim('')} claude, codex, opencode, all ${pc.yellow('--backend')} ${pc.dim('')} claude, codex, or opencode ${pc.yellow('--no-hooks')} Skip Claude hook installation ${pc.yellow('--hooks-only')} Update Claude hooks only ${pc.yellow('--no-graph')} Skip import graph analysis @@ -88,9 +88,11 @@ function showWelcome() { ${pc.dim('$')} aspens doc impact ${pc.dim('2. Verify health + discover optional upgrades')} ${pc.bold('Target Notes')} - ${pc.dim('Claude:')} ${pc.cyan('CLAUDE.md + .claude/skills + hooks')} - ${pc.dim('Codex: ')} ${pc.cyan('AGENTS.md + .agents/skills + directory AGENTS.md')} - ${pc.dim('Hooks are Claude-only today. Codex is instruction-file driven.')} + ${pc.dim('Claude: ')} ${pc.cyan('CLAUDE.md + .claude/skills + hooks')} + ${pc.dim('Codex: ')} ${pc.cyan('AGENTS.md + .agents/skills + directory AGENTS.md')} + ${pc.dim('OpenCode:')} ${pc.cyan('AGENTS.md + .claude/skills')} + ${pc.dim('Hooks are Claude-only today. Codex and OpenCode are instruction-file driven.')} + ${pc.dim('Codex and OpenCode both write AGENTS.md — combine each with Claude, not with each other.')} ${pc.dim('Run')} ${pc.cyan('aspens --help')} ${pc.dim('for detailed usage.')} @@ -163,8 +165,8 @@ doc .option('--no-hooks', 'Skip Claude hook/rules/settings installation') .option('--hooks-only', 'Skip doc generation, just install/update Claude hooks') .option('--no-graph', 'Skip import graph analysis') - .option('--target ', 'Output target: claude, codex, all') - .option('--backend ', 'Generation backend: claude, codex (default: matches target)') + .option('--target ', 'Output target: claude, codex, opencode, all') + .option('--backend ', 'Generation backend: claude, codex, opencode (default: matches target)') .action(docInitCommand); doc @@ -190,7 +192,7 @@ doc .description('Show generated context freshness and coverage') .argument('[path]', 'Path to repo', '.') .option('--apply', 'Apply recommended fixes after confirmation') - .option('--backend ', 'Interpretation backend: claude, codex (default: whichever is available)') + .option('--backend ', 'Interpretation backend: claude, codex, opencode (default: whichever is available)') .option('--model ', 'Model to use for impact interpretation') .option('--timeout ', 'Backend timeout in seconds', parseTimeout, 300) .option('--verbose', 'Show backend reads/activity in real time') diff --git a/docs/specs/go-policy.yaml b/docs/specs/go-policy.yaml new file mode 100644 index 0000000..5dd4a4c --- /dev/null +++ b/docs/specs/go-policy.yaml @@ -0,0 +1,4 @@ +# Worktrail /go policy for aspens. +# pre_pr_cmd mirrors the CI test job (.github/workflows/ci.yml) so the +# mandatory pre-PR gate reflects the same signal CI will check. +pre_pr_cmd: "npm test" diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index edb853a..d043c49 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -229,11 +229,13 @@ export async function docInitCommand(path, options) { // --- Step 0: Detect available backends --- const available = detectAvailableBackends(); - if (!available.claude && !available.codex) { + if (!available.claude && !available.codex && !available.opencode) { + const installLines = []; + for (const backend of Object.values(BACKENDS)) { + installLines.push(` Install ${backend.label}: ${backend.installUrl}`); + } throw new CliError( - 'aspens requires either Claude CLI or Codex CLI.\n' + - ' Install Claude CLI: https://docs.anthropic.com/claude-code\n' + - ' Install Codex CLI: https://github.com/openai/codex' + 'aspens requires Claude CLI, Codex CLI, or OpenCode CLI.\n' + installLines.join('\n') ); } @@ -257,15 +259,22 @@ export async function docInitCommand(path, options) { backendResult = resolveBackend({ backendFlag: recommendedBackendId, available }); } else if (recommended && recommendedTargetIds?.length === 1) { backendResult = resolveBackend({ targetId: recommendedTargetIds[0], available }); - } else if (available.claude && available.codex && !recommended) { - const backendChoice = await p.select({ - message: 'Which AI should generate the docs?', - options: [ - { value: 'claude', label: 'Claude CLI', hint: 'uses your Anthropic subscription' }, - { value: 'codex', label: 'Codex CLI', hint: 'uses your OpenAI subscription' }, - ], - }); - if (p.isCancel(backendChoice)) { p.cancel('Aborted'); return; } + } else if (!recommended) { + const availableBackends = Object.keys(available).filter(id => available[id]); + let backendChoice; + if (availableBackends.length > 1) { + backendChoice = await p.select({ + message: 'Which AI should generate the docs?', + options: availableBackends.map(id => ({ + value: id, + label: BACKENDS[id].label, + hint: `uses ${BACKENDS[id].label}`, + })), + }); + if (p.isCancel(backendChoice)) { p.cancel('Aborted'); return; } + } else { + backendChoice = availableBackends[0]; + } backendResult = resolveBackend({ backendFlag: backendChoice, available }); } else { // Only one available — use it @@ -278,28 +287,50 @@ export async function docInitCommand(path, options) { // --- Step 2: Target selection (what to generate FOR) --- let targetIds; if (options.target) { - targetIds = options.target === 'all' ? ['claude', 'codex'] : [options.target]; + targetIds = options.target === 'all' ? Object.keys(TARGETS) : [options.target]; } else if (recommendedTargetIds?.length) { targetIds = recommendedTargetIds; } else if (recommended) { targetIds = [backend.id]; - } else if (available.claude && available.codex) { - const selected = await p.multiselect({ - message: 'Generate docs for which coding agents?', - options: [ - { value: 'claude', label: 'Claude Code', hint: 'CLAUDE.md + .claude/skills/ + hooks' }, - { value: 'codex', label: 'Codex CLI', hint: 'AGENTS.md + .agents/skills/' }, - ], - initialValues: [backend.id], // pre-select matching target - required: true, - }); - if (p.isCancel(selected)) { p.cancel('Aborted'); return; } - targetIds = selected; } else { - // Only one CLI — generate for matching target - targetIds = [available.claude ? 'claude' : 'codex']; + const availableTargetIds = Object.keys(TARGETS).filter(id => available[id]); + if (availableTargetIds.length > 1) { + const selected = await p.multiselect({ + message: 'Generate docs for which coding agents?', + options: availableTargetIds.map(id => ({ + value: id, + label: TARGETS[id].label, + })), + initialValues: [backend.id], // pre-select matching target + required: true, + }); + if (p.isCancel(selected)) { p.cancel('Aborted'); return; } + targetIds = selected; + } else { + // Only one CLI — generate for matching target + targetIds = [backend.id]; + } } const targets = targetIds.map(id => resolveTarget(id)); + + // codex and opencode both write their root instructions file to AGENTS.md + // but via different transforms (codex: directory-scoped restructure, + // opencode: centralized copy of CLAUDE.md) — combining them silently + // clobbers whichever one is written last. Reject the combination instead + // of guessing at ownership. + const instructionsFileOwners = new Map(); + for (const target of targets) { + const owners = instructionsFileOwners.get(target.instructionsFile) || []; + owners.push(target); + instructionsFileOwners.set(target.instructionsFile, owners); + } + for (const [file, owners] of instructionsFileOwners) { + if (owners.length > 1) { + throw new CliError( + `Cannot generate for ${owners.map(t => t.label).join(' + ')} together — both write ${file} with different content. Run \`aspens doc init --target \` separately for each.` + ); + } + } const primaryTarget = targets[0]; _primaryTarget = primaryTarget; _allowedPaths = null; // canonical generation uses defaults diff --git a/src/lib/backend.js b/src/lib/backend.js index eae86bc..7484e1e 100644 --- a/src/lib/backend.js +++ b/src/lib/backend.js @@ -28,6 +28,13 @@ export const BACKENDS = { detectArgs: '--version', installUrl: 'https://github.com/openai/codex', }, + opencode: { + id: 'opencode', + label: 'OpenCode CLI', + command: 'opencode', + detectArgs: '--version', + installUrl: 'https://opencode.ai', + }, }; // --------------------------------------------------------------------------- @@ -51,13 +58,14 @@ function isCommandAvailable(command, args) { /** * Detect which backends are installed. - * @returns {{ claude: boolean, codex: boolean }} + * @returns {Record} */ export function detectAvailableBackends() { - return { - claude: isCommandAvailable(BACKENDS.claude.command, BACKENDS.claude.detectArgs), - codex: isCommandAvailable(BACKENDS.codex.command, BACKENDS.codex.detectArgs), - }; + const result = {}; + for (const [id, backend] of Object.entries(BACKENDS)) { + result[id] = isCommandAvailable(backend.command, backend.detectArgs); + } + return result; } // --------------------------------------------------------------------------- @@ -100,9 +108,9 @@ export function resolveBackend({ backendFlag, targetId, available }) { return { backend: matchingBackend, warning: null }; } - // Matching backend not available — fall back to the other - const fallbackId = targetId === 'claude' ? 'codex' : 'claude'; - if (available[fallbackId]) { + // Matching backend not available — fall back to the best available + const fallbackId = Object.keys(BACKENDS).find(id => id !== targetId && available[id]); + if (fallbackId) { const fallback = BACKENDS[fallbackId]; const missing = BACKENDS[targetId]; return { @@ -112,14 +120,16 @@ export function resolveBackend({ backendFlag, targetId, available }) { } } - // No target preference or target is 'all' — use whatever is available + // No target preference or target is 'all' — use whatever is available (prefer claude, then codex, then opencode) if (available.claude) return { backend: BACKENDS.claude, warning: null }; if (available.codex) return { backend: BACKENDS.codex, warning: null }; + if (available.opencode) return { backend: BACKENDS.opencode, warning: null }; - // Neither available + // None available + const installLines = Object.values(BACKENDS) + .map(b => ` Install ${b.label}: ${b.installUrl}`) + .join('\n'); throw new Error( - 'aspens requires either Claude CLI or Codex CLI.\n' + - ` Install Claude CLI: ${BACKENDS.claude.installUrl}\n` + - ` Install Codex CLI: ${BACKENDS.codex.installUrl}` + 'aspens requires Claude CLI, Codex CLI, or OpenCode CLI.\n' + installLines ); } diff --git a/src/lib/runner.js b/src/lib/runner.js index 1d5dbbb..98b45c6 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -1,5 +1,5 @@ import { execSync, spawn } from 'child_process'; -import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync, rmSync } from 'fs'; import { join, dirname, normalize, resolve, relative, sep } from 'path'; import { fileURLToPath } from 'url'; import { tmpdir } from 'os'; @@ -158,6 +158,15 @@ export function runLLM(prompt, options = {}, backendId = 'claude') { cwd: options.cwd, }); } + if (backendId === 'opencode') { + return runOpenCode(prompt, { + timeout: options.timeout, + verbose: options.verbose, + onActivity: options.onActivity, + model: options.model, + cwd: options.cwd, + }); + } return runClaude(prompt, options); } @@ -269,6 +278,143 @@ export function runCodex(prompt, options = {}) { }); } +/** + * Execute a prompt via OpenCode CLI (opencode run). + * Uses --format json for structured event output. + * Returns { text, usage } matching runClaude's interface. + */ +export function runOpenCode(prompt, options = {}) { + const { timeout = 300000, verbose = false, onActivity = null, model = null, cwd = null } = options; + + // Write prompt to temp file for long prompts + const promptFile = join(tmpdir(), `aspens-opencode-prompt-${Date.now()}.md`); + writeFileSync(promptFile, prompt, 'utf8'); + + // model is spawned through a shell on Windows (see below) — reject + // anything outside a safe model-id charset before it can reach argv. + if (model && !/^[A-Za-z0-9_.\-/:]+$/.test(model)) { + throw new Error(`Invalid --model value for OpenCode: ${model}`); + } + + // The message must precede `-f` — `-f`/`--file` is a yargs array-type + // option, so a bare positional placed after it gets swallowed into the + // file array instead of being treated as the message. + const args = [ + 'run', + 'Generate repo documentation based on the attached prompt file', + '--format', 'json', + '-f', promptFile, + ]; + if (model) args.push('--model', model); + // Resolve to an absolute path: runOpenCode is exported and may receive a + // relative cwd, and on Windows this value is spawned through a shell. + if (cwd) args.push('--dir', resolve(cwd)); + + const cleanupPromptFile = () => { + try { rmSync(promptFile, { force: true }); } catch { /* ignore */ } + }; + + return new Promise((resolve, reject) => { + // Unlike runClaude/runCodex, the prompt is passed via -f (a file), not + // stdin — an open, never-written, never-closed stdin pipe makes + // `opencode run` hang indefinitely before it even starts (confirmed: + // `sleep 999 | opencode run ...` never gets past its init phase). + // 'ignore' means no stdin pipe exists at all. + const child = spawn('opencode', args, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + }); + + const chunks = []; + const errChunks = []; + // Keyed by part.id: a "text" event carries the full accumulated text + // for that part (not a delta), and later events for the same id replace + // earlier ones. Distinct part ids are separate text blocks, joined in + // first-seen order. + const textPartsById = new Map(); + let usage = { output_tokens: 0, tool_uses: 0, tool_result_chars: 0 }; + let lineBuffer = ''; + + function processOpenCodeLine(line) { + if (!line.trim()) return; + try { + const event = JSON.parse(line); + const part = event.part; + if (event.type === 'text' && part?.type === 'text' && typeof part.text === 'string') { + textPartsById.set(part.id, part.text); + } + if ((event.type === 'step_finish' || event.type === 'step-finish') && part?.tokens) { + usage.output_tokens = part.tokens.output || 0; + } + if (event.type === 'tool' || part?.type === 'tool') { + usage.tool_uses++; + } + if (verbose && onActivity) { + if (event.type === 'step_start') { + onActivity('OpenCode thinking...'); + } + } + } catch { /* not JSON */ } + } + + child.stdout.on('data', (data) => { + chunks.push(data); + // Parse JSON events for text content — buffer across chunks since a + // record can span multiple `data` callbacks. + lineBuffer += data.toString('utf8'); + const lines = lineBuffer.split('\n'); + lineBuffer = lines.pop(); + for (const line of lines) processOpenCodeLine(line); + }); + + child.stderr.on('data', (data) => errChunks.push(data)); + + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + if (process.platform === 'win32' && child.pid) { + try { execSync(`taskkill /pid ${child.pid} /t /f`, { stdio: 'ignore' }); } catch { /* ignore */ } + } else { + child.kill('SIGTERM'); + } + }, timeout); + + child.on('close', (code, signal) => { + clearTimeout(timer); + if (lineBuffer.trim()) processOpenCodeLine(lineBuffer); + cleanupPromptFile(); + + if (timedOut || signal === 'SIGTERM' || signal === 'SIGKILL') { + reject(new Error(`OpenCode timed out after ${timeout / 1000}s. Try a smaller repo or increase --timeout.`)); + } else if (code === 0) { + const combined = [...textPartsById.values()].join('\n').trim(); + if (!combined && chunks.length > 0) { + // Fallback: try to extract from raw output + const raw = Buffer.concat(chunks).toString('utf8'); + resolve({ text: raw, usage }); + } else { + resolve({ text: combined, usage }); + } + } else if (code === 127) { + reject(new Error('OpenCode CLI not found. Install it first: https://opencode.ai')); + } else { + const stderr = Buffer.concat(errChunks).toString('utf8'); + if (stderr.includes('rate limit') || stderr.includes('429')) { + reject(new Error('OpenCode rate limit hit. Wait a moment and try again.')); + } else { + reject(new Error(`OpenCode exited with code ${code}${stderr ? ': ' + stderr.slice(0, 500) : ''}`)); + } + } + }); + + child.on('error', (err) => { + clearTimeout(timer); + cleanupPromptFile(); + reject(new Error(`OpenCode failed to start: ${err.message}. Is OpenCode CLI installed?`)); + }); + }); +} + /** * Extract final text and usage from Codex JSONL stream output. * Codex events: thread.started, item.started, item.updated, item.completed, turn.completed diff --git a/src/lib/target.js b/src/lib/target.js index 1562224..e60019e 100644 --- a/src/lib/target.js +++ b/src/lib/target.js @@ -64,6 +64,30 @@ export const TARGETS = { needsCodeMapEmbed: false, maxInstructionsBytes: 32768, }, + opencode: { + id: 'opencode', + label: 'OpenCode CLI', + placement: 'centralized', + format: 'markdown', + instructionsFile: 'AGENTS.md', + configDir: '.opencode', + skillsDir: '.claude/skills', + skillFilename: 'skill.md', + hooksDir: null, + settingsFile: null, + graphPath: null, + codeMapPath: null, + graphIndexPath: null, + agentsDir: null, + commandsDir: null, + supportsHooks: false, + supportsSettings: false, + supportsGraph: false, + supportsSkills: false, + supportsMCP: false, + needsActivationSection: true, + needsCodeMapEmbed: false, + }, }; // --------------------------------------------------------------------------- @@ -260,9 +284,21 @@ export function inferConfig(repoPath) { hasCodexSkills || (hasCodexInstructions && (hasCodexConfig || hasCodexSkills)); + // OpenCode shares codex's instructionsFile (AGENTS.md) and claude's + // skillsDir (.claude/skills), so it only counts as present when AGENTS.md + // exists WITHOUT any codex-specific artifact (.codex/ or .agents/skills) — + // otherwise an AGENTS.md + .claude/skills repo is ambiguous with a + // Claude-only repo and is left to hasClaudeArtifacts instead. + const hasOpenCodeArtifacts = + hasClaudeArtifacts && + hasCodexInstructions && + !hasCodexConfig && + !hasCodexSkills; + const targets = []; if (hasClaudeArtifacts) targets.push('claude'); if (hasCodexArtifacts) targets.push('codex'); + if (hasOpenCodeArtifacts) targets.push('opencode'); if (targets.length === 0) return null; diff --git a/tests/backend.test.js b/tests/backend.test.js index c5899fc..e9da8a4 100644 --- a/tests/backend.test.js +++ b/tests/backend.test.js @@ -81,12 +81,12 @@ describe('resolveBackend', () => { }); describe('nothing available', () => { - it('throws with install message when neither backend available', () => { + it('throws with install message when no backend available', () => { expect(() => resolveBackend({ - available: { claude: false, codex: false }, + available: { claude: false, codex: false, opencode: false }, }) - ).toThrow('aspens requires either Claude CLI or Codex CLI'); + ).toThrow('aspens requires Claude CLI, Codex CLI, or OpenCode CLI'); }); it('throws with install URLs', () => { diff --git a/tests/target.test.js b/tests/target.test.js index daa8e6c..e6aa194 100644 --- a/tests/target.test.js +++ b/tests/target.test.js @@ -50,12 +50,13 @@ describe('resolveTarget', () => { }); describe('resolveTargets', () => { - it('returns both targets for "all"', () => { + it('returns all targets for "all"', () => { const targets = resolveTargets('all'); - expect(targets).toHaveLength(2); + expect(targets).toHaveLength(3); const ids = targets.map(t => t.id); expect(ids).toContain('claude'); expect(ids).toContain('codex'); + expect(ids).toContain('opencode'); }); it('returns array with claude only for "claude"', () => { @@ -213,6 +214,29 @@ describe('config persistence', () => { expect(config.backend).toBeNull(); }); + it('infers opencode target from AGENTS.md + .claude/skills without codex artifacts', () => { + const dir = join(FIXTURES_DIR, 'config-infer-opencode'); + mkdirSync(join(dir, '.claude', 'skills'), { recursive: true }); + writeFileSync(join(dir, 'AGENTS.md'), '# docs\n', 'utf8'); + + const config = inferConfig(dir); + + expect(config).not.toBeNull(); + expect(config.targets).toEqual(['claude', 'opencode']); + }); + + it('does not infer opencode when real codex artifacts are present', () => { + const dir = join(FIXTURES_DIR, 'config-infer-codex-not-opencode'); + mkdirSync(join(dir, '.claude', 'skills'), { recursive: true }); + mkdirSync(join(dir, '.agents', 'skills'), { recursive: true }); + writeFileSync(join(dir, 'AGENTS.md'), '# docs\n', 'utf8'); + + const config = inferConfig(dir); + + expect(config.targets).toEqual(['claude', 'codex']); + expect(config.targets).not.toContain('opencode'); + }); + it('recovers missing .aspens.json and persists inferred targets', () => { const dir = join(FIXTURES_DIR, 'config-recover'); mkdirSync(join(dir, '.claude', 'skills'), { recursive: true });