free-coding-models 0.3.17 → 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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## 0.3.18
6
+
7
+ ### Added
8
+ - **Missing tool bootstrap flow**: FCM now detects when a target CLI is absent, offers a minimal in-TUI install confirmation, runs the official global install command, then resumes the selected model launch automatically.
9
+
10
+ ### Changed
11
+ - **TUI readability overhaul across every screen**: the main table, Settings, Help, Smart Recommend, Feedback, and Changelog overlays now share a semantic high-contrast theme system instead of a patchwork of hardcoded colors.
12
+ - **Global theme switching now works for real**: press `G` to cycle `auto → dark → light` live, and the Settings screen now exposes a visible `Global Theme` row for the same control.
13
+ - **Launcher binary resolution**: direct tool launches now search PATH plus common user bin directories so a freshly installed CLI can be reused immediately in the same FCM session.
14
+
15
+ ### Fixed
16
+ - **Theme repaint bugs**: provider colors, tier colors, separators, badges, cursor highlights, and overlay backgrounds now update immediately when the theme changes instead of keeping stale import-time colors.
17
+
5
18
  ## 0.3.17
6
19
 
7
20
  ### Added
package/README.md CHANGED
@@ -103,6 +103,8 @@ free-coding-models
103
103
 
104
104
  On first run, you'll be prompted to enter your API key(s). You can skip providers and add more later with **`P`**.
105
105
 
106
+ Need to fix contrast because your terminal theme is fighting the TUI? Press **`G`** at any time to cycle **Auto → Dark → Light**. The switch recolors the full interface live: table, Settings, Help, Smart Recommend, Feedback, and Changelog.
107
+
106
108
  **③ Pick a model and launch your tool:**
107
109
 
