free-coding-models 0.5.86 → 0.5.87

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.
Files changed (163) hide show
  1. package/README.md +110 -881
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/package.json +8 -5
  5. package/sources.js +4 -4
  6. package/src/core/cache.js +3 -1
  7. package/src/core/config.js +43 -5
  8. package/src/core/endpoint-installer.js +23 -12
  9. package/src/core/installed-models-manager.js +43 -20
  10. package/src/core/kilo-config.js +51 -5
  11. package/src/core/kilo.js +5 -3
  12. package/src/core/legacy-proxy-cleanup.js +22 -5
  13. package/src/core/model-merger.js +4 -0
  14. package/src/core/models-drift.js +9 -0
  15. package/src/core/opencode-config.js +57 -5
  16. package/src/core/opencode.js +67 -41
  17. package/src/core/playground.js +7 -0
  18. package/src/core/probe-cache.js +35 -4
  19. package/src/core/provider-key-tester.js +10 -2
  20. package/src/core/provider-metadata.js +10 -0
  21. package/src/core/provider-quota-fetchers.js +14 -7
  22. package/src/core/router-daemon.js +208 -26
  23. package/src/core/runtime-telemetry.js +27 -1
  24. package/src/core/shared-helpers.js +12 -1
  25. package/src/core/shell-env.js +3 -1
  26. package/src/core/telemetry.js +18 -5
  27. package/src/core/tool-launchers.js +9 -6
  28. package/src/core/updater.js +28 -2
  29. package/src/core/utils.js +76 -71
  30. package/src/tui/app.js +38 -14
  31. package/src/tui/command-palette.js +22 -2
  32. package/src/tui/key-handler.js +123 -179
  33. package/src/tui/overlays.js +21 -71
  34. package/src/tui/render-helpers.js +62 -4
  35. package/src/tui/render-table.js +34 -3
  36. package/src/tui/theme.js +36 -5
  37. package/src/tui/tui-filters.js +28 -8
  38. package/src/tui/tui-state.js +22 -6
  39. package/web/dist/assets/index-BpTpPS0g.js +44 -0
  40. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  41. package/web/dist/index.html +5 -9
  42. package/web/index.html +3 -7
  43. package/web/server.js +97 -23
  44. package/web/vite.config.js +8 -0
  45. package/web/app.legacy.js +0 -900
  46. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  47. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  48. package/web/public/favicon.ico +0 -0
  49. package/web/public/favicons/apple-touch-icon.png +0 -0
  50. package/web/public/favicons/browserconfig.xml +0 -12
  51. package/web/public/favicons/favicon-16x16.png +0 -0
  52. package/web/public/favicons/favicon-192x192.png +0 -0
  53. package/web/public/favicons/favicon-32x32.png +0 -0
  54. package/web/public/favicons/favicon-48x48.png +0 -0
  55. package/web/public/favicons/favicon-512x512.png +0 -0
  56. package/web/public/favicons/favicon-96x96.png +0 -0
  57. package/web/public/favicons/favicon.ico +0 -0
  58. package/web/public/favicons/mstile-150x150.png +0 -0
  59. package/web/public/favicons/mstile-310x150.png +0 -0
  60. package/web/public/favicons/mstile-310x310.png +0 -0
  61. package/web/public/favicons/mstile-512x512.png +0 -0
  62. package/web/public/favicons/mstile-70x70.png +0 -0
  63. package/web/public/favicons/site.webmanifest +0 -25
  64. package/web/src/App.jsx +0 -521
  65. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  66. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  67. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  68. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  69. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  70. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  71. package/web/src/components/atoms/HealthCell.jsx +0 -143
  72. package/web/src/components/atoms/HealthCell.module.css +0 -60
  73. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  74. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  75. package/web/src/components/atoms/MoodCell.jsx +0 -25
  76. package/web/src/components/atoms/MoodCell.module.css +0 -6
  77. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  78. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  79. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  80. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  81. package/web/src/components/atoms/RankCell.jsx +0 -9
  82. package/web/src/components/atoms/RankCell.module.css +0 -9
  83. package/web/src/components/atoms/Sparkline.jsx +0 -63
  84. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  85. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  86. package/web/src/components/atoms/StatusDot.jsx +0 -13
  87. package/web/src/components/atoms/StatusDot.module.css +0 -29
  88. package/web/src/components/atoms/TPSCell.jsx +0 -36
  89. package/web/src/components/atoms/TPSCell.module.css +0 -44
  90. package/web/src/components/atoms/TierBadge.jsx +0 -10
  91. package/web/src/components/atoms/TierBadge.module.css +0 -29
  92. package/web/src/components/atoms/Toast.jsx +0 -25
  93. package/web/src/components/atoms/Toast.module.css +0 -41
  94. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  95. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  96. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  97. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  98. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  99. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  100. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  101. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  102. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  103. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  104. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  105. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  106. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  107. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  108. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  109. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  110. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  111. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  112. package/web/src/components/help/HelpView.jsx +0 -201
  113. package/web/src/components/help/HelpView.module.css +0 -156
  114. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  115. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  116. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  117. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  118. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  119. package/web/src/components/launch/LaunchButton.jsx +0 -30
  120. package/web/src/components/launch/LaunchButton.module.css +0 -35
  121. package/web/src/components/launch/LaunchModal.module.css +0 -125
  122. package/web/src/components/layout/Footer.jsx +0 -19
  123. package/web/src/components/layout/Footer.module.css +0 -10
  124. package/web/src/components/layout/Header.jsx +0 -265
  125. package/web/src/components/layout/Header.module.css +0 -357
  126. package/web/src/components/palette/CommandPalette.jsx +0 -301
  127. package/web/src/components/palette/CommandPalette.module.css +0 -121
  128. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  129. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  130. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  131. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  132. package/web/src/components/recommend/RecommendView.jsx +0 -121
  133. package/web/src/components/recommend/RecommendView.module.css +0 -57
  134. package/web/src/components/router/RouterView.jsx +0 -1212
  135. package/web/src/components/router/RouterView.module.css +0 -1289
  136. package/web/src/components/settings/SettingsView.jsx +0 -555
  137. package/web/src/components/settings/SettingsView.module.css +0 -550
  138. package/web/src/components/tools/ToolPicker.jsx +0 -86
  139. package/web/src/components/tools/ToolPicker.module.css +0 -97
  140. package/web/src/components/update/UpdateChip.jsx +0 -104
  141. package/web/src/components/update/UpdateChip.module.css +0 -146
  142. package/web/src/global.css +0 -298
  143. package/web/src/hooks/urlState.constants.js +0 -28
  144. package/web/src/hooks/useChangelog.js +0 -51
  145. package/web/src/hooks/useColumnSizing.js +0 -211
  146. package/web/src/hooks/useFavorites.js +0 -150
  147. package/web/src/hooks/useFilter.js +0 -310
  148. package/web/src/hooks/useInstalledModels.js +0 -42
  149. package/web/src/hooks/useRecommend.js +0 -67
  150. package/web/src/hooks/useRouterDashboard.js +0 -133
  151. package/web/src/hooks/useSSE.js +0 -73
  152. package/web/src/hooks/useSocket.js +0 -203
  153. package/web/src/hooks/useTheme.js +0 -83
  154. package/web/src/hooks/useTokenUsage.js +0 -103
  155. package/web/src/hooks/useToolMode.js +0 -77
  156. package/web/src/hooks/useUpdateChecker.js +0 -91
  157. package/web/src/hooks/useUrlState.js +0 -170
  158. package/web/src/main.jsx +0 -15
  159. package/web/src/utils/download.js +0 -15
  160. package/web/src/utils/format.js +0 -42
  161. package/web/src/utils/m3.js +0 -57
  162. package/web/src/utils/ranks.js +0 -37
  163. package/web/styles.legacy.css +0 -963
