free-coding-models 0.1.61 โ†’ 0.1.63

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 CHANGED
@@ -64,6 +64,7 @@
64
64
  - **๐Ÿ“ถ Status indicators** โ€” UP โœ… ยท No Key ๐Ÿ”‘ ยท Timeout โณ ยท Overloaded ๐Ÿ”ฅ ยท Not Found ๐Ÿšซ
65
65
  - **๐Ÿ” Keyless latency** โ€” Models are pinged even without an API key โ€” a `๐Ÿ”‘ NO KEY` status confirms the server is reachable with real latency shown, so you can compare providers before committing to a key
66
66
  - **๐Ÿท Tier filtering** โ€” Filter models by tier letter (S, A, B, C) with `--tier` flag or dynamically with `T` key
67
+ - **๐Ÿ“Š Privacy-first analytics (optional)** โ€” anonymous PostHog events with explicit consent + opt-out
67
68
 
68
69
  ---
69
70
 
@@ -85,7 +86,7 @@ Before using `free-coding-models`, make sure you have:
85
86
  3. **OpenCode** *(optional)* โ€” [Install OpenCode](https://github.com/opencode-ai/opencode) to use the OpenCode integration
86
87
  4. **OpenClaw** *(optional)* โ€” [Install OpenClaw](https://openclaw.ai) to use the OpenClaw integration
87
88
 
88
- > ๐Ÿ’ก **Tip:** You don't need all four providers. One key is enough to get started. Add more later via the Settings screen (`P` key). Models without a key still show real latency (`๐Ÿ”‘ NO KEY`) so you can evaluate providers before signing up.
89
+ > ๐Ÿ’ก **Tip:** You don't need all nine providers. One key is enough to get started. Add more later via the Settings screen (`P` key). Models without a key still show real latency (`๐Ÿ”‘ NO KEY`) so you can evaluate providers before signing up.
89
90
 
90
91
  ---
91
92
 
@@ -130,6 +131,9 @@ free-coding-models --best
130
131
  # Analyze for 10 seconds and output the most reliable model
131
132
  free-coding-models --fiable
132
133
 
134
+ # Disable anonymous analytics for this run
135
+ free-coding-models --no-telemetry
136
+
133
137
  # Filter models by tier letter
134
138
  free-coding-models --tier S # S+ and S only
135
139
  free-coding-models --tier A # A+, A, A- only
@@ -199,7 +203,7 @@ Setup wizard (first run โ€” walks through all 9 providers):
199
203
  You can add or change keys anytime with the P key in the TUI.
200
204
  ```
201
205
 
202
- You don't need all four โ€” skip any provider by pressing Enter. At least one key is required.
206
+ You don't need all nine โ€” skip any provider by pressing Enter. At least one key is required.
203
207
 
204
208
  ### Adding or changing keys later
205
209
 
@@ -225,6 +229,8 @@ Press **`P`** to open the Settings screen at any time:
225
229
 
226
230
  Keys are saved to `~/.free-coding-models.json` (permissions `0600`).
227
231
 
232
+ Analytics toggle is in the same Settings screen (`P`) as a dedicated row (toggle with Enter or Space).
233
+
228
234
  ### Environment variable overrides
229
235
 
230
236
  Env vars always take priority over the config file:
@@ -233,8 +239,19 @@ Env vars always take priority over the config file:
233
239
  NVIDIA_API_KEY=nvapi-xxx free-coding-models
234
240
  GROQ_API_KEY=gsk_xxx free-coding-models
235
241
  CEREBRAS_API_KEY=csk_xxx free-coding-models
242
+ FREE_CODING_MODELS_TELEMETRY=0 free-coding-models
236
243
  ```
237
244
 
245
+ Telemetry env vars:
246
+
247
+ - `FREE_CODING_MODELS_TELEMETRY=0|1` โ€” force disable/enable analytics
248
+ - `FREE_CODING_MODELS_POSTHOG_KEY` โ€” PostHog project API key (required to send events)
249
+ - `FREE_CODING_MODELS_POSTHOG_HOST` โ€” optional ingest host (`https://eu.i.posthog.com` default)
250
+ - `FREE_CODING_MODELS_TELEMETRY_DEBUG=1` โ€” optional stderr debug logs for telemetry troubleshooting
251
+
252
+ On first run (or when consent policy changes), the CLI asks users to accept or decline anonymous analytics.
253
+ When enabled, telemetry events include: event name, app version, selected mode, system (`macOS`/`Windows`/`Linux`), and terminal family (`Terminal.app`, `iTerm2`, `kitty`, `Warp`, `WezTerm`, etc., with generic fallback from `TERM_PROGRAM`/`TERM`).
254
+
238
255
  ### Get your free API keys
239
256
 
240
257
  **NVIDIA NIM** (44 models, S+ โ†’ C tier):
@@ -500,11 +517,14 @@ This script:
500
517
 
501
518
  **Environment variables (override config file):**
502
519
 
503
- | Variable | Provider |
504
- |----------|----------|
505
- | `NVIDIA_API_KEY` | NVIDIA NIM |
506
- | `GROQ_API_KEY` | Groq |
507
- | `CEREBRAS_API_KEY` | Cerebras |
520
+ | Variable | Description |
521
+ |----------|-------------|
522
+ | `NVIDIA_API_KEY` | NVIDIA NIM key |
523
+ | `GROQ_API_KEY` | Groq key |
524
+ | `CEREBRAS_API_KEY` | Cerebras key |
525
+ | `FREE_CODING_MODELS_TELEMETRY` | `0` disables analytics, `1` enables analytics |
526
+ | `FREE_CODING_MODELS_POSTHOG_KEY` | PostHog project API key used for anonymous event capture |
527
+ | `FREE_CODING_MODELS_POSTHOG_HOST` | Optional PostHog ingest host (`https://eu.i.posthog.com` default) |
508
528
 
509
529
  **Config file:** `~/.free-coding-models.json` (created automatically, permissions `0600`)
510
530
 
@@ -519,6 +539,11 @@ This script:
519
539
  "nvidia": { "enabled": true },
520
540
  "groq": { "enabled": true },
521
541
  "cerebras": { "enabled": true }
542
+ },
543
+ "telemetry": {
544
+ "enabled": true,
545
+ "consentVersion": 1,
546
+ "anonymousId": "anon_550e8400-e29b-41d4-a716-446655440000"
522
547
  }
523
548
  }
