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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
push:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
Expand Down
32 changes: 27 additions & 5 deletions bin/cli.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import { program } from 'commander';
import { program, InvalidArgumentError } from 'commander';
import pc from 'picocolors';
import { readFileSync, readdirSync } from 'fs';
import { join, dirname } from 'path';
Expand All @@ -10,6 +10,20 @@ import { docInitCommand } from '../src/commands/doc-init.js';
import { docSyncCommand } from '../src/commands/doc-sync.js';
import { addCommand } from '../src/commands/add.js';
import { customizeCommand } from '../src/commands/customize.js';
import { CliError } from '../src/lib/errors.js';

function parsePositiveInt(value, name) {
const n = parseInt(value, 10);
if (isNaN(n) || n <= 0) throw new InvalidArgumentError(`${name} must be a positive integer`);
return n;
}

function parseTimeout(value) { return parsePositiveInt(value, 'timeout'); }
function parseCommits(value) {
const n = parsePositiveInt(value, 'commits');
if (n > 50) throw new InvalidArgumentError('commits must be 50 or less');
return n;
}

const __dirname = dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = join(__dirname, '..', 'src', 'templates');
Expand Down Expand Up @@ -94,7 +108,7 @@ doc
.argument('[path]', 'Path to repo', '.')
.option('--dry-run', 'Preview without writing files')
.option('--force', 'Overwrite existing skills')
.option('--timeout <seconds>', 'Claude timeout in seconds', '300')
.option('--timeout <seconds>', 'Claude timeout in seconds', parseTimeout, 300)
.option('--mode <mode>', 'Generation mode: all, chunked, base-only (skips interactive prompt)')
.option('--strategy <strategy>', 'Existing docs: improve, rewrite, skip (skips interactive prompt)')
.option('--domains <domains>', 'Additional domains to include (comma-separated, e.g., "backtest,advisory")')
Expand All @@ -107,11 +121,11 @@ doc
.command('sync')
.description('Update skills from recent commits')
.argument('[path]', 'Path to repo', '.')
.option('--commits <n>', 'Number of commits to analyze', '1')
.option('--commits <n>', 'Number of commits to analyze', parseCommits, 1)
.option('--install-hook', 'Install git post-commit hook')
.option('--remove-hook', 'Remove git post-commit hook')
.option('--dry-run', 'Preview without writing files')
.option('--timeout <seconds>', 'Claude timeout in seconds', '300')
.option('--timeout <seconds>', 'Claude timeout in seconds', parseTimeout, 300)
.option('--model <model>', 'Claude model to use (e.g., sonnet, opus, haiku)')
.option('--verbose', 'Show what Claude is reading/doing in real time')
.action(docSyncCommand);
Expand All @@ -131,12 +145,20 @@ program
.description('Inject project-specific context into agents')
.argument('<what>', 'What to customize: agents')
.option('--dry-run', 'Preview without writing files')
.option('--timeout <seconds>', 'Claude timeout in seconds', '300')
.option('--timeout <seconds>', 'Claude timeout in seconds', parseTimeout, 300)
.option('--model <model>', 'Claude model to use (e.g., sonnet, opus, haiku)')
.option('--verbose', 'Show what Claude is reading/doing in real time')
.action(customizeCommand);

// Clean up spawned processes on interrupt
process.on('SIGINT', () => process.exit(130));
process.on('SIGTERM', () => process.exit(143));

program.parseAsync().catch((err) => {
if (err instanceof CliError) {
if (!err.logged) console.error(pc.red('Error:'), err.message);
process.exit(err.exitCode);
}
console.error(pc.red('Error:'), err.message);
process.exit(1);
});
7 changes: 4 additions & 3 deletions src/commands/add.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync, readFileSync, copyFileSync, mkdirSync, readdirSync } from '
import { fileURLToPath } from 'url';
import pc from 'picocolors';
import * as p from '@clack/prompts';
import { CliError } from '../lib/errors.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = join(__dirname, '..', 'templates');
Expand Down Expand Up @@ -47,7 +48,7 @@ export async function addCommand(type, name, options) {
${pc.dim('aspens add hook [name]')}
${pc.dim('aspens add agent --list')}
`);
process.exit(1);
throw new CliError(`Unknown type: ${type}`, { logged: true });
}

const resourceType = RESOURCE_TYPES[type];
Expand Down Expand Up @@ -78,7 +79,7 @@ export async function addCommand(type, name, options) {

if (p.isCancel(picked)) {
p.cancel('Aborted');
process.exit(0);
return;
}

for (const pickedName of picked) {
Expand Down Expand Up @@ -110,7 +111,7 @@ export async function addCommand(type, name, options) {
Available ${type}s:
${available.map(a => ` ${pc.green(a.name)} — ${a.description}`).join('\n')}
`);
process.exit(1);
throw new CliError(`Not found: ${name}`, { logged: true });
}

addResource(repoPath, resourceType, name, available);
Expand Down
15 changes: 7 additions & 8 deletions src/commands/customize.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import pc from 'picocolors';
import * as p from '@clack/prompts';
import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js';
import { writeSkillFiles } from '../lib/skill-writer.js';
import { CliError } from '../lib/errors.js';