@@ -225,7 +225,10 @@ async function main() {
225
225
 
226
226
  // Validate --tier early, before entering alternate screen
227
227
  if (cliArgs.tierFilter && !TIER_LETTER_MAP[cliArgs.tierFilter]) {
228
- console.error(chalk.red(` Unknown tier "${cliArgs.tierFilter}". Valid tiers: S, A, B, C`));
228
+ // 📖 Strip control characters before echoing raw argv back to the terminal,
229
+ // 📖 otherwise an argv sequence like \r could forge fake terminal output.
230
+ const safeTierEcho = String(cliArgs.tierFilter).replace(/[\x00-\x1f\x7f]/g, '')
231
+ console.error(chalk.red(` Unknown tier "${safeTierEcho}". Valid tiers: S, A, B, C`));
229
232
  process.exit(1);
230
233
  }
231
234
 
@@ -0,0 +1,48 @@
1
+ # Changelog v0.5.87 - 2026-09-05
2
+
3
+ Full quality pass over the whole codebase: security hardening, reliability fixes, dead code removal, docs overhaul. Nothing was rebuilt from scratch: every change is a targeted fix verified against the 1068-test suite, a real TUI smoke test, and live daemon probes.
4
+
5
+ ### Security
6
+
7
+ - **API keys are no longer served raw over HTTP**: `GET /api/key/:provider` on the daemon and the `--web` dashboard now return a masked key (`••••••••` + `hasKey`). Before, any local process (or any web page, via wildcard CORS) could read every saved provider key with one `curl`.
8
+ - **DNS rebinding closed**: the daemon now validates the `Host` header and rejects anything that is not loopback (403) before routing.
9
+ - **CSRF gaps closed**: `Origin: null` (sandboxed iframes) is no longer trusted, and `/daemon/shutdown`, the canonical `/sets` routes, the benchmark endpoints and the SSE streams now enforce the same-origin check. SSE connections are also capped per origin (5 of the 10 slots) so a hostile page cannot starve the real dashboard.
10
+ - **web server (`--web` mode)**: the `Access-Control-Allow-Origin: *` wildcard is gone and the server binds `127.0.0.1` unless `FCM_HOST` says otherwise.
11
+ - **`/v1` proxy**: no longer forwards your browser cookies upstream, no longer relays `set-cookie` / `access-control-*` headers downstream. New optional `FCM_ROUTER_TOKEN` env var adds bearer-token auth to `/v1` (off by default, useful in LAN/Docker setups).
12
+ - **Keys on disk**: every file the endpoint installer writes (aider, goose, env files, JSON configs) is now mode `0600`, and pre-existing wide-permission files get tightened on rewrite. Env files shell-escape the key, so a key containing quotes can no longer execute commands when sourced.
13
+ - **No more shell interpolation on POSIX**: OpenCode/Kilo/desktop launches spawn binaries directly instead of going through a shell (injection-proof by construction).
14
+ - **Telemetry**: upstream error bodies are no longer shipped to PostHog (the module header always promised this; now it's true), `telemetry.enabled: false` in the config is finally honored, and the two hardcoded Discord webhook URLs were removed: set `FCM_DISCORD_FEATURE_WEBHOOK` / `FCM_DISCORD_BUG_WEBHOOK` to re-enable feedback.
15
+
16
+ ### Fixed
17
+
18
+ - **Multi-key providers worked for nobody**: adding a second API key in Settings stored an array, and every request then sent `Authorization: Bearer key1,key2` (guaranteed 401). The first key is now used.
19
+ - **Auto-hide of broken models now persists**: `saveConfig` used to silently wipe `config.hiddenModels`, so the Ctrl+Shift+P hide feature never survived a restart, and stale writers could resurrect deleted rows from the probe cache.
20
+ - **Honest verdicts**: models whose key is rejected (401/no key) no longer show a green "Perfect" verdict and can no longer win `--fiable` / `findBestModel`: they show "Not Active".
21
+ - **TUI memory + startup**: ping history is capped at 300 samples per model (stats are windowed instead of growing forever), and the startup burst is bounded to 5 concurrent pings instead of ~226 simultaneous requests (no more 429 storms on launch).
22
+ - **TUI truthfulness**: the broken-rows auto-hide is no longer re-shown 83 ms after being hidden (Shift+B and the footer counter now tell the truth), Ctrl+P over the token/runtime overlays no longer opens an invisible palette that swallows keystrokes, and clicking the "AI Speed Test" footer badges launches the benchmarks instead of silently re-sorting the table.
23
+ - **Daemon stability**: a stalled upstream stream no longer feeds the unhandled-rejection crash counter, so a flaky provider day cannot kill the daemon into a restart loop; PID files are verified before SIGTERM; `x-request-id` is clamped.
24
+ - **OpenCode/Kilo configs**: saves are atomic, and a corrupt existing config now blocks the save with a clear message instead of being replaced from an empty base. Aider configs are only touched when they carry the FCM marker.
25
+ - **8 providers now inject their keys into OpenCode/Kilo**: huggingface, deepinfra, fireworks, together, hyperbolic, perplexity, chutes, iflow were missing from the env-var map.
26
+ - **Data quality**: 4 tier/score mismatches fixed in the catalog (S+ scale), quota snapshots no longer fake freshness, the drift report no longer drowns real drift in hundreds of false "add" rows, and the updater no longer treats an npm dist-tag rollback as an update.
27
+ - **CLI parsing**: `--tier=S` (equals form) works, repeated flags no longer leak a value into the API key argument, sort comparators can no longer return NaN, and the invalid-tier error message strips control characters.
28
+ - **Docker**: the entrypoint no longer pipes the daemon through `sed` (SIGTERM now reaches the real node process and exit codes are accurate), and 13 missing provider env passthroughs were added to docker-compose.
29
+ - **Website**: 19 TypeScript errors fixed (typecheck is green again), the vendored catalog was 17 models behind the CLI (re-synced to 24 providers / 229 models), PostHog placeholder snippet and Google Fonts removed (the dashboard and site now work fully offline), unknown URLs render the styled 404, sitemap.xml added.
30
+
31
+ ### Added
32
+
33
+ - **CI finally runs the tests**: a new "Tests" workflow runs the suite on Node 20 and 22 for every push and PR. Before this release, nothing in CI ever executed the test suite.
34
+ - **+185 tests** (883 to 1068): 5 orphaned test files were revived from the dead (stale import paths, never executed), and new coverage covers key masking, Host rejection, multi-key handling, hiddenModels persistence, ping 401/429 classification, zero-chunk SSE failover, hostile-key shell escaping, and more.
35
+ - **Self-updating docs**: provider tables in `docs/providers.md` are generated from `sources.js` by `scripts/generate-provider-table.mjs`, so counts can no longer drift from reality.
36
+ - New docs: `docs/tui.md` (every key binding verified against the code), `docs/web-dashboard.md`, `docs/router.md`, `docs/resources.md`, plus `CONTRIBUTORS.md`.
37
+
38
+ ### Changed
39
+
40
+ - **README: 67 KB to 16 KB**. Restructured around the quick start; every number, flag, key binding and path is now verified against the code (the old one claimed NVIDIA had 27 models, linked a 404 demo GIF and 5 nonexistent videos).
41
+ - **npm package 27% lighter**: 1.8 MB to 1.3 MB packed (397 files, was 514): build-time-only web sources and duplicate favicons no longer ship, and 4.4 MB of dead root media (logo duplicates) are gone from the repo.
42
+ - `kandown` moved to devDependencies: npm users no longer install a task-board CLI. `vite-plus` (unused) removed.
43
+ - 200+ em dashes purged from docs and comments (project style rule).
44
+ - The "Commit Log to Discord" action now self-diagnoses a dead webhook (404) instead of failing every push with a bare error: note that the current `DISCORD_WEBHOOK_GITHUB` secret points at a deleted webhook and needs a fresh one.
45
+
46
+ ### Changed (breaking-ish, opt-in)
47
+
48
+ - LAN/Docker users: private-network origins are no longer implicitly trusted by the daemon. Same-origin requests keep working; other origins need `FCM_ALLOWED_ORIGINS`. `/v1` stays open by default; set `FCM_ROUTER_TOKEN` to require a token.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.86",
3
+ "version": "0.5.87",
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",
@@ -49,6 +49,10 @@
49
49
  "web/",
50
50
  "!web/node_modules/",
51
51
  "!web/node_modules/**",
52
+ "!web/src/",
53
+ "!web/src/**",
54
+ "!web/public/",
55
+ "!web/public/**",
52
56
  "sources.js",
53
57
  "patch-openclaw.js",
54
58
  "patch-openclaw-models.js",
@@ -58,7 +62,7 @@
58
62
  ],
59
63
  "scripts": {
60
64
  "start": "node bin/free-coding-models.js",
61
- "test": "node --test test/test.js test/fcm-agent-core.test.js test/patch-openclaw.test.js test/provider-metadata.test.js test/config-permission-hint.test.js test/probe-cache.test.js test/passive-quota.test.js test/runtime-telemetry.test.js test/extended-benchmarks.test.js test/models-dev.test.js test/model-merger.test.js test/config-dir.test.js test/cache-dir.test.js test/model-family.test.js",
65
+ "test": "node --test test/*.test.js test/test.js",
62
66
  "test:extended-benchmarks": "node --test test/extended-benchmarks.test.js",
63
67
  "test:models-dev": "node --test test/models-dev.test.js",
64
68
  "update:benchmarks": "node scripts/update-benchmarks.mjs",
@@ -76,7 +80,6 @@
76
80
  "@tabler/icons-react": "^3.44.0",
77
81
  "@tanstack/react-table": "^8.21.3",
78
82
  "chalk": "^5.6.2",
79
- "kandown": "^0.52.0",
80
83
  "socket.io": "^4.8.3",
81
84
  "socket.io-client": "^4.8.3",
82
85
  "@tanstack/react-virtual": "^3.14.2"
@@ -87,9 +90,9 @@
87
90
  },
88
91
  "devDependencies": {
89
92
  "@vitejs/plugin-react": "^6.0.2",
93
+ "kandown": "^0.52.0",
90
94
  "react": "^19.2.7",
91
95
  "react-dom": "^19.2.7",
92
- "vite": "^8.0.16",
93
- "vite-plus": "^0.2.6"
96
+ "vite": "^8.0.16"
94
97
  }
95
98
  }
