free-coding-models 0.3.37 → 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +5 -1800
  2. package/README.md +10 -1
  3. package/bin/free-coding-models.js +8 -0
  4. package/package.json +13 -3
  5. package/src/app.js +30 -0
  6. package/src/cli-help.js +2 -0
  7. package/src/command-palette.js +3 -0
  8. package/src/config.js +7 -0
  9. package/src/endpoint-installer.js +1 -1
  10. package/src/key-handler.js +27 -1
  11. package/src/overlays.js +11 -1
  12. package/src/shell-env.js +393 -0
  13. package/src/tool-bootstrap.js +41 -0
  14. package/src/tool-launchers.js +166 -1
  15. package/src/tool-metadata.js +12 -0
  16. package/src/utils.js +12 -0
  17. package/web/app.legacy.js +900 -0
  18. package/web/index.html +20 -0
  19. package/web/server.js +443 -0
  20. package/web/src/App.jsx +150 -0
  21. package/web/src/components/analytics/AnalyticsView.jsx +109 -0
  22. package/web/src/components/analytics/AnalyticsView.module.css +186 -0
  23. package/web/src/components/atoms/Sparkline.jsx +44 -0
  24. package/web/src/components/atoms/StabilityCell.jsx +18 -0
  25. package/web/src/components/atoms/StabilityCell.module.css +8 -0
  26. package/web/src/components/atoms/StatusDot.jsx +10 -0
  27. package/web/src/components/atoms/StatusDot.module.css +17 -0
  28. package/web/src/components/atoms/TierBadge.jsx +10 -0
  29. package/web/src/components/atoms/TierBadge.module.css +18 -0
  30. package/web/src/components/atoms/Toast.jsx +25 -0
  31. package/web/src/components/atoms/Toast.module.css +35 -0
  32. package/web/src/components/atoms/ToastContainer.jsx +16 -0
  33. package/web/src/components/atoms/ToastContainer.module.css +10 -0
  34. package/web/src/components/atoms/VerdictBadge.jsx +13 -0
  35. package/web/src/components/atoms/VerdictBadge.module.css +19 -0
  36. package/web/src/components/dashboard/DetailPanel.jsx +131 -0
  37. package/web/src/components/dashboard/DetailPanel.module.css +99 -0
  38. package/web/src/components/dashboard/ExportModal.jsx +79 -0
  39. package/web/src/components/dashboard/ExportModal.module.css +99 -0
  40. package/web/src/components/dashboard/FilterBar.jsx +73 -0
  41. package/web/src/components/dashboard/FilterBar.module.css +43 -0
  42. package/web/src/components/dashboard/ModelTable.jsx +86 -0
  43. package/web/src/components/dashboard/ModelTable.module.css +46 -0
  44. package/web/src/components/dashboard/StatsBar.jsx +40 -0
  45. package/web/src/components/dashboard/StatsBar.module.css +28 -0
  46. package/web/src/components/layout/Footer.jsx +19 -0
  47. package/web/src/components/layout/Footer.module.css +10 -0
  48. package/web/src/components/layout/Header.jsx +38 -0
  49. package/web/src/components/layout/Header.module.css +73 -0
  50. package/web/src/components/layout/Sidebar.jsx +41 -0
  51. package/web/src/components/layout/Sidebar.module.css +76 -0
  52. package/web/src/components/settings/SettingsView.jsx +264 -0
  53. package/web/src/components/settings/SettingsView.module.css +377 -0
  54. package/web/src/global.css +199 -0
  55. package/web/src/hooks/useFilter.js +83 -0
  56. package/web/src/hooks/useSSE.js +49 -0
  57. package/web/src/hooks/useTheme.js +27 -0
  58. package/web/src/main.jsx +15 -0
  59. package/web/src/utils/download.js +15 -0
  60. package/web/src/utils/format.js +42 -0
  61. package/web/src/utils/ranks.js +37 -0
  62. package/web/styles.legacy.css +963 -0
@@ -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
+ }
@@ -228,6 +228,47 @@ export const TOOL_BOOTSTRAP_METADATA = {
228
228
  },
229
229
  },
230
230
  },
