diff --git a/.gitignore b/.gitignore index 30cc889..49bd59a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ agent-orchestrator.yaml /skills/* !/skills/dart-gherkin-tests *.g.dart +**/*.exe diff --git a/bin/code_work_spawner.dart b/bin/code_work_spawner.dart index efa0fca..d54d3c8 100644 --- a/bin/code_work_spawner.dart +++ b/bin/code_work_spawner.dart @@ -1,123 +1,152 @@ import 'dart:io'; import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; import 'package:code_work_spawner/code_work_spawner.dart'; import 'package:logging/logging.dart'; Future main(List arguments) async { _configureLogging(); - final parser = ArgParser() - ..addFlag( - 'once', - abbr: '1', - help: 'Run a single poll cycle and exit.', - negatable: false, - ) - ..addOption( - 'config', - abbr: 'c', - help: 'Path to agent-orchestrator.yaml.', - defaultsTo: 'agent-orchestrator.yaml', - ) - ..addOption( - 'db', - help: 'Path to the SQLite database file.', - defaultsTo: '.code_work_spawner.sqlite3', - ) - ..addOption( - 'gh-command', - help: 'Path to the gh executable.', - defaultsTo: 'gh', - ) - ..addFlag('help', abbr: 'h', negatable: false); - final initConfigParser = parser.addCommand('init-config') - ..addOption( - 'output', - abbr: 'o', - help: 'Write the starter config to a file.', - ) - ..addFlag( - 'force', - abbr: 'f', - help: 'Overwrite the output file if it already exists.', - negatable: false, - ) - ..addFlag('help', abbr: 'h', negatable: false); - - final results = parser.parse(arguments); - final command = results.command; - if (command?.name == 'init-config') { - if (command!['help'] as bool) { - stdout.writeln(initConfigParser.usage); - return; - } - await _runInitConfig(command); - return; - } - - if (results['help'] as bool) { - stdout.writeln(parser.usage); - return; - } - - final config = await AppConfig.load(results['config'] as String); - final logger = Logger('code_work_spawner.cli'); - logger.info( - 'loaded config=${config.configPath} ' - 'projects=${config.projects.length} ' - 'worktreeDir=${config.worktreeDir ?? "(system temp)"}', - ); - for (final project in config.projects.values) { - final repoDirectory = Directory(project.path); - logger.info( - 'project=${project.key} ' - 'repo=${project.repo} ' - 'path=${project.path} ' - 'exists=${await repoDirectory.exists()} ' - 'enabled=${project.issueAssistant.enabled}', - ); - } - final app = await IssueAssistantApp.open( - config: config, - databasePath: results['db'] as String, - ghCommand: results['gh-command'] as String, - ); - + final runner = _CodeWorkSpawnerCommandRunner(); try { - if (results['once'] as bool) { - await app.runOnce(); - return; - } - - await app.run(); - } finally { - await app.close(); + await runner.run(arguments); + } on UsageException catch (error) { + stdout.writeln(error); + exitCode = 64; } } -Future _runInitConfig(ArgResults command) async { - final outputPath = command['output'] as String?; - final force = command['force'] as bool; - final content = AppConfig.renderInitialConfig(); +class _CodeWorkSpawnerCommandRunner extends CommandRunner { + _CodeWorkSpawnerCommandRunner() + : super( + 'code_work_spawner', + 'GitHub issue thread assistant with an Agent Orchestrator style YAML config.', + ) { + argParser + ..addFlag( + 'once', + abbr: '1', + help: 'Run a single poll cycle and exit.', + negatable: false, + ) + ..addOption( + 'config', + abbr: 'c', + help: 'Path to agent-orchestrator.yaml.', + defaultsTo: 'agent-orchestrator.yaml', + ) + ..addOption( + 'db', + help: 'Path to the SQLite database file.', + defaultsTo: '.code_work_spawner.sqlite3', + ) + ..addOption( + 'gh-command', + help: 'Path to the gh executable.', + defaultsTo: 'gh', + ); - if (outputPath == null) { - stdout.writeln(content); - return; + addCommand(_InitConfigCommand()); } - final outputFile = File(outputPath); - if (await outputFile.exists() && !force) { - stderr.writeln( - 'Refusing to overwrite existing file: $outputPath. Use --force to replace it.', + @override + Future runCommand(ArgResults topLevelResults) async { + if (topLevelResults['help'] as bool) { + stdout.writeln(usage); + return; + } + + if (topLevelResults.command != null) { + await super.runCommand(topLevelResults); + return; + } + + final config = await AppConfig.load(topLevelResults['config'] as String); + final logger = Logger('code_work_spawner.cli'); + logger.info( + 'loaded config=${config.configPath} ' + 'projects=${config.projects.length} ' + 'worktreeDir=${config.worktreeDir ?? "(system temp)"}', ); - exitCode = 2; - return; + for (final project in config.projects.values) { + final repoDirectory = Directory(project.path); + logger.info( + 'project=${project.key} ' + 'repo=${project.repo} ' + 'path=${project.path} ' + 'exists=${await repoDirectory.exists()} ' + 'enabled=${project.issueAssistant.enabled}', + ); + } + final app = await IssueAssistantApp.open( + config: config, + databasePath: topLevelResults['db'] as String, + ghCommand: topLevelResults['gh-command'] as String, + ); + + try { + if (topLevelResults['once'] as bool) { + await app.runOnce(); + return; + } + + await app.run(); + } finally { + await app.close(); + } + } +} + +class _InitConfigCommand extends Command { + @override + String get name => 'init-config'; + + @override + String get description => 'Print or write a starter config file.'; + + @override + String get invocation => 'code_work_spawner init-config [arguments]'; + + _InitConfigCommand() { + argParser + ..addOption( + 'output', + abbr: 'o', + help: 'Write the starter config to a file.', + ) + ..addFlag( + 'force', + abbr: 'f', + help: 'Overwrite the output file if it already exists.', + negatable: false, + ); } - await outputFile.parent.create(recursive: true); - await outputFile.writeAsString('$content\n'); - stdout.writeln('Wrote starter config to ${outputFile.path}'); + @override + Future run() async { + final outputPath = argResults!['output'] as String?; + final force = argResults!['force'] as bool; + final content = AppConfig.renderInitialConfig(); + + if (outputPath == null) { + stdout.writeln(content); + return; + } + + final outputFile = File(outputPath); + if (await outputFile.exists() && !force) { + stderr.writeln( + 'Refusing to overwrite existing file: $outputPath. Use --force to replace it.', + ); + exitCode = 2; + return; + } + + await outputFile.parent.create(recursive: true); + await outputFile.writeAsString('$content\n'); + stdout.writeln('Wrote starter config to ${outputFile.path}'); + } } void _configureLogging() { diff --git a/pubspec.yaml b/pubspec.yaml index 1bc338b..c3bb728 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: meta: ^1.16.0 github: ^9.25.0 git: ^2.3.2 + nyxx: ^6.8.1 dev_dependencies: build_runner: ^2.6.0 diff --git a/test/init_config_cli_test.dart b/test/init_config_cli_test.dart index 32b79f4..5c5fe96 100644 --- a/test/init_config_cli_test.dart +++ b/test/init_config_cli_test.dart @@ -13,6 +13,29 @@ void main() { /// So that I can bootstrap configuration without reading the code first /// ``` group('code_work_spawner init-config', () { + /// ```gherkin + /// Scenario: Show init-config in top-level help + /// Given the CLI is executed with the top-level help flag + /// When the process completes successfully + /// Then stdout lists the init-config command in the available commands section + /// ``` + test('Show init-config in top-level help', () async { + // Given the CLI is executed with the top-level help flag. + final result = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/code_work_spawner.dart', + '--help', + ], workingDirectory: Directory.current.path); + + // When the process completes successfully. + expect(result.exitCode, 0, reason: '${result.stderr}'); + + // Then stdout lists the init-config command in the available commands section. + final stdoutText = result.stdout as String; + expect(stdoutText, contains('Available commands:')); + expect(stdoutText, contains('init-config')); + }); + /// ```gherkin /// Scenario: Print the starter config to stdout /// Given the CLI is executed with the init-config command