package/sources.js CHANGED
@@ -274,7 +274,7 @@ export const zai = [
274
274
  ['zai/glm-4.7-flash', 'GLM-4.7-Flash', 'A+', '59.2%', '200k'], // Fixed (2026-07-27): ctx '203k' → '200k' per official docs
275
275
  ['zai/glm-4.5-flash', 'GLM-4.5-Flash', 'S', '59.2%', '128k'],
276
276
  ['zai/glm-5-turbo', 'GLM-5-Turbo', 'S', '-', '200k'], // Added (2026-08-13)
277
- ['zai/glm-4.7', 'GLM-4.7', 'S', '73.8%', '200k'], // Added (2026-08-13)
277
+ ['zai/glm-4.7', 'GLM-4.7', 'S+', '73.8%', '200k'], // Added (2026-08-13); tier fixed: 73.8% >= 70% is S+ on the documented scale
278
278
  ['zai/glm-4.6', 'GLM-4.6', 'S', '68.0%', '200k'], // Added (2026-08-13)
279
279
  // Removed (2026-08-23): zai/glm-4.7-flashx, zai/glm-5v-turbo, zai/glm-4.6v — now paid-only ("Insufficient balance or no resource package" per ping test)
280
280
  // ── A tier — SWE-bench Verified 40–50% ──
@@ -407,7 +407,7 @@ export const opencodeZen = [
407
407
  export const kilo = [
408
408
  ['kilo-auto/free', 'Kilo Auto Free', 'A+', '-', '256k'],
409
409
  // Removed (2026-09-05): kilo-auto/small (Kilo Auto Small) - no longer free: gateway now lists it with isFree=false and paid pricing ($0.05/M prompt, $0.40/M completion); it routes to paid small models
410
- ['thinkingmachines/inkling-small:free', 'Thinking Machines Inkling Small (free)', 'A+', '80.2%', '1M'],
410
+ ['thinkingmachines/inkling-small:free', 'Thinking Machines Inkling Small (free)', 'S+', '80.2%', '1M'], // tier fixed: 80.2% >= 70% is S+ on the documented scale
411
411
  ['stepfun/step-3.7-flash:free', 'StepFun Step 3.7 Flash (free)', 'A+', '-', '262k'],
412
412
  ['poolside/laguna-s-2.1:free', 'Poolside Laguna S 2.1 (free)', 'A+', '-', '262k'],
413
413
  ['nvidia/nemotron-3-ultra-550b-a55b:free', 'NVIDIA Nemotron 3 Ultra (free)', 'A+', '-', '1M'],
@@ -418,8 +418,8 @@ export const kilo = [
418
418
  ['nvidia/nemotron-3.5-lightning:free', 'NVIDIA Nemotron 3.5 Lightning (free)', 'B+', '-', '1M'],
419
419
  ['dots-studio/dots-3-note-preview:free', 'Dots Studio Dots3-Note Preview (free)', 'B+', '-', '512k'],
420
420
  ['openrouter/free', 'OpenRouter Free Models Router', 'B', '-', '200k'],
421
- ['minimax/minimax-m3:free', 'MiniMax M3 (free)', 'S', '80.5%', '1M'],
422
- ['thinkingmachines/inkling:free', 'Thinking Machines Inkling (free)', 'S', '80.2%', '1M'],
421
+ ['minimax/minimax-m3:free', 'MiniMax M3 (free)', 'S+', '80.5%', '1M'], // tier fixed: 80.5% >= 70% is S+ on the documented scale
422
+ ['thinkingmachines/inkling:free', 'Thinking Machines Inkling (free)', 'S+', '80.2%', '1M'], // tier fixed: 80.2% >= 70% is S+ on the documented scale
423
423
  ]
424
424
 
425
425
  // 📖 LLM7 source - https://api.llm7.io/v1
package/src/core/cache.js CHANGED
@@ -135,8 +135,10 @@ export function saveCache(results, providerTier = 'normal') {
135
135
  providerTier
136
136
  }
137
137
 
138
- // 📖 Write with secure permissions (user read/write only)
138
+ // 📖 Write with secure permissions (user read/write only).
139
+ // 📖 chmod also tightens pre-existing files created before the mode option was added.
139
140
  fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), { mode: 0o600 })
141
+ fs.chmodSync(cachePath, 0o600)
140
142
  } catch (err) {
141
143
  // 📖 Silently fail on write errors — caching is optional
142
144
  }