108
110
  ```
@@ -111,6 +113,8 @@ On first run, you'll be prompted to enter your API key(s). You can skip provider
111
113
 
112
114
  The model you select is automatically written into your tool's config (OpenCode, OpenClaw, Crush, etc.) and the tool opens immediately. Done.
113
115
 
116
+ If the active CLI tool is missing, FCM now catches it before launch, offers a tiny Yes/No install prompt, installs the tool with its official global command, then resumes the same model launch automatically.
117
+
114
118
  > 💡 You can also run `free-coding-models --goose --tier S` to pre-filter to S-tier models for Goose before the TUI even opens.
115
119
 
116
120
 
@@ -171,8 +175,9 @@ Press **`Z`** in the TUI to cycle between tools without restarting.
171
175
  | `D` | Cycle provider filter |
172
176
  | `E` | Toggle configured-only mode |
173
177
  | `F` | Favorite / unfavorite model |
178
+ | `G` | Cycle global theme (`Auto → Dark → Light`) |
174
179
  | `R/S/C/M/O/L/A/H/V/B/U` | Sort columns |
175
- | `P` | Settings (API keys, providers, updates) |
180
+ | `P` | Settings (API keys, providers, updates, theme) |
176
181
  | `Y` | Install Endpoints (push provider into tool config) |
177
182
  | `Q` | Smart Recommend overlay |
178
183
  | `N` | Changelog |
@@ -196,7 +201,10 @@ Press **`Z`** in the TUI to cycle between tools without restarting.
196
201
  - **Keyless latency** — models ping even without an API key (show 🔑 NO KEY)
197
202
  - **Smart Recommend** — questionnaire picks the best model for your task type
198
203
  - **Install Endpoints** — push a full provider catalog into any tool's config (`Y`)
204
+ - **Missing tool bootstrap** — detect absent CLIs, offer one-click install, then continue the selected launch automatically
199
205
  - **Width guardrail** — shows a warning instead of a broken table in narrow terminals
206
+ - **Readable everywhere** — semantic theme palette keeps table rows, overlays, badges, and help screens legible in dark and light terminals
207
+ - **Global theme switch** — `G` cycles `auto`, `dark`, and `light` live without restarting
200
208
  - **Auto-retry** — timeout models keep getting retried
201
209
 
202
210
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
package/src/app.js CHANGED
@@ -122,6 +122,7 @@ import { createOverlayRenderers } from '../src/overlays.js'
122
122
  import { createKeyHandler } from '../src/key-handler.js'
123
123
  import { getToolModeOrder, getToolMeta } from '../src/tool-metadata.js'
124
124
  import { startExternalTool } from '../src/tool-launchers.js'
125
+ import { getToolInstallPlan, installToolWithPlan, isToolInstalled } from '../src/tool-bootstrap.js'
125
126
  import { getConfiguredInstallableProviders, installProviderEndpoints, refreshInstalledEndpoints, getInstallTargetModes, getProviderCatalogModels } from '../src/endpoint-installer.js'
126
127
  import { loadCache, saveCache, clearCache, getCacheAge } from '../src/cache.js'
127
128
  import { checkConfigSecurity } from '../src/security.js'
@@ -176,7 +177,7 @@ const LOCAL_VERSION = pkg.version
176
177
  export async function runApp(cliArgs, config) {
177
178
 
178
179
  // 📖 Detect user active terminal theme
179
- detectActiveTheme(config.settings?.theme || 'dark')
180
+ detectActiveTheme(config.settings?.theme || 'auto')
180
181
 
181
182
  // 📖 Check config file security — warn and offer auto-fix if permissions are too open
182
183
  const securityCheck = checkConfigSecurity()
@@ -420,6 +421,14 @@ export async function runApp(cliArgs, config) {
420
421
  installEndpointsSelectedModelIds: new Set(), // 📖 Multi-select buffer for the selected-models phase
421
422
  installEndpointsErrorMsg: null, // 📖 Temporary validation/error message inside the install flow
422
423
  installEndpointsResult: null, // 📖 Final install result shown in the result phase
424
+ // 📖 Missing-tool bootstrap overlay — confirms a one-click install before retrying the launch.
425
+ toolInstallPromptOpen: false,
426
+ toolInstallPromptCursor: 0,
427
+ toolInstallPromptScrollOffset: 0,
428
+ toolInstallPromptMode: null,
429
+ toolInstallPromptModel: null,
430
+ toolInstallPromptPlan: null,
431
+ toolInstallPromptErrorMsg: null,
423
432
  // 📖 Smart Recommend overlay state (Q key opens it)
424
433
  recommendOpen: false, // 📖 Whether the recommend overlay is active
425
434
  recommendPhase: 'questionnaire', // 📖 'questionnaire'|'analyzing'|'results' — current phase
@@ -750,6 +759,8 @@ export async function runApp(cliArgs, config) {
750
759
  getInstallTargetModes,
751
760
  getProviderCatalogModels,
752
761
  getToolMeta,
762
+ getToolInstallPlan,
763
+ padEndDisplay,
753
764
  })
754
765
 
755
766
  onKeyPress = createKeyHandler({
@@ -785,6 +796,9 @@ export async function runApp(cliArgs, config) {
785
796
  startOpenCode,
786
797
  startExternalTool,
787
798
  getToolModeOrder,
799
+ getToolInstallPlan,
800
+ isToolInstalled,
801
+ installToolWithPlan,
788
802
  startRecommendAnalysis: overlays.startRecommendAnalysis,
789
803
  stopRecommendAnalysis: overlays.stopRecommendAnalysis,
790
804
  sendBugReport,
@@ -844,7 +858,7 @@ export async function runApp(cliArgs, config) {
844
858
  refreshAutoPingMode()
845
859
  state.frame++
846
860
  // 📖 Cache visible+sorted models each frame so Enter handler always matches the display
847
- if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen) {
861
+ if (!state.settingsOpen && !state.installEndpointsOpen && !state.toolInstallPromptOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen) {
848
862
  const visible = state.results.filter(r => !r.hidden)
849
863
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
850
864
  }
@@ -852,6 +866,8 @@ export async function runApp(cliArgs, config) {
852
866
  ? overlays.renderSettings()
853
867
  : state.installEndpointsOpen
854
868
  ? overlays.renderInstallEndpoints()
869
+ : state.toolInstallPromptOpen
870
+ ? overlays.renderToolInstallPrompt()
855
871
  : state.recommendOpen
856
872
  ? overlays.renderRecommend()
857
873
  : state.feedbackOpen
package/src/config.js CHANGED
@@ -210,7 +210,7 @@ function normalizeSettingsSection(settings) {
210
210
  ...safeSettings,
211
211
  hideUnconfiguredModels: typeof safeSettings.hideUnconfiguredModels === 'boolean' ? safeSettings.hideUnconfiguredModels : true,
212
212
  disableWidthsWarning: safeSettings.disableWidthsWarning === true,
213
- theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'dark',
213
+ theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'auto',
214
214
  }
215
215
  }
216
216
 
@@ -231,7 +231,7 @@ function normalizeProfileSettings(settings) {
231
231
  ..._emptyProfileSettings(),
232
232
  ...safeSettings,
233
233
  disableWidthsWarning: safeSettings.disableWidthsWarning === true,
234
- theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'dark',
234
+ theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'auto',
235
235
  }
236
236
  }
237
237
 
@@ -844,7 +844,7 @@ export function _emptyProfileSettings() {
844
844
  hideUnconfiguredModels: true, // 📖 true = default to providers that are actually configured
845
845
  preferredToolMode: 'opencode', // 📖 remember the last Z-selected launcher across app restarts
846
846
  disableWidthsWarning: false, // 📖 Disable widths warning (default off)
847
- theme: 'dark', // 📖 'dark', 'light', or 'auto'
847
+ theme: 'auto', // 📖 'auto' follows the terminal/OS theme, override with 'dark' or 'light' if needed
848
848
  }
849
849
  }
850
850
 
@@ -30,6 +30,7 @@
30
30
  import { loadChangelog } from './changelog-loader.js'
31
31
  import { loadConfig, replaceConfigContents } from './config.js'
32
32
  import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
33
+ import { cycleThemeSetting, detectActiveTheme } from './theme.js'
33
34
 
34
35
  // 📖 Some providers need an explicit probe model because the first catalog entry
35
36
  // 📖 is not guaranteed to be accepted by their chat endpoint.
@@ -184,6 +185,9 @@ export function createKeyHandler(ctx) {
184
185
  startOpenCode,
185
186
  startExternalTool,
186
187
  getToolModeOrder,
188
+ getToolInstallPlan,
189
+ isToolInstalled,
190
+ installToolWithPlan,
187
191
  startRecommendAnalysis,
188
192
  stopRecommendAnalysis,
189
193
  sendBugReport,
@@ -207,6 +211,97 @@ export function createKeyHandler(ctx) {
207
211
 
208
212
  let userSelected = null
209
213
 
214
+ function resetToolInstallPrompt() {
215
+ state.toolInstallPromptOpen = false
216
+ state.toolInstallPromptCursor = 0
217
+ state.toolInstallPromptScrollOffset = 0
218
+ state.toolInstallPromptMode = null
219
+ state.toolInstallPromptModel = null
220
+ state.toolInstallPromptPlan = null
221
+ state.toolInstallPromptErrorMsg = null
222
+ }
223
+
224
+ function shouldCheckMissingTool(mode) {
225
+ return mode !== 'opencode-desktop'
226
+ }
227
+
228
+ async function launchSelectedModel(selected, options = {}) {
229
+ const { uiAlreadyStopped = false } = options
230
+ userSelected = { modelId: selected.modelId, label: selected.label, tier: selected.tier, providerKey: selected.providerKey }
231
+
232
+ if (!uiAlreadyStopped) {
233
+ readline.emitKeypressEvents(process.stdin)
234
+ if (process.stdin.isTTY) process.stdin.setRawMode(true)
235
+ stopUi()
236
+ }
237
+
238
+ // 📖 Show selection status before handing control to the target tool.
239
+ if (selected.status === 'timeout') {
240
+ console.log(chalk.yellow(` ⚠ Selected: ${selected.label} (currently timing out)`))
241
+ } else if (selected.status === 'down') {
242
+ console.log(chalk.red(` ⚠ Selected: ${selected.label} (currently down)`))
243
+ } else {
244
+ console.log(chalk.cyan(` ✓ Selected: ${selected.label}`))
245
+ }
246
+ console.log()
247
+
248
+ // 📖 OpenClaw manages API keys inside its own config file. All other tools
249
+ // 📖 still need a provider key to be useful, so keep the existing warning.
250
+ if (state.mode !== 'openclaw') {
251
+ const selectedApiKey = getApiKey(state.config, selected.providerKey)
252
+ if (!selectedApiKey) {
253
+ console.log(chalk.yellow(` Warning: No API key configured for ${selected.providerKey}.`))
254
+ console.log(chalk.yellow(` The selected tool may not be able to use ${selected.label}.`))
255
+ console.log(chalk.dim(` Set ${ENV_VAR_NAMES[selected.providerKey] || selected.providerKey.toUpperCase() + '_API_KEY'} or configure via settings (P key).`))
256
+ console.log()
257
+ }
258
+ }
259
+
260
+ let exitCode = 0
261
+ if (state.mode === 'openclaw') {
262
+ exitCode = await startOpenClaw(userSelected, state.config, { launchCli: true })
263
+ } else if (state.mode === 'opencode-desktop') {
264
+ exitCode = await startOpenCodeDesktop(userSelected, state.config)
265
+ } else if (state.mode === 'opencode') {
266
+ exitCode = await startOpenCode(userSelected, state.config)
267
+ } else {
268
+ exitCode = await startExternalTool(state.mode, userSelected, state.config)
269
+ }
270
+
271
+ process.exit(typeof exitCode === 'number' ? exitCode : 0)
272
+ }
273
+
274
+ async function installMissingToolAndLaunch(selected, installPlan) {
275
+ const currentPlan = installPlan || getToolInstallPlan(state.mode)
276
+ stopUi({ resetRawMode: true })
277
+
278
+ console.log(chalk.cyan(` 📦 Installing missing tool for ${state.mode}...`))
279
+ if (currentPlan?.summary) console.log(chalk.dim(` ${currentPlan.summary}`))
280
+ if (currentPlan?.shellCommand) console.log(chalk.dim(` ${currentPlan.shellCommand}`))
281
+ if (currentPlan?.note) console.log(chalk.dim(` ${currentPlan.note}`))
282
+ console.log()
283
+
284
+ const installResult = await installToolWithPlan(currentPlan)
285
+ if (!installResult.ok) {
286
+ console.log(chalk.red(` X Tool installation failed with exit code ${installResult.exitCode}.`))
287
+ if (currentPlan?.docsUrl) console.log(chalk.dim(` Docs: ${currentPlan.docsUrl}`))
288
+ console.log()
289
+ process.exit(installResult.exitCode || 1)
290
+ }
291
+
292
+ if (shouldCheckMissingTool(state.mode) && !isToolInstalled(state.mode)) {
293
+ console.log(chalk.yellow(' ⚠ The installer finished, but the tool is still not reachable from this terminal session.'))
294
+ console.log(chalk.dim(' Restart your shell or add the tool bin directory to PATH, then retry the launch.'))
295
+ if (currentPlan?.docsUrl) console.log(chalk.dim(` Docs: ${currentPlan.docsUrl}`))
296
+ console.log()
297
+ process.exit(1)
298
+ }
299
+
300
+ console.log(chalk.green(' ✓ Tool installed successfully. Continuing with the selected model...'))
301
+ console.log()
302
+ await launchSelectedModel(selected, { uiAlreadyStopped: true })
303
+ }
304
+
210
305
  // ─── Settings key test helper ───────────────────────────────────────────────
211
306
  // 📖 Fires a single ping to the selected provider to verify the API key works.
212
307
  async function testProviderKey(providerKey) {
@@ -383,6 +478,20 @@ export function createKeyHandler(ctx) {
383
478
  saveConfig(state.config)
384
479
  }
385
480
 
481
+ // 📖 Theme switches need to update both persisted preference and the live
482
+ // 📖 semantic palette immediately so every screen redraw adopts the new colors.
483
+ function applyThemeSetting(nextTheme) {
484
+ if (!state.config.settings) state.config.settings = {}
485
+ state.config.settings.theme = nextTheme
486
+ saveConfig(state.config)
487
+ detectActiveTheme(nextTheme)
488
+ }
489
+
490
+ function cycleGlobalTheme() {
491
+ const currentTheme = state.config.settings?.theme || 'auto'
492
+ applyThemeSetting(cycleThemeSetting(currentTheme))
493
+ }
494
+
386
495
  function resetInstallEndpointsOverlay() {
387
496
  state.installEndpointsOpen = false
388
497
  state.installEndpointsPhase = 'providers'
@@ -431,6 +540,11 @@ export function createKeyHandler(ctx) {
431
540
  if (!key) return
432
541
  noteUserActivity()
433
542
 
543
+ if (!state.feedbackOpen && !state.settingsEditMode && !state.settingsAddKeyMode && key.name === 'g' && !key.ctrl && !key.meta) {
544
+ cycleGlobalTheme()
545
+ return
546
+ }
547
+
434
548
  // 📖 Profile system removed - API keys now persist permanently across all sessions
435
549
 
436
550
  // 📖 Install Endpoints overlay: provider → tool → connection → scope → optional model subset.
@@ -614,6 +728,44 @@ export function createKeyHandler(ctx) {
614
728
  return
615
729
  }
616
730
 
731
+ if (state.toolInstallPromptOpen) {
732
+ if (key.ctrl && key.name === 'c') { exit(0); return }
733
+
734
+ const installPlan = state.toolInstallPromptPlan || getToolInstallPlan(state.toolInstallPromptMode)
735
+ const installSupported = Boolean(installPlan?.supported)
736
+
737
+ if (key.name === 'escape') {
738
+ resetToolInstallPrompt()
739
+ return
740
+ }
741
+
742
+ if (installSupported && key.name === 'up') {
743
+ state.toolInstallPromptCursor = Math.max(0, state.toolInstallPromptCursor - 1)
744
+ return
745
+ }
746
+
747
+ if (installSupported && key.name === 'down') {
748
+ state.toolInstallPromptCursor = Math.min(1, state.toolInstallPromptCursor + 1)
749
+ return
750
+ }
751
+
752
+ if (key.name === 'return') {
753
+ if (!installSupported) {
754
+ resetToolInstallPrompt()
755
+ return
756
+ }
757
+
758
+ const selectedModel = state.toolInstallPromptModel
759
+ const shouldInstall = state.toolInstallPromptCursor === 0
760
+ resetToolInstallPrompt()
761
+
762
+ if (!shouldInstall || !selectedModel) return
763
+ await installMissingToolAndLaunch(selectedModel, installPlan)
764
+ }
765
+
766
+ return
767
+ }
768
+
617
769
  // 📖 Feedback overlay: intercept ALL keys while overlay is active.
618
770
  // 📖 Enter → send to Discord, Esc → cancel, Backspace → delete char, printable → append to buffer.
619
771
  if (state.feedbackOpen) {
@@ -1078,6 +1230,11 @@ export function createKeyHandler(ctx) {
1078
1230
  return
1079
1231
  }
1080
1232
 
1233
+ if (state.settingsCursor === themeRowIdx) {
1234
+ cycleGlobalTheme()
1235
+ return
1236
+ }
1237
+
1081
1238
  if (state.settingsCursor === cleanupLegacyProxyRowIdx) {
1082
1239
  runLegacyProxyCleanup()
1083
1240
  return
@@ -1098,6 +1255,7 @@ export function createKeyHandler(ctx) {
1098
1255
 
1099
1256
  // 📖 Enter edit mode for the selected provider's key
1100
1257
  const pk = providerKeys[state.settingsCursor]
1258
+ if (!pk) return
1101
1259
  state.settingsEditBuffer = resolveApiKeys(state.config, pk)[0] ?? ''
1102
1260
  state.settingsEditMode = true
1103
1261
  return
@@ -1112,15 +1270,7 @@ export function createKeyHandler(ctx) {
1112
1270
  ) return
1113
1271
  // 📖 Theme configuration cycle inside settings
1114
1272
  if (state.settingsCursor === themeRowIdx) {
1115
- const themes = ['dark', 'light', 'auto']
1116
- const currentTheme = state.config.settings?.theme || 'dark'
1117
- const nextIndex = (themes.indexOf(currentTheme) + 1) % themes.length
1118
- state.config.settings.theme = themes[nextIndex]
1119
- saveConfig(state.config)
1120
- try {
1121
- const { detectActiveTheme } = await import('../src/theme.js')
1122
- detectActiveTheme(state.config.settings.theme)
1123
- } catch {}
1273
+ cycleGlobalTheme()
1124
1274
  return
1125
1275
  }
1126
1276
  // 📖 Widths Warning toggle (disable/enable)
@@ -1142,6 +1292,8 @@ export function createKeyHandler(ctx) {
1142
1292
  if (key.name === 't') {
1143
1293
  if (
1144
1294
  state.settingsCursor === updateRowIdx
1295
+ || state.settingsCursor === widthWarningRowIdx
1296
+ || state.settingsCursor === themeRowIdx
1145
1297
  || state.settingsCursor === cleanupLegacyProxyRowIdx
1146
1298
  || state.settingsCursor === changelogViewRowIdx
1147
1299
  ) return
@@ -1149,6 +1301,7 @@ export function createKeyHandler(ctx) {
1149
1301
 
1150
1302
  // 📖 Test the selected provider's key (fires a real ping)
1151
1303
  const pk = providerKeys[state.settingsCursor]
1304
+ if (!pk) return
1152
1305
  testProviderKey(pk)
1153
1306
  return
1154
1307
  }
@@ -1442,46 +1595,24 @@ export function createKeyHandler(ctx) {
1442
1595
  // 📖 Use the cached visible+sorted array — guaranteed to match what's on screen
1443
1596
  const selected = state.visibleSorted[state.cursor]
1444
1597
  if (!selected) return // 📖 Guard: empty visible list (all filtered out)
1445
- // 📖 Allow selecting ANY model (even timeout/down) - user knows what they're doing
1446
- userSelected = { modelId: selected.modelId, label: selected.label, tier: selected.tier, providerKey: selected.providerKey }
1447
-
1448
- // 📖 Stop everything and act on selection immediately
1449
- readline.emitKeypressEvents(process.stdin)
1450
- process.stdin.setRawMode(true)
1451
- stopUi()
1452
-
1453
- // 📖 Show selection with status
1454
- if (selected.status === 'timeout') {
1455
- console.log(chalk.yellow(` ⚠ Selected: ${selected.label} (currently timing out)`))
1456
- } else if (selected.status === 'down') {
1457
- console.log(chalk.red(` ⚠ Selected: ${selected.label} (currently down)`))
1458
- } else {
1459
- console.log(chalk.cyan(` ✓ Selected: ${selected.label}`))
1460
- }
1461
- console.log()
1462
-
1463
- // 📖 Warn if no API key is configured for the selected model's provider
1464
- if (state.mode !== 'openclaw') {
1465
- const selectedApiKey = getApiKey(state.config, selected.providerKey)
1466
- if (!selectedApiKey) {
1467
- console.log(chalk.yellow(` Warning: No API key configured for ${selected.providerKey}.`))
1468
- console.log(chalk.yellow(` The selected tool may not be able to use ${selected.label}.`))
1469
- console.log(chalk.dim(` Set ${ENV_VAR_NAMES[selected.providerKey] || selected.providerKey.toUpperCase() + '_API_KEY'} or configure via settings (P key).`))
1470
- console.log()
1598
+ if (shouldCheckMissingTool(state.mode) && !isToolInstalled(state.mode)) {
1599
+ state.toolInstallPromptOpen = true
1600
+ state.toolInstallPromptCursor = 0
1601
+ state.toolInstallPromptScrollOffset = 0
1602
+ state.toolInstallPromptMode = state.mode
1603
+ state.toolInstallPromptModel = {
1604
+ modelId: selected.modelId,
1605
+ label: selected.label,
1606
+ tier: selected.tier,
1607
+ providerKey: selected.providerKey,
1608
+ status: selected.status,
1471
1609
  }
1610
+ state.toolInstallPromptPlan = getToolInstallPlan(state.mode)
1611
+ state.toolInstallPromptErrorMsg = null
1612
+ return
1472
1613
  }
1473
1614
 
1474
- // 📖 Dispatch to the correct integration based on active mode
1475
- if (state.mode === 'openclaw') {
1476
- await startOpenClaw(userSelected, state.config)
1477
- } else if (state.mode === 'opencode-desktop') {
1478
- await startOpenCodeDesktop(userSelected, state.config)
1479
- } else if (state.mode === 'opencode') {
1480
- await startOpenCode(userSelected, state.config)
1481
- } else {
1482
- await startExternalTool(state.mode, userSelected, state.config)
1483
- }
1484
- process.exit(0)
1615
+ await launchSelectedModel(selected)
1485
1616
  }
1486
1617
  }
1487
1618
  }
package/src/openclaw.js CHANGED
@@ -3,10 +3,12 @@
3
3
  * @description OpenClaw config helpers for persisting the selected provider/model as the default.
4
4
  *
5
5
  * @details
6
- * 📖 OpenClaw is config-driven: FCM does not launch a separate foreground CLI here.
6
+ * 📖 OpenClaw is primarily config-driven, but FCM can now optionally launch the
7
+ * 📖 installed CLI right after persisting the selected default model.
7
8
  * 📖 Pressing Enter in `OpenClaw` mode must therefore do two things reliably:
8
9
  * - install the selected provider/model into `~/.openclaw/openclaw.json`
9
10
  * - set that exact model as the default primary model for the next OpenClaw session
11
+ * - optionally start `openclaw` immediately when the caller asks for it
10
12
  *
11
13
  * 📖 The old implementation was hard-coded to `nvidia/*`, which meant selecting
12
14
  * 📖 a Groq/Cerebras/etc. row silently wrote the wrong provider/model into the
@@ -30,6 +32,7 @@ import { dirname, join } from 'path'
30
32
  import { installProviderEndpoints } from './endpoint-installer.js'
31
33
  import { ENV_VAR_NAMES } from './provider-metadata.js'
32
34
  import { PROVIDER_COLOR } from './render-table.js'
35
+ import { resolveToolBinaryPath } from './tool-bootstrap.js'
33
36
 
34
37
  const OPENCLAW_CONFIG = join(homedir(), '.openclaw', 'openclaw.json')
35
38
 
@@ -53,13 +56,38 @@ export function saveOpenClawConfig(config, options = {}) {
53
56
  writeFileSync(filePath, JSON.stringify(config, null, 2))
54
57
  }
55
58
 
59
+ function spawnOpenClawCli() {
60
+ return new Promise(async (resolve, reject) => {
61
+ const { spawn } = await import('child_process')
62
+ const command = resolveToolBinaryPath('openclaw') || 'openclaw'
63
+ const child = spawn(command, [], {
64
+ stdio: 'inherit',
65
+ shell: false,
66
+ detached: false,
67
+ env: process.env,
68
+ })
69
+
70
+ child.on('exit', (code) => resolve(typeof code === 'number' ? code : 0))
71
+ child.on('error', (error) => {
72
+ if (error?.code === 'ENOENT') {
73
+ console.log(chalk.red(' X Could not find "openclaw" in PATH.'))
74
+ console.log(chalk.dim(' Install: npm install -g openclaw@latest or see https://docs.openclaw.ai/install'))
75
+ console.log()
76
+ resolve(1)
77
+ return
78
+ }
79
+ reject(error)
80
+ })
81
+ })
82
+ }
83
+
56
84
  /**
57
85
  * 📖 startOpenClaw installs the selected provider/model into OpenClaw and sets
58
86
  * 📖 it as the primary default model. OpenClaw itself is not launched here.
59
87
  *
60
88
  * @param {{ providerKey: string, modelId: string, label: string }} model
61
89
  * @param {Record<string, unknown>} config
62
- * @param {{ paths?: { openclawConfigPath?: string } }} [options]
90
+ * @param {{ paths?: { openclawConfigPath?: string }, launchCli?: boolean }} [options]
63
91
  * @returns {Promise<ReturnType<typeof installProviderEndpoints> | null>}
64
92
  */
