free-coding-models 0.2.17 → 0.3.1

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.
@@ -15,11 +15,23 @@
15
15
  * For those, we prefer a transparent warning over pretending the integration is
16
16
  * fully official. The user still gets a reproducible env/config handoff.
17
17
  *
18
+ * 📖 Goose: writes custom provider JSON + secrets.yaml + updates config.yaml (GOOSE_PROVIDER/GOOSE_MODEL)
19
+ * 📖 Crush: writes crush.json with provider config + models.large/small defaults
20
+ * 📖 Pi: uses --provider/--model CLI flags for guaranteed auto-selection
21
+ * 📖 Aider: writes ~/.aider.conf.yml + passes --model flag
22
+ * 📖 Claude Code: uses ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN only (mirrors free-claude-code)
23
+ * 📖 Codex CLI: uses a custom model_provider override so Codex stays in explicit API-provider mode
24
+ * 📖 Gemini CLI: proxy mode is capability-gated because older builds do not support custom base URL routing cleanly
25
+ *
18
26
  * @functions
19
27
  * → `resolveLauncherModelId` — choose the provider-specific id or proxy slug for a launch
28
+ * → `buildCodexProxyArgs` — force Codex into a proxy-backed custom provider config
29
+ * → `inspectGeminiCliSupport` — detect whether the installed Gemini CLI can use proxy mode safely
30
+ * → `writeGooseConfig` — install provider + set GOOSE_PROVIDER/GOOSE_MODEL in config.yaml
31
+ * → `writeCrushConfig` — write provider + models.large/small to crush.json
20
32
  * → `startExternalTool` — configure and launch the selected external tool mode
21
33
  *
22
- * @exports resolveLauncherModelId, startExternalTool
34
+ * @exports resolveLauncherModelId, buildCodexProxyArgs, inspectGeminiCliSupport, startExternalTool
23
35
  *
24
36
  * @see src/tool-metadata.js
25
37
  * @see src/provider-metadata.js
@@ -27,16 +39,42 @@
27
39
  */
28
40
 
29
41
  import chalk from 'chalk'
30
- import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
42
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, copyFileSync } from 'fs'
31
43
  import { homedir } from 'os'
32
- import { dirname, join } from 'path'
33
- import { spawn } from 'child_process'
44
+ import { delimiter, dirname, join } from 'path'
45
+ import { spawn, spawnSync } from 'child_process'
34
46
  import { sources } from '../sources.js'
35
47
  import { PROVIDER_COLOR } from './render-table.js'
36
48
  import { getApiKey, getProxySettings } from './config.js'
37
49
  import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
38
50
  import { getToolMeta } from './tool-metadata.js'
39
51
  import { ensureProxyRunning, resolveProxyModelId } from './opencode.js'
52
+ import { PROVIDER_METADATA } from './provider-metadata.js'
53
+
54
+ const OPENAI_COMPAT_ENV_KEYS = [
55
+ 'OPENAI_API_KEY',
56
+ 'OPENAI_BASE_URL',
57
+ 'OPENAI_API_BASE',
58
+ 'OPENAI_MODEL',
59
+ 'LLM_API_KEY',
60
+ 'LLM_BASE_URL',
61
+ 'LLM_MODEL',
62
+ ]
63
+ const ANTHROPIC_ENV_KEYS = [
64
+ 'ANTHROPIC_API_KEY',
65
+ 'ANTHROPIC_AUTH_TOKEN',
66
+ 'ANTHROPIC_BASE_URL',
67
+ 'ANTHROPIC_MODEL',
68
+ ]
69
+ const GEMINI_ENV_KEYS = [
70
+ 'GEMINI_API_KEY',
71
+ 'GOOGLE_API_KEY',
72
+ 'GOOGLE_GEMINI_BASE_URL',
73
+ 'GOOGLE_VERTEX_BASE_URL',
74
+ ]
75
+ const PROXY_SANITIZED_ENV_KEYS = [...OPENAI_COMPAT_ENV_KEYS, ...ANTHROPIC_ENV_KEYS, ...GEMINI_ENV_KEYS]
76
+ const GEMINI_PROXY_MIN_VERSION = '0.34.0'
77
+ 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.'
40
78
 