@@ -249,6 +249,9 @@ function isPlainObject(value) {
249
249
  }
250
250
 
251
251
  function cloneConfigValue(value) {
252
+ // 📖 Sets (hiddenModels) have no JSON representation and would come back as {}
253
+ // 📖 after the round-trip, breaking every `instanceof Set` consumer in the TUI.
254
+ if (value instanceof Set) return new Set(value)
252
255
  return JSON.parse(JSON.stringify(value))
253
256
  }
254
257
 
@@ -370,7 +373,9 @@ function normalizeRouterName(value, fallback = '') {
370
373
  }
371
374
 
372
375
  function normalizePositiveInteger(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
373
- if (value === null || value === undefined || value === '') return fallback
376
+ // 📖 Booleans coerce through Number() (true -> 1) and would turn a malformed
377
+ // 📖 "router.port: true" into port 1, so non-numeric types fall back instead.
378
+ if (typeof value === 'boolean' || value === null || value === undefined || value === '') return fallback
374
379
  const numeric = Number(value)
375
380
  if (!Number.isFinite(numeric)) return fallback
376
381
  return Math.max(min, Math.min(max, Math.round(numeric)))
@@ -610,6 +615,19 @@ function mergeEndpointInstalls(diskEndpointInstalls, incomingEndpointInstalls) {
610
615
  return [...merged.values()]
611
616
  }
612
617
 
618
+ // 📖 mergeHiddenModels: union of the disk auto-hide set with the live one, so a
619
+ // 📖 stale writer (daemon autosave, second TUI) cannot wipe probe-hidden models.
620
+ // 📖 When the incoming config explicitly disables auto-hide, the incoming set wins
621
+ // 📖 so the TUI's "unhide all" toggle actually stays cleared instead of being
622
+ // 📖 resurrected from disk on every save.
623
+ function mergeHiddenModels(diskSet, incomingSet, incomingAutoHideDisabled) {
624
+ if (incomingAutoHideDisabled) return incomingSet
625
+ const merged = new Set()
626
+ for (const entry of diskSet) merged.add(entry)
627
+ for (const entry of incomingSet) merged.add(entry)
628
+ return merged
629
+ }
630
+
613
631
 
614
632
 
615
633
  /**
@@ -629,7 +647,18 @@ export function buildPersistedConfig(incomingConfig, diskConfig = _emptyConfig()
629
647
  ? cloneConfigValue(normalizedIncoming.apiKeys)
630
648
  : { ...normalizedDisk.apiKeys, ...normalizedIncoming.apiKeys },
631
649
  providers: { ...normalizedDisk.providers, ...normalizedIncoming.providers },
632
- settings: cloneConfigValue(normalizedIncoming.settings),
650
+ // 📖 Shallow-merge settings per top-level key: replacing wholesale let
651
+ // 📖 concurrent surfaces (TUI, web dashboard, daemon autosave) clobber each
652
+ // 📖 other's unrelated settings. Incoming wins per key.
653
+ settings: { ...normalizedDisk.settings, ...normalizedIncoming.settings },
654
+ // 📖 hiddenModels used to be dropped here entirely, so every saveConfig wiped
655
+ // 📖 the auto-hide list both on disk and in the live config object. Merge it
656
+ // 📖 so the Set serializer in saveConfig can persist it again.
657
+ hiddenModels: mergeHiddenModels(
658
+ normalizedDisk.hiddenModels,
659
+ normalizedIncoming.hiddenModels,
660
+ normalizedIncoming.settings.autoHideBrokenModels === false,
661
+ ),
633
662
  favorites: options.replaceFavorites === true
634
663
  ? [...normalizedIncoming.favorites]
635
664
  : mergeOrderedUniqueStrings(normalizedIncoming.favorites, normalizedDisk.favorites),
@@ -960,8 +989,10 @@ function createBackup() {
960
989
  mkdirSync(backupDir, { mode: 0o700, recursive: true })
961
990
  }
962
991
 
963
- // 📖 Create timestamped backup
964
- const timestamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, -5) + 'Z'
992
+ // 📖 Create timestamped backup. Full timestamp including seconds: the old
993
+ // 📖 slice(0, -5) truncated them, so two saves <10s apart overwrote the same
994
+ // 📖 backup file and lost the older snapshot.
995
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '') + 'Z'
965
996
  const backupPath = join(backupDir, `config.${timestamp}.json`)
966
997
  const backupContent = readFileSync(CONFIG_PATH, 'utf8')
967
998
  writeFileSync(backupPath, backupContent, { mode: 0o600 })
@@ -1107,8 +1138,15 @@ export function getApiKey(config, providerKey) {
1107
1138
  if (candidate && process.env[candidate]) return process.env[candidate]
1108
1139
  }
1109
1140
 
1110
- // 📖 Config file value
1141
+ // 📖 Config file value. addApiKey() stores a provider's extra keys as an array,
1142
+ // 📖 but every consumer (ping Authorization header, tool env vars, daemon) needs
1143
+ // 📖 a single string: an array would stringify to "key1,key2" and get a
1144
+ // 📖 guaranteed 401. Return the first non-empty entry instead.
1111
1145
  const key = config?.apiKeys?.[providerKey]
1146
+ if (Array.isArray(key)) {
1147
+ const firstUsable = key.find(k => typeof k === 'string' && k.length > 0)
1148
+ return firstUsable ?? null
1149
+ }
1112
1150
  if (key) return key
1113
1151
 
1114
1152
  return null
@@ -40,14 +40,14 @@
40
40
  * @see src/tool-metadata.js
41
41
  */
42
42
 
43
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
43
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
44
44
  import { homedir } from 'node:os'
45
45
  import { dirname, join } from 'node:path'
46
46
  import { MODELS, sources } from '../../sources.js'
47
47
  import { getApiKey, saveConfig } from './config.js'
48
48
  import { ENV_VAR_NAMES, PROVIDER_METADATA } from './provider-metadata.js'
49
49
  import { getToolMeta } from './tool-metadata.js'
50
- import { ensureDir, readJson as sharedReadJson } from './shared-helpers.js'
50
+ import { ensureDir, readJson as sharedReadJson, shellSingleQuote, atomicWriteJson } from './shared-helpers.js'
51
51
 
52
52
  // 📖 replicate uses /v1/predictions (not /chat/completions), so it's not OpenAI-compatible.
53
53
  // 📖 zai and opencode-zen ARE OpenAI-compatible and CAN be installed into any tool.
@@ -90,10 +90,21 @@ function readJson(filePath, fallback = {}) {
90
90
  return sharedReadJson(filePath, fallback)
91
91
  }
92
92
 
93
+ // 📖 These configs carry plaintext API keys - 0600 at creation, chmod after write
94
+ // 📖 so pre-existing wide-permission files get tightened too (mode only applies at creation).
95
+ function writeSecretFile(filePath, content) {
96
+ ensureDirFor(filePath)
97
+ writeFileSync(filePath, content, { mode: 0o600 })
98
+ chmodSync(filePath, 0o600)
99
+ }
100
+
93
101
  function writeJson(filePath, value, { backup = true } = {}) {
94
102
  ensureDirFor(filePath)
95
103
  const backupPath = backup ? backupIfExists(filePath) : null
96
- writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n')
104
+ // 📖 Atomic tmp + rename via the shared helper instead of an in-place
105
+ // 📖 writeFileSync, so a crash mid-write can never truncate a tool config.
106
+ // 📖 0600 because these configs carry plaintext API keys.
107
+ atomicWriteJson(filePath, value, 0o600)
97
108
  return backupPath
98
109
  }
99
110
 
@@ -123,7 +134,7 @@ function writeSimpleYamlMap(filePath, entries) {
123
134
  const lines = Object.keys(entries)
124
135
  .sort()
125
136
  .map((key) => `${key}: ${JSON.stringify(String(entries[key] ?? ''))}`)
126
- writeFileSync(filePath, lines.join('\n') + '\n')
137
+ writeSecretFile(filePath, lines.join('\n') + '\n')
127
138
  return backupPath
128
139
  }
129
140
 
@@ -463,8 +474,7 @@ function installIntoAider(providerKey, models, apiKey, paths) {
463
474
  `model: openai/${primaryModel.modelId}`,
464
475
  '',
465
476
  ]
466
- ensureDirFor(paths.aiderConfigPath)
467
- writeFileSync(paths.aiderConfigPath, lines.join('\n'))
477
+ writeSecretFile(paths.aiderConfigPath, lines.join('\n'))
468
478
  return { path: paths.aiderConfigPath, backupPath, providerId, modelCount: models.length }
469
479
  }
