free-coding-models 0.5.85 → 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.
- package/README.md +110 -881
- package/bin/free-coding-models.js +4 -1
- package/changelog/v0.5.86.md +10 -0
- package/changelog/v0.5.87.md +48 -0
- package/package.json +8 -5
- package/sources.js +4 -4
- package/src/core/cache.js +3 -1
- package/src/core/config.js +43 -5
- package/src/core/endpoint-installer.js +23 -12
- package/src/core/installed-models-manager.js +43 -20
- package/src/core/kilo-config.js +51 -5
- package/src/core/kilo.js +5 -3
- package/src/core/legacy-proxy-cleanup.js +22 -5
- package/src/core/model-merger.js +4 -0
- package/src/core/models-drift.js +9 -0
- package/src/core/opencode-config.js +57 -5
- package/src/core/opencode.js +67 -41
- package/src/core/playground.js +7 -0
- package/src/core/probe-cache.js +35 -4
- package/src/core/provider-key-tester.js +10 -2
- package/src/core/provider-metadata.js +10 -0
- package/src/core/provider-quota-fetchers.js +14 -7
- package/src/core/router-daemon.js +208 -26
- package/src/core/runtime-telemetry.js +27 -1
- package/src/core/shared-helpers.js +12 -1
- package/src/core/shell-env.js +3 -1
- package/src/core/telemetry.js +18 -5
- package/src/core/tool-launchers.js +9 -6
- package/src/core/updater.js +28 -2
- package/src/core/utils.js +76 -71
- package/src/tui/app.js +47 -16
- package/src/tui/command-palette.js +22 -2
- package/src/tui/key-handler.js +123 -179
- package/src/tui/overlays.js +35 -75
- package/src/tui/render-helpers.js +62 -4
- package/src/tui/render-table.js +46 -5
- package/src/tui/theme.js +36 -5
- package/src/tui/tui-filters.js +28 -8
- package/src/tui/tui-state.js +22 -6
- package/web/dist/assets/index-BpTpPS0g.js +44 -0
- package/web/dist/assets/index-CAzFIt8P.css +1 -0
- package/web/dist/index.html +5 -9
- package/web/index.html +3 -7
- package/web/server.js +97 -23
- package/web/vite.config.js +8 -0
- package/web/app.legacy.js +0 -900
- package/web/dist/assets/index-C5-Ywvv3.css +0 -1
- package/web/dist/assets/index-DWUely84.js +0 -40
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +0 -12
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +0 -25
- package/web/src/App.jsx +0 -521
- package/web/src/components/analytics/AnalyticsView.jsx +0 -147
- package/web/src/components/analytics/AnalyticsView.module.css +0 -190
- package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
- package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
- package/web/src/components/atoms/AILatencyCell.jsx +0 -38
- package/web/src/components/atoms/AILatencyCell.module.css +0 -51
- package/web/src/components/atoms/HealthCell.jsx +0 -143
- package/web/src/components/atoms/HealthCell.module.css +0 -60
- package/web/src/components/atoms/LastPingCell.jsx +0 -35
- package/web/src/components/atoms/LastPingCell.module.css +0 -45
- package/web/src/components/atoms/MoodCell.jsx +0 -25
- package/web/src/components/atoms/MoodCell.module.css +0 -6
- package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
- package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
- package/web/src/components/atoms/ProviderLogo.jsx +0 -187
- package/web/src/components/atoms/ProviderLogo.module.css +0 -103
- package/web/src/components/atoms/RankCell.jsx +0 -9
- package/web/src/components/atoms/RankCell.module.css +0 -9
- package/web/src/components/atoms/Sparkline.jsx +0 -63
- package/web/src/components/atoms/StabilityCell.jsx +0 -18
- package/web/src/components/atoms/StabilityCell.module.css +0 -13
- package/web/src/components/atoms/StatusDot.jsx +0 -13
- package/web/src/components/atoms/StatusDot.module.css +0 -29
- package/web/src/components/atoms/TPSCell.jsx +0 -36
- package/web/src/components/atoms/TPSCell.module.css +0 -44
- package/web/src/components/atoms/TierBadge.jsx +0 -10
- package/web/src/components/atoms/TierBadge.module.css +0 -29
- package/web/src/components/atoms/Toast.jsx +0 -25
- package/web/src/components/atoms/Toast.module.css +0 -41
- package/web/src/components/atoms/ToastContainer.jsx +0 -16
- package/web/src/components/atoms/ToastContainer.module.css +0 -10
- package/web/src/components/atoms/VerdictBadge.jsx +0 -36
- package/web/src/components/atoms/VerdictBadge.module.css +0 -40
- package/web/src/components/changelog/ChangelogView.jsx +0 -135
- package/web/src/components/changelog/ChangelogView.module.css +0 -159
- package/web/src/components/dashboard/DetailPanel.jsx +0 -217
- package/web/src/components/dashboard/DetailPanel.module.css +0 -260
- package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
- package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
- package/web/src/components/dashboard/ExportModal.jsx +0 -84
- package/web/src/components/dashboard/ExportModal.module.css +0 -99
- package/web/src/components/dashboard/FilterBar.jsx +0 -338
- package/web/src/components/dashboard/FilterBar.module.css +0 -342
- package/web/src/components/dashboard/ModelTable.jsx +0 -672
- package/web/src/components/dashboard/ModelTable.module.css +0 -446
- package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
- package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
- package/web/src/components/help/HelpView.jsx +0 -201
- package/web/src/components/help/HelpView.module.css +0 -156
- package/web/src/components/install/InstallEndpointsView.jsx +0 -309
- package/web/src/components/install/InstallEndpointsView.module.css +0 -427
- package/web/src/components/installed/InstalledModelsView.jsx +0 -89
- package/web/src/components/installed/InstalledModelsView.module.css +0 -200
- package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
- package/web/src/components/launch/LaunchButton.jsx +0 -30
- package/web/src/components/launch/LaunchButton.module.css +0 -35
- package/web/src/components/launch/LaunchModal.module.css +0 -125
- package/web/src/components/layout/Footer.jsx +0 -19
- package/web/src/components/layout/Footer.module.css +0 -10
- package/web/src/components/layout/Header.jsx +0 -265
- package/web/src/components/layout/Header.module.css +0 -357
- package/web/src/components/palette/CommandPalette.jsx +0 -301
- package/web/src/components/palette/CommandPalette.module.css +0 -121
- package/web/src/components/playground/PlaygroundChat.jsx +0 -527
- package/web/src/components/playground/PlaygroundChat.module.css +0 -382
- package/web/src/components/playground/PlaygroundView.jsx +0 -371
- package/web/src/components/playground/PlaygroundView.module.css +0 -504
- package/web/src/components/recommend/RecommendView.jsx +0 -121
- package/web/src/components/recommend/RecommendView.module.css +0 -57
- package/web/src/components/router/RouterView.jsx +0 -1212
- package/web/src/components/router/RouterView.module.css +0 -1289
- package/web/src/components/settings/SettingsView.jsx +0 -555
- package/web/src/components/settings/SettingsView.module.css +0 -550
- package/web/src/components/tools/ToolPicker.jsx +0 -86
- package/web/src/components/tools/ToolPicker.module.css +0 -97
- package/web/src/components/update/UpdateChip.jsx +0 -104
- package/web/src/components/update/UpdateChip.module.css +0 -146
- package/web/src/global.css +0 -298
- package/web/src/hooks/urlState.constants.js +0 -28
- package/web/src/hooks/useChangelog.js +0 -51
- package/web/src/hooks/useColumnSizing.js +0 -211
- package/web/src/hooks/useFavorites.js +0 -150
- package/web/src/hooks/useFilter.js +0 -310
- package/web/src/hooks/useInstalledModels.js +0 -42
- package/web/src/hooks/useRecommend.js +0 -67
- package/web/src/hooks/useRouterDashboard.js +0 -133
- package/web/src/hooks/useSSE.js +0 -73
- package/web/src/hooks/useSocket.js +0 -203
- package/web/src/hooks/useTheme.js +0 -83
- package/web/src/hooks/useTokenUsage.js +0 -103
- package/web/src/hooks/useToolMode.js +0 -77
- package/web/src/hooks/useUpdateChecker.js +0 -91
- package/web/src/hooks/useUrlState.js +0 -170
- package/web/src/main.jsx +0 -15
- package/web/src/utils/download.js +0 -15
- package/web/src/utils/format.js +0 -42
- package/web/src/utils/m3.js +0 -57
- package/web/src/utils/ranks.js +0 -37
- 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
|
-
|
|
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,10 @@
|
|
|
1
|
+
# Changelog v0.5.86 - 2026-09-05
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
- 🖥️ Provider column in the TUI table widened from 11 to 15 characters, with automatic truncation at 14 characters (13 + ellipsis) for anything longer. Provider names like "Cloudflare AI" (13 chars) now render fully instead of pushing every following cell out of alignment. Any future provider name longer than 14 chars is truncated cleanly with an ellipsis, so a long label can never break the table layout again. The compact-mode truncation (4 chars + ellipsis on narrow terminals) is unchanged.
|
|
5
|
+
- ⚡ Command Palette no longer paints a giant dark rectangle over the table. Each palette row used to be emitted from column 1 with a leading margin of unstyled spaces plus a trailing "erase to end of line": both inherited the still-active panel background, so the panel tint filled the entire screen width for every palette row, masking the frozen table on both sides of the modal. Rows are now positioned directly at the panel's left column and paint only the panel's own cells; the frozen table stays visible beside the palette.
|
|
6
|
+
- ✨ Command Palette no longer flickers on large terminals. While the palette is open the table behind it is frozen, so repainting the full table every frame was pure waste: on a 280-column terminal the per-frame write was ~300 KB, got split into chunks, and the terminal showed the bare table for a moment before the palette painted over it, 30 times per second. The frozen table is now written once when the palette opens; after that only the palette itself is repainted (per-frame output divided by ~50).
|
|
7
|
+
- 🧹 Palette height now stays constant while typing a search. Nothing repaints the cells below the panel while it is open (the table behind is frozen), so a shrinking result list used to leave stale painted rows hanging under the panel's bottom edge. Short result lists are padded to the full body height instead.
|
|
8
|
+
|
|
9
|
+
### Tests
|
|
10
|
+
- 2 new regression tests: the palette must never emit margin filler or edge-to-edge erase codes, and the panel must keep a constant height when the result list shrinks. 883 tests passing across 11 suites.
|
|
@@ -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.
|
|
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
|
|
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)', '
|
|
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
|
}
|
package/src/core/config.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
39
|
+
* @exports scanAllToolConfigs, parseToolConfig, softDeleteModel
|
|
43
40
|
*
|
|
44
|
-
* @see src/tool-launchers.js
|
|
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 }
|
package/src/core/kilo-config.js
CHANGED
|
@@ -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`
|
|
11
|
-
* → `saveKiloConfig`
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]) {
|