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.
@@ -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,
@@ -200,12 +204,104 @@ export function createKeyHandler(ctx) {
200
204
  noteUserActivity,
201
205
  intervalToPingMode,
202
206
  PING_MODE_CYCLE,
207
+ themeRowIdx,
203
208
  setResults,
204
209
  readline,
205
210
  } = ctx
206
211
 
207
212
  let userSelected = null
208
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
+
209
305
  // ─── Settings key test helper ───────────────────────────────────────────────
210
306
  // 📖 Fires a single ping to the selected provider to verify the API key works.
211
307
  async function testProviderKey(providerKey) {
@@ -382,6 +478,20 @@ export function createKeyHandler(ctx) {
382
478
  saveConfig(state.config)
383
479
  }
384
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
+
385
495
  function resetInstallEndpointsOverlay() {
386
496
  state.installEndpointsOpen = false
387
497
  state.installEndpointsPhase = 'providers'
@@ -430,6 +540,11 @@ export function createKeyHandler(ctx) {
430
540
  if (!key) return
431
541
  noteUserActivity()
432
542
 
543
+ if (!state.feedbackOpen && !state.settingsEditMode && !state.settingsAddKeyMode && key.name === 'g' && !key.ctrl && !key.meta) {
544
+ cycleGlobalTheme()
545
+ return
546
+ }
547
+
433
548
  // 📖 Profile system removed - API keys now persist permanently across all sessions
434
549
 
435
550
  // 📖 Install Endpoints overlay: provider → tool → connection → scope → optional model subset.
@@ -613,6 +728,44 @@ export function createKeyHandler(ctx) {
613
728
  return
614
729
  }
615
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
+
616
769
  // 📖 Feedback overlay: intercept ALL keys while overlay is active.
617
770
  // 📖 Enter → send to Discord, Esc → cancel, Backspace → delete char, printable → append to buffer.
618
771
  if (state.feedbackOpen) {
@@ -926,7 +1079,8 @@ export function createKeyHandler(ctx) {
926
1079
  const providerKeys = Object.keys(sources)
927
1080
  const updateRowIdx = providerKeys.length
928
1081
  const widthWarningRowIdx = updateRowIdx + 1
929
- const cleanupLegacyProxyRowIdx = widthWarningRowIdx + 1
1082
+ const themeRowIdx = widthWarningRowIdx + 1
1083
+ const cleanupLegacyProxyRowIdx = themeRowIdx + 1
930
1084
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
931
1085
  // 📖 Profile system removed - API keys now persist permanently across all sessions
932
1086
  const maxRowIdx = changelogViewRowIdx
@@ -1076,6 +1230,11 @@ export function createKeyHandler(ctx) {
1076
1230
  return
1077
1231
  }
1078
1232
 
1233
+ if (state.settingsCursor === themeRowIdx) {
1234
+ cycleGlobalTheme()
1235
+ return
1236
+ }
1237
+
1079
1238
  if (state.settingsCursor === cleanupLegacyProxyRowIdx) {
1080
1239
  runLegacyProxyCleanup()
1081
1240
  return
@@ -1096,6 +1255,7 @@ export function createKeyHandler(ctx) {
1096
1255
 
1097
1256
  // 📖 Enter edit mode for the selected provider's key
1098
1257
  const pk = providerKeys[state.settingsCursor]
1258
+ if (!pk) return
1099
1259
  state.settingsEditBuffer = resolveApiKeys(state.config, pk)[0] ?? ''
1100
1260
  state.settingsEditMode = true
1101
1261
  return
@@ -1108,6 +1268,11 @@ export function createKeyHandler(ctx) {
1108
1268
  || state.settingsCursor === cleanupLegacyProxyRowIdx
1109
1269
  || state.settingsCursor === changelogViewRowIdx
1110
1270
  ) return
1271
+ // 📖 Theme configuration cycle inside settings
1272
+ if (state.settingsCursor === themeRowIdx) {
1273
+ cycleGlobalTheme()
1274
+ return
1275
+ }
1111
1276
  // 📖 Widths Warning toggle (disable/enable)
1112
1277
  if (state.settingsCursor === widthWarningRowIdx) {
1113
1278
  toggleWidthsWarningSetting()
@@ -1127,6 +1292,8 @@ export function createKeyHandler(ctx) {
1127
1292
  if (key.name === 't') {
1128
1293
  if (
1129
1294
  state.settingsCursor === updateRowIdx
1295
+ || state.settingsCursor === widthWarningRowIdx
1296
+ || state.settingsCursor === themeRowIdx
1130
1297
  || state.settingsCursor === cleanupLegacyProxyRowIdx
1131
1298
  || state.settingsCursor === changelogViewRowIdx
1132
1299
  ) return
@@ -1134,6 +1301,7 @@ export function createKeyHandler(ctx) {
1134
1301
 
1135
1302
  // 📖 Test the selected provider's key (fires a real ping)
1136
1303
  const pk = providerKeys[state.settingsCursor]
1304
+ if (!pk) return
1137
1305
  testProviderKey(pk)
1138
1306
  return
1139
1307
  }
@@ -1427,46 +1595,24 @@ export function createKeyHandler(ctx) {
1427
1595
  // 📖 Use the cached visible+sorted array — guaranteed to match what's on screen
1428
1596
  const selected = state.visibleSorted[state.cursor]
1429
1597
  if (!selected) return // 📖 Guard: empty visible list (all filtered out)
1430
- // 📖 Allow selecting ANY model (even timeout/down) - user knows what they're doing
1431
- userSelected = { modelId: selected.modelId, label: selected.label, tier: selected.tier, providerKey: selected.providerKey }
1432
-
1433
- // 📖 Stop everything and act on selection immediately
1434
- readline.emitKeypressEvents(process.stdin)
1435
- process.stdin.setRawMode(true)
1436
- stopUi()
1437
-
1438
- // 📖 Show selection with status
1439
- if (selected.status === 'timeout') {
1440
- console.log(chalk.yellow(` ⚠ Selected: ${selected.label} (currently timing out)`))
1441
- } else if (selected.status === 'down') {
1442
- console.log(chalk.red(` ⚠ Selected: ${selected.label} (currently down)`))
1443
- } else {
1444
- console.log(chalk.cyan(` ✓ Selected: ${selected.label}`))
1445
- }
1446
- console.log()
1447
-
1448
- // 📖 Warn if no API key is configured for the selected model's provider
1449
- if (state.mode !== 'openclaw') {
1450
- const selectedApiKey = getApiKey(state.config, selected.providerKey)
1451
- if (!selectedApiKey) {
1452
- console.log(chalk.yellow(` Warning: No API key configured for ${selected.providerKey}.`))
1453
- console.log(chalk.yellow(` The selected tool may not be able to use ${selected.label}.`))
1454
- console.log(chalk.dim(` Set ${ENV_VAR_NAMES[selected.providerKey] || selected.providerKey.toUpperCase() + '_API_KEY'} or configure via settings (P key).`))
1455
- 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,
1456
1609
  }
1610
+ state.toolInstallPromptPlan = getToolInstallPlan(state.mode)
1611
+ state.toolInstallPromptErrorMsg = null
1612
+ return
1457
1613
  }
1458
1614
 
1459
- // 📖 Dispatch to the correct integration based on active mode
1460
- if (state.mode === 'openclaw') {
1461
- await startOpenClaw(userSelected, state.config)
1462
- } else if (state.mode === 'opencode-desktop') {
1463
- await startOpenCodeDesktop(userSelected, state.config)
1464
- } else if (state.mode === 'opencode') {
1465
- await startOpenCode(userSelected, state.config)
1466
- } else {
1467
- await startExternalTool(state.mode, userSelected, state.config)
1468
- }
1469
- process.exit(0)
1615
+ await launchSelectedModel(selected)
1470
1616
  }
1471
1617
  }
1472
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,