free-coding-models 0.3.40 → 0.3.41

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.
package/CHANGELOG.md CHANGED
@@ -1,21 +1,10 @@
1
1
  # Changelog
2
2
  ---
3
3
 
4
- ## [0.3.40] - 2026-04-09
5
-
6
- ### Fixed
7
- - **🔧 CI/CD** — Added `packageManager` field to `package.json` so `pnpm/action-setup@v4` can resolve the pnpm version
8
-
9
- ## [0.3.39] - 2026-04-09
10
-
11
- ### Fixed
12
- - **🔧 CI/CD** — Switched GitHub Actions from `npm ci` to `pnpm install --frozen-lockfile` (release + security-audit workflows)
13
- - Removed obsolete `package-lock.json` that was causing `EUSAGE` sync errors in CI
14
-
15
- ## [0.3.38] - 2026-04-09
4
+ ## [0.3.41] - 2026-04-09
16
5
 
17
6
  ### Added
18
- - **🔮 Hermes Agent** Configures Hermes via `hermes config set`, restarts the gateway, and launches `hermes chat`. Supports all OpenAI-compatible providers.
19
- - **▶️ Continue CLI** Writes `~/.continue/config.yaml` with `provider: openai` + `apiBase` and launches `cn`.
20
- - **🧠 Cline CLI** Writes `~/.cline/globalState.json` with OpenAI-compatible config and launches `cline`.
21
- - `--hermes`, `--continue`, `--cline` CLI flags, `Z` cycle entries, command palette actions, and Install Endpoints support for all three tools.
7
+ - Added support for Xcode Intelligence (Xcode 26.3+) via the `--xcode` flag and manual configuration flow. The tool provides clear setup instructions and launches Xcode directly.
8
+ - The `xcode` launcher mode has been registered in the tool metadata, enabling Xcode selection through the command palette, `Z` cycle, and CLI arguments.
9
+ - Users can now use Xcode Intelligence with any of the 230+ supported OpenAI-compatible API endpoints.
10
+ - Compatibility logic ensures Xcode mode accurately highlights supported models (compatible with all standard models).
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  <p align="center">
16
16
  <strong>Find the fastest free coding model in seconds</strong><br>
17
- <sub>Ping 238 models across 25 AI Free providers in real-time </sub><br><sub> Install Free API endpoints to your favorite AI coding tool: <br>📦 OpenCode, 🦞 OpenClaw, 💘 Crush, 🪿 Goose, 🛠 Aider, 🐉 Qwen Code, 🤲 OpenHands, ⚡ Amp, 🔮 Hermes, ▶️ Continue, 🧠 Cline, π Pi, 🦘 Rovo or ♊ Gemini in one keystroke</sub>
17
+ <sub>Ping 238 models across 25 AI Free providers in real-time </sub><br> <sub> Install Free API endpoints to your favorite AI coding tool: <br>📦 OpenCode, 🦞 OpenClaw, 💘 Crush, 🪿 Goose, 🛠 Aider, 🐉 Qwen Code, 🤲 OpenHands, ⚡ Amp, 🔮 Hermes, ▶️ Continue, 🧠 Cline, 🛠️ Xcode, π Pi, 🦘 Rovo or ♊ Gemini in one keystroke</sub>
18
18
  </p>
19
19
 
20
20
 
@@ -159,6 +159,9 @@ free-coding-models --goose --tier S
159
159
  # "I want NVIDIA's top models only"
160
160
  free-coding-models --origin nvidia --tier S
161
161
 
162
+ # "I want the local web dashboard"
163
+ free-coding-models --web
164
+
162
165
  # "Start with an elite-focused preset, then adjust filters live"
163
166
  free-coding-models --premium
164
167
 
@@ -169,6 +172,8 @@ free-coding-models --tier S --json | jq -r '.[0].modelId'
169
172
  free-coding-models --openclaw --origin groq
170
173
  ```
171
174
 
175
+ When launching the web dashboard, `free-coding-models` prefers `http://localhost:3333`. If that port is already used by another app, it now auto-picks the next free local port and prints the exact URL to open.
176
+
172
177
  ### Tool launcher flags
173
178
 
174
179
  | Flag | Launches |
@@ -185,6 +190,7 @@ free-coding-models --openclaw --origin groq
185
190
  | `--hermes` | 🔮 Hermes |
186
191
  | `--continue` | ▶️ Continue CLI |
187
192
  | `--cline` | 🧠 Cline |
193
+ | `--xcode` | 🛠️ Xcode Intelligence |
188
194
  | `--pi` | π Pi |
189
195
  | `--rovo` | 🦘 Rovo Dev CLI |
190
196
  | `--gemini` | ♊ Gemini CLI |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.40",
3
+ "version": "0.3.41",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
package/src/app.js CHANGED
@@ -114,6 +114,7 @@ import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelem
114
114
  import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel } from '../src/favorites.js'
115
115
  import { checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification, fetchLastReleaseDate } from './updater.js'
116
116
  import { promptApiKey } from '../src/setup.js'
117
+ import { syncShellEnv, ensureShellRcSource, promptShellEnvMigration, removeShellEnv } from '../src/shell-env.js'
117
118
  import { stripAnsi, maskApiKey, displayWidth, padEndDisplay, tintOverlayLines, keepOverlayTargetVisible, sliceOverlayLines, calculateViewport, sortResultsWithPinnedFavorites, adjustScrollOffset } from '../src/render-helpers.js'
118
119
  import { renderTable, PROVIDER_COLOR } from '../src/render-table.js'
