free-coding-models 0.3.17 → 0.3.19

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.
@@ -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
+ }
@@ -43,6 +43,7 @@ import { getApiKey } from './config.js'
43
43
  import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
44
44
  import { getToolMeta } from './tool-metadata.js'
45
45
  import { PROVIDER_METADATA } from './provider-metadata.js'
46
+ import { resolveToolBinaryPath } from './tool-bootstrap.js'
46
47
 
47
48
  const OPENAI_COMPAT_ENV_KEYS = [
48
49
  'OPENAI_API_KEY',
@@ -127,6 +128,10 @@ function applyOpenAiCompatEnv(env, apiKey, baseUrl, modelId) {
127
128
  return env
128
129
  }
129
130
 
131
+ function resolveLaunchCommand(mode, fallbackCommand) {
132
+ return resolveToolBinaryPath(mode) || fallbackCommand
133
+ }
134
+
130
135
  /**
131
136
  * 📖 resolveLauncherModelId returns the provider-native id used by the direct
132
137
  * 📖 launchers. Legacy bridge-specific model remapping has been removed.
@@ -562,35 +567,35 @@ export async function startExternalTool(mode, model, config) {
562
567
  printConfigArtifacts(meta.label, launchPlan.configArtifacts)
563
568
 
564
569
  if (mode === 'aider') {
565
- return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
570
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
566
571
  }
567
572
 
568
573
  if (mode === 'crush') {
569
574
  console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
570
- return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
575
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
571
576
  }
572
577
 
573
578
  if (mode === 'goose') {
574
- return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
579
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
575
580
  }
576
581
 
577
582
  if (mode === 'qwen') {
578
- return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
583
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
579
584
  }
580
585
 
581
586
  if (mode === 'openhands') {
582
587
  console.log(chalk.dim(` 📖 OpenHands launched with model: ${model.modelId}`))
583
- return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
588
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
584
589
  }
585
590
 
586
591
  if (mode === 'amp') {
587
592
  console.log(chalk.dim(` 📖 Amp config updated with model: ${model.modelId}`))
588
- return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
593
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
589
594
  }
590
595
 
591
596
  if (mode === 'pi') {
592
597
  // 📖 Pi supports --provider and --model flags for guaranteed auto-selection
593
- return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
598
+ return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
594
599
  }
595
600
 
596
601
  console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))
package/src/ui-config.js CHANGED
@@ -1,49 +1,42 @@
1
1
  /**
2
2
  * @file ui-config.js
3
- * @description Central configuration for TUI visual styling.
3
+ * @description Central configuration helpers for TUI separators and spacing.
4
4
  *
5
5
  * @details
6
- * This module centralizes all visual styling constants for the TUI interface.
7
- * By keeping colors, separators, and spacing in one place, it becomes easy to
8
- * customize the look and feel without modifying rendering logic.
6
+ * This module centralizes the shared table separators used by the TUI. The
7
+ * theme can change at runtime, so separators must be generated lazily instead
8
+ * of frozen once at import time.
9
9
  *
10
10
  * 📖 Configuration:
11
- * - BORDER_COLOR: Color of column separators (vertical bars)
12
- * - BORDER_STYLE: Style of separators (dim, bold, etc.)
13
- * - HORIZONTAL_SEPARATOR: Character used for horizontal lines
14
- * - HORIZONTAL_STYLE: Style of horizontal lines
15
- * - COLUMN_SPACING: Space between columns
11
+ * - `getVerticalSeparator()` theme-aware vertical divider
12
+ * - `getHorizontalLine()` theme-aware horizontal divider
13
+ * - `getColumnSpacing()` formatted spacing wrapper around the divider
16
14
  *
17
15
  * @see render-table.js - uses these constants for rendering
18
16
  * @see tier-colors.js - for tier-specific color definitions
19
17
  */
20
18
 
21
- import chalk from 'chalk';
19
+ import { themeColors } from './theme.js'
22
20
 
23
- // 📖 Column separator (vertical bar) configuration
24
- export const BORDER_COLOR = chalk.rgb(255, 140, 0); // Gentle dark orange
25
- export const BORDER_STYLE = 'dim'; // Options: 'dim', 'bold', 'underline', 'inverse', etc.
26
- export const VERTICAL_SEPARATOR = BORDER_COLOR[BORDER_STYLE]('│');
21
+ // 📖 Column separator stays subtle so it improves scanability without turning
22
+ // 📖 the table into a bright fence.
23
+ export function getVerticalSeparator() {
24
+ return themeColors.border('│')
25
+ }
27
26
 
28
- // 📖 Horizontal separator configuration
29
- export const HORIZONTAL_SEPARATOR = '─'; // Unicode horizontal line
30
- export const HORIZONTAL_STYLE = 'dim'; // Options: 'dim', 'bold', etc.
31
- export const HORIZONTAL_LINE = chalk[HORIZONTAL_STYLE](HORIZONTAL_SEPARATOR);
27
+ export function getHorizontalLine() {
28
+ return themeColors.dim('─')
29
+ }
32
30
 
33
- // 📖 Column spacing configuration
34
- export const COLUMN_SPACING = ` ${VERTICAL_SEPARATOR} `; // Space around vertical separator
31
+ export function getColumnSpacing() {
32
+ return ` ${getVerticalSeparator()} `
33
+ }
35
34
 
36
- // 📖 Optional: Add more UI styling constants here as needed
37
- export const TABLE_PADDING = 1; // Padding around table edges
35
+ export const TABLE_PADDING = 1
38
36
 
39
- // 📖 Export all constants for easy import
40
37
  export default {
41
- BORDER_COLOR,
42
- BORDER_STYLE,
43
- VERTICAL_SEPARATOR,
44
- HORIZONTAL_SEPARATOR,
45
- HORIZONTAL_STYLE,
46
- HORIZONTAL_LINE,
47
- COLUMN_SPACING,
38
+ getVerticalSeparator,
39
+ getHorizontalLine,
40
+ getColumnSpacing,
48
41
  TABLE_PADDING
49
- };
42
+ }