diff --git a/README.md b/README.md index 82c522e..cce9a4b 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,12 @@ Build and device runs target mobile platforms. iOS packaging is handled by CI. ```bash git push -gh run watch -gh run download --name ios-ipa +gh run list --limit 10 +gh run download --name ios-ipa --dir build/artifacts/ios-ipa ``` -Install the unsigned IPA with Sideloadly or AltStore. +For the agent workflow to verify Actions and download the IPA artifact locally, +see `docs/workflow.md`. ## Project Layout diff --git a/docs/workflow.md b/docs/workflow.md index d4754e2..8a0226c 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -61,18 +61,48 @@ docker run --rm ` bash -c "flutter pub get && flutter analyze && flutter test" ``` -## iOS CI 打包 +## iOS IPA 工作流 -iOS 打包由 GitHub Actions 处理,workflow 位于 `.github/workflows/ios.yml`。 +iOS 打包由 GitHub Actions 处理。给 agent 的标准流程如下: + +1. 确认本地分支已经推送,或 PR 已合并到 `main`。 +2. 查看最近的 workflow run: + +```powershell +gh run list --limit 10 +``` + +3. 找到最新的 `main` / `push` run,确认这些 workflow 成功: + - `CI` + - `iOS unsigned IPA` + - `Build IPA` + +4. 查看目标 run 的 artifact。优先使用 `iOS unsigned IPA` workflow 的 run id: + +```powershell +gh api repos/botlong/remote-multi-agent/actions/runs//artifacts ` + --jq '.artifacts[] | {name, expired, size_in_bytes, archive_download_url}' +``` + +5. 确认存在未过期的 `ios-ipa` artifact 后,下载到本地固定目录: + +```powershell +New-Item -ItemType Directory -Force -Path "build\artifacts\ios-ipa" | Out-Null +gh run download --name ios-ipa --dir "build\artifacts\ios-ipa" +Get-ChildItem -Path "build\artifacts\ios-ipa" -Force +``` + +如果只存在 `Build IPA` workflow 的 `ipa` artifact,下载到单独目录,避免和 +`ios-ipa` 混在一起: ```powershell -git push -gh run list --limit 3 -gh run watch -gh run download --name ios-ipa +New-Item -ItemType Directory -Force -Path "build\artifacts\ipa" | Out-Null +gh run download --name ipa --dir "build\artifacts\ipa" +Get-ChildItem -Path "build\artifacts\ipa" -Force ``` -下载后的 unsigned IPA 可通过 Sideloadly 或 AltStore 安装到 iPhone。 +6. 最后向用户报告本地目录、IPA 文件名、run id 和 artifact 名称。不要提交 IPA; + `.gitignore` 已忽略 `/build/` 和 `*.ipa`。 ## 常用命令 diff --git a/gateway/src/cli.js b/gateway/src/cli.js index ded9d15..9580974 100644 --- a/gateway/src/cli.js +++ b/gateway/src/cli.js @@ -23,23 +23,20 @@ function resolveCodexCommand() { return { command: 'codex', prefixArgs: [], shell: process.platform === 'win32' }; } -function resolveOpenCodeCommand() { - if (process.env.OPENCODE_BIN) return commandFromPath(process.env.OPENCODE_BIN); - if (process.platform === 'win32') { - const exe = path.join( - process.env.APPDATA || '', - 'npm', - 'node_modules', - 'opencode-ai', - 'bin', - 'opencode.exe', - ); - if (fs.existsSync(exe)) { - return { command: exe, prefixArgs: [], shell: false }; - } - } - return { - command: 'opencode', +function resolveOpenCodeCommand() { + if (process.env.OPENCODE_BIN) return commandFromPath(process.env.OPENCODE_BIN); + if (process.platform === 'win32') { + const exe = findGlobalNpmPackageBin('opencode-ai', ['bin', 'opencode.exe']); + if (fs.existsSync(exe)) { + return { command: exe, prefixArgs: [], shell: false }; + } + const shim = path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'); + if (fs.existsSync(shim)) { + return { command: shim, prefixArgs: [], shell: true }; + } + } + return { + command: 'opencode', prefixArgs: [], shell: process.platform === 'win32', }; @@ -82,12 +79,45 @@ function commandFromPath(command) { shell: false, }; } - return { command, prefixArgs: [], shell: false }; -} - -function commandExists(spec) { - if (spec.command === process.execPath) { - return (spec.prefixArgs || []).every((arg) => fs.existsSync(arg)); + return { command, prefixArgs: [], shell: false }; +} + +function findGlobalNpmPackageBin(packageName, relativeParts) { + const nodeModules = path.join(process.env.APPDATA || '', 'npm', 'node_modules'); + const exact = path.join(nodeModules, packageName, ...relativeParts); + if (fs.existsSync(exact)) return exact; + + let directories = []; + try { + directories = fs + .readdirSync(nodeModules, { withFileTypes: true }) + .filter((entry) => ( + entry.isDirectory() && + entry.name.startsWith(`.${packageName}-`) + )) + .map((entry) => path.join(nodeModules, entry.name)); + } catch { + return exact; + } + + directories.sort((a, b) => { + try { + return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; + } catch { + return 0; + } + }); + + for (const directory of directories) { + const candidate = path.join(directory, ...relativeParts); + if (fs.existsSync(candidate)) return candidate; + } + return exact; +} + +function commandExists(spec) { + if (spec.command === process.execPath) { + return (spec.prefixArgs || []).every((arg) => fs.existsSync(arg)); } if (path.isAbsolute(spec.command)) return fs.existsSync(spec.command); return findOnPath(spec.command) !== null; diff --git a/gateway/src/credential_sources.js b/gateway/src/credential_sources.js index b31f6c7..7dd0e5b 100644 --- a/gateway/src/credential_sources.js +++ b/gateway/src/credential_sources.js @@ -10,6 +10,7 @@ * Supported providers: * - anthropic (Claude) * - openai (Codex) + * - opencode (OpenCode) * * Sources: * - official: per-provider config files (~/.claude/settings.json, @@ -31,7 +32,9 @@ const CC_SWITCH_DB_PATH = path.join(os.homedir(), '.cc-switch', 'cc-switch.db'); const APP_TYPE_TO_PROVIDER = { claude: 'anthropic', + 'claude-desktop': 'anthropic', codex: 'openai', + opencode: 'opencode', }; const CACHE_TTL_MS = 30_000; @@ -85,7 +88,7 @@ function configurePaths(options) { * @typedef {Object} CredentialEntry * @property {string} id Stable identifier within the source. * @property {'official'|'cc-switch'} source - * @property {'anthropic'|'openai'} provider Gateway provider slot for this credential. + * @property {'anthropic'|'openai'|'opencode'} provider Gateway provider slot for this credential. * @property {string} label Human-readable name. * @property {boolean} hasToken True iff a token was found. * @property {string|null} authToken Raw token. **Caller must mask before HTTP.** @@ -234,7 +237,7 @@ async function _readCcSwitchCredentials() { const extracted = _extractCcSwitchCred(row); if (!extracted || !extracted.authToken) continue; entries.push({ - id: String(row.id), + id: _ccSwitchEntryId(row), source: 'cc-switch', provider, label: row.name || `${row.app_type} #${row.id}`, @@ -242,7 +245,12 @@ async function _readCcSwitchCredentials() { authToken: extracted.authToken, baseUrl: extracted.baseUrl, isCurrent: Boolean(row.is_current), - raw: { providerId: row.id, appType: row.app_type }, + raw: { + providerId: row.id, + appType: row.app_type, + ...(row.provider_type ? { providerType: row.provider_type } : {}), + ...(extracted.models ? { models: extracted.models } : {}), + }, }); } return entries; @@ -257,8 +265,12 @@ async function _readCcSwitchCredentials() { } } +function _ccSwitchEntryId(row) { + return `${row.app_type}:${row.id}`; +} + function _extractCcSwitchCred(row) { - if (row.app_type === 'claude') { + if (row.app_type === 'claude' || row.app_type === 'claude-desktop') { const cfg = _tryParseJson(row.settings_config); const env = (cfg && cfg.env) || {}; const authToken = env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || null; @@ -266,27 +278,66 @@ function _extractCcSwitchCred(row) { return { authToken, baseUrl: env.ANTHROPIC_BASE_URL || null }; } if (row.app_type === 'codex') { - // CC-Switch stores Codex creds in `auth_config` (JSON) when available; - // older schemas might use `settings_config`. + const settings = _tryParseJson(row.settings_config); + // CC-Switch has used both a dedicated `auth_config` column and a nested + // `settings_config.auth` object across versions. const auth = - _tryParseJson(row.auth_config) || _tryParseJson(row.settings_config); + _tryParseJson(row.auth_config) || + (settings && typeof settings.auth === 'object' ? settings.auth : null) || + settings; const authToken = - (auth && (auth.OPENAI_API_KEY || auth.openai_api_key || auth.apiKey)) || + (auth && + (auth.OPENAI_API_KEY || + auth.openai_api_key || + auth.apiKey || + auth.api_key)) || null; let baseUrl = - (auth && (auth.OPENAI_BASE_URL || auth.openai_base_url)) || null; - // Codex `settings_config` is typically TOML; do a cheap regex grab if we - // didn't find a base URL in the auth blob. - if (!baseUrl && typeof row.settings_config === 'string') { - const match = row.settings_config.match(/base_url\s*=\s*"([^"]+)"/); - if (match) baseUrl = match[1]; - } + (auth && + (auth.OPENAI_BASE_URL || + auth.openai_base_url || + auth.baseUrl || + auth.base_url)) || + null; + const configText = + (settings && typeof settings.config === 'string' && settings.config) || + (typeof row.settings_config === 'string' ? row.settings_config : ''); + if (!baseUrl) baseUrl = _extractTomlString(configText, 'base_url'); if (!authToken) return null; return { authToken, baseUrl }; } + if (row.app_type === 'opencode') { + const cfg = _tryParseJson(row.settings_config); + const options = cfg && typeof cfg.options === 'object' ? cfg.options : {}; + const authToken = + options.apiKey || + options.api_key || + options.OPENAI_API_KEY || + options.openai_api_key || + null; + const baseUrl = + options.baseURL || + options.baseUrl || + options.base_url || + options.OPENAI_BASE_URL || + options.openai_base_url || + null; + if (!authToken) return null; + const models = cfg && typeof cfg.models === 'object' + ? Object.keys(cfg.models) + : undefined; + return { authToken, baseUrl, models }; + } return null; } +function _extractTomlString(raw, key) { + if (typeof raw !== 'string' || !raw) return null; + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = raw.match(new RegExp(`^\\s*${escaped}\\s*=\\s*"([^"]+)"`, 'm')); + return match ? match[1] : null; +} + function _tryParseJson(raw) { if (typeof raw !== 'string' || !raw) return null; try { @@ -315,7 +366,12 @@ async function loadCredential({ source, sourceId } = {}) { if (source === 'cc-switch') { const entries = await listCcSwitchCredentials(); if (sourceId != null && sourceId !== '') { - return entries.find((e) => e.id === String(sourceId)) || null; + const id = String(sourceId); + return entries.find((e) => ( + e.id === id || + String(e.raw?.providerId) === id || + `${e.raw?.appType}:${e.raw?.providerId}` === id + )) || null; } return entries.find((e) => e.isCurrent) || entries[0] || null; } diff --git a/gateway/test/cli.test.js b/gateway/test/cli.test.js new file mode 100644 index 0000000..55fd869 --- /dev/null +++ b/gateway/test/cli.test.js @@ -0,0 +1,50 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + commandExists, + resolveOpenCodeCommand, +} = require('../src/cli'); + +test( + 'resolveOpenCodeCommand finds npm temp package installs on Windows', + { skip: process.platform !== 'win32' }, + async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rma-cli-')); + const oldAppData = process.env.APPDATA; + const oldBin = process.env.OPENCODE_BIN; + t.after(async () => { + if (oldAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = oldAppData; + if (oldBin === undefined) delete process.env.OPENCODE_BIN; + else process.env.OPENCODE_BIN = oldBin; + await fs.rm(root, { recursive: true, force: true }); + }); + + delete process.env.OPENCODE_BIN; + process.env.APPDATA = root; + + const exe = path.join( + root, + 'npm', + 'node_modules', + '.opencode-ai-AbCdEf', + 'bin', + 'opencode.exe', + ); + await fs.mkdir(path.dirname(exe), { recursive: true }); + await fs.writeFile(exe, ''); + + const command = resolveOpenCodeCommand(); + + assert.equal(command.command, exe); + assert.deepEqual(command.prefixArgs, []); + assert.equal(command.shell, false); + assert.equal(commandExists(command), true); + }, +); diff --git a/gateway/test/credential_sources.test.js b/gateway/test/credential_sources.test.js index 0e61249..729ccc7 100644 --- a/gateway/test/credential_sources.test.js +++ b/gateway/test/credential_sources.test.js @@ -205,6 +205,93 @@ test( }, ); +test( + 'listCcSwitchCredentials reads current CC-Switch schema across visible app types', + { skip: !sqliteAvailable }, + async (t) => { + const { DatabaseSync } = require('node:sqlite'); + const root = await withTempPaths(t); + const dbPath = path.join(root, 'cc-switch.db'); + + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE providers ( + id TEXT, + app_type TEXT NOT NULL, + name TEXT, + settings_config TEXT, + meta TEXT, + is_current INTEGER DEFAULT 0, + provider_type TEXT + ); + `); + const insert = db.prepare( + 'INSERT INTO providers (id, app_type, name, settings_config, meta, is_current, provider_type) VALUES (?, ?, ?, ?, ?, ?, ?)', + ); + insert.run( + 'shared-claude-id', 'claude', 'Claude CLI', + JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-ant-cli-aaaaaaaaaa', ANTHROPIC_BASE_URL: 'https://claude.example' } }), + '{}', 1, 'anthropic', + ); + insert.run( + 'shared-claude-id', 'claude-desktop', 'Claude Desktop', + JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-ant-desktop-bbbbbbbbbb', ANTHROPIC_BASE_URL: 'https://desktop.example' } }), + '{}', 0, 'anthropic', + ); + insert.run( + 'codex-json', 'codex', 'Codex JSON', + JSON.stringify({ + auth: { OPENAI_API_KEY: 'sk-openai-json-cccc' }, + config: 'model_provider = "codex-json"\nbase_url = "https://codex.example/v1"', + }), + '{}', 1, null, + ); + insert.run( + 'opencode-json', 'opencode', 'OpenCode JSON', + JSON.stringify({ + options: { + apiKey: 'sk-opencode-dddddddddd', + baseURL: 'https://opencode.example/v1', + }, + models: { 'gpt-5.5': { name: 'GPT 5.5' } }, + }), + '{}', 0, null, + ); + insert.run( + 'codex-official', 'codex', 'OpenAI Official', + JSON.stringify({ auth: {}, config: '' }), + '{}', 0, null, + ); + db.close(); + + configurePaths({ ccSwitchPath: dbPath }); + + const result = await listCcSwitchCredentials(); + assert.equal(result.length, 4, 'should skip only entries without tokens'); + + assert.deepEqual( + result.map((entry) => entry.id), + [ + 'claude:shared-claude-id', + 'claude-desktop:shared-claude-id', + 'codex:codex-json', + 'opencode:opencode-json', + ], + ); + + const byId = Object.fromEntries(result.map((entry) => [entry.id, entry])); + assert.equal(byId['claude:shared-claude-id'].provider, 'anthropic'); + assert.equal(byId['claude-desktop:shared-claude-id'].provider, 'anthropic'); + assert.equal(byId['codex:codex-json'].provider, 'openai'); + assert.equal(byId['opencode:opencode-json'].provider, 'opencode'); + assert.equal(byId['codex:codex-json'].authToken, 'sk-openai-json-cccc'); + assert.equal(byId['codex:codex-json'].baseUrl, 'https://codex.example/v1'); + assert.equal(byId['opencode:opencode-json'].authToken, 'sk-opencode-dddddddddd'); + assert.equal(byId['opencode:opencode-json'].baseUrl, 'https://opencode.example/v1'); + assert.deepEqual(byId['opencode:opencode-json'].raw.models, ['gpt-5.5']); + }, +); + test( 'loadCredential resolves cc-switch by id across providers', { skip: !sqliteAvailable }, diff --git a/lib/ui/pages/settings_page.dart b/lib/ui/pages/settings_page.dart index 92b3860..adce368 100644 --- a/lib/ui/pages/settings_page.dart +++ b/lib/ui/pages/settings_page.dart @@ -9,7 +9,7 @@ import 'home_page.dart'; enum _AddProfileChoice { official, ccSwitch, manual } -class _ProviderBadge extends StatelessWidget { +class _ProviderBadge extends StatelessWidget { const _ProviderBadge({required this.provider}); final String? provider; @@ -39,10 +39,78 @@ class _ProviderBadge extends StatelessWidget { ), ), ); - } -} - -class SettingsPage extends ConsumerStatefulWidget { + } +} + +class _CredentialEntryTile extends StatelessWidget { + const _CredentialEntryTile({ + required this.entry, + required this.onTap, + }); + + final Map entry; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final isCurrent = entry['isCurrent'] == true; + final raw = entry['raw']; + final appType = raw is Map ? raw['appType']?.toString() : null; + return ListTile( + dense: true, + leading: Icon( + isCurrent ? Icons.check_circle : Icons.radio_button_unchecked, + size: 18, + color: isCurrent ? Colors.green : Theme.of(context).disabledColor, + ), + title: Row( + children: [ + Expanded( + child: Text( + entry['label']?.toString() ?? 'Unnamed', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + _ProviderBadge(provider: entry['provider']?.toString()), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (appType != null && appType.isNotEmpty) + Text( + appType, + style: Theme.of(context).textTheme.bodySmall, + ), + if (entry['tokenPreview'] != null) + Text( + entry['tokenPreview'].toString(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + if (entry['baseUrl'] != null) + Text( + entry['baseUrl'].toString(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + onTap: onTap, + ); + } +} + +class SettingsPage extends ConsumerStatefulWidget { const SettingsPage({super.key, this.firstRun = false}); final bool firstRun; @@ -326,91 +394,117 @@ class _SettingsPageState extends ConsumerState { await _loadProfiles(); } - Future?> _pickCredentialEntry( - List> entries, { - required String title, - }) async { - if (entries.length == 1) return entries.first; - return showDialog>( - context: context, - builder: (ctx) => SimpleDialog( - title: Text(title), - children: [ - for (final entry in entries) - SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, entry), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - entry['isCurrent'] == true - ? Icons.check_circle - : Icons.radio_button_unchecked, - size: 18, - color: entry['isCurrent'] == true - ? Colors.green - : Theme.of(ctx).disabledColor, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - entry['label']?.toString() ?? 'Unnamed', - style: Theme.of(ctx) - .textTheme - .bodyLarge - ?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 8), - _ProviderBadge( - provider: entry['provider']?.toString(), - ), - ], - ), - if (entry['tokenPreview'] != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - entry['tokenPreview'].toString(), - style: Theme.of(ctx) - .textTheme - .bodySmall - ?.copyWith( - fontFeatures: const [ - FontFeature.tabularFigures(), - ], - ), - ), - ), - if (entry['baseUrl'] != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - entry['baseUrl'].toString(), - style: Theme.of(ctx).textTheme.bodySmall, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ); - } + Future?> _pickCredentialEntry( + List> entries, { + required String title, + }) async { + if (entries.length == 1) return entries.first; + final grouped = _groupCredentialEntries(entries); + final groupKeys = grouped.keys.toList() + ..sort((a, b) { + final order = + _credentialGroupOrder(a).compareTo(_credentialGroupOrder(b)); + if (order != 0) return order; + return _credentialGroupLabel(a).compareTo(_credentialGroupLabel(b)); + }); + final initialGroup = groupKeys.firstWhere( + (key) => grouped[key]!.any((entry) => entry['isCurrent'] == true), + orElse: () => groupKeys.first, + ); + + return showDialog>( + context: context, + builder: (ctx) => AlertDialog( + title: Text(title), + contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 0), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 560), + child: SingleChildScrollView( + child: ExpansionPanelList.radio( + initialOpenPanelValue: initialGroup, + children: [ + for (final key in groupKeys) + ExpansionPanelRadio( + value: key, + headerBuilder: (context, isExpanded) { + final items = grouped[key]!; + final current = + items.any((entry) => entry['isCurrent'] == true); + return ListTile( + dense: true, + title: Text(_credentialGroupLabel(key)), + subtitle: Text( + current + ? '${items.length} providers - current selected' + : '${items.length} providers', + ), + ); + }, + body: Column( + children: [ + for (final entry in grouped[key]!) + _CredentialEntryTile( + entry: entry, + onTap: () => Navigator.pop(ctx, entry), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Map>> _groupCredentialEntries( + List> entries, + ) { + final grouped = >>{}; + for (final entry in entries) { + final key = _credentialGroupKey(entry); + grouped.putIfAbsent(key, () => >[]).add(entry); + } + return grouped; + } + + String _credentialGroupKey(Map entry) { + final raw = entry['raw']; + if (raw is Map && raw['appType'] != null) { + return raw['appType'].toString(); + } + return entry['provider']?.toString() ?? 'other'; + } + + int _credentialGroupOrder(String key) { + return switch (key) { + 'claude' => 0, + 'claude-desktop' => 1, + 'codex' => 2, + 'opencode' => 3, + 'anthropic' => 4, + 'openai' => 5, + 'google' => 6, + _ => 99, + }; + } + + String _credentialGroupLabel(String key) { + return switch (key) { + 'claude' => 'Claude', + 'claude-desktop' => 'Claude Desktop', + 'codex' => 'Codex', + 'opencode' => 'OpenCode', + 'anthropic' => 'Anthropic', + 'openai' => 'OpenAI', + 'google' => 'Google', + _ => key.isEmpty ? 'Other' : key, + }; + } Future _promptProfileName({ required String defaultName, @@ -1242,16 +1336,19 @@ class _ProfileEditorPageState extends State<_ProfileEditorPage> { // When editing, don't populate key fields with masked values. // Show masked values as hints only; empty field means "keep existing". final isEdit = existing != null; - _anthropicKeyCtrl = - TextEditingController(text: isEdit ? '' : (anthropic['key'] as String? ?? '')); + _anthropicKeyCtrl = TextEditingController( + text: isEdit ? '' : (anthropic['key'] as String? ?? ''), + ); _anthropicBaseUrlCtrl = TextEditingController(text: anthropic['baseUrl'] as String? ?? ''); - _openaiKeyCtrl = - TextEditingController(text: isEdit ? '' : (openai['key'] as String? ?? '')); + _openaiKeyCtrl = TextEditingController( + text: isEdit ? '' : (openai['key'] as String? ?? ''), + ); _openaiBaseUrlCtrl = TextEditingController(text: openai['baseUrl'] as String? ?? ''); - _opencodeKeyCtrl = - TextEditingController(text: isEdit ? '' : (opencode['key'] as String? ?? '')); + _opencodeKeyCtrl = TextEditingController( + text: isEdit ? '' : (opencode['key'] as String? ?? ''), + ); _opencodeBaseUrlCtrl = TextEditingController(text: opencode['baseUrl'] as String? ?? '');