119
120
  import { setOpenCodeModelData, startOpenCode, startOpenCodeDesktop } from '../src/opencode.js'
@@ -215,6 +216,31 @@ export async function runApp(cliArgs, config) {
215
216
  console.log()
216
217
  process.exit(1)
217
218
  }
219
+ // 📖 New users get shell env enabled by default
220
+ if (config.settings.shellEnvEnabled === undefined) {
221
+ config.settings.shellEnvEnabled = true
222
+ saveConfig(config)
223
+ syncShellEnv(config)
224
+ ensureShellRcSource()
225
+ }
226
+ }
227
+
228
+ // 📖 Shell env migration popup for existing users who haven't been asked yet
229
+ // 📖 Only show when user has keys but shellEnvEnabled is still undefined (never prompted)
230
+ if (hasAnyKey && config.settings.shellEnvEnabled === undefined) {
231
+ const choice = await promptShellEnvMigration(config)
232
+ if (choice === 'enable') {
233
+ if (!config.settings) config.settings = {}
234
+ config.settings.shellEnvEnabled = true
235
+ saveConfig(config)
236
+ syncShellEnv(config)
237
+ ensureShellRcSource()
238
+ } else if (choice === 'never') {
239
+ if (!config.settings) config.settings = {}
240
+ config.settings.shellEnvEnabled = false
241
+ saveConfig(config)
242
+ }
243
+ // 📖 'skip' leaves shellEnvEnabled undefined — will prompt again next launch
218
244
  }
219
245
 
220
246
  // 📖 Default mode: use the last persisted launcher choice when valid,
@@ -236,6 +262,7 @@ export async function runApp(cliArgs, config) {
236
262
  hermes: cliArgs.hermesMode,
237
263
  'continue': cliArgs.continueMode,
238
264
  cline: cliArgs.clineMode,
265
+ xcode: cliArgs.xcodeMode,
239
266
  pi: cliArgs.piMode,
240
267
  rovo: cliArgs.rovoMode,
241
268
  gemini: cliArgs.geminiMode,
package/src/config.js CHANGED
@@ -102,6 +102,7 @@
102
102
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, renameSync } from 'node:fs'
103
103
  import { homedir } from 'node:os'
104
104
  import { join } from 'node:path'
105
+ import { syncShellEnv } from './shell-env.js'
105
106
 
106
107
  // 📖 New JSON config path — stores all providers' API keys + enabled state
107
108
  export const CONFIG_PATH = join(homedir(), '.free-coding-models.json')
@@ -484,6 +485,12 @@ export function saveConfig(config, options = {}) {
484
485
  }
485
486
 
486
487
  replaceConfigContents(config, persistedConfig)
488
+
489
+ // 📖 Keep shell env file in sync when enabled
490
+ if (persistedConfig.settings?.shellEnvEnabled) {
491
+ try { syncShellEnv(persistedConfig) } catch { /* non-critical */ }
492
+ }
493
+
487
494
  return { success: true, backupCreated }
488
495
  } catch (verifyError) {
489
496
  // 📖 Verification failed - this is critical!
@@ -33,6 +33,7 @@ import { loadConfig, replaceConfigContents } from './config.js'
33
33
  import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
34
34
  import { getLastLayout, COLUMN_SORT_MAP } from './render-table.js'
35
35
  import { cycleThemeSetting, detectActiveTheme } from './theme.js'
36
+ import { syncShellEnv, ensureShellRcSource, removeShellEnv } from './shell-env.js'
36
37
  import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntries } from './command-palette.js'
37
38
  import { WIDTH_WARNING_MIN_COLS } from './constants.js'
38
39
  import { scanAllToolConfigs, softDeleteModel } from './installed-models-manager.js'
@@ -553,6 +554,19 @@ export function createKeyHandler(ctx) {
553
554
  applyThemeSetting(cycleThemeSetting(currentTheme))
554
555
  }
555
556
 
