free-coding-models 0.3.11 → 0.3.12

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,432 @@
1
+ /**
2
+ * @file src/legacy-proxy-cleanup.js
3
+ * @description Best-effort cleanup for discontinued proxy-era config leftovers.
4
+ *
5
+ * @details
6
+ * 📖 The old global proxy/daemon stack has been removed from the product, but
7
+ * 📖 some users may still have persisted `fcm-proxy` entries, env files, or
8
+ * 📖 runtime artifacts from earlier versions.
9
+ *
10
+ * 📖 This module removes only the legacy proxy markers that are now obsolete:
11
+ * - `fcm-proxy` providers inside tool configs
12
+ * - proxy-only env files for removed tools
13
+ * - daemon/log artifacts from the old bridge
14
+ * - stale proxy fields in `~/.free-coding-models.json`
15
+ *
16
+ * 📖 It intentionally preserves current direct-provider installs such as
17
+ * 📖 `fcm-nvidia`, `fcm-groq`, or the current OpenHands env file when it is
18
+ * 📖 clearly configured for direct provider usage instead of the removed proxy.
19
+ *
20
+ * @functions
21
+ * → `cleanupLegacyProxyArtifacts` — remove discontinued proxy-era config files and entries
22
+ *
23
+ * @exports cleanupLegacyProxyArtifacts
24
+ */
25
+
26
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { join } from 'node:path'
29
+
30
+ const LEGACY_TOOL_MODES = new Set(['claude-code', 'codex', 'gemini'])
31
+ const LEGACY_RUNTIME_FILES = ['daemon.json', 'daemon-stdout.log', 'daemon-stderr.log', 'request-log.jsonl']
32
+ const LEGACY_ENV_FILES = ['.fcm-claude-code-env', '.fcm-codex-env', '.fcm-gemini-env']
33
+
34
+ function getDefaultPaths(homeDir) {
35
+ return {
36
+ configPath: join(homeDir, '.free-coding-models.json'),
37
+ dataDir: join(homeDir, '.free-coding-models'),
38
+ opencodeConfigPath: join(homeDir, '.config', 'opencode', 'opencode.json'),
39
+ openclawConfigPath: join(homeDir, '.openclaw', 'openclaw.json'),
40
+ crushConfigPath: join(homeDir, '.config', 'crush', 'crush.json'),
41
+ gooseProvidersDir: join(homeDir, '.config', 'goose', 'custom_providers'),
42
+ gooseSecretsPath: join(homeDir, '.config', 'goose', 'secrets.yaml'),
43
+ gooseConfigPath: join(homeDir, '.config', 'goose', 'config.yaml'),
44
+ piModelsPath: join(homeDir, '.pi', 'agent', 'models.json'),
45
+ piSettingsPath: join(homeDir, '.pi', 'agent', 'settings.json'),
46
+ aiderConfigPath: join(homeDir, '.aider.conf.yml'),
47
+ ampConfigPath: join(homeDir, '.config', 'amp', 'settings.json'),
48
+ qwenConfigPath: join(homeDir, '.qwen', 'settings.json'),
49
+ launchAgentPath: join(homeDir, 'Library', 'LaunchAgents', 'com.fcm.proxy.plist'),
50
+ systemdServicePath: join(homeDir, '.config', 'systemd', 'user', 'fcm-proxy.service'),
51
+ shellProfilePaths: [
52
+ join(homeDir, '.zshrc'),
53
+ join(homeDir, '.bashrc'),
54
+ join(homeDir, '.bash_profile'),
55
+ ],
56
+ }
57
+ }
58
+
59
+ function createSummary() {
60
+ return {
61
+ changed: false,
62
+ removedFiles: [],
63
+ updatedFiles: [],
64
+ removedEntries: 0,
65
+ errors: [],
66
+ }
67
+ }
68
+
69
+ function noteRemovedFile(summary, filePath) {
70
+ summary.changed = true
71
+ summary.removedFiles.push(filePath)
72
+ }
73
+
74
+ function noteUpdatedFile(summary, filePath, removedEntries = 1) {
75
+ summary.changed = true
76
+ summary.updatedFiles.push(filePath)
77
+ summary.removedEntries += removedEntries
78
+ }
79
+
80
+ function noteError(summary, filePath, error) {
81
+ summary.errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`)
82
+ }
83
+
84
+ function readJsonFile(filePath, fallback = null) {
85
+ if (!existsSync(filePath)) return fallback
86
+ return JSON.parse(readFileSync(filePath, 'utf8'))
87
+ }
88
+
89
+ function writeJsonFile(filePath, value) {
90
+ writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n')
91
+ }
92
+
93
+ function deleteFileIfExists(filePath, summary) {
94
+ if (!existsSync(filePath)) return false
95
+ try {
96
+ unlinkSync(filePath)
97
+ noteRemovedFile(summary, filePath)
98
+ return true
99
+ } catch (error) {
100
+ noteError(summary, filePath, error)
101
+ return false
102
+ }
103
+ }
104
+
105
+ function updateJsonFile(filePath, mutate, summary) {
106
+ if (!existsSync(filePath)) return
107
+ try {
108
+ const data = readJsonFile(filePath, {})
109
+ const removedEntries = mutate(data)
110
+ if (removedEntries > 0) {
111
+ writeJsonFile(filePath, data)
112
+ noteUpdatedFile(summary, filePath, removedEntries)
113
+ }
114
+ } catch (error) {
115
+ noteError(summary, filePath, error)
116
+ }
117
+ }
118
+
119
+ function cleanupMainConfig(filePath, summary) {
120
+ if (!existsSync(filePath)) return
121
+ try {
122
+ const config = readJsonFile(filePath, {})
123
+ let removedEntries = 0
124
+
125
+ if (config.settings && typeof config.settings === 'object' && 'proxy' in config.settings) {
126
+ delete config.settings.proxy
127
+ removedEntries += 1
128
+ }
129
+
130
+ if ('proxySettings' in config) {
131
+ delete config.proxySettings
132
+ removedEntries += 1
133
+ }
134
+
135
+ if (Array.isArray(config.endpointInstalls)) {
136
+ const before = config.endpointInstalls.length
137
+ config.endpointInstalls = config.endpointInstalls.filter(
138
+ (entry) => !LEGACY_TOOL_MODES.has(entry?.toolMode)
139
+ )
140
+ removedEntries += before - config.endpointInstalls.length
141
+ }
142
+
143
+ if (config.settings?.preferredToolMode && LEGACY_TOOL_MODES.has(config.settings.preferredToolMode)) {
144
+ config.settings.preferredToolMode = 'opencode'
145
+ removedEntries += 1
146
+ }
147
+
148
+ if (removedEntries > 0) {
149
+ writeJsonFile(filePath, config)
150
+ noteUpdatedFile(summary, filePath, removedEntries)
151
+ }
152
+ } catch (error) {
153
+ noteError(summary, filePath, error)
154
+ }
155
+ }
156
+
157
+ function cleanupRuntimeFiles(dataDir, summary) {
158
+ for (const fileName of LEGACY_RUNTIME_FILES) {
159
+ deleteFileIfExists(join(dataDir, fileName), summary)
160
+ }
161
+ }
162
+
163
+ function cleanupLegacyEnvFiles(homeDir, summary) {
164
+ for (const fileName of LEGACY_ENV_FILES) {
165
+ deleteFileIfExists(join(homeDir, fileName), summary)
166
+ deleteFileIfExists(join(homeDir, `${fileName}.bak`), summary)
167
+ }
168
+ }
169
+
170
+ function cleanupShellProfiles(profilePaths, summary) {
171
+ const patterns = [
172
+ /# 📖 FCM Proxy — Claude Code env vars/,
173
+ /\.fcm-claude-code-env/,
174
+ ]
175
+
176
+ for (const profilePath of profilePaths) {
177
+ if (!existsSync(profilePath)) continue
178
+ try {
179
+ const raw = readFileSync(profilePath, 'utf8')
180
+ const lines = raw.split(/\r?\n/)
181
+ const filtered = lines.filter((line) => !patterns.some((pattern) => pattern.test(line)))
182
+ if (filtered.join('\n') !== lines.join('\n')) {
183
+ writeFileSync(profilePath, filtered.join('\n'))
184
+ noteUpdatedFile(summary, profilePath, 1)
185
+ }
186
+ } catch (error) {
187
+ noteError(summary, profilePath, error)
188
+ }
189
+ }
190
+ }
191
+
192
+ function cleanupOpenCode(filePath, summary) {
193
+ updateJsonFile(filePath, (config) => {
194
+ let removedEntries = 0
195
+ if (config.provider?.['fcm-proxy']) {
196
+ delete config.provider['fcm-proxy']
197
+ removedEntries += 1
198
+ if (Object.keys(config.provider).length === 0) delete config.provider
199
+ }
200
+ if (typeof config.model === 'string' && config.model.startsWith('fcm-proxy/')) {
201
+ delete config.model
202
+ removedEntries += 1
203
+ }
204
+ return removedEntries
205
+ }, summary)
206
+ }
207
+
208
+ function cleanupOpenClaw(filePath, summary) {
209
+ updateJsonFile(filePath, (config) => {
210
+ let removedEntries = 0
211
+ if (config.models?.providers?.['fcm-proxy']) {
212
+ delete config.models.providers['fcm-proxy']
213
+ removedEntries += 1
214
+ }
215
+ if (config.agents?.defaults?.models && typeof config.agents.defaults.models === 'object') {
216
+ for (const key of Object.keys(config.agents.defaults.models)) {
217
+ if (key.startsWith('fcm-proxy/')) {
218
+ delete config.agents.defaults.models[key]
219
+ removedEntries += 1
220
+ }
221
+ }
222
+ }
223
+ return removedEntries
224
+ }, summary)
225
+ }
226
+
227
+ function cleanupCrush(filePath, summary) {
228
+ updateJsonFile(filePath, (config) => {
229
+ let removedEntries = 0
230
+ if (config.providers?.['fcm-proxy']) {
231
+ delete config.providers['fcm-proxy']
232
+ removedEntries += 1
233
+ }
234
+ if (config.models?.large?.provider === 'fcm-proxy') {
235
+ delete config.models.large
236
+ removedEntries += 1
237
+ }
238
+ if (config.models?.small?.provider === 'fcm-proxy') {
239
+ delete config.models.small
240
+ removedEntries += 1
241
+ }
242
+ return removedEntries
243
+ }, summary)
244
+ }
245
+
246
+ function readSimpleYamlMap(filePath) {
247
+ if (!existsSync(filePath)) return {}
248
+ const output = {}
249
+ const lines = readFileSync(filePath, 'utf8').split(/\r?\n/)
250
+ for (const line of lines) {
251
+ if (!line.trim() || line.trim().startsWith('#')) continue
252
+ const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
253
+ if (!match) continue
254
+ output[match[1]] = match[2].trim().replace(/^['"]|['"]$/g, '')
255
+ }
256
+ return output
257
+ }
258
+
259
+ function writeSimpleYamlMap(filePath, entries) {
260
+ const lines = Object.keys(entries)
261
+ .sort()
262
+ .map((key) => `${key}: ${JSON.stringify(String(entries[key] ?? ''))}`)
263
+ writeFileSync(filePath, lines.join('\n') + '\n')
264
+ }
265
+
266
+ function cleanupGoose(paths, summary) {
267
+ deleteFileIfExists(join(paths.gooseProvidersDir, 'fcm-proxy.json'), summary)
268
+
269
+ if (existsSync(paths.gooseSecretsPath)) {
270
+ try {
271
+ const secrets = readSimpleYamlMap(paths.gooseSecretsPath)
272
+ if ('FCM_PROXY_API_KEY' in secrets) {
273
+ delete secrets.FCM_PROXY_API_KEY
274
+ writeSimpleYamlMap(paths.gooseSecretsPath, secrets)
275
+ noteUpdatedFile(summary, paths.gooseSecretsPath, 1)
276
+ }
277
+ } catch (error) {
278
+ noteError(summary, paths.gooseSecretsPath, error)
279
+ }
280
+ }
281
+
282
+ if (existsSync(paths.gooseConfigPath)) {
283
+ try {
284
+ const lines = readFileSync(paths.gooseConfigPath, 'utf8').split(/\r?\n/)
285
+ const hadLegacyProvider = lines.some((line) => /^GOOSE_PROVIDER:\s*fcm-proxy\s*$/.test(line))
286
+ if (hadLegacyProvider) {
287
+ const filtered = lines.filter((line) => {
288
+ if (/^GOOSE_PROVIDER:\s*fcm-proxy\s*$/.test(line)) return false
289
+ if (/^GOOSE_MODEL:\s*/.test(line)) return false
290
+ return true
291
+ })
292
+ writeFileSync(paths.gooseConfigPath, filtered.join('\n'))
293
+ noteUpdatedFile(summary, paths.gooseConfigPath, 2)
294
+ }
295
+ } catch (error) {
296
+ noteError(summary, paths.gooseConfigPath, error)
297
+ }
298
+ }
299
+ }
300
+
301
+ function cleanupPi(paths, summary) {
302
+ updateJsonFile(paths.piModelsPath, (config) => {
303
+ let removedEntries = 0
304
+ if (config.providers?.['fcm-proxy']) {
305
+ delete config.providers['fcm-proxy']
306
+ removedEntries += 1
307
+ }
308
+ return removedEntries
309
+ }, summary)
310
+
311
+ updateJsonFile(paths.piSettingsPath, (config) => {
312
+ let removedEntries = 0
313
+ if (config.defaultProvider === 'fcm-proxy') {
314
+ delete config.defaultProvider
315
+ removedEntries += 1
316
+ }
317
+ if (typeof config.defaultModel === 'string' && config.defaultModel.startsWith('fcm-proxy/')) {
318
+ delete config.defaultModel
319
+ removedEntries += 1
320
+ }
321
+ return removedEntries
322
+ }, summary)
323
+ }
324
+
325
+ function cleanupAider(filePath, summary) {
326
+ if (!existsSync(filePath)) return
327
+ try {
328
+ const content = readFileSync(filePath, 'utf8')
329
+ const isLegacyProxyConfig = content.includes('FCM Proxy V2') || /openai-api-base:\s*http:\/\/127\.0\.0\.1:/i.test(content)
330
+ if (isLegacyProxyConfig) {
331
+ unlinkSync(filePath)
332
+ noteRemovedFile(summary, filePath)
333
+ }
334
+ } catch (error) {
335
+ noteError(summary, filePath, error)
336
+ }
337
+ }
338
+
339
+ function cleanupAmp(filePath, summary) {
340
+ updateJsonFile(filePath, (config) => {
341
+ let removedEntries = 0
342
+ const usesLegacyLocalhost = typeof config['amp.url'] === 'string' && /127\.0\.0\.1|localhost/.test(config['amp.url'])
343
+ if (usesLegacyLocalhost) {
344
+ delete config['amp.url']
345
+ removedEntries += 1
346
+ if (typeof config['amp.model'] === 'string') {
347
+ delete config['amp.model']
348
+ removedEntries += 1
349
+ }
350
+ }
351
+ return removedEntries
352
+ }, summary)
353
+ }
354
+
355
+ function cleanupQwen(filePath, summary) {
356
+ updateJsonFile(filePath, (config) => {
357
+ let removedEntries = 0
358
+ const removedIds = new Set()
359
+ if (Array.isArray(config.modelProviders?.openai)) {
360
+ const next = []
361
+ for (const entry of config.modelProviders.openai) {
362
+ const isLegacyEntry = entry?.envKey === 'FCM_PROXY_API_KEY'
363
+ || (typeof entry?.baseUrl === 'string' && /127\.0\.0\.1|localhost/.test(entry.baseUrl))
364
+ || (typeof entry?.id === 'string' && entry.id.startsWith('fcm-proxy/'))
365
+ if (isLegacyEntry) {
366
+ if (typeof entry?.id === 'string') removedIds.add(entry.id)
367
+ removedEntries += 1
368
+ continue
369
+ }
370
+ next.push(entry)
371
+ }
372
+ config.modelProviders.openai = next
373
+ }
374
+ if (typeof config.model === 'string' && (config.model.startsWith('fcm-proxy/') || removedIds.has(config.model))) {
375
+ delete config.model
376
+ removedEntries += 1
377
+ }
378
+ return removedEntries
379
+ }, summary)
380
+ }
381
+
382
+ function cleanupOpenHandsEnv(homeDir, summary) {
383
+ const filePath = join(homeDir, '.fcm-openhands-env')
384
+ if (!existsSync(filePath)) return
385
+ try {
386
+ const content = readFileSync(filePath, 'utf8')
387
+ const isLegacyProxyEnv = content.includes('FCM Proxy V2') || /127\.0\.0\.1|localhost/.test(content)
388
+ if (isLegacyProxyEnv) {
389
+ unlinkSync(filePath)
390
+ noteRemovedFile(summary, filePath)
391
+ }
392
+ } catch (error) {
393
+ noteError(summary, filePath, error)
394
+ }
395
+ }
396
+
397
+ /**
398
+ * 📖 cleanupLegacyProxyArtifacts removes stale proxy-era artifacts from older
399
+ * 📖 releases. It is safe to run multiple times.
400
+ *
401
+ * @param {{
402
+ * homeDir?: string,
403
+ * paths?: Partial<ReturnType<typeof getDefaultPaths>>
404
+ * }} [options]
405
+ * @returns {{ changed: boolean, removedFiles: string[], updatedFiles: string[], removedEntries: number, errors: string[] }}
406
+ */
407
+ export function cleanupLegacyProxyArtifacts(options = {}) {
408
+ const homeDir = options.homeDir || homedir()
409
+ const paths = {
410
+ ...getDefaultPaths(homeDir),
411
+ ...(options.paths || {}),
412
+ }
413
+ const summary = createSummary()
414
+
415
+ cleanupMainConfig(paths.configPath, summary)
416
+ cleanupRuntimeFiles(paths.dataDir, summary)
417
+ cleanupLegacyEnvFiles(homeDir, summary)
418
+ cleanupShellProfiles(paths.shellProfilePaths || [], summary)
419
+ cleanupOpenCode(paths.opencodeConfigPath, summary)
420
+ cleanupOpenClaw(paths.openclawConfigPath, summary)
421
+ cleanupCrush(paths.crushConfigPath, summary)
422
+ cleanupGoose(paths, summary)
423
+ cleanupPi(paths, summary)
424
+ cleanupAider(paths.aiderConfigPath, summary)
425
+ cleanupAmp(paths.ampConfigPath, summary)
426
+ cleanupQwen(paths.qwenConfigPath, summary)
427
+ cleanupOpenHandsEnv(homeDir, summary)
428
+ deleteFileIfExists(paths.launchAgentPath, summary)
429
+ deleteFileIfExists(paths.systemdServicePath, summary)
430
+
431
+ return summary
432
+ }
package/src/openclaw.js CHANGED
@@ -1,136 +1,97 @@
1
1
  /**
2
- * @file openclaw.js
3
- * @description OpenClaw config helpers for setting NVIDIA NIM defaults.
2
+ * @file src/openclaw.js
3
+ * @description OpenClaw config helpers for persisting the selected provider/model as the default.
4
4
  *
5
5
  * @details
6
- * This module owns the OpenClaw integration logic:
7
- * - Read/write ~/.openclaw/openclaw.json
8
- * - Ensure the NVIDIA provider block exists under models.providers
9
- * - Patch the OpenClaw allowlist for NVIDIA models when needed
10
- * - Set the selected model as the default primary model
6
+ * 📖 OpenClaw is config-driven: FCM does not launch a separate foreground CLI here.
7
+ * 📖 Pressing Enter in `OpenClaw` mode must therefore do two things reliably:
8
+ * - install the selected provider/model into `~/.openclaw/openclaw.json`
9
+ * - set that exact model as the default primary model for the next OpenClaw session
11
10
  *
12
- * Functions:
13
- * - `loadOpenClawConfig` read OpenClaw config as JSON
14
- * - `saveOpenClawConfig` persist OpenClaw config safely
15
- * - `startOpenClaw` set NVIDIA model as OpenClaw default
11
+ * 📖 The old implementation was hard-coded to `nvidia/*`, which meant selecting
12
+ * 📖 a Groq/Cerebras/etc. row silently wrote the wrong provider/model into the
13
+ * 📖 OpenClaw config. This module now delegates to the shared direct-install
14
+ * 📖 writer so every supported provider uses the same contract.
15
+ *
16
+ * @functions
17
+ * → `loadOpenClawConfig` — read the OpenClaw config from disk
18
+ * → `saveOpenClawConfig` — persist the OpenClaw config to disk
19
+ * → `startOpenClaw` — install the selected provider/model and set it as default
16
20
  *
17
21
  * @exports { loadOpenClawConfig, saveOpenClawConfig, startOpenClaw }
18
- * @see ../patch-openclaw-models.js
22
+ *
23
+ * @see src/endpoint-installer.js
19
24
  */
20
25
 
21
26
  import chalk from 'chalk'
22
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
23
28
  import { homedir } from 'os'
24
- import { join } from 'path'
25
- import { patchOpenClawModelsJson } from '../patch-openclaw-models.js'
26
- import { sources } from '../sources.js'
29
+ import { dirname, join } from 'path'
30
+ import { installProviderEndpoints } from './endpoint-installer.js'
31
+ import { ENV_VAR_NAMES } from './provider-metadata.js'
27
32
  import { PROVIDER_COLOR } from './render-table.js'
28
33
 
29
- // 📖 OpenClaw config: ~/.openclaw/openclaw.json (JSON format, may be JSON5 in newer versions)
30
34
  const OPENCLAW_CONFIG = join(homedir(), '.openclaw', 'openclaw.json')
31
35
 
32
- export function loadOpenClawConfig() {
33
- if (!existsSync(OPENCLAW_CONFIG)) return {}
36
+ function getOpenClawConfigPath(options = {}) {
37
+ return options.paths?.openclawConfigPath || OPENCLAW_CONFIG
38
+ }
39
+
40
+ export function loadOpenClawConfig(options = {}) {
41
+ const filePath = getOpenClawConfigPath(options)
42
+ if (!existsSync(filePath)) return {}
34
43
  try {
35
- // 📖 JSON.parse works for standard JSON; OpenClaw may use JSON5 but base config is valid JSON
36
- return JSON.parse(readFileSync(OPENCLAW_CONFIG, 'utf8'))
44
+ return JSON.parse(readFileSync(filePath, 'utf8'))
37
45
  } catch {
38
46
  return {}
39
47
  }
40
48
  }
41
49
 
42
- export function saveOpenClawConfig(config) {
43
- const dir = join(homedir(), '.openclaw')
44
- if (!existsSync(dir)) {
45
- mkdirSync(dir, { recursive: true })
46
- }
47
- writeFileSync(OPENCLAW_CONFIG, JSON.stringify(config, null, 2))
50
+ export function saveOpenClawConfig(config, options = {}) {
51
+ const filePath = getOpenClawConfigPath(options)
52
+ mkdirSync(dirname(filePath), { recursive: true })
53
+ writeFileSync(filePath, JSON.stringify(config, null, 2))
48
54
  }
49
55
 
50
- // 📖 startOpenClaw: sets the selected NVIDIA NIM model as default in OpenClaw config.
51
- // 📖 Also ensures the nvidia provider block is present with the NIM base URL.
52
- // 📖 Does NOT launch OpenClaw — OpenClaw runs as a daemon, so config changes are picked up on restart.
53
- export async function startOpenClaw(model, apiKey) {
56
+ /**
57
+ * 📖 startOpenClaw installs the selected provider/model into OpenClaw and sets
58
+ * 📖 it as the primary default model. OpenClaw itself is not launched here.
59
+ *
60
+ * @param {{ providerKey: string, modelId: string, label: string }} model
61
+ * @param {Record<string, unknown>} config
62
+ * @param {{ paths?: { openclawConfigPath?: string } }} [options]
63
+ * @returns {Promise<ReturnType<typeof installProviderEndpoints> | null>}
64
+ */
65
+ export async function startOpenClaw(model, config, options = {}) {
66
+ const providerRgb = PROVIDER_COLOR[model.providerKey] ?? [105, 190, 245]
67
+ const coloredProviderName = chalk.bold.rgb(...providerRgb)(model.providerKey)
54
68
  console.log(chalk.rgb(255, 100, 50)(` 🦞 Setting ${chalk.bold(model.label)} as OpenClaw default…`))
55
- console.log(chalk.dim(` Model: nvidia/${model.modelId}`))
69
+ console.log(chalk.dim(` Provider: ${coloredProviderName}`))
70
+ console.log(chalk.dim(` Model: ${model.providerKey}/${model.modelId}`))
56
71
  console.log()
57
72
 
58
- const config = loadOpenClawConfig()
59
-
60
- // 📖 Backup existing config before touching it
61
- if (existsSync(OPENCLAW_CONFIG)) {
62
- const backupPath = `${OPENCLAW_CONFIG}.backup-${Date.now()}`
63
- copyFileSync(OPENCLAW_CONFIG, backupPath)
64
- console.log(chalk.dim(` 💾 Backup: ${backupPath}`))
65
- }
66
-
67
- // 📖 Patch models.json to add all NVIDIA models (fixes "not allowed" errors)
68
- const patchResult = patchOpenClawModelsJson()
69
- if (patchResult.wasPatched) {
70
- console.log(chalk.dim(` ✨ Added ${patchResult.added} NVIDIA models to allowlist (${patchResult.total} total)`))
71
- if (patchResult.backup) {
72
- console.log(chalk.dim(` 💾 models.json backup: ${patchResult.backup}`))
73
- }
74
- }
75
-
76
- // 📖 Ensure models.providers section exists with nvidia NIM block.
77
- // 📖 Per OpenClaw docs (docs.openclaw.ai/providers/nvidia), providers MUST be nested under
78
- // 📖 "models.providers", NOT at the config root. Root-level "providers" is ignored by OpenClaw.
79
- // 📖 API key is NOT stored in the provider block — it's read from env var NVIDIA_API_KEY.
80
- // 📖 If needed, it can be stored under the root "env" key: { env: { NVIDIA_API_KEY: "nvapi-..." } }
81
- if (!config.models) config.models = {}
82
- if (!config.models.providers) config.models.providers = {}
83
- if (!config.models.providers.nvidia) {
84
- config.models.providers.nvidia = {
85
- baseUrl: 'https://integrate.api.nvidia.com/v1',
86
- api: 'openai-completions',
87
- models: [],
88
- }
89
- // 📖 Color provider name the same way as in the main table
90
- const providerRgb = PROVIDER_COLOR['nvidia'] ?? [105, 190, 245]
91
- const coloredProviderName = chalk.bold.rgb(...providerRgb)('nvidia')
92
- console.log(chalk.dim(` ➕ Added ${coloredProviderName} provider block to OpenClaw config (models.providers.nvidia)`))
93
- }
94
- // 📖 Ensure models array exists even if the provider block was created by an older version
95
- if (!Array.isArray(config.models.providers.nvidia.models)) {
96
- config.models.providers.nvidia.models = []
97
- }
73
+ try {
74
+ const result = installProviderEndpoints(config, model.providerKey, 'openclaw', {
75
+ scope: 'selected',
76
+ modelIds: [model.modelId],
77
+ track: false,
78
+ paths: options.paths,
79
+ })
98
80
 
99
- // 📖 Store API key in the root "env" section so OpenClaw can read it as NVIDIA_API_KEY env var.
100
- // 📖 Only writes if not already set to avoid overwriting an existing key.
101
- const resolvedKey = apiKey || process.env.NVIDIA_API_KEY
102
- if (resolvedKey) {
103
- if (!config.env) config.env = {}
104
- if (!config.env.NVIDIA_API_KEY) {
105
- config.env.NVIDIA_API_KEY = resolvedKey
106
- console.log(chalk.dim(' 🔑 Stored NVIDIA_API_KEY in config env section'))
107
- }
81
+ const providerEnvName = ENV_VAR_NAMES[model.providerKey]
82
+ console.log(chalk.rgb(255, 140, 0)(` ✓ Default model set to: ${result.primaryModelRef || `${result.providerId}/${model.modelId}`}`))
83
+ console.log()
84
+ console.log(chalk.dim(` 📄 Config updated: ${result.path}`))
85
+ if (result.backupPath) console.log(chalk.dim(` 💾 Backup: ${result.backupPath}`))
86
+ if (providerEnvName) console.log(chalk.dim(` 🔑 API key synced under config env.${providerEnvName}`))
87
+ 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()
91
+ return result
92
+ } catch (error) {
93
+ console.log(chalk.red(` X Could not configure OpenClaw: ${error instanceof Error ? error.message : String(error)}`))
94
+ console.log()
95
+ return null
108
96
  }
109
-
110
- // 📖 Set as the default primary model for all agents.
111
- // 📖 Format: "provider/model-id" — e.g. "nvidia/deepseek-ai/deepseek-v3.2"
112
- if (!config.agents) config.agents = {}
113
- if (!config.agents.defaults) config.agents.defaults = {}
114
- if (!config.agents.defaults.model) config.agents.defaults.model = {}
115
- config.agents.defaults.model.primary = `nvidia/${model.modelId}`
116
-
117
- // 📖 REQUIRED: OpenClaw requires the model to be explicitly listed in agents.defaults.models
118
- // 📖 (the allowlist). Without this entry, OpenClaw rejects the model with "not allowed".
119
- // 📖 See: https://docs.openclaw.ai/gateway/configuration-reference
120
- if (!config.agents.defaults.models) config.agents.defaults.models = {}
121
- config.agents.defaults.models[`nvidia/${model.modelId}`] = {}
122
-
123
- saveOpenClawConfig(config)
124
-
125
- console.log(chalk.rgb(255, 140, 0)(` ✓ Default model set to: nvidia/${model.modelId}`))
126
- console.log()
127
- console.log(chalk.dim(' 📄 Config updated: ' + OPENCLAW_CONFIG))
128
- console.log()
129
- // 📖 "openclaw restart" does NOT exist. The gateway auto-reloads on config file changes.
130
- // 📖 To apply manually: use "openclaw models set" or "openclaw configure"
131
- // 📖 See: https://docs.openclaw.ai/gateway/configuration
132
- console.log(chalk.dim(' 💡 OpenClaw will reload config automatically (gateway.reload.mode).'))
133
- console.log(chalk.dim(' To apply manually: openclaw models set nvidia/' + model.modelId))
134
- console.log(chalk.dim(' Or run the setup wizard: openclaw configure'))
135
- console.log()
136
97
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @file src/opencode-config.js
3
+ * @description Small filesystem helpers for the shared OpenCode config file.
4
+ *
5
+ * @details
6
+ * 📖 The app still needs a stable way to read and write `opencode.json`
7
+ * 📖 for direct OpenCode CLI and Desktop launches.
8
+ * 📖 This module deliberately stays tiny so OpenCode launch code is not
9
+ * 📖 coupled to old bridge-specific sync behavior anymore.
10
+ *
11
+ * @functions
12
+ * → `loadOpenCodeConfig` — read `~/.config/opencode/opencode.json` safely
13
+ * → `saveOpenCodeConfig` — write `opencode.json` with a simple backup
14
+ * → `restoreOpenCodeBackup` — restore the last `.bak` copy if needed
15
+ *
16
+ * @exports loadOpenCodeConfig, saveOpenCodeConfig, restoreOpenCodeBackup
17
+ */
18
+
19
+ import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import { homedir } from 'node:os'
22
+
23
+ const OPENCODE_CONFIG_DIR = join(homedir(), '.config', 'opencode')
24
+ const OPENCODE_CONFIG_PATH = join(OPENCODE_CONFIG_DIR, 'opencode.json')
25
+ const OPENCODE_BACKUP_PATH = join(OPENCODE_CONFIG_DIR, 'opencode.json.bak')
26
+
27
+ export function loadOpenCodeConfig() {
28
+ try {
29
+ if (existsSync(OPENCODE_CONFIG_PATH)) {
30
+ return JSON.parse(readFileSync(OPENCODE_CONFIG_PATH, 'utf8'))
31
+ }
32
+ } catch {}
33
+ return {}
34
+ }
35
+
36
+ export function saveOpenCodeConfig(config) {
37
+ mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true })
38
+ if (existsSync(OPENCODE_CONFIG_PATH)) {
39
+ copyFileSync(OPENCODE_CONFIG_PATH, OPENCODE_BACKUP_PATH)
40
+ }
41
+ writeFileSync(OPENCODE_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n')
42
+ }
43
+
44
+ export function restoreOpenCodeBackup() {
45
+ if (!existsSync(OPENCODE_BACKUP_PATH)) return false
46
+ copyFileSync(OPENCODE_BACKUP_PATH, OPENCODE_CONFIG_PATH)
47
+ return true
48
+ }