Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -79,7 +79,7 @@ function showWelcome() {
${pc.yellow('--force')} Overwrite existing files ${pc.yellow('--model')} ${pc.dim('<m>')} Choose backend model
${pc.yellow('--mode')} ${pc.dim('<mode>')} all, chunked, base-only ${pc.yellow('--timeout')} ${pc.dim('<s>')} Seconds per call
${pc.yellow('--strategy')} ${pc.dim('<s>')} improve, rewrite, skip ${pc.yellow('--json')} JSON output (scan)
${pc.yellow('--target')} ${pc.dim('<t>')} claude, codex, all ${pc.yellow('--backend')} ${pc.dim('<b>')} Generate with claude or codex
${pc.yellow('--target')} ${pc.dim('<t>')} claude, codex, opencode, all ${pc.yellow('--backend')} ${pc.dim('<b>')} claude, codex, or opencode
Comment thread
coderabbitai[bot] marked this conversation as resolved.
${pc.yellow('--no-hooks')} Skip Claude hook installation ${pc.yellow('--hooks-only')} Update Claude hooks only
${pc.yellow('--no-graph')} Skip import graph analysis

Expand All @@ -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 <command> --help')} ${pc.dim('for detailed usage.')}

Expand Down Expand Up @@ -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 <target>', 'Output target: claude, codex, all')
.option('--backend <backend>', 'Generation backend: claude, codex (default: matches target)')
.option('--target <target>', 'Output target: claude, codex, opencode, all')
.option('--backend <backend>', 'Generation backend: claude, codex, opencode (default: matches target)')
.action(docInitCommand);

doc
Expand All @@ -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 <backend>', 'Interpretation backend: claude, codex (default: whichever is available)')
.option('--backend <backend>', 'Interpretation backend: claude, codex, opencode (default: whichever is available)')
.option('--model <model>', 'Model to use for impact interpretation')
.option('--timeout <seconds>', 'Backend timeout in seconds', parseTimeout, 300)
.option('--verbose', 'Show backend reads/activity in real time')
Expand Down
4 changes: 4 additions & 0 deletions docs/specs/go-policy.yaml
Original file line number Diff line number Diff line change
@@ -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"
87 changes: 59 additions & 28 deletions src/commands/doc-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
);
}

Expand All @@ -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];
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
backendResult = resolveBackend({ backendFlag: backendChoice, available });
} else {
// Only one available — use it
Expand All @@ -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];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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 <one>\` separately for each.`
);
}
}
const primaryTarget = targets[0];
_primaryTarget = primaryTarget;
_allowedPaths = null; // canonical generation uses defaults
Expand Down
36 changes: 23 additions & 13 deletions src/lib/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};

// ---------------------------------------------------------------------------
Expand All @@ -51,13 +58,14 @@ function isCommandAvailable(command, args) {

/**
* Detect which backends are installed.
* @returns {{ claude: boolean, codex: boolean }}
* @returns {Record<string, boolean>}
*/
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;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 {
Expand All @@ -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
);
}
Loading
Loading