524
549
  ```
@@ -538,6 +563,7 @@ This script:
538
563
  | `--openclaw` | OpenClaw mode โ€” Enter sets selected model as default in OpenClaw |
539
564
  | `--best` | Show only top-tier models (A+, S, S+) |
540
565
  | `--fiable` | Analyze 10 seconds, output the most reliable model as `provider/model_id` |
566
+ | `--no-telemetry` | Disable anonymous analytics for this run |
541
567
  | `--tier S` | Show only S+ and S tier models |
542
568
  | `--tier A` | Show only A+, A, A- tier models |
543
569
  | `--tier B` | Show only B+, B tier models |
@@ -549,15 +575,15 @@ This script:
549
575
  - **R/Y/O/M/L/A/S/N/H/V/U** โ€” Sort by Rank/Tier/Origin/Model/LatestPing/Avg/SWE/Ctx/Health/Verdict/Uptime
550
576
  - **T** โ€” Cycle tier filter (All โ†’ S+ โ†’ S โ†’ A+ โ†’ A โ†’ A- โ†’ B+ โ†’ B โ†’ C โ†’ All)
551
577
  - **Z** โ€” Cycle mode (OpenCode CLI โ†’ OpenCode Desktop โ†’ OpenClaw)
552
- - **P** โ€” Open Settings (manage API keys, enable/disable providers)
578
+ - **P** โ€” Open Settings (manage API keys, provider toggles, analytics toggle)
553
579
  - **W** โ€” Decrease ping interval (faster pings)
554
580
  - **X** โ€” Increase ping interval (slower pings)
555
581
  - **Ctrl+C** โ€” Exit
556
582
 
557
583
  **Keyboard shortcuts (Settings screen โ€” `P` key):**
558
- - **โ†‘โ†“** โ€” Navigate providers
559
- - **Enter** โ€” Edit API key inline (type key, Enter to save, Esc to cancel)
560
- - **Space** โ€” Toggle provider enabled/disabled
584
+ - **โ†‘โ†“** โ€” Navigate providers and analytics row
585
+ - **Enter** โ€” Edit API key inline, or toggle analytics on analytics row
586
+ - **Space** โ€” Toggle provider enabled/disabled, or toggle analytics on analytics row
561
587
  - **T** โ€” Test current provider's API key (fires a live ping)
562
588
  - **Esc** โ€” Close settings and return to main TUI
563
589
 
@@ -615,6 +641,6 @@ We welcome contributions! Feel free to open issues, submit pull requests, or get
615
641
 
616
642
  For questions or issues, open a [GitHub issue](https://github.com/vava-nessa/free-coding-models/issues).
617
643
 
618
- ๐Ÿ’ฌ Let's talk about the projet on Discord: https://discord.gg/5MbTnDC3Md
644
+ ๐Ÿ’ฌ Let's talk about the project on Discord: https://discord.gg/5MbTnDC3Md
619
645
 
620
646
  > โš ๏ธ **free-coding-models is a BETA TUI** โ€” it might crash or have problems. Use at your own risk and feel free to report issues!
@@ -27,6 +27,11 @@
27
27
  *
28
28
  * โ†’ Functions:
29
29
  * - `loadConfig` / `saveConfig` / `getApiKey`: Multi-provider JSON config via lib/config.js
30
+ * - `promptTelemetryConsent`: First-run consent flow for anonymous analytics
31
+ * - `getTelemetryDistinctId`: Generate/reuse a stable anonymous ID for telemetry
32
+ * - `getTelemetryTerminal`: Infer terminal family (Terminal.app, iTerm2, kitty, etc.)
33
+ * - `isTelemetryDebugEnabled` / `telemetryDebug`: Optional runtime telemetry diagnostics via env
34
+ * - `sendUsageTelemetry`: Fire-and-forget anonymous app-start event
30
35
  * - `promptApiKey`: Interactive wizard for first-time NVIDIA API key setup
31
36
  * - `promptModeSelection`: Startup menu to choose OpenCode vs OpenClaw
32
37
  * - `ping`: Perform HTTP request to NIM endpoint with timeout handling
@@ -67,6 +72,7 @@
67
72
  * - --openclaw: OpenClaw mode (set selected model as default in OpenClaw)
68
73
  * - --best: Show only top-tier models (A+, S, S+)
69
74
  * - --fiable: Analyze 10s and output the most reliable model
75
+ * - --no-telemetry: Disable anonymous usage analytics for this run
70
76
  * - --tier S/A/B/C: Filter models by tier letter (S=S+/S, A=A+/A/A-, B=B+/B, C=C)
71
77
  *
72
78
  * @see {@link https://build.nvidia.com} NVIDIA API key generation
@@ -77,6 +83,7 @@
77
83
  import chalk from 'chalk'
78
84
  import { createRequire } from 'module'
79
85
  import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs'
86
+ import { randomUUID } from 'crypto'
80
87
  import { homedir } from 'os'
81
88
  import { join, dirname } from 'path'
82
89
  import { MODELS, sources } from '../sources.js'
@@ -90,6 +97,322 @@ const readline = require('readline')
90
97
  // โ”€โ”€โ”€ Version check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
91
98
  const pkg = require('../package.json')
92
99
  const LOCAL_VERSION = pkg.version
100
+ const TELEMETRY_CONSENT_VERSION = 1
101
+ const TELEMETRY_TIMEOUT = 1_200
102
+ const POSTHOG_CAPTURE_PATH = '/i/v0/e/'
103
+ const POSTHOG_DEFAULT_HOST = 'https://eu.i.posthog.com'
104
+ // ๐Ÿ“– Consent ASCII banner shown before telemetry choice to make first-run intent explicit.
105
+ const TELEMETRY_CONSENT_ASCII = [
106
+ 'โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ',
107
+ 'โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ',
108
+ 'โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ',
109
+ 'โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ',
110
+ 'โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ',
111
+ '',
112
+ '',
113
+ ]
114
+ // ๐Ÿ“– Maintainer defaults for global npm telemetry (safe to publish: project key is a public ingest token).
115
+ const POSTHOG_PROJECT_KEY_DEFAULT = 'phc_5P1n8HaLof6nHM0tKJYt4bV5pj2XPb272fLVigwf1YQ'
116
+ const POSTHOG_HOST_DEFAULT = 'https://eu.i.posthog.com'
117
+
118
+ // ๐Ÿ“– parseTelemetryEnv: Convert env var strings into booleans.
119
+ // ๐Ÿ“– Returns true/false when value is recognized, otherwise null.
120
+ function parseTelemetryEnv(value) {
121
+ if (typeof value !== 'string') return null
122
+ const normalized = value.trim().toLowerCase()
123
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
124
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false
125
+ return null
126
+ }
127
+
128
+ // ๐Ÿ“– Optional debug switch for telemetry troubleshooting (disabled by default).
129
+ function isTelemetryDebugEnabled() {
130
+ return parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY_DEBUG) === true
131
+ }
132
+
133
+ // ๐Ÿ“– Writes telemetry debug traces to stderr only when explicitly enabled.
134
+ function telemetryDebug(message, meta = null) {
135
+ if (!isTelemetryDebugEnabled()) return
136
+ const prefix = '[telemetry-debug]'
137
+ if (meta === null) {
138
+ process.stderr.write(`${prefix} ${message}\n`)
139
+ return
140
+ }
141
+ try {
142
+ process.stderr.write(`${prefix} ${message} ${JSON.stringify(meta)}\n`)
143
+ } catch {
144
+ process.stderr.write(`${prefix} ${message}\n`)
145
+ }
146
+ }
147
+
148
+ // ๐Ÿ“– Ensure telemetry config shape exists even on old config files.
149
+ function ensureTelemetryConfig(config) {
150
+ if (!config.telemetry || typeof config.telemetry !== 'object') {
151
+ config.telemetry = { enabled: null, consentVersion: 0, anonymousId: null }
152
+ }
153
+ if (typeof config.telemetry.enabled !== 'boolean') config.telemetry.enabled = null
154
+ if (typeof config.telemetry.consentVersion !== 'number') config.telemetry.consentVersion = 0
155
+ if (typeof config.telemetry.anonymousId !== 'string' || !config.telemetry.anonymousId.trim()) {
156
+ config.telemetry.anonymousId = null
157
+ }
158
+ }
159
+
160
+ // ๐Ÿ“– Create or reuse a persistent anonymous distinct_id for PostHog.
161
+ // ๐Ÿ“– Stored locally in config so one user is stable over time without personal data.
162
+ function getTelemetryDistinctId(config) {
163
+ ensureTelemetryConfig(config)
164
+ if (config.telemetry.anonymousId) return config.telemetry.anonymousId
165
+
166
+ config.telemetry.anonymousId = `anon_${randomUUID()}`
167
+ saveConfig(config)
168
+ return config.telemetry.anonymousId
169
+ }
170
+
171
+ // ๐Ÿ“– Convert Node platform to human-readable system name for analytics segmentation.
172
+ function getTelemetrySystem() {
173
+ if (process.platform === 'darwin') return 'macOS'
174
+ if (process.platform === 'win32') return 'Windows'
175
+ if (process.platform === 'linux') return 'Linux'
176
+ return process.platform
177
+ }
178
+
179
+ // ๐Ÿ“– Infer terminal family from environment hints for coarse usage segmentation.
180
+ // ๐Ÿ“– Never sends full env dumps; only a normalized terminal label is emitted.
181
+ function getTelemetryTerminal() {
182
+ const termProgramRaw = (process.env.TERM_PROGRAM || '').trim()
183
+ const termProgram = termProgramRaw.toLowerCase()
184
+ const term = (process.env.TERM || '').toLowerCase()
185
+
186
+ if (termProgram === 'apple_terminal') return 'Terminal.app'
187
+ if (termProgram === 'iterm.app') return 'iTerm2'
188
+ if (termProgram === 'warpterminal' || process.env.WARP_IS_LOCAL_SHELL_SESSION) return 'Warp'
189
+ if (process.env.WT_SESSION) return 'Windows Terminal'
190
+ if (process.env.KITTY_WINDOW_ID || term.includes('kitty')) return 'kitty'
191
+ if (process.env.GHOSTTY_RESOURCES_DIR || term.includes('ghostty')) return 'Ghostty'
192
+ if (process.env.WEZTERM_PANE || termProgram === 'wezterm') return 'WezTerm'
193
+ if (process.env.KONSOLE_VERSION || termProgram === 'konsole') return 'Konsole'
194
+ if (process.env.GNOME_TERMINAL_SCREEN || termProgram === 'gnome-terminal') return 'GNOME Terminal'
195
+ if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') return 'JetBrains Terminal'
196
+ if (process.env.TABBY_CONFIG_DIRECTORY || termProgram === 'tabby') return 'Tabby'
197
+ if (termProgram === 'vscode' || process.env.VSCODE_GIT_IPC_HANDLE) return 'VS Code Terminal'
198
+ if (process.env.ALACRITTY_SOCKET || term.includes('alacritty') || termProgram === 'alacritty') return 'Alacritty'
199
+ if (term.includes('foot') || termProgram === 'foot') return 'foot'
200
+ if (termProgram === 'hyper' || process.env.HYPER) return 'Hyper'
201
+ if (process.env.TMUX) return 'tmux'
202
+ if (process.env.STY) return 'screen'
203
+ // ๐Ÿ“– Generic fallback for many terminals exposing TERM_PROGRAM (e.g., Rio, Contour, etc.).
204
+ if (termProgramRaw) return termProgramRaw
205
+ if (term) return term
206
+
207
+ return 'unknown'
208
+ }
209
+
210
+ // ๐Ÿ“– Prompt consent on first run (or when consent schema version changes).
211
+ // ๐Ÿ“– This prompt is skipped when the env var explicitly controls telemetry.
212
+ async function promptTelemetryConsent(config, cliArgs) {
213
+ if (cliArgs.noTelemetry) return
214
+
215
+ const envTelemetry = parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY)
216
+ if (envTelemetry !== null) return
217
+
218
+ ensureTelemetryConfig(config)
219
+ const hasStoredChoice = typeof config.telemetry.enabled === 'boolean'
220
+ const isConsentCurrent = config.telemetry.consentVersion >= TELEMETRY_CONSENT_VERSION
221
+ if (hasStoredChoice && isConsentCurrent) return
222
+
223
+ // ๐Ÿ“– Non-interactive runs should never hang waiting for input.
224
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
225
+ // ๐Ÿ“– Do not mutate persisted consent in headless runs.
226
+ // ๐Ÿ“– We simply skip the prompt; runtime telemetry remains governed by env/config precedence.
227
+ return
228
+ }
229
+
230
+ const options = [
231
+ { label: 'Accept & Continue', value: true, emoji: '๐Ÿ’–๐Ÿฅฐ๐Ÿ’–' },
232
+ { label: 'Reject and Continue', value: false, emoji: '๐Ÿ˜ข' },
233
+ ]
234
+ let selected = 0 // ๐Ÿ“– Default selection is Accept & Continue.
235
+
236
+ const accepted = await new Promise((resolve) => {
237
+ const render = () => {
238
+ const EL = '\x1b[K'
239
+ const lines = []
240
+ for (const asciiLine of TELEMETRY_CONSENT_ASCII) {
241
+ lines.push(chalk.greenBright(asciiLine))
242
+ }
243
+ lines.push(chalk.greenBright(`free-coding-models (v${LOCAL_VERSION})`))
244
+ lines.push(chalk.greenBright('Welcome ! Would you like to help improve the app and fix bugs by activating PostHog telemetry (anonymous & secure)'))
245
+ lines.push(chalk.greenBright("anonymous telemetry analytics (we don't collect anything from you)"))
246
+ lines.push('')
247
+
248
+ for (let i = 0; i < options.length; i++) {
249
+ const isSelected = i === selected
250
+ const option = options[i]
251
+ const buttonText = `${option.emoji} ${option.label}`
252
+
253
+ let button
254
+ if (isSelected && option.value) button = chalk.black.bgGreenBright(` ${buttonText} `)
255
+ else if (isSelected && !option.value) button = chalk.black.bgRedBright(` ${buttonText} `)
256
+ else if (option.value) button = chalk.greenBright(` ${buttonText} `)
257
+ else button = chalk.redBright(` ${buttonText} `)
258
+
259
+ const prefix = isSelected ? chalk.cyan(' โฏ ') : chalk.dim(' ')
260
+ lines.push(prefix + button)
261
+ }
262
+
263
+ lines.push('')
264
+ lines.push(chalk.dim(' โ†‘โ†“ Navigate โ€ข Enter Select'))
265
+ lines.push(chalk.dim(' You can change this later in Settings (P).'))
266
+ lines.push('')
267
+
268
+ // ๐Ÿ“– Avoid full-screen clear escape here to prevent title/header offset issues in some terminals.
269
+ const cleared = lines.map(l => l + EL)
270
+ const terminalRows = process.stdout.rows || 24
271
+ const remaining = Math.max(0, terminalRows - cleared.length)
272
+ for (let i = 0; i < remaining; i++) cleared.push(EL)
273
+ process.stdout.write('\x1b[H' + cleared.join('\n'))
274
+ }
275
+
276
+ const cleanup = () => {
277
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
278
+ process.stdin.removeListener('keypress', onKeyPress)
279
+ process.stdin.pause()
280
+ }
281
+
282
+ const onKeyPress = (_str, key) => {
283
+ if (!key) return
284
+
285
+ if (key.ctrl && key.name === 'c') {
286
+ cleanup()
287
+ resolve(false)
288
+ return
289
+ }
290
+
291
+ if ((key.name === 'up' || key.name === 'left') && selected > 0) {
292
+ selected--
293
+ render()
294
+ return
295
+ }
296
+
297
+ if ((key.name === 'down' || key.name === 'right') && selected < options.length - 1) {
298
+ selected++
299
+ render()
300
+ return
301
+ }
302
+
303
+ if (key.name === 'return') {
304
+ cleanup()
305
+ resolve(options[selected].value)
306
+ }
307
+ }
308
+
309
+ readline.emitKeypressEvents(process.stdin)
310
+ process.stdin.setEncoding('utf8')
311
+ process.stdin.resume()
312
+ if (process.stdin.isTTY) process.stdin.setRawMode(true)
313
+ process.stdin.on('keypress', onKeyPress)
314
+ render()
315
+ })
316
+
317
+ config.telemetry.enabled = accepted
318
+ config.telemetry.consentVersion = TELEMETRY_CONSENT_VERSION
319
+ saveConfig(config)
320
+
321
+ console.log()
322
+ if (accepted) {
323
+ console.log(chalk.green(' โœ… Analytics enabled. You can disable it later in Settings (P) or with --no-telemetry.'))
324
+ } else {
325
+ console.log(chalk.yellow(' Analytics disabled. You can enable it later in Settings (P).'))
326
+ }
327
+ console.log()
328
+ }
329
+
330
+ // ๐Ÿ“– Resolve telemetry effective state with clear precedence:
331
+ // ๐Ÿ“– CLI flag > env var > config file > disabled by default.
332
+ function isTelemetryEnabled(config, cliArgs) {
333
+ if (cliArgs.noTelemetry) return false
334
+ const envTelemetry = parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY)
335
+ if (envTelemetry !== null) return envTelemetry
336
+ ensureTelemetryConfig(config)
337
+ return config.telemetry.enabled === true
338
+ }
339
+
340
+ // ๐Ÿ“– Fire-and-forget analytics ping: never blocks UX, never throws.
341
+ async function sendUsageTelemetry(config, cliArgs, payload) {
342
+ if (!isTelemetryEnabled(config, cliArgs)) {
343
+ telemetryDebug('skip: telemetry disabled', {
344
+ cliNoTelemetry: cliArgs.noTelemetry === true,
345
+ envTelemetry: process.env.FREE_CODING_MODELS_TELEMETRY || null,
346
+ configEnabled: config?.telemetry?.enabled ?? null,
347
+ })
348
+ return
349
+ }
350
+
351
+ const apiKey = (
352
+ process.env.FREE_CODING_MODELS_POSTHOG_KEY ||
353
+ process.env.POSTHOG_PROJECT_API_KEY ||
354
+ POSTHOG_PROJECT_KEY_DEFAULT ||
355
+ ''
356
+ ).trim()
357
+ if (!apiKey) {
358
+ telemetryDebug('skip: missing api key')
359
+ return
360
+ }
361
+
362
+ const host = (
363
+ process.env.FREE_CODING_MODELS_POSTHOG_HOST ||
364
+ process.env.POSTHOG_HOST ||
365
+ POSTHOG_HOST_DEFAULT ||
366
+ POSTHOG_DEFAULT_HOST
367
+ ).trim().replace(/\/+$/, '')
368
+ if (!host) {
369
+ telemetryDebug('skip: missing host')
370
+ return
371
+ }
372
+
373
+ try {
374
+ const endpoint = `${host}${POSTHOG_CAPTURE_PATH}`
375
+ const distinctId = getTelemetryDistinctId(config)
376
+ const timestamp = typeof payload?.ts === 'string' ? payload.ts : new Date().toISOString()
377
+ const signal = (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function')
378
+ ? AbortSignal.timeout(TELEMETRY_TIMEOUT)
379
+ : undefined
380
+
381
+ const posthogBody = {
382
+ api_key: apiKey,
383
+ event: payload?.event || 'app_start',
384
+ distinct_id: distinctId,
385
+ timestamp,
386
+ properties: {
387
+ $process_person_profile: false,
388
+ source: 'cli',
389
+ app: 'free-coding-models',
390
+ version: payload?.version || LOCAL_VERSION,
391
+ app_version: payload?.version || LOCAL_VERSION,
392
+ mode: payload?.mode || 'opencode',
393
+ system: getTelemetrySystem(),
394
+ terminal: getTelemetryTerminal(),
395
+ },
396
+ }
397
+
398
+ await fetch(endpoint, {
399
+ method: 'POST',
400
+ headers: { 'content-type': 'application/json' },
401
+ body: JSON.stringify(posthogBody),
402
+ signal,
403
+ })
404
+ telemetryDebug('sent', {
405
+ event: posthogBody.event,
406
+ endpoint,
407
+ mode: posthogBody.properties.mode,
408
+ system: posthogBody.properties.system,
409
+ terminal: posthogBody.properties.terminal,
410
+ })
411
+ } catch {
412
+ // ๐Ÿ“– Ignore failures silently: analytics must never break the CLI.
413
+ telemetryDebug('error: send failed')
414
+ }
415
+ }
93
416
 
94
417
  async function checkForUpdate() {
95
418
  try {
@@ -440,14 +763,17 @@ const spinCell = (f, o = 0) => chalk.dim.yellow(FRAMES[(f + o) % FRAMES.length].
440
763
  // ๐Ÿ“– are imported from lib/utils.js for testability
441
764
 
442
765
  // โ”€โ”€โ”€ Viewport calculation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
766
+ // ๐Ÿ“– Keep these constants in sync with renderTable() fixed shell lines.
767
+ // ๐Ÿ“– If this drifts, model rows overflow and can push the title row out of view.
768
+ const TABLE_HEADER_LINES = 4 // ๐Ÿ“– title, spacer, column headers, separator
769
+ const TABLE_FOOTER_LINES = 6 // ๐Ÿ“– spacer, hints, spacer, credit+contributors, discord, spacer
770
+ const TABLE_FIXED_LINES = TABLE_HEADER_LINES + TABLE_FOOTER_LINES
771
+
443
772
  // ๐Ÿ“– Computes the visible slice of model rows that fits in the terminal.
444
- // ๐Ÿ“– Fixed lines: 5 header + 5 footer = 10 lines always consumed.
445
- // ๐Ÿ“– Header: empty, title, empty, column headers, separator (5)
446
- // ๐Ÿ“– Footer: empty, hints, empty, credit, empty (5)
447
773
  // ๐Ÿ“– When scroll indicators are needed, they each consume 1 line from the model budget.
448
774
  function calculateViewport(terminalRows, scrollOffset, totalModels) {
449
775
  if (terminalRows <= 0) return { startIdx: 0, endIdx: totalModels, hasAbove: false, hasBelow: false }
450
- let maxSlots = terminalRows - 10 // 5 header + 5 footer
776
+ let maxSlots = terminalRows - TABLE_FIXED_LINES
451
777
  if (maxSlots < 1) maxSlots = 1
452
778
  if (totalModels <= maxSlots) return { startIdx: 0, endIdx: totalModels, hasAbove: false, hasBelow: false }
453
779
 
@@ -486,7 +812,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
486
812
  if (mode === 'openclaw') {
487
813
  modeBadge = chalk.bold.rgb(255, 100, 50)(' [๐Ÿฆž OpenClaw]')
488
814
  } else if (mode === 'opencode-desktop') {
489
- modeBadge = chalk.bold.rgb(0, 200, 255)(' [๐Ÿ–ฅ Desktop]')
815
+ modeBadge = chalk.bold.rgb(0, 200, 255)(' [๐Ÿ–ฅ Desktop]')
490
816
  } else {
491
817
  modeBadge = chalk.bold.rgb(0, 200, 255)(' [๐Ÿ’ป CLI]')
492
818
  }
@@ -529,7 +855,6 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
529
855
  const sorted = sortResults(visibleResults, sortColumn, sortDirection)
530
856
 
531
857
  const lines = [
532
- '',
533
858
  ` ${chalk.bold('โšก Free Coding Models')} ${chalk.dim('v' + LOCAL_VERSION)}${modeBadge}${modeHint}${tierBadge}${originBadge} ` +
534
859
  chalk.greenBright(`โœ… ${up}`) + chalk.dim(' up ') +
535
860
  chalk.yellow(`โณ ${timeout}`) + chalk.dim(' timeout ') +
@@ -769,9 +1094,17 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
769
1094
  : mode === 'opencode-desktop'
770
1095
  ? chalk.rgb(0, 200, 255)('Enterโ†’OpenDesktop')
771
1096
  : chalk.rgb(0, 200, 255)('Enterโ†’OpenCode')
772
- lines.push(chalk.dim(` โ†‘โ†“ Navigate โ€ข `) + actionHint + chalk.dim(` โ€ข R/Y/O/M/L/A/S/C/H/V/U Sort โ€ข T Tier โ€ข N Origin โ€ข Wโ†“/Xโ†‘ (${intervalSec}s) โ€ข Z Mode โ€ข `) + chalk.yellow('P') + chalk.dim(` Settings โ€ข `) + chalk.bgGreenBright.yellow.bold(' K Help ') + chalk.dim(` โ€ข Ctrl+C Exit`))
1097
+ lines.push(chalk.dim(` โ†‘โ†“ Navigate โ€ข `) + actionHint + chalk.dim(` โ€ข R/Y/O/M/L/A/S/C/H/V/U Sort โ€ข T Tier โ€ข N Origin โ€ข Wโ†“/Xโ†‘ (${intervalSec}s) โ€ข Z Mode โ€ข `) + chalk.yellow('P') + chalk.dim(` Settings โ€ข `) + chalk.bgGreenBright.black.bold(' K Help ') + chalk.dim(` โ€ข Ctrl+C Exit`))
773
1098
  lines.push('')
774
- lines.push(chalk.rgb(255, 150, 200)(' Made with ๐Ÿ’– & โ˜• by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') + chalk.dim(' โ€ข ') + 'โญ ' + '\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\')
1099
+ lines.push(
1100
+ chalk.rgb(255, 150, 200)(' Made with ๐Ÿ’– & โ˜• by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
1101
+ chalk.dim(' โ€ข ') +
1102
+ 'โญ ' +
1103
+ '\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\' +
1104
+ chalk.dim(' โ€ข ') +
1105
+ '๐Ÿค ' +
1106
+ '\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\'
1107
+ )
775
1108
  // ๐Ÿ“– Discord invite + BETA warning โ€” always visible at the bottom of the TUI
776
1109
  lines.push(' ๐Ÿ’ฌ ' + chalk.cyanBright('\x1b]8;;https://discord.gg/5MbTnDC3Md\x1b\\Join our Discord\x1b]8;;\x1b\\') + chalk.dim(' โ†’ ') + chalk.cyanBright('https://discord.gg/5MbTnDC3Md') + chalk.dim(' โ€ข ') + chalk.yellow('โš  BETA TUI') + chalk.dim(' โ€” might crash or have problems'))
777
1110
  lines.push('')
@@ -1585,6 +1918,7 @@ async function main() {
1585
1918
 
1586
1919
  // ๐Ÿ“– Load JSON config (auto-migrates old plain-text ~/.free-coding-models if needed)
1587
1920
  const config = loadConfig()
1921
+ ensureTelemetryConfig(config)
1588
1922
 
1589
1923
  // ๐Ÿ“– Check if any provider has a key โ€” if not, run the first-time setup wizard
1590
1924
  const hasAnyKey = Object.keys(sources).some(pk => !!getApiKey(config, pk))
@@ -1600,9 +1934,27 @@ async function main() {
1600
1934
  }
1601
1935
  }
1602
1936
 
1937
+ // ๐Ÿ“– Ask analytics consent only when not explicitly controlled by env or CLI flag.
1938
+ await promptTelemetryConsent(config, cliArgs)
1939
+
1603
1940
  // ๐Ÿ“– Backward-compat: keep apiKey var for startOpenClaw() which still needs it
1604
1941
  let apiKey = getApiKey(config, 'nvidia')
1605
1942
 
1943
+ // ๐Ÿ“– Default mode: OpenCode CLI
1944
+ let mode = 'opencode'
1945
+ if (cliArgs.openClawMode) mode = 'openclaw'
1946
+ else if (cliArgs.openCodeDesktopMode) mode = 'opencode-desktop'
1947
+ else if (cliArgs.openCodeMode) mode = 'opencode'
1948
+
1949
+ // ๐Ÿ“– Track app opening early so fast exits are still counted.
1950
+ // ๐Ÿ“– Must run before update checks because npm registry lookups can add startup delay.
1951
+ void sendUsageTelemetry(config, cliArgs, {
1952
+ event: 'app_start',
1953
+ version: LOCAL_VERSION,
1954
+ mode,
1955
+ ts: new Date().toISOString(),
1956
+ })
1957
+
1606
1958
  // ๐Ÿ“– Check for updates in the background
1607
1959
  let latestVersion = null
1608
1960
  try {
@@ -1611,9 +1963,6 @@ async function main() {
1611
1963
  // Silently fail - don't block the app if npm registry is unreachable
1612
1964
  }
1613
1965
 
1614
- // ๐Ÿ“– Default mode: OpenCode CLI
1615
- let mode = 'opencode'
1616
-
1617
1966
  // ๐Ÿ“– Show update notification menu if a new version is available
1618
1967
  if (latestVersion) {
1619
1968
  const action = await promptUpdateNotification(latestVersion)
@@ -1643,6 +1992,7 @@ async function main() {
1643
1992
 
1644
1993
  // ๐Ÿ“– Build results from MODELS โ€” only include enabled providers
1645
1994
  // ๐Ÿ“– Each result gets providerKey so ping() knows which URL + API key to use
1995
+
1646
1996
  let results = MODELS
1647
1997
  .filter(([,,,,,providerKey]) => isProviderEnabled(config, providerKey))
1648
1998
  .map(([modelId, label, tier, sweScore, ctx, providerKey], i) => ({
@@ -1657,7 +2007,7 @@ async function main() {
1657
2007
  // ๐Ÿ“– Called after every cursor move, sort change, and terminal resize.
1658
2008
  const adjustScrollOffset = (st) => {
1659
2009
  const total = st.visibleSorted ? st.visibleSorted.length : st.results.filter(r => !r.hidden).length
1660
- let maxSlots = st.terminalRows - 10 // 5 header + 5 footer
2010
+ let maxSlots = st.terminalRows - TABLE_FIXED_LINES
1661
2011
  if (maxSlots < 1) maxSlots = 1
1662
2012
  if (total <= maxSlots) { st.scrollOffset = 0; return }
1663
2013
  // Ensure cursor is not above the visible window
@@ -1754,6 +2104,7 @@ async function main() {
1754
2104
  // ๐Ÿ“– Key "T" in settings = test API key for selected provider.
1755
2105
  function renderSettings() {
1756
2106
  const providerKeys = Object.keys(sources)
2107
+ const telemetryRowIdx = providerKeys.length
1757
2108
  const EL = '\x1b[K'
1758
2109
  const lines = []
1759
2110
 
@@ -1798,11 +2149,26 @@ async function main() {
1798
2149
  lines.push(isCursor ? chalk.bgRgb(30, 30, 60)(row) : row)
1799
2150
  }
1800
2151
 
2152
+ lines.push('')
2153
+ lines.push(` ${chalk.bold('Analytics')}`)
2154
+ lines.push('')
2155
+
2156
+ const telemetryCursor = state.settingsCursor === telemetryRowIdx
2157
+ const telemetryEnabled = state.config.telemetry?.enabled === true
2158
+ const telemetryStatus = telemetryEnabled ? chalk.greenBright('โœ… Enabled') : chalk.dim('โฌœ Disabled')
2159
+ const telemetryRowBullet = telemetryCursor ? chalk.bold.cyan(' โฏ ') : chalk.dim(' ')
2160
+ const telemetryEnv = parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY)
2161
+ const telemetrySource = telemetryEnv === null
2162
+ ? chalk.dim('[Config]')
2163
+ : chalk.yellow('[Env override]')
2164
+ const telemetryRow = `${telemetryRowBullet}${chalk.bold('Anonymous usage analytics').padEnd(44)} ${telemetryStatus} ${telemetrySource}`
2165
+ lines.push(telemetryCursor ? chalk.bgRgb(30, 30, 60)(telemetryRow) : telemetryRow)
2166
+
1801
2167
  lines.push('')
1802
2168
  if (state.settingsEditMode) {
1803
2169
  lines.push(chalk.dim(' Type API key โ€ข Enter Save โ€ข Esc Cancel'))
1804
2170
  } else {
1805
- lines.push(chalk.dim(' โ†‘โ†“ Navigate โ€ข Enter Edit key โ€ข Space Toggle enabled โ€ข T Test key โ€ข Esc Close'))
2171
+ lines.push(chalk.dim(' โ†‘โ†“ Navigate โ€ข Enter Edit key / Toggle analytics โ€ข Space Toggle enabled โ€ข T Test key โ€ข Esc Close'))
1806
2172
  }
1807
2173
  lines.push('')
1808
2174
 
@@ -1838,7 +2204,7 @@ async function main() {
1838
2204
  lines.push(` ${chalk.yellow('W')} Decrease ping interval (faster)`)
1839
2205
  lines.push(` ${chalk.yellow('X')} Increase ping interval (slower)`)
1840
2206
  lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI โ†’ OpenCode Desktop โ†’ OpenClaw)')}`)