231
+ 'continue': {
232
+ binary: 'cn',
233
+ docsUrl: 'https://docs.continue.dev/cli/overview',
234
+ install: {
235
+ default: {
236
+ shellCommand: 'npm install -g @continuedev/cli',
237
+ summary: 'Install Continue CLI globally via npm.',
238
+ },
239
+ },
240
+ },
241
+ cline: {
242
+ binary: 'cline',
243
+ docsUrl: 'https://docs.cline.bot/cline-cli/overview',
244
+ install: {
245
+ default: {
246
+ shellCommand: 'npm install -g cline',
247
+ summary: 'Install Cline CLI globally via npm.',
248
+ },
249
+ },
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
+ },
258
+ hermes: {
259
+ binary: 'hermes',
260
+ docsUrl: 'https://github.com/NousResearch/hermes-agent',
261
+ install: {
262
+ default: {
263
+ shellCommand: 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash',
264
+ summary: 'Install Hermes Agent via the official Nous Research installer.',
265
+ note: 'Hermes requires Python 3.11+ and git. The installer handles everything else automatically.',
266
+ },
267
+ },
268
+ installUnsupported: {
269
+ win32: 'Hermes Agent does not support native Windows. Use WSL2 instead.',
270
+ },
271
+ },
231
272
  gemini: {
232
273
  binary: 'gemini',
233
274
  docsUrl: 'https://github.com/google-gemini/gemini-cli',
@@ -17,6 +17,9 @@
17
17
  * 📖 Crush: writes crush.json with provider config + models.large/small defaults
18
18
  * 📖 Pi: uses --provider/--model CLI flags for guaranteed auto-selection
19
19
  * 📖 Aider: writes ~/.aider.conf.yml + passes --model flag
20
+ * 📖 Hermes: uses `hermes config set` CLI commands + `hermes gateway restart` before launching `hermes chat`
21
+ * 📖 Continue: writes ~/.continue/config.yaml with provider: openai + apiBase
22
+ * 📖 Cline: writes ~/.cline/globalState.json with openai-compatible provider config
20
23
  *
21
24
  * @functions
22
25
  * → `resolveLauncherModelId` — choose the provider-specific id for a launch
@@ -36,7 +39,7 @@ import chalk from 'chalk'
36
39
  import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
37
40
  import { homedir } from 'os'
38
41
  import { dirname, join } from 'path'
39
- import { spawn } from 'child_process'
42
+ import { spawn, spawnSync } from 'child_process'
40
43
  import { sources } from '../sources.js'
41
44
  import { PROVIDER_COLOR } from './render-table.js'
42
45
  import { getApiKey } from './config.js'
@@ -73,6 +76,9 @@ function getDefaultToolPaths(homeDir = homedir()) {
73
76
  piModelsPath: join(homeDir, '.pi', 'agent', 'models.json'),
74
77
  piSettingsPath: join(homeDir, '.pi', 'agent', 'settings.json'),
75
78
  openHandsEnvPath: join(homeDir, '.fcm-openhands-env'),
79
+ hermesConfigPath: join(homeDir, '.hermes', 'config.yaml'),
80
+ continueConfigPath: join(homeDir, '.continue', 'config.yaml'),
81
+ clineConfigPath: join(homeDir, '.cline', 'globalState.json'),
76
82
  }
77
83
  }
78
84
 