const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep'];

export async function customizeCommand(what, options) {
const repoPath = resolve('.');
const timeoutMs = parseInt(options.timeout) * 1000 || 300000;
const timeoutMs = (typeof options.timeout === 'number' ? options.timeout : 300) * 1000;
const verbose = !!options.verbose;

if (what !== 'agents') {
Expand All @@ -19,22 +20,20 @@ export async function customizeCommand(what, options) {
Usage:
${pc.green('aspens customize agents')} Inject project context into your agents
`);
process.exit(1);
throw new CliError(`Unknown target: ${what}`, { logged: true });
}

p.intro(pc.cyan('aspens customize agents'));

// Step 1: Find agents in the repo
const agentsDir = join(repoPath, '.claude', 'agents');
if (!existsSync(agentsDir)) {
p.log.error('No .claude/agents/ found. Run aspens add agent first.');
process.exit(1);
throw new CliError('No .claude/agents/ found. Run aspens add agent first.');
}

const agents = findAgents(agentsDir, repoPath);
if (agents.length === 0) {
p.log.error('No agent files found in .claude/agents/');
process.exit(1);
throw new CliError('No agent files found in .claude/agents/');
}

p.log.info(`Found ${agents.length} agent(s): ${agents.map(a => pc.yellow(a.name)).join(', ')}`);
Expand All @@ -48,7 +47,7 @@ export async function customizeCommand(what, options) {
if (!projectContext) {
contextSpinner.stop(pc.yellow('No skills or CLAUDE.md found'));
p.log.warn('Run aspens doc init first to generate skills, then customize agents.');
process.exit(0);
return;
}

contextSpinner.stop('Project context loaded');
Expand Down Expand Up @@ -114,7 +113,7 @@ export async function customizeCommand(what, options) {

if (p.isCancel(proceed) || !proceed) {
p.cancel('Aborted');
process.exit(0);
return;
}

// Allow .claude/agents/ paths in sanitizePath
Expand Down
25 changes: 11 additions & 14 deletions src/commands/doc-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import { buildRepoGraph } from '../lib/graph-builder.js';
import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js';
import { writeSkillFiles } from '../lib/skill-writer.js';
import { installGitHook } from './doc-sync.js';
import { CliError } from '../lib/errors.js';

// Read-only tools — Claude explores the repo itself
const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep'];

// Auto-scale timeout based on repo size
function autoTimeout(scan, userTimeout) {
if (userTimeout) {
const parsed = parseInt(userTimeout) * 1000;
if (!isNaN(parsed) && parsed > 0) return parsed;
if (typeof userTimeout === 'number' && userTimeout > 0) {
return userTimeout * 1000;
}
const defaults = { 'small': 120000, 'medium': 300000, 'large': 600000, 'very-large': 900000 };
return defaults[scan.size?.category] || 300000;
Expand Down Expand Up @@ -207,8 +207,7 @@ export async function docInitCommand(path, options) {
const strategyMap = { 'improve': 'improve', 'rewrite': 'rewrite', 'skip': 'skip-existing' };
existingDocsStrategy = strategyMap[options.strategy] || options.strategy;
if (!['improve', 'rewrite', 'skip-existing', 'fresh'].includes(existingDocsStrategy)) {
p.log.error(`Unknown strategy: ${options.strategy}. Use: improve, rewrite, or skip`);
process.exit(1);
throw new CliError(`Unknown strategy: ${options.strategy}. Use: improve, rewrite, or skip`);
}
} else if ((scan.hasClaudeConfig || scan.hasClaudeMd) && !options.force) {
const strategy = await p.select({
Expand All @@ -222,7 +221,7 @@ export async function docInitCommand(path, options) {

if (p.isCancel(strategy)) {
p.cancel('Aborted');
process.exit(0);
return;
}
existingDocsStrategy = strategy;

Expand All @@ -240,8 +239,7 @@ export async function docInitCommand(path, options) {
const modeMap = { 'all': 'all-at-once', 'chunked': 'chunked', 'base-only': 'base-only' };
mode = modeMap[options.mode] || options.mode;
if (!['all-at-once', 'chunked', 'base-only'].includes(mode)) {
p.log.error(`Unknown mode: ${options.mode}. Use: all, chunked, or base-only`);
process.exit(1);
throw new CliError(`Unknown mode: ${options.mode}. Use: all, chunked, or base-only`);
}
} else if (effectiveDomains.length === 0) {
p.log.info('No domains detected — generating base skill only.');
Expand Down Expand Up @@ -270,7 +268,7 @@ export async function docInitCommand(path, options) {

if (p.isCancel(modeChoice)) {
p.cancel('Aborted');
process.exit(0);
return;
}
mode = modeChoice;

Expand All @@ -287,7 +285,7 @@ export async function docInitCommand(path, options) {

if (p.isCancel(picked)) {
p.cancel('Aborted');
process.exit(0);
return;
}
selectedDomains = effectiveDomains.filter(d => picked.includes(d.name));
mode = 'chunked';
Expand All @@ -304,11 +302,10 @@ export async function docInitCommand(path, options) {
}

if (allFiles.length === 0) {
p.log.error('No skill files generated.');
if (tokenTracker.calls > 0) {
console.log(pc.dim(` ${tokenTracker.calls} Claude call(s) made, but no parseable output.`));
}
process.exit(1);
throw new CliError('No skill files generated.', { logged: true });
}

// Step 4: Show what will be written
Expand Down Expand Up @@ -339,7 +336,7 @@ export async function docInitCommand(path, options) {

if (p.isCancel(proceed) || !proceed) {
p.cancel('Aborted');
process.exit(0);
return;
}

// Step 5: Write files
Expand Down Expand Up @@ -536,7 +533,7 @@ async function generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, tim
initialValue: true,
});
if (p.isCancel(retry) || !retry) {
process.exit(1);
throw new CliError('Generation failed.', { logged: true });
}
return generateChunked(repoPath, scan, repoGraph, selectedDomains, false, timeoutMs, strategy, verbose, model, findings);
}
Expand Down
23 changes: 12 additions & 11 deletions src/commands/doc-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import * as p from '@clack/prompts';
import { scanRepo } from '../lib/scanner.js';
import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js';
import { writeSkillFiles } from '../lib/skill-writer.js';
import { CliError } from '../lib/errors.js';

const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep'];

export async function docSyncCommand(path, options) {
const repoPath = resolve(path);
const timeoutMs = parseInt(options.timeout) * 1000 || 300000;
const timeoutMs = (typeof options.timeout === 'number' ? options.timeout : 300) * 1000;
const verbose = !!options.verbose;
const commits = parseInt(options.commits) || 1;
const commits = typeof options.commits === 'number' ? options.commits : 1;

// Install/remove hook mode
if (options.installHook) {
Expand All @@ -27,13 +28,11 @@ export async function docSyncCommand(path, options) {

// Step 1: Check prerequisites
if (!isGitRepo(repoPath)) {
p.log.error('Not a git repository. doc sync requires git history.');
process.exit(1);
throw new CliError('Not a git repository. doc sync requires git history.');
}

if (!existsSync(join(repoPath, '.claude', 'skills'))) {
p.log.error('No .claude/skills/ found. Run aspens doc init first.');
process.exit(1);
throw new CliError('No .claude/skills/ found. Run aspens doc init first.');
}

// Step 2: Get git diff
Expand Down Expand Up @@ -136,8 +135,7 @@ ${truncate(claudeMdContent, 5000)}
});
} catch (err) {
syncSpinner.stop(pc.red('Failed'));
p.log.error(err.message);
process.exit(1);
throw new CliError(err.message);
}

// Step 6: Parse output
Expand Down Expand Up @@ -186,7 +184,7 @@ ${truncate(claudeMdContent, 5000)}

function isGitRepo(repoPath) {
try {
execSync('git rev-parse --git-dir', { cwd: repoPath, stdio: 'pipe' });
execSync('git rev-parse --git-dir', { cwd: repoPath, stdio: 'pipe', timeout: 5000 });
return true;
} catch {
return false;
Expand All @@ -201,6 +199,7 @@ function getGitDiff(repoPath, commits) {
cwd: repoPath,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
timeout: 30000,
stdio: ['pipe', 'pipe', 'pipe'],
});
return { diff, actualCommits: n };
Expand All @@ -217,6 +216,7 @@ function getGitLog(repoPath, commits) {
cwd: repoPath,
encoding: 'utf8',
maxBuffer: 5 * 1024 * 1024,
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch {
Expand All @@ -230,6 +230,7 @@ function getChangedFiles(repoPath, commits) {
cwd: repoPath,
encoding: 'utf8',
maxBuffer: 5 * 1024 * 1024,
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe'],
});
return output.trim().split('\n').filter(Boolean);
Expand Down Expand Up @@ -325,6 +326,7 @@ function resolveAspensPath() {
try {
const resolved = execSync('which aspens', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
if (resolved && existsSync(resolved)) return resolved;
Expand All @@ -337,8 +339,7 @@ export function installGitHook(repoPath) {
const hookPath = join(hookDir, 'post-commit');

if (!existsSync(join(repoPath, '.git'))) {
console.log(pc.red('\n Not a git repository.\n'));
process.exit(1);
throw new CliError('Not a git repository.');
}

mkdirSync(hookDir, { recursive: true });
Expand Down
1 change: 1 addition & 0 deletions src/lib/context-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ function getGitLog(repoPath) {
return execSync('git log --oneline -20', {
cwd: repoPath,
encoding: 'utf8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Error thrown by command handlers for expected failures (validation, missing prereqs, etc.).
* Caught at the top level in cli.js — avoids scattered process.exit() calls.
*/
export class CliError extends Error {
/**
* @param {string} message
* @param {{ exitCode?: number, logged?: boolean }} options
* - exitCode: process exit code (default 1)
* - logged: if true, the top-level handler won't re-print the message
*/
constructor(message, { exitCode = 1, logged = false } = {}) {
super(message);
this.name = 'CliError';
this.exitCode = exitCode;
this.logged = logged;
}
}
Loading
Loading