557
+ function toggleShellEnv() {
558
+ if (!state.config.settings) state.config.settings = {}
559
+ const currentlyEnabled = state.config.settings.shellEnvEnabled === true
560
+ state.config.settings.shellEnvEnabled = !currentlyEnabled
561
+ saveConfig(state.config)
562
+ if (!currentlyEnabled) {
563
+ syncShellEnv(state.config)
564
+ ensureShellRcSource()
565
+ } else {
566
+ removeShellEnv()
567
+ }
568
+ }
569
+
556
570
  function resetInstallEndpointsOverlay() {
557
571
  state.installEndpointsOpen = false
558
572
  state.installEndpointsPhase = 'providers'
@@ -1828,8 +1842,9 @@ export function createKeyHandler(ctx) {
1828
1842
  const favoritesModeRowIdx = themeRowIdx + 1
1829
1843
  const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
1830
1844
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
1845
+ const shellEnvRowIdx = changelogViewRowIdx + 1
1831
1846
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1832
- const maxRowIdx = changelogViewRowIdx
1847
+ const maxRowIdx = shellEnvRowIdx
1833
1848
 
1834
1849
  // 📖 Edit/Add-key mode: capture typed characters for the API key
1835
1850
  if (state.settingsEditMode || state.settingsAddKeyMode) {
@@ -1993,6 +2008,12 @@ export function createKeyHandler(ctx) {
1993
2008
  return
1994
2009
  }
1995
2010
 
2011
+ // 📖 Shell env row: Enter → toggle shell env export
2012
+ if (state.settingsCursor === shellEnvRowIdx) {
2013
+ toggleShellEnv()
2014
+ return
2015
+ }
2016
+
1996
2017
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1997
2018
 
1998
2019
  // 📖 Enter edit mode for the selected provider's key
@@ -2010,6 +2031,11 @@ export function createKeyHandler(ctx) {
2010
2031
  || state.settingsCursor === cleanupLegacyProxyRowIdx
2011
2032
  || state.settingsCursor === changelogViewRowIdx
2012
2033
  ) return
2034
+ // 📖 Shell env toggle
2035
+ if (state.settingsCursor === shellEnvRowIdx) {
2036
+ toggleShellEnv()
2037
+ return
2038
+ }
2013
2039
  // 📖 Theme configuration cycle inside settings
2014
2040
  if (state.settingsCursor === themeRowIdx) {
2015
2041
  cycleGlobalTheme()
package/src/overlays.js CHANGED
@@ -120,6 +120,7 @@ export function createOverlayRenderers(state, deps) {
120
120
  const favoritesModeRowIdx = themeRowIdx + 1
121
121
  const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
122
122
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
123
+ const shellEnvRowIdx = changelogViewRowIdx + 1
123
124
  const EL = '\x1b[K'
124
125
  const lines = []
125
126
  const cursorLineByRow = {}
@@ -272,6 +273,15 @@ export function createOverlayRenderers(state, deps) {
272
273
  cursorLineByRow[changelogViewRowIdx] = lines.length
273
274
  lines.push(state.settingsCursor === changelogViewRowIdx ? themeColors.bgCursorSettingsList(changelogViewRow) : changelogViewRow)
274
275
 
276
+ // 📖 Shell env toggle — expose API keys as shell environment variables
277
+ const shellEnvEnabled = state.config.settings?.shellEnvEnabled === true
278
+ const shellEnvStatus = shellEnvEnabled
279
+ ? themeColors.successBold('✅ Enabled — keys available in shell')
280
+ : themeColors.dim('❌ Disabled')
281
+ const shellEnvRow = `${bullet(state.settingsCursor === shellEnvRowIdx)}${themeColors.textBold('Shell Env Export').padEnd(44)} ${shellEnvStatus}`
282
+ cursorLineByRow[shellEnvRowIdx] = lines.length
283
+ lines.push(state.settingsCursor === shellEnvRowIdx ? themeColors.bgCursorSettingsList(shellEnvRow) : shellEnvRow)
284
+
275
285
  // 📖 Profile system removed - API keys now persist permanently across all sessions
276
286
 
277
287
  lines.push('')
@@ -313,7 +323,7 @@ export function createOverlayRenderers(state, deps) {
313
323
  // 📖 Mouse support: record layout so click handler can map Y → settingsCursor
314
324
  overlayLayout.settingsCursorToLine = { ...cursorLineByRow }
315
325
  overlayLayout.settingsScrollOffset = offset
316
- overlayLayout.settingsMaxRow = changelogViewRowIdx
326
+ overlayLayout.settingsMaxRow = shellEnvRowIdx
317
327
 
318
328
  const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
319
329
  const cleared = tintedLines.map(l => l + EL)
@@ -0,0 +1,393 @@
1
+ /**
2
+ * @file shell-env.js
3
+ * @description Exposes API keys as shell environment variables via a sourced dotfile.
4
+ *
5
+ * @details
6
+ * Creates `~/.free-coding-models.env` containing `export VAR="value"` lines for every
7
+ * configured provider, and injects a single `[ -f ~/.free-coding-models.env ] && source ...`
8
+ * line into the user's shell rc file (`.zshrc`, `.bashrc`, or fish `config.fish`).
9
+ *
10
+ * The env file is kept in sync automatically: every time `saveConfig()` or
11
+ * `persistApiKeysForProvider()` writes a new config to disk, `syncShellEnv()` is called
12
+ * to regenerate the `.env` if `settings.shellEnvEnabled === true`.
13
+ *
14
+ * Shell detection uses `$SHELL` with a fallback chain: zsh → bash → fish.
15
+ * Each shell's rc path is resolved relative to `$HOME`. Fish uses `set -gx` syntax
16
+ * instead of `export`, which is handled transparently.
17
+ *
18
+ * The env file is written with mode `0600` (owner read/write only) to protect API keys.
19
+ * The source line in the rc file is idempotent — calling `ensureShellRcSource()` multiple
20
+ * times will never add a duplicate line.
21
+ *
22
+ * @functions
23
+ * → `syncShellEnv(config)` — regenerate the .env file from current apiKeys
24
+ * → `ensureShellRcSource()` — add the source line to the detected shell rc (idempotent)
25
+ * → `removeShellEnv()` — delete the .env file and remove the source line from rc
26
+ * → `detectShellInfo()` — return { shell, rcPath } for the current user
27
+ * → `getEnvFilePath()` — return the absolute path to ~/.free-coding-models.env
28
+ * → `buildEnvContent(config, shell)` — build the env file body (pure function, testable)
29
+ * → `buildRcSourceLine(envFilePath, shell)` — build the rc source line for a given shell
30
+ *
31
+ * @exports syncShellEnv, ensureShellRcSource, removeShellEnv, detectShellInfo,
32
+ * getEnvFilePath, buildEnvContent, buildRcSourceLine, ENV_FILE_MARKER
33
+ *
34
+ * @see src/config.js — calls syncShellEnv() after saveConfig()
35
+ * @see src/provider-metadata.js — ENV_VAR_NAMES maps provider → env var name
36
+ */
37
+
38
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs'
39
+ import { homedir } from 'node:os'
40
+ import { join } from 'node:path'
41
+ import * as readline from 'node:readline'
42
+ import chalk from 'chalk'
43
+ import { ENV_VAR_NAMES } from './provider-metadata.js'
44
+
45
+ // 📖 Unique marker used to identify the source line we inject into shell rc files.
46
+ // 📖 This allows idempotent add/remove without relying on exact path matching.
47
+ export const ENV_FILE_MARKER = '# free-coding-models-env'
48
+
49
+ // 📖 The env dotfile path — always next to the JSON config in $HOME.
50
+ const ENV_FILE_NAME = '.free-coding-models.env'
51
+
52
+ /**
53
+ * 📖 Returns the absolute path to the env dotfile.
54
+ * @returns {string}
55
+ */
56
+ export function getEnvFilePath() {
57
+ return join(homedir(), ENV_FILE_NAME)
58
+ }
59
+
60
+ /**
61
+ * 📖 Detect the user's shell and return its rc file path.
62
+ *
63
+ * Detection order: $SHELL env var → fallback to zsh → bash → fish.
64
+ * Returns the shell type ('zsh'|'bash'|'fish') and the absolute rc path.
65
+ *
66
+ * @returns {{ shell: 'zsh'|'bash'|'fish', rcPath: string }}
67
+ */
68
+ export function detectShellInfo() {
69
+ const shellEnv = (process.env.SHELL || '').toLowerCase()
70
+ const home = homedir()
71
+
72
+ if (shellEnv.includes('zsh')) {
73
+ return { shell: 'zsh', rcPath: join(home, '.zshrc') }
74
+ }
75
+ if (shellEnv.includes('bash')) {
76
+ return { shell: 'bash', rcPath: join(home, '.bashrc') }
77
+ }
78
+ if (shellEnv.includes('fish')) {
79
+ return { shell: 'fish', rcPath: join(home, '.config', 'fish', 'config.fish') }
80
+ }
81
+
82
+ // 📖 Fallback: try to detect which rc files exist
83
+ if (existsSync(join(home, '.zshrc'))) {
84
+ return { shell: 'zsh', rcPath: join(home, '.zshrc') }
85
+ }
86
+ if (existsSync(join(home, '.bashrc'))) {
87
+ return { shell: 'bash', rcPath: join(home, '.bashrc') }
88
+ }
89
+ const fishConfig = join(home, '.config', 'fish', 'config.fish')
90
+ if (existsSync(fishConfig)) {
91
+ return { shell: 'fish', rcPath: fishConfig }
92
+ }
93
+
94
+ // 📖 Last resort: assume zsh (most common on macOS/Linux)
95
+ return { shell: 'zsh', rcPath: join(home, '.zshrc') }
96
+ }
97
+
98
+ /**
99
+ * 📖 Build the env file content for a given shell type.
100
+ *
101
+ * Pure function — no I/O. Iterates over config.apiKeys and generates
102
+ * the appropriate `export` (bash/zsh) or `set -gx` (fish) lines.
103
+ *
104
+ * @param {{ apiKeys: Record<string, string|string[]> }} config
105
+ * @param {'zsh'|'bash'|'fish'} shell
106
+ * @returns {string} The complete file content to write
107
+ */
108
+ export function buildEnvContent(config, shell) {
109
+ const apiKeys = config?.apiKeys || {}
110
+ const lines = [
111
+ '#!/bin/env sh',
112
+ `# ${ENV_FILE_MARKER}`,
113
+ '# Auto-generated by free-coding-models — do not edit manually.',
114
+ '# Changes to API keys are synced automatically from the TUI.',
115
+ '',
116
+ ]
117
+
118
+ const isFish = shell === 'fish'
119
+
120
+ for (const [providerKey, envName] of Object.entries(ENV_VAR_NAMES)) {
121
+ const rawValue = apiKeys[providerKey]
122
+ if (!rawValue) continue
123
+
124
+ // 📖 Support multi-key arrays — use the first key for shell env
125
+ const value = Array.isArray(rawValue) ? rawValue[0] : rawValue
126
+ if (!value || typeof value !== 'string') continue
127
+
128
+ const safeValue = value.replace(/'/g, "'\\''")
129
+
130
+ if (isFish) {
131
+ lines.push(`set -gx ${envName} '${safeValue}'`)
132
+ } else {
133
+ lines.push(`export ${envName}='${safeValue}'`)
134
+ }
135
+ }
136
+
137
+ lines.push('')
138
+ return lines.join('\n')
139
+ }
140
+
141
+ /**
142
+ * 📖 Build the rc source line that loads the env file.
143
+ *
144
+ * For bash/zsh: `[ -f ~/.free-coding-models.env ] && . ~/.free-coding-models.env # free-coding-models-env`
145
+ * For fish: `test -f ~/.free-coding-models.env; and source ~/.free-coding-models.env # free-coding-models-env`
146
+ *
147
+ * @param {string} envFilePath — absolute path to the .env file
148
+ * @param {'zsh'|'bash'|'fish'} shell
149
+ * @returns {string} A single line to append to the rc file
150
+ */
151
+ export function buildRcSourceLine(envFilePath, shell) {
152
+ const home = homedir()
153
+ // 📖 Use ~/ relative path in rc for portability
154
+ const relativePath = envFilePath.startsWith(home)
155
+ ? '~/' + envFilePath.slice(home.length + 1)
156
+ : envFilePath
157
+
158
+ if (shell === 'fish') {
159
+ return `test -f ${relativePath}; and source ${relativePath} ${ENV_FILE_MARKER}`
160
+ }
161
+ return `[ -f ${relativePath} ] && . ${relativePath} ${ENV_FILE_MARKER}`
162
+ }
163
+
164
+ /**
165
+ * 📖 Regenerate the env file from the current config.
166
+ *
167
+ * Called after every saveConfig() when shellEnvEnabled is true.
168
+ * Writes with mode 0600 for security. Skips if there are no keys.
169
+ *
170
+ * @param {{ apiKeys: Record<string, string|string[]>, settings?: { shellEnvEnabled?: boolean } }} config
171
+ * @returns {{ success: boolean, envPath: string, error?: string }}
172
+ */
173
+ export function syncShellEnv(config) {
174
+ const { shell } = detectShellInfo()
175
+ const envPath = getEnvFilePath()
176
+ const apiKeys = config?.apiKeys || {}
177
+ const hasKeys = Object.values(apiKeys).some(v => {
178
+ if (Array.isArray(v)) return v.length > 0
179
+ return !!v
180
+ })
181
+
182
+ if (!hasKeys) {
183
+ // 📖 No keys — remove stale env file if it exists
184
+ if (existsSync(envPath)) {
185
+ try { unlinkSync(envPath) } catch { /* best effort */ }
186
+ }
187
+ return { success: true, envPath }
188
+ }
189
+
190
+ try {
191
+ const content = buildEnvContent(config, shell)
192
+ writeFileSync(envPath, content, { mode: 0o600 })
193
+ return { success: true, envPath }
194
+ } catch (err) {
195
+ return { success: false, envPath, error: err.message }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * 📖 Add the source line to the user's shell rc file (idempotent).
201
+ *
202
+ * Creates the rc file if it doesn't exist. Skips if the marker is already present.
203
+ *
204
+ * @returns {{ success: boolean, rcPath: string, wasAdded: boolean, error?: string }}
205
+ */
206
+ export function ensureShellRcSource() {
207
+ const { shell, rcPath } = detectShellInfo()
208
+ const envPath = getEnvFilePath()
209
+ const sourceLine = buildRcSourceLine(envPath, shell)
210
+
211
+ let existingContent = ''
212
+ if (existsSync(rcPath)) {
213
+ try {
214
+ existingContent = readFileSync(rcPath, 'utf8')
215
+ } catch (err) {
216
+ return { success: false, rcPath, wasAdded: false, error: `Cannot read ${rcPath}: ${err.message}` }
217
+ }
218
+ }
219
+
220
+ // 📖 Idempotent: skip if our marker is already in the file
221
+ if (existingContent.includes(ENV_FILE_MARKER)) {
222
+ return { success: true, rcPath, wasAdded: false }
223
+ }
224
+
225
+ try {
226
+ // 📖 Ensure parent directory exists (e.g. ~/.config/fish/)
227
+ const dir = join(rcPath, '..')
228
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
229
+
230
+ const newContent = existingContent
231
+ ? existingContent.trimEnd() + '\n\n' + sourceLine + '\n'
232
+ : sourceLine + '\n'
233
+ writeFileSync(rcPath, newContent)
234
+ return { success: true, rcPath, wasAdded: true }
235
+ } catch (err) {
236
+ return { success: false, rcPath, wasAdded: false, error: `Cannot write ${rcPath}: ${err.message}` }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * 📖 Remove the env file and the source line from the shell rc.
242
+ *
243
+ * @returns {{ success: boolean, envRemoved: boolean, rcCleaned: boolean, error?: string }}
244
+ */
245
+ export function removeShellEnv() {
246
+ const { shell, rcPath } = detectShellInfo()
247
+ const envPath = getEnvFilePath()
248
+ let envRemoved = false
249
+ let rcCleaned = false
250
+
251
+ // 📖 Remove the env file
252
+ if (existsSync(envPath)) {
253
+ try {
254
+ unlinkSync(envPath)
255
+ envRemoved = true
256
+ } catch (err) {
257
+ return { success: false, envRemoved: false, rcCleaned: false, error: `Cannot delete ${envPath}: ${err.message}` }
258
+ }
259
+ }
260
+
261
+ // 📖 Remove the source line from rc
262
+ if (existsSync(rcPath)) {
263
+ try {
264
+ const content = readFileSync(rcPath, 'utf8')
265
+ if (content.includes(ENV_FILE_MARKER)) {
266
+ const cleaned = content
267
+ .split('\n')
268
+ .filter(line => !line.includes(ENV_FILE_MARKER))
269
+ .join('\n')
270
+ .replace(/\n{3,}/g, '\n\n')
271
+ writeFileSync(rcPath, cleaned)
272
+ rcCleaned = true
273
+ }
274
+ } catch {
275
+ // 📖 Non-critical — env file is already removed
276
+ }
277
+ }
278
+
279
+ return { success: true, envRemoved, rcCleaned }
280
+ }
281
+
282
+ /**
283
+ * 📖 Pre-TUI popup asking the user whether to enable shell env exposure.
284
+ *
285
+ * Follows the same pattern as `promptUpdateNotification()` in updater.js.
286
+ * Shows three options: Enable (recommended, green), Skip for now, Don't ask again.
287
+ * Runs BEFORE entering the alt-screen buffer so it renders in the normal terminal.
288
+ *
289
+ * @param {{ apiKeys: Record<string, string|string[]>, settings: object }} config
290
+ * @returns {Promise<'enable'|'skip'|'never'>} The user's choice
291
+ */
292
+ export async function promptShellEnvMigration(config) {
293
+ const { shell, rcPath } = detectShellInfo()
294
+ const rcName = rcPath.split('/').pop()
295
+ const keyCount = Object.keys(config.apiKeys || {}).filter(pk => {
296
+ const v = config.apiKeys[pk]
297
+ return Array.isArray(v) ? v.length > 0 : !!v
298
+ }).length
299
+
300
+ return new Promise((resolve) => {
301
+ let selected = 0
302
+ const options = [
303
+ {
304
+ label: 'Yes, enable (recommended)',
305
+ icon: '✅',
306
+ description: `Export ${keyCount} API key${keyCount > 1 ? 's' : ''} to shell via ${rcName}`,
307
+ },
308
+ {
309
+ label: 'Skip for now',
310
+ icon: '⏭',
311
+ description: 'You can enable it later in Settings (P)',
312
+ },
313
+ {
314
+ label: "Don't ask again",
315
+ icon: '🔇',
316
+ description: 'Disable this prompt permanently',
317
+ },
318
+ ]
319
+
320
+ const render = () => {
321
+ process.stdout.write('\x1b[2J\x1b[H')
322
+
323
+ const terminalWidth = process.stdout.columns || 80
324
+ const maxWidth = Math.min(terminalWidth - 4, 70)
325
+ const centerPad = ' '.repeat(Math.max(0, Math.floor((terminalWidth - maxWidth) / 2)))
326
+
327
+ console.log()
328
+ console.log(centerPad + chalk.bold.rgb(110, 214, 255)(' 🐚 Shell Environment Setup'))
329
+ console.log()
330
+ console.log(centerPad + chalk.white(' Expose your API keys as environment variables'))
331
+ console.log(centerPad + chalk.white(' so they are available globally in your shell.'))
332
+ console.log()
333
+ console.log(centerPad + chalk.dim(` Detected shell: ${shell} (${rcName})`))
334
+ console.log(centerPad + chalk.dim(` This adds a single source line to ${rcName}`))
335
+ console.log(centerPad + chalk.dim(' and creates ~/.free-coding-models.env (0600)'))
336
+ console.log()
337
+
338
+ for (let i = 0; i < options.length; i++) {
339
+ const isSelected = i === selected
340
+ const bullet = isSelected ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
341
+ let label
342
+ if (i === 0) {
343
+ label = isSelected
344
+ ? chalk.bold.rgb(112, 231, 181)(options[i].icon + ' ' + options[i].label + ' ★')
345
+ : chalk.rgb(112, 231, 181)(options[i].icon + ' ' + options[i].label)
346
+ } else {
347
+ label = isSelected
348
+ ? chalk.bold.white(options[i].icon + ' ' + options[i].label)
349
+ : chalk.dim(options[i].icon + ' ' + options[i].label)
350
+ }
351
+
352
+ console.log(centerPad + bullet + label)
353
+ console.log(centerPad + chalk.dim(' ' + options[i].description))
354
+ console.log()
355
+ }
356
+
357
+ console.log(centerPad + chalk.dim(' ↑↓ Navigate • Enter Select • Ctrl+C Skip'))
358
+ console.log()
359
+ }
360
+
361
+ render()
362
+
363
+ readline.emitKeypressEvents(process.stdin)
364
+ if (process.stdin.isTTY) process.stdin.setRawMode(true)
365
+
366
+ const onKey = (_str, key) => {
367
+ if (!key) return
368
+ if (key.ctrl && key.name === 'c') {
369
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
370
+ process.stdin.removeListener('keypress', onKey)
371
+ resolve('skip')
372
+ return
373
+ }
374
+ if (key.name === 'up' && selected > 0) {
375
+ selected--
376
+ render()
377
+ } else if (key.name === 'down' && selected < options.length - 1) {
378
+ selected++
379
+ render()
380
+ } else if (key.name === 'return') {
381
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
382
+ process.stdin.removeListener('keypress', onKey)
383
+ process.stdin.pause()
384
+
385
+ if (selected === 0) resolve('enable')
386
+ else if (selected === 1) resolve('skip')
387
+ else resolve('never')
388
+ }
389
+ }
390
+
391
+ process.stdin.on('keypress', onKey)
392
+ })
393
+ }
@@ -248,6 +248,13 @@ export const TOOL_BOOTSTRAP_METADATA = {
248
248
  },
249
249
  },
250
250
  },
251
+ xcode: {
252
+ binary: null,
253
+ docsUrl: 'https://developer.apple.com/documentation/Xcode/setting-up-coding-intelligence',
254
+ installUnsupported: {
255
+ default: 'Xcode Intelligence requires manual setup. Go to Xcode > Settings > Intelligence > Add a Chat Provider.',
256
+ },
257
+ },
251
258
  hermes: {
252
259
  binary: 'hermes',
253
260
  docsUrl: 'https://github.com/NousResearch/hermes-agent',
@@ -462,7 +462,9 @@ function writeHermesConfig(model, apiKey, baseUrl, paths = getDefaultToolPaths()
462
462
  const hermesBin = resolveToolBinaryPath('hermes') || 'hermes'
463
463
 
464
464
  // 📖 Use `hermes config set` for each field — robust and dependency-free
465
- spawnSync(hermesBin, ['config', 'set', 'model', model.modelId], { stdio: 'ignore' })
465
+ // 📖 Must use 'model.default' not 'model', otherwise it replaces the entire model: dict with a string
466
+ // 📖 and subsequent model.provider / model.base_url / model.api_key calls silently fail
467
+ spawnSync(hermesBin, ['config', 'set', 'model.default', model.modelId], { stdio: 'ignore' })
466
468
  spawnSync(hermesBin, ['config', 'set', 'model.provider', 'custom'], { stdio: 'ignore' })
467
469
  if (baseUrl) {
468
470
  spawnSync(hermesBin, ['config', 'set', 'model.base_url', baseUrl], { stdio: 'ignore' })
@@ -716,6 +718,18 @@ export function prepareExternalToolLaunch(mode, model, config, options = {}) {
716
718
  }
717
719
  }
718
720
 
721
+ if (mode === 'xcode') {
722
+ return {
723
+ command: 'open',
724
+ args: ['-a', 'Xcode'],
725
+ env,
726
+ apiKey,
727
+ baseUrl,
728
+ meta,
729
+ configArtifacts: [],
730
+ }
731
+ }
732
+
719
733
  if (mode === 'rovo') {
720
734
  const result = writeRovoConfig(model, join(homedir(), '.rovodev', 'config.yml'), paths)
721
735
  console.log(chalk.dim(` 📖 Rovo Dev CLI configured with model: ${model.modelId}`))
@@ -815,6 +829,21 @@ export async function startExternalTool(mode, model, config) {
815
829
  return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
816
830
  }
817
831
 
832
+ if (mode === 'xcode') {
833
+ const xcodeUrl = baseUrl ? baseUrl.replace(/\/v1$/, '').replace(/\/v1\/chat\/completions$/, '') : ''
834
+ console.log(chalk.bold.cyan('\n 🛠️ Xcode Intelligence Setup Instructions:'))
835
+ console.log(chalk.white(' 1. Open Xcode and go to ') + chalk.bold('Xcode > Settings > Intelligence'))
836
+ console.log(chalk.white(' 2. Click ') + chalk.bold('Add a Chat Provider') + chalk.white(' and select ') + chalk.bold('Internet Hosted'))
837
+ console.log(chalk.white(' 3. Enter the following details:'))
838
+ console.log(chalk.dim(' URL: ') + chalk.green(xcodeUrl))
839
+ console.log(chalk.dim(' API Key: ') + chalk.green(apiKey || '<your_api_key>'))
840
+ console.log(chalk.dim(' API Key Header: ') + chalk.green('Authorization') + chalk.dim(' (or x-api-key)'))
841
+ console.log(chalk.dim(' Description: ') + chalk.green(`FCM - ${sources[model.providerKey]?.name || model.providerKey}`))
842
+ console.log(chalk.white(` 4. Click Add, then select `) + chalk.bold(model.modelId) + chalk.white(` from the list.\n`))
843
+ console.log(chalk.dim(` 📖 Attempting to launch Xcode...`))
844
+ return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
845
+ }
846
+
818
847
  if (mode === 'rovo') {
819
848
  console.log(chalk.dim(` 📖 Launching Rovo Dev CLI in interactive mode...`))
820
849
  return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
@@ -41,6 +41,7 @@ export const TOOL_METADATA = {
41
41
  cline: { label: 'Cline', emoji: '🧠', flag: '--cline', color: [100, 220, 180] },
42
42
  rovo: { label: 'Rovo Dev CLI', emoji: '🦘', flag: '--rovo', color: [148, 163, 184], cliOnly: true },
43
43
  gemini: { label: 'Gemini CLI', emoji: '♊', flag: '--gemini', color: [66, 165, 245], cliOnly: true },
44
+ xcode: { label: 'Xcode Intelligence',emoji: '🛠️', flag: '--xcode', color: [20, 126, 251] },
44
45
  }
45
46
 
46
47
  // 📖 Deduplicated emoji order for the "Compatible with" column.
@@ -61,6 +62,7 @@ export const COMPAT_COLUMN_SLOTS = [
61
62
  { emoji: '🧠', toolKeys: ['cline'], color: [100, 220, 180] },
62
63
  { emoji: '🦘', toolKeys: ['rovo'], color: [148, 163, 184] },
63
64
  { emoji: '♊', toolKeys: ['gemini'], color: [66, 165, 245] },
65
+ { emoji: '🛠️', toolKeys: ['xcode'], color: [20, 126, 251] },
64
66
  ]
65
67
 
66
68
  export const TOOL_MODE_ORDER = [
@@ -77,6 +79,7 @@ export const TOOL_MODE_ORDER = [
77
79
  'hermes',
78
80
  'continue',
79
81
  'cline',
82
+ 'xcode',
80
83
  'rovo',
81
84
  'gemini',
82
85
  ]
package/src/utils.js CHANGED
@@ -458,6 +458,7 @@ export function parseArgs(argv) {
458
458
  const hermesMode = flags.includes('--hermes')
459
459
  const continueMode = flags.includes('--continue')
460
460
  const clineMode = flags.includes('--cline')
461
+ const xcodeMode = flags.includes('--xcode')
461
462
  const geminiMode = flags.includes('--gemini')
462
463
  const noTelemetry = flags.includes('--no-telemetry')
463
464
  const jsonMode = flags.includes('--json')
@@ -501,6 +502,7 @@ export function parseArgs(argv) {
501
502
  hermesMode,
502
503
  continueMode,
503
504
  clineMode,
505
+ xcodeMode,
504
506
  rovoMode,
505
507
  geminiMode,
506
508
  noTelemetry,
package/web/server.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * GET /styles.css → Dashboard styles
12
12
  * GET /app.js → Dashboard client JS
13
13
  * GET /api/models → All model metadata (JSON)
14
+ * GET /api/health → Lightweight dashboard health probe
14
15
  * GET /api/config → Current config (sanitized — masked keys)
15
16
  * GET /api/key/:prov → Reveal a provider's full API key
16
17
  * GET /api/events → SSE stream of live ping results
@@ -32,6 +33,7 @@ import {
32
33
  } from '../src/utils.js'
33
34
 
34
35
  const __dirname = dirname(fileURLToPath(import.meta.url))
36
+ const SERVER_SIGNATURE = 'free-coding-models-web'
35
37
 
36
38
  // ─── State ───────────────────────────────────────────────────────────────────
37
39
 
@@ -215,6 +217,8 @@ function serveDistFile(res, pathname) {
215
217
  }
216
218
 
217
219
  function handleRequest(req, res) {
220
+ res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
221
+
218
222
  // CORS for local dev
219
223
  res.setHeader('Access-Control-Allow-Origin', '*')
220
224
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
@@ -265,6 +269,11 @@ function handleRequest(req, res) {
265
269
  res.end(JSON.stringify(getModelsPayload()))
266
270
  break
267
271
 
272
+ case '/api/health':
273
+ res.writeHead(200, { 'Content-Type': 'application/json' })
274
+ res.end(JSON.stringify({ ok: true, app: SERVER_SIGNATURE }))
275
+ break
276
+
268
277
  case '/api/config':
269
278
  res.writeHead(200, { 'Content-Type': 'application/json' })
270
279
  res.end(JSON.stringify(getConfigPayload()))
@@ -335,6 +344,40 @@ function checkPortInUse(port) {
335
344
  })
336
345
  }
337
346
 
347
+ export async function inspectExistingWebServer(port) {
348
+ const inUse = await checkPortInUse(port)
349
+ if (!inUse) return { inUse: false, isFcm: false }
350
+
351
+ const controller = new AbortController()
352
+ const timeout = setTimeout(() => controller.abort(), 750)
353
+
354
+ try {
355
+ // 📖 Probe a tiny health route so we only reuse a port when the running
356
+ // 📖 process is actually the free-coding-models dashboard, not any random app.
357
+ const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
358
+ signal: controller.signal,
359
+ headers: { Accept: 'application/json' },
360
+ })
361
+ const payload = await response.json().catch(() => null)
362
+ const signature = response.headers.get('x-fcm-server')
363
+ return {
364
+ inUse: true,
365
+ isFcm: signature === SERVER_SIGNATURE || payload?.app === SERVER_SIGNATURE,
366
+ }
367
+ } catch {
368
+ return { inUse: true, isFcm: false }
369
+ } finally {
370
+ clearTimeout(timeout)
371
+ }
372
+ }
373
+
374
+ export async function findAvailablePort(startPort, maxAttempts = 20) {
375
+ for (let port = startPort; port < startPort + maxAttempts; port++) {
376
+ if (!(await checkPortInUse(port))) return port
377
+ }
378
+ throw new Error(`No free local port found between ${startPort} and ${startPort + maxAttempts - 1}`)
379
+ }
380
+
338
381
  function openBrowser(url) {
339
382
  const cmd = process.platform === 'darwin' ? 'open'
340
383
  : process.platform === 'win32' ? 'start'
@@ -346,11 +389,12 @@ function openBrowser(url) {
346
389
 
347
390
  // ─── Exports ─────────────────────────────────────────────────────────────────
348
391
 
349
- export async function startWebServer(port = 3333, { open = true } = {}) {
350
- const alreadyRunning = await checkPortInUse(port)
351
- const url = `http://localhost:${port}`
392
+ export async function startWebServer(port = 3333, { open = true, startPingLoop = true } = {}) {
393
+ const portStatus = await inspectExistingWebServer(port)
394
+
395
+ if (portStatus.inUse && portStatus.isFcm) {
396
+ const url = `http://localhost:${port}`
352
397
 
353
- if (alreadyRunning) {
354
398
  console.log()
355
399
  console.log(` ⚡ free-coding-models Web Dashboard already running`)
356
400
  console.log(` 🌐 ${url}`)
@@ -359,9 +403,21 @@ export async function startWebServer(port = 3333, { open = true } = {}) {
359
403
  return null
360
404
  }
361
405
 
406
+ let resolvedPort = port
407
+ if (portStatus.inUse && !portStatus.isFcm) {
408
+ resolvedPort = await findAvailablePort(port + 1)
409
+ console.log()
410
+ console.log(` ⚠️ Port ${port} is already in use by another local app`)
411
+ console.log(` ↪ Starting free-coding-models Web Dashboard on port ${resolvedPort} instead`)
412
+ console.log()
413
+ }
414
+
415
+ const url = `http://localhost:${resolvedPort}`
416
+
362
417
  const server = createServer(handleRequest)
418
+ let pingLoopTimer = null
363
419
 
364
- server.listen(port, () => {
420
+ server.listen(resolvedPort, () => {
365
421
  console.log()
366
422
  console.log(` ⚡ free-coding-models Web Dashboard`)
367
423
  console.log(` 🌐 ${url}`)
@@ -373,10 +429,15 @@ export async function startWebServer(port = 3333, { open = true } = {}) {
373
429
  })
374
430
 
375
431
  async function schedulePingLoop() {
432
+ if (!server.listening) return
376
433
  await pingAllModels()
377
- setTimeout(schedulePingLoop, 10_000)
434
+ pingLoopTimer = setTimeout(schedulePingLoop, 10_000)
378
435
  }
379
- schedulePingLoop()
436
+
437
+ if (startPingLoop) schedulePingLoop()
438
+ server.on('close', () => {
439
+ if (pingLoopTimer) clearTimeout(pingLoopTimer)
440
+ })
380
441
 
381
442
  return server
382
443
  }