From 4763852db0bebaa9672c468d1a06e8715f04eb57 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 25 Mar 2026 17:18:42 +0800 Subject: [PATCH] tooling: add external acp config helper --- docs/howto/external-acp-bridge-config.md | 210 +++++++++ tool/configure_external_acp.dart | 533 +++++++++++++++++++++++ 2 files changed, 743 insertions(+) create mode 100644 docs/howto/external-acp-bridge-config.md create mode 100644 tool/configure_external_acp.dart diff --git a/docs/howto/external-acp-bridge-config.md b/docs/howto/external-acp-bridge-config.md new file mode 100644 index 00000000..4563e55d --- /dev/null +++ b/docs/howto/external-acp-bridge-config.md @@ -0,0 +1,210 @@ +# 外部 ACP 接入配置脚本 + +这个 how-to 对应仓库内的独立工具: + +```bash +dart tool/configure_external_acp.dart +``` + +它只做一件事:生成或更新 XWorkmate 本地 `settings.yaml` 里的 `externalAcpEndpoints`,不改 Flutter 运行时代码,不碰 secrets。 + +前提: + +- 在仓库根目录执行。 +- 首次在新 clone 上使用前,先跑一次 `flutter pub get`,确保 `.dart_tool/package_config.json` 已生成。 + +## App Store 对齐约束 + +这次工具链按 App Store 分发边界设计,约束如下: + +- 这是仓库外置脚本,不是 app bundle 内功能。 +- `codex`、`opencode`、`supergateway`、`mcp-bridge`、`gemini-bridge` 都必须作为用户侧或运维侧前置依赖,不能打包进 XWorkmate 的 App Store 构建产物。 +- 脚本只改用户态 `settings.yaml`,不改 entitlements、不改打包脚本、不往 DMG / `.app` bundle 写入任何第三方二进制或 secrets。 +- 外部 bridge 进程必须由用户在安装后手动启动;这次方案不要求、也不允许 App Store 包内自动拉起这些进程。 + +## 默认 provider 映射 + +脚本默认会把 4 个内置 provider 写成下面的 endpoint: + +| Provider | Endpoint | 约定启动方式 | +| --- | --- | --- | +| Codex | `ws://127.0.0.1:9001` | `codex app-server --listen ws://127.0.0.1:9001` | +| OpenCode | `http://127.0.0.1:4096` | `opencode serve --port 4096` | +| Claude | `ws://127.0.0.1:9011` | 本地 bridge 槽位 | +| Gemini | `ws://127.0.0.1:9012` | 本地 bridge 槽位 | + +注意: + +- 这里把 OpenCode 固定写成 `127.0.0.1:4096`。如果你之前记成 `27.0.0.1:4096`,这里应为 loopback 地址 `127.0.0.1:4096`。 +- 当前 XWorkmate external ACP 路径保存的是 provider endpoint;脚本负责“写配置”,不负责替你守护进程。 +- `settings.yaml` 是非敏感配置源。token、password、API key 仍不应该写进去。 + +## 默认路径 + +脚本默认按宿主平台定位 `settings.yaml`: + +- macOS: `~/Library/Application Support/xworkmate/config/settings.yaml` +- Linux: `${XDG_CONFIG_HOME:-~/.config}/xworkmate/config/settings.yaml` +- Windows: `%APPDATA%\\xworkmate\\config\\settings.yaml` + +也可以显式传 `--settings-file` 覆盖。 + +## 常用命令 + +先只看计划,不落盘: + +```bash +dart tool/configure_external_acp.dart print +``` + +按默认 endpoint 落盘,并自动备份原文件: + +```bash +dart tool/configure_external_acp.dart apply +``` + +指定自定义 endpoint: + +```bash +dart tool/configure_external_acp.dart apply \ + --claude-endpoint ws://127.0.0.1:19111 \ + --gemini-endpoint ws://127.0.0.1:19112 +``` + +只打印将要写入的 YAML,不真正写文件: + +```bash +dart tool/configure_external_acp.dart apply --dry-run +``` + +禁用某个 provider 槽位: + +```bash +dart tool/configure_external_acp.dart apply --disable-claude +``` + +## 原生 provider + +### Codex + +启动: + +```bash +codex app-server --listen ws://127.0.0.1:9001 +``` + +写配置: + +```bash +dart tool/configure_external_acp.dart apply \ + --codex-endpoint ws://127.0.0.1:9001 +``` + +### OpenCode + +启动: + +```bash +opencode serve --port 4096 +``` + +写配置: + +```bash +dart tool/configure_external_acp.dart apply \ + --opencode-endpoint http://127.0.0.1:4096 +``` + +说明: + +- 这是按当前约定写入的原生 OpenCode endpoint。 +- XWorkmate 当前 single-agent 运行时会把 `http/https` endpoint 归一化成 `ws/wss` 连接再发 JSON-RPC 请求,所以 OpenCode 端是否完全兼容,取决于你前面的桥是否提供了 XWorkmate 当前期望的方法集。 +- 这次变更不改项目代码,因此这里只负责把槽位写好,不额外实现 OpenCode 协议适配。 + +## 桥接 provider + +### Gemini + +你给出的要求是: + +1. 本地起一个 `stdio` MCP server +2. 再把它桥到 XWorkmate 使用的本地 endpoint + +推荐的最小链路: + +1. 安装 Gemini CLI 并登录 +2. 安装 `gemini-bridge` +3. 用 `supergateway` 把本地 `stdio` MCP server 暴露到 WebSocket 端口 + +参考命令: + +```bash +npm install -g @google/gemini-cli +gemini auth login +pip install gemini-bridge +npx -y supergateway \ + --stdio "uvx gemini-bridge" \ + --outputTransport ws \ + --port 9012 \ + --messagePath / +``` + +然后写入: + +```bash +dart tool/configure_external_acp.dart apply \ + --gemini-endpoint ws://127.0.0.1:9012 +``` + +### Claude + +你给出的要求同样是: + +1. 本地起一个 `stdio` bridge +2. 再桥到 XWorkmate 的本地 endpoint + +按这次文档约定,Claude 槽位使用: + +- 本地 bridge 命令:`mcp-bridge` +- WebSocket 暴露:`supergateway` +- 本地 endpoint:`ws://127.0.0.1:9011` + +参考命令: + +```bash +pip install mcp-bridge +mcp-bridge init +``` + +编辑: + +```text +~/.config/mcp-bridge/config.json +``` + +填入你的远程 MCP URL 和认证信息后,再启动本地桥: + +```bash +npx -y supergateway \ + --stdio "mcp-bridge" \ + --outputTransport ws \ + --port 9011 \ + --messagePath / +``` + +然后写入: + +```bash +dart tool/configure_external_acp.dart apply \ + --claude-endpoint ws://127.0.0.1:9011 +``` + +## 兼容性边界 + +这次提交只补“配置脚本 + 使用说明”,不改 XWorkmate runtime,所以边界需要说清楚: + +- 脚本保证 `externalAcpEndpoints` 写法正确,并保留非内置 custom provider 条目。 +- 脚本不会自动拉起 `codex`、`opencode`、`supergateway`、`mcp-bridge` 或 `gemini-bridge`。 +- 脚本不会写入任何 secret。 +- Claude / Gemini 这里走的是“本地 bridge endpoint 槽位”方案。是否能被当前 XWorkmate runtime 直接消费,取决于桥后的协议是否与当前 external ACP 路径兼容。 +- 如果后续要把 Claude / Gemini 做成真正的开箱即用 provider,需要再补一层项目内协议适配;这次明确不做。 diff --git a/tool/configure_external_acp.dart b/tool/configure_external_acp.dart new file mode 100644 index 00000000..9e810623 --- /dev/null +++ b/tool/configure_external_acp.dart @@ -0,0 +1,533 @@ +import 'dart:io'; + +import 'package:yaml/yaml.dart'; + +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', +}; + +const Map _defaultLaunchCommands = { + 'codex': 'codex app-server --listen ws://127.0.0.1:9001', + 'opencode': 'opencode serve --port 4096', + 'claude': + 'npx -y supergateway --stdio "mcp-bridge" --outputTransport ws --port 9011 --messagePath /', + 'gemini': + 'npx -y supergateway --stdio "uvx gemini-bridge" --outputTransport ws --port 9012 --messagePath /', +}; + +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, + ); + 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', + ), + ); + 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', + ), + ); +} + +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 and suggested launch commands. + +Options: + --settings-file Override settings.yaml path. + --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. + - App Store-safe usage means running this tool outside the shipped app bundle + and keeping Codex/OpenCode/Supergateway/MCP bridge binaries outside the + bundle as user-managed prerequisites. + - Default macOS settings path: + ~/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, +}) { + final buffer = StringBuffer() + ..writeln() + ..writeln('Settings file: ${settingsFile.path}') + ..writeln('Mode: $modeLabel') + ..writeln('Provider endpoint plan:'); + + for (final provider in _providerLabels.keys) { + buffer.writeln('- ${_providerLabels[provider]}: ${endpoints[provider]}'); + buffer.writeln(' launch: ${_defaultLaunchCommands[provider]}'); + } + + buffer + ..writeln() + ..writeln('Bridge notes:') + ..writeln( + '- Codex is configured for native app-server WebSocket on port 9001.', + ) + ..writeln( + '- OpenCode is configured to the native HTTP server on port 4096.', + ) + ..writeln( + '- Claude and Gemini are assigned local bridge slots; see docs/howto/external-acp-bridge-config.md for install and compatibility notes.', + ) + ..writeln( + '- App Store alignment: all bridge binaries stay outside the app bundle and must be started manually after install.', + ); + 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, +}) { + final home = environment['HOME']?.trim() ?? ''; + if (operatingSystem == 'macos' && home.isNotEmpty) { + return File( + '$home/Library/Application Support/xworkmate/config/settings.yaml', + ); + } + 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 } + +class _CliOptions { + const _CliOptions({ + required this.command, + required this.showHelp, + required this.dryRun, + required this.backup, + required this.settingsFile, + required this.endpoints, + required this.enableProviders, + }); + + final _Command command; + final bool showHelp; + final bool dryRun; + final bool backup; + final File? settingsFile; + 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, + 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; + 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, + 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 '--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, + 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'"; +}