@@ -401,6 +407,82 @@ function writeRovoConfig(model, configPath = join(homedir(), '.rovodev', 'config
401
407
  return { filePath: configPath, backupPath }
402
408
  }
403
409
 
410
+ // 📖 writeContinueConfig — write ~/.continue/config.yaml with the selected model.
411
+ // 📖 Continue CLI uses YAML config with `provider: openai` for OpenAI-compatible endpoints.
412
+ function writeContinueConfig(model, apiKey, baseUrl, paths = getDefaultToolPaths()) {
413
+ const filePath = paths.continueConfigPath
414
+ const backupPath = backupIfExists(filePath)
415
+ // 📖 Write a minimal config.yaml that Continue CLI can parse directly
416
+ const content = [
417
+ '# 📖 Managed by free-coding-models',
418
+ 'name: FCM Config',
419
+ 'version: 0.0.1',
420
+ 'schema: v1',
421
+ 'models:',
422
+ ' - name: ' + (model.label || model.modelId),
423
+ ' provider: openai',
424
+ ' model: ' + model.modelId,
425
+ ...(baseUrl ? [' apiBase: ' + baseUrl] : []),
426
+ ...(apiKey ? [' apiKey: ' + apiKey] : []),
427
+ ' roles:',
428
+ ' - chat',
429
+ ' - edit',
430
+ ' - apply',
431
+ '',
432
+ ].join('\n')
433
+ ensureDir(filePath)
434
+ writeFileSync(filePath, content)
435
+ return { filePath, backupPath }
436
+ }
437
+
438
+ // 📖 writeClineConfig — write ~/.cline/globalState.json with the selected model.
439
+ // 📖 Cline CLI stores provider config in globalState.json under apiConfiguration.
440
+ function writeClineConfig(model, apiKey, baseUrl, paths = getDefaultToolPaths()) {
441
+ const filePath = paths.clineConfigPath
442
+ const backupPath = backupIfExists(filePath)
443
+ const config = readJson(filePath, {})
444
+ // 📖 Set the API provider to "openai-compatible" and configure the endpoint
445
+ config.apiConfiguration = {
446
+ ...(config.apiConfiguration || {}),
447
+ apiProvider: 'openai-compatible',
448
+ openAiCompatibleApiModelId: model.modelId,
449
+ ...(baseUrl ? { openAiCompatibleApiBaseUrl: baseUrl } : {}),
450
+ ...(apiKey ? { openAiCompatibleApiKey: apiKey } : {}),
451
+ }
452
+ writeJson(filePath, config)
453
+ return { filePath, backupPath }
454
+ }
455
+
456
+ // 📖 writeHermesConfig — configure Hermes Agent via its own `hermes config set` CLI.
457
+ // 📖 This avoids YAML parsing and uses Hermes's native config management.
458
+ // 📖 Sets model name, base_url (OpenAI-compatible endpoint), and api_key.
459
+ function writeHermesConfig(model, apiKey, baseUrl, paths = getDefaultToolPaths()) {
460
+ const configPath = paths.hermesConfigPath
461
+ const backupPath = backupIfExists(configPath)
462
+ const hermesBin = resolveToolBinaryPath('hermes') || 'hermes'
463
+
464
+ // 📖 Use `hermes config set` for each field — robust and dependency-free
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' })
468
+ spawnSync(hermesBin, ['config', 'set', 'model.provider', 'custom'], { stdio: 'ignore' })
469
+ if (baseUrl) {
470
+ spawnSync(hermesBin, ['config', 'set', 'model.base_url', baseUrl], { stdio: 'ignore' })
471
+ }
472
+ if (apiKey) {
473
+ spawnSync(hermesBin, ['config', 'set', 'model.api_key', apiKey], { stdio: 'ignore' })
474
+ }
475
+
476
+ return { filePath: configPath, backupPath }
477
+ }
478
+
479
+ // 📖 restartHermesGateway — restart the Hermes messaging gateway after config changes.
480
+ // 📖 Non-blocking: if gateway is not running, this is a no-op.
481
+ function restartHermesGateway() {
482
+ const hermesBin = resolveToolBinaryPath('hermes') || 'hermes'
483
+ spawnSync(hermesBin, ['gateway', 'restart'], { stdio: 'ignore', timeout: 10000 })
484
+ }
485
+
404
486
  /**
405
487
  * 📖 buildGeminiEnv - Build environment variables for Gemini CLI
406
488
  *
@@ -597,6 +679,57 @@ export function prepareExternalToolLaunch(mode, model, config, options = {}) {
597
679
  }
598
680
  }
599
681
 
682
+ if (mode === 'hermes') {
683
+ const result = writeHermesConfig(model, apiKey, baseUrl, paths)
684
+ return {
685
+ command: 'hermes',
686
+ args: ['chat'],
687
+ env,
688
+ apiKey,
689
+ baseUrl,
690
+ meta,
691
+ configArtifacts: [{ path: result.filePath, backupPath: result.backupPath, label: 'config' }],
692
+ }
693
+ }
694
+
695
+ if (mode === 'continue') {
696
+ const result = writeContinueConfig(model, apiKey, baseUrl, paths)
697
+ return {
698
+ command: 'cn',
699
+ args: [],
700
+ env,
701
+ apiKey,
702
+ baseUrl,
703
+ meta,
704
+ configArtifacts: [{ path: result.filePath, backupPath: result.backupPath, label: 'config' }],
705
+ }
706
+ }
707
+
708
+ if (mode === 'cline') {
709
+ const result = writeClineConfig(model, apiKey, baseUrl, paths)
710
+ return {
711
+ command: 'cline',
712
+ args: [],
713
+ env,
714
+ apiKey,
715
+ baseUrl,
716
+ meta,
717
+ configArtifacts: [{ path: result.filePath, backupPath: result.backupPath, label: 'config' }],
718
+ }
719
+ }
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
+
600
733
  if (mode === 'rovo') {
601
734
  const result = writeRovoConfig(model, join(homedir(), '.rovodev', 'config.yml'), paths)
602
735
  console.log(chalk.dim(` 📖 Rovo Dev CLI configured with model: ${model.modelId}`))
@@ -679,6 +812,38 @@ export async function startExternalTool(mode, model, config) {
679
812
  return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
680
813
  }
681
814
 
815
+ if (mode === 'hermes') {
816
+ // 📖 Restart the Hermes gateway so the new model config takes effect immediately
817
+ restartHermesGateway()
818
+ console.log(chalk.dim(` 📖 Hermes Agent configured with model: ${model.modelId}`))
819
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
820
+ }
821
+
822
+ if (mode === 'continue') {
823
+ console.log(chalk.dim(` 📖 Continue CLI configured with model: ${model.modelId}`))
824
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
825
+ }
826
+
827
+ if (mode === 'cline') {
828
+ console.log(chalk.dim(` 📖 Cline configured with model: ${model.modelId}`))
829
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
830
+ }
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
+
682
847
  if (mode === 'rovo') {
683
848
  console.log(chalk.dim(` 📖 Launching Rovo Dev CLI in interactive mode...`))
684
849
  return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
@@ -36,8 +36,12 @@ export const TOOL_METADATA = {
36
36
  qwen: { label: 'Qwen Code', emoji: '🐉', flag: '--qwen', color: [255, 213, 128] },
37
37
  openhands: { label: 'OpenHands', emoji: '🤲', flag: '--openhands', color: [228, 191, 239] },
38
38
  amp: { label: 'Amp', emoji: '⚡', flag: '--amp', color: [255, 232, 98] },
39
+ hermes: { label: 'Hermes', emoji: '🔮', flag: '--hermes', color: [200, 160, 255] },
40
+ 'continue': { label: 'Continue CLI', emoji: '▶️', flag: '--continue', color: [255, 100, 100] },
41
+ cline: { label: 'Cline', emoji: '🧠', flag: '--cline', color: [100, 220, 180] },
39
42
  rovo: { label: 'Rovo Dev CLI', emoji: '🦘', flag: '--rovo', color: [148, 163, 184], cliOnly: true },
40
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] },
41
45
  }
42
46
 
43
47
  // 📖 Deduplicated emoji order for the "Compatible with" column.
@@ -53,8 +57,12 @@ export const COMPAT_COLUMN_SLOTS = [
53
57
  { emoji: '🐉', toolKeys: ['qwen'], color: [255, 213, 128] },
54
58
  { emoji: '🤲', toolKeys: ['openhands'], color: [228, 191, 239] },
55
59
  { emoji: '⚡', toolKeys: ['amp'], color: [255, 232, 98] },
60
+ { emoji: '🔮', toolKeys: ['hermes'], color: [200, 160, 255] },
61
+ { emoji: '▶️', toolKeys: ['continue'], color: [255, 100, 100] },
62
+ { emoji: '🧠', toolKeys: ['cline'], color: [100, 220, 180] },
56
63
  { emoji: '🦘', toolKeys: ['rovo'], color: [148, 163, 184] },
57
64
  { emoji: '♊', toolKeys: ['gemini'], color: [66, 165, 245] },
65
+ { emoji: '🛠️', toolKeys: ['xcode'], color: [20, 126, 251] },
58
66
  ]
59
67
 
60
68
  export const TOOL_MODE_ORDER = [
@@ -68,6 +76,10 @@ export const TOOL_MODE_ORDER = [
68
76
  'qwen',
69
77
  'openhands',
70
78
  'amp',
79
+ 'hermes',
80
+ 'continue',
81
+ 'cline',
82
+ 'xcode',
71
83
  'rovo',
72
84
  'gemini',
73
85
  ]