From 8fe0d1017f1691c5473f39c5ecf7e57a69a66e78 Mon Sep 17 00:00:00 2001 From: insleker Date: Sun, 5 Apr 2026 02:28:11 +0800 Subject: [PATCH] feat: add `init-config` option to gen initial config file refactor: use `json_serializable` to achieve config file deserialization --- README.md | 14 +- bin/code_work_spawner.dart | 47 ++ build.yaml | 7 +- examples/README.md | 9 +- examples/simple-github.yaml | 3 +- lib/src/config.dart | 902 +++++++++++++++++++-------------- lib/src/config_schema.dart | 146 ++++++ pubspec.yaml | 2 + test/app_config_test.dart | 153 +++++- test/init_config_cli_test.dart | 94 ++++ 10 files changed, 947 insertions(+), 430 deletions(-) create mode 100644 lib/src/config_schema.dart create mode 100644 test/init_config_cli_test.dart diff --git a/README.md b/README.md index 157dc64..476cb45 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # code_work_spawner -GitHub issue thread assistant with Agent Orchestrator style config. +GitHub issue thread assistant with an Agent Orchestrator style YAML config. The app polls configured GitHub repositories with `gh`, reads issue threads and issue comments, and uses configured CLI responders such as `codex`, @@ -32,7 +32,17 @@ It supports multi-repo configuration, responder fallback chains. ## Config -Create a local `agent-orchestrator.yaml` follow [`/examples/README.md`](examples/README.md) as a starting point. +Create a local `agent-orchestrator.yaml` and follow [`/examples/README.md`](examples/README.md) as a starting point. + +Generate a starter config: + +```bash +dart run bin/code_work_spawner.dart init-config +# Write it to a file: +dart run bin/code_work_spawner.dart init-config --output agent-orchestrator.yaml +# Overwrite an existing file: +dart run bin/code_work_spawner.dart init-config --output agent-orchestrator.yaml --force +``` ## Run diff --git a/bin/code_work_spawner.dart b/bin/code_work_spawner.dart index 70235d8..efa0fca 100644 --- a/bin/code_work_spawner.dart +++ b/bin/code_work_spawner.dart @@ -31,8 +31,31 @@ Future main(List arguments) async { 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; @@ -73,6 +96,30 @@ Future main(List arguments) async { } } +Future _runInitConfig(ArgResults command) async { + final outputPath = command['output'] as String?; + final force = command['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() { hierarchicalLoggingEnabled = true; Logger.root.level = Level.INFO; diff --git a/build.yaml b/build.yaml index edf45f8..ac563c3 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,9 @@ targets: $default: -# sources: builders: drift_dev: generate_for: - lib/src/database.dart - # json_serializable: - # generate_for: - # - lib/**/model/*.dart + json_serializable: + generate_for: + - lib/src/config_schema.dart diff --git a/examples/README.md b/examples/README.md index d404993..a474e1b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Config Examples -The repo try to [follow Agent Orchestrator style config](https://github.com/ComposioHQ/agent-orchestrator/tree/main/examples). +The repo uses an Agent Orchestrator style YAML config with `defaults` and `projects`. Project entries can override `issueAssistant` fields when a repo needs a different prompt, mention triggers, or responder chain. @@ -12,7 +12,6 @@ account. Those markers support template variables such as `{{gh_login}}`, `{{comment_tag}}`, and `{{fingerprint}}`. Set either template to an empty string to disable that section. -For a more minimal Agent Orchestrator compatible subset, start from -[`examples/simple-github.yaml`](examples/simple-github.yaml). This repo accepts -AO-style `dataDir`, `worktreeDir`, and core `projects` fields, and uses -`worktreeDir` for bug-verification worktrees. +Start from [`examples/simple-github.yaml`](examples/simple-github.yaml). This +repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for +bug-verification worktrees. diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index fdd86f3..e707e7d 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -1,5 +1,4 @@ -# Minimal setup for a single GitHub repo with GitHub Issues -# AO-compatible subset supported by code_work_spawner. +# Minimal setup for a single GitHub repo with GitHub Issues. # Replies require at least one configured responder. dataDir: ~/.agent-orchestrator diff --git a/lib/src/config.dart b/lib/src/config.dart index b554dbc..30a3614 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -6,373 +6,10 @@ import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; import 'comment_templates.dart'; +import 'config_schema.dart'; +export 'config_schema.dart' show AssistantCapability; -class AppConfig { - AppConfig({ - required this.configPath, - required this.dataDir, - required this.worktreeDir, - required this.defaults, - required this.projects, - }); - - final String configPath; - final String? dataDir; - final String? worktreeDir; - final IssueAssistantConfig defaults; - final Map projects; - - static Future load(String path) async { - final file = File(path); - final contents = await file.readAsString(); - final yaml = loadYaml(contents); - if (yaml is! YamlMap) { - throw const FormatException('Config root must be a YAML map.'); - } - - final root = _asMap(yaml); - final dialect = _detectDialect(root); - return switch (dialect) { - _ConfigDialect.current => _loadCurrentConfig(path, root), - _ConfigDialect.aoSubset => _loadAoSubsetConfig(path, root), - }; - } - - static AppConfig _loadCurrentConfig(String path, Map root) { - _validateKeys( - scope: 'config', - map: root, - allowed: {'defaults', 'projects', 'dataDir', 'worktreeDir'}, - ); - final defaultsMap = _asMap(root['defaults']); - final defaultAssistantMap = _asMap(defaultsMap['issueAssistant']); - final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap); - final projects = _loadProjects( - root['projects'], - defaultAssistant: defaults, - defaultAssistantMap: defaultAssistantMap, - ); - - return AppConfig( - configPath: p.normalize(p.absolute(path)), - dataDir: _expandOptionalPath(root['dataDir']?.toString()), - worktreeDir: _expandOptionalPath(root['worktreeDir']?.toString()), - defaults: defaults, - projects: projects, - ); - } - - static AppConfig _loadAoSubsetConfig(String path, Map root) { - _validateKeys( - scope: 'config', - map: root, - allowed: {'defaults', 'projects', 'dataDir', 'worktreeDir'}, - ); - - final defaultsMap = _asMap(root['defaults']); - _validateKeys( - scope: 'defaults', - map: defaultsMap, - allowed: { - 'defaultBranch', - 'default_branch', - 'sessionPrefix', - 'session_prefix', - }, - ); - - final defaultAssistantMap = {}; - final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap); - final projects = _loadAoSubsetProjects( - root['projects'], - defaultsMap: defaultsMap, - defaultAssistant: defaults, - defaultAssistantMap: defaultAssistantMap, - ); - - return AppConfig( - configPath: p.normalize(p.absolute(path)), - dataDir: _expandOptionalPath(root['dataDir']?.toString()), - worktreeDir: _expandOptionalPath(root['worktreeDir']?.toString()), - defaults: defaults, - projects: projects, - ); - } - - static Map _loadProjects( - Object? projectsNode, { - required IssueAssistantConfig defaultAssistant, - required Map defaultAssistantMap, - }) { - if (projectsNode is! YamlMap || projectsNode.isEmpty) { - throw const FormatException('projects must be a non-empty map.'); - } - - final projects = {}; - for (final entry in projectsNode.entries) { - final key = entry.key.toString(); - final value = _asMap(entry.value); - projects[key] = ProjectConfig.fromMap( - key: key, - map: value, - defaultAssistant: defaultAssistant, - defaultAssistantMap: defaultAssistantMap, - ); - } - return projects; - } - - static Map _loadAoSubsetProjects( - Object? projectsNode, { - required Map defaultsMap, - required IssueAssistantConfig defaultAssistant, - required Map defaultAssistantMap, - }) { - if (projectsNode is! YamlMap || projectsNode.isEmpty) { - throw const FormatException('projects must be a non-empty map.'); - } - - final projects = {}; - for (final entry in projectsNode.entries) { - final key = entry.key.toString(); - final value = _asMap(entry.value); - _validateKeys( - scope: 'projects.$key', - map: value, - allowed: { - 'repo', - 'path', - 'defaultBranch', - 'default_branch', - 'sessionPrefix', - 'session_prefix', - }, - ); - final merged = {...defaultsMap, ...value}; - projects[key] = ProjectConfig.fromMap( - key: key, - map: merged, - defaultAssistant: defaultAssistant, - defaultAssistantMap: defaultAssistantMap, - ); - } - return projects; - } - - static _ConfigDialect _detectDialect(Map root) { - final defaultsMap = _asMap(root['defaults']); - if (defaultsMap.containsKey('issueAssistant')) { - return _ConfigDialect.current; - } - return _ConfigDialect.aoSubset; - } - - static Map _asMap(Object? value) { - if (value == null) { - return {}; - } - - if (value is YamlMap) { - return value.map( - (key, dynamicValue) => MapEntry(key.toString(), dynamicValue), - ); - } - - if (value is Map) { - return value; - } - - throw FormatException('Expected map, got ${value.runtimeType}.'); - } - - static void _validateKeys({ - required String scope, - required Map map, - required Set allowed, - }) { - for (final key in map.keys) { - if (!allowed.contains(key)) { - throw FormatException('Unsupported config field: $scope.$key'); - } - } - } -} - -class ProjectConfig { - ProjectConfig({ - required this.key, - required this.repo, - required this.repoSlug, - required this.path, - required this.defaultBranch, - required this.sessionPrefix, - required this.issueAssistant, - }); - - final String key; - final String repo; - final RepositorySlug repoSlug; - final String path; - final String defaultBranch; - final String sessionPrefix; - final IssueAssistantConfig issueAssistant; - - factory ProjectConfig.fromMap({ - required String key, - required Map map, - required IssueAssistantConfig defaultAssistant, - required Map defaultAssistantMap, - }) { - final assistantMap = AppConfig._asMap(map['issueAssistant']); - final mergedAssistantMap = { - ...defaultAssistantMap, - ...assistantMap, - }; - final repo = map['repo']?.toString() ?? _missing('projects.$key.repo'); - return ProjectConfig( - key: key, - repo: repo, - repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'), - path: _expandHome( - map['path']?.toString() ?? _missing('projects.$key.path'), - ), - defaultBranch: - map['defaultBranch']?.toString() ?? - map['default_branch']?.toString() ?? - 'main', - sessionPrefix: - map['sessionPrefix']?.toString() ?? - map['session_prefix']?.toString() ?? - _deriveSessionPrefix(key), - issueAssistant: assistantMap.isEmpty - ? defaultAssistant - : IssueAssistantConfig.fromMap(mergedAssistantMap), - ); - } - - static String _missing(String field) { - throw FormatException('Missing required config field: $field'); - } - - static RepositorySlug _parseRepositorySlug( - String value, { - required String field, - }) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw FormatException('Missing required config field: $field'); - } - - final parts = trimmed.split('/'); - if (parts.length != 2 || parts.any((part) => part.trim().isEmpty)) { - throw FormatException( - 'Invalid repository slug for $field: "$value". Expected owner/repo.', - ); - } - return RepositorySlug(parts[0], parts[1]); - } -} - -class IssueAssistantConfig { - IssueAssistantConfig({ - required this.enabled, - required this.pollInterval, - required this.maxConcurrentIssues, - required this.mentionTriggers, - required this.prompt, - required this.responders, - required this.capabilities, - required this.commentTag, - required this.commentPrefixTemplate, - required this.commentFooterTemplate, - }); - - final bool enabled; - final Duration pollInterval; - final int maxConcurrentIssues; - final List mentionTriggers; - final String prompt; - final List responders; - final Set capabilities; - final String commentTag; - final String commentPrefixTemplate; - final String commentFooterTemplate; - - factory IssueAssistantConfig.fromMap(Map map) { - final respondersNode = map['responders']; - final responders = respondersNode is List - ? respondersNode - .map((item) => CliResponderConfig.fromMap(AppConfig._asMap(item))) - .toList(growable: false) - : const []; - - final capabilitiesNode = map['capabilities']; - final capabilities = capabilitiesNode is List - ? capabilitiesNode - .map((item) => AssistantCapability.parse(item.toString())) - .toSet() - : {AssistantCapability.planning, AssistantCapability.bugVerification}; - final maxConcurrentIssues = _parsePositiveInt( - map['maxConcurrentIssues'], - fieldName: 'maxConcurrentIssues', - defaultValue: 3, - ); - - return IssueAssistantConfig( - enabled: map['enabled'] as bool? ?? true, - pollInterval: _parseDuration(map['pollInterval']?.toString() ?? '30s'), - maxConcurrentIssues: maxConcurrentIssues, - mentionTriggers: - (map['mentionTriggers'] as List?) - ?.map((item) => item.toString()) - .toList(growable: false) ?? - const [], - prompt: map['prompt']?.toString() ?? _defaultPrompt, - responders: responders, - capabilities: capabilities, - commentTag: map['commentTag']?.toString() ?? 'code-work-spawner', - commentPrefixTemplate: - map['commentPrefixTemplate']?.toString() ?? - defaultCommentPrefixTemplate, - commentFooterTemplate: - map['commentFooterTemplate']?.toString() ?? - defaultCommentFooterTemplate, - ); - } - static Duration _parseDuration(String input) { - final match = RegExp(r'^(\d+)([smhd])$').firstMatch(input.trim()); - if (match == null) { - throw FormatException('Invalid duration: $input'); - } - - final value = int.parse(match.group(1)!); - return switch (match.group(2)!) { - 's' => Duration(seconds: value), - 'm' => Duration(minutes: value), - 'h' => Duration(hours: value), - 'd' => Duration(days: value), - _ => throw FormatException('Invalid duration unit: $input'), - }; - } - - static int _parsePositiveInt( - Object? value, { - required String fieldName, - required int defaultValue, - }) { - if (value == null) { - return defaultValue; - } - final parsed = int.tryParse(value.toString()); - if (parsed == null || parsed < 1) { - throw FormatException( - '$fieldName must be an integer greater than or equal to 1.', - ); - } - return parsed; - } - - static const String _defaultPrompt = ''' +const String defaultIssueAssistantPrompt = ''' You are a GitHub issue thread assistant. Decide whether to reply. Rules: @@ -408,6 +45,203 @@ Issue body: Current issue thread JSON: {{thread_json}} '''; + +class AppConfig { + AppConfig({ + required this.configPath, + required this.dataDir, + required this.worktreeDir, + required this.defaults, + required this.projects, + }); + + final String configPath; + final String? dataDir; + final String? worktreeDir; + final IssueAssistantConfig defaults; + final Map projects; + + static Future load(String path) async { + final file = File(path); + final contents = await file.readAsString(); + final yaml = loadYaml(contents); + if (yaml is! YamlMap) { + throw const FormatException('Config root must be a YAML map.'); + } + + final root = _normalizeYamlNode(yaml); + if (root is! Map) { + throw const FormatException('Config root must be a YAML map.'); + } + + _assertCamelCaseConfigKeys(root); + final document = _parseDocument(root); + return _fromDocument(path, document); + } + + static String renderInitialConfig() { + return _InitialConfigRenderer().render(_initialTemplateDocument()); + } + + static AppConfig _fromDocument(String path, AppConfigDocument document) { + final defaults = IssueAssistantConfig.fromDocument( + document.defaults?.issueAssistant, + ); + final projectDocuments = document.projects; + if (projectDocuments == null || projectDocuments.isEmpty) { + throw const FormatException('projects must be a non-empty map.'); + } + + final projects = {}; + for (final entry in projectDocuments.entries) { + projects[entry.key] = ProjectConfig.fromDocument( + key: entry.key, + document: entry.value, + defaultAssistant: defaults, + ); + } + + return AppConfig( + configPath: p.normalize(p.absolute(path)), + dataDir: _expandOptionalPath(document.dataDir), + worktreeDir: _expandOptionalPath(document.worktreeDir), + defaults: defaults, + projects: projects, + ); + } + + static AppConfigDocument _parseDocument(Map root) { + try { + return AppConfigDocument.fromJson(root); + } catch (error) { + throw FormatException(error.toString()); + } + } + + static AppConfigDocument _initialTemplateDocument() { + return AppConfigDocument( + dataDir: '~/.agent-orchestrator', + worktreeDir: '~/.worktrees', + defaults: AppConfigDefaultsDocument( + issueAssistant: IssueAssistantConfigDocument( + enabled: true, + pollInterval: '30s', + maxConcurrentIssues: 3, + mentionTriggers: const ['@codex', '@helper'], + ), + ), + projects: { + 'sample': ProjectConfigDocument( + repo: 'owner/repo', + path: '~/path/to/repo', + defaultBranch: 'main', + ), + }, + ); + } +} + +class ProjectConfig { + ProjectConfig({ + required this.key, + required this.repo, + required this.repoSlug, + required this.path, + required this.defaultBranch, + required this.sessionPrefix, + required this.issueAssistant, + }); + + final String key; + final String repo; + final RepositorySlug repoSlug; + final String path; + final String defaultBranch; + final String sessionPrefix; + final IssueAssistantConfig issueAssistant; + + factory ProjectConfig.fromDocument({ + required String key, + required ProjectConfigDocument document, + required IssueAssistantConfig defaultAssistant, + }) { + final repo = document.repo?.trim(); + if (repo == null || repo.isEmpty) { + throw FormatException( + 'Missing required config field: projects.$key.repo', + ); + } + final path = document.path?.trim(); + if (path == null || path.isEmpty) { + throw FormatException( + 'Missing required config field: projects.$key.path', + ); + } + + return ProjectConfig( + key: key, + repo: repo, + repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'), + path: _expandHome(path), + defaultBranch: document.defaultBranch ?? 'main', + sessionPrefix: document.sessionPrefix ?? _deriveSessionPrefix(key), + issueAssistant: defaultAssistant.merge(document.issueAssistant), + ); + } + + static RepositorySlug _parseRepositorySlug( + String value, { + required String field, + }) { + final parts = value.split('/'); + if (parts.length != 2 || parts.any((part) => part.trim().isEmpty)) { + throw FormatException( + 'Invalid repository slug for $field: "$value". Expected owner/repo.', + ); + } + return RepositorySlug(parts[0], parts[1]); + } +} + +class IssueAssistantConfig { + IssueAssistantConfig({ + required this.enabled, + required this.pollInterval, + required this.maxConcurrentIssues, + required this.mentionTriggers, + required this.prompt, + required this.responders, + required this.capabilities, + required this.commentTag, + required this.commentPrefixTemplate, + required this.commentFooterTemplate, + }); + + final bool enabled; + final Duration pollInterval; + final int maxConcurrentIssues; + final List mentionTriggers; + final String prompt; + final List responders; + final Set capabilities; + final String commentTag; + final String commentPrefixTemplate; + final String commentFooterTemplate; + + factory IssueAssistantConfig.fromDocument( + IssueAssistantConfigDocument? document, + ) { + return const _IssueAssistantConfigResolver() + .resolve(base: null, overlay: null) + .merge(document); + } + + IssueAssistantConfig merge(IssueAssistantConfigDocument? overlay) { + return const _IssueAssistantConfigResolver().resolve( + base: this, + overlay: overlay, + ); + } } class CliResponderConfig { @@ -427,33 +261,23 @@ class CliResponderConfig { final String stdinTemplate; final Duration timeout; - factory CliResponderConfig.fromMap(Map map) { - final id = map['id']?.toString(); - final command = map['command']?.toString(); + factory CliResponderConfig.fromDocument(CliResponderConfigDocument document) { + final id = document.id?.trim(); if (id == null || id.isEmpty) { throw const FormatException('Responder id is required.'); } + final command = document.command?.trim(); if (command == null || command.isEmpty) { throw FormatException('Responder "$id" must define command.'); } - final args = _normalizeArgs( - (map['args'] as List?)?.map((item) => item.toString()) ?? - const [], - ); - final env = AppConfig._asMap( - map['env'], - ).map((key, value) => MapEntry(key, value.toString())); - return CliResponderConfig( id: id, command: command, - args: args, - env: env, - stdinTemplate: map['stdinTemplate']?.toString() ?? '{{prompt}}', - timeout: IssueAssistantConfig._parseDuration( - map['timeout']?.toString() ?? '10m', - ), + args: _normalizeArgs(document.args ?? const []), + env: document.env ?? const {}, + stdinTemplate: document.stdinTemplate ?? '{{prompt}}', + timeout: _parseDuration(document.timeout ?? '10m'), ); } @@ -464,17 +288,50 @@ class CliResponderConfig { } } -enum AssistantCapability { - planning, - bugVerification; +class _IssueAssistantConfigResolver { + const _IssueAssistantConfigResolver(); - static AssistantCapability parse(String input) { - return switch (input) { - 'planning' => AssistantCapability.planning, - 'bugVerification' || - 'bug_verification' => AssistantCapability.bugVerification, - _ => throw FormatException('Unsupported capability: $input'), - }; + IssueAssistantConfig resolve({ + required IssueAssistantConfig? base, + required IssueAssistantConfigDocument? overlay, + }) { + final responders = overlay?.responders; + return IssueAssistantConfig( + enabled: overlay?.enabled ?? base?.enabled ?? true, + pollInterval: overlay?.pollInterval != null + ? _parseDuration(overlay!.pollInterval!) + : base?.pollInterval ?? const Duration(seconds: 30), + maxConcurrentIssues: _resolvePositiveInt( + value: overlay?.maxConcurrentIssues, + fallback: base?.maxConcurrentIssues ?? 3, + fieldName: 'maxConcurrentIssues', + ), + mentionTriggers: + overlay?.mentionTriggers ?? base?.mentionTriggers ?? const [], + prompt: overlay?.prompt ?? base?.prompt ?? defaultIssueAssistantPrompt, + responders: responders != null + ? responders + .map(CliResponderConfig.fromDocument) + .toList(growable: false) + : base?.responders ?? const [], + capabilities: overlay?.capabilities != null + ? overlay!.capabilities!.toSet() + : base?.capabilities ?? + { + AssistantCapability.planning, + AssistantCapability.bugVerification, + }, + commentTag: + overlay?.commentTag ?? base?.commentTag ?? 'code-work-spawner', + commentPrefixTemplate: + overlay?.commentPrefixTemplate ?? + base?.commentPrefixTemplate ?? + defaultCommentPrefixTemplate, + commentFooterTemplate: + overlay?.commentFooterTemplate ?? + base?.commentFooterTemplate ?? + defaultCommentFooterTemplate, + ); } } @@ -491,6 +348,58 @@ String encodePrettyJson(Object value) { return encoder.convert(value); } +Object? _normalizeYamlNode(Object? value) { + if (value is YamlMap) { + return value.map( + (key, dynamicValue) => + MapEntry(key.toString(), _normalizeYamlNode(dynamicValue)), + ); + } + if (value is YamlList) { + return value.map(_normalizeYamlNode).toList(growable: false); + } + if (value is Map) { + return value.map( + (key, dynamicValue) => + MapEntry(key.toString(), _normalizeYamlNode(dynamicValue)), + ); + } + if (value is List) { + return value.map(_normalizeYamlNode).toList(growable: false); + } + return value; +} + +Duration _parseDuration(String input) { + final match = RegExp(r'^(\d+)([smhd])$').firstMatch(input.trim()); + if (match == null) { + throw FormatException('Invalid duration: $input'); + } + + final value = int.parse(match.group(1)!); + return switch (match.group(2)!) { + 's' => Duration(seconds: value), + 'm' => Duration(minutes: value), + 'h' => Duration(hours: value), + 'd' => Duration(days: value), + _ => throw FormatException('Invalid duration unit: $input'), + }; +} + +int _resolvePositiveInt({ + required int? value, + required int fallback, + required String fieldName, +}) { + final resolved = value ?? fallback; + if (resolved < 1) { + throw FormatException( + '$fieldName must be an integer greater than or equal to 1.', + ); + } + return resolved; +} + String _expandHome(String path) { if (!path.startsWith('~/')) { return p.normalize(p.absolute(path)); @@ -510,6 +419,67 @@ String? _expandOptionalPath(String? path) { return _expandHome(path); } +void _assertCamelCaseConfigKeys(Map root) { + _assertCamelCaseMap(root, scope: ''); + + final defaults = root['defaults']; + if (defaults is Map) { + final issueAssistant = defaults['issueAssistant']; + if (issueAssistant is Map) { + _assertCamelCaseMap(issueAssistant, scope: 'defaults.issueAssistant'); + final responders = issueAssistant['responders']; + if (responders is List) { + for (final responder in responders) { + if (responder is Map) { + _assertCamelCaseMap( + responder, + scope: 'defaults.issueAssistant.responders', + ); + } + } + } + } + } + + final projects = root['projects']; + if (projects is Map) { + for (final entry in projects.entries) { + final project = entry.value; + if (project is! Map) { + continue; + } + _assertCamelCaseMap(project, scope: 'projects.${entry.key}'); + final issueAssistant = project['issueAssistant']; + if (issueAssistant is Map) { + _assertCamelCaseMap( + issueAssistant, + scope: 'projects.${entry.key}.issueAssistant', + ); + final responders = issueAssistant['responders']; + if (responders is List) { + for (final responder in responders) { + if (responder is Map) { + _assertCamelCaseMap( + responder, + scope: 'projects.${entry.key}.issueAssistant.responders', + ); + } + } + } + } + } + } +} + +void _assertCamelCaseMap(Map map, {required String scope}) { + for (final key in map.keys) { + if (key.contains('_')) { + final qualified = scope.isEmpty ? key : '$scope.$key'; + throw FormatException('Unsupported config field: $qualified'); + } + } +} + String _deriveSessionPrefix(String key) { if (key.contains('-')) { return key @@ -532,4 +502,148 @@ String _deriveSessionPrefix(String key) { return key.substring(0, key.length < 3 ? key.length : 3).toLowerCase(); } -enum _ConfigDialect { current, aoSubset } +class _InitialConfigRenderer { + String render(AppConfigDocument document) { + final root = document.toJson(); + final defaultsSection = root.remove('defaults') as Map?; + final projectsSection = root.remove('projects') as Map?; + final assistant = document.defaults?.issueAssistant; + + return ''' +# Starter config for code_work_spawner. +# Required fields are active. Optional settings are shown as commented examples. +# +# Update the sample repo, path, and responder command before running the app. + +${_YamlEmitter().write(root)} + +${_YamlEmitter().write({'defaults': defaultsSection})} + + # capabilities: ["planning", "bug_verification"] + # commentTag: code-work-spawner + # commentPrefixTemplate: | +${_commentBlock(defaultCommentPrefixTemplate, indent: ' # ')} + # commentFooterTemplate: | +${_commentBlock(defaultCommentFooterTemplate, indent: ' # ')} + # prompt: | +${_commentBlock(defaultIssueAssistantPrompt, indent: ' # ')} + # responders: + # - id: codex + # command: codex + # args: ["exec", "--json"] + # timeout: 10m + # - id: opencode + # command: opencode + # timeout: 10m + +${_YamlEmitter().write({'projects': projectsSection})} + + # issueAssistant: + # enabled: ${assistant?.enabled ?? true} + # mentionTriggers: ["@helper"] + # sessionPrefix: sample +''' + .trimRight(); + } + + String _commentBlock(String value, {required String indent}) { + return value.split('\n').map((line) => '$indent$line').join('\n'); + } +} + +class _YamlEmitter { + String write(Map map) { + final buffer = StringBuffer(); + _writeMap(buffer, map, 0); + return buffer.toString().trimRight(); + } + + void _writeMap(StringBuffer buffer, Map map, int indent) { + final prefix = ' ' * indent; + for (final entry in map.entries) { + final value = entry.value; + if (value is Map) { + buffer.writeln('$prefix${entry.key}:'); + _writeMap(buffer, value, indent + 2); + continue; + } + if (value is List) { + if (value.isEmpty) { + buffer.writeln('$prefix${entry.key}: []'); + continue; + } + if (value.every((item) => item is! Map && item is! List)) { + final rendered = value.map(_renderScalar).join(', '); + buffer.writeln('$prefix${entry.key}: [$rendered]'); + continue; + } + buffer.writeln('$prefix${entry.key}:'); + _writeList(buffer, value, indent + 2); + continue; + } + if (value is String && value.contains('\n')) { + buffer.writeln('$prefix${entry.key}: |'); + for (final line in value.split('\n')) { + buffer.writeln('${' ' * (indent + 2)}$line'); + } + continue; + } + buffer.writeln('$prefix${entry.key}: ${_renderScalar(value)}'); + } + } + + void _writeList(StringBuffer buffer, List values, int indent) { + final prefix = ' ' * indent; + for (final value in values) { + if (value is Map) { + final entries = value.entries.toList(growable: false); + if (entries.isEmpty) { + buffer.writeln('$prefix- {}'); + continue; + } + final first = entries.first; + buffer.writeln( + '$prefix- ${first.key}: ${_renderInlineValue(first.value)}', + ); + if (entries.length > 1) { + _writeMap( + buffer, + Map.fromEntries(entries.skip(1)), + indent + 2, + ); + } + continue; + } + buffer.writeln('$prefix- ${_renderScalar(value)}'); + } + } + + String _renderInlineValue(Object? value) { + if (value is String && value.contains('\n')) { + return '|'; + } + if (value is List && value.every((item) => item is! Map && item is! List)) { + return '[${value.map(_renderScalar).join(', ')}]'; + } + return _renderScalar(value); + } + + String _renderScalar(Object? value) { + if (value is String) { + if (value.isEmpty) { + return '""'; + } + if (RegExp(r'^[A-Za-z0-9_./{}\-]+$').hasMatch(value)) { + return value; + } + return jsonEncode(value); + } + if (value is bool || value is num) { + return value.toString(); + } + if (value == null) { + return 'null'; + } + return jsonEncode(value); + } +} diff --git a/lib/src/config_schema.dart b/lib/src/config_schema.dart new file mode 100644 index 0000000..f017440 --- /dev/null +++ b/lib/src/config_schema.dart @@ -0,0 +1,146 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'config_schema.g.dart'; + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: true, + explicitToJson: true, + includeIfNull: false, +) +class AppConfigDocument { + const AppConfigDocument({ + this.dataDir, + this.worktreeDir, + this.defaults, + this.projects, + }); + + final String? dataDir; + final String? worktreeDir; + final AppConfigDefaultsDocument? defaults; + final Map? projects; + + factory AppConfigDocument.fromJson(Map json) => + _$AppConfigDocumentFromJson(json); + + Map toJson() => _$AppConfigDocumentToJson(this); +} + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: true, + explicitToJson: true, + includeIfNull: false, +) +class AppConfigDefaultsDocument { + const AppConfigDefaultsDocument({this.issueAssistant}); + + final IssueAssistantConfigDocument? issueAssistant; + + factory AppConfigDefaultsDocument.fromJson(Map json) => + _$AppConfigDefaultsDocumentFromJson(json); + + Map toJson() => _$AppConfigDefaultsDocumentToJson(this); +} + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: true, + explicitToJson: true, + includeIfNull: false, +) +class ProjectConfigDocument { + const ProjectConfigDocument({ + this.repo, + this.path, + this.defaultBranch, + this.sessionPrefix, + this.issueAssistant, + }); + + final String? repo; + final String? path; + final String? defaultBranch; + final String? sessionPrefix; + final IssueAssistantConfigDocument? issueAssistant; + + factory ProjectConfigDocument.fromJson(Map json) => + _$ProjectConfigDocumentFromJson(json); + + Map toJson() => _$ProjectConfigDocumentToJson(this); +} + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: true, + explicitToJson: true, + includeIfNull: false, +) +class IssueAssistantConfigDocument { + const IssueAssistantConfigDocument({ + this.enabled, + this.pollInterval, + this.maxConcurrentIssues, + this.mentionTriggers, + this.prompt, + this.responders, + this.capabilities, + this.commentTag, + this.commentPrefixTemplate, + this.commentFooterTemplate, + }); + + final bool? enabled; + final String? pollInterval; + final int? maxConcurrentIssues; + final List? mentionTriggers; + final String? prompt; + final List? responders; + final List? capabilities; + final String? commentTag; + final String? commentPrefixTemplate; + final String? commentFooterTemplate; + + factory IssueAssistantConfigDocument.fromJson(Map json) => + _$IssueAssistantConfigDocumentFromJson(json); + + Map toJson() => _$IssueAssistantConfigDocumentToJson(this); +} + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: true, + explicitToJson: true, + includeIfNull: false, +) +class CliResponderConfigDocument { + const CliResponderConfigDocument({ + this.id, + this.command, + this.args, + this.env, + this.stdinTemplate, + this.timeout, + }); + + final String? id; + final String? command; + final List? args; + final Map? env; + final String? stdinTemplate; + final String? timeout; + + factory CliResponderConfigDocument.fromJson(Map json) => + _$CliResponderConfigDocumentFromJson(json); + + Map toJson() => _$CliResponderConfigDocumentToJson(this); +} + +@JsonEnum(fieldRename: FieldRename.snake) +enum AssistantCapability { planning, bugVerification } diff --git a/pubspec.yaml b/pubspec.yaml index 628a65e..1bc338b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: crypto: ^3.0.6 drift: ^2.28.2 file: ^7.0.1 + json_annotation: ^4.11.0 path: ^1.9.0 sqlite3: ^2.9.3 yaml: ^3.1.3 @@ -24,5 +25,6 @@ dependencies: dev_dependencies: build_runner: ^2.6.0 drift_dev: ^2.28.1 + json_serializable: ^6.11.1 lints: ^6.0.0 test: ^1.25.6 diff --git a/test/app_config_test.dart b/test/app_config_test.dart index 341b4c3..8ea3910 100644 --- a/test/app_config_test.dart +++ b/test/app_config_test.dart @@ -18,13 +18,13 @@ void main() { group('AppConfig.load', () { /// ```gherkin /// Scenario: Merge defaults with project overrides - /// Given AO style config with shared defaults and one project override + /// Given camelCase config with shared defaults and one project override /// When loading the app config from disk /// Then the project keeps inherited defaults that were not overridden /// And the project-specific prompt replaces the shared prompt /// ``` test('Merge defaults with project overrides', () async { - // Given AO style config with shared defaults and one project override. + // Given camelCase config with shared defaults and one project override. final tempDir = await Directory.systemTemp.createTemp('cws-config-'); final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); await configFile.writeAsString(''' @@ -110,12 +110,12 @@ projects: /// ```gherkin /// Scenario: Override visible comment templates per project - /// Given AO style config with shared defaults and project-specific comment templates + /// Given camelCase config with shared defaults and project-specific comment templates /// When loading the app config from disk /// Then the project-specific comment templates replace the shared defaults /// ``` test('Override visible comment templates per project', () async { - // Given AO style config with shared defaults and project-specific comment templates. + // Given camelCase config with shared defaults and project-specific comment templates. final tempDir = await Directory.systemTemp.createTemp( 'cws-comment-template-', ); @@ -182,26 +182,28 @@ projects: }); /// ```gherkin - /// Scenario: Load AO subset config with shared worktree root - /// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults + /// Scenario: Load camelCase config with shared worktree root + /// Given camelCase config with dataDir, worktreeDir, and project settings /// When loading the app config from disk /// Then the app exposes the configured data and worktree directories - /// And each project inherits the supported shared defaults + /// And each project keeps the configured camelCase settings /// ``` - test('Load AO subset config with shared worktree root', () async { - // Given an AO style shortcut config with dataDir, worktreeDir, and project defaults. - final tempDir = await Directory.systemTemp.createTemp('cws-ao-subset-'); + test('Load camelCase config with shared worktree root', () async { + // Given camelCase config with dataDir, worktreeDir, and project settings. + final tempDir = await Directory.systemTemp.createTemp('cws-camel-case-'); final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); await configFile.writeAsString(''' dataDir: ~/.agent-orchestrator worktreeDir: ~/.worktrees defaults: - defaultBranch: trunk - sessionPrefix: ao + issueAssistant: + enabled: false projects: sample: repo: owner/sample path: ${tempDir.path} + defaultBranch: trunk + sessionPrefix: ao '''); // When loading the app config from disk. @@ -217,31 +219,31 @@ projects: p.join(Platform.environment['HOME']!, '.worktrees'), ); - // And each project inherits the supported shared defaults. + // And each project keeps the configured camelCase settings. expect(config.projects['sample'], isNotNull); expect(config.projects['sample']!.defaultBranch, 'trunk'); expect(config.projects['sample']!.sessionPrefix, 'ao'); - expect(config.projects['sample']!.issueAssistant.enabled, isTrue); + expect(config.projects['sample']!.issueAssistant.enabled, isFalse); }); /// ```gherkin - /// Scenario: Reject unsupported AO fields - /// Given an AO style config that includes an unsupported field + /// Scenario: Reject snake_case config keys + /// Given a config that still uses snake_case keys /// When loading the app config from disk /// Then loading fails with a clear config error /// ``` - test('Reject unsupported AO fields', () async { - // Given an AO style config that includes an unsupported field. - final tempDir = await Directory.systemTemp.createTemp('cws-ao-invalid-'); + test('Reject snake_case config keys', () async { + // Given a config that still uses snake_case keys. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-snake-case-invalid-', + ); final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); await configFile.writeAsString(''' -worktreeDir: ~/.worktrees +worktree_dir: ~/.worktrees projects: sample: repo: owner/sample path: ${tempDir.path} - tracker: - plugin: github '''); // When loading the app config from disk. @@ -254,7 +256,7 @@ projects: isA().having( (error) => error.message, 'message', - contains('Unsupported config field: projects.sample.tracker'), + contains('worktree_dir'), ), ), ); @@ -294,5 +296,110 @@ projects: ), ); }); + + /// ```gherkin + /// Scenario: Generate a starter config that parses as valid YAML + /// Given the built-in initial config renderer + /// When writing the generated starter config to disk + /// Then the file parses through the normal app config loader + /// And the required starter values stay available to users + /// ``` + test('Generate a starter config that parses as valid YAML', () async { + // Given the built-in initial config renderer. + final tempDir = await Directory.systemTemp.createTemp('cws-init-config-'); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + final contents = AppConfig.renderInitialConfig(); + + // When writing the generated starter config to disk. + await configFile.writeAsString(contents); + final config = await AppConfig.load(configFile.path); + + // Then the file parses through the normal app config loader. + expect(config.projects['sample'], isNotNull); + + // And the required starter values stay available to users. + expect( + config.dataDir, + p.join(Platform.environment['HOME']!, '.agent-orchestrator'), + ); + expect( + config.worktreeDir, + p.join(Platform.environment['HOME']!, '.worktrees'), + ); + expect(config.projects['sample']!.repo, 'owner/repo'); + expect( + config.projects['sample']!.path, + p.join(Platform.environment['HOME']!, 'path/to/repo'), + ); + expect(config.projects['sample']!.defaultBranch, 'main'); + expect(config.projects['sample']!.issueAssistant.mentionTriggers, [ + '@codex', + '@helper', + ]); + }); + + /// ```gherkin + /// Scenario: Parse uncommented optional starter fields + /// Given a generated starter config with optional example lines uncommented + /// When loading that config through the normal parser + /// Then the uncommented optional values are accepted + /// And the parsed config reflects the example optional settings + /// ``` + test('Parse uncommented optional starter fields', () async { + // Given a generated starter config with optional example lines uncommented. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-init-optional-', + ); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + final contents = AppConfig.renderInitialConfig() + .replaceFirst( + ' # capabilities: ["planning", "bug_verification"]', + ' capabilities: ["planning", "bug_verification"]', + ) + .replaceFirst( + ' # commentTag: code-work-spawner', + ' commentTag: code-work-spawner', + ) + .replaceFirst(' # responders:', ' responders:') + .replaceFirst(' # - id: codex', ' - id: codex') + .replaceFirst(' # command: codex', ' command: codex') + .replaceFirst( + ' # args: ["exec", "--json"]', + ' args: ["exec", "--json"]', + ) + .replaceFirst(' # timeout: 10m', ' timeout: 10m') + .replaceFirst(' # issueAssistant:', ' issueAssistant:') + .replaceFirst(' # enabled: true', ' enabled: true') + .replaceFirst( + ' # mentionTriggers: ["@helper"]', + ' mentionTriggers: ["@helper"]', + ) + .replaceFirst( + ' # sessionPrefix: sample', + ' sessionPrefix: sample', + ); + await configFile.writeAsString(contents); + + // When loading that config through the normal parser. + final config = await AppConfig.load(configFile.path); + + // Then the uncommented optional values are accepted. + expect(config.projects['sample'], isNotNull); + + // And the parsed config reflects the example optional settings. + expect(config.defaults.commentTag, 'code-work-spawner'); + expect(config.defaults.responders.single.id, 'codex'); + expect( + config.defaults.capabilities, + containsAll({ + AssistantCapability.planning, + AssistantCapability.bugVerification, + }), + ); + expect(config.projects['sample']!.sessionPrefix, 'sample'); + expect(config.projects['sample']!.issueAssistant.mentionTriggers, [ + '@helper', + ]); + }); }); } diff --git a/test/init_config_cli_test.dart b/test/init_config_cli_test.dart new file mode 100644 index 0000000..5ec3238 --- /dev/null +++ b/test/init_config_cli_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:code_work_spawner/code_work_spawner.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + /// ```gherkin + /// Feature: Init-config CLI + /// + /// As a maintainer setting up code_work_spawner + /// I want a starter config command that can print or write a valid template + /// So that I can bootstrap configuration without reading the code first + /// ``` + group('code_work_spawner init-config', () { + /// ```gherkin + /// Scenario: Print the starter config to stdout + /// Given the CLI is executed with the init-config command + /// When the process completes successfully + /// Then stdout contains the starter config content + /// And the printed config is valid when written to disk and loaded + /// ``` + test('Print the starter config to stdout', () async { + // Given the CLI is executed with the init-config command. + final sandbox = await Directory.systemTemp.createTemp('cws-cli-stdout-'); + final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); + final result = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/code_work_spawner.dart', + 'init-config', + ], workingDirectory: Directory.current.path); + + // When the process completes successfully. + expect(result.exitCode, 0, reason: '${result.stderr}'); + + // Then stdout contains the starter config content. + final stdoutText = result.stdout as String; + expect(stdoutText, contains('dataDir:')); + expect(stdoutText, contains('# responders:')); + expect(stdoutText, contains('projects:')); + + // And the printed config is valid when written to disk and loaded. + await configFile.writeAsString(stdoutText); + final config = await AppConfig.load(configFile.path); + expect(config.projects['sample'], isNotNull); + }); + + /// ```gherkin + /// Scenario: Refuse overwrite unless force is provided + /// Given an existing output file for the init-config command + /// When the CLI writes without the force flag and then with the force flag + /// Then the first run fails with an overwrite error + /// And the forced run replaces the file with a valid starter config + /// ``` + test('Refuse overwrite unless force is provided', () async { + // Given an existing output file for the init-config command. + final sandbox = await Directory.systemTemp.createTemp('cws-cli-force-'); + final outputFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); + await outputFile.writeAsString('original'); + + // When the CLI writes without the force flag and then with the force flag. + final withoutForce = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/code_work_spawner.dart', + 'init-config', + '--output', + outputFile.path, + ], workingDirectory: Directory.current.path); + final withForce = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/code_work_spawner.dart', + 'init-config', + '--output', + outputFile.path, + '--force', + ], workingDirectory: Directory.current.path); + + // Then the first run fails with an overwrite error. + expect(withoutForce.exitCode, 2); + expect( + withoutForce.stderr as String, + contains('Refusing to overwrite existing file'), + ); + + // And the forced run replaces the file with a valid starter config. + expect(withForce.exitCode, 0, reason: '${withForce.stderr}'); + final contents = await outputFile.readAsString(); + expect(contents, contains('defaults:')); + expect(contents, contains('projects:')); + final config = await AppConfig.load(outputFile.path); + expect(config.projects['sample'], isNotNull); + }); + }); +}