free-coding-models 0.5.80 โ†’ 0.5.83

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.
@@ -62,6 +62,10 @@ export const ENV_VAR_NAMES = {
62
62
  routeway: 'ROUTEWAY_API_KEY',
63
63
  novita: 'NOVITA_API_KEY',
64
64
  'ollama-cloud': 'OLLAMA_API_KEY',
65
+ pollinations: 'POLLINATIONS_API_KEY',
66
+ siliconflow: 'SILICONFLOW_API_KEY',
67
+ requesty: 'REQUESTY_API_KEY',
68
+ orcarouter: 'ORCAROUTER_API_KEY',
65
69
  }
66
70
 
67
71
  // ๐Ÿ“– OPENCODE_MODEL_MAP: sparse table of model IDs that differ between sources.js and OpenCode's
@@ -201,13 +205,6 @@ export const PROVIDER_METADATA = {
201
205
  signupHint: 'Get API key',
202
206
  rateLimits: 'Gemini free quotas vary by model and region',
203
207
  },
204
- siliconflow: {
205
- label: 'SiliconFlow',
206
- color: chalk.rgb(178, 235, 242),
207
- signupUrl: 'https://cloud.siliconflow.cn/account/ak',
208
- signupHint: 'API Keys โ†’ Create',
209
- rateLimits: 'Free models: usually 100 RPM, varies by model',
210
- },
211
208
  together: {
212
209
  label: 'Together AI',
213
210
  color: chalk.rgb(255, 241, 118),
@@ -259,11 +256,11 @@ export const PROVIDER_METADATA = {
259
256
  rateLimits: 'Depends on provider subscription (e.g., Anthropic, OpenAI)',
260
257
  },
261
258
  'opencode-zen': {
262
- label: 'OpenCode Zen',
259
+ label: 'OpencodeZen',
263
260
  color: chalk.rgb(139, 92, 246), // violet โ€” distinctive from other providers
264
261
  signupUrl: 'https://opencode.ai/auth',
265
262
  signupHint: 'Login at opencode.ai/auth to get your Zen API key',
266
- rateLimits: 'Free tier models โ€” requires OpenCode Zen API key',
263
+ rateLimits: 'Free tier models โ€” requires Zen API key',
267
264
  zenOnly: true,
268
265
  },
269
266
  chutes: {
@@ -312,6 +309,36 @@ export const PROVIDER_METADATA = {
312
309
  rateLimits: 'Only zero-price live chat models are listed; other Novita models are paid/trial-credit',
313
310
  paidProviderNote: 'paid โ€” 3 free models',
314
311
  },
312
+ pollinations: {
313
+ label: 'Pollinations AI โš ๏ธ experimental',
314
+ color: chalk.rgb(255, 105, 180),
315
+ signupUrl: 'https://enter.pollinations.ai',
316
+ signupHint: 'enter.pollinations.ai โ†’ API Keys (free Pollen daily; star github.com/pollinations/pollinations to earn Pollen; 0 Pollen = 402; no purchase needed)',
317
+ rateLimits: 'Free via Pollen (daily grants; ~0.0001-0.003 Pollen/req; 0 Pollen = 402; experimental, system may change)',
318
+ detailedLimits: 'Pollen โ‰ˆ $1. Daily free Pollen via tier (seed/flower/nectar) + quests (star repo, help issue). No purchase required. Cost per request 0.0001-0.003 Pollen. If balance is 0.0000, every MISS returns 402 PAYMENT_REQUIRED and the TUI shows 402; HIT (Cloudflare cache) may still return 200 but is not reliable. Check balance at enter.pollinations.ai โ†’ API Keys. Grants reset daily at midnight UTC. Integration is experimental and not yet confirmed as stable.',
319
+ },
320
+ siliconflow: {
321
+ label: 'SiliconFlow',
322
+ color: chalk.rgb(178, 235, 242),
323
+ signupUrl: 'https://cloud.siliconflow.cn/account/ak',
324
+ signupHint: 'API Keys โ†’ Create (phone SMS verification required)',
325
+ rateLimits: 'Free ยท 3 models @ $0 ยท 1000 RPM ยท 1M tokens/day per model',
326
+ },
327
+ requesty: {
328
+ label: 'Requesty',
329
+ color: chalk.rgb(100, 149, 255),
330
+ signupUrl: 'https://app.requesty.ai/api-keys',
331
+ signupHint: 'API Keys โ†’ Create (200 req/day free, no card)',
332
+ rateLimits: 'Free ยท 200 req/day on free models (20 req/min) ยท no card',
333
+ },
334
+ orcarouter: {
335
+ label: 'OrcaRouter',
336
+ color: chalk.rgb(255, 138, 64),
337
+ signupUrl: 'https://www.orcarouter.ai',
338
+ signupHint: 'Register (GitHub OAuth, no credit card) โ†’ API keys',
339
+ rateLimits: 'Free Hacker tier ยท zero token markup ยท 3 API keys',
340
+ detailedLimits: 'Zero-markup AI gateway: token prices are passed through at provider rates.\nFree Hacker plan: 3 API keys, adaptive routing + automatic failover + guardrails included.\nOnly the explicit $-0 models are listed in this catalog; the orcarouter/fusion family is pay-as-you-go.',
341
+ },
315
342
  'ollama-cloud': {
316
343
  label: 'Ollama Cloud',
317
344
  color: chalk.rgb(230, 230, 230),
@@ -61,6 +61,10 @@ export const PROVIDER_CAPABILITIES = {
61
61
  routeway: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' },
62
62
  novita: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' },
63
63
  'ollama-cloud': { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'rolling' },
64
+ pollinations: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
65
+ siliconflow: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
66
+ requesty: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
67
+ orcarouter: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' },
64
68
  }
65
69
 
66
70
  /** Fallback for unrecognized providers */
@@ -578,7 +578,7 @@ export function cloneHeadersForUpstream(reqHeaders, apiKey, providerKey) {
578
578
  }
579
579
  headers['Content-Type'] = headers['Content-Type'] || 'application/json'
580
580
  headers.Authorization = `Bearer ${apiKey}`
581
- if (providerKey === 'openrouter') {
581
+ if (providerKey === 'openrouter' || providerKey === 'orcarouter') {
582
582
  headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
583
583
  headers['X-Title'] = 'free-coding-models'
584
584
  }
@@ -3619,7 +3619,7 @@ function createDefaultProbeFn(apiKeys) {
3619
3619
  headers.Prefer = 'wait=4'
3620
3620
  } else {
3621
3621
  headers.Authorization = `Bearer ${apiKey}`
3622
- if (provider === 'openrouter') {
3622
+ if (provider === 'openrouter' || provider === 'orcarouter') {
3623
3623
  headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
3624
3624
  headers['X-Title'] = 'free-coding-models'
3625
3625
  }
@@ -8,27 +8,33 @@
8
8
  * ๐Ÿ“– This module:
9
9
  * - Checks config file permissions on startup
10
10
  * - Warns user if permissions are too open
11
- * - Offers auto-fix option with user confirmation
11
+ * - Offers auto-fix option with user confirmation (interactive TTY only)
12
12
  * - Fixes permissions securely (chmod 600 = user read/write only)
13
13
  *
14
+ * ๐Ÿ“– Issue #173: this check used to run un-awaited inside runApp, so the TUI
15
+ * entered raw mode / the alternate screen while the prompt was still pending.
16
+ * On Windows the warning + prompt were invisible and the app looked frozen.
17
+ * Now checkConfigSecurity() is async, awaited by the bin entry BEFORE the TUI
18
+ * starts, and it never prompts on non-TTY stdin or daemon/web/JSON surfaces.
19
+ *
14
20
  * ๐Ÿ“– Secure permissions:
15
21
  * - 0o600 (octal 600) = user:rw, group:---, world:---
16
22
  * - Only the file owner can read or write
17
23
  * - This is the standard for files containing secrets (SSH keys, API keys, etc.)
18
24
  *
19
- * ๐Ÿ“– Why this matters:
20
- * - Shared systems: Other users could read your API keys
21
- * - Git accidents: File could be committed with wrong permissions
22
- * - Backup tools: Might copy files with permissions intact
25
+ * ๐Ÿ“– Windows note: Node's chmod on win32 is best-effort (it can only toggle the
26
+ * read-only attribute, NTFS ACLs still govern real access). We still try,
27
+ * and the manual hint points at icacls for a real fix.
23
28
  *
24
29
  * @functions
25
- * โ†’ checkConfigSecurity() โ€” Main security check, prompts for auto-fix if needed
26
- * โ†’ getConfigPermissions() โ€” Returns file mode object for config
27
- * โ†’ isConfigSecure() โ€” Boolean check if permissions are correct
28
- * โ†’ fixConfigPermissions() โ€” Applies chmod 600 to config file
29
- * โ†’ promptSecurityFix() โ€” Interactive prompt asking user to fix permissions
30
+ * โ†’ checkConfigSecurity() - Async main security check; awaited before the TUI starts
31
+ * โ†’ resolveSecurityAction() - Pure gate deciding auto-fix / prompt / warn-only (in utils.js)
32
+ * โ†’ getConfigPermissions() - Returns file mode object for config
33
+ * โ†’ isConfigSecure() - Boolean check if permissions are correct
34
+ * โ†’ fixConfigPermissions() - Applies chmod 600 to config file (best-effort on Windows)
35
+ * โ†’ promptSecurityFix() - Interactive prompt asking user to fix permissions
30
36
  *
31
- * @exports checkConfigSecurity, isConfigSecure, fixConfigPermissions
37
+ * @exports checkConfigSecurity, isConfigSecure, fixConfigPermissions, formatMode, formatModeRwx
32
38
  */
33
39
 
34
40
  import fs from 'node:fs'
@@ -36,8 +42,9 @@ import path from 'node:path'
36
42
  import os from 'node:os'
37
43
  import readline from 'node:readline'
38
44
  import { CONFIG_PATH } from './config.js'
45
+ import { resolveSecurityAction } from './utils.js'
39
46
 
40
- // ๐Ÿ“– Config file path โ€” matches the path used in config.js (honours the
47
+ // ๐Ÿ“– Config file path - matches the path used in config.js (honours the
41
48
  // ๐Ÿ“– --config-dir / FCM_CONFIG_DIR override when set).
42
49
  function getConfigPath() {
43
50
  return CONFIG_PATH
@@ -47,6 +54,10 @@ function getConfigPath() {
47
54
  // ๐Ÿ“– This means: owner can read+write, group and others have no permissions
48
55
  const SECURE_MODE = 0o600
49
56
 
57
+ // ๐Ÿ“– True on Windows, where chmod is best-effort (read-only bit only) and the
58
+ // ๐Ÿ“– manual fix hint should point at icacls instead of chmod.
59
+ const IS_WINDOWS = process.platform === 'win32'
60
+
50
61
  // ๐Ÿ“– Get file stats including permissions for the config file
51
62
  // ๐Ÿ“– Returns null if file doesn't exist
52
63
  function getConfigPermissions() {
@@ -80,7 +91,7 @@ export function isConfigSecure() {
80
91
  }
81
92
 
82
93
  // ๐Ÿ“– Fix config file permissions to secure mode (chmod 600)
83
- // ๐Ÿ“– Returns true if successful, false otherwise
94
+ // ๐Ÿ“– Best-effort on Windows (read-only bit); returns true if successful, false otherwise
84
95
  export function fixConfigPermissions() {
85
96
  const configPath = getConfigPath()
86
97
 
@@ -97,23 +108,21 @@ export function fixConfigPermissions() {
97
108
  }
98
109
 
99
110
  // ๐Ÿ“– Format permission mode in octal (e.g., 0o644 โ†’ "644")
100
- function formatMode(mode) {
111
+ // ๐Ÿ“– Exported for unit tests
112
+ export function formatMode(mode) {
101
113
  return (mode & 0o777).toString(8).padStart(3, '0')
102
114
  }
103
115
 
104
116
  // ๐Ÿ“– Format permission mode in human-readable rwx format (e.g., 0o644 โ†’ "rw-r--r--")
105
- function formatModeRwx(mode) {
106
- const perms = []
117
+ // ๐Ÿ“– Walks bits 8..0 in groups of three: owner rwx, group rwx, others rwx.
118
+ // ๐Ÿ“– Bit 8 = owner read, bit 7 = owner write, bit 6 = owner exec, and so on.
119
+ // ๐Ÿ“– Exported for unit tests
120
+ export function formatModeRwx(mode) {
107
121
  const types = ['r', 'w', 'x']
122
+ const perms = []
108
123
 
109
- for (let i = 6; i >= 0; i -= 3) {
110
- for (let j = 0; j < 3; j++) {
111
- if (mode & (1 << (i + j))) {
112
- perms.push(types[j])
113
- } else {
114
- perms.push('-')
115
- }
116
- }
124
+ for (let i = 8; i >= 0; i--) {
125
+ perms.push(mode & (1 << i) ? types[(8 - i) % 3] : '-')
117
126
  }
118
127
 
119
128
  return [
@@ -123,10 +132,72 @@ function formatModeRwx(mode) {
123
132
  ].join(' / ')
124
133
  }
125
134
 
126
- // ๐Ÿ“– Check security and prompt for auto-fix if needed
127
- // ๐Ÿ“– Call this on startup before loading config
135
+ // ๐Ÿ“– Print the insecure-permissions warning (stderr, so --json stdout stays clean)
136
+ function printSecurityWarning(perms) {
137
+ const currentMode = formatMode(perms.mode)
138
+ const currentRwx = formatModeRwx(perms.mode)
139
+
140
+ console.error('')
141
+ console.error('โš ๏ธ SECURITY WARNING โš ๏ธ')
142
+ console.error('')
143
+ console.error(`Your config file has insecure permissions: ${currentMode} (${currentRwx})`)
144
+ console.error(`File: ${perms.path}`)
145
+ console.error('')
146
+ console.error('This means other users on this system may be able to read your API keys.')
147
+ console.error('')
148
+ console.error('Recommended: Fix permissions to 600 (rw-------) - owner read/write only')
149
+ }
150
+
151
+ // ๐Ÿ“– Print the manual fix hint. On Windows, point at icacls since Node's chmod
152
+ // ๐Ÿ“– only toggles the read-only attribute there.
153
+ function printManualFixHint() {
154
+ console.error('')
155
+ if (IS_WINDOWS) {
156
+ console.error('To fix manually (PowerShell), run:')
157
+ console.error(` icacls "${getConfigPath()}" /inheritance:r /grant:r "$env:USERNAME:R,W"`)
158
+ } else {
159
+ console.error('To fix manually, run:')
160
+ console.error(` chmod 600 ${getConfigPath()}`)
161
+ }
162
+ console.error('')
163
+ }
164
+
165
+ // ๐Ÿ“– Apply the fix and report the outcome. Shared by the prompt path (user said
166
+ // ๐Ÿ“– yes) and the auto-fix path (--fix-permissions / --yes / -y).
167
+ function applyFixAndReport() {
168
+ const success = fixConfigPermissions()
169
+
170
+ if (success) {
171
+ console.error('')
172
+ console.error('โœ… Permissions fixed! Your API keys are now secure.')
173
+ console.error('')
174
+ if (IS_WINDOWS) {
175
+ console.error('Note: on Windows this is best-effort (read-only bit). See docs for NTFS ACLs.')
176
+ console.error('')
177
+ }
178
+ return { wasSecure: false, wasFixed: true }
179
+ }
180
+
181
+ console.error('')
182
+ console.error('โŒ Failed to fix permissions automatically.')
183
+ printManualFixHint()
184
+ return { wasSecure: false, wasFixed: false, error: 'chmod_failed' }
185
+ }
186
+
187
+ // ๐Ÿ“– Check security and handle the fix flow if needed
188
+ // ๐Ÿ“– Await this BEFORE starting any terminal UI (issue #173) so the warning and
189
+ // ๐Ÿ“– the confirmation prompt are visible and fully resolved before raw mode /
190
+ // ๐Ÿ“– the alternate screen take over.
191
+ //
192
+ // ๐Ÿ“– Options:
193
+ // autoFix - true when --fix-permissions / --yes / -y was passed: apply the
194
+ // fix without asking
195
+ // promptAllowed - false on daemon/web/JSON surfaces: never prompt there, at most
196
+ // warn on stderr
197
+ // stdinIsTTY - override the stdin TTY detection (tests); defaults to real detection
198
+ //
128
199
  // ๐Ÿ“– Returns: { wasSecure: boolean, wasFixed: boolean, error?: string }
129
- export function checkConfigSecurity() {
200
+ export async function checkConfigSecurity(options = {}) {
130
201
  const perms = getConfigPermissions()
131
202
 
132
203
  // ๐Ÿ“– No file yet = nothing to check
@@ -139,24 +210,38 @@ export function checkConfigSecurity() {
139
210
  return { wasSecure: true, wasFixed: false }
140
211
  }
141
212
 
142
- // ๐Ÿ“– Security issue detected! Show warning and offer fix.
143
- const currentMode = formatMode(perms.mode)
144
- const currentRwx = formatModeRwx(perms.mode)
213
+ // ๐Ÿ“– Pure gate (see utils.js): decides auto-fix vs prompt vs warn-only.
214
+ const action = resolveSecurityAction({
215
+ configExists: true,
216
+ isSecure: false,
217
+ autoFixRequested: options.autoFix === true,
218
+ stdinIsTTY: options.stdinIsTTY ?? (process.stdin?.isTTY === true),
219
+ promptAllowed: options.promptAllowed !== false,
220
+ })
145
221
 
146
- console.error('')
147
- console.error('โš ๏ธ SECURITY WARNING โš ๏ธ')
148
- console.error('')
149
- console.error(`Your config file has insecure permissions: ${currentMode} (${currentRwx})`)
150
- console.error(`File: ${perms.path}`)
151
- console.error('')
152
- console.error('This means other users on this system may be able to read your API keys.')
153
- console.error('')
154
- console.error('Recommended: Fix permissions to 600 (rw-------) โ€” owner read/write only')
222
+ if (action === 'none') {
223
+ return { wasSecure: true, wasFixed: false }
224
+ }
225
+
226
+ // ๐Ÿ“– Security issue detected! Print the warning first so it is on screen
227
+ // ๐Ÿ“– no matter which path follows.
228
+ printSecurityWarning(perms)
229
+
230
+ if (action === 'auto-fix') {
231
+ return applyFixAndReport()
232
+ }
233
+
234
+ if (action === 'warn-only') {
235
+ console.error('Running non-interactively (piped stdin or daemon/web mode), so skipping the prompt.')
236
+ printManualFixHint()
237
+ return { wasSecure: false, wasFixed: false, error: 'non_interactive' }
238
+ }
155
239
 
156
240
  return promptSecurityFix()
157
241
  }
158
242
 
159
243
  // ๐Ÿ“– Interactive prompt asking user if they want to auto-fix
244
+ // ๐Ÿ“– Only reached on a real interactive TTY (gated in checkConfigSecurity)
160
245
  // ๐Ÿ“– Returns: { wasSecure: boolean, wasFixed: boolean, error?: string }
161
246
  async function promptSecurityFix() {
162
247
  const rl = readline.createInterface({
@@ -165,37 +250,22 @@ async function promptSecurityFix() {
165
250
  })
166
251
 
167
252
  try {
168
- const answer = await new Promise((resolve) => {
253
+ const rawAnswer = await new Promise((resolve) => {
169
254
  rl.question('Fix permissions automatically? (Y/n): ', resolve)
170
255
  })
171
256
 
172
257
  rl.close()
173
258
 
259
+ // ๐Ÿ“– Normalise: readline can resolve with undefined when stdin closes mid-prompt
260
+ const answer = String(rawAnswer ?? '').trim().toLowerCase()
261
+
174
262
  // ๐Ÿ“– Default to yes if user just presses Enter
175
- if (answer.toLowerCase() === 'y' || answer === '') {
176
- const success = fixConfigPermissions()
177
-
178
- if (success) {
179
- console.error('')
180
- console.error('โœ… Permissions fixed! Your API keys are now secure.')
181
- console.error('')
182
- return { wasSecure: false, wasFixed: true }
183
- } else {
184
- console.error('')
185
- console.error('โŒ Failed to fix permissions automatically.')
186
- console.error('')
187
- console.error('Run this command manually:')
188
- console.error(` chmod 600 ${getConfigPath()}`)
189
- console.error('')
190
- return { wasSecure: false, wasFixed: false, error: 'chmod_failed' }
191
- }
263
+ if (answer === 'y' || answer === '') {
264
+ return applyFixAndReport()
192
265
  } else {
193
266
  console.error('')
194
267
  console.error('โš ๏ธ Permissions not fixed. Your API keys may be at risk.')
195
- console.error('')
196
- console.error('To fix later, run:')
197
- console.error(` chmod 600 ${getConfigPath()}`)
198
- console.error('')
268
+ printManualFixHint()
199
269
  return { wasSecure: false, wasFixed: false, error: 'user_declined' }
200
270
  }
201
271
  } catch (err) {
@@ -203,10 +273,7 @@ async function promptSecurityFix() {
203
273
  // ๐Ÿ“– If we can't prompt (e.g., non-interactive TTY), just warn and continue
204
274
  console.error('')
205
275
  console.error('โš ๏ธ Unable to prompt for permission fix (non-interactive terminal?)')
206
- console.error('')
207
- console.error('To fix manually, run:')
208
- console.error(` chmod 600 ${getConfigPath()}`)
209
- console.error('')
276
+ printManualFixHint()
210
277
  return { wasSecure: false, wasFixed: false, error: 'no_tty' }
211
278
  }
212
279
  }
@@ -192,7 +192,7 @@ function buildHeaders(providerKey, apiKey) {
192
192
  'Content-Type': 'application/json',
193
193
  Authorization: `Bearer ${apiKey}`,
194
194
  }
195
- if (providerKey === 'openrouter') {
195
+ if (providerKey === 'openrouter' || providerKey === 'orcarouter') {
196
196
  headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
197
197
  headers['X-Title'] = 'free-coding-models'
198
198
  }
@@ -50,6 +50,7 @@ import { PROVIDER_METADATA } from './provider-metadata.js'
50
50
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
51
51
  import { ensureDir, readJson, writeJson } from './shared-helpers.js'
52
52
  import { parseContextWindow } from './endpoint-installer.js'
53
+ import { resolveCloudflareUrl } from './ping.js'
53
54
 
54
55
  const OPENAI_COMPAT_ENV_KEYS = [
55
56
  'OPENAI_API_KEY',
@@ -108,10 +109,15 @@ function backupIfExists(filePath) {
108
109
  function getProviderBaseUrl(providerKey) {
109
110
  const url = sources[providerKey]?.url
110
111
  if (!url) return null
111
- return url
112
+ let resolvedUrl = url
112
113
  .replace(/\/chat\/completions$/i, '')
113
114
  .replace(/\/responses$/i, '')
114
115
  .replace(/\/predictions$/i, '')
116
+ // ๐Ÿ“– Cloudflare uses account_id in URL - resolve from CLOUDFLARE_ACCOUNT_ID env var
117
+ if (providerKey === 'cloudflare') {
118
+ resolvedUrl = resolveCloudflareUrl(resolvedUrl)
119
+ }
120
+ return resolvedUrl
115
121
  }
116
122
 
117
123
  function deleteEnvKeys(env, keys) {
@@ -579,9 +585,9 @@ export function writeZCodeConfig(model, config, paths = getDefaultToolPaths()) {
579
585
 
580
586
  const providerKey = model.providerKey || 'nvidia'
581
587
  const apiKey = getApiKey(config, providerKey)
582
- const baseUrl = sources[providerKey]?.url
583
- ? sources[providerKey].url.replace(/\/chat\/completions$/i, '').replace(/\/responses$/i, '').replace(/\/predictions$/i, '')
584
- : null
588
+ // ๐Ÿ“– Use getProviderBaseUrl so Cloudflare's {account_id} placeholder is resolved
589
+ // ๐Ÿ“– (raw sources[].url would leave it literal and ZCode would fail on Cloudflare models).
590
+ const baseUrl = getProviderBaseUrl(providerKey)
585
591
  const providerId = `fcm-${providerKey}`
586
592
  const providerLabel = `FCM ${sources[providerKey]?.name || providerKey}`
587
593
 
@@ -838,7 +844,11 @@ export function prepareExternalToolLaunch(mode, model, config, options = {}) {
838
844
  }
839
845
 
840
846
  if (mode === 'goose') {
841
- const gooseBaseUrl = sources[model.providerKey]?.url || baseUrl || ''
847
+ // ๐Ÿ“– Always use the resolved provider base URL from buildToolEnv (getProviderBaseUrl),
848
+ // ๐Ÿ“– which substitutes Cloudflare's {account_id} placeholder via resolveCloudflareUrl.
849
+ // ๐Ÿ“– Using the raw sources[].url would leave the literal {account_id} in the Goose
850
+ // ๐Ÿ“– provider config, so Goose would POST to /accounts/{account_id}/... and 400.
851
+ const gooseBaseUrl = baseUrl
842
852
  const gooseModelId = resolveLauncherModelId(model)
843
853
  const result = writeGooseConfig({ ...model, modelId: gooseModelId }, apiKey, gooseBaseUrl, model.providerKey, paths)
844
854
  env.GOOSE_PROVIDER = `fcm-${model.providerKey}`
package/src/core/utils.js CHANGED
@@ -409,6 +409,30 @@ export function filterByTier(results, tierLetter) {
409
409
  return results.filter(r => allowed.includes(r.tier))
410
410
  }
411
411
 
412
+ // ๐Ÿ“– PROBE_FAILED_STATUSES: row health states that count as "failed" for the
413
+ // ๐Ÿ“– Shift+P re-probe action (issue #168). Covers the errors a user actually sees
414
+ // ๐Ÿ“– in the table: dead endpoints (404/410/5xx/ERR โ†’ 'down'), rate limits
415
+ // ๐Ÿ“– (429 โ†’ 'down'), rejected keys ('auth_error') and network timeouts.
416
+ // ๐Ÿ“– 'noauth' is deliberately excluded: those rows have no API key, so an
417
+ // ๐Ÿ“– authenticated probe cannot even run for them.
418
+ export const PROBE_FAILED_STATUSES = new Set(['down', 'timeout', 'auth_error'])
419
+
420
+ // ๐Ÿ“– isProbeFailedRow: True when a single result row is currently showing an
421
+ // ๐Ÿ“– error state worth re-probing. Hidden rows are skipped on purpose: Shift+P
422
+ // ๐Ÿ“– re-probes what the user can SEE failing, not rows the probe already hid.
423
+ export function isProbeFailedRow(row) {
424
+ return !!row && !row.hidden && PROBE_FAILED_STATUSES.has(row.status)
425
+ }
426
+
427
+ // ๐Ÿ“– selectProbeFailedRows: Filter a results array down to the rows that are
428
+ // ๐Ÿ“– currently failing (auth fail / 429 / 404 / timeout). Pure helper used by
429
+ // ๐Ÿ“– the TUI Shift+P "re-probe failed rows only" action (issue #168) so users
430
+ // ๐Ÿ“– can retry 1-2 flaky providers without burning quota on the whole list.
431
+ export function selectProbeFailedRows(results) {
432
+ if (!Array.isArray(results)) return []
433
+ return results.filter(isProbeFailedRow)
434
+ }
435
+
412
436
  // ๐Ÿ“– findBestModel: Pick the single best model from a results array.
413
437
  // ๐Ÿ“– Used by --fiable mode to output the most reliable model after 10s of analysis.
414
438
  //
@@ -460,6 +484,7 @@ export function findBestModel(results) {
460
484
  // --daemon, --daemon-bg, --daemon-stop,
461
485
  // --daemon-status, --no-telemetry, --json, --help/-h (case-insensitive)
462
486
  // --playground / playground subcommand (open the in-TUI chat playground)
487
+ // --fix-permissions / --yes / -y (auto-fix config permissions without prompting)
463
488
  // - Value flag: --tier <letter> (the next non-flag arg is the tier value)
464
489
  // - Probe-cache flags (t1):
465
490
  // --reprobe / --no-cache (boolean) โ€” force-rebuild the probe cache this run
@@ -533,7 +558,8 @@ export function parseArgs(argv) {
533
558
  if (configDirValueIdx !== -1) skipIndices.add(configDirValueIdx)
534
559
 
535
560
  for (const [i, arg] of args.entries()) {
536
- if (arg.startsWith('--') || arg === '-h') {
561
+ // ๐Ÿ“– -y is a boolean flag (security auto-fix), never an API key
562
+ if (arg.startsWith('--') || arg === '-h' || arg === '-y') {
537
563
  flags.push(arg.toLowerCase())
538
564
  } else if (skipIndices.has(i)) {
539
565
  // ๐Ÿ“– Skip โ€” this is a value for --tier, not an API key
@@ -577,6 +603,11 @@ export function parseArgs(argv) {
577
603
  const daemonStopMode = flags.includes('--daemon-stop')
578
604
  const daemonStatusMode = flags.includes('--daemon-status')
579
605
 
606
+ // ๐Ÿ“– --fix-permissions / --yes / -y - auto-answer "yes" to the config-permission
607
+ // ๐Ÿ“– security prompt (chmod 600, best-effort on Windows) so scripts, CI and
608
+ // ๐Ÿ“– non-interactive terminals never hang on a hidden prompt. Issue #173.
609
+ const fixPermissionsMode = flags.includes('--fix-permissions') || flags.includes('--yes') || flags.includes('-y')
610
+
580
611
  // ๐Ÿ“– --sync-set [name] โ€” auto-discover and populate a router set with best available models
581
612
  const syncSetMode = flags.includes('--sync-set')
582
613
  const syncSetName = syncSetValueIdx !== -1 ? args[syncSetValueIdx] : null
@@ -686,9 +717,65 @@ export function parseArgs(argv) {
686
717
  clearRuntimeMode,
687
718
  // ๐Ÿ“– Config location flag โ€” see src/core/config.js getConfigDir()
688
719
  configDir: configDirValueIdx !== -1 ? args[configDirValueIdx] : null,
720
+ // ๐Ÿ“– Security auto-fix flag - see src/core/security.js checkConfigSecurity()
721
+ fixPermissionsMode,
689
722
  }
690
723
  }
691
724
 
725
+ // โ”€โ”€โ”€ Config Security Gating (issue #173) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
726
+
727
+ // ๐Ÿ“– resolveSecurityAction: pure decision helper that tells the startup security
728
+ // ๐Ÿ“– check what it should do about insecure config file permissions.
729
+ //
730
+ // ๐Ÿ“– Why: the security warning + "Fix permissions automatically?" prompt used to
731
+ // ๐Ÿ“– run un-awaited while the TUI entered raw mode / the alternate screen, so the
732
+ // ๐Ÿ“– prompt was invisible (worst on Windows) and the app looked frozen. This gate
733
+ // ๐Ÿ“– guarantees: never prompt without an interactive surface, never prompt a
734
+ // ๐Ÿ“– daemon/web/JSON surface, and always auto-fix when a yes-flag is passed.
735
+ //
736
+ // ๐Ÿ“– Params:
737
+ // configExists - does the config file exist (no file = nothing to secure)
738
+ // isSecure - are permissions already 0600
739
+ // autoFixRequested - user passed --fix-permissions / --yes / -y
740
+ // stdinIsTTY - is stdin an interactive terminal
741
+ // promptAllowed - is this an interactive surface (TUI)? false for daemon/web/JSON
742
+ //
743
+ // ๐Ÿ“– Returns one of: 'none' | 'auto-fix' | 'warn-only' | 'prompt'
744
+ export function resolveSecurityAction({ configExists, isSecure, autoFixRequested, stdinIsTTY, promptAllowed }) {
745
+ if (!configExists || isSecure) return 'none'
746
+ if (autoFixRequested) return 'auto-fix'
747
+ if (!stdinIsTTY || !promptAllowed) return 'warn-only'
748
+ return 'prompt'
749
+ }
750
+
751
+ // ๐Ÿ“– detectTerminalCapabilities: PURE terminal capability probe for TUI overlays.
752
+ // ๐Ÿ“– WHY: basic server consoles (IPMI/KVM viewers, ASPEED framebuffer, serial
753
+ // ๐Ÿ“– terminals) often run 80x24 or smaller with no or limited color support, and
754
+ // ๐Ÿ“– the palette/overlays must degrade instead of overflowing or painting garbage.
755
+ // ๐Ÿ“– Everything is injected by the caller (env, size, TTY) so tests never touch
756
+ // ๐Ÿ“– process.env and the function stays deterministic.
757
+ //
758
+ // ๐Ÿ“– Rules:
759
+ // - FORCE_COLOR wins over NO_COLOR (same precedence chalk uses).
760
+ // - NO_COLOR (non-empty, per no-color.org spec) disables color.
761
+ // - Not a TTY, TERM missing / "dumb" / "unknown" disables color, unless
762
+ // COLORTERM is set (some terminals export only COLORTERM).
763
+ // - compact = size too tight for the roomy overlay layout (cols < 90 or rows < 24,
764
+ // thresholds chosen so a plain 80x24 console gets the space-saving layout).
765
+ //
766
+ // ๐Ÿ“– Returns { colorSupported: boolean, compact: boolean }
767
+ export function detectTerminalCapabilities({ env = {}, cols = 80, rows = 24, isTTY = true } = {}) {
768
+ const term = String(env.TERM ?? '').trim().toLowerCase()
769
+ const colorterm = String(env.COLORTERM ?? '').trim().toLowerCase()
770
+ const force = env.FORCE_COLOR
771
+ const forceOn = force !== undefined && force !== '' && force !== '0' && force !== 'false'
772
+ const noColor = env.NO_COLOR !== undefined && env.NO_COLOR !== ''
773
+ const termUsable = term !== '' && term !== 'dumb' && term !== 'unknown'
774
+ const colorSupported = forceOn || (!noColor && isTTY !== false && (termUsable || colorterm !== ''))
775
+ const compact = Math.floor(cols) < 90 || Math.floor(rows) < 24
776
+ return { colorSupported, compact }
777
+ }
778
+
692
779
  // โ”€โ”€โ”€ Smart Recommend โ€” Scoring Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
693
780
 
694
781
  // ๐Ÿ“– Task types for the Smart Recommend questionnaire.
package/src/tui/app.js CHANGED
@@ -143,7 +143,6 @@ import {
143
143
  isProviderQuotaPaused,
144
144
  listPausedProviders,
145
145
  } from '../core/provider-cooldown.js'
146
- import { checkConfigSecurity } from '../core/security.js'
147
146
  import { buildCliHelpText } from './cli-help.js'
148
147
  import { detectActiveTheme, THEME_BG_RGB, getTheme, patchThemeBg } from './theme.js'
149
148
 
@@ -271,10 +270,9 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
271
270
  detectActiveTheme(config.settings?.theme || 'auto')
272
271
 
273
272
  // ๐Ÿ“– Check config file security โ€” warn and offer auto-fix if permissions are too open
274
- const securityCheck = checkConfigSecurity()
275
- if (!securityCheck.wasSecure && !securityCheck.wasFixed) {
276
- // ๐Ÿ“– User declined auto-fix or it failed โ€” continue anyway, just warned
277
- }
273
+ // ๐Ÿ“– (issue #173): this moved to bin/free-coding-models.js BEFORE runApp so the
274
+ // ๐Ÿ“– warning + prompt are visible and resolved before the alternate screen / raw
275
+ // ๐Ÿ“– mode take over. Do not call checkConfigSecurity() here again.
278
276
 
279
277
  // ๐Ÿ“– Apply CLI overrides for settings
280
278
  if (cliArgs.sortColumn) config.settings.sortColumn = cliArgs.sortColumn
@@ -1045,6 +1043,10 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1045
1043
  probeTotal: state.probeTotal,
1046
1044
  probeCompleted: state.probeCompleted,
1047
1045
  probeHiddenCount: state.probeHiddenCount,
1046
+ // ๐Ÿ“– Space-expanded row (issue #168): "providerKey/modelId" or null
1047
+ expandedRowKey: state.expandedRowKey ?? null,
1048
+ // ๐Ÿ“– Non-fatal action error chip (issue #168): only shown while fresh
1049
+ actionErrorMsg: (state.actionErrorMsgUntil || 0) > Date.now() ? state.actionErrorMsg : null,
1048
1050
  // ๐Ÿ“– Probe-cache (t1): footer chip stats
1049
1051
  probeCacheHits: state.probeCacheHits || 0,
1050
1052
  probeCacheMisses: state.probeCacheMisses || 0,
@@ -49,6 +49,7 @@ const CONFIG_FLAGS = [
49
49
  { flag: '--daemon-stop', description: 'Gracefully stop the FCM Router daemon' },
50
50
  { flag: '--sync-set [name]', description: 'Auto-discover and live-probe models into a router set' },
51
51
  { flag: '--config-dir <dir>', description: 'Store config.json + backups/ in <dir> (e.g. --config-dir ~/.config/free-coding-models for the XDG layout)' },
52
+ { flag: '--fix-permissions, --yes, -y', description: 'Auto-fix insecure config file permissions (chmod 600) without prompting; safe for scripts and CI' },
52
53
  { flag: '--no-telemetry', description: 'Disable anonymous telemetry for this run' },
53
54
  { flag: '--help, -h', description: 'Print this help and exit' },
54
55
  ]
@@ -45,7 +45,7 @@ const TOOL_MODE_DESCRIPTIONS = {
45
45
  hermes: 'Launch Hermes Agent with the selected model.',
46
46
  'continue': 'Launch Continue CLI with the selected model.',
47
47
  cline: 'Launch Cline CLI with the selected model.',
48
- caveman: 'Caveman Code โ€” token-efficient coding agent (launch with Caveman tool only).',
48
+ caveman: 'Caveman Code - token-efficient coding agent (launch with Caveman tool only).',
49
49
  jcode: 'Launch jcode coding agent with the selected model.',
50
50
  }
51
51
 
@@ -201,7 +201,8 @@ const BASE_COMMAND_TREE = [
201
201
  },
202
202
  { id: 'action-cycle-theme', label: 'Cycle theme', shortcut: 'G', icon: '๐ŸŒ—', description: 'Switch dark/light/auto', keywords: ['theme', 'dark', 'light', 'auto'] },
203
203
  { id: 'action-reset-view', label: 'Reset view', shortcut: 'N', icon: '\u{1F504}', description: 'Reset filters and sort', keywords: ['reset', 'view', 'sort', 'filters'] },
204
- { id: 'action-probe-404', label: 'Probe 404 Models', shortcut: 'Ctrl+Shift+P', icon: '\u{1F50D}', description: 'Test all configured models for 404/410 errors. Auto-hides broken models.', keywords: ['probe', '404', 'broken', 'dead', 'health', 'check', 'test', 'verify'] },
204
+ { id: 'action-probe-failed', label: 'Re-probe Failed Models', shortcut: 'Shift+P', icon: '\u{1F50D}', description: 'Re-test only rows showing errors (auth fail / 429 / 404 / timeout) - not the whole list.', keywords: ['probe', 'failed', 'retry', '429', 'auth', 'error', 'broken', 'rescan', 'health'] },
205
+ { id: 'action-probe-404', label: 'Probe All Models (404)', shortcut: 'Ctrl+Shift+P', icon: '\u{1F50D}', description: 'Test all configured models for 404/410 errors. Auto-hides broken models.', keywords: ['probe', '404', 'broken', 'dead', 'health', 'check', 'test', 'verify', 'all'] },
205
206
  { id: 'action-toggle-auto-hide-broken', label: 'Toggle Auto-hide Broken Models', icon: '\u{1F6A9}', description: 'Enable/disable auto-hiding of 404/410 models. Unhides all when disabled.', keywords: ['auto', 'hide', 'broken', '404', 'models', 'toggle', 'probe'] },
206
207
  ],
207
208
  },