free-coding-models 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,34 +9,23 @@
9
9
  * 📖 The design is pragmatic:
10
10
  * - Write a small managed config file when the tool's config shape is stable enough
11
11
  * - Always export the runtime environment variables before spawning the tool
12
+ * - Persist the selected model into the tool config before launch so Enter
13
+ * really means "open this tool on this model right now"
12
14
  * - Keep each launcher isolated so a partial integration does not break others
13
15
  *
14
- * 📖 Some tools still have weaker official support for arbitrary custom providers.
15
- * For those, we prefer a transparent warning over pretending the integration is
16
- * fully official. The user still gets a reproducible env/config handoff.
17
- *
18
16
  * 📖 Goose: writes custom provider JSON + secrets.yaml + updates config.yaml (GOOSE_PROVIDER/GOOSE_MODEL)
19
17
  * 📖 Crush: writes crush.json with provider config + models.large/small defaults
20
18
  * 📖 Pi: uses --provider/--model CLI flags for guaranteed auto-selection
21
19
  * 📖 Aider: writes ~/.aider.conf.yml + passes --model flag
22
- * 📖 Claude Code: mirrors Claude proxy by keeping fake Claude model ids on the client,
23
- * forcing a valid Claude alias at launch, and moving MODEL / MODEL_OPUS / MODEL_SONNET /
24
- * MODEL_HAIKU routing into the proxy
25
- * 📖 Codex CLI: uses a custom model_provider override so Codex stays in explicit API-provider mode
26
- * 📖 Gemini CLI: proxy mode is capability-gated because older builds do not support custom base URL routing cleanly
27
20
  *
28
21
  * @functions
29
- * → `resolveLauncherModelId` — choose the provider-specific id or proxy slug for a launch
30
- * → `waitForClaudeProxyRouting` — wait until the daemon/proxy has reloaded the Claude proxy style Claude-family mapping
31
- * → `buildClaudeProxyArgs` — force a valid Claude alias so stale local non-Claude selections cannot break launch
32
- * → `buildCodexProxyArgs` — force Codex into a proxy-backed custom provider config
33
- * → `inspectGeminiCliSupport` — detect whether the installed Gemini CLI can use proxy mode safely
22
+ * → `resolveLauncherModelId` — choose the provider-specific id for a launch
34
23
  * → `writeGooseConfig` — install provider + set GOOSE_PROVIDER/GOOSE_MODEL in config.yaml
35
24
  * → `writeCrushConfig` — write provider + models.large/small to crush.json
25
+ * → `prepareExternalToolLaunch` — persist selected-model defaults and compute the launch command
36
26
  * → `startExternalTool` — configure and launch the selected external tool mode
37
27
  *
38
- * @exports resolveLauncherModelId, waitForClaudeProxyRouting, buildClaudeProxyArgs, buildCodexProxyArgs
39
- * @exports inspectGeminiCliSupport, startExternalTool
28
+ * @exports resolveLauncherModelId, buildToolEnv, prepareExternalToolLaunch, startExternalTool
40
29
  *
41
30
  * @see src/tool-metadata.js
42
31
  * @see src/provider-metadata.js
@@ -44,16 +33,15 @@
44
33
  */
45
34
 
46
35
  import chalk from 'chalk'
47
- import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, copyFileSync } from 'fs'
36
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
48
37
  import { homedir } from 'os'
49
- import { delimiter, dirname, join } from 'path'
50
- import { spawn, spawnSync } from 'child_process'
38
+ import { dirname, join } from 'path'
39
+ import { spawn } from 'child_process'
51
40
  import { sources } from '../sources.js'
52
41
  import { PROVIDER_COLOR } from './render-table.js'
53
- import { getApiKey, getProxySettings, saveConfig, setClaudeProxyModelRouting } from './config.js'
42
+ import { getApiKey } from './config.js'
54
43
  import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
55
44
  import { getToolMeta } from './tool-metadata.js'
56
- import { ensureProxyRunning, resolveProxyModelId } from './opencode.js'
57
45
  import { PROVIDER_METADATA } from './provider-metadata.js'
58
46
 