65
93
  export async function startOpenClaw(model, config, options = {}) {
@@ -85,9 +113,15 @@ export async function startOpenClaw(model, config, options = {}) {
85
113
  if (result.backupPath) console.log(chalk.dim(` 💾 Backup: ${result.backupPath}`))
86
114
  if (providerEnvName) console.log(chalk.dim(` 🔑 API key synced under config env.${providerEnvName}`))
87
115
  console.log()
88
- console.log(chalk.dim(' 💡 OpenClaw will reload config automatically when it notices the file change.'))
89
- console.log(chalk.dim(` To apply manually: openclaw models set ${result.primaryModelRef || `${result.providerId}/${model.modelId}`}`))
90
- console.log()
116
+ if (options.launchCli) {
117
+ console.log(chalk.dim(' Starting OpenClaw...'))
118
+ console.log()
119
+ await spawnOpenClawCli()
120
+ } else {
121
+ console.log(chalk.dim(' 💡 OpenClaw will reload config automatically when it notices the file change.'))
122
+ console.log(chalk.dim(` To apply manually: openclaw models set ${result.primaryModelRef || `${result.providerId}/${model.modelId}`}`))
123
+ console.log()
124
+ }
91
125
  return result
92
126
  } catch (error) {
93
127
  console.log(chalk.red(` X Could not configure OpenClaw: ${error instanceof Error ? error.message : String(error)}`))
package/src/opencode.js CHANGED
@@ -32,6 +32,7 @@ import { PROVIDER_COLOR } from './render-table.js'
32
32
  import { loadOpenCodeConfig, saveOpenCodeConfig } from './opencode-config.js'
33
33
  import { getApiKey } from './config.js'
34
34
  import { ENV_VAR_NAMES, OPENCODE_MODEL_MAP, isWindows, isMac, isLinux } from './provider-metadata.js'
35
+ import { resolveToolBinaryPath } from './tool-bootstrap.js'
35
36
 
36
37
  // 📖 OpenCode config location: ~/.config/opencode/opencode.json on ALL platforms.
37
38
  // 📖 OpenCode uses xdg-basedir which resolves to %USERPROFILE%\.config on Windows.
@@ -177,7 +178,7 @@ async function spawnOpenCode(args, providerKey, fcmConfig, existingZaiProxy = nu
177
178
  }
178
179
 
179
180
  const { spawn } = await import('child_process')
180
- const child = spawn('opencode', finalArgs, {
181
+ const child = spawn(resolveToolBinaryPath('opencode') || 'opencode', finalArgs, {
181
182
  stdio: 'inherit',
182
183
  shell: true,
183
184
  detached: false,