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.
- package/CHANGELOG.md +5 -1800
- package/README.md +10 -1
- package/bin/free-coding-models.js +8 -0
- package/package.json +13 -3
- package/src/app.js +30 -0
- package/src/cli-help.js +2 -0
- package/src/command-palette.js +3 -0
- package/src/config.js +7 -0
- package/src/endpoint-installer.js +1 -1
- package/src/key-handler.js +27 -1
- package/src/overlays.js +11 -1
- package/src/shell-env.js +393 -0
- package/src/tool-bootstrap.js +41 -0
- package/src/tool-launchers.js +166 -1
- package/src/tool-metadata.js +12 -0
- package/src/utils.js +12 -0
- package/web/app.legacy.js +900 -0
- package/web/index.html +20 -0
- package/web/server.js +443 -0
- package/web/src/App.jsx +150 -0
- package/web/src/components/analytics/AnalyticsView.jsx +109 -0
- package/web/src/components/analytics/AnalyticsView.module.css +186 -0
- package/web/src/components/atoms/Sparkline.jsx +44 -0
- package/web/src/components/atoms/StabilityCell.jsx +18 -0
- package/web/src/components/atoms/StabilityCell.module.css +8 -0
- package/web/src/components/atoms/StatusDot.jsx +10 -0
- package/web/src/components/atoms/StatusDot.module.css +17 -0
- package/web/src/components/atoms/TierBadge.jsx +10 -0
- package/web/src/components/atoms/TierBadge.module.css +18 -0
- package/web/src/components/atoms/Toast.jsx +25 -0
- package/web/src/components/atoms/Toast.module.css +35 -0
- package/web/src/components/atoms/ToastContainer.jsx +16 -0
- package/web/src/components/atoms/ToastContainer.module.css +10 -0
- package/web/src/components/atoms/VerdictBadge.jsx +13 -0
- package/web/src/components/atoms/VerdictBadge.module.css +19 -0
- package/web/src/components/dashboard/DetailPanel.jsx +131 -0
- package/web/src/components/dashboard/DetailPanel.module.css +99 -0
- package/web/src/components/dashboard/ExportModal.jsx +79 -0
- package/web/src/components/dashboard/ExportModal.module.css +99 -0
- package/web/src/components/dashboard/FilterBar.jsx +73 -0
- package/web/src/components/dashboard/FilterBar.module.css +43 -0
- package/web/src/components/dashboard/ModelTable.jsx +86 -0
- package/web/src/components/dashboard/ModelTable.module.css +46 -0
- package/web/src/components/dashboard/StatsBar.jsx +40 -0
- package/web/src/components/dashboard/StatsBar.module.css +28 -0
- package/web/src/components/layout/Footer.jsx +19 -0
- package/web/src/components/layout/Footer.module.css +10 -0
- package/web/src/components/layout/Header.jsx +38 -0
- package/web/src/components/layout/Header.module.css +73 -0
- package/web/src/components/layout/Sidebar.jsx +41 -0
- package/web/src/components/layout/Sidebar.module.css +76 -0
- package/web/src/components/settings/SettingsView.jsx +264 -0
- package/web/src/components/settings/SettingsView.module.css +377 -0
- package/web/src/global.css +199 -0
- package/web/src/hooks/useFilter.js +83 -0
- package/web/src/hooks/useSSE.js +49 -0
- package/web/src/hooks/useTheme.js +27 -0
- package/web/src/main.jsx +15 -0
- package/web/src/utils/download.js +15 -0
- package/web/src/utils/format.js +42 -0
- package/web/src/utils/ranks.js +37 -0
- package/web/styles.legacy.css +963 -0
package/src/shell-env.js
ADDED
|
@@ -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
|
+
}
|
package/src/tool-bootstrap.js
CHANGED
|
@@ -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',
|
package/src/tool-launchers.js
CHANGED
|
@@ -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)
|
package/src/tool-metadata.js
CHANGED
|
@@ -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
|
]
|