Skip to main content
Glama
cli.tsโ€ข11.9 kB
#!/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 };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Snack-JPG/Godot-Sentinel-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server