59
47
  const OPENAI_COMPAT_ENV_KEYS = [
@@ -65,35 +53,28 @@ const OPENAI_COMPAT_ENV_KEYS = [
65
53
  'LLM_BASE_URL',
66
54
  'LLM_MODEL',
67
55
  ]
68
- const ANTHROPIC_ENV_KEYS = [
69
- 'ANTHROPIC_API_KEY',
70
- 'ANTHROPIC_AUTH_TOKEN',
71
- 'ANTHROPIC_BASE_URL',
72
- 'ANTHROPIC_MODEL',
73
- 'ANTHROPIC_DEFAULT_OPUS_MODEL',
74
- 'ANTHROPIC_DEFAULT_SONNET_MODEL',
75
- 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
76
- 'ANTHROPIC_SMALL_FAST_MODEL',
77
- 'CLAUDE_CODE_SUBAGENT_MODEL',
78
- ]
79
- const GEMINI_ENV_KEYS = [
80
- 'GEMINI_API_KEY',
81
- 'GOOGLE_API_KEY',
82
- 'GOOGLE_GEMINI_BASE_URL',
83
- 'GOOGLE_VERTEX_BASE_URL',
84
- ]
85
- const PROXY_SANITIZED_ENV_KEYS = [...OPENAI_COMPAT_ENV_KEYS, ...ANTHROPIC_ENV_KEYS, ...GEMINI_ENV_KEYS]
86
- const GEMINI_PROXY_MIN_VERSION = '0.34.0'
87
- const EXPERIMENTAL_PROXY_TOOLS_NOTE = 'FCM Proxy V2 support for external tools is still in beta, so some launch and authentication flows can remain flaky while the integration stabilizes.'
88
- const CLAUDE_PROXY_RELOAD_TIMEOUT_MS = 4000
89
- const CLAUDE_PROXY_RELOAD_INTERVAL_MS = 200
90
- const CLAUDE_PROXY_CLIENT_MODEL = 'sonnet'
56
+ const SANITIZED_TOOL_ENV_KEYS = [...OPENAI_COMPAT_ENV_KEYS]
91
57
 
92
58
  function ensureDir(filePath) {
93
59
  const dir = dirname(filePath)
94
60
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
95
61
  }
96
62
 
63
+ function getDefaultToolPaths(homeDir = homedir()) {
64
+ return {
65
+ aiderConfigPath: join(homeDir, '.aider.conf.yml'),
66
+ crushConfigPath: join(homeDir, '.config', 'crush', 'crush.json'),
67
+ gooseProvidersDir: join(homeDir, '.config', 'goose', 'custom_providers'),
68
+ gooseSecretsPath: join(homeDir, '.config', 'goose', 'secrets.yaml'),
69
+ gooseConfigPath: join(homeDir, '.config', 'goose', 'config.yaml'),
70
+ qwenConfigPath: join(homeDir, '.qwen', 'settings.json'),
71
+ ampConfigPath: join(homeDir, '.config', 'amp', 'settings.json'),
72
+ piModelsPath: join(homeDir, '.pi', 'agent', 'models.json'),
73
+ piSettingsPath: join(homeDir, '.pi', 'agent', 'settings.json'),
74
+ openHandsEnvPath: join(homeDir, '.fcm-openhands-env'),
75
+ }
76
+ }
77
+
97
78
  function backupIfExists(filePath) {
98
79
  if (!existsSync(filePath)) return null
99
80
  const backupPath = `${filePath}.backup-${Date.now()}`
@@ -147,29 +128,16 @@ function applyOpenAiCompatEnv(env, apiKey, baseUrl, modelId) {
147
128
  }
148
129
 
149
130
  /**
150
- * 📖 resolveLauncherModelId keeps proxy-backed launches on the universal
151
- * 📖 `fcm-proxy` catalog slug instead of leaking a provider-specific upstream id.
131
+ * 📖 resolveLauncherModelId returns the provider-native id used by the direct
132
+ * 📖 launchers. Legacy bridge-specific model remapping has been removed.
152
133
  *
153
134
  * @param {{ label?: string, modelId?: string }} model
154
- * @param {boolean} useProxy
155
135
  * @returns {string}
156
136
  */
157
- export function resolveLauncherModelId(model, useProxy = false) {
158
- if (useProxy) return resolveProxyModelId(model)
137
+ export function resolveLauncherModelId(model) {
159
138
  return model?.modelId ?? ''
160
139
  }
161
140
 
162
- /**
163
- * 📖 Force Claude Code to start on a real Claude alias, never on an FCM slug.
164
- * 📖 Older FCM launches poisoned Claude's local model state with `gpt-oss-*`,
165
- * 📖 and Claude rejects those client-side before any proxy request is made.
166
- *
167
- * @returns {string[]}
168
- */
169
- export function buildClaudeProxyArgs() {
170
- return ['--model', CLAUDE_PROXY_CLIENT_MODEL]
171
- }
172
-
173
141
  export function buildToolEnv(mode, model, config, options = {}) {
174
142
  const {
175
143
  sanitize = false,
@@ -181,7 +149,7 @@ export function buildToolEnv(mode, model, config, options = {}) {
181
149
  const providerUrl = sources[providerKey]?.url || ''
182
150
  const baseUrl = getProviderBaseUrl(providerKey)
183
151
  const apiKey = sanitize ? (config?.apiKeys?.[providerKey] ?? null) : getApiKey(config, providerKey)
184
- const env = cloneInheritedEnv(inheritedEnv, sanitize ? PROXY_SANITIZED_ENV_KEYS : [])
152
+ const env = cloneInheritedEnv(inheritedEnv, sanitize ? SANITIZED_TOOL_ENV_KEYS : [])
185
153
  const providerEnvName = ENV_VAR_NAMES[providerKey]
186
154
  if (includeProviderEnv && providerEnvName && apiKey) env[providerEnvName] = apiKey
187
155
 
@@ -196,154 +164,9 @@ export function buildToolEnv(mode, model, config, options = {}) {
196
164
  env.LLM_MODEL = `openai/${model.modelId}`
197
165
  }
198
166
 
199
- // 📖 Provider-specific envs for tools that expect a different wire format.
200
- if (mode === 'claude-code' && apiKey && baseUrl) {
201
- env.ANTHROPIC_AUTH_TOKEN = apiKey
202
- env.ANTHROPIC_BASE_URL = baseUrl
203
- }
204
-
205
- if (mode === 'gemini' && apiKey && baseUrl) {
206
- env.GEMINI_API_KEY = apiKey
207
- env.GOOGLE_API_KEY = apiKey
208
- env.GOOGLE_GEMINI_BASE_URL = baseUrl
209
- }
210
-
211
167
  return { env, apiKey, baseUrl, providerUrl }
212
168
  }
213
169
 
214
- export async function waitForClaudeProxyRouting(port, token, expectedModelId) {
215
- const expected = typeof expectedModelId === 'string' ? expectedModelId.trim().replace(/^fcm-proxy\//, '') : ''
216
- if (!expected || !port || !token) return false
217
-
218
- const deadline = Date.now() + CLAUDE_PROXY_RELOAD_TIMEOUT_MS
219
- while (Date.now() < deadline) {
220
- try {
221
- const res = await fetch(`http://127.0.0.1:${port}/v1/stats`, {
222
- headers: { authorization: `Bearer ${token}` },
223
- })
224
- if (res.ok) {
225
- const payload = await res.json()
226
- const active = payload?.anthropicRouting?.model
227
- if (typeof active === 'string' && active.replace(/^fcm-proxy\//, '') === expected) {
228
- return true
229
- }
230
- }
231
- } catch { /* daemon may still be reloading — keep polling */ }
232
-
233
- await new Promise(resolve => setTimeout(resolve, CLAUDE_PROXY_RELOAD_INTERVAL_MS))
234
- }
235
-
236
- return false
237
- }
238
-
239
- export function buildCodexProxyArgs(baseUrl) {
240
- return [
241
- '-c', 'model_provider="fcm_proxy"',
242
- '-c', 'model_providers.fcm_proxy.name="FCM Proxy V2"',
243
- '-c', `model_providers.fcm_proxy.base_url=${JSON.stringify(baseUrl)}`,
244
- '-c', 'model_providers.fcm_proxy.env_key="FCM_PROXY_API_KEY"',
245
- '-c', 'model_providers.fcm_proxy.wire_api="responses"',
246
- ]
247
- }
248
-
249
- function compareSemver(a, b) {
250
- const left = String(a || '').split('.').map(part => Number.parseInt(part, 10) || 0)
251
- const right = String(b || '').split('.').map(part => Number.parseInt(part, 10) || 0)
252
- const length = Math.max(left.length, right.length)
253
- for (let idx = 0; idx < length; idx++) {
254
- const lhs = left[idx] || 0
255
- const rhs = right[idx] || 0
256
- if (lhs > rhs) return 1
257
- if (lhs < rhs) return -1
258
- }
259
- return 0
260
- }
261
-
262
- function findExecutableOnPath(command) {
263
- const pathValue = process.env.PATH || ''
264
- const candidates = process.platform === 'win32'
265
- ? [command, `${command}.cmd`, `${command}.exe`]
266
- : [command]
267
-
268
- for (const dir of pathValue.split(delimiter).filter(Boolean)) {
269
- for (const candidate of candidates) {
270
- const fullPath = join(dir, candidate)
271
- try {
272
- accessSync(fullPath, constants.X_OK)
273
- return fullPath
274
- } catch { /* not executable */ }
275
- }
276
- }
277
- return null
278
- }
279
-
280
- function findPackageJsonUpwards(startPath) {
281
- let current = dirname(startPath)
282
- while (current && current !== dirname(current)) {
283
- const packageJsonPath = join(current, 'package.json')
284
- if (existsSync(packageJsonPath)) return packageJsonPath
285
- current = dirname(current)
286
- }
287
- return null
288
- }
289
-
290
- function detectGeminiCliVersion(binaryPath) {
291
- if (!binaryPath) return null
292
- try {
293
- const realPath = realpathSync(binaryPath)
294
- const versionMatch = realPath.match(/gemini-cli[\\/](\d+\.\d+\.\d+)(?:[\\/]|$)/)
295
- if (versionMatch?.[1]) return versionMatch[1]
296
-
297
- const packageJsonPath = findPackageJsonUpwards(realPath)
298
- if (!packageJsonPath) return null
299
- const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
300
- if (typeof pkg?.version === 'string' && pkg.version.length > 0) {
301
- return pkg.version
302
- }
303
- } catch { /* best effort */ }
304
- return null
305
- }
306
-
307
- export function extractGeminiConfigError(output) {
308
- const text = String(output || '').trim()
309
- if (!text.includes('Invalid configuration in ')) return null
310
- const lines = text.split(/\r?\n/).filter(Boolean)
311
- return lines.slice(0, 8).join('\n')
312
- }
313
-
314
- export function inspectGeminiCliSupport(options = {}) {
315
- const binaryPath = options.binaryPath || findExecutableOnPath(options.command || 'gemini')
316
- if (!binaryPath) {
317
- return {
318
- installed: false,
319
- version: null,
320
- supportsProxyBaseUrl: false,
321
- configError: null,
322
- reason: 'Gemini CLI is not installed in PATH.',
323
- }
324
- }
325
-
326
- const version = options.version || detectGeminiCliVersion(binaryPath)
327
- const helpResult = options.helpResult || spawnSync(binaryPath, ['--help'], {
328
- encoding: 'utf8',
329
- timeout: 5000,
330
- env: options.inheritedEnv || process.env,
331
- })
332
- const helpOutput = `${helpResult.stdout || ''}\n${helpResult.stderr || ''}`.trim()
333
- const configError = extractGeminiConfigError(helpOutput)
334
- const supportsProxyBaseUrl = version ? compareSemver(version, GEMINI_PROXY_MIN_VERSION) >= 0 : false
335
-
336
- return {
337
- installed: true,
338
- version,
339
- supportsProxyBaseUrl,
340
- configError,
341
- reason: supportsProxyBaseUrl
342
- ? null
343
- : `Gemini CLI ${version || '(unknown version)'} does not expose stable custom base URL support for proxy mode yet.`,
344
- }
345
- }
346
-
347
170
  function spawnCommand(command, args, env) {
348
171
  return new Promise((resolve, reject) => {
349
172
  const child = spawn(command, args, {
@@ -365,8 +188,8 @@ function spawnCommand(command, args, env) {
365
188
  })
366
189
  }
367
190
 
368
- function writeAiderConfig(model, apiKey, baseUrl) {
369
- const filePath = join(homedir(), '.aider.conf.yml')
191
+ function writeAiderConfig(model, apiKey, baseUrl, paths = getDefaultToolPaths()) {
192
+ const filePath = paths.aiderConfigPath
370
193
  const backupPath = backupIfExists(filePath)
371
194
  const content = [
372
195
  '# 📖 Managed by free-coding-models',
@@ -380,8 +203,8 @@ function writeAiderConfig(model, apiKey, baseUrl) {
380
203
  return { filePath, backupPath }
381
204
  }
382
205
 
383
- function writeCrushConfig(model, apiKey, baseUrl, providerId) {
384
- const filePath = join(homedir(), '.config', 'crush', 'crush.json')
206
+ function writeCrushConfig(model, apiKey, baseUrl, providerId, paths = getDefaultToolPaths()) {
207
+ const filePath = paths.crushConfigPath
385
208
  const backupPath = backupIfExists(filePath)
386
209
  const config = readJson(filePath, { $schema: 'https://charm.land/crush.json' })
387
210
  // 📖 Remove legacy disable_default_providers — it can prevent Crush from auto-selecting models
@@ -412,17 +235,8 @@ function writeCrushConfig(model, apiKey, baseUrl, providerId) {
412
235
  return { filePath, backupPath }
413
236
  }
414
237
 
415
- function writeGeminiConfig(model) {
416
- const filePath = join(homedir(), '.gemini', 'settings.json')
417
- const backupPath = backupIfExists(filePath)
418
- const config = readJson(filePath, {})
419
- config.model = model.modelId
420
- writeJson(filePath, config)
421
- return { filePath, backupPath }
422
- }
423
-
424
- function writeQwenConfig(model, providerKey, apiKey, baseUrl) {
425
- const filePath = join(homedir(), '.qwen', 'settings.json')
238
+ function writeQwenConfig(model, providerKey, apiKey, baseUrl, paths = getDefaultToolPaths()) {
239
+ const filePath = paths.qwenConfigPath
426
240
  const backupPath = backupIfExists(filePath)
427
241
  const config = readJson(filePath, {})
428
242
  if (!config.modelProviders || typeof config.modelProviders !== 'object') config.modelProviders = {}
@@ -441,9 +255,9 @@ function writeQwenConfig(model, providerKey, apiKey, baseUrl) {
441
255
  return { filePath, backupPath, envKey: nextEntry.envKey, apiKey }
442
256
  }
443
257
 
444
- function writePiConfig(model, apiKey, baseUrl) {
258
+ function writePiConfig(model, apiKey, baseUrl, paths = getDefaultToolPaths()) {
445
259
  // 📖 Write models.json with the selected provider config
446
- const modelsFilePath = join(homedir(), '.pi', 'agent', 'models.json')
260
+ const modelsFilePath = paths.piModelsPath
447
261
  const modelsBackupPath = backupIfExists(modelsFilePath)
448
262
  const modelsConfig = readJson(modelsFilePath, { providers: {} })
449
263
  if (!modelsConfig.providers || typeof modelsConfig.providers !== 'object') modelsConfig.providers = {}
@@ -456,7 +270,7 @@ function writePiConfig(model, apiKey, baseUrl) {
456
270
  writeJson(modelsFilePath, modelsConfig)
457
271
 
458
272
  // 📖 Write settings.json to set the model as default on next launch
459
- const settingsFilePath = join(homedir(), '.pi', 'agent', 'settings.json')
273
+ const settingsFilePath = paths.piSettingsPath
460
274
  const settingsBackupPath = backupIfExists(settingsFilePath)
461
275
  const settingsConfig = readJson(settingsFilePath, {})
462
276
  settingsConfig.defaultProvider = 'freeCodingModels'
@@ -469,15 +283,13 @@ function writePiConfig(model, apiKey, baseUrl) {
469
283
  // 📖 writeGooseConfig: Install/update the provider in Goose's custom_providers/, set the
470
284
  // 📖 API key in secrets.yaml, and update config.yaml with GOOSE_PROVIDER + GOOSE_MODEL
471
285
  // 📖 so Goose auto-selects the model on launch.
472
- function writeGooseConfig(model, apiKey, baseUrl, providerKey) {
473
- const home = homedir()
286
+ function writeGooseConfig(model, apiKey, baseUrl, providerKey, paths = getDefaultToolPaths()) {
474
287
  const providerId = `fcm-${providerKey}`
475
288
  const providerLabel = PROVIDER_METADATA[providerKey]?.label || sources[providerKey]?.name || providerKey
476
289
  const secretEnvName = `FCM_${providerKey.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
477
290
 
478
291
  // 📖 Step 1: Write custom provider JSON (same format as endpoint-installer)
479
- const providerDir = join(home, '.config', 'goose', 'custom_providers')
480
- const providerFilePath = join(providerDir, `${providerId}.json`)
292
+ const providerFilePath = join(paths.gooseProvidersDir, `${providerId}.json`)
481
293
  ensureDir(providerFilePath)
482
294
  const providerConfig = {
483
295
  name: providerId,
@@ -493,7 +305,7 @@ function writeGooseConfig(model, apiKey, baseUrl, providerKey) {
493
305
  writeFileSync(providerFilePath, JSON.stringify(providerConfig, null, 2) + '\n')
494
306
 
495
307
  // 📖 Step 2: Write API key to secrets.yaml (simple key: value format)
496
- const secretsPath = join(home, '.config', 'goose', 'secrets.yaml')
308
+ const secretsPath = paths.gooseSecretsPath
497
309
  let secretsContent = ''
498
310
  if (existsSync(secretsPath)) {
499
311
  secretsContent = readFileSync(secretsPath, 'utf8')
@@ -510,7 +322,8 @@ function writeGooseConfig(model, apiKey, baseUrl, providerKey) {
510
322
  writeFileSync(secretsPath, secretsContent)
511
323
 
512
324
  // 📖 Step 3: Update config.yaml — set GOOSE_PROVIDER and GOOSE_MODEL at top level
513
- const configPath = join(home, '.config', 'goose', 'config.yaml')
325
+ const configPath = paths.gooseConfigPath
326
+ const configBackupPath = backupIfExists(configPath)
514
327
  let configContent = ''
515
328
  if (existsSync(configPath)) {
516
329
  configContent = readFileSync(configPath, 'utf8')
@@ -530,11 +343,11 @@ function writeGooseConfig(model, apiKey, baseUrl, providerKey) {
530
343
  }
531
344
  writeFileSync(configPath, configContent)
532
345
 
533
- return { providerFilePath, secretsPath, configPath }
346
+ return { providerFilePath, secretsPath, configPath, configBackupPath }
534
347
  }
535
348
 
536
- function writeAmpConfig(model, baseUrl) {
537
- const filePath = join(homedir(), '.config', 'amp', 'settings.json')
349
+ function writeAmpConfig(model, baseUrl, paths = getDefaultToolPaths()) {
350
+ const filePath = paths.ampConfigPath
538
351
  const backupPath = backupIfExists(filePath)
539
352
  const config = readJson(filePath, {})
540
353
  config['amp.url'] = baseUrl
@@ -543,228 +356,241 @@ function writeAmpConfig(model, baseUrl) {
543
356
  return { filePath, backupPath }
544
357
  }
545
358
 
546
- function printConfigResult(toolName, result) {
547
- if (!result?.filePath) return
548
- console.log(chalk.dim(` 📄 ${toolName} config updated: ${result.filePath}`))
549
- if (result.backupPath) console.log(chalk.dim(` 💾 Backup: ${result.backupPath}`))
359
+ function writeOpenHandsEnv(model, apiKey, baseUrl, paths = getDefaultToolPaths()) {
360
+ const filePath = paths.openHandsEnvPath
361
+ const backupPath = backupIfExists(filePath)
362
+ const lines = [
363
+ '# 📖 Managed by free-coding-models',
364
+ `export OPENAI_API_KEY="${apiKey}"`,
365
+ `export OPENAI_BASE_URL="${baseUrl}"`,
366
+ `export OPENAI_MODEL="${model.modelId}"`,
367
+ `export LLM_API_KEY="${apiKey}"`,
368
+ `export LLM_BASE_URL="${baseUrl}"`,
369
+ `export LLM_MODEL="openai/${model.modelId}"`,
370
+ ]
371
+ ensureDir(filePath)
372
+ writeFileSync(filePath, lines.join('\n') + '\n')
373
+ return { filePath, backupPath }
550
374
  }
551
375
 
552
- function printExperimentalProxyNote() {
553
- console.log(chalk.dim(` ${EXPERIMENTAL_PROXY_TOOLS_NOTE}`))
376
+ function printConfigArtifacts(toolName, artifacts = []) {
377
+ for (const artifact of artifacts) {
378
+ if (!artifact?.path) continue
379
+ const label = artifact.label ? `${artifact.label}: ` : ''
380
+ console.log(chalk.dim(` 📄 ${toolName} ${label}${artifact.path}`))
381
+ if (artifact.backupPath) console.log(chalk.dim(` 💾 Backup: ${artifact.backupPath}`))
382
+ }
554
383
  }
555
384
 
556
- export async function startExternalTool(mode, model, config) {
385
+ /**
386
+ * 📖 prepareExternalToolLaunch persists the selected model into the target tool's
387
+ * 📖 config before launch, then returns the exact command/env/args that should
388
+ * 📖 be spawned. This makes launcher behavior unit-testable without requiring
389
+ * 📖 the real CLIs in PATH.
390
+ *
391
+ * @param {string} mode
392
+ * @param {{ providerKey: string, modelId: string, label: string }} model
393
+ * @param {Record<string, unknown>} config
394
+ * @param {{
395
+ * paths?: Partial<ReturnType<typeof getDefaultToolPaths>>,
396
+ * inheritedEnv?: NodeJS.ProcessEnv,
397
+ * }} [options]
398
+ * @returns {{
399
+ * blocked?: boolean,
400
+ * exitCode?: number,
401
+ * warnings?: string[],
402
+ * command?: string,
403
+ * args?: string[],
404
+ * env?: NodeJS.ProcessEnv,
405
+ * apiKey?: string | null,
406
+ * baseUrl?: string | null,
407
+ * meta: { label: string, emoji: string, flag: string | null },
408
+ * configArtifacts: Array<{ path: string, backupPath: string | null, label?: string }>
409
+ * }}
410
+ */
411
+ export function prepareExternalToolLaunch(mode, model, config, options = {}) {
557
412
  const meta = getToolMeta(mode)
558
- const { env, apiKey, baseUrl } = buildToolEnv(mode, model, config)
559
- const proxySettings = getProxySettings(config)
413
+ const paths = { ...getDefaultToolPaths(), ...(options.paths || {}) }
414
+ const { env, apiKey, baseUrl } = buildToolEnv(mode, model, config, {
415
+ inheritedEnv: options.inheritedEnv,
416
+ })
560
417
 
561
418
  if (!apiKey && mode !== 'amp') {
562
- // 📖 Color provider name the same way as in the main table
563
419
  const providerRgb = PROVIDER_COLOR[model.providerKey] ?? [105, 190, 245]
564
420
  const providerName = sources[model.providerKey]?.name || model.providerKey
565
421
  const coloredProviderName = chalk.bold.rgb(...providerRgb)(providerName)
566
- console.log(chalk.yellow(` ⚠ No API key configured for ${coloredProviderName}.`))
567
- console.log(chalk.dim(' Configure the provider first from the Settings screen (P) or via env vars.'))
568
- console.log()
569
- return 1
422
+ return {
423
+ blocked: true,
424
+ exitCode: 1,
425
+ warnings: [
426
+ ` ⚠ No API key configured for ${coloredProviderName}.`,
427
+ ' Configure the provider first from the Settings screen (P) or via env vars.',
428
+ ],
429
+ meta,
430
+ configArtifacts: [],
431
+ }
570
432
  }
571
433
 
572
- console.log(chalk.cyan(` ▶ Launching ${meta.label} with ${chalk.bold(model.label)}...`))
573
-
574
434
  if (mode === 'aider') {
575
- printConfigResult(meta.label, writeAiderConfig(model, apiKey, baseUrl))
576
- return spawnCommand('aider', ['--model', `openai/${model.modelId}`], env)
435
+ const result = writeAiderConfig(model, apiKey, baseUrl, paths)
436
+ return {
437
+ command: 'aider',
438
+ args: ['--model', `openai/${model.modelId}`],
439
+ env,
440
+ apiKey,
441
+ baseUrl,
442
+ meta,
443
+ configArtifacts: [{ path: result.filePath, backupPath: result.backupPath, label: 'config' }],
444
+ }
577
445
  }
578
446
 
579
447
  if (mode === 'crush') {
580
- let crushApiKey = apiKey
581
- let crushBaseUrl = baseUrl
582
- let providerId = 'freeCodingModels'
583
- let launchModelId = resolveLauncherModelId(model, false)
584
-
585
- if (proxySettings.enabled) {
586
- const started = await ensureProxyRunning(config)
587
- crushApiKey = started.proxyToken
588
- crushBaseUrl = `http://127.0.0.1:${started.port}/v1`
589
- providerId = 'freeCodingModelsProxy'
590
- launchModelId = resolveLauncherModelId(model, true)
591
- console.log(chalk.dim(` 📖 Crush will use the local FCM proxy on :${started.port} for this launch.`))
592
- } else {
593
- console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
448
+ const launchModelId = resolveLauncherModelId(model)
449
+ applyOpenAiCompatEnv(env, apiKey, baseUrl, launchModelId)
450
+ const result = writeCrushConfig({ ...model, modelId: launchModelId }, apiKey, baseUrl, 'freeCodingModels', paths)
451
+ return {
452
+ command: 'crush',
453
+ args: [],
454
+ env,
455
+ apiKey,
456
+ baseUrl,
457
+ meta,
458
+ configArtifacts: [{ path: result.filePath, backupPath: result.backupPath, label: 'config' }],
594
459
  }
595
-
596
- const launchModel = { ...model, modelId: launchModelId }
597
- applyOpenAiCompatEnv(env, crushApiKey, crushBaseUrl, launchModelId)
598
- printConfigResult(meta.label, writeCrushConfig(launchModel, crushApiKey, crushBaseUrl, providerId))
599
- return spawnCommand('crush', [], env)
600
460
  }
601
461
 
602
462
  if (mode === 'goose') {
603
- let gooseBaseUrl = sources[model.providerKey]?.url || baseUrl || ''
604
- let gooseApiKey = apiKey
605
- let gooseModelId = resolveLauncherModelId(model, false)
606
- let gooseProviderKey = model.providerKey
607
-
608
- if (proxySettings.enabled) {
609
- const started = await ensureProxyRunning(config)
610
- gooseApiKey = started.proxyToken
611
- gooseBaseUrl = `http://127.0.0.1:${started.port}/v1/chat/completions`
612
- gooseModelId = resolveLauncherModelId(model, true)
613
- gooseProviderKey = 'proxy'
614
- console.log(chalk.dim(` 📖 Goose will use the local FCM proxy on :${started.port} for this launch.`))
615
- }
616
-
617
- // 📖 Write Goose config: custom provider JSON + secrets.yaml + config.yaml (GOOSE_PROVIDER/GOOSE_MODEL)
618
- const gooseResult = writeGooseConfig({ ...model, modelId: gooseModelId }, gooseApiKey, gooseBaseUrl, gooseProviderKey)
619
- console.log(chalk.dim(` 📄 Goose config updated: ${gooseResult.configPath}`))
620
- console.log(chalk.dim(` 📄 Provider installed: ${gooseResult.providerFilePath}`))
621
-
622
- // 📖 Also set env vars as belt-and-suspenders
623
- env.GOOSE_PROVIDER = `fcm-${gooseProviderKey}`
463
+ const gooseBaseUrl = sources[model.providerKey]?.url || baseUrl || ''
464
+ const gooseModelId = resolveLauncherModelId(model)
465
+ const result = writeGooseConfig({ ...model, modelId: gooseModelId }, apiKey, gooseBaseUrl, model.providerKey, paths)
466
+ env.GOOSE_PROVIDER = `fcm-${model.providerKey}`
624
467
  env.GOOSE_MODEL = gooseModelId
625
- applyOpenAiCompatEnv(env, gooseApiKey, gooseBaseUrl.replace(/\/chat\/completions$/, ''), gooseModelId)
626
- return spawnCommand('goose', [], env)
627
- }
628
-
629
- // 📖 Codex and Gemini require FCM Proxy V2 to talk to the free-provider mesh.
630
- if (mode === 'codex' || mode === 'gemini') {
631
- if (!proxySettings.enabled) {
632
- console.log()
633
- console.log(chalk.red(` ✖ ${meta.label} requires FCM Proxy V2 to work with free providers.`))
634
- console.log()
635
- console.log(chalk.yellow(' The proxy translates between provider protocols and handles key rotation,'))
636
- console.log(chalk.yellow(' which is required for this tool to connect.'))
637
- console.log(chalk.dim(` ${EXPERIMENTAL_PROXY_TOOLS_NOTE}`))
638
- console.log()
639
- console.log(chalk.white(' To enable it:'))
640
- console.log(chalk.dim(' 1. Press ') + chalk.bold.white('J') + chalk.dim(' to open FCM Proxy V2 settings'))
641
- console.log(chalk.dim(' 2. Enable ') + chalk.bold.white('Proxy mode') + chalk.dim(' and install the ') + chalk.bold.white('background service'))
642
- console.log(chalk.dim(' 3. Come back and select your model again'))
643
- console.log()
644
- return 1
468
+ applyOpenAiCompatEnv(env, apiKey, gooseBaseUrl.replace(/\/chat\/completions$/, ''), gooseModelId)
469
+ return {
470
+ command: 'goose',
471
+ args: [],
472
+ env,
473
+ apiKey,
474
+ baseUrl,
475
+ meta,
476
+ configArtifacts: [
477
+ { path: result.providerFilePath, backupPath: null, label: 'provider' },
478
+ { path: result.secretsPath, backupPath: null, label: 'secrets' },
479
+ { path: result.configPath, backupPath: result.configBackupPath || null, label: 'config' },
480
+ ],
645
481
  }
646
482
  }
647
483
 
648
- if (mode === 'claude-code') {
649
- // 📖 Mirror Claude proxy exactly on the client side:
650
- // 📖 Claude gets only ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN, and the
651
- // 📖 proxy owns the fake Claude model ids -> real backend model mapping.
652
- const launchModelId = resolveLauncherModelId(model, true)
653
- const routingChanged = setClaudeProxyModelRouting(config, launchModelId)
654
- if (routingChanged) {
655
- const saveResult = saveConfig(config)
656
- if (!saveResult.success) {
657
- console.log()
658
- console.log(chalk.red(' ✖ Failed to persist the Claude proxy routing before launch.'))
659
- console.log(chalk.dim(` ${saveResult.error || 'Unknown config write error.'}`))
660
- console.log()
661
- return 1
662
- }
484
+ if (mode === 'qwen') {
485
+ const result = writeQwenConfig(model, model.providerKey, apiKey, baseUrl, paths)
486
+ return {
487
+ command: 'qwen',
488
+ args: [],
489
+ env,
490
+ apiKey,
491
+ baseUrl,
492
+ meta,
493
+ configArtifacts: [{ path: result.filePath, backupPath: result.backupPath, label: 'config' }],
663
494
  }
495
+ }
664
496
 
665
- const started = await ensureProxyRunning(config, { forceRestart: true })
666
- const { env: proxyEnv } = buildToolEnv(mode, model, config, {
667
- sanitize: true,
668
- includeCompatDefaults: false,
669
- includeProviderEnv: false,
670
- })
671
- const proxyBase = `http://127.0.0.1:${started.port}`
672
- const claudeProxyToken = `${started.proxyToken}:${launchModelId}`
673
- proxyEnv.ANTHROPIC_BASE_URL = proxyBase
674
- proxyEnv.ANTHROPIC_AUTH_TOKEN = claudeProxyToken
675
-
676
- const routingReady = await waitForClaudeProxyRouting(started.port, started.proxyToken, launchModelId)
677
- if (!routingReady) {
678
- console.log(chalk.yellow(` ⚠ Claude proxy routing reload is taking longer than expected; launching anyway.`))
679
- console.log(chalk.dim(` ${EXPERIMENTAL_PROXY_TOOLS_NOTE}`))
497
+ if (mode === 'openhands') {
498
+ const result = writeOpenHandsEnv(model, apiKey, baseUrl, paths)
499
+ env.LLM_MODEL = model.modelId
500
+ env.LLM_API_KEY = apiKey || env.LLM_API_KEY
501
+ if (baseUrl) env.LLM_BASE_URL = baseUrl
502
+ return {
503
+ command: 'openhands',
504
+ args: ['--override-with-envs'],
505
+ env,
506
+ apiKey,
507
+ baseUrl,
508
+ meta,
509
+ configArtifacts: [{ path: result.filePath, backupPath: result.backupPath, label: 'env file' }],
680
510
  }
511
+ }
681
512
 
682
- if (routingChanged && proxySettings.enabled !== true) {
683
- console.log(chalk.dim(' 📖 Proxy mode was auto-enabled for Claude Code because this integration is proxy-only.'))
684
- }
685
- console.log(chalk.dim(` 📖 Claude Code routed through FCM proxy on :${started.port} with proxy-side Claude model mapping`))
686
- console.log(chalk.dim(` 📖 Claude itself is forced onto the safe alias: ${CLAUDE_PROXY_CLIENT_MODEL}`))
687
- console.log(chalk.dim(` 📖 All Claude families now resolve to: ${model.label} (${launchModelId})`))
688
- return spawnCommand('claude', buildClaudeProxyArgs(), proxyEnv)
689
- }
690
-
691
- if (mode === 'codex') {
692
- const started = await ensureProxyRunning(config)
693
- const { env: proxyEnv } = buildToolEnv(mode, model, config, {
694
- sanitize: true,
695
- includeCompatDefaults: false,
696
- includeProviderEnv: false,
697
- })
698
- const launchModelId = resolveLauncherModelId(model, true)
699
- const proxyBaseUrl = `http://127.0.0.1:${started.port}/v1`
700
- proxyEnv.FCM_PROXY_API_KEY = started.proxyToken
701
- console.log(chalk.dim(` 📖 Codex routed through FCM proxy on :${started.port}`))
702
- return spawnCommand('codex', [...buildCodexProxyArgs(proxyBaseUrl), '--model', launchModelId], proxyEnv)
703
- }
704
-
705
- if (mode === 'gemini') {
706
- const geminiSupport = inspectGeminiCliSupport()
707
- if (geminiSupport.configError) {
708
- console.log()
709
- console.log(chalk.red(' ✖ Gemini CLI configuration is invalid, so the proxy launch is blocked before auth.'))
710
- console.log(chalk.dim(` ${geminiSupport.configError.split('\n').join('\n ')}`))
711
- printExperimentalProxyNote()
712
- console.log(chalk.dim(' Fix ~/.gemini/settings.json, then try again.'))
713
- console.log()
714
- return 1
513
+ if (mode === 'amp') {
514
+ const result = writeAmpConfig(model, baseUrl, paths)
515
+ return {
516
+ command: 'amp',
517
+ args: [],
518
+ env,
519
+ apiKey,
520
+ baseUrl,
521
+ meta,
522
+ configArtifacts: [{ path: result.filePath, backupPath: result.backupPath, label: 'config' }],
715
523
  }
716
- if (!geminiSupport.supportsProxyBaseUrl) {
717
- console.log()
718
- const versionLabel = geminiSupport.version ? `v${geminiSupport.version}` : 'this installed version'
719
- console.log(chalk.red(` ✖ Gemini CLI ${versionLabel} is not proxy-compatible in FCM yet.`))
720
- console.log(chalk.yellow(' This build does not expose the custom base-URL contract we need, so launching it through the proxy would be misleading.'))
721
- printExperimentalProxyNote()
722
- console.log(chalk.dim(` Expected: Gemini CLI ${GEMINI_PROXY_MIN_VERSION}+ with stable proxy base URL support.`))
723
- console.log()
724
- return 1
524
+ }
525
+
526
+ if (mode === 'pi') {
527
+ const result = writePiConfig(model, apiKey, baseUrl, paths)
528
+ return {
529
+ command: 'pi',
530
+ args: ['--provider', 'freeCodingModels', '--model', model.modelId, '--api-key', apiKey],
531
+ env,
532
+ apiKey,
533
+ baseUrl,
534
+ meta,
535
+ configArtifacts: [
536
+ { path: result.filePath, backupPath: result.backupPath, label: 'models' },
537
+ { path: result.settingsFilePath, backupPath: result.settingsBackupPath, label: 'settings' },
538
+ ],
725
539
  }
540
+ }
726
541
 
727
- const started = await ensureProxyRunning(config)
728
- const launchModelId = resolveLauncherModelId(model, true)
729
- const { env: proxyEnv } = buildToolEnv(mode, model, config, {
730
- sanitize: true,
731
- includeCompatDefaults: false,
732
- includeProviderEnv: false,
733
- })
734
- proxyEnv.GEMINI_API_KEY = started.proxyToken
735
- proxyEnv.GOOGLE_API_KEY = started.proxyToken
736
- proxyEnv.GOOGLE_GEMINI_BASE_URL = `http://127.0.0.1:${started.port}/v1`
737
- printConfigResult(meta.label, writeGeminiConfig({ ...model, modelId: launchModelId }))
738
- console.log(chalk.dim(` 📖 Gemini routed through FCM proxy on :${started.port}`))
739
- return spawnCommand('gemini', ['--model', launchModelId], proxyEnv)
542
+ return {
543
+ blocked: true,
544
+ exitCode: 1,
545
+ warnings: [chalk.red(` X Unsupported external tool mode: ${mode}`)],
546
+ meta,
547
+ configArtifacts: [],
548
+ }
549
+ }
550
+
551
+ export async function startExternalTool(mode, model, config) {
552
+ const launchPlan = prepareExternalToolLaunch(mode, model, config)
553
+ const { meta } = launchPlan
554
+
555
+ if (launchPlan.blocked) {
556
+ for (const warning of launchPlan.warnings || []) console.log(warning)
557
+ console.log()
558
+ return launchPlan.exitCode || 1
559
+ }
560
+
561
+ console.log(chalk.cyan(` ▶ Launching ${meta.label} with ${chalk.bold(model.label)}...`))
562
+ printConfigArtifacts(meta.label, launchPlan.configArtifacts)
563
+
564
+ if (mode === 'aider') {
565
+ return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
566
+ }
567
+
568
+ if (mode === 'crush') {
569
+ console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
570
+ return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
571
+ }
572
+
573
+ if (mode === 'goose') {
574
+ return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
740
575
  }
741
576
 
742
577
  if (mode === 'qwen') {
743
- printConfigResult(meta.label, writeQwenConfig(model, model.providerKey, apiKey, baseUrl))
744
- return spawnCommand('qwen', [], env)
578
+ return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
745
579
  }
746
580
 
747
581
  if (mode === 'openhands') {
748
- // 📖 OpenHands supports LLM_MODEL env var to set the default model
749
- env.LLM_MODEL = model.modelId
750
- env.LLM_API_KEY = apiKey || env.LLM_API_KEY
751
- if (baseUrl) env.LLM_BASE_URL = baseUrl
752
582
  console.log(chalk.dim(` 📖 OpenHands launched with model: ${model.modelId}`))
753
- return spawnCommand('openhands', ['--override-with-envs'], env)
583
+ return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
754
584
  }
755
585
 
756
586
  if (mode === 'amp') {
757
- printConfigResult(meta.label, writeAmpConfig(model, baseUrl))
758
587
  console.log(chalk.dim(` 📖 Amp config updated with model: ${model.modelId}`))
759
- return spawnCommand('amp', [], env)
588
+ return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
760
589
  }
761
590
 
762
591
  if (mode === 'pi') {
763
- const piResult = writePiConfig(model, apiKey, baseUrl)
764
- printConfigResult(meta.label, { filePath: piResult.filePath, backupPath: piResult.backupPath })
765
- printConfigResult(meta.label, { filePath: piResult.settingsFilePath, backupPath: piResult.settingsBackupPath })
766
592
  // 📖 Pi supports --provider and --model flags for guaranteed auto-selection
767
- return spawnCommand('pi', ['--provider', 'freeCodingModels', '--model', model.modelId, '--api-key', apiKey], env)
593
+ return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
768
594
  }
769
595
 
770
596
  console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))