470
480
 
@@ -505,11 +515,12 @@ function installIntoQwen(providerKey, models, apiKey, paths) {
505
515
 
506
516
  // 📖 installIntoEnvBasedTool handles tools that rely on env vars only.
507
517
  // 📖 We write a small .env-style helper file so users can source it before launching.
508
- function installIntoEnvBasedTool(providerKey, models, apiKey, toolMode) {
518
+ // 📖 `paths` is optional and lets tests override the env file location.
519
+ function installIntoEnvBasedTool(providerKey, models, apiKey, toolMode, paths = {}) {
509
520
  const providerId = getManagedProviderId(providerKey)
510
521
  const home = homedir()
511
522
  const envFileName = `.fcm-${toolMode}-env`
512
- const envFilePath = join(home, envFileName)
523
+ const envFilePath = paths?.envFilePath ?? join(home, envFileName)
513
524
  const primaryModel = models[0]
514
525
  const effectiveApiKey = apiKey
515
526
  const effectiveBaseUrl = resolveProviderBaseUrl(providerKey)
@@ -519,17 +530,17 @@ function installIntoEnvBasedTool(providerKey, models, apiKey, toolMode) {
519
530
  '# 📖 Managed by free-coding-models — source this file before launching the tool',
520
531
  `# 📖 Provider: ${getProviderLabel(providerKey)} (${models.length} models)`,
521
532
  '# 📖 Connection: Direct provider',
522
- `export OPENAI_API_KEY="${effectiveApiKey}"`,
533
+ // 📖 Single-quote the key so quotes/$()/backticks in it stay literal on `source`.
534
+ `export OPENAI_API_KEY=${shellSingleQuote(effectiveApiKey)}`,
523
535
  `export OPENAI_BASE_URL="${effectiveBaseUrl}"`,
524
536
  `export OPENAI_MODEL="${effectiveModelId}"`,
525
- `export LLM_API_KEY="${effectiveApiKey}"`,
537
+ `export LLM_API_KEY=${shellSingleQuote(effectiveApiKey)}`,
526
538
  `export LLM_BASE_URL="${effectiveBaseUrl}"`,
527
539
  `export LLM_MODEL="openai/${effectiveModelId}"`,
528
540
  ]
529
541
 
530
- ensureDirFor(envFilePath)
531
542
  const backupPath = backupIfExists(envFilePath)
532
- writeFileSync(envFilePath, envLines.join('\n') + '\n')
543
+ writeSecretFile(envFilePath, envLines.join('\n') + '\n')
533
544
  return { path: envFilePath, backupPath, providerId, modelCount: models.length }
534
545
  }
535
546
 
@@ -7,8 +7,7 @@
7
7
  * - Scan all supported tool configs for installed models
8
8
  * - Parse tool-specific config files (YAML, JSON)
9
9
  * - Soft-delete models with backup to ~/.free-coding-models-backups.json
10
- * - Launch tools with selected models
11
- * - Reinstall FCM endpoints for providers
10
+ * (launch + reinstall live in tool-launchers.js / endpoint-installer.js)
12
11
  *
13
12
  * 📖 Supported tools:
14
13
  * - Goose (~/.config/goose/config.yaml + custom_providers/*.json)
@@ -36,16 +35,14 @@
36
35
  * → parseOpenHandsConfig — Parse OpenHands env file
37
36
  * → parseAmpConfig — Parse Amp JSON config
38
37
  * → softDeleteModel — Remove model from config with backup
39
- * → launchToolWithModel — Launch tool with specific model
40
- * → reinstallEndpoint — Reinstall FCM endpoint for provider
41
38
  *
42
- * @exports scanAllToolConfigs, softDeleteModel, launchToolWithModel, reinstallEndpoint
39
+ * @exports scanAllToolConfigs, parseToolConfig, softDeleteModel
43
40
  *
44
- * @see src/tool-launchers.js for launch functions
41
+ * @see src/tool-launchers.js - for launch functions (launchToolWithModel lives there)
45
42
  * @see src/endpoint-installer.js — for reinstall logic
46
43
  */
47
44
 
48
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
45
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
49
46
  import { homedir } from 'node:os'
50
47
  import { join, dirname } from 'node:path'
51
48
  import { sources } from '../../sources.js'
@@ -211,17 +208,6 @@ function parseCrushConfig(paths = getToolConfigPaths()) {
211
208
  canLaunch: true,
212
209
  })
213
210
  }
214
- if (config.models?.small?.model) {
215
- models.push({
216
- modelId: config.models.small.model + '-small',
217
- label: `${config.models.small.model} (small)`,
218
- tier: '-',
219
- sweScore: '-',
220
- providerKey: 'external',
221
- isExternal: true,
222
- canLaunch: true,
223
- })
224
- }
225
211
 
226
212
  return {
227
213
  isValid: true,
@@ -677,13 +663,46 @@ function loadBackups() {
677
663
 
678
664
  /**
679
665
  * 📖 Save backups to ~/.free-coding-models-backups.json
666
+ * 📖 Written 0600 (snapshots may contain tool config content) with a chmod after
667
+ * 📖 write so pre-existing wide-permission files get tightened too.
680
668
  */
681
669
  function saveBackups(backups) {
682
670
  const dir = dirname(BACKUP_PATH)
683
671
  if (!existsSync(dir)) {
684
672
  mkdirSync(dir, { recursive: true })
685
673
  }
686
- writeFileSync(BACKUP_PATH, JSON.stringify(backups, null, 2))
674
+ writeFileSync(BACKUP_PATH, JSON.stringify(backups, null, 2), { mode: 0o600 })
675
+ chmodSync(BACKUP_PATH, 0o600)
676
+ }
677
+
678
+ // 📖 Backups keep a copy of the original tool config so deletions can be
679
+ // 📖 inspected later. Key-like fields are stripped recursively (and by line for
680
+ // 📖 non-JSON configs) so plaintext API keys never land in the backups file.
681
+ const BACKUP_SENSITIVE_KEYS = new Set(['api_key', 'apiKey', 'keys', 'token'])
682
+
683
+ function stripSensitiveFields(value) {
684
+ if (Array.isArray(value)) return value.map(stripSensitiveFields)
685
+ if (value && typeof value === 'object') {
686
+ const out = {}
687
+ for (const [key, val] of Object.entries(value)) {
688
+ if (BACKUP_SENSITIVE_KEYS.has(key)) continue
689
+ out[key] = stripSensitiveFields(val)
690
+ }
691
+ return out
692
+ }
693
+ return value
694
+ }
695
+
696
+ function sanitizeBackupConfig(content) {
697
+ try {
698
+ return JSON.stringify(stripSensitiveFields(JSON.parse(content)), null, 2)
699
+ } catch {
700
+ // 📖 Non-JSON config (goose/aider YAML etc.): drop whole key-bearing lines.
701
+ return content
702
+ .split('\n')
703
+ .filter((line) => !/^(#|\s)*(export\s+)?[\w-]*(api[_-]?key|token|keys)[\w-]*\s*[:=]/i.test(line))
704
+ .join('\n')
705
+ }
687
706
  }
688
707
 
689
708
  /**
@@ -830,11 +849,15 @@ export function softDeleteModel(toolMode, modelId, paths = getToolConfigPaths())
830
849
  id: `${toolMode}-${modelId}-${new Date().toISOString()}`,
831
850
  toolMode,
832
851
  modelId,
833
- originalConfig: originalContent,
852
+ originalConfig: sanitizeBackupConfig(originalContent),
834
853
  configPath,
835
854
  disabledAt: new Date().toISOString(),
836
855
  reason: 'user_deleted',
837
856
  })
857
+ // 📖 Keep the backup list bounded: only the 20 most recent deletions survive.
858
+ if (backups.disabledModels.length > 20) {
859
+ backups.disabledModels = backups.disabledModels.slice(-20)
860
+ }
838
861
  saveBackups(backups)
839
862
 
840
863
  return { success: true }
@@ -6,36 +6,82 @@
6
6
  * 📖 Kilo is a fork of OpenCode and uses the same config structure,
7
7
  * 📖 but stored in a different directory: ~/.config/kilo/opencode.json
8
8
  *
9
+ * 📖 Data-loss guard (mirrors opencode-config.js): an existing config that
10
+ * 📖 fails to parse returns null from `loadKiloConfig`, and `saveKiloConfig`
11
+ * 📖 refuses to write over an unreadable file instead of replacing the
12
+ * 📖 user's config with an empty base.
13
+ *
9
14
  * @functions
10
- * → `loadKiloConfig` read `~/.config/kilo/opencode.json` safely
11
- * → `saveKiloConfig` write `opencode.json` with a simple backup
15
+ * → `loadKiloConfig` - read `~/.config/kilo/opencode.json` safely (null when unreadable)
16
+ * → `saveKiloConfig` - write `opencode.json` atomically with a simple backup
12
17
  *
13
18
  * @exports loadKiloConfig, saveKiloConfig
14
19
  */
15
20
 
16
- import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs'
21
+ import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs'
17
22
  import { join } from 'node:path'
18
23
  import { homedir } from 'node:os'
24
+ import { atomicWriteJson } from './shared-helpers.js'
19
25
 
20
26
  const KILO_CONFIG_DIR = join(homedir(), '.config', 'kilo')
21
27
  const KILO_CONFIG_PATH = join(KILO_CONFIG_DIR, 'opencode.json')
22
28
  const KILO_BACKUP_PATH = join(KILO_CONFIG_DIR, 'opencode.json.bak')
23
29
 
30
+ const UNREADABLE_CONFIG_HINT = ` ⚠ ${KILO_CONFIG_PATH} contains invalid JSON and was NOT modified. Fix or remove the file, then retry.`
31
+
32
+ /**
33
+ * 📖 True when the file exists, is non-empty, and does not parse as JSON.
34
+ */
35
+ function isExistingFileUnreadable(configPath) {
36
+ if (!existsSync(configPath)) return false
37
+ try {
38
+ const raw = readFileSync(configPath, 'utf8')
39
+ if (!raw.trim()) return false
40
+ JSON.parse(raw)
41
+ return false
42
+ } catch {
43
+ return true
44
+ }
45
+ }
46
+
24
47
  export function loadKiloConfig() {
25
48
  try {
26
49
  if (existsSync(KILO_CONFIG_PATH)) {
27
- return JSON.parse(readFileSync(KILO_CONFIG_PATH, 'utf8'))
50
+ const raw = readFileSync(KILO_CONFIG_PATH, 'utf8')
51
+ if (!raw.trim()) return {}
52
+ try {
53
+ return JSON.parse(raw)
54
+ } catch {
55
+ // 📖 Existing non-empty file with broken JSON: signal "unreadable" so
56
+ // 📖 callers never build a save on an empty base and wipe the file.
57
+ return null
58
+ }
28
59
  }
29
60
  } catch {}
30
61
  return {}
31
62
  }
32
63
 
64
+ /**
65
+ * 📖 Save kilo opencode.json atomically (tmp + rename) with a one-shot backup.
66
+ * 📖 Returns false and aborts instead of overwriting an unreadable file.
67
+ * @param {object|null} config
68
+ * @returns {boolean} true when written
69
+ */
33
70
  export function saveKiloConfig(config) {
71
+ if (config === null || config === undefined) {
72
+ console.error(UNREADABLE_CONFIG_HINT)
73
+ return false
74
+ }
75
+ if (isExistingFileUnreadable(KILO_CONFIG_PATH)) {
76
+ console.error(UNREADABLE_CONFIG_HINT)
77
+ return false
78
+ }
34
79
  mkdirSync(KILO_CONFIG_DIR, { recursive: true })
35
80
  if (existsSync(KILO_CONFIG_PATH)) {
36
81
  copyFileSync(KILO_CONFIG_PATH, KILO_BACKUP_PATH)
37
82
  }
38
- writeFileSync(KILO_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n')
83
+ atomicWriteJson(KILO_CONFIG_PATH, config, 0o600)
84
+ return true
39
85
  }
40
86
 
41
87
  export function getKiloConfigPath() {
package/src/core/kilo.js CHANGED
@@ -7,7 +7,7 @@ import chalk from 'chalk'
7
7
  import { sources } from '../../sources.js'
8
8
  import { loadKiloConfig, saveKiloConfig, getKiloConfigPath } from './kilo-config.js'
9
9
  import { getApiKey } from './config.js'
10
- import { ENV_VAR_NAMES, OPENCODE_MODEL_MAP } from './provider-metadata.js'
10
+ import { ENV_VAR_NAMES, OPENCODE_MODEL_MAP, isWindows } from './provider-metadata.js'
11
11
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
12
12
 
13
13
  // 📖 Map source model IDs to Kilo built-in IDs (same as OpenCode).
@@ -45,7 +45,9 @@ async function spawnKilo(args, providerKey, fcmConfig) {
45
45
  const { spawn } = await import('child_process')
46
46
  const child = spawn(resolveToolBinaryPath('kilo') || 'kilo', finalArgs, {
47
47
  stdio: 'inherit',
48
- shell: true,
48
+ // 📖 Windows keeps a shell because npm-installed CLIs resolve to .cmd shims;
49
+ // 📖 everywhere else the resolved absolute binary path is spawned directly.
50
+ shell: isWindows,
49
51
  detached: false,
50
52
  env: childEnv
51
53
  })
@@ -75,7 +77,7 @@ export async function startKilo(model, fcmConfig) {
75
77
  console.log(chalk.dim(` Model: ${modelRef}`))
76
78
  console.log()
77
79
 
78
- const config = loadKiloConfig()
80
+ const config = loadKiloConfig() || {}
79
81
 
80
82
  if (!config.provider) config.provider = {}
81
83
  if (!config.provider[providerKey]) {
@@ -23,7 +23,7 @@
23
23
  * @exports cleanupLegacyProxyArtifacts
24
24
  */
25
25
 
26
- import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs'
26
+ import { chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs'
27
27
  import { homedir } from 'node:os'
28
28
  import { join } from 'node:path'
29
29
 
@@ -82,7 +82,9 @@ function noteError(summary, filePath, error) {
82
82
  }
83
83
 
84
84
  // 📖 Thin wrappers: readJsonFile propagates parse errors (callers have try/catch).
85
- // 📖 writeJsonFile appends a trailing newline for clean diffs.
85
+ // 📖 writeJsonFile appends a trailing newline for clean diffs. These configs can
86
+ // 📖 carry plaintext API keys, so tighten permissions after every write (chmod
87
+ // 📖 also fixes pre-existing files that were created wide-permission).
86
88
  function readJsonFile(filePath, fallback = null) {
87
89
  if (!existsSync(filePath)) return fallback
88
90
  return JSON.parse(readFileSync(filePath, 'utf8'))
@@ -90,6 +92,7 @@ function readJsonFile(filePath, fallback = null) {
90
92
 
91
93
  function writeJsonFile(filePath, value) {
92
94
  writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n')
95
+ chmodSync(filePath, 0o600)
93
96
  }
94
97
 
95
98
  function deleteFileIfExists(filePath, summary) {
@@ -262,7 +265,8 @@ function writeSimpleYamlMap(filePath, entries) {
262
265
  const lines = Object.keys(entries)
263
266
  .sort()
264
267
  .map((key) => `${key}: ${JSON.stringify(String(entries[key] ?? ''))}`)
265
- writeFileSync(filePath, lines.join('\n') + '\n')
268
+ writeFileSync(filePath, lines.join('\n') + '\n', { mode: 0o600 })
269
+ chmodSync(filePath, 0o600)
266
270
  }
267
271
 
268
272
  function cleanupGoose(paths, summary) {
@@ -328,8 +332,16 @@ function cleanupAider(filePath, summary) {
328
332
  if (!existsSync(filePath)) return
329
333
  try {
330
334
  const content = readFileSync(filePath, 'utf8')
331
- const isLegacyProxyConfig = content.includes('FCM Proxy V2') || /openai-api-base:\s*http:\/\/127\.0\.0\.1:/i.test(content)
335
+ // 📖 Only treat the file as proxy-era when BOTH signals hold: the FCM proxy
336
+ // 📖 banner we used to write AND a loopback openai-api-base. Matching on the
337
+ // 📖 loopback base alone deleted unrelated local setups (e.g. ollama) wholesale.
338
+ const isLegacyProxyConfig = content.includes('FCM Proxy V2')
339
+ && /openai-api-base:\s*http:\/\/127\.0\.0\.1:/i.test(content)
332
340
  if (isLegacyProxyConfig) {
341
+ // 📖 Back up before any unlink: the file may contain user edits we cannot
342
+ // 📖 reconstruct.
343
+ const backupPath = `${filePath}.backup-${Date.now()}`
344
+ try { writeFileSync(backupPath, content, { mode: 0o600 }) } catch { /* best-effort */ }
333
345
  unlinkSync(filePath)
334
346
  noteRemovedFile(summary, filePath)
335
347
  }
@@ -341,7 +353,12 @@ function cleanupAider(filePath, summary) {
341
353
  function cleanupAmp(filePath, summary) {
342
354
  updateJsonFile(filePath, (config) => {
343
355
  let removedEntries = 0
344
- const usesLegacyLocalhost = typeof config['amp.url'] === 'string' && /127\.0\.0\.1|localhost/.test(config['amp.url'])
356
+ // 📖 The old proxy wrote its identity into the URL. Only loopback URLs that
357
+ // 📖 are actually FCM proxy artifacts (banner value in the URL) are removed;
358
+ // 📖 a user's own localhost amp.url (ollama, llama.cpp, ...) stays untouched.
359
+ const usesLegacyLocalhost = typeof config['amp.url'] === 'string'
360
+ && /127\.0\.0\.1|localhost/.test(config['amp.url'])
361
+ && /fcm|free-coding-models/i.test(config['amp.url'])
345
362
  if (usesLegacyLocalhost) {
346
363
  delete config['amp.url']
347
364
  removedEntries += 1