41
79
  function ensureDir(filePath) {
42
80
  const dir = dirname(filePath)
@@ -73,6 +111,16 @@ function getProviderBaseUrl(providerKey) {
73
111
  .replace(/\/predictions$/i, '')
74
112
  }
75
113
 
114
+ function deleteEnvKeys(env, keys) {
115
+ for (const key of keys) delete env[key]
116
+ }
117
+
118
+ function cloneInheritedEnv(inheritedEnv = process.env, sanitizeKeys = []) {
119
+ const env = { ...inheritedEnv }
120
+ deleteEnvKeys(env, sanitizeKeys)
121
+ return env
122
+ }
123
+
76
124
  function applyOpenAiCompatEnv(env, apiKey, baseUrl, modelId) {
77
125
  if (!apiKey || !baseUrl || !modelId) return env
78
126
  env.OPENAI_API_KEY = apiKey
@@ -98,17 +146,23 @@ export function resolveLauncherModelId(model, useProxy = false) {
98
146
  return model?.modelId ?? ''
99
147
  }
100
148
 
101
- function buildToolEnv(mode, model, config) {
149
+ export function buildToolEnv(mode, model, config, options = {}) {
150
+ const {
151
+ sanitize = false,
152
+ includeCompatDefaults = true,
153
+ includeProviderEnv = true,
154
+ inheritedEnv = process.env,
155
+ } = options
102
156
  const providerKey = model.providerKey
103
157
  const providerUrl = sources[providerKey]?.url || ''
104
158
  const baseUrl = getProviderBaseUrl(providerKey)
105
159
  const apiKey = getApiKey(config, providerKey)
106
- const env = { ...process.env }
160
+ const env = cloneInheritedEnv(inheritedEnv, sanitize ? PROXY_SANITIZED_ENV_KEYS : [])
107
161
  const providerEnvName = ENV_VAR_NAMES[providerKey]
108
- if (providerEnvName && apiKey) env[providerEnvName] = apiKey
162
+ if (includeProviderEnv && providerEnvName && apiKey) env[providerEnvName] = apiKey
109
163
 
110
164
  // 📖 OpenAI-compatible defaults reused by multiple CLIs.
111
- if (apiKey && baseUrl) {
165
+ if (includeCompatDefaults && apiKey && baseUrl) {
112
166
  env.OPENAI_API_KEY = apiKey
113
167
  env.OPENAI_BASE_URL = baseUrl
114
168
  env.OPENAI_API_BASE = baseUrl
@@ -126,6 +180,7 @@ function buildToolEnv(mode, model, config) {
126
180
  }
127
181
 
128
182
  if (mode === 'gemini' && apiKey && baseUrl) {
183
+ env.GEMINI_API_KEY = apiKey
129
184
  env.GOOGLE_API_KEY = apiKey
130
185
  env.GOOGLE_GEMINI_BASE_URL = baseUrl
131
186
  }
@@ -133,6 +188,114 @@ function buildToolEnv(mode, model, config) {
133
188
  return { env, apiKey, baseUrl, providerUrl }
134
189
  }
135
190
 
191
+ export function buildCodexProxyArgs(baseUrl) {
192
+ return [
193
+ '-c', 'model_provider="fcm_proxy"',
194
+ '-c', 'model_providers.fcm_proxy.name="FCM Proxy V2"',
195
+ '-c', `model_providers.fcm_proxy.base_url=${JSON.stringify(baseUrl)}`,
196
+ '-c', 'model_providers.fcm_proxy.env_key="FCM_PROXY_API_KEY"',
197
+ '-c', 'model_providers.fcm_proxy.wire_api="responses"',
198
+ ]
199
+ }
200
+
201
+ function compareSemver(a, b) {
202
+ const left = String(a || '').split('.').map(part => Number.parseInt(part, 10) || 0)
203
+ const right = String(b || '').split('.').map(part => Number.parseInt(part, 10) || 0)
204
+ const length = Math.max(left.length, right.length)
205
+ for (let idx = 0; idx < length; idx++) {
206
+ const lhs = left[idx] || 0
207
+ const rhs = right[idx] || 0
208
+ if (lhs > rhs) return 1
209
+ if (lhs < rhs) return -1
210
+ }
211
+ return 0
212
+ }
213
+
214
+ function findExecutableOnPath(command) {
215
+ const pathValue = process.env.PATH || ''
216
+ const candidates = process.platform === 'win32'
217
+ ? [command, `${command}.cmd`, `${command}.exe`]
218
+ : [command]
219
+
220
+ for (const dir of pathValue.split(delimiter).filter(Boolean)) {
221
+ for (const candidate of candidates) {
222
+ const fullPath = join(dir, candidate)
223
+ try {
224
+ accessSync(fullPath, constants.X_OK)
225
+ return fullPath
226
+ } catch { /* not executable */ }
227
+ }
228
+ }
229
+ return null
230
+ }
231
+
232
+ function findPackageJsonUpwards(startPath) {
233
+ let current = dirname(startPath)
234
+ while (current && current !== dirname(current)) {
235
+ const packageJsonPath = join(current, 'package.json')
236
+ if (existsSync(packageJsonPath)) return packageJsonPath
237
+ current = dirname(current)
238
+ }
239
+ return null
240
+ }
241
+
242
+ function detectGeminiCliVersion(binaryPath) {
243
+ if (!binaryPath) return null
244
+ try {
245
+ const realPath = realpathSync(binaryPath)
246
+ const versionMatch = realPath.match(/gemini-cli[\\/](\d+\.\d+\.\d+)(?:[\\/]|$)/)
247
+ if (versionMatch?.[1]) return versionMatch[1]
248
+
249
+ const packageJsonPath = findPackageJsonUpwards(realPath)
250
+ if (!packageJsonPath) return null
251
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
252
+ if (typeof pkg?.version === 'string' && pkg.version.length > 0) {
253
+ return pkg.version
254
+ }
255
+ } catch { /* best effort */ }
256
+ return null
257
+ }
258
+
259
+ export function extractGeminiConfigError(output) {
260
+ const text = String(output || '').trim()
261
+ if (!text.includes('Invalid configuration in ')) return null
262
+ const lines = text.split(/\r?\n/).filter(Boolean)
263
+ return lines.slice(0, 8).join('\n')
264
+ }
265
+
266
+ export function inspectGeminiCliSupport(options = {}) {
267
+ const binaryPath = options.binaryPath || findExecutableOnPath(options.command || 'gemini')
268
+ if (!binaryPath) {
269
+ return {
270
+ installed: false,
271
+ version: null,
272
+ supportsProxyBaseUrl: false,
273
+ configError: null,
274
+ reason: 'Gemini CLI is not installed in PATH.',
275
+ }
276
+ }
277
+
278
+ const version = options.version || detectGeminiCliVersion(binaryPath)
279
+ const helpResult = options.helpResult || spawnSync(binaryPath, ['--help'], {
280
+ encoding: 'utf8',
281
+ timeout: 5000,
282
+ env: options.inheritedEnv || process.env,
283
+ })
284
+ const helpOutput = `${helpResult.stdout || ''}\n${helpResult.stderr || ''}`.trim()
285
+ const configError = extractGeminiConfigError(helpOutput)
286
+ const supportsProxyBaseUrl = version ? compareSemver(version, GEMINI_PROXY_MIN_VERSION) >= 0 : false
287
+
288
+ return {
289
+ installed: true,
290
+ version,
291
+ supportsProxyBaseUrl,
292
+ configError,
293
+ reason: supportsProxyBaseUrl
294
+ ? null
295
+ : `Gemini CLI ${version || '(unknown version)'} does not expose stable custom base URL support for proxy mode yet.`,
296
+ }
297
+ }
298
+
136
299
  function spawnCommand(command, args, env) {
137
300
  return new Promise((resolve, reject) => {
138
301
  const child = spawn(command, args, {
@@ -173,8 +336,10 @@ function writeCrushConfig(model, apiKey, baseUrl, providerId) {
173
336
  const filePath = join(homedir(), '.config', 'crush', 'crush.json')
174
337
  const backupPath = backupIfExists(filePath)
175
338
  const config = readJson(filePath, { $schema: 'https://charm.land/crush.json' })
176
- if (!config.options || typeof config.options !== 'object') config.options = {}
177
- config.options.disable_default_providers = true
339
+ // 📖 Remove legacy disable_default_providers it can prevent Crush from auto-selecting models
340
+ if (config.options && config.options.disable_default_providers) {
341
+ delete config.options.disable_default_providers
342
+ }
178
343
  if (!config.providers || typeof config.providers !== 'object') config.providers = {}
179
344
  config.providers[providerId] = {
180
345
  name: 'Free Coding Models',
@@ -189,10 +354,11 @@ function writeCrushConfig(model, apiKey, baseUrl, providerId) {
189
354
  ],
190
355
  }
191
356
  // 📖 Crush expects structured selected models at config.models.{large,small}.
192
- // 📖 Root `crush` reads these defaults in interactive mode, unlike `crush run --model`.
357
+ // 📖 Setting both large AND small ensures Crush auto-selects the model in interactive mode.
193
358
  config.models = {
194
359
  ...(config.models && typeof config.models === 'object' ? config.models : {}),
195
360
  large: { model: model.modelId, provider: providerId },
361
+ small: { model: model.modelId, provider: providerId },
196
362
  }
197
363
  writeJson(filePath, config)
198
364
  return { filePath, backupPath }
@@ -252,6 +418,73 @@ function writePiConfig(model, apiKey, baseUrl) {
252
418
  return { filePath: modelsFilePath, backupPath: modelsBackupPath, settingsFilePath, settingsBackupPath }
253
419
  }
254
420
 
421
+ // 📖 writeGooseConfig: Install/update the provider in Goose's custom_providers/, set the
422
+ // 📖 API key in secrets.yaml, and update config.yaml with GOOSE_PROVIDER + GOOSE_MODEL
423
+ // 📖 so Goose auto-selects the model on launch.
424
+ function writeGooseConfig(model, apiKey, baseUrl, providerKey) {
425
+ const home = homedir()
426
+ const providerId = `fcm-${providerKey}`
427
+ const providerLabel = PROVIDER_METADATA[providerKey]?.label || sources[providerKey]?.name || providerKey
428
+ const secretEnvName = `FCM_${providerKey.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
429
+
430
+ // 📖 Step 1: Write custom provider JSON (same format as endpoint-installer)
431
+ const providerDir = join(home, '.config', 'goose', 'custom_providers')
432
+ const providerFilePath = join(providerDir, `${providerId}.json`)
433
+ ensureDir(providerFilePath)
434
+ const providerConfig = {
435
+ name: providerId,
436
+ engine: 'openai',
437
+ display_name: `FCM ${providerLabel}`,
438
+ description: `Managed by free-coding-models for ${providerLabel}`,
439
+ api_key_env: secretEnvName,
440
+ base_url: baseUrl?.endsWith('/chat/completions') ? baseUrl : (baseUrl || ''),
441
+ models: [{ name: model.modelId, context_limit: 128000 }],
442
+ supports_streaming: true,
443
+ requires_auth: true,
444
+ }
445
+ writeFileSync(providerFilePath, JSON.stringify(providerConfig, null, 2) + '\n')
446
+
447
+ // 📖 Step 2: Write API key to secrets.yaml (simple key: value format)
448
+ const secretsPath = join(home, '.config', 'goose', 'secrets.yaml')
449
+ let secretsContent = ''
450
+ if (existsSync(secretsPath)) {
451
+ secretsContent = readFileSync(secretsPath, 'utf8')
452
+ }
453
+ // 📖 Replace existing secret or append new one
454
+ const secretLine = `${secretEnvName}: ${JSON.stringify(apiKey)}`
455
+ const secretRegex = new RegExp(`^${secretEnvName}:.*$`, 'm')
456
+ if (secretRegex.test(secretsContent)) {
457
+ secretsContent = secretsContent.replace(secretRegex, secretLine)
458
+ } else {
459
+ secretsContent = secretsContent.trimEnd() + '\n' + secretLine + '\n'
460
+ }
461
+ ensureDir(secretsPath)
462
+ writeFileSync(secretsPath, secretsContent)
463
+
464
+ // 📖 Step 3: Update config.yaml — set GOOSE_PROVIDER and GOOSE_MODEL at top level
465
+ const configPath = join(home, '.config', 'goose', 'config.yaml')
466
+ let configContent = ''
467
+ if (existsSync(configPath)) {
468
+ configContent = readFileSync(configPath, 'utf8')
469
+ }
470
+ // 📖 Replace or add GOOSE_PROVIDER line
471
+ if (/^GOOSE_PROVIDER:.*/m.test(configContent)) {
472
+ configContent = configContent.replace(/^GOOSE_PROVIDER:.*/m, `GOOSE_PROVIDER: ${providerId}`)
473
+ } else {
474
+ configContent = `GOOSE_PROVIDER: ${providerId}\n` + configContent
475
+ }
476
+ // 📖 Replace or add GOOSE_MODEL line
477
+ if (/^GOOSE_MODEL:.*/m.test(configContent)) {
478
+ configContent = configContent.replace(/^GOOSE_MODEL:.*/m, `GOOSE_MODEL: ${model.modelId}`)
479
+ } else {
480
+ // 📖 Insert after GOOSE_PROVIDER line
481
+ configContent = configContent.replace(/^(GOOSE_PROVIDER:.*)/m, `$1\nGOOSE_MODEL: ${model.modelId}`)
482
+ }
483
+ writeFileSync(configPath, configContent)
484
+
485
+ return { providerFilePath, secretsPath, configPath }
486
+ }
487
+
255
488
  function writeAmpConfig(model, baseUrl) {
256
489
  const filePath = join(homedir(), '.config', 'amp', 'settings.json')
257
490
  const backupPath = backupIfExists(filePath)
@@ -268,6 +501,10 @@ function printConfigResult(toolName, result) {
268
501
  if (result.backupPath) console.log(chalk.dim(` 💾 Backup: ${result.backupPath}`))
269
502
  }
270
503
 
504
+ function printExperimentalProxyNote() {
505
+ console.log(chalk.dim(` ${EXPERIMENTAL_PROXY_TOOLS_NOTE}`))
506
+ }
507
+
271
508
  export async function startExternalTool(mode, model, config) {
272
509
  const meta = getToolMeta(mode)
273
510
  const { env, apiKey, baseUrl } = buildToolEnv(mode, model, config)
@@ -315,40 +552,119 @@ export async function startExternalTool(mode, model, config) {
315
552
  }
316
553
 
317
554
  if (mode === 'goose') {
318
- let gooseBaseUrl = baseUrl
555
+ let gooseBaseUrl = sources[model.providerKey]?.url || baseUrl || ''
319
556
  let gooseApiKey = apiKey
320
557
  let gooseModelId = resolveLauncherModelId(model, false)
558
+ let gooseProviderKey = model.providerKey
321
559
 
322
560
  if (proxySettings.enabled) {
323
561
  const started = await ensureProxyRunning(config)
324
562
  gooseApiKey = started.proxyToken
325
- gooseBaseUrl = `http://127.0.0.1:${started.port}/v1`
563
+ gooseBaseUrl = `http://127.0.0.1:${started.port}/v1/chat/completions`
326
564
  gooseModelId = resolveLauncherModelId(model, true)
327
- applyOpenAiCompatEnv(env, gooseApiKey, gooseBaseUrl, gooseModelId)
565
+ gooseProviderKey = 'proxy'
328
566
  console.log(chalk.dim(` 📖 Goose will use the local FCM proxy on :${started.port} for this launch.`))
329
567
  }
330
568
 
331
- env.OPENAI_HOST = gooseBaseUrl
332
- env.OPENAI_BASE_PATH = 'v1/chat/completions'
333
- env.OPENAI_MODEL = gooseModelId
334
- console.log(chalk.dim(` 📖 Goose uses env-based OpenAI-compatible configuration for ${proxySettings.enabled ? 'the proxy' : 'this provider'} launch.`))
569
+ // 📖 Write Goose config: custom provider JSON + secrets.yaml + config.yaml (GOOSE_PROVIDER/GOOSE_MODEL)
570
+ const gooseResult = writeGooseConfig({ ...model, modelId: gooseModelId }, gooseApiKey, gooseBaseUrl, gooseProviderKey)
571
+ console.log(chalk.dim(` 📄 Goose config updated: ${gooseResult.configPath}`))
572
+ console.log(chalk.dim(` 📄 Provider installed: ${gooseResult.providerFilePath}`))
573
+
574
+ // 📖 Also set env vars as belt-and-suspenders
575
+ env.GOOSE_PROVIDER = `fcm-${gooseProviderKey}`
576
+ env.GOOSE_MODEL = gooseModelId
577
+ applyOpenAiCompatEnv(env, gooseApiKey, gooseBaseUrl.replace(/\/chat\/completions$/, ''), gooseModelId)
335
578
  return spawnCommand('goose', [], env)
336
579
  }
337
580
 
581
+ // 📖 Claude Code, Codex, and Gemini require the FCM Proxy V2 background service.
582
+ // 📖 Without it, these tools cannot connect to the free providers (protocol mismatch / no direct support).
583
+ if (mode === 'claude-code' || mode === 'codex' || mode === 'gemini') {
584
+ if (!proxySettings.enabled) {
585
+ console.log()
586
+ console.log(chalk.red(` ✖ ${meta.label} requires FCM Proxy V2 to work with free providers.`))
587
+ console.log()
588
+ console.log(chalk.yellow(' The proxy translates between provider protocols and handles key rotation,'))
589
+ console.log(chalk.yellow(' which is required for this tool to connect.'))
590
+ console.log(chalk.dim(` ${EXPERIMENTAL_PROXY_TOOLS_NOTE}`))
591
+ console.log()
592
+ console.log(chalk.white(' To enable it:'))
593
+ console.log(chalk.dim(' 1. Press ') + chalk.bold.white('J') + chalk.dim(' to open FCM Proxy V2 settings'))
594
+ console.log(chalk.dim(' 2. Enable ') + chalk.bold.white('Proxy mode') + chalk.dim(' and install the ') + chalk.bold.white('background service'))
595
+ console.log(chalk.dim(' 3. Come back and select your model again'))
596
+ console.log()
597
+ return 1
598
+ }
599
+ }
600
+
338
601
  if (mode === 'claude-code') {
339
- console.log(chalk.yellow(' ⚠ Claude Code expects an Anthropic/Bedrock/Vertex-compatible gateway.'))
340
- console.log(chalk.dim(' This launch passes proxy env vars, but your endpoint must support Claude Code wire semantics.'))
341
- return spawnCommand('claude', ['--model', model.modelId], env)
602
+ // 📖 Claude Code needs Anthropic-compatible wire format (POST /v1/messages).
603
+ // 📖 Mirror free-claude-code: one auth env only (`ANTHROPIC_AUTH_TOKEN`) plus base URL.
604
+ const started = await ensureProxyRunning(config)
605
+ const { env: proxyEnv } = buildToolEnv(mode, model, config, {
606
+ sanitize: true,
607
+ includeCompatDefaults: false,
608
+ includeProviderEnv: false,
609
+ })
610
+ const proxyBase = `http://127.0.0.1:${started.port}`
611
+ const launchModelId = resolveLauncherModelId(model, true)
612
+ proxyEnv.ANTHROPIC_BASE_URL = proxyBase
613
+ proxyEnv.ANTHROPIC_AUTH_TOKEN = started.proxyToken
614
+ proxyEnv.ANTHROPIC_MODEL = launchModelId
615
+ console.log(chalk.dim(` 📖 Claude Code routed through FCM proxy on :${started.port} (Anthropic translation enabled)`))
616
+ return spawnCommand('claude', ['--model', launchModelId], proxyEnv)
342
617
  }
343
618
 
344
619
  if (mode === 'codex') {
345
- console.log(chalk.dim(' 📖 Codex CLI is launched with proxy env vars for this session.'))
346
- return spawnCommand('codex', ['--model', model.modelId], env)
620
+ const started = await ensureProxyRunning(config)
621
+ const { env: proxyEnv } = buildToolEnv(mode, model, config, {
622
+ sanitize: true,
623
+ includeCompatDefaults: false,
624
+ includeProviderEnv: false,
625
+ })
626
+ const launchModelId = resolveLauncherModelId(model, true)
627
+ const proxyBaseUrl = `http://127.0.0.1:${started.port}/v1`
628
+ proxyEnv.FCM_PROXY_API_KEY = started.proxyToken
629
+ console.log(chalk.dim(` 📖 Codex routed through FCM proxy on :${started.port}`))
630
+ return spawnCommand('codex', [...buildCodexProxyArgs(proxyBaseUrl), '--model', launchModelId], proxyEnv)
347
631
  }
348
632
 
349
633
  if (mode === 'gemini') {
350
- printConfigResult(meta.label, writeGeminiConfig(model))
351
- return spawnCommand('gemini', ['--model', model.modelId], env)
634
+ const geminiSupport = inspectGeminiCliSupport()
635
+ if (geminiSupport.configError) {
636
+ console.log()
637
+ console.log(chalk.red(' ✖ Gemini CLI configuration is invalid, so the proxy launch is blocked before auth.'))
638
+ console.log(chalk.dim(` ${geminiSupport.configError.split('\n').join('\n ')}`))
639
+ printExperimentalProxyNote()
640
+ console.log(chalk.dim(' Fix ~/.gemini/settings.json, then try again.'))
641
+ console.log()
642
+ return 1
643
+ }
644
+ if (!geminiSupport.supportsProxyBaseUrl) {
645
+ console.log()
646
+ const versionLabel = geminiSupport.version ? `v${geminiSupport.version}` : 'this installed version'
647
+ console.log(chalk.red(` ✖ Gemini CLI ${versionLabel} is not proxy-compatible in FCM yet.`))
648
+ 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.'))
649
+ printExperimentalProxyNote()
650
+ console.log(chalk.dim(` Expected: Gemini CLI ${GEMINI_PROXY_MIN_VERSION}+ with stable proxy base URL support.`))
651
+ console.log()
652
+ return 1
653
+ }
654
+
655
+ const started = await ensureProxyRunning(config)
656
+ const launchModelId = resolveLauncherModelId(model, true)
657
+ const { env: proxyEnv } = buildToolEnv(mode, model, config, {
658
+ sanitize: true,
659
+ includeCompatDefaults: false,
660
+ includeProviderEnv: false,
661
+ })
662
+ proxyEnv.GEMINI_API_KEY = started.proxyToken
663
+ proxyEnv.GOOGLE_API_KEY = started.proxyToken
664
+ proxyEnv.GOOGLE_GEMINI_BASE_URL = `http://127.0.0.1:${started.port}/v1`
665
+ printConfigResult(meta.label, writeGeminiConfig({ ...model, modelId: launchModelId }))
666
+ console.log(chalk.dim(` 📖 Gemini routed through FCM proxy on :${started.port}`))
667
+ return spawnCommand('gemini', ['--model', launchModelId], proxyEnv)
352
668
  }
353
669
 
354
670
  if (mode === 'qwen') {
@@ -375,7 +691,8 @@ export async function startExternalTool(mode, model, config) {
375
691
  const piResult = writePiConfig(model, apiKey, baseUrl)
376
692
  printConfigResult(meta.label, { filePath: piResult.filePath, backupPath: piResult.backupPath })
377
693
  printConfigResult(meta.label, { filePath: piResult.settingsFilePath, backupPath: piResult.settingsBackupPath })
378
- return spawnCommand('pi', [], env)
694
+ // 📖 Pi supports --provider and --model flags for guaranteed auto-selection
695
+ return spawnCommand('pi', ['--provider', 'freeCodingModels', '--model', model.modelId, '--api-key', apiKey], env)
379
696
  }
380
697
 
381
698
  console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))
package/src/utils.js CHANGED
@@ -386,16 +386,16 @@ export function findBestModel(results) {
386
386
  // 📖 Slices from index 2 to get user-provided arguments only.
387
387
  //
388
388
  // 📖 Argument types:
389
- // - API key: first positional arg that doesn't start with "--" (e.g., "nvapi-xxx")
389
+ // - API key: first positional arg that does not look like a CLI flag (e.g., "nvapi-xxx")
390
390
  // - Boolean flags: --best, --fiable, --opencode, --opencode-desktop, --openclaw,
391
391
  // --aider, --crush, --goose, --claude-code, --codex, --gemini, --qwen,
392
- // --openhands, --amp, --pi, --no-telemetry, --json (case-insensitive)
392
+ // --openhands, --amp, --pi, --no-telemetry, --json, --help/-h (case-insensitive)
393
393
  // - Value flag: --tier <letter> (the next non-flag arg is the tier value)
394
394
  //
395
395
  // 📖 Returns:
396
396
  // { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode,
397
397
  // aiderMode, crushMode, gooseMode, claudeCodeMode, codexMode, geminiMode,
398
- // qwenMode, openHandsMode, ampMode, piMode, noTelemetry, jsonMode, tierFilter }
398
+ // qwenMode, openHandsMode, ampMode, piMode, noTelemetry, jsonMode, helpMode, tierFilter }
399
399
  //
400
400
  // 📖 Note: apiKey may be null here — the main CLI falls back to env vars and saved config.
401
401
  export function parseArgs(argv) {
@@ -420,7 +420,7 @@ export function parseArgs(argv) {
420
420
  if (profileValueIdx !== -1) skipIndices.add(profileValueIdx)
421
421
 
422
422
  for (const [i, arg] of args.entries()) {
423
- if (arg.startsWith('--')) {
423
+ if (arg.startsWith('--') || arg === '-h') {
424
424
  flags.push(arg.toLowerCase())
425
425
  } else if (skipIndices.has(i)) {
426
426
  // 📖 Skip — this is a value for --tier or --profile, not an API key
@@ -447,6 +447,7 @@ export function parseArgs(argv) {
447
447
  const noTelemetry = flags.includes('--no-telemetry')
448
448
  const cleanProxyMode = flags.includes('--clean-proxy') || flags.includes('--proxy-clean')
449
449
  const jsonMode = flags.includes('--json')
450
+ const helpMode = flags.includes('--help') || flags.includes('-h')
450
451
 
451
452
  let tierFilter = tierValueIdx !== -1 ? args[tierValueIdx].toUpperCase() : null
452
453
 
@@ -475,6 +476,7 @@ export function parseArgs(argv) {
475
476
  noTelemetry,
476
477
  cleanProxyMode,
477
478
  jsonMode,
479
+ helpMode,
478
480
  tierFilter,
479
481
  profileName,
480
482
  recommendMode
@@ -707,17 +709,31 @@ export function getProxyStatusInfo(proxyStartupStatus, isProxyActive, isProxyEna
707
709
  }
708
710
 
709
711
  /**
710
- * 📖 getVersionStatusInfo turns the settings update-check state into a compact,
712
+ * 📖 getVersionStatusInfo turns startup + manual update-check state into a compact,
711
713
  * 📖 render-friendly footer descriptor for the main table.
712
714
  *
713
- * 📖 Only an explicit `available` state should mark the local install as outdated.
714
- * 📖 This avoids showing a scary warning before the user has actually checked npm.
715
+ * 📖 Priority:
716
+ * 📖 1. Manual Settings check found an update (`available`)
717
+ * 📖 2. Startup auto-check already found a newer npm version
718
+ * 📖 3. Otherwise stay quiet
719
+ * 📖
720
+ * 📖 `versionAlertsEnabled` lets the CLI suppress npm-specific warnings in dev checkouts,
721
+ * 📖 where telling contributors to run a global npm update would be bogus.
715
722
  *
716
723
  * @param {'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'} updateState
717
724
  * @param {string|null} latestVersion
725
+ * @param {string|null} [startupLatestVersion=null]
726
+ * @param {boolean} [versionAlertsEnabled=true]
718
727
  * @returns {{ isOutdated: boolean, latestVersion: string|null }}
719
728
  */
720
- export function getVersionStatusInfo(updateState, latestVersion) {
729
+ export function getVersionStatusInfo(updateState, latestVersion, startupLatestVersion = null, versionAlertsEnabled = true) {
730
+ if (!versionAlertsEnabled) {
731
+ return {
732
+ isOutdated: false,
733
+ latestVersion: null,
734
+ }
735
+ }
736
+
721
737
  if (updateState === 'available' && typeof latestVersion === 'string' && latestVersion.trim()) {
722
738
  return {
723
739
  isOutdated: true,
@@ -725,6 +741,13 @@ export function getVersionStatusInfo(updateState, latestVersion) {
725
741
  }
726
742
  }
727
743
 
744
+ if (typeof startupLatestVersion === 'string' && startupLatestVersion.trim()) {
745
+ return {
746
+ isOutdated: true,
747
+ latestVersion: startupLatestVersion.trim(),
748
+ }
749
+ }
750
+
728
751
  return {
729
752
  isOutdated: false,
730
753
  latestVersion: null,