feat: add `init-config` option to gen initial config file

refactor: use `json_serializable` to achieve config file deserialization
This commit is contained in:
insleker 2026-04-05 02:28:11 +08:00
parent 5d3b4ff2c1
commit 8fe0d1017f
10 changed files with 947 additions and 430 deletions

View File

@ -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

View File

@ -31,8 +31,31 @@ Future<void> main(List<String> 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<void> main(List<String> arguments) async {
}
}
Future<void> _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;

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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<String, ProjectConfig> projects;
static Future<AppConfig> 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<String, dynamic> 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<String, dynamic> 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 = <String, dynamic>{};
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<String, ProjectConfig> _loadProjects(
Object? projectsNode, {
required IssueAssistantConfig defaultAssistant,
required Map<String, dynamic> defaultAssistantMap,
}) {
if (projectsNode is! YamlMap || projectsNode.isEmpty) {
throw const FormatException('projects must be a non-empty map.');
}
final projects = <String, ProjectConfig>{};
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<String, ProjectConfig> _loadAoSubsetProjects(
Object? projectsNode, {
required Map<String, dynamic> defaultsMap,
required IssueAssistantConfig defaultAssistant,
required Map<String, dynamic> defaultAssistantMap,
}) {
if (projectsNode is! YamlMap || projectsNode.isEmpty) {
throw const FormatException('projects must be a non-empty map.');
}
final projects = <String, ProjectConfig>{};
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 = <String, dynamic>{...defaultsMap, ...value};
projects[key] = ProjectConfig.fromMap(
key: key,
map: merged,
defaultAssistant: defaultAssistant,
defaultAssistantMap: defaultAssistantMap,
);
}
return projects;
}
static _ConfigDialect _detectDialect(Map<String, dynamic> root) {
final defaultsMap = _asMap(root['defaults']);
if (defaultsMap.containsKey('issueAssistant')) {
return _ConfigDialect.current;
}
return _ConfigDialect.aoSubset;
}
static Map<String, dynamic> _asMap(Object? value) {
if (value == null) {
return <String, dynamic>{};
}
if (value is YamlMap) {
return value.map(
(key, dynamicValue) => MapEntry(key.toString(), dynamicValue),
);
}
if (value is Map<String, dynamic>) {
return value;
}
throw FormatException('Expected map, got ${value.runtimeType}.');
}
static void _validateKeys({
required String scope,
required Map<String, dynamic> map,
required Set<String> 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<String, dynamic> map,
required IssueAssistantConfig defaultAssistant,
required Map<String, dynamic> defaultAssistantMap,
}) {
final assistantMap = AppConfig._asMap(map['issueAssistant']);
final mergedAssistantMap = <String, dynamic>{
...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<String> mentionTriggers;
final String prompt;
final List<CliResponderConfig> responders;
final Set<AssistantCapability> capabilities;
final String commentTag;
final String commentPrefixTemplate;
final String commentFooterTemplate;
factory IssueAssistantConfig.fromMap(Map<String, dynamic> map) {
final respondersNode = map['responders'];
final responders = respondersNode is List
? respondersNode
.map((item) => CliResponderConfig.fromMap(AppConfig._asMap(item)))
.toList(growable: false)
: const <CliResponderConfig>[];
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 <String>[],
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<String, ProjectConfig> projects;
static Future<AppConfig> 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<String, dynamic>) {
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 = <String, ProjectConfig>{};
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<String, dynamic> 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<String> mentionTriggers;
final String prompt;
final List<CliResponderConfig> responders;
final Set<AssistantCapability> 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<String, dynamic> 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 <String>[],
);
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 <String>[]),
env: document.env ?? const <String, String>{},
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 <String>[],
prompt: overlay?.prompt ?? base?.prompt ?? defaultIssueAssistantPrompt,
responders: responders != null
? responders
.map(CliResponderConfig.fromDocument)
.toList(growable: false)
: base?.responders ?? const <CliResponderConfig>[],
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<String, dynamic> root) {
_assertCamelCaseMap(root, scope: '');
final defaults = root['defaults'];
if (defaults is Map<String, dynamic>) {
final issueAssistant = defaults['issueAssistant'];
if (issueAssistant is Map<String, dynamic>) {
_assertCamelCaseMap(issueAssistant, scope: 'defaults.issueAssistant');
final responders = issueAssistant['responders'];
if (responders is List) {
for (final responder in responders) {
if (responder is Map<String, dynamic>) {
_assertCamelCaseMap(
responder,
scope: 'defaults.issueAssistant.responders',
);
}
}
}
}
}
final projects = root['projects'];
if (projects is Map<String, dynamic>) {
for (final entry in projects.entries) {
final project = entry.value;
if (project is! Map<String, dynamic>) {
continue;
}
_assertCamelCaseMap(project, scope: 'projects.${entry.key}');
final issueAssistant = project['issueAssistant'];
if (issueAssistant is Map<String, dynamic>) {
_assertCamelCaseMap(
issueAssistant,
scope: 'projects.${entry.key}.issueAssistant',
);
final responders = issueAssistant['responders'];
if (responders is List) {
for (final responder in responders) {
if (responder is Map<String, dynamic>) {
_assertCamelCaseMap(
responder,
scope: 'projects.${entry.key}.issueAssistant.responders',
);
}
}
}
}
}
}
}
void _assertCamelCaseMap(Map<String, dynamic> 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<String, dynamic>?;
final projectsSection = root.remove('projects') as Map<String, dynamic>?;
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<String, dynamic> map) {
final buffer = StringBuffer();
_writeMap(buffer, map, 0);
return buffer.toString().trimRight();
}
void _writeMap(StringBuffer buffer, Map<String, dynamic> map, int indent) {
final prefix = ' ' * indent;
for (final entry in map.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
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<dynamic> values, int indent) {
final prefix = ' ' * indent;
for (final value in values) {
if (value is Map<String, dynamic>) {
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<String, dynamic>.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);
}
}

146
lib/src/config_schema.dart Normal file
View File

@ -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<String, ProjectConfigDocument>? projects;
factory AppConfigDocument.fromJson(Map<String, dynamic> json) =>
_$AppConfigDocumentFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$AppConfigDefaultsDocumentFromJson(json);
Map<String, dynamic> 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<String, dynamic> json) =>
_$ProjectConfigDocumentFromJson(json);
Map<String, dynamic> 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<String>? mentionTriggers;
final String? prompt;
final List<CliResponderConfigDocument>? responders;
final List<AssistantCapability>? capabilities;
final String? commentTag;
final String? commentPrefixTemplate;
final String? commentFooterTemplate;
factory IssueAssistantConfigDocument.fromJson(Map<String, dynamic> json) =>
_$IssueAssistantConfigDocumentFromJson(json);
Map<String, dynamic> 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<String>? args;
final Map<String, String>? env;
final String? stdinTemplate;
final String? timeout;
factory CliResponderConfigDocument.fromJson(Map<String, dynamic> json) =>
_$CliResponderConfigDocumentFromJson(json);
Map<String, dynamic> toJson() => _$CliResponderConfigDocumentToJson(this);
}
@JsonEnum(fieldRename: FieldRename.snake)
enum AssistantCapability { planning, bugVerification }

View File

@ -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

View File

@ -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<FormatException>().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',
]);
});
});
}

View File

@ -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);
});
});
}