free-coding-models 0.2.15 → 0.3.0

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.
@@ -13,11 +13,21 @@
13
13
  * - it merges into existing config files instead of replacing them
14
14
  * - it records successful installs in `~/.free-coding-models.json` so catalogs can be refreshed automatically later
15
15
  *
16
+ * 📖 Connection modes:
17
+ * - `direct` — connect the tool straight to the provider API endpoint (no proxy)
18
+ * - `proxy` — route through the local FCM proxy (key rotation + usage tracking)
19
+ *
16
20
  * 📖 Tool-specific notes:
17
21
  * - OpenCode CLI and OpenCode Desktop share the same `opencode.json`
18
22
  * - Crush gets a managed provider block in `crush.json`
19
23
  * - Goose gets a declarative custom provider JSON + a matching secret in `secrets.yaml`
20
24
  * - OpenClaw gets a managed `models.providers` entry plus matching allowlist rows
25
+ * - Pi gets models.json + settings.json under ~/.pi/agent/
26
+ * - Aider gets ~/.aider.conf.yml with OpenAI-compatible config
27
+ * - Amp gets ~/.config/amp/settings.json
28
+ * - Gemini gets ~/.gemini/settings.json
29
+ * - Qwen gets ~/.qwen/settings.json with modelProviders
30
+ * - Claude Code, Codex, OpenHands get a sourceable env file (~/.fcm-{tool}-env)
21
31
  *
22
32
  * @functions
23
33
  * → `getConfiguredInstallableProviders` — list configured providers that support direct endpoint installs
@@ -39,12 +49,19 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
39
49
  import { homedir } from 'node:os'
40
50
  import { dirname, join } from 'node:path'
41
51
  import { MODELS, sources } from '../sources.js'
42
- import { getApiKey, saveConfig } from './config.js'
52
+ import { getApiKey, saveConfig, getProxySettings, loadConfig } from './config.js'
43
53
  import { ENV_VAR_NAMES, PROVIDER_METADATA } from './provider-metadata.js'
44
54
  import { getToolMeta } from './tool-metadata.js'
45
55
 
46
56
  const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set(['replicate', 'zai'])
47
- const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'openclaw', 'crush', 'goose']
57
+ // 📖 All supported install targets matches TOOL_MODE_ORDER in tool-metadata.js
58
+ const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'openclaw', 'crush', 'goose', 'pi', 'aider', 'claude-code', 'codex', 'gemini', 'qwen', 'openhands', 'amp']
59
+
60
+ // 📖 Connection modes: direct (pure provider) vs FCM proxy (rotates keys)
61
+ export const CONNECTION_MODES = [
62
+ { key: 'direct', label: 'Direct Provider', hint: 'Connect the tool straight to the provider API — no proxy involved.' },
63
+ { key: 'proxy', label: 'FCM Proxy V2', hint: 'Route through FCM Proxy V2 with key rotation and usage tracking.' },
64
+ ]
48
65
 
49
66
  function getDefaultPaths() {
50
67
  const home = homedir()
@@ -54,6 +71,12 @@ function getDefaultPaths() {
54
71
  crushConfigPath: join(home, '.config', 'crush', 'crush.json'),
55
72
  gooseProvidersDir: join(home, '.config', 'goose', 'custom_providers'),
56
73
  gooseSecretsPath: join(home, '.config', 'goose', 'secrets.yaml'),
74
+ piModelsPath: join(home, '.pi', 'agent', 'models.json'),
75
+ piSettingsPath: join(home, '.pi', 'agent', 'settings.json'),
76
+ aiderConfigPath: join(home, '.aider.conf.yml'),
77
+ ampConfigPath: join(home, '.config', 'amp', 'settings.json'),
78
+ geminiConfigPath: join(home, '.gemini', 'settings.json'),
79
+ qwenConfigPath: join(home, '.qwen', 'settings.json'),
57
80
  }
58
81
  }
59
82
 
@@ -374,8 +397,156 @@ function installIntoOpenClaw(providerKey, models, apiKey, paths) {
374
397
  return { path: filePath, backupPath, providerId, modelCount: models.length }
375
398
  }
376
399
 
