free-coding-models 0.1.87 → 0.1.89
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/README.md +58 -45
- package/bin/free-coding-models.js +65 -14
- package/package.json +1 -1
- package/src/config.js +42 -4
- package/src/key-handler.js +105 -13
- package/src/opencode-sync.js +41 -0
- package/src/opencode.js +23 -2
- package/src/overlays.js +74 -13
- package/src/render-table.js +72 -16
- package/src/token-usage-reader.js +5 -5
- package/src/tool-launchers.js +319 -0
- package/src/tool-metadata.js +63 -0
- package/src/updater.js +128 -30
- package/src/utils.js +40 -3
package/src/render-table.js
CHANGED
|
@@ -39,6 +39,18 @@ import { getAvg, getVerdict, getUptime, getStabilityScore } from './utils.js'
|
|
|
39
39
|
import { usagePlaceholderForProvider } from './ping.js'
|
|
40
40
|
import { formatTokenTotalCompact } from './token-usage-reader.js'
|
|
41
41
|
import { calculateViewport, sortResultsWithPinnedFavorites, renderProxyStatusLine, padEndDisplay } from './render-helpers.js'
|
|
42
|
+
import { getToolMeta } from './tool-metadata.js'
|
|
43
|
+
|
|
44
|
+
const ACTIVE_FILTER_BG_BY_TIER = {
|
|
45
|
+
'S+': [57, 255, 20],
|
|
46
|
+
'S': [57, 255, 20],
|
|
47
|
+
'A+': [160, 255, 60],
|
|
48
|
+
'A': [255, 224, 130],
|
|
49
|
+
'A-': [255, 204, 128],
|
|
50
|
+
'B+': [255, 171, 64],
|
|
51
|
+
'B': [239, 83, 80],
|
|
52
|
+
'C': [186, 104, 200],
|
|
53
|
+
}
|
|
42
54
|
|
|
43
55
|
const require = createRequire(import.meta.url)
|
|
44
56
|
const { version: LOCAL_VERSION } = require('../package.json')
|
|
@@ -77,7 +89,7 @@ export function setActiveProxy(proxyInstance) {
|
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
// ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
|
|
80
|
-
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false) {
|
|
92
|
+
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false) {
|
|
81
93
|
// 📖 Filter out hidden models for display
|
|
82
94
|
const visibleResults = results.filter(r => !r.hidden)
|
|
83
95
|
|
|
@@ -119,20 +131,21 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
119
131
|
|
|
120
132
|
// 📖 Tool badge keeps the active launch target visible in the header, so the
|
|
121
133
|
// 📖 footer no longer needs a redundant Enter action or mode toggle reminder.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
modeBadge = chalk.bold.rgb(0, 200, 255)(' [ ') + chalk.yellow.bold('Z') + chalk.bold.rgb(0, 200, 255)(' Tool : OpenCode CLI ]')
|
|
129
|
-
}
|
|
134
|
+
const toolMeta = getToolMeta(mode)
|
|
135
|
+
const toolBadgeColor = mode === 'openclaw'
|
|
136
|
+
? chalk.bold.rgb(255, 100, 50)
|
|
137
|
+
: chalk.bold.rgb(0, 200, 255)
|
|
138
|
+
const modeBadge = toolBadgeColor(' [ ') + chalk.yellow.bold('Z') + toolBadgeColor(` Tool : ${toolMeta.label} ]`)
|
|
139
|
+
const activeHeaderBadge = (text, bg = [57, 255, 20], fg = [0, 0, 0]) => chalk.bgRgb(...bg).rgb(...fg).bold(` ${text} `)
|
|
130
140
|
|
|
131
141
|
// 📖 Tier filter badge shown when filtering is active (shows exact tier name)
|
|
132
142
|
const TIER_CYCLE_NAMES = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
133
143
|
let tierBadge = ''
|
|
144
|
+
let activeTierLabel = ''
|
|
134
145
|
if (tierFilterMode > 0) {
|
|
135
|
-
|
|
146
|
+
activeTierLabel = TIER_CYCLE_NAMES[tierFilterMode]
|
|
147
|
+
const tierBg = ACTIVE_FILTER_BG_BY_TIER[activeTierLabel] || [57, 255, 20]
|
|
148
|
+
tierBadge = ` ${activeHeaderBadge(`TIER (${activeTierLabel})`, tierBg)}`
|
|
136
149
|
}
|
|
137
150
|
|
|
138
151
|
const normalizeOriginLabel = (name, key) => {
|
|
@@ -142,15 +155,23 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
142
155
|
|
|
143
156
|
// 📖 Origin filter badge — shown when filtering by provider is active
|
|
144
157
|
let originBadge = ''
|
|
158
|
+
let activeOriginLabel = ''
|
|
145
159
|
if (originFilterMode > 0) {
|
|
146
160
|
const originKeys = [null, ...Object.keys(sources)]
|
|
147
161
|
const activeOriginKey = originKeys[originFilterMode]
|
|
148
162
|
const activeOriginName = activeOriginKey ? sources[activeOriginKey]?.name ?? activeOriginKey : null
|
|
149
163
|
if (activeOriginName) {
|
|
150
|
-
|
|
164
|
+
activeOriginLabel = normalizeOriginLabel(activeOriginName, activeOriginKey)
|
|
165
|
+
const providerRgb = PROVIDER_COLOR[activeOriginKey] || [255, 255, 255]
|
|
166
|
+
originBadge = ` ${activeHeaderBadge(`PROVIDER (${activeOriginLabel})`, [0, 0, 0], providerRgb)}`
|
|
151
167
|
}
|
|
152
168
|
}
|
|
153
169
|
|
|
170
|
+
let configuredBadge = ''
|
|
171
|
+
if (hideUnconfiguredModels) {
|
|
172
|
+
configuredBadge = ` ${activeHeaderBadge('CONFIGURED ONLY')}`
|
|
173
|
+
}
|
|
174
|
+
|
|
154
175
|
// 📖 Profile badge — shown when a named profile is active (Shift+P to cycle, Shift+S to save)
|
|
155
176
|
let profileBadge = ''
|
|
156
177
|
if (activeProfile) {
|
|
@@ -173,14 +194,27 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
173
194
|
const W_TOKENS = 7
|
|
174
195
|
const W_USAGE = 7
|
|
175
196
|
const MIN_TABLE_WIDTH = 166
|
|
197
|
+
const warningDurationMs = 5_000
|
|
198
|
+
const elapsed = widthWarningStartedAt ? Math.max(0, Date.now() - widthWarningStartedAt) : warningDurationMs
|
|
199
|
+
const remainingMs = Math.max(0, warningDurationMs - elapsed)
|
|
200
|
+
const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !widthWarningDismissed && remainingMs > 0
|
|
176
201
|
|
|
177
|
-
if (
|
|
202
|
+
if (showWidthWarning) {
|
|
178
203
|
const lines = []
|
|
179
|
-
const blankLines = Math.max(0, Math.floor(((terminalRows || 24) -
|
|
180
|
-
const warning = 'Please maximize your terminal for optimal use.
|
|
204
|
+
const blankLines = Math.max(0, Math.floor(((terminalRows || 24) - 5) / 2))
|
|
205
|
+
const warning = 'Please maximize your terminal for optimal use.'
|
|
206
|
+
const warning2 = 'The current terminal is too small.'
|
|
207
|
+
const warning3 = 'Reduce font size or maximize width of terminal.'
|
|
181
208
|
const padLeft = Math.max(0, Math.floor((terminalCols - warning.length) / 2))
|
|
209
|
+
const padLeft2 = Math.max(0, Math.floor((terminalCols - warning2.length) / 2))
|
|
210
|
+
const padLeft3 = Math.max(0, Math.floor((terminalCols - warning3.length) / 2))
|
|
182
211
|
for (let i = 0; i < blankLines; i++) lines.push('')
|
|
183
212
|
lines.push(' '.repeat(padLeft) + chalk.red.bold(warning))
|
|
213
|
+
lines.push(' '.repeat(padLeft2) + chalk.red(warning2))
|
|
214
|
+
lines.push(' '.repeat(padLeft3) + chalk.red(warning3))
|
|
215
|
+
lines.push('')
|
|
216
|
+
lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 34) / 2))) + chalk.yellow(`this message will hide in ${(remainingMs / 1000).toFixed(1)}s`))
|
|
217
|
+
lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 20) / 2))) + chalk.dim('press esc to dismiss'))
|
|
184
218
|
while (terminalRows > 0 && lines.length < terminalRows) lines.push('')
|
|
185
219
|
const EL = '\x1b[K'
|
|
186
220
|
return lines.map(line => line + EL).join('\n')
|
|
@@ -190,7 +224,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
190
224
|
const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
|
|
191
225
|
|
|
192
226
|
const lines = [
|
|
193
|
-
` ${chalk.greenBright.bold(`✅ Free-Coding-Models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${profileBadge}${chalk.reset('')} ` +
|
|
227
|
+
` ${chalk.greenBright.bold(`✅ Free-Coding-Models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${configuredBadge}${profileBadge}${chalk.reset('')} ` +
|
|
194
228
|
chalk.greenBright(`✅ ${up}`) + chalk.dim(' up ') +
|
|
195
229
|
chalk.yellow(`⏳ ${timeout}`) + chalk.dim(' timeout ') +
|
|
196
230
|
chalk.red(`❌ ${down}`) + chalk.dim(' down ') +
|
|
@@ -576,8 +610,30 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
576
610
|
// 📖 Footer hints keep only navigation and secondary actions now that the
|
|
577
611
|
// 📖 active tool target is already visible in the header badge.
|
|
578
612
|
const hotkey = (keyLabel, text) => chalk.yellow(keyLabel) + chalk.dim(text)
|
|
613
|
+
// 📖 Active filter pills use a loud green background so tier/provider/configured-only
|
|
614
|
+
// 📖 states are obvious even when the user misses the smaller header badges.
|
|
615
|
+
const activeHotkey = (keyLabel, text, bg = [57, 255, 20], fg = [0, 0, 0]) => chalk.bgRgb(...bg).rgb(...fg)(` ${keyLabel}${text} `)
|
|
579
616
|
// 📖 Line 1: core navigation + filtering shortcuts
|
|
580
|
-
lines.push(
|
|
617
|
+
lines.push(
|
|
618
|
+
chalk.dim(` ↑↓ Navigate • `) +
|
|
619
|
+
hotkey('F', ' Toggle Favorite') +
|
|
620
|
+
chalk.dim(` • `) +
|
|
621
|
+
(tierFilterMode > 0
|
|
622
|
+
? activeHotkey('T', ` Tier (${activeTierLabel})`, ACTIVE_FILTER_BG_BY_TIER[activeTierLabel] || [57, 255, 20])
|
|
623
|
+
: hotkey('T', ' Tier')) +
|
|
624
|
+
chalk.dim(` • `) +
|
|
625
|
+
(originFilterMode > 0
|
|
626
|
+
? activeHotkey('D', ` Provider (${activeOriginLabel})`, [0, 0, 0], PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
|
|
627
|
+
: hotkey('D', ' Provider')) +
|
|
628
|
+
chalk.dim(` • `) +
|
|
629
|
+
(hideUnconfiguredModels ? activeHotkey('E', ' Configured Only') : hotkey('E', ' Configured Only')) +
|
|
630
|
+
chalk.dim(` • `) +
|
|
631
|
+
hotkey('X', ' Token Logs') +
|
|
632
|
+
chalk.dim(` • `) +
|
|
633
|
+
hotkey('P', ' Settings') +
|
|
634
|
+
chalk.dim(` • `) +
|
|
635
|
+
hotkey('K', ' Help')
|
|
636
|
+
)
|
|
581
637
|
// 📖 Line 2: profiles, recommend, feature request, bug report, and extended hints — gives visibility to less-obvious features
|
|
582
638
|
lines.push(chalk.dim(` `) + hotkey('⇧P', ' Cycle profile') + chalk.dim(` • `) + hotkey('⇧S', ' Save profile') + chalk.dim(` • `) + hotkey('Q', ' Smart Recommend') + chalk.dim(` • `) + hotkey('J', ' Request feature') + chalk.dim(` • `) + hotkey('I', ' Report bug'))
|
|
583
639
|
// 📖 Proxy status line — always rendered with explicit state (starting/running/failed/stopped)
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* @functions
|
|
19
19
|
* → `buildProviderModelTokenKey` — creates a stable aggregation key
|
|
20
20
|
* → `loadTokenUsageByProviderModel` — reads request-log.jsonl and returns total tokens by provider+model
|
|
21
|
-
* → `formatTokenTotalCompact` — renders totals as
|
|
21
|
+
* → `formatTokenTotalCompact` — renders totals as raw ints or compact K / M strings with 2 decimals
|
|
22
22
|
*
|
|
23
23
|
* @exports buildProviderModelTokenKey, loadTokenUsageByProviderModel, formatTokenTotalCompact
|
|
24
24
|
*
|
|
@@ -53,11 +53,11 @@ export function loadTokenUsageByProviderModel({ logFile, limit = 50_000 } = {})
|
|
|
53
53
|
return totals
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// 📖 formatTokenTotalCompact keeps
|
|
57
|
-
// 📖 0-999 => raw integer, 1k-999k =>
|
|
56
|
+
// 📖 formatTokenTotalCompact keeps token counts readable in both the table and log view:
|
|
57
|
+
// 📖 0-999 => raw integer, 1k-999k => N.NNk, 1m+ => N.NNM.
|
|
58
58
|
export function formatTokenTotalCompact(totalTokens) {
|
|
59
59
|
const safeTotal = Number(totalTokens) || 0
|
|
60
|
-
if (safeTotal >=
|
|
61
|
-
if (safeTotal >= 1_000) return `${
|
|
60
|
+
if (safeTotal >= 999_500) return `${(safeTotal / 1_000_000).toFixed(2)}M`
|
|
61
|
+
if (safeTotal >= 1_000) return `${(safeTotal / 1_000).toFixed(2)}k`
|
|
62
62
|
return String(Math.floor(safeTotal))
|
|
63
63
|
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/tool-launchers.js
|
|
3
|
+
* @description Auto-configure and launch external coding tools from the selected model row.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 This module extends the existing "pick a model and press Enter" workflow to
|
|
7
|
+
* external CLIs that can consume OpenAI-compatible or provider-specific settings.
|
|
8
|
+
*
|
|
9
|
+
* 📖 The design is pragmatic:
|
|
10
|
+
* - Write a small managed config file when the tool's config shape is stable enough
|
|
11
|
+
* - Always export the runtime environment variables before spawning the tool
|
|
12
|
+
* - Keep each launcher isolated so a partial integration does not break others
|
|
13
|
+
*
|
|
14
|
+
* 📖 Some tools still have weaker official support for arbitrary custom providers.
|
|
15
|
+
* For those, we prefer a transparent warning over pretending the integration is
|
|
16
|
+
* fully official. The user still gets a reproducible env/config handoff.
|
|
17
|
+
*
|
|
18
|
+
* @functions
|
|
19
|
+
* → `startExternalTool` — configure and launch the selected external tool mode
|
|
20
|
+
*
|
|
21
|
+
* @exports startExternalTool
|
|
22
|
+
*
|
|
23
|
+
* @see src/tool-metadata.js
|
|
24
|
+
* @see src/provider-metadata.js
|
|
25
|
+
* @see sources.js
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import chalk from 'chalk'
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
|
|
30
|
+
import { homedir } from 'os'
|
|
31
|
+
import { dirname, join } from 'path'
|
|
32
|
+
import { spawn } from 'child_process'
|
|
33
|
+
import { sources } from '../sources.js'
|
|
34
|
+
import { getApiKey, getProxySettings } from './config.js'
|
|
35
|
+
import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
|
|
36
|
+
import { getToolMeta } from './tool-metadata.js'
|
|
37
|
+
import { ensureProxyRunning } from './opencode.js'
|
|
38
|
+
|
|
39
|
+
function ensureDir(filePath) {
|
|
40
|
+
const dir = dirname(filePath)
|
|
41
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function backupIfExists(filePath) {
|
|
45
|
+
if (!existsSync(filePath)) return null
|
|
46
|
+
const backupPath = `${filePath}.backup-${Date.now()}`
|
|
47
|
+
copyFileSync(filePath, backupPath)
|
|
48
|
+
return backupPath
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readJson(filePath, fallback) {
|
|
52
|
+
if (!existsSync(filePath)) return fallback
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(readFileSync(filePath, 'utf8'))
|
|
55
|
+
} catch {
|
|
56
|
+
return fallback
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeJson(filePath, value) {
|
|
61
|
+
ensureDir(filePath)
|
|
62
|
+
writeFileSync(filePath, JSON.stringify(value, null, 2))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getProviderBaseUrl(providerKey) {
|
|
66
|
+
const url = sources[providerKey]?.url
|
|
67
|
+
if (!url) return null
|
|
68
|
+
return url
|
|
69
|
+
.replace(/\/chat\/completions$/i, '')
|
|
70
|
+
.replace(/\/responses$/i, '')
|
|
71
|
+
.replace(/\/predictions$/i, '')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildToolEnv(mode, model, config) {
|
|
75
|
+
const providerKey = model.providerKey
|
|
76
|
+
const providerUrl = sources[providerKey]?.url || ''
|
|
77
|
+
const baseUrl = getProviderBaseUrl(providerKey)
|
|
78
|
+
const apiKey = getApiKey(config, providerKey)
|
|
79
|
+
const env = { ...process.env }
|
|
80
|
+
const providerEnvName = ENV_VAR_NAMES[providerKey]
|
|
81
|
+
if (providerEnvName && apiKey) env[providerEnvName] = apiKey
|
|
82
|
+
|
|
83
|
+
// 📖 OpenAI-compatible defaults reused by multiple CLIs.
|
|
84
|
+
if (apiKey && baseUrl) {
|
|
85
|
+
env.OPENAI_API_KEY = apiKey
|
|
86
|
+
env.OPENAI_BASE_URL = baseUrl
|
|
87
|
+
env.OPENAI_API_BASE = baseUrl
|
|
88
|
+
env.OPENAI_MODEL = model.modelId
|
|
89
|
+
env.LLM_API_KEY = apiKey
|
|
90
|
+
env.LLM_BASE_URL = baseUrl
|
|
91
|
+
env.LLM_MODEL = `openai/${model.modelId}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 📖 Provider-specific envs for tools that expect a different wire format.
|
|
95
|
+
if (mode === 'claude-code' && apiKey && baseUrl) {
|
|
96
|
+
env.ANTHROPIC_AUTH_TOKEN = apiKey
|
|
97
|
+
env.ANTHROPIC_BASE_URL = baseUrl
|
|
98
|
+
env.ANTHROPIC_MODEL = model.modelId
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (mode === 'gemini' && apiKey && baseUrl) {
|
|
102
|
+
env.GOOGLE_API_KEY = apiKey
|
|
103
|
+
env.GOOGLE_GEMINI_BASE_URL = baseUrl
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { env, apiKey, baseUrl, providerUrl }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function spawnCommand(command, args, env) {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const child = spawn(command, args, {
|
|
112
|
+
stdio: 'inherit',
|
|
113
|
+
shell: isWindows,
|
|
114
|
+
detached: false,
|
|
115
|
+
env,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
child.on('exit', (code) => resolve(code))
|
|
119
|
+
child.on('error', (err) => {
|
|
120
|
+
if (err.code === 'ENOENT') {
|
|
121
|
+
console.error(chalk.red(` X Could not find "${command}" in PATH.`))
|
|
122
|
+
resolve(1)
|
|
123
|
+
} else {
|
|
124
|
+
reject(err)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function writeAiderConfig(model, apiKey, baseUrl) {
|
|
131
|
+
const filePath = join(homedir(), '.aider.conf.yml')
|
|
132
|
+
const backupPath = backupIfExists(filePath)
|
|
133
|
+
const content = [
|
|
134
|
+
'# 📖 Managed by free-coding-models',
|
|
135
|
+
`openai-api-base: ${baseUrl}`,
|
|
136
|
+
`openai-api-key: ${apiKey}`,
|
|
137
|
+
`model: openai/${model.modelId}`,
|
|
138
|
+
'',
|
|
139
|
+
].join('\n')
|
|
140
|
+
ensureDir(filePath)
|
|
141
|
+
writeFileSync(filePath, content)
|
|
142
|
+
return { filePath, backupPath }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function writeCrushConfig(model, apiKey, baseUrl, providerId) {
|
|
146
|
+
const filePath = join(homedir(), '.config', 'crush', 'crush.json')
|
|
147
|
+
const backupPath = backupIfExists(filePath)
|
|
148
|
+
const config = readJson(filePath, { $schema: 'https://charm.land/crush.json' })
|
|
149
|
+
if (!config.options || typeof config.options !== 'object') config.options = {}
|
|
150
|
+
config.options.disable_default_providers = true
|
|
151
|
+
if (!config.providers || typeof config.providers !== 'object') config.providers = {}
|
|
152
|
+
config.providers[providerId] = {
|
|
153
|
+
name: 'Free Coding Models',
|
|
154
|
+
type: 'openai-compat',
|
|
155
|
+
base_url: baseUrl,
|
|
156
|
+
api_key: apiKey,
|
|
157
|
+
models: [
|
|
158
|
+
{
|
|
159
|
+
name: model.label,
|
|
160
|
+
id: model.modelId,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
}
|
|
164
|
+
// 📖 Crush expects structured selected models at config.models.{large,small}.
|
|
165
|
+
// 📖 Root `crush` reads these defaults in interactive mode, unlike `crush run --model`.
|
|
166
|
+
config.models = {
|
|
167
|
+
...(config.models && typeof config.models === 'object' ? config.models : {}),
|
|
168
|
+
large: { model: model.modelId, provider: providerId },
|
|
169
|
+
}
|
|
170
|
+
writeJson(filePath, config)
|
|
171
|
+
return { filePath, backupPath }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function writeGeminiConfig(model) {
|
|
175
|
+
const filePath = join(homedir(), '.gemini', 'settings.json')
|
|
176
|
+
const backupPath = backupIfExists(filePath)
|
|
177
|
+
const config = readJson(filePath, {})
|
|
178
|
+
config.model = model.modelId
|
|
179
|
+
writeJson(filePath, config)
|
|
180
|
+
return { filePath, backupPath }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function writeQwenConfig(model, providerKey, apiKey, baseUrl) {
|
|
184
|
+
const filePath = join(homedir(), '.qwen', 'settings.json')
|
|
185
|
+
const backupPath = backupIfExists(filePath)
|
|
186
|
+
const config = readJson(filePath, {})
|
|
187
|
+
if (!config.modelProviders || typeof config.modelProviders !== 'object') config.modelProviders = {}
|
|
188
|
+
if (!Array.isArray(config.modelProviders.openai)) config.modelProviders.openai = []
|
|
189
|
+
const nextEntry = {
|
|
190
|
+
id: model.modelId,
|
|
191
|
+
name: model.label,
|
|
192
|
+
envKey: ENV_VAR_NAMES[providerKey] || 'OPENAI_API_KEY',
|
|
193
|
+
baseUrl,
|
|
194
|
+
}
|
|
195
|
+
const filtered = config.modelProviders.openai.filter((entry) => entry?.id !== model.modelId)
|
|
196
|
+
filtered.unshift(nextEntry)
|
|
197
|
+
config.modelProviders.openai = filtered
|
|
198
|
+
config.model = model.modelId
|
|
199
|
+
writeJson(filePath, config)
|
|
200
|
+
return { filePath, backupPath, envKey: nextEntry.envKey, apiKey }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function writePiConfig(model, apiKey, baseUrl) {
|
|
204
|
+
const filePath = join(homedir(), '.pi', 'agent', 'models.json')
|
|
205
|
+
const backupPath = backupIfExists(filePath)
|
|
206
|
+
const config = readJson(filePath, { providers: {} })
|
|
207
|
+
if (!config.providers || typeof config.providers !== 'object') config.providers = {}
|
|
208
|
+
config.providers.freeCodingModels = {
|
|
209
|
+
baseUrl,
|
|
210
|
+
api: 'openai-completions',
|
|
211
|
+
apiKey,
|
|
212
|
+
models: [{ id: model.modelId, name: model.label }],
|
|
213
|
+
}
|
|
214
|
+
writeJson(filePath, config)
|
|
215
|
+
return { filePath, backupPath }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function writeAmpConfig(baseUrl) {
|
|
219
|
+
const filePath = join(homedir(), '.config', 'amp', 'settings.json')
|
|
220
|
+
const backupPath = backupIfExists(filePath)
|
|
221
|
+
const config = readJson(filePath, {})
|
|
222
|
+
config['amp.url'] = baseUrl
|
|
223
|
+
writeJson(filePath, config)
|
|
224
|
+
return { filePath, backupPath }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function printConfigResult(toolName, result) {
|
|
228
|
+
if (!result?.filePath) return
|
|
229
|
+
console.log(chalk.dim(` 📄 ${toolName} config updated: ${result.filePath}`))
|
|
230
|
+
if (result.backupPath) console.log(chalk.dim(` 💾 Backup: ${result.backupPath}`))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function startExternalTool(mode, model, config) {
|
|
234
|
+
const meta = getToolMeta(mode)
|
|
235
|
+
const { env, apiKey, baseUrl } = buildToolEnv(mode, model, config)
|
|
236
|
+
|
|
237
|
+
if (!apiKey && mode !== 'amp') {
|
|
238
|
+
console.log(chalk.yellow(` ⚠ No API key configured for ${model.providerKey}.`))
|
|
239
|
+
console.log(chalk.dim(' Configure the provider first from the Settings screen (P) or via env vars.'))
|
|
240
|
+
console.log()
|
|
241
|
+
return 1
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
console.log(chalk.cyan(` ▶ Launching ${meta.label} with ${chalk.bold(model.label)}...`))
|
|
245
|
+
|
|
246
|
+
if (mode === 'aider') {
|
|
247
|
+
printConfigResult(meta.label, writeAiderConfig(model, apiKey, baseUrl))
|
|
248
|
+
return spawnCommand('aider', ['--model', `openai/${model.modelId}`], env)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (mode === 'crush') {
|
|
252
|
+
let crushApiKey = apiKey
|
|
253
|
+
let crushBaseUrl = baseUrl
|
|
254
|
+
let providerId = 'freeCodingModels'
|
|
255
|
+
const proxySettings = getProxySettings(config)
|
|
256
|
+
|
|
257
|
+
if (proxySettings.enabled) {
|
|
258
|
+
const started = await ensureProxyRunning(config)
|
|
259
|
+
crushApiKey = started.proxyToken
|
|
260
|
+
crushBaseUrl = `http://127.0.0.1:${started.port}/v1`
|
|
261
|
+
providerId = 'freeCodingModelsProxy'
|
|
262
|
+
console.log(chalk.dim(` 📖 Crush will use the local FCM proxy on :${started.port} for this launch.`))
|
|
263
|
+
} else {
|
|
264
|
+
console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
printConfigResult(meta.label, writeCrushConfig(model, crushApiKey, crushBaseUrl, providerId))
|
|
268
|
+
return spawnCommand('crush', [], env)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (mode === 'goose') {
|
|
272
|
+
env.OPENAI_HOST = baseUrl
|
|
273
|
+
env.OPENAI_BASE_PATH = 'v1/chat/completions'
|
|
274
|
+
env.OPENAI_MODEL = model.modelId
|
|
275
|
+
console.log(chalk.dim(' 📖 Goose uses env-based OpenAI-compatible configuration for this launch.'))
|
|
276
|
+
return spawnCommand('goose', [], env)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (mode === 'claude-code') {
|
|
280
|
+
console.log(chalk.yellow(' ⚠ Claude Code expects an Anthropic/Bedrock/Vertex-compatible gateway.'))
|
|
281
|
+
console.log(chalk.dim(' This launch passes proxy env vars, but your endpoint must support Claude Code wire semantics.'))
|
|
282
|
+
return spawnCommand('claude', ['--model', model.modelId], env)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (mode === 'codex') {
|
|
286
|
+
console.log(chalk.dim(' 📖 Codex CLI is launched with proxy env vars for this session.'))
|
|
287
|
+
return spawnCommand('codex', ['--model', model.modelId], env)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (mode === 'gemini') {
|
|
291
|
+
printConfigResult(meta.label, writeGeminiConfig(model))
|
|
292
|
+
return spawnCommand('gemini', ['--model', model.modelId], env)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (mode === 'qwen') {
|
|
296
|
+
printConfigResult(meta.label, writeQwenConfig(model, model.providerKey, apiKey, baseUrl))
|
|
297
|
+
return spawnCommand('qwen', [], env)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (mode === 'openhands') {
|
|
301
|
+
console.log(chalk.dim(' 📖 OpenHands is launched with --override-with-envs so the selected model applies immediately.'))
|
|
302
|
+
return spawnCommand('openhands', ['--override-with-envs'], env)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (mode === 'amp') {
|
|
306
|
+
printConfigResult(meta.label, writeAmpConfig(baseUrl))
|
|
307
|
+
console.log(chalk.yellow(' ⚠ Amp does not officially expose arbitrary model switching like the other CLIs.'))
|
|
308
|
+
console.log(chalk.dim(' The proxy URL is written, then Amp is launched so you can reuse the current endpoint.'))
|
|
309
|
+
return spawnCommand('amp', [], env)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (mode === 'pi') {
|
|
313
|
+
printConfigResult(meta.label, writePiConfig(model, apiKey, baseUrl))
|
|
314
|
+
return spawnCommand('pi', [], env)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))
|
|
318
|
+
return 1
|
|
319
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/tool-metadata.js
|
|
3
|
+
* @description Shared metadata for supported launch targets and mode ordering.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 The TUI now supports more than the historical OpenCode/OpenClaw trio.
|
|
7
|
+
* Centralizing mode metadata keeps the header badge, help screen, key handler,
|
|
8
|
+
* and CLI parsing aligned instead of hard-coding tool names in multiple files.
|
|
9
|
+
*
|
|
10
|
+
* 📖 The metadata here is intentionally small:
|
|
11
|
+
* - display label for the active tool badge
|
|
12
|
+
* - optional emoji for compact UI hints
|
|
13
|
+
* - flag name used in CLI help
|
|
14
|
+
*
|
|
15
|
+
* 📖 External tool integrations are still implemented elsewhere. This file only
|
|
16
|
+
* answers "what modes exist?" and "how should they be presented to the user?".
|
|
17
|
+
*
|
|
18
|
+
* @functions
|
|
19
|
+
* → `getToolMeta` — return display metadata for one mode
|
|
20
|
+
* → `getToolModeOrder` — stable mode cycle order for the `Z` hotkey
|
|
21
|
+
*
|
|
22
|
+
* @exports TOOL_METADATA, TOOL_MODE_ORDER, getToolMeta, getToolModeOrder
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const TOOL_METADATA = {
|
|
26
|
+
opencode: { label: 'OpenCode CLI', emoji: '💻', flag: '--opencode' },
|
|
27
|
+
'opencode-desktop': { label: 'OpenCode Desktop', emoji: '🖥', flag: '--opencode-desktop' },
|
|
28
|
+
openclaw: { label: 'OpenClaw', emoji: '🦞', flag: '--openclaw' },
|
|
29
|
+
crush: { label: 'Crush', emoji: '💘', flag: '--crush' },
|
|
30
|
+
goose: { label: 'Goose', emoji: '🪿', flag: '--goose' },
|
|
31
|
+
// aider: { label: 'Aider', emoji: '🛠', flag: '--aider' },
|
|
32
|
+
// 'claude-code': { label: 'Claude Code', emoji: '🧠', flag: '--claude-code' },
|
|
33
|
+
// codex: { label: 'Codex CLI', emoji: '⌘', flag: '--codex' },
|
|
34
|
+
// gemini: { label: 'Gemini CLI', emoji: '✦', flag: '--gemini' },
|
|
35
|
+
// qwen: { label: 'Qwen Code', emoji: '🌊', flag: '--qwen' },
|
|
36
|
+
// openhands: { label: 'OpenHands', emoji: '🤲', flag: '--openhands' },
|
|
37
|
+
// amp: { label: 'Amp', emoji: '⚡', flag: '--amp' },
|
|
38
|
+
// pi: { label: 'Pi', emoji: 'π', flag: '--pi' },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const TOOL_MODE_ORDER = [
|
|
42
|
+
'opencode',
|
|
43
|
+
'opencode-desktop',
|
|
44
|
+
'openclaw',
|
|
45
|
+
'crush',
|
|
46
|
+
'goose',
|
|
47
|
+
// 'aider',
|
|
48
|
+
// 'claude-code',
|
|
49
|
+
// 'codex',
|
|
50
|
+
// 'gemini',
|
|
51
|
+
// 'qwen',
|
|
52
|
+
// 'openhands',
|
|
53
|
+
// 'amp',
|
|
54
|
+
// 'pi',
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
export function getToolMeta(mode) {
|
|
58
|
+
return TOOL_METADATA[mode] || { label: mode, emoji: '•', flag: null }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getToolModeOrder() {
|
|
62
|
+
return [...TOOL_MODE_ORDER]
|
|
63
|
+
}
|