free-coding-models 0.5.81 → 0.5.84

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.
@@ -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.