#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs/promises';
import chokidar from 'chokidar';
import fetch from 'node-fetch';
import { GodotShell } from '../adapters/godot_shell';
import { Context } from '../adapters/context';
import { Patcher } from '../adapters/patcher';
import { ParserGodot4 } from '../adapters/parser_godot4';
dotenv.config();
class SentinelCLI {
private projectRoot: string;
private godotShell: GodotShell;
private context: Context;
private patcher: Patcher;
private parser: ParserGodot4;
private sentinelDir: string;
constructor() {
this.projectRoot = path.resolve(process.env.GODOT_PROJECT_ROOT || '../game');
this.godotShell = new GodotShell(this.projectRoot);
this.context = new Context(this.projectRoot);
this.patcher = new Patcher(this.projectRoot);
this.parser = new ParserGodot4();
this.sentinelDir = path.join(process.env.HOME || '', '.sentinel');
}
public setupCommands(): Command {
const program = new Command();
program
.name('sentinel')
.description('Terminal bridge for auto-fixing Godot errors')
.version('1.0.0');
program
.command('test')
.description('Run tests and show first error if any')
.action(async () => {
await this.runTests();
});
program
.command('fix')
.description('Run tests, auto-fix errors, and optionally run game')
.option('--autorun', 'Run game if tests pass after fix')
.action(async (options) => {
await this.runFix(options.autorun);
});
program
.command('run')
.description('Run game and stream logs with colorized errors')
.option('-s, --scene <scene>', 'Scene to launch')
.action(async (options) => {
await this.runGame(options.scene);
});
program
.command('watch')
.description('Watch for file changes and auto-run fix loop')
.action(async () => {
await this.watchFiles();
});
program
.command('ctx <file> <line> [radius]')
.description('Print numbered snippet around file:line')
.action(async (file: string, line: string, radius: string = '20') => {
await this.showContext(file, parseInt(line), parseInt(radius));
});
const movesetCmd = program
.command('moveset')
.description('Manage movesets');
movesetCmd
.command('list')
.description('List all movesets')
.action(async () => {
await this.listMovesets();
});
movesetCmd
.command('read <name>')
.description('Read a moveset')
.action(async (name: string) => {
await this.readMoveset(name);
});
movesetCmd
.command('write <name> <file>')
.description('Write a moveset from JSON file')
.action(async (name: string, file: string) => {
await this.writeMoveset(name, file);
});
return program;
}
private async runTests(): Promise<void> {
console.log(chalk.blue('๐งช Running tests...'));
const result = await this.godotShell.runTests();
if (result.pass) {
console.log(chalk.green('โ
All tests passed!'));
} else {
console.log(chalk.red('โ Tests failed'));
if (result.first_error) {
this.printError(result.first_error);
}
}
process.exit(result.pass ? 0 : 1);
}
private async runFix(autorun: boolean = false): Promise<void> {
console.log(chalk.blue('๐ง Running fix loop...'));
// Step 1: Run tests
const testResult = await this.godotShell.runTests();
if (testResult.pass) {
console.log(chalk.green('โ
Tests already passing!'));
if (autorun) {
await this.runGame();
}
process.exit(0);
}
if (!testResult.first_error) {
console.log(chalk.red('โ Tests failed but no actionable error found'));
process.exit(1);
}
// Step 2: Gather context
console.log(chalk.blue('๐ Gathering context...'));
const error = testResult.first_error;
const contextResult = await this.context.getContext(error.file, error.line);
if (!contextResult.valid_path) {
console.log(chalk.red(`โ Invalid file path: ${error.file}`));
process.exit(1);
}
// Step 3: Get git status and recent changes
const gitStatus = await this.context.getGitStatus();
const recentDiff = await this.context.getRecentDiff();
// Step 4: Call Claude API
console.log(chalk.blue('๐ค Calling Claude for fix...'));
const patch = await this.callClaudeForFix(error, contextResult, gitStatus, recentDiff);
if (!patch) {
console.log(chalk.red('โ Failed to get fix from Claude'));
process.exit(1);
}
// Step 5: Apply patch
console.log(chalk.blue('๐จ Applying patch...'));
const patchResult = await this.patcher.applyPatch(patch);
if (!patchResult.success) {
console.log(chalk.red(`โ Failed to apply patch: ${patchResult.error}`));
process.exit(1);
}
console.log(chalk.green(`โ
Patch applied on branch: ${patchResult.branch_name}`));
// Step 6: Re-run tests
const retestResult = await this.godotShell.runTests();
if (retestResult.pass) {
console.log(chalk.green('๐ Tests now passing!'));
if (autorun || process.env.AUTORUN === 'true') {
await this.runGame();
}
process.exit(0);
} else {
console.log(chalk.red('โ Tests still failing after patch'));
if (retestResult.first_error) {
this.printError(retestResult.first_error);
}
process.exit(1);
}
}
private async runGame(scene?: string): Promise<void> {
console.log(chalk.blue('๐ฎ Starting game...'));
const result = await this.godotShell.runGame({ scene });
if (result.started) {
console.log(chalk.green(`โ
Game started (PID: ${result.pid})`));
// Stream errors in real-time
this.godotShell.onError((error) => {
this.printError(error);
});
// Keep process alive to monitor
process.on('SIGINT', () => {
console.log(chalk.blue('\n๐ Stopping game monitor...'));
process.exit(0);
});
} else {
console.log(chalk.red('โ Failed to start game'));
process.exit(1);
}
}
private async watchFiles(): Promise<void> {
console.log(chalk.blue('๐ Watching for file changes...'));
console.log(chalk.gray(`Watching: ${this.projectRoot}`));
const watcher = chokidar.watch([
path.join(this.projectRoot, '**/*.gd'),
path.join(this.projectRoot, '**/*.json'),
path.join(this.projectRoot, '**/*.tscn')
], {
ignored: /node_modules|\.git|\.import/,
persistent: true
});
let fixing = false;
watcher.on('change', async (filePath) => {
if (fixing) return;
console.log(chalk.yellow(`๐ Changed: ${path.relative(this.projectRoot, filePath)}`));
fixing = true;
try {
await this.runFix(false);
} catch (error) {
console.log(chalk.red('โ Fix failed:', error));
} finally {
fixing = false;
}
});
// Keep process alive
process.on('SIGINT', () => {
console.log(chalk.blue('\n๐ Stopping file watcher...'));
watcher.close();
process.exit(0);
});
}
private async showContext(file: string, line: number, radius: number): Promise<void> {
const result = await this.context.getContext(file, line, radius);
if (!result.valid_path) {
console.log(chalk.red(`โ Invalid file: ${file}`));
process.exit(1);
}
console.log(chalk.blue(`๐ ${file}:${line} (ยฑ${radius} lines)`));
console.log(chalk.gray('โ'.repeat(60)));
result.numbered_lines.forEach(line => {
if (line.startsWith('โ')) {
console.log(chalk.red(line));
} else {
console.log(line);
}
});
}
private async listMovesets(): Promise<void> {
const result = await this.context.listMovesets();
if (result.movesets.length === 0) {
console.log(chalk.yellow('No movesets found'));
return;
}
console.log(chalk.blue('๐ Available movesets:'));
result.movesets.forEach(name => {
console.log(` โข ${name}`);
});
}
private async readMoveset(name: string): Promise<void> {
const result = await this.context.readMoveset(name);
if (!result.valid) {
console.log(chalk.red(`โ Moveset not found: ${name}`));
process.exit(1);
}
console.log(chalk.blue(`๐ Moveset: ${name}`));
console.log(JSON.stringify(result.moveset, null, 2));
}
private async writeMoveset(name: string, filePath: string): Promise<void> {
try {
const content = await fs.readFile(filePath, 'utf8');
const movesetData = JSON.parse(content);
const result = await this.context.writeMoveset(name, movesetData);
if (result.success) {
console.log(chalk.green(`โ
Moveset written: ${name}`));
} else {
console.log(chalk.red(`โ Failed to write moveset: ${result.error}`));
process.exit(1);
}
} catch (error) {
console.log(chalk.red(`โ Error reading file: ${error}`));
process.exit(1);
}
}
private async callClaudeForFix(
error: any,
context: any,
gitStatus: string,
recentDiff: string
): Promise<string | null> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
console.log(chalk.red('โ ANTHROPIC_API_KEY not set'));
return null;
}
try {
// Load prompt template
const promptPath = path.join(__dirname, 'prompts', 'fix_error.md');
let prompt = await fs.readFile(promptPath, 'utf8');
// Fill in template variables
prompt = prompt
.replace('{{ERROR_FILE}}', error.file)
.replace('{{ERROR_LINE}}', error.line.toString())
.replace('{{ERROR_MESSAGE}}', error.message)
.replace('{{ERROR_STACK}}', error.stack.join('\n'))
.replace('{{CONTEXT_SNIPPET}}', context.numbered_lines.join('\n'))
.replace('{{GIT_STATUS}}', gitStatus)
.replace('{{RECENT_DIFF}}', recentDiff);
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: process.env.CLAUDE_MODEL || 'claude-3-5-sonnet-20241022',
max_tokens: 4000,
messages: [
{
role: 'user',
content: prompt
}
]
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json() as any;
return data.content[0].text;
} catch (error) {
console.log(chalk.red(`โ Claude API error: ${error}`));
return null;
}
}
private printError(error: any): void {
console.log(chalk.red('\n๐จ Error Details:'));
console.log(chalk.red(` File: ${error.file}:${error.line}`));
console.log(chalk.red(` Type: ${error.type}`));
console.log(chalk.red(` Message: ${error.message}`));
if (error.stack && error.stack.length > 0) {
console.log(chalk.red(` Stack:`));
error.stack.forEach((frame: string) => {
console.log(chalk.red(` ${frame}`));
});
}
console.log();
}
}
// Run CLI if called directly
if (require.main === module) {
const cli = new SentinelCLI();
const program = cli.setupCommands();
program.parse();
}
export { SentinelCLI };