1841
- lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys per provider, enable/disable, test)')}`)
2207
+ lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, analytics toggle)')}`)
1842
2208
  lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
1843
2209
  lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
1844
2210
  lines.push('')
@@ -1892,6 +2258,7 @@ async function main() {
1892
2258
  // โ”€โ”€โ”€ Settings overlay keyboard handling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1893
2259
  if (state.settingsOpen) {
1894
2260
  const providerKeys = Object.keys(sources)
2261
+ const telemetryRowIdx = providerKeys.length
1895
2262
 
1896
2263
  // ๐Ÿ“– Edit mode: capture typed characters for the API key
1897
2264
  if (state.settingsEditMode) {
@@ -1953,12 +2320,20 @@ async function main() {
1953
2320
  return
1954
2321
  }
1955
2322
 
1956
- if (key.name === 'down' && state.settingsCursor < providerKeys.length - 1) {
2323
+ if (key.name === 'down' && state.settingsCursor < telemetryRowIdx) {
1957
2324
  state.settingsCursor++
1958
2325
  return
1959
2326
  }
1960
2327
 
1961
2328
  if (key.name === 'return') {
2329
+ if (state.settingsCursor === telemetryRowIdx) {
2330
+ ensureTelemetryConfig(state.config)
2331
+ state.config.telemetry.enabled = state.config.telemetry.enabled !== true
2332
+ state.config.telemetry.consentVersion = TELEMETRY_CONSENT_VERSION
2333
+ saveConfig(state.config)
2334
+ return
2335
+ }
2336
+
1962
2337
  // ๐Ÿ“– Enter edit mode for the selected provider's key
1963
2338
  const pk = providerKeys[state.settingsCursor]
1964
2339
  state.settingsEditBuffer = state.config.apiKeys?.[pk] ?? ''
@@ -1967,6 +2342,14 @@ async function main() {
1967
2342
  }
1968
2343
 
1969
2344
  if (key.name === 'space') {
2345
+ if (state.settingsCursor === telemetryRowIdx) {
2346
+ ensureTelemetryConfig(state.config)
2347
+ state.config.telemetry.enabled = state.config.telemetry.enabled !== true
2348
+ state.config.telemetry.consentVersion = TELEMETRY_CONSENT_VERSION
2349
+ saveConfig(state.config)
2350
+ return
2351
+ }
2352
+
1970
2353
  // ๐Ÿ“– Toggle enabled/disabled for selected provider
1971
2354
  const pk = providerKeys[state.settingsCursor]
1972
2355
  if (!state.config.providers) state.config.providers = {}
@@ -1977,6 +2360,8 @@ async function main() {
1977
2360
  }
1978
2361
 
1979
2362
  if (key.name === 't') {
2363
+ if (state.settingsCursor === telemetryRowIdx) return
2364
+
1980
2365
  // ๐Ÿ“– Test the selected provider's key (fires a real ping)
1981
2366
  const pk = providerKeys[state.settingsCursor]
1982
2367
  testProviderKey(pk)
package/lib/config.js CHANGED
@@ -32,6 +32,11 @@
32
32
  * "hyperbolic": { "enabled": true },
33
33
  * "scaleway": { "enabled": true },
34
34
  * "googleai": { "enabled": true }
35
+ * },
36
+ * "telemetry": {
37
+ * "enabled": true,
38
+ * "consentVersion": 1,
39
+ * "anonymousId": "anon_550e8400-e29b-41d4-a716-446655440000"
35
40
  * }
36
41
  * }
37
42
  *
@@ -86,7 +91,7 @@ const ENV_VARS = {
86
91
  * ๐Ÿ“– The migration reads the old file as a plain nvidia API key and writes
87
92
  * a proper JSON config. The old file is NOT deleted (safety first).
88
93
  *
89
- * @returns {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}> }}
94
+ * @returns {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, telemetry: { enabled: boolean | null, consentVersion: number, anonymousId: string | null } }}
90
95
  */
91
96
  export function loadConfig() {
92
97
  // ๐Ÿ“– Try new JSON config first
@@ -97,6 +102,10 @@ export function loadConfig() {
97
102
  // ๐Ÿ“– Ensure the shape is always complete โ€” fill missing sections with defaults
98
103
  if (!parsed.apiKeys) parsed.apiKeys = {}
99
104
  if (!parsed.providers) parsed.providers = {}
105
+ if (!parsed.telemetry || typeof parsed.telemetry !== 'object') parsed.telemetry = { enabled: null, consentVersion: 0, anonymousId: null }
106
+ if (typeof parsed.telemetry.enabled !== 'boolean') parsed.telemetry.enabled = null
107
+ if (typeof parsed.telemetry.consentVersion !== 'number') parsed.telemetry.consentVersion = 0
108
+ if (typeof parsed.telemetry.anonymousId !== 'string' || !parsed.telemetry.anonymousId.trim()) parsed.telemetry.anonymousId = null
100
109
  return parsed
101
110
  } catch {
102
111
  // ๐Ÿ“– Corrupted JSON โ€” return empty config (user will re-enter keys)
@@ -129,7 +138,7 @@ export function loadConfig() {
129
138
  * ๐Ÿ“– Uses mode 0o600 so the file is only readable by the owning user (API keys!).
130
139
  * ๐Ÿ“– Pretty-prints JSON for human readability.
131
140
  *
132
- * @param {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}> }} config
141
+ * @param {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, telemetry?: { enabled?: boolean | null, consentVersion?: number, anonymousId?: string | null } }} config
133
142
  */
134
143
  export function saveConfig(config) {
135
144
  try {
@@ -184,5 +193,11 @@ function _emptyConfig() {
184
193
  return {
185
194
  apiKeys: {},
186
195
  providers: {},
196
+ // ๐Ÿ“– Telemetry consent is explicit. null = not decided yet.
197
+ telemetry: {
198
+ enabled: null,
199
+ consentVersion: 0,
200
+ anonymousId: null,
201
+ },
187
202
  }
188
203
  }
package/lib/utils.js CHANGED
@@ -277,11 +277,11 @@ export function findBestModel(results) {
277
277
  //
278
278
  // ๐Ÿ“– Argument types:
279
279
  // - API key: first positional arg that doesn't start with "--" (e.g., "nvapi-xxx")
280
- // - Boolean flags: --best, --fiable, --opencode, --opencode-desktop, --openclaw (case-insensitive)
280
+ // - Boolean flags: --best, --fiable, --opencode, --opencode-desktop, --openclaw, --no-telemetry (case-insensitive)
281
281
  // - Value flag: --tier <letter> (the next non-flag arg is the tier value)
282
282
  //
283
283
  // ๐Ÿ“– Returns:
284
- // { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode, tierFilter }
284
+ // { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode, noTelemetry, tierFilter }
285
285
  //
286
286
  // ๐Ÿ“– Note: apiKey may be null here โ€” the main CLI falls back to env vars and saved config.
287
287
  export function parseArgs(argv) {
@@ -310,8 +310,9 @@ export function parseArgs(argv) {
310
310
  const openCodeMode = flags.includes('--opencode')
311
311
  const openCodeDesktopMode = flags.includes('--opencode-desktop')
312
312
  const openClawMode = flags.includes('--openclaw')
313
+ const noTelemetry = flags.includes('--no-telemetry')
313
314
 
314
315
  let tierFilter = tierValueIdx !== -1 ? args[tierValueIdx].toUpperCase() : null
315
316
 
316
- return { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode, tierFilter }
317
+ return { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode, noTelemetry, tierFilter }
317
318
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
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",