400
+ // 📖 installIntoPi writes models.json + settings.json under ~/.pi/agent/
401
+ function installIntoPi(providerKey, models, apiKey, paths) {
402
+ const providerId = getManagedProviderId(providerKey)
403
+ const baseUrl = resolveProviderBaseUrl(providerKey)
404
+
405
+ // 📖 Write models.json with provider config
406
+ const modelsConfig = readJson(paths.piModelsPath, { providers: {} })
407
+ if (!modelsConfig.providers || typeof modelsConfig.providers !== 'object') modelsConfig.providers = {}
408
+ modelsConfig.providers[providerId] = {
409
+ baseUrl,
410
+ api: 'openai-completions',
411
+ apiKey,
412
+ models: models.map((model) => ({ id: model.modelId, name: model.label })),
413
+ }
414
+ const modelsBackupPath = writeJson(paths.piModelsPath, modelsConfig)
415
+
416
+ // 📖 Write settings.json to set default provider
417
+ const settingsConfig = readJson(paths.piSettingsPath, {})
418
+ settingsConfig.defaultProvider = providerId
419
+ settingsConfig.defaultModel = models[0]?.modelId ?? ''
420
+ writeJson(paths.piSettingsPath, settingsConfig, { backup: true })
421
+
422
+ return { path: paths.piModelsPath, backupPath: modelsBackupPath, providerId, modelCount: models.length }
423
+ }
424
+
425
+ // 📖 installIntoAider writes ~/.aider.conf.yml with OpenAI-compatible config
426
+ function installIntoAider(providerKey, models, apiKey, paths) {
427
+ const providerId = getManagedProviderId(providerKey)
428
+ const baseUrl = resolveProviderBaseUrl(providerKey)
429
+ const backupPath = backupIfExists(paths.aiderConfigPath)
430
+ // 📖 Aider YAML config — one model at a time, uses first selected model
431
+ const primaryModel = models[0]
432
+ const lines = [
433
+ '# 📖 Managed by free-coding-models',
434
+ `openai-api-base: ${baseUrl}`,
435
+ `openai-api-key: ${apiKey}`,
436
+ `model: openai/${primaryModel.modelId}`,
437
+ '',
438
+ ]
439
+ ensureDirFor(paths.aiderConfigPath)
440
+ writeFileSync(paths.aiderConfigPath, lines.join('\n'))
441
+ return { path: paths.aiderConfigPath, backupPath, providerId, modelCount: models.length }
442
+ }
443
+
444
+ // 📖 installIntoAmp writes ~/.config/amp/settings.json with model+URL
445
+ function installIntoAmp(providerKey, models, apiKey, paths) {
446
+ const providerId = getManagedProviderId(providerKey)
447
+ const baseUrl = resolveProviderBaseUrl(providerKey)
448
+ const config = readJson(paths.ampConfigPath, {})
449
+ config['amp.url'] = baseUrl
450
+ config['amp.model'] = models[0]?.modelId ?? ''
451
+ const backupPath = writeJson(paths.ampConfigPath, config)
452
+ return { path: paths.ampConfigPath, backupPath, providerId, modelCount: models.length }
453
+ }
454
+
455
+ // 📖 installIntoGemini writes ~/.gemini/settings.json with model ID
456
+ function installIntoGemini(providerKey, models, apiKey, paths) {
457
+ const providerId = getManagedProviderId(providerKey)
458
+ const config = readJson(paths.geminiConfigPath, {})
459
+ config.model = models[0]?.modelId ?? ''
460
+ const backupPath = writeJson(paths.geminiConfigPath, config)
461
+ return { path: paths.geminiConfigPath, backupPath, providerId, modelCount: models.length }
462
+ }
463
+
464
+ // 📖 installIntoQwen writes ~/.qwen/settings.json with modelProviders config
465
+ function installIntoQwen(providerKey, models, apiKey, paths) {
466
+ const providerId = getManagedProviderId(providerKey)
467
+ const baseUrl = resolveProviderBaseUrl(providerKey)
468
+ const config = readJson(paths.qwenConfigPath, {})
469
+ if (!config.modelProviders || typeof config.modelProviders !== 'object') config.modelProviders = {}
470
+ if (!Array.isArray(config.modelProviders.openai)) config.modelProviders.openai = []
471
+
472
+ // 📖 Remove existing FCM-managed entries, then prepend all selected models
473
+ const filtered = config.modelProviders.openai.filter(
474
+ (entry) => !models.some((m) => m.modelId === entry?.id)
475
+ )
476
+ const newEntries = models.map((model) => ({
477
+ id: model.modelId,
478
+ name: model.label,
479
+ envKey: ENV_VAR_NAMES[providerKey] || 'OPENAI_API_KEY',
480
+ baseUrl,
481
+ }))
482
+ config.modelProviders.openai = [...newEntries, ...filtered]
483
+ config.model = models[0]?.modelId ?? ''
484
+ const backupPath = writeJson(paths.qwenConfigPath, config)
485
+ return { path: paths.qwenConfigPath, backupPath, providerId, modelCount: models.length }
486
+ }
487
+
488
+ // 📖 installIntoEnvBasedTool handles tools that rely on env vars only (claude-code, codex, openhands).
489
+ // 📖 We write a small .env-style helper file so users can source it before launching.
490
+ // 📖 When connectionMode is 'proxy', writes env vars pointing to the daemon's stable port/token.
491
+ function installIntoEnvBasedTool(providerKey, models, apiKey, toolMode, paths, connectionMode = 'direct') {
492
+ const providerId = getManagedProviderId(providerKey)
493
+ const home = homedir()
494
+ const envFileName = `.fcm-${toolMode}-env`
495
+ const envFilePath = join(home, envFileName)
496
+ const primaryModel = models[0]
497
+
498
+ // 📖 Resolve effective API key, base URL, and model ID based on connection mode
499
+ let effectiveApiKey = apiKey
500
+ let effectiveBaseUrl = resolveProviderBaseUrl(providerKey)
501
+ let effectiveModelId = primaryModel.modelId
502
+
503
+ if (connectionMode === 'proxy') {
504
+ // 📖 Read stable proxy settings from config for daemon-compatible env files
505
+ try {
506
+ const cfg = loadConfig()
507
+ const proxySettings = getProxySettings(cfg)
508
+ effectiveApiKey = proxySettings.stableToken || apiKey
509
+ const port = proxySettings.preferredPort || 18045
510
+ effectiveBaseUrl = `http://127.0.0.1:${port}/v1`
511
+ } catch { /* fallback to direct values */ }
512
+ }
513
+
514
+ const envLines = [
515
+ '# 📖 Managed by free-coding-models — source this file before launching the tool',
516
+ `# 📖 Provider: ${getProviderLabel(providerKey)} (${models.length} models)`,
517
+ `# 📖 Connection: ${connectionMode === 'proxy' ? 'FCM Proxy V2 (background service)' : 'Direct provider'}`,
518
+ `export OPENAI_API_KEY="${effectiveApiKey}"`,
519
+ `export OPENAI_BASE_URL="${effectiveBaseUrl}"`,
520
+ `export OPENAI_MODEL="${effectiveModelId}"`,
521
+ `export LLM_API_KEY="${effectiveApiKey}"`,
522
+ `export LLM_BASE_URL="${effectiveBaseUrl}"`,
523
+ `export LLM_MODEL="openai/${effectiveModelId}"`,
524
+ ]
525
+
526
+ // 📖 Claude Code: Anthropic-specific env vars pointing to proxy /v1/messages endpoint
527
+ if (toolMode === 'claude-code') {
528
+ if (connectionMode === 'proxy') {
529
+ // 📖 Point to proxy base (not /v1) — Claude Code adds /v1/messages itself
530
+ const proxyBase = effectiveBaseUrl.replace(/\/v1$/, '')
531
+ envLines.push(`export ANTHROPIC_API_KEY="${effectiveApiKey}"`)
532
+ envLines.push(`export ANTHROPIC_BASE_URL="${proxyBase}"`)
533
+ envLines.push(`export ANTHROPIC_MODEL="${effectiveModelId}"`)
534
+ } else {
535
+ envLines.push(`export ANTHROPIC_AUTH_TOKEN="${effectiveApiKey}"`)
536
+ envLines.push(`export ANTHROPIC_BASE_URL="${effectiveBaseUrl}"`)
537
+ envLines.push(`export ANTHROPIC_MODEL="${effectiveModelId}"`)
538
+ }
539
+ }
540
+
541
+ ensureDirFor(envFilePath)
542
+ const backupPath = backupIfExists(envFilePath)
543
+ writeFileSync(envFilePath, envLines.join('\n') + '\n')
544
+ return { path: envFilePath, backupPath, providerId, modelCount: models.length }
545
+ }
546
+
377
547
  export function installProviderEndpoints(config, providerKey, toolMode, options = {}) {
378
548
  const canonicalToolMode = canonicalizeToolMode(toolMode)
549
+ const connectionMode = options.connectionMode || 'direct'
379
550
  const support = getDirectInstallSupport(providerKey)
380
551
  if (!support.supported) {
381
552
  throw new Error(support.reason || 'Direct install is not supported for this provider')
@@ -389,6 +560,7 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
389
560
  }
390
561
 
391
562
  const paths = { ...getDefaultPaths(), ...(options.paths || {}) }
563
+ // 📖 Dispatch to the right installer based on canonical tool mode
392
564
  let installResult
393
565
  if (canonicalToolMode === 'opencode') {
394
566
  installResult = installIntoOpenCode(providerKey, models, apiKey, paths)
@@ -398,6 +570,18 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
398
570
  installResult = installIntoCrush(providerKey, models, apiKey, paths)
399
571
  } else if (canonicalToolMode === 'goose') {
400
572
  installResult = installIntoGoose(providerKey, models, apiKey, paths)
573
+ } else if (canonicalToolMode === 'pi') {
574
+ installResult = installIntoPi(providerKey, models, apiKey, paths)
575
+ } else if (canonicalToolMode === 'aider') {
576
+ installResult = installIntoAider(providerKey, models, apiKey, paths)
577
+ } else if (canonicalToolMode === 'amp') {
578
+ installResult = installIntoAmp(providerKey, models, apiKey, paths)
579
+ } else if (canonicalToolMode === 'gemini') {
580
+ installResult = installIntoGemini(providerKey, models, apiKey, paths)
581
+ } else if (canonicalToolMode === 'qwen') {
582
+ installResult = installIntoQwen(providerKey, models, apiKey, paths)
583
+ } else if (canonicalToolMode === 'claude-code' || canonicalToolMode === 'codex' || canonicalToolMode === 'openhands') {
584
+ installResult = installIntoEnvBasedTool(providerKey, models, apiKey, canonicalToolMode, paths, connectionMode)
401
585
  } else {
402
586
  throw new Error(`Unsupported install target: ${toolMode}`)
403
587
  }
@@ -414,6 +598,7 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
414
598
  providerKey,
415
599
  providerLabel: getProviderLabel(providerKey),
416
600
  scope,
601
+ connectionMode,
417
602
  autoRefreshEnabled: true,
418
603
  models,
419
604
  }