415 lines
12 KiB
Dart
415 lines
12 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'platform_environment.dart';
|
|
|
|
/// Bridge for generating Codex configuration files.
|
|
///
|
|
/// This class generates `~/.codex/config.toml` and `~/.codex/auth.json`
|
|
/// to configure Codex CLI to use XWorkmate's AI Gateway.
|
|
class CodexConfigBridge {
|
|
static const String _managedBlockStart = '# BEGIN XWORKMATE MANAGED BLOCK';
|
|
static const String _managedBlockEnd = '# END XWORKMATE MANAGED BLOCK';
|
|
static const String _managedMcpBlockStart =
|
|
'# BEGIN XWORKMATE MANAGED MCP BLOCK';
|
|
static const String _managedMcpBlockEnd =
|
|
'# END XWORKMATE MANAGED MCP BLOCK';
|
|
|
|
final String codexHome;
|
|
|
|
CodexConfigBridge({String? codexHome})
|
|
: codexHome = codexHome ?? resolveCodexHomeDirectory();
|
|
|
|
/// Generate config.toml to use XWorkmate AI Gateway.
|
|
Future<void> configureForGateway({
|
|
required String gatewayUrl,
|
|
required String apiKey,
|
|
String providerName = 'xworkmate',
|
|
String defaultModel = 'gpt-4.1',
|
|
CodexSandboxMode sandbox = CodexSandboxMode.workspaceWrite,
|
|
CodexApprovalPolicy approval = CodexApprovalPolicy.suggest,
|
|
Map<String, String>? extraConfig,
|
|
}) async {
|
|
final configDir = Directory(codexHome);
|
|
if (!await configDir.exists()) {
|
|
await configDir.create(recursive: true);
|
|
}
|
|
|
|
final configFile = File('$codexHome/config.toml');
|
|
final existingConfig = await configFile.exists()
|
|
? await configFile.readAsString()
|
|
: '';
|
|
final preserved = _stripManagedBlock(existingConfig).trimRight();
|
|
final managedBlock = _buildManagedBlock(
|
|
gatewayUrl: gatewayUrl,
|
|
apiKey: apiKey,
|
|
providerName: providerName,
|
|
defaultModel: defaultModel,
|
|
sandbox: sandbox,
|
|
approval: approval,
|
|
extraConfig: extraConfig,
|
|
);
|
|
final merged = preserved.isEmpty
|
|
? '$managedBlock\n'
|
|
: '$preserved\n\n$managedBlock\n';
|
|
await configFile.writeAsString(merged);
|
|
}
|
|
|
|
String _buildProviderSection({
|
|
required String providerName,
|
|
required String gatewayUrl,
|
|
required String apiKey,
|
|
}) {
|
|
final buffer = StringBuffer();
|
|
buffer.writeln('[model_providers.$providerName]');
|
|
buffer.writeln('name = "XWorkmate AI Gateway"');
|
|
buffer.writeln('base_url = "$gatewayUrl"');
|
|
|
|
// Use experimental_bearer_token for API key
|
|
if (apiKey.isNotEmpty) {
|
|
buffer.writeln('experimental_bearer_token = "$apiKey"');
|
|
}
|
|
|
|
buffer.writeln('wire_api = "responses"');
|
|
buffer.writeln('supports_websockets = false');
|
|
|
|
return buffer.toString();
|
|
}
|
|
|
|
String _buildManagedBlock({
|
|
required String gatewayUrl,
|
|
required String apiKey,
|
|
required String providerName,
|
|
required String defaultModel,
|
|
required CodexSandboxMode sandbox,
|
|
required CodexApprovalPolicy approval,
|
|
Map<String, String>? extraConfig,
|
|
}) {
|
|
final providerSection = _buildProviderSection(
|
|
providerName: providerName,
|
|
gatewayUrl: gatewayUrl,
|
|
apiKey: apiKey,
|
|
);
|
|
final config = StringBuffer();
|
|
config.writeln(_managedBlockStart);
|
|
config.writeln('# Generated by XWorkmate - AI Gateway Configuration');
|
|
config.writeln('# Last updated: ${DateTime.now().toIso8601String()}');
|
|
config.writeln();
|
|
config.writeln(providerSection);
|
|
config.writeln();
|
|
config.writeln('[model]');
|
|
config.writeln('model = "$defaultModel"');
|
|
config.writeln();
|
|
config.writeln('[approval_policy]');
|
|
config.writeln('policy = "${approval.value}"');
|
|
config.writeln();
|
|
config.writeln('[sandbox]');
|
|
config.writeln('mode = "${sandbox.value}"');
|
|
config.writeln();
|
|
config.writeln('[features]');
|
|
config.writeln('child_agents_md = true');
|
|
config.writeln('realtime = false');
|
|
config.writeln();
|
|
if (extraConfig != null && extraConfig.isNotEmpty) {
|
|
config.writeln('# Custom configuration');
|
|
for (final entry in extraConfig.entries) {
|
|
config.writeln('${entry.key} = "${entry.value}"');
|
|
}
|
|
config.writeln();
|
|
}
|
|
config.writeln(_managedBlockEnd);
|
|
return config.toString().trimRight();
|
|
}
|
|
|
|
String _stripManagedBlock(String content) {
|
|
return _stripBlock(content, _managedBlockStart, _managedBlockEnd);
|
|
}
|
|
|
|
String _stripManagedMcpBlock(String content) {
|
|
return _stripBlock(content, _managedMcpBlockStart, _managedMcpBlockEnd);
|
|
}
|
|
|
|
String _stripBlock(String content, String startMarker, String endMarker) {
|
|
if (content.isEmpty) {
|
|
return content;
|
|
}
|
|
|
|
var remaining = content;
|
|
while (true) {
|
|
final start = remaining.indexOf(startMarker);
|
|
if (start < 0) {
|
|
break;
|
|
}
|
|
final end = remaining.indexOf(endMarker, start);
|
|
if (end < 0) {
|
|
remaining = remaining.substring(0, start);
|
|
break;
|
|
}
|
|
remaining =
|
|
remaining.substring(0, start) +
|
|
remaining.substring(end + endMarker.length);
|
|
}
|
|
return remaining;
|
|
}
|
|
|
|
/// Generate auth.json for ChatGPT OAuth authentication.
|
|
Future<void> configureAuth({
|
|
required String accessToken,
|
|
String? refreshToken,
|
|
DateTime? expiresAt,
|
|
String? email,
|
|
String? plan,
|
|
}) async {
|
|
final authFile = File('$codexHome/auth.json');
|
|
|
|
final auth = <String, dynamic>{
|
|
'access_token': accessToken,
|
|
'last_refresh': DateTime.now().toIso8601String(),
|
|
};
|
|
|
|
if (refreshToken != null && refreshToken.isNotEmpty) {
|
|
auth['refresh_token'] = refreshToken;
|
|
}
|
|
|
|
if (expiresAt != null) {
|
|
auth['expires_at'] = expiresAt.millisecondsSinceEpoch;
|
|
}
|
|
|
|
if (email != null && email.isNotEmpty) {
|
|
auth['email'] = email;
|
|
}
|
|
|
|
if (plan != null && plan.isNotEmpty) {
|
|
auth['plan'] = plan;
|
|
}
|
|
|
|
await authFile.writeAsString(JsonEncoder.withIndent(' ').convert(auth));
|
|
}
|
|
|
|
/// Configure MCP servers for Codex.
|
|
Future<void> configureMcpServers({
|
|
required List<CodexMcpServer> servers,
|
|
bool append = true,
|
|
}) async {
|
|
final configFile = File('$codexHome/config.toml');
|
|
|
|
String existingConfig = '';
|
|
if (await configFile.exists()) {
|
|
existingConfig = await configFile.readAsString();
|
|
}
|
|
|
|
final buffer = StringBuffer();
|
|
|
|
if (append && existingConfig.isNotEmpty) {
|
|
buffer.writeln(existingConfig);
|
|
buffer.writeln();
|
|
}
|
|
|
|
buffer.writeln('# MCP Servers');
|
|
|
|
for (final server in servers) {
|
|
buffer.writeln('[mcp_servers.${server.name}]');
|
|
buffer.writeln('command = "${server.command}"');
|
|
|
|
if (server.args.isNotEmpty) {
|
|
buffer.writeln('args = ${_formatTomlArray(server.args)}');
|
|
}
|
|
|
|
if (server.env.isNotEmpty) {
|
|
buffer.writeln('[mcp_servers.${server.name}.env]');
|
|
for (final entry in server.env.entries) {
|
|
buffer.writeln('${entry.key} = "${entry.value}"');
|
|
}
|
|
}
|
|
|
|
buffer.writeln();
|
|
}
|
|
|
|
await configFile.writeAsString(buffer.toString());
|
|
}
|
|
|
|
Future<void> configureManagedMcpServers({
|
|
required List<CodexMcpServer> servers,
|
|
}) async {
|
|
final configDir = Directory(codexHome);
|
|
if (!await configDir.exists()) {
|
|
await configDir.create(recursive: true);
|
|
}
|
|
|
|
final configFile = File('$codexHome/config.toml');
|
|
final existingConfig = await configFile.exists()
|
|
? await configFile.readAsString()
|
|
: '';
|
|
final preserved = _stripManagedMcpBlock(existingConfig).trimRight();
|
|
final managedBlock = _buildManagedMcpBlock(servers);
|
|
final merged = preserved.isEmpty
|
|
? '$managedBlock\n'
|
|
: '$preserved\n\n$managedBlock\n';
|
|
await configFile.writeAsString(merged);
|
|
}
|
|
|
|
String _buildManagedMcpBlock(List<CodexMcpServer> servers) {
|
|
final buffer = StringBuffer()
|
|
..writeln(_managedMcpBlockStart)
|
|
..writeln('# Generated by XWorkmate - Managed MCP Server Configuration')
|
|
..writeln('# Last updated: ${DateTime.now().toIso8601String()}')
|
|
..writeln();
|
|
|
|
for (final server in servers) {
|
|
buffer.writeln('[mcp_servers.${server.name}]');
|
|
buffer.writeln('command = "${server.command}"');
|
|
|
|
if (server.args.isNotEmpty) {
|
|
buffer.writeln('args = ${_formatTomlArray(server.args)}');
|
|
}
|
|
|
|
if (server.env.isNotEmpty) {
|
|
buffer.writeln('[mcp_servers.${server.name}.env]');
|
|
for (final entry in server.env.entries) {
|
|
buffer.writeln('${entry.key} = "${entry.value}"');
|
|
}
|
|
}
|
|
|
|
buffer.writeln();
|
|
}
|
|
|
|
buffer.writeln(_managedMcpBlockEnd);
|
|
return buffer.toString().trimRight();
|
|
}
|
|
|
|
String _formatTomlArray(List<String> items) {
|
|
if (items.isEmpty) return '[]';
|
|
if (items.length == 1) return '["${items[0]}"]';
|
|
return '[${items.map((s) => '"$s"').join(', ')}]';
|
|
}
|
|
|
|
/// Generate configuration for OpenClaw Gateway integration.
|
|
Future<void> configureOpenClawGateway({
|
|
required String gatewayUrl,
|
|
required String token,
|
|
String providerName = 'openclaw',
|
|
}) async {
|
|
await configureForGateway(
|
|
gatewayUrl: gatewayUrl,
|
|
apiKey: token,
|
|
providerName: providerName,
|
|
);
|
|
|
|
// Add MCP server for OpenClaw
|
|
await configureMcpServers(
|
|
servers: [
|
|
CodexMcpServer(
|
|
name: 'openclaw',
|
|
command: 'openclaw-mcp',
|
|
args: ['--gateway', gatewayUrl],
|
|
env: {'OPENCLAW_TOKEN': token},
|
|
),
|
|
],
|
|
append: true,
|
|
);
|
|
}
|
|
|
|
/// Check if Codex configuration exists.
|
|
Future<bool> hasConfig() async {
|
|
final configFile = File('$codexHome/config.toml');
|
|
return configFile.exists();
|
|
}
|
|
|
|
/// Check if auth.json exists.
|
|
Future<bool> hasAuth() async {
|
|
final authFile = File('$codexHome/auth.json');
|
|
return authFile.exists();
|
|
}
|
|
|
|
/// Read current model provider configuration.
|
|
Future<Map<String, dynamic>?> readProviderConfig(String providerName) async {
|
|
final configFile = File('$codexHome/config.toml');
|
|
if (!await configFile.exists()) {
|
|
return null;
|
|
}
|
|
|
|
final content = await configFile.readAsString();
|
|
return _parseTomlSection(content, 'model_providers.$providerName');
|
|
}
|
|
|
|
/// Parse a TOML section into a Map.
|
|
Map<String, dynamic>? _parseTomlSection(String content, String section) {
|
|
final lines = content.split('\n');
|
|
final result = <String, dynamic>{};
|
|
bool inSection = false;
|
|
|
|
for (final line in lines) {
|
|
final trimmed = line.trim();
|
|
|
|
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
|
|
|
|
if (trimmed.startsWith('[')) {
|
|
final sectionName = trimmed.substring(1, trimmed.length - 1);
|
|
inSection = sectionName == section;
|
|
continue;
|
|
}
|
|
|
|
if (inSection) {
|
|
final eqIndex = trimmed.indexOf('=');
|
|
if (eqIndex > 0) {
|
|
final key = trimmed.substring(0, eqIndex).trim();
|
|
var value = trimmed.substring(eqIndex + 1).trim();
|
|
|
|
// Remove quotes
|
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.substring(1, value.length - 1);
|
|
}
|
|
|
|
result[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result.isNotEmpty ? result : null;
|
|
}
|
|
|
|
/// Clear all Codex configuration.
|
|
Future<void> clearConfig() async {
|
|
final configDir = Directory(codexHome);
|
|
if (await configDir.exists()) {
|
|
await configDir.delete(recursive: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Codex sandbox mode for configuration.
|
|
enum CodexSandboxMode {
|
|
readOnly('read-only'),
|
|
workspaceWrite('workspace-write'),
|
|
dangerFullAccess('danger-full-access');
|
|
|
|
final String value;
|
|
const CodexSandboxMode(this.value);
|
|
}
|
|
|
|
/// Codex approval policy for configuration.
|
|
enum CodexApprovalPolicy {
|
|
suggest('suggest'),
|
|
autoEdit('auto-edit'),
|
|
fullAuto('full-auto');
|
|
|
|
final String value;
|
|
const CodexApprovalPolicy(this.value);
|
|
}
|
|
|
|
/// MCP server configuration for Codex.
|
|
class CodexMcpServer {
|
|
final String name;
|
|
final String command;
|
|
final List<String> args;
|
|
final Map<String, String> env;
|
|
|
|
const CodexMcpServer({
|
|
required this.name,
|
|
required this.command,
|
|
this.args = const [],
|
|
this.env = const {},
|
|
});
|
|
}
|