free-coding-models 0.3.25 → 0.3.28

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,636 @@
1
+ /**
2
+ * @file src/installed-models-manager.js
3
+ * @description Scan, parse, and manage models configured in external tool configs.
4
+ *
5
+ * @details
6
+ * 📖 This module provides functions to:
7
+ * - Scan all supported tool configs for installed models
8
+ * - Parse tool-specific config files (YAML, JSON)
9
+ * - Soft-delete models with backup to ~/.free-coding-models-backups.json
10
+ * - Launch tools with selected models
11
+ * - Reinstall FCM endpoints for providers
12
+ *
13
+ * 📖 Supported tools:
14
+ * - Goose (~/.config/goose/config.yaml + custom_providers/*.json)
15
+ * - Crush (~/.config/crush/crush.json)
16
+ * - Aider (~/.aider.conf.yml)
17
+ * - Qwen (~/.qwen/settings.json)
18
+ * - Pi (~/.pi/agent/models.json + settings.json)
19
+ * - OpenHands (~/.fcm-openhands-env)
20
+ * - Amp (~/.config/amp/settings.json)
21
+ *
22
+ * 📖 Backup system:
23
+ * - Disabled models are saved to ~/.free-coding-models-backups.json
24
+ * - Each entry includes: toolMode, modelId, originalConfig, configPath, disabledAt
25
+ *
26
+ * @functions
27
+ * → scanAllToolConfigs — Scan all tool configs and return structured results
28
+ * → parseToolConfig — Parse a specific tool's config file
29
+ * → parseGooseConfig — Parse Goose YAML config
30
+ * → parseCrushConfig — Parse Crush JSON config
31
+ * → parseAiderConfig — Parse Aider YAML config
32
+ * → parseQwenConfig — Parse Qwen JSON config
33
+ * → parsePiConfig — Parse Pi JSON configs
34
+ * → parseOpenHandsConfig — Parse OpenHands env file
35
+ * → parseAmpConfig — Parse Amp JSON config
36
+ * → softDeleteModel — Remove model from config with backup
37
+ * → launchToolWithModel — Launch tool with specific model
38
+ * → reinstallEndpoint — Reinstall FCM endpoint for provider
39
+ *
40
+ * @exports scanAllToolConfigs, softDeleteModel, launchToolWithModel, reinstallEndpoint
41
+ *
42
+ * @see src/tool-launchers.js — for launch functions
43
+ * @see src/endpoint-installer.js — for reinstall logic
44
+ */
45
+
46
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
47
+ import { homedir } from 'node:os'
48
+ import { join, dirname } from 'node:path'
49
+ import { sources } from '../sources.js'
50
+
51
+ const BACKUP_PATH = join(homedir(), '.free-coding-models-backups.json')
52
+
53
+ /**
54
+ * 📖 Get tool config paths
55
+ */
56
+ function getToolConfigPaths(homeDir = homedir()) {
57
+ return {
58
+ goose: join(homeDir, '.config', 'goose', 'config.yaml'),
59
+ crush: join(homeDir, '.config', 'crush', 'crush.json'),
60
+ aider: join(homeDir, '.aider.conf.yml'),
61
+ qwen: join(homeDir, '.qwen', 'settings.json'),
62
+ piModels: join(homeDir, '.pi', 'agent', 'models.json'),
63
+ piSettings: join(homeDir, '.pi', 'agent', 'settings.json'),
64
+ openHands: join(homeDir, '.fcm-openhands-env'),
65
+ amp: join(homeDir, '.config', 'amp', 'settings.json'),
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 📖 Simple YAML parser for Goose and Aider configs
71
+ * Handles basic key: value and multiline strings
72
+ */
73
+ function parseSimpleYaml(filePath) {
74
+ if (!existsSync(filePath)) return null
75
+ try {
76
+ const content = readFileSync(filePath, 'utf8')
77
+ const result = {}
78
+ let currentKey = null
79
+
80
+ for (const line of content.split('\n')) {
81
+ const trimmed = line.trim()
82
+ if (!trimmed || trimmed.startsWith('#')) continue
83
+
84
+ const colonIndex = trimmed.indexOf(':')
85
+ if (colonIndex === -1) {
86
+ if (currentKey && result[currentKey] !== undefined) {
87
+ result[currentKey] += '\n' + trimmed
88
+ }
89
+ continue
90
+ }
91
+
92
+ currentKey = trimmed.slice(0, colonIndex).trim()
93
+ const value = trimmed.slice(colonIndex + 1).trim()
94
+
95
+ if (value === '' || value === '|') {
96
+ result[currentKey] = ''
97
+ } else if (value.startsWith('"') || value.startsWith("'")) {
98
+ result[currentKey] = value.slice(1, -1)
99
+ } else {
100
+ result[currentKey] = value
101
+ }
102
+ }
103
+
104
+ return result
105
+ } catch (err) {
106
+ return null
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 📖 Parse Goose config for GOOSE_MODEL
112
+ */
113
+ function parseGooseConfig(paths = getToolConfigPaths()) {
114
+ const configPath = paths.goose
115
+ if (!existsSync(configPath)) {
116
+ return { isValid: false, models: [], configPath }
117
+ }
118
+
119
+ try {
120
+ const yaml = parseSimpleYaml(configPath)
121
+ if (!yaml) {
122
+ return { isValid: false, models: [], configPath }
123
+ }
124
+
125
+ const gooseModel = yaml['GOOSE_MODEL']
126
+ const gooseProvider = yaml['GOOSE_PROVIDER']
127
+
128
+ const models = []
129
+ if (gooseModel) {
130
+ models.push({
131
+ modelId: gooseModel,
132
+ label: gooseModel,
133
+ tier: '-',
134
+ sweScore: '-',
135
+ providerKey: 'external',
136
+ isExternal: true,
137
+ canLaunch: true,
138
+ })
139
+ }
140
+
141
+ return {
142
+ isValid: true,
143
+ hasManagedMarker: yaml['GOOSE_PROVIDER']?.startsWith('fcm-'),
144
+ models,
145
+ configPath,
146
+ }
147
+ } catch (err) {
148
+ return { isValid: false, models: [], configPath }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 📖 Parse Crush config for models.large/small
154
+ */
155
+ function parseCrushConfig(paths = getToolConfigPaths()) {
156
+ const configPath = paths.crush
157
+ if (!existsSync(configPath)) {
158
+ return { isValid: false, models: [], configPath }
159
+ }
160
+
161
+ try {
162
+ const content = readFileSync(configPath, 'utf8')
163
+ const config = JSON.parse(content)
164
+
165
+ const models = []
166
+ // Extract models from providers section
167
+ if (config.providers) {
168
+ for (const providerKey in config.providers) {
169
+ const provider = config.providers[providerKey]
170
+ if (provider.models) {
171
+ for (const model of provider.models) {
172
+ models.push({
173
+ modelId: model.id,
174
+ label: model.name || model.id,
175
+ tier: '-',
176
+ sweScore: '-',
177
+ providerKey: 'external',
178
+ isExternal: true,
179
+ canLaunch: true,
180
+ })
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ // Extract models from models section (large/small)
187
+ if (config.models?.large?.model) {
188
+ models.push({
189
+ modelId: config.models.large.model,
190
+ label: `${config.models.large.model} (large)`,
191
+ tier: '-',
192
+ sweScore: '-',
193
+ providerKey: 'external',
194
+ isExternal: true,
195
+ canLaunch: true,
196
+ })
197
+ }
198
+ if (config.models?.small?.model) {
199
+ models.push({
200
+ modelId: config.models.small.model,
201
+ label: `${config.models.small.model} (small)`,
202
+ tier: '-',
203
+ sweScore: '-',
204
+ providerKey: 'external',
205
+ isExternal: true,
206
+ canLaunch: true,
207
+ })
208
+ }
209
+ if (config.models?.small?.model) {
210
+ models.push({
211
+ modelId: config.models.small.model + '-small',
212
+ label: `${config.models.small.model} (small)`,
213
+ tier: '-',
214
+ sweScore: '-',
215
+ providerKey: 'external',
216
+ isExternal: true,
217
+ canLaunch: true,
218
+ })
219
+ }
220
+
221
+ return {
222
+ isValid: true,
223
+ hasManagedMarker: config.providers?.freeCodingModels !== undefined,
224
+ models,
225
+ configPath,
226
+ }
227
+ } catch (err) {
228
+ return { isValid: false, models: [], configPath }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * 📖 Parse Aider config for model
234
+ */
235
+ function parseAiderConfig(paths = getToolConfigPaths()) {
236
+ const configPath = paths.aider
237
+ if (!existsSync(configPath)) {
238
+ return { isValid: false, models: [], configPath }
239
+ }
240
+
241
+ try {
242
+ const yaml = parseSimpleYaml(configPath)
243
+ if (!yaml) {
244
+ return { isValid: false, models: [], configPath }
245
+ }
246
+
247
+ const models = []
248
+ const aiderModel = yaml['model']
249
+ if (aiderModel) {
250
+ const modelId = aiderModel.startsWith('openai/') ? aiderModel.slice(7) : aiderModel
251
+ models.push({
252
+ modelId,
253
+ label: modelId,
254
+ tier: '-',
255
+ sweScore: '-',
256
+ providerKey: 'external',
257
+ isExternal: true,
258
+ canLaunch: true,
259
+ })
260
+ }
261
+
262
+ return {
263
+ isValid: true,
264
+ hasManagedMarker: yaml['openai-api-base']?.includes('build.nvidia.com') || false,
265
+ models,
266
+ configPath,
267
+ }
268
+ } catch (err) {
269
+ return { isValid: false, models: [], configPath }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * 📖 Parse Qwen config for model
275
+ */
276
+ function parseQwenConfig(paths = getToolConfigPaths()) {
277
+ const configPath = paths.qwen
278
+ if (!existsSync(configPath)) {
279
+ return { isValid: false, models: [], configPath }
280
+ }
281
+
282
+ try {
283
+ const content = readFileSync(configPath, 'utf8')
284
+ const config = JSON.parse(content)
285
+
286
+ const models = []
287
+ if (config.model) {
288
+ models.push({
289
+ modelId: config.model,
290
+ label: config.model,
291
+ tier: '-',
292
+ sweScore: '-',
293
+ providerKey: 'external',
294
+ isExternal: true,
295
+ canLaunch: true,
296
+ })
297
+ }
298
+
299
+ return {
300
+ isValid: true,
301
+ hasManagedMarker: Array.isArray(config.modelProviders?.openai) && config.modelProviders.openai.length > 0,
302
+ models,
303
+ configPath,
304
+ }
305
+ } catch (err) {
306
+ return { isValid: false, models: [], configPath }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * 📖 Parse Pi configs for defaultModel
312
+ */
313
+ function parsePiConfig(paths = getToolConfigPaths()) {
314
+ const settingsPath = paths.piSettings
315
+ if (!existsSync(settingsPath)) {
316
+ return { isValid: false, models: [], configPath: settingsPath }
317
+ }
318
+
319
+ try {
320
+ const content = readFileSync(settingsPath, 'utf8')
321
+ const config = JSON.parse(content)
322
+
323
+ const models = []
324
+ if (config.defaultModel && config.defaultProvider) {
325
+ models.push({
326
+ modelId: config.defaultModel,
327
+ label: config.defaultModel,
328
+ tier: '-',
329
+ sweScore: '-',
330
+ providerKey: 'external',
331
+ isExternal: true,
332
+ canLaunch: true,
333
+ })
334
+ }
335
+
336
+ return {
337
+ isValid: true,
338
+ hasManagedMarker: config.defaultProvider === 'freeCodingModels',
339
+ models,
340
+ configPath: settingsPath,
341
+ }
342
+ } catch (err) {
343
+ return { isValid: false, models: [], configPath: settingsPath }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * 📖 Parse OpenHands env file for LLM_MODEL
349
+ */
350
+ function parseOpenHandsConfig(paths = getToolConfigPaths()) {
351
+ const configPath = paths.openHands
352
+ if (!existsSync(configPath)) {
353
+ return { isValid: false, models: [], configPath }
354
+ }
355
+
356
+ try {
357
+ const content = readFileSync(configPath, 'utf8')
358
+ const models = []
359
+
360
+ for (const line of content.split('\n')) {
361
+ const trimmed = line.trim()
362
+ if (trimmed.startsWith('export LLM_MODEL="') || trimmed.startsWith('export LLM_MODEL=\'')) {
363
+ const match = trimmed.match(/export LLM_MODEL=(["'])(.*?)\1/)
364
+ if (match) {
365
+ models.push({
366
+ modelId: match[2],
367
+ label: match[2],
368
+ tier: '-',
369
+ sweScore: '-',
370
+ providerKey: 'external',
371
+ isExternal: true,
372
+ canLaunch: true,
373
+ })
374
+ }
375
+ }
376
+ }
377
+
378
+ return {
379
+ isValid: true,
380
+ hasManagedMarker: content.includes('Managed by free-coding-models'),
381
+ models,
382
+ configPath,
383
+ }
384
+ } catch (err) {
385
+ return { isValid: false, models: [], configPath }
386
+ }
387
+ }
388
+
389
+ /**
390
+ * 📖 Parse Amp config for amp.model
391
+ */
392
+ function parseAmpConfig(paths = getToolConfigPaths()) {
393
+ const configPath = paths.amp
394
+ if (!existsSync(configPath)) {
395
+ return { isValid: false, models: [], configPath }
396
+ }
397
+
398
+ try {
399
+ const content = readFileSync(configPath, 'utf8')
400
+ const config = JSON.parse(content)
401
+
402
+ const models = []
403
+ if (config['amp.model']) {
404
+ models.push({
405
+ modelId: config['amp.model'],
406
+ label: config['amp.model'],
407
+ tier: '-',
408
+ sweScore: '-',
409
+ providerKey: 'external',
410
+ isExternal: true,
411
+ canLaunch: true,
412
+ })
413
+ }
414
+
415
+ return {
416
+ isValid: true,
417
+ hasManagedMarker: config['amp.url']?.includes('build.nvidia.com') || false,
418
+ models,
419
+ configPath,
420
+ }
421
+ } catch (err) {
422
+ return { isValid: false, models: [], configPath }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * 📖 Enhance model with metadata from sources.js
428
+ */
429
+ function enhanceModelMetadata(model) {
430
+ const modelId = model.modelId
431
+
432
+ for (const providerKey in sources) {
433
+ const provider = sources[providerKey]
434
+ for (const m of provider.models) {
435
+ if (m[0] === modelId) {
436
+ return {
437
+ ...model,
438
+ label: m[1],
439
+ tier: m[2],
440
+ sweScore: m[3],
441
+ providerKey,
442
+ isExternal: false,
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ return model
449
+ }
450
+
451
+ /**
452
+ * 📖 Parse a specific tool's config
453
+ */
454
+ export function parseToolConfig(toolMode, paths = getToolConfigPaths()) {
455
+ switch (toolMode) {
456
+ case 'goose':
457
+ return parseGooseConfig(paths)
458
+ case 'crush':
459
+ return parseCrushConfig(paths)
460
+ case 'aider':
461
+ return parseAiderConfig(paths)
462
+ case 'qwen':
463
+ return parseQwenConfig(paths)
464
+ case 'pi':
465
+ return parsePiConfig(paths)
466
+ case 'openhands':
467
+ return parseOpenHandsConfig(paths)
468
+ case 'amp':
469
+ return parseAmpConfig(paths)
470
+ default:
471
+ return { isValid: false, models: [], configPath: '' }
472
+ }
473
+ }
474
+
475
+ /**
476
+ * 📖 Scan all tool configs and return structured results
477
+ */
478
+ export function scanAllToolConfigs(paths = getToolConfigPaths()) {
479
+ const toolModes = ['goose', 'crush', 'aider', 'qwen', 'pi', 'openhands', 'amp']
480
+
481
+ return toolModes.map((toolMode) => {
482
+ const result = parseToolConfig(toolMode, paths)
483
+
484
+ return {
485
+ toolMode,
486
+ toolLabel: toolMode.charAt(0).toUpperCase() + toolMode.slice(1),
487
+ toolEmoji: getToolEmoji(toolMode),
488
+ ...result,
489
+ models: result.models.map(enhanceModelMetadata),
490
+ }
491
+ })
492
+ }
493
+
494
+ /**
495
+ * 📖 Get tool emoji
496
+ */
497
+ function getToolEmoji(toolMode) {
498
+ const emojis = {
499
+ goose: '🪿',
500
+ crush: '💘',
501
+ aider: '🛠',
502
+ qwen: '🐉',
503
+ pi: 'π',
504
+ openhands: '🤲',
505
+ amp: '⚡',
506
+ }
507
+ return emojis[toolMode] || '🧰'
508
+ }
509
+
510
+ /**
511
+ * 📖 Load backups from ~/.free-coding-models-backups.json
512
+ */
513
+ function loadBackups() {
514
+ if (!existsSync(BACKUP_PATH)) {
515
+ return { disabledModels: [] }
516
+ }
517
+ try {
518
+ const content = readFileSync(BACKUP_PATH, 'utf8')
519
+ return JSON.parse(content)
520
+ } catch (err) {
521
+ return { disabledModels: [] }
522
+ }
523
+ }
524
+
525
+ /**
526
+ * 📖 Save backups to ~/.free-coding-models-backups.json
527
+ */
528
+ function saveBackups(backups) {
529
+ const dir = dirname(BACKUP_PATH)
530
+ if (!existsSync(dir)) {
531
+ mkdirSync(dir, { recursive: true })
532
+ }
533
+ writeFileSync(BACKUP_PATH, JSON.stringify(backups, null, 2))
534
+ }
535
+
536
+ /**
537
+ * 📖 Soft-delete a model from tool config with backup
538
+ */
539
+ export function softDeleteModel(toolMode, modelId, paths = getToolConfigPaths()) {
540
+ const configPath = paths[toolMode === 'pi' ? 'piSettings' : toolMode]
541
+ if (!existsSync(configPath)) {
542
+ return { success: false, error: 'Config file not found' }
543
+ }
544
+
545
+ try {
546
+ let originalContent = readFileSync(configPath, 'utf8')
547
+ let newContent = originalContent
548
+ let modified = false
549
+
550
+ switch (toolMode) {
551
+ case 'goose':
552
+ if (originalContent.includes(`GOOSE_MODEL: ${modelId}`)) {
553
+ newContent = originalContent.replace(/^GOOSE_MODEL:.*$/m, '# GOOSE_MODEL: (disabled by FCM)\n# GOOSE_MODEL: ' + modelId)
554
+ modified = true
555
+ }
556
+ break
557
+
558
+ case 'crush':
559
+ const crushConfig = JSON.parse(originalContent)
560
+ if (crushConfig.models?.large?.model === modelId || crushConfig.models?.small?.model === modelId) {
561
+ if (crushConfig.models?.large?.model === modelId) {
562
+ delete crushConfig.models.large
563
+ }
564
+ if (crushConfig.models?.small?.model === modelId) {
565
+ delete crushConfig.models.small
566
+ }
567
+ newContent = JSON.stringify(crushConfig, null, 2)
568
+ modified = true
569
+ }
570
+ break
571
+
572
+ case 'aider':
573
+ if (originalContent.includes(`model: openai/${modelId}`)) {
574
+ newContent = originalContent.replace(/^model:.*$/m, '# model: (disabled by FCM)\n# model: openai/' + modelId)
575
+ modified = true
576
+ }
577
+ break
578
+
579
+ case 'qwen':
580
+ const qwenConfig = JSON.parse(originalContent)
581
+ if (qwenConfig.model === modelId) {
582
+ delete qwenConfig.model
583
+ newContent = JSON.stringify(qwenConfig, null, 2)
584
+ modified = true
585
+ }
586
+ break
587
+
588
+ case 'pi':
589
+ const piConfig = JSON.parse(originalContent)
590
+ if (piConfig.defaultModel === modelId) {
591
+ delete piConfig.defaultModel
592
+ newContent = JSON.stringify(piConfig, null, 2)
593
+ modified = true
594
+ }
595
+ break
596
+
597
+ case 'openhands':
598
+ if (originalContent.includes(`export LLM_MODEL="${modelId}"`) || originalContent.includes(`export LLM_MODEL='${modelId}'`)) {
599
+ newContent = originalContent.replace(/^export LLM_MODEL=.*$/m, '# export LLM_MODEL: (disabled by FCM)\n# export LLM_MODEL="' + modelId + '"')
600
+ modified = true
601
+ }
602
+ break
603
+
604
+ case 'amp':
605
+ const ampConfig = JSON.parse(originalContent)
606
+ if (ampConfig['amp.model'] === modelId) {
607
+ delete ampConfig['amp.model']
608
+ newContent = JSON.stringify(ampConfig, null, 2)
609
+ modified = true
610
+ }
611
+ break
612
+ }
613
+
614
+ if (!modified) {
615
+ return { success: false, error: 'Model not found in config' }
616
+ }
617
+
618
+ writeFileSync(configPath, newContent)
619
+
620
+ const backups = loadBackups()
621
+ backups.disabledModels.push({
622
+ id: `${toolMode}-${modelId}-${new Date().toISOString()}`,
623
+ toolMode,
624
+ modelId,
625
+ originalConfig: originalContent,
626
+ configPath,
627
+ disabledAt: new Date().toISOString(),
628
+ reason: 'user_deleted',
629
+ })
630
+ saveBackups(backups)
631
+
632
+ return { success: true }
633
+ } catch (err) {
634
+ return { success: false, error: err.message }
635
+ }
636
+ }