free-coding-models 0.3.16 → 0.3.18
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 +23 -0
- package/README.md +195 -120
- package/package.json +1 -1
- package/src/app.js +20 -2
- package/src/config.js +3 -0
- package/src/key-handler.js +184 -38
- package/src/openclaw.js +39 -5
- package/src/opencode.js +2 -1
- package/src/overlays.js +314 -223
- package/src/render-helpers.js +1 -1
- package/src/render-table.js +152 -180
- package/src/theme.js +315 -0
- package/src/tier-colors.js +15 -17
- package/src/tool-bootstrap.js +310 -0
- package/src/tool-launchers.js +12 -7
- package/src/ui-config.js +24 -31
package/src/theme.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file theme.js
|
|
3
|
+
* @description Semantic light/dark palette, auto theme detection, and shared TUI colour helpers.
|
|
4
|
+
*
|
|
5
|
+
* @functions
|
|
6
|
+
* → `detectActiveTheme` — resolve the live theme from user preference or terminal/OS signals
|
|
7
|
+
* → `getTheme` — return the currently active resolved theme (`dark` or `light`)
|
|
8
|
+
* → `cycleThemeSetting` — rotate persisted theme preference (`auto` → `dark` → `light`)
|
|
9
|
+
* → `getThemeStatusLabel` — format the settings/help label for the current theme mode
|
|
10
|
+
* → `getProviderRgb` — return the provider accent colour for the active theme
|
|
11
|
+
* → `getTierRgb` — return the tier accent colour for the active theme
|
|
12
|
+
*
|
|
13
|
+
* @exports { THEME_OPTIONS, detectActiveTheme, getTheme, cycleThemeSetting, getThemeStatusLabel, getReadableTextRgb, getProviderRgb, getTierRgb, themeColors }
|
|
14
|
+
*
|
|
15
|
+
* @see src/render-table.js
|
|
16
|
+
* @see src/overlays.js
|
|
17
|
+
* @see src/tier-colors.js
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import chalk from 'chalk'
|
|
21
|
+
import { execSync } from 'child_process'
|
|
22
|
+
|
|
23
|
+
export const THEME_OPTIONS = ['auto', 'dark', 'light']
|
|
24
|
+
|
|
25
|
+
let activeTheme = 'dark'
|
|
26
|
+
|
|
27
|
+
const PALETTES = {
|
|
28
|
+
dark: {
|
|
29
|
+
text: [234, 239, 248],
|
|
30
|
+
textStrong: [255, 255, 255],
|
|
31
|
+
muted: [149, 160, 182],
|
|
32
|
+
soft: [178, 190, 210],
|
|
33
|
+
accent: [110, 214, 255],
|
|
34
|
+
accentStrong: [72, 198, 255],
|
|
35
|
+
info: [129, 210, 255],
|
|
36
|
+
success: [112, 231, 181],
|
|
37
|
+
successStrong: [139, 239, 176],
|
|
38
|
+
warning: [255, 208, 102],
|
|
39
|
+
warningStrong: [255, 221, 140],
|
|
40
|
+
danger: [255, 129, 129],
|
|
41
|
+
dangerStrong: [255, 166, 166],
|
|
42
|
+
hotkey: [255, 214, 102],
|
|
43
|
+
link: [149, 205, 255],
|
|
44
|
+
border: [111, 125, 149],
|
|
45
|
+
footerLove: [255, 168, 209],
|
|
46
|
+
footerCoffee: [255, 209, 134],
|
|
47
|
+
footerDiscord: [207, 179, 255],
|
|
48
|
+
overlayFg: [234, 239, 248],
|
|
49
|
+
overlayBg: {
|
|
50
|
+
settings: [7, 13, 24],
|
|
51
|
+
help: [9, 18, 31],
|
|
52
|
+
recommend: [8, 21, 20],
|
|
53
|
+
feedback: [31, 13, 20],
|
|
54
|
+
changelog: [12, 24, 44],
|
|
55
|
+
},
|
|
56
|
+
cursor: {
|
|
57
|
+
defaultBg: [39, 55, 90],
|
|
58
|
+
defaultFg: [255, 255, 255],
|
|
59
|
+
installBg: [22, 60, 69],
|
|
60
|
+
installFg: [255, 255, 255],
|
|
61
|
+
settingsBg: [26, 54, 34],
|
|
62
|
+
settingsFg: [255, 255, 255],
|
|
63
|
+
legacyBg: [67, 31, 69],
|
|
64
|
+
legacyFg: [255, 255, 255],
|
|
65
|
+
modelBg: [62, 73, 115],
|
|
66
|
+
modelFg: [255, 255, 255],
|
|
67
|
+
recommendedBg: [20, 51, 33],
|
|
68
|
+
recommendedFg: [234, 239, 248],
|
|
69
|
+
favoriteBg: [76, 55, 17],
|
|
70
|
+
favoriteFg: [255, 244, 220],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
light: {
|
|
74
|
+
text: [28, 36, 51],
|
|
75
|
+
textStrong: [8, 12, 20],
|
|
76
|
+
muted: [95, 109, 129],
|
|
77
|
+
soft: [76, 89, 109],
|
|
78
|
+
accent: [0, 120, 186],
|
|
79
|
+
accentStrong: [0, 99, 163],
|
|
80
|
+
info: [0, 109, 168],
|
|
81
|
+
success: [0, 118, 68],
|
|
82
|
+
successStrong: [0, 96, 56],
|
|
83
|
+
warning: [146, 90, 0],
|
|
84
|
+
warningStrong: [171, 102, 0],
|
|
85
|
+
danger: [177, 53, 53],
|
|
86
|
+
dangerStrong: [147, 35, 48],
|
|
87
|
+
hotkey: [171, 98, 0],
|
|
88
|
+
link: [0, 94, 170],
|
|
89
|
+
border: [151, 166, 188],
|
|
90
|
+
footerLove: [176, 79, 128],
|
|
91
|
+
footerCoffee: [170, 102, 0],
|
|
92
|
+
footerDiscord: [104, 83, 190],
|
|
93
|
+
overlayFg: [28, 36, 51],
|
|
94
|
+
overlayBg: {
|
|
95
|
+
settings: [248, 250, 255],
|
|
96
|
+
help: [246, 250, 255],
|
|
97
|
+
recommend: [246, 252, 248],
|
|
98
|
+
feedback: [255, 247, 248],
|
|
99
|
+
changelog: [244, 248, 255],
|
|
100
|
+
},
|
|
101
|
+
cursor: {
|
|
102
|
+
defaultBg: [217, 231, 255],
|
|
103
|
+
defaultFg: [9, 18, 35],
|
|
104
|
+
installBg: [218, 242, 236],
|
|
105
|
+
installFg: [12, 33, 26],
|
|
106
|
+
settingsBg: [225, 244, 229],
|
|
107
|
+
settingsFg: [14, 43, 27],
|
|
108
|
+
legacyBg: [248, 228, 244],
|
|
109
|
+
legacyFg: [76, 28, 73],
|
|
110
|
+
modelBg: [209, 223, 255],
|
|
111
|
+
modelFg: [9, 18, 35],
|
|
112
|
+
recommendedBg: [221, 245, 229],
|
|
113
|
+
recommendedFg: [17, 47, 28],
|
|
114
|
+
favoriteBg: [255, 241, 208],
|
|
115
|
+
favoriteFg: [79, 53, 0],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const PROVIDER_PALETTES = {
|
|
121
|
+
dark: {
|
|
122
|
+
nvidia: [132, 235, 168],
|
|
123
|
+
groq: [255, 191, 144],
|
|
124
|
+
cerebras: [153, 215, 255],
|
|
125
|
+
sambanova: [255, 215, 142],
|
|
126
|
+
openrouter: [228, 191, 239],
|
|
127
|
+
huggingface: [255, 235, 122],
|
|
128
|
+
replicate: [166, 212, 255],
|
|
129
|
+
deepinfra: [146, 222, 213],
|
|
130
|
+
fireworks: [255, 184, 194],
|
|
131
|
+
codestral: [245, 175, 212],
|
|
132
|
+
hyperbolic: [255, 160, 127],
|
|
133
|
+
scaleway: [115, 209, 255],
|
|
134
|
+
googleai: [166, 210, 255],
|
|
135
|
+
siliconflow: [145, 232, 243],
|
|
136
|
+
together: [255, 232, 98],
|
|
137
|
+
cloudflare: [255, 191, 118],
|
|
138
|
+
perplexity: [243, 157, 195],
|
|
139
|
+
qwen: [255, 213, 128],
|
|
140
|
+
zai: [150, 208, 255],
|
|
141
|
+
iflow: [211, 229, 101],
|
|
142
|
+
},
|
|
143
|
+
light: {
|
|
144
|
+
nvidia: [0, 126, 73],
|
|
145
|
+
groq: [171, 86, 22],
|
|
146
|
+
cerebras: [0, 102, 177],
|
|
147
|
+
sambanova: [165, 94, 0],
|
|
148
|
+
openrouter: [122, 65, 156],
|
|
149
|
+
huggingface: [135, 104, 0],
|
|
150
|
+
replicate: [0, 94, 163],
|
|
151
|
+
deepinfra: [0, 122, 117],
|
|
152
|
+
fireworks: [183, 55, 72],
|
|
153
|
+
codestral: [157, 61, 110],
|
|
154
|
+
hyperbolic: [178, 68, 27],
|
|
155
|
+
scaleway: [0, 113, 189],
|
|
156
|
+
googleai: [0, 111, 168],
|
|
157
|
+
siliconflow: [0, 115, 138],
|
|
158
|
+
together: [122, 101, 0],
|
|
159
|
+
cloudflare: [176, 92, 0],
|
|
160
|
+
perplexity: [171, 62, 121],
|
|
161
|
+
qwen: [132, 89, 0],
|
|
162
|
+
zai: [0, 104, 171],
|
|
163
|
+
iflow: [107, 130, 0],
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const TIER_PALETTES = {
|
|
168
|
+
dark: {
|
|
169
|
+
'S+': [111, 255, 164],
|
|
170
|
+
'S': [147, 241, 101],
|
|
171
|
+
'A+': [201, 233, 104],
|
|
172
|
+
'A': [255, 211, 101],
|
|
173
|
+
'A-': [255, 178, 100],
|
|
174
|
+
'B+': [255, 145, 112],
|
|
175
|
+
'B': [255, 113, 113],
|
|
176
|
+
'C': [255, 139, 164],
|
|
177
|
+
},
|
|
178
|
+
light: {
|
|
179
|
+
'S+': [0, 122, 58],
|
|
180
|
+
'S': [54, 122, 0],
|
|
181
|
+
'A+': [95, 113, 0],
|
|
182
|
+
'A': [128, 92, 0],
|
|
183
|
+
'A-': [156, 80, 0],
|
|
184
|
+
'B+': [171, 69, 0],
|
|
185
|
+
'B': [168, 44, 44],
|
|
186
|
+
'C': [123, 35, 75],
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function currentPalette() {
|
|
191
|
+
return PALETTES[activeTheme] ?? PALETTES.dark
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function themeLabel(theme) {
|
|
195
|
+
return theme.charAt(0).toUpperCase() + theme.slice(1)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildStyle({ fgRgb = null, bgRgb = null, bold = false, italic = false } = {}) {
|
|
199
|
+
let style = chalk
|
|
200
|
+
if (bgRgb) style = style.bgRgb(...bgRgb)
|
|
201
|
+
if (fgRgb) style = style.rgb(...fgRgb)
|
|
202
|
+
if (bold) style = style.bold
|
|
203
|
+
if (italic) style = style.italic
|
|
204
|
+
return style
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function getReadableTextRgb(bgRgb) {
|
|
208
|
+
const [r, g, b] = bgRgb
|
|
209
|
+
const yiq = (r * 299 + g * 587 + b * 114) / 1000
|
|
210
|
+
return yiq >= 150 ? [10, 16, 28] : [248, 251, 255]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function detectActiveTheme(configTheme = 'auto') {
|
|
214
|
+
if (configTheme === 'dark' || configTheme === 'light') {
|
|
215
|
+
activeTheme = configTheme
|
|
216
|
+
return activeTheme
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const fgbg = process.env.COLORFGBG || ''
|
|
220
|
+
if (fgbg.includes(';15') || fgbg.includes(';7') || fgbg.includes(';base03')) {
|
|
221
|
+
activeTheme = 'light'
|
|
222
|
+
return activeTheme
|
|
223
|
+
}
|
|
224
|
+
if (fgbg) {
|
|
225
|
+
activeTheme = 'dark'
|
|
226
|
+
return activeTheme
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (process.platform === 'darwin') {
|
|
230
|
+
try {
|
|
231
|
+
const style = execSync('defaults read -g AppleInterfaceStyle 2>/dev/null', { timeout: 100 }).toString().trim()
|
|
232
|
+
activeTheme = style === 'Dark' ? 'dark' : 'light'
|
|
233
|
+
} catch {
|
|
234
|
+
activeTheme = 'light'
|
|
235
|
+
}
|
|
236
|
+
return activeTheme
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
activeTheme = 'dark'
|
|
240
|
+
return activeTheme
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function getTheme() {
|
|
244
|
+
return activeTheme
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function cycleThemeSetting(currentTheme = 'auto') {
|
|
248
|
+
const currentIdx = THEME_OPTIONS.indexOf(currentTheme)
|
|
249
|
+
const nextIdx = currentIdx === -1 ? 0 : (currentIdx + 1) % THEME_OPTIONS.length
|
|
250
|
+
return THEME_OPTIONS[nextIdx]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function getThemeStatusLabel(setting = 'auto') {
|
|
254
|
+
if (setting === 'auto') return `Auto → ${themeLabel(activeTheme)}`
|
|
255
|
+
return themeLabel(setting)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function getProviderRgb(providerKey) {
|
|
259
|
+
const palette = PROVIDER_PALETTES[activeTheme] ?? PROVIDER_PALETTES.dark
|
|
260
|
+
return palette[providerKey] ?? currentPalette().accent
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function getTierRgb(tier) {
|
|
264
|
+
const palette = TIER_PALETTES[activeTheme] ?? TIER_PALETTES.dark
|
|
265
|
+
return palette[tier] ?? currentPalette().textStrong
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function paintRgb(rgb, text, options = {}) {
|
|
269
|
+
return buildStyle({ fgRgb: rgb, ...options })(text)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function paintBg(bgRgb, text, fgRgb = null, options = {}) {
|
|
273
|
+
return buildStyle({ bgRgb, fgRgb: fgRgb ?? getReadableTextRgb(bgRgb), ...options })(text)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export const themeColors = {
|
|
277
|
+
text: (text) => paintRgb(currentPalette().text, text),
|
|
278
|
+
textBold: (text) => paintRgb(currentPalette().textStrong, text, { bold: true }),
|
|
279
|
+
dim: (text) => paintRgb(currentPalette().muted, text),
|
|
280
|
+
soft: (text) => paintRgb(currentPalette().soft, text),
|
|
281
|
+
accent: (text) => paintRgb(currentPalette().accent, text),
|
|
282
|
+
accentBold: (text) => paintRgb(currentPalette().accentStrong, text, { bold: true }),
|
|
283
|
+
info: (text) => paintRgb(currentPalette().info, text),
|
|
284
|
+
success: (text) => paintRgb(currentPalette().success, text),
|
|
285
|
+
successBold: (text) => paintRgb(currentPalette().successStrong, text, { bold: true }),
|
|
286
|
+
warning: (text) => paintRgb(currentPalette().warning, text),
|
|
287
|
+
warningBold: (text) => paintRgb(currentPalette().warningStrong, text, { bold: true }),
|
|
288
|
+
error: (text) => paintRgb(currentPalette().danger, text),
|
|
289
|
+
errorBold: (text) => paintRgb(currentPalette().dangerStrong, text, { bold: true }),
|
|
290
|
+
hotkey: (text) => paintRgb(currentPalette().hotkey, text, { bold: true }),
|
|
291
|
+
link: (text) => paintRgb(currentPalette().link, text),
|
|
292
|
+
border: (text) => paintRgb(currentPalette().border, text),
|
|
293
|
+
footerLove: (text) => paintRgb(currentPalette().footerLove, text),
|
|
294
|
+
footerCoffee: (text) => paintRgb(currentPalette().footerCoffee, text),
|
|
295
|
+
footerDiscord: (text) => paintRgb(currentPalette().footerDiscord, text),
|
|
296
|
+
metricGood: (text) => paintRgb(currentPalette().successStrong, text),
|
|
297
|
+
metricOk: (text) => paintRgb(currentPalette().info, text),
|
|
298
|
+
metricWarn: (text) => paintRgb(currentPalette().warning, text),
|
|
299
|
+
metricBad: (text) => paintRgb(currentPalette().danger, text),
|
|
300
|
+
provider: (providerKey, text, { bold = false } = {}) => paintRgb(getProviderRgb(providerKey), text, { bold }),
|
|
301
|
+
tier: (tier, text, { bold = true } = {}) => paintRgb(getTierRgb(tier), text, { bold }),
|
|
302
|
+
badge: (text, bgRgb, fgRgb = null) => paintBg(bgRgb, ` ${text} `, fgRgb, { bold: true }),
|
|
303
|
+
bgCursor: (text) => paintBg(currentPalette().cursor.defaultBg, text, currentPalette().cursor.defaultFg),
|
|
304
|
+
bgCursorInstall: (text) => paintBg(currentPalette().cursor.installBg, text, currentPalette().cursor.installFg),
|
|
305
|
+
bgCursorSettingsList: (text) => paintBg(currentPalette().cursor.settingsBg, text, currentPalette().cursor.settingsFg),
|
|
306
|
+
bgCursorLegacy: (text) => paintBg(currentPalette().cursor.legacyBg, text, currentPalette().cursor.legacyFg),
|
|
307
|
+
bgModelCursor: (text) => paintBg(currentPalette().cursor.modelBg, text, currentPalette().cursor.modelFg),
|
|
308
|
+
bgModelRecommended: (text) => paintBg(currentPalette().cursor.recommendedBg, text, currentPalette().cursor.recommendedFg),
|
|
309
|
+
bgModelFavorite: (text) => paintBg(currentPalette().cursor.favoriteBg, text, currentPalette().cursor.favoriteFg),
|
|
310
|
+
overlayBgSettings: (text) => paintBg(currentPalette().overlayBg.settings, text, currentPalette().overlayFg),
|
|
311
|
+
overlayBgHelp: (text) => paintBg(currentPalette().overlayBg.help, text, currentPalette().overlayFg),
|
|
312
|
+
overlayBgRecommend: (text) => paintBg(currentPalette().overlayBg.recommend, text, currentPalette().overlayFg),
|
|
313
|
+
overlayBgFeedback: (text) => paintBg(currentPalette().overlayBg.feedback, text, currentPalette().overlayFg),
|
|
314
|
+
overlayBgChangelog: (text) => paintBg(currentPalette().overlayBg.changelog, text, currentPalette().overlayFg),
|
|
315
|
+
}
|
package/src/tier-colors.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file tier-colors.js
|
|
3
|
-
* @description Chalk colour functions for each tier level
|
|
3
|
+
* @description Theme-aware Chalk colour functions for each tier level.
|
|
4
4
|
*
|
|
5
5
|
* @details
|
|
6
6
|
* The tier system maps model quality tiers (S+, S, A+, A, A-, B+, B, C) to a
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* single, consistent visual language without depending on the whole TUI entry point.
|
|
10
10
|
*
|
|
11
11
|
* The gradient is deliberately designed so that the higher the tier the more
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The previous palette used very dark reds and bright yellows directly, which
|
|
13
|
+
* became muddy on dark terminals and nearly invisible on light ones. This
|
|
14
|
+
* module now delegates to the semantic theme palette so tier colours stay
|
|
15
|
+
* readable in both modes while keeping the same best→worst ordering.
|
|
15
16
|
*
|
|
16
17
|
* @exports
|
|
17
18
|
* TIER_COLOR — object mapping tier string → chalk colouring function
|
|
@@ -21,17 +22,14 @@
|
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
import chalk from 'chalk'
|
|
25
|
+
import { getTierRgb } from './theme.js'
|
|
24
26
|
|
|
25
|
-
// 📖
|
|
26
|
-
// 📖
|
|
27
|
-
// 📖
|
|
28
|
-
export const TIER_COLOR = {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'B+': t => chalk.bold.rgb(255, 70, 0)(t), // 🟠 orange-red — average
|
|
35
|
-
'B': t => chalk.bold.rgb(210, 20, 0)(t), // 🔴 red — below avg
|
|
36
|
-
'C': t => chalk.bold.rgb(140, 0, 0)(t), // 🔴 dark red — lightweight
|
|
37
|
-
}
|
|
27
|
+
// 📖 TIER_COLOR remains object-like for existing call sites, but every access is
|
|
28
|
+
// 📖 resolved lazily from the live theme so `G`/Settings theme switches repaint
|
|
29
|
+
// 📖 the whole TUI without rebuilding import-time constants.
|
|
30
|
+
export const TIER_COLOR = new Proxy({}, {
|
|
31
|
+
get(_target, tier) {
|
|
32
|
+
if (typeof tier !== 'string') return undefined
|
|
33
|
+
return (text) => chalk.bold.rgb(...getTierRgb(tier))(text)
|
|
34
|
+
},
|
|
35
|
+
})
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/tool-bootstrap.js
|
|
3
|
+
* @description Shared detection and auto-install helpers for external coding tools launched by FCM.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 This module answers three operational questions for every supported launcher:
|
|
7
|
+
* - which executable should exist locally before FCM can launch the tool
|
|
8
|
+
* - how to detect that executable without spawning the full TUI/CLI itself
|
|
9
|
+
* - which official install command FCM can offer when the tool is missing
|
|
10
|
+
*
|
|
11
|
+
* 📖 The goal is deliberately narrow. We only solve the "binary missing" bootstrap
|
|
12
|
+
* path so the main TUI can keep the user's selected model, offer a tiny Yes/No
|
|
13
|
+
* confirmation overlay, then continue with the existing config-write + launch flow.
|
|
14
|
+
*
|
|
15
|
+
* 📖 FCM prefers npm when a tool officially supports it because the binary usually
|
|
16
|
+
* lands in a predictable global location and works immediately in the same session.
|
|
17
|
+
* For tools that do not have a maintained npm install path, we use the official
|
|
18
|
+
* installer script documented by the tool itself.
|
|
19
|
+
*
|
|
20
|
+
* @functions
|
|
21
|
+
* → `getToolBootstrapMeta` — static metadata for one tool mode
|
|
22
|
+
* → `resolveToolBinaryPath` — find a launcher executable in PATH or common user bin dirs
|
|
23
|
+
* → `isToolInstalled` — quick boolean wrapper around binary resolution
|
|
24
|
+
* → `getToolInstallPlan` — pick the platform-specific install command for a tool
|
|
25
|
+
* → `installToolWithPlan` — execute the chosen install command with inherited stdio
|
|
26
|
+
*
|
|
27
|
+
* @exports
|
|
28
|
+
* TOOL_BOOTSTRAP_METADATA, getToolBootstrapMeta, resolveToolBinaryPath,
|
|
29
|
+
* isToolInstalled, getToolInstallPlan, installToolWithPlan
|
|
30
|
+
*
|
|
31
|
+
* @see src/key-handler.js
|
|
32
|
+
* @see src/tool-launchers.js
|
|
33
|
+
* @see src/opencode.js
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { spawn } from 'child_process'
|
|
37
|
+
import { existsSync, statSync } from 'fs'
|
|
38
|
+
import { homedir } from 'os'
|
|
39
|
+
import { delimiter, extname, join } from 'path'
|
|
40
|
+
import { isWindows } from './provider-metadata.js'
|
|
41
|
+
|
|
42
|
+
const HOME = homedir()
|
|
43
|
+
|
|
44
|
+
// 📖 Common user-level binary directories that installer scripts frequently use.
|
|
45
|
+
// 📖 We search them in addition to PATH so FCM can keep going right after install
|
|
46
|
+
// 📖 even if the user's shell profile has not been reloaded yet.
|
|
47
|
+
const COMMON_USER_BIN_DIRS = isWindows
|
|
48
|
+
? [
|
|
49
|
+
join(HOME, '.local', 'bin'),
|
|
50
|
+
join(HOME, 'AppData', 'Roaming', 'npm'),
|
|
51
|
+
join(HOME, 'scoop', 'shims'),
|
|
52
|
+
]
|
|
53
|
+
: [
|
|
54
|
+
join(HOME, '.local', 'bin'),
|
|
55
|
+
join(HOME, '.bun', 'bin'),
|
|
56
|
+
join(HOME, '.npm-global', 'bin'),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
function uniqueStrings(values = []) {
|
|
60
|
+
return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pathEntries(env = process.env) {
|
|
64
|
+
return uniqueStrings([
|
|
65
|
+
...(String(env.PATH || '').split(delimiter)),
|
|
66
|
+
...(env.npm_config_prefix ? [isWindows ? env.npm_config_prefix : join(env.npm_config_prefix, 'bin')] : []),
|
|
67
|
+
...COMMON_USER_BIN_DIRS,
|
|
68
|
+
])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function executableSuffixes(env = process.env) {
|
|
72
|
+
if (!isWindows) return ['']
|
|
73
|
+
const raw = String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
|
74
|
+
return uniqueStrings(raw.split(';').flatMap((ext) => {
|
|
75
|
+
const normalized = ext.trim()
|
|
76
|
+
if (!normalized) return []
|
|
77
|
+
return [normalized.toLowerCase(), normalized.toUpperCase()]
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isRunnableFile(filePath) {
|
|
82
|
+
try {
|
|
83
|
+
return existsSync(filePath) && statSync(filePath).isFile()
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveBinaryPath(binaryName, env = process.env) {
|
|
90
|
+
if (!binaryName || typeof binaryName !== 'string') return null
|
|
91
|
+
|
|
92
|
+
const entries = pathEntries(env)
|
|
93
|
+
if (entries.length === 0) return null
|
|
94
|
+
|
|
95
|
+
const hasExtension = extname(binaryName).length > 0
|
|
96
|
+
const suffixes = isWindows
|
|
97
|
+
? (hasExtension ? [''] : executableSuffixes(env))
|
|
98
|
+
: ['']
|
|
99
|
+
|
|
100
|
+
for (const dir of entries) {
|
|
101
|
+
for (const suffix of suffixes) {
|
|
102
|
+
const candidate = join(dir, `${binaryName}${suffix}`)
|
|
103
|
+
if (isRunnableFile(candidate)) return candidate
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const TOOL_BOOTSTRAP_METADATA = {
|
|
111
|
+
opencode: {
|
|
112
|
+
binary: 'opencode',
|
|
113
|
+
docsUrl: 'https://opencode.ai/download',
|
|
114
|
+
install: {
|
|
115
|
+
default: {
|
|
116
|
+
shellCommand: 'npm install -g opencode-ai',
|
|
117
|
+
summary: 'Install OpenCode CLI globally via npm.',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
'opencode-desktop': {
|
|
122
|
+
binary: null,
|
|
123
|
+
docsUrl: 'https://opencode.ai/download',
|
|
124
|
+
installUnsupported: {
|
|
125
|
+
default: 'OpenCode Desktop uses platform-specific app installers, so FCM does not auto-install it yet.',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
openclaw: {
|
|
129
|
+
binary: 'openclaw',
|
|
130
|
+
docsUrl: 'https://docs.openclaw.ai/install',
|
|
131
|
+
install: {
|
|
132
|
+
default: {
|
|
133
|
+
shellCommand: 'npm install -g openclaw@latest',
|
|
134
|
+
summary: 'Install OpenClaw globally via npm.',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
crush: {
|
|
139
|
+
binary: 'crush',
|
|
140
|
+
docsUrl: 'https://github.com/charmbracelet/crush',
|
|
141
|
+
install: {
|
|
142
|
+
default: {
|
|
143
|
+
shellCommand: 'npm install -g @charmland/crush',
|
|
144
|
+
summary: 'Install Crush globally via npm.',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
goose: {
|
|
149
|
+
binary: 'goose',
|
|
150
|
+
docsUrl: 'https://block.github.io/goose/docs/getting-started/installation/',
|
|
151
|
+
install: {
|
|
152
|
+
default: {
|
|
153
|
+
shellCommand: 'curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash',
|
|
154
|
+
summary: 'Install goose CLI with the official installer script.',
|
|
155
|
+
},
|
|
156
|
+
win32: {
|
|
157
|
+
shellCommand: 'powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri https://raw.githubusercontent.com/block/goose/main/download_cli.ps1 -OutFile $env:TEMP\\download_cli.ps1; & $env:TEMP\\download_cli.ps1"',
|
|
158
|
+
summary: 'Install goose CLI with the official PowerShell installer.',
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
aider: {
|
|
163
|
+
binary: 'aider',
|
|
164
|
+
docsUrl: 'https://aider.chat/docs/install.html',
|
|
165
|
+
install: {
|
|
166
|
+
default: {
|
|
167
|
+
shellCommand: 'curl -LsSf https://aider.chat/install.sh | sh',
|
|
168
|
+
summary: 'Install aider with the official installer.',
|
|
169
|
+
},
|
|
170
|
+
win32: {
|
|
171
|
+
shellCommand: 'powershell -ExecutionPolicy ByPass -c "irm https://aider.chat/install.ps1 | iex"',
|
|
172
|
+
summary: 'Install aider with the official PowerShell installer.',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
qwen: {
|
|
177
|
+
binary: 'qwen',
|
|
178
|
+
docsUrl: 'https://qwenlm.github.io/qwen-code-docs/en/users/quickstart/',
|
|
179
|
+
install: {
|
|
180
|
+
default: {
|
|
181
|
+
shellCommand: 'npm install -g @qwen-code/qwen-code@latest',
|
|
182
|
+
summary: 'Install Qwen Code globally via npm.',
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
openhands: {
|
|
187
|
+
binary: 'openhands',
|
|
188
|
+
docsUrl: 'https://docs.openhands.dev/openhands/usage/cli/installation',
|
|
189
|
+
install: {
|
|
190
|
+
default: {
|
|
191
|
+
shellCommand: 'curl -fsSL https://install.openhands.dev/install.sh | sh',
|
|
192
|
+
summary: 'Install OpenHands CLI with the official installer.',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
installUnsupported: {
|
|
196
|
+
win32: 'OpenHands CLI currently recommends installation inside WSL on Windows.',
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
amp: {
|
|
200
|
+
binary: 'amp',
|
|
201
|
+
docsUrl: 'https://ampcode.com/manual',
|
|
202
|
+
install: {
|
|
203
|
+
default: {
|
|
204
|
+
shellCommand: 'npm install -g @sourcegraph/amp',
|
|
205
|
+
summary: 'Install Amp globally via npm.',
|
|
206
|
+
note: 'Amp documents npm as a fallback install path. Its plugin API works best with the binary installer from ampcode.com/install.',
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
pi: {
|
|
211
|
+
binary: 'pi',
|
|
212
|
+
docsUrl: 'https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md',
|
|
213
|
+
install: {
|
|
214
|
+
default: {
|
|
215
|
+
shellCommand: 'npm install -g @mariozechner/pi-coding-agent',
|
|
216
|
+
summary: 'Install Pi Coding Agent globally via npm.',
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function getToolBootstrapMeta(mode) {
|
|
223
|
+
return TOOL_BOOTSTRAP_METADATA[mode] || null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function resolveToolBinaryPath(mode, options = {}) {
|
|
227
|
+
const meta = getToolBootstrapMeta(mode)
|
|
228
|
+
if (!meta?.binary) return null
|
|
229
|
+
return resolveBinaryPath(meta.binary, options.env || process.env)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function isToolInstalled(mode, options = {}) {
|
|
233
|
+
return Boolean(resolveToolBinaryPath(mode, options))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function getToolInstallPlan(mode, options = {}) {
|
|
237
|
+
const meta = getToolBootstrapMeta(mode)
|
|
238
|
+
const platform = options.platform || process.platform
|
|
239
|
+
|
|
240
|
+
if (!meta) {
|
|
241
|
+
return {
|
|
242
|
+
supported: false,
|
|
243
|
+
mode,
|
|
244
|
+
binary: null,
|
|
245
|
+
docsUrl: null,
|
|
246
|
+
reason: `Unknown tool mode: ${mode}`,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const platformUnsupportedReason = meta.installUnsupported?.[platform]
|
|
251
|
+
if (platformUnsupportedReason) {
|
|
252
|
+
return {
|
|
253
|
+
supported: false,
|
|
254
|
+
mode,
|
|
255
|
+
binary: meta.binary || null,
|
|
256
|
+
docsUrl: meta.docsUrl || null,
|
|
257
|
+
reason: platformUnsupportedReason,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const installPlan = meta.install?.[platform] || meta.install?.default || null
|
|
262
|
+
if (!installPlan) {
|
|
263
|
+
return {
|
|
264
|
+
supported: false,
|
|
265
|
+
mode,
|
|
266
|
+
binary: meta.binary || null,
|
|
267
|
+
docsUrl: meta.docsUrl || null,
|
|
268
|
+
reason: meta.installUnsupported?.default || 'No auto-install plan is available for this tool on the current platform.',
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
supported: true,
|
|
274
|
+
mode,
|
|
275
|
+
binary: meta.binary || null,
|
|
276
|
+
docsUrl: meta.docsUrl || null,
|
|
277
|
+
shellCommand: installPlan.shellCommand,
|
|
278
|
+
summary: installPlan.summary,
|
|
279
|
+
note: installPlan.note || null,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function installToolWithPlan(plan, options = {}) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
if (!plan?.supported || !plan.shellCommand) {
|
|
286
|
+
resolve({
|
|
287
|
+
ok: false,
|
|
288
|
+
exitCode: 1,
|
|
289
|
+
command: plan?.shellCommand || null,
|
|
290
|
+
})
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const child = spawn(plan.shellCommand, [], {
|
|
295
|
+
stdio: 'inherit',
|
|
296
|
+
shell: true,
|
|
297
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
298
|
+
cwd: options.cwd || process.cwd(),
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
child.on('exit', (code) => {
|
|
302
|
+
resolve({
|
|
303
|
+
ok: code === 0,
|
|
304
|
+
exitCode: typeof code === 'number' ? code : 1,
|
|
305
|
+
command: plan.shellCommand,
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
child.on('error', reject)
|
|
309
|
+
})
|
|
310
|
+
}
|