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/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
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file tier-colors.js
3
- * @description Chalk colour functions for each tier level, extracted from bin/free-coding-models.js.
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
- * "neon" and attention-grabbing the colour, while lower tiers fade toward dark red.
13
- * `chalk.rgb()` is used for fine-grained control terminal 256-colour and truecolour
14
- * modes both support this; on terminals that don't, chalk gracefully degrades.
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
- // 📖 Tier colors: green gradient (best) yellow orange red (worst).
26
- // 📖 Uses chalk.rgb() for fine-grained color control across 8 tier levels.
27
- // 📖 Each entry is a function (t) => styled string so it can be applied to any text.
28
- export const TIER_COLOR = {
29
- 'S+': t => chalk.bold.rgb(0, 255, 80)(t), // 🟢 bright neon green — elite
30
- 'S': t => chalk.bold.rgb(80, 220, 0)(t), // 🟢 green — excellent
31
- 'A+': t => chalk.bold.rgb(170, 210, 0)(t), // 🟡 yellow-green — great
32
- 'A': t => chalk.bold.rgb(240, 190, 0)(t), // 🟡 yellow — good
33
- 'A-': t => chalk.bold.rgb(255, 130, 0)(t), // 🟠 amber — decent
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
+ }