import 'dart:io'; import 'package:yaml/yaml.dart'; const String _macosBundleIdentifier = 'plus.svc.xworkmate'; const Map _providerLabels = { 'codex': 'Codex', 'opencode': 'OpenCode', 'claude': 'Claude', 'gemini': 'Gemini', }; const Map _defaultEndpoints = { 'codex': 'ws://127.0.0.1:9001', 'opencode': 'http://127.0.0.1:4096', 'claude': 'ws://127.0.0.1:9011', 'gemini': 'ws://127.0.0.1:9012', }; void main(List args) async { final options = _CliOptions.parse(args); if (options.showHelp) { stdout.write(_usage()); exit(0); } final settingsFile = options.settingsFile ?? _defaultSettingsFile( environment: Platform.environment, operatingSystem: Platform.operatingSystem, scope: options.settingsScope, ); final resolvedEndpoints = { for (final entry in _defaultEndpoints.entries) entry.key: options.endpoints[entry.key] ?? entry.value, }; if (options.command == _Command.printPlan) { stdout.write( _renderPlan( settingsFile: settingsFile, endpoints: resolvedEndpoints, modeLabel: 'print-only', settingsScope: options.settingsScope, ), ); return; } final existing = await _readExistingSettings(settingsFile); final updated = _mergeExternalAcpEndpoints( existing, endpoints: resolvedEndpoints, enableProviders: options.enableProviders, ); if (options.dryRun) { stdout.write(encodeYamlDocument(updated)); return; } await settingsFile.parent.create(recursive: true); if (await settingsFile.exists() && options.backup) { final backupFile = File( '${settingsFile.path}.bak.${DateTime.now().toUtc().millisecondsSinceEpoch}', ); await settingsFile.copy(backupFile.path); stdout.writeln('Backup written: ${backupFile.path}'); } await settingsFile.writeAsString(encodeYamlDocument(updated)); stdout.writeln('Updated: ${settingsFile.path}'); stdout.write( _renderPlan( settingsFile: settingsFile, endpoints: resolvedEndpoints, modeLabel: 'applied', settingsScope: options.settingsScope, ), ); } String _usage() { return ''' Usage: dart tool/configure_external_acp.dart apply [options] dart tool/configure_external_acp.dart print [options] Commands: apply Update XWorkmate settings.yaml externalAcpEndpoints. print Print the resolved endpoint plan. Options: --settings-file Override settings.yaml path. --settings-scope macOS only: auto | sandbox | user. --codex-endpoint Default: ${_defaultEndpoints['codex']} --opencode-endpoint Default: ${_defaultEndpoints['opencode']} --claude-endpoint Default: ${_defaultEndpoints['claude']} --gemini-endpoint Default: ${_defaultEndpoints['gemini']} --disable-codex Mark the Codex slot as disabled. --disable-opencode Mark the OpenCode slot as disabled. --disable-claude Mark the Claude slot as disabled. --disable-gemini Mark the Gemini slot as disabled. --no-backup Skip settings.yaml backup on apply. --dry-run Print the resulting YAML instead of writing it. --help Show this help. Notes: - This tool only updates the externalAcpEndpoints block and preserves all other settings keys. - This is a pre-config tool. Starting external providers is out of scope. - App Store-safe usage means running this tool outside the shipped app bundle. - macOS path selection with --settings-scope auto: ~/Library/Containers/$_macosBundleIdentifier/Data/Library/Application Support/xworkmate/config/settings.yaml falls back to ~/Library/Application Support/xworkmate/config/settings.yaml - Default Linux settings path: ~/.config/xworkmate/config/settings.yaml '''; } String _renderPlan({ required File settingsFile, required Map endpoints, required String modeLabel, required _SettingsScope settingsScope, }) { final buffer = StringBuffer() ..writeln() ..writeln('Settings file: ${settingsFile.path}') ..writeln('Mode: $modeLabel') ..writeln('Settings scope: ${settingsScope.name}') ..writeln('Provider endpoint plan:'); for (final provider in _providerLabels.keys) { buffer.writeln('- ${_providerLabels[provider]}: ${endpoints[provider]}'); } buffer ..writeln() ..writeln('Scope notes:') ..writeln( '- This tool configures endpoint slots only. Provider launch and bridge orchestration stay external to the app.', ) ..writeln( '- On macOS, auto scope prefers the App Sandbox container after the app has launched at least once.', ) ..writeln( '- App Store alignment: no external runtime binary is bundled or auto-started by this tool.', ) ..writeln( '- Claude and Gemini remain plain endpoint slots; this tool no longer prescribes any third-party bridge package.', ) ..writeln( '- Codex and OpenCode defaults are retained as local endpoint examples.', ); return buffer.toString(); } Map _mergeExternalAcpEndpoints( Map existing, { required Map endpoints, required Map enableProviders, }) { final updated = Map.from(existing); final incomingProfiles = (existing['externalAcpEndpoints'] is List) ? List.from(existing['externalAcpEndpoints'] as List) : []; final byKey = >{}; final extras = >[]; for (final item in incomingProfiles) { if (item is! Map) { continue; } final profile = item.map( (Object? key, Object? value) => MapEntry(key.toString(), value), ); final providerKey = profile['providerKey']?.toString().trim().toLowerCase() ?? ''; if (_providerLabels.containsKey(providerKey)) { byKey[providerKey] = profile; } else if (providerKey.isNotEmpty) { extras.add(profile); } } final builtins = >[ for (final provider in _providerLabels.keys) { ...?byKey[provider], 'providerKey': provider, 'label': _providerLabels[provider], 'endpoint': endpoints[provider] ?? '', 'enabled': enableProviders[provider] ?? true, }, ]; updated['externalAcpEndpoints'] = [...builtins, ...extras]; return updated; } Future> _readExistingSettings(File settingsFile) async { if (!await settingsFile.exists()) { return {}; } try { final raw = await settingsFile.readAsString(); final decoded = decodeYamlDocument(raw); if (decoded is Map) { return decoded; } if (decoded is Map) { return decoded.map( (Object? key, Object? value) => MapEntry(key.toString(), value), ); } } catch (error) { stderr.writeln( 'Warning: failed to parse ${settingsFile.path}; starting from an empty map. $error', ); } return {}; } File _defaultSettingsFile({ required Map environment, required String operatingSystem, required _SettingsScope scope, }) { final home = environment['HOME']?.trim() ?? ''; if (operatingSystem == 'macos' && home.isNotEmpty) { final sandboxContainer = Directory( '$home/Library/Containers/$_macosBundleIdentifier', ); final sandboxed = File( '${sandboxContainer.path}/Data/Library/Application Support/xworkmate/config/settings.yaml', ); final userScoped = File( '$home/Library/Application Support/xworkmate/config/settings.yaml', ); return switch (scope) { _SettingsScope.sandbox => sandboxed, _SettingsScope.user => userScoped, _SettingsScope.auto => sandboxContainer.existsSync() ? sandboxed : userScoped, }; } if (operatingSystem == 'linux' && home.isNotEmpty) { final xdgConfigHome = environment['XDG_CONFIG_HOME']?.trim() ?? ''; final base = xdgConfigHome.isNotEmpty ? xdgConfigHome : '$home/.config'; return File('$base/xworkmate/config/settings.yaml'); } if (operatingSystem == 'windows') { final appData = environment['APPDATA']?.trim() ?? ''; if (appData.isNotEmpty) { return File('$appData\\xworkmate\\config\\settings.yaml'); } final userProfile = environment['USERPROFILE']?.trim() ?? ''; if (userProfile.isNotEmpty) { return File('$userProfile\\.xworkmate\\config\\settings.yaml'); } } if (home.isNotEmpty) { return File('$home/.xworkmate/config/settings.yaml'); } return File('settings.yaml'); } enum _Command { apply, printPlan } enum _SettingsScope { auto, sandbox, user } class _CliOptions { const _CliOptions({ required this.command, required this.showHelp, required this.dryRun, required this.backup, required this.settingsFile, required this.settingsScope, required this.endpoints, required this.enableProviders, }); final _Command command; final bool showHelp; final bool dryRun; final bool backup; final File? settingsFile; final _SettingsScope settingsScope; final Map endpoints; final Map enableProviders; static _CliOptions parse(List args) { if (args.isEmpty) { return _CliOptions( command: _Command.apply, showHelp: true, dryRun: false, backup: true, settingsFile: null, settingsScope: _SettingsScope.auto, endpoints: const {}, enableProviders: const {}, ); } final normalizedCommand = switch (args.first.trim().toLowerCase()) { 'apply' => _Command.apply, 'print' => _Command.printPlan, '--help' || '-h' || 'help' => _Command.apply, _ => _Command.apply, }; final showHelp = { '--help', '-h', 'help', }.contains(args.first.trim().toLowerCase()); final rest = showHelp ? args.skip(1).toList(growable: false) : args.skip(1); var dryRun = false; var backup = true; File? settingsFile; var settingsScope = _SettingsScope.auto; final endpoints = {}; final enableProviders = {}; final values = rest.toList(growable: false); for (var index = 0; index < values.length; index += 1) { final argument = values[index].trim(); if (argument.isEmpty) { continue; } if (argument == '--help' || argument == '-h') { return _CliOptions( command: normalizedCommand, showHelp: true, dryRun: dryRun, backup: backup, settingsFile: settingsFile, settingsScope: settingsScope, endpoints: endpoints, enableProviders: enableProviders, ); } if (argument == '--dry-run') { dryRun = true; continue; } if (argument == '--no-backup') { backup = false; continue; } if (argument.startsWith('--disable-')) { final provider = argument.substring('--disable-'.length).trim(); if (_providerLabels.containsKey(provider)) { enableProviders[provider] = false; continue; } } if (!argument.startsWith('--')) { stderr.writeln('Ignoring unexpected argument: $argument'); continue; } if (index + 1 >= values.length) { throw ArgumentError('Missing value for $argument'); } final value = values[index + 1].trim(); index += 1; switch (argument) { case '--settings-file': settingsFile = File(value); break; case '--settings-scope': settingsScope = switch (value.trim().toLowerCase()) { 'sandbox' => _SettingsScope.sandbox, 'user' => _SettingsScope.user, _ => _SettingsScope.auto, }; break; case '--codex-endpoint': endpoints['codex'] = value; break; case '--opencode-endpoint': endpoints['opencode'] = value; break; case '--claude-endpoint': endpoints['claude'] = value; break; case '--gemini-endpoint': endpoints['gemini'] = value; break; default: stderr.writeln('Ignoring unknown option: $argument'); break; } } return _CliOptions( command: normalizedCommand, showHelp: showHelp, dryRun: dryRun, backup: backup, settingsFile: settingsFile, settingsScope: settingsScope, endpoints: endpoints, enableProviders: enableProviders, ); } } Object? decodeYamlDocument(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) { return null; } try { return _yamlToObject(loadYaml(trimmed)); } catch (_) { return null; } } Object? _yamlToObject(Object? value) { if (value is YamlMap) { return value.map( (Object? key, Object? item) => MapEntry(key?.toString() ?? '', _yamlToObject(item)), ); } if (value is YamlList) { return value.map(_yamlToObject).toList(growable: false); } return value; } String encodeYamlDocument(Object? value) { final buffer = StringBuffer('---\n'); _writeYamlValue(buffer, value, 0, listItem: false); if (!buffer.toString().endsWith('\n')) { buffer.writeln(); } return buffer.toString(); } void _writeYamlValue( StringBuffer buffer, Object? value, int indent, { required bool listItem, }) { final prefix = ' ' * indent; if (value is Map) { if (value.isEmpty) { if (listItem) { buffer.writeln('{}'); } else { buffer.writeln('$prefix{}'); } return; } if (listItem) { buffer.writeln(); } for (final entry in value.entries) { final key = entry.key.toString(); final item = entry.value; if (_isInlineYamlValue(item)) { buffer.writeln('$prefix$key: ${_yamlInlineValue(item)}'); } else if (item is String && item.contains('\n')) { buffer.writeln('$prefix$key: |-'); for (final line in item.split('\n')) { buffer.writeln('${' ' * (indent + 1)}$line'); } } else { buffer.writeln('$prefix$key:'); _writeYamlValue(buffer, item, indent + 1, listItem: false); } } return; } if (value is List) { if (value.isEmpty) { if (listItem) { buffer.writeln('[]'); } else { buffer.writeln('$prefix[]'); } return; } if (listItem) { buffer.writeln(); } for (final item in value) { if (_isInlineYamlValue(item)) { buffer.writeln('$prefix- ${_yamlInlineValue(item)}'); } else if (item is String && item.contains('\n')) { buffer.writeln('$prefix- |-'); for (final line in item.split('\n')) { buffer.writeln('${' ' * (indent + 1)}$line'); } } else { buffer.writeln('$prefix-'); _writeYamlValue(buffer, item, indent + 1, listItem: false); } } return; } if (listItem) { buffer.writeln(_yamlInlineValue(value)); return; } buffer.writeln('$prefix${_yamlInlineValue(value)}'); } bool _isInlineYamlValue(Object? value) { if (value == null || value is bool || value is num) { return true; } if (value is String) { return !value.contains('\n'); } if (value is List) { return value.isEmpty; } if (value is Map) { return value.isEmpty; } return false; } String _yamlInlineValue(Object? value) { if (value == null) { return 'null'; } if (value is bool || value is num) { return value.toString(); } if (value is List && value.isEmpty) { return '[]'; } if (value is Map && value.isEmpty) { return '{}'; } final stringValue = value.toString(); if (stringValue.isEmpty) { return "''"; } final safe = RegExp(r'^[A-Za-z0-9_./:@+%-]+$'); final reserved = {'null', 'true', 'false', '~'}; if (safe.hasMatch(stringValue) && !reserved.contains(stringValue)) { return stringValue; } final escaped = stringValue.replaceAll("'", "''"); return "'$escaped'"; }