codeep 2.5.1 → 2.6.0

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
@@ -217,6 +217,43 @@ AI-powered review of your git diff with `/review`:
217
217
 
218
218
  If there are no git changes, falls back to static analysis automatically.
219
219
 
220
+ #### Custom rules (`.codeep/review.json`)
221
+
222
+ The static reviewer (`codeep review` / `/review --static`) ships a set of
223
+ built-in rules, but a project can tailor them — check a `.codeep/review.json`
224
+ into the repo and the CLI **and** the [Codeep GitHub Action](https://github.com/VladoIvankovic/codeep-action)
225
+ both pick it up automatically (zero LLM cost):
226
+
227
+ ```json
228
+ {
229
+ "rules": [
230
+ {
231
+ "id": "no-internal-import",
232
+ "pattern": "from ['\"]@acme/internal",
233
+ "category": "best-practice",
234
+ "severity": "error",
235
+ "message": "Don't import from @acme/internal outside the platform team",
236
+ "suggestion": "Use the public @acme/sdk package",
237
+ "extensions": [".ts", ".tsx"]
238
+ }
239
+ ],
240
+ "disable": ["todo-comment", "anonymous-function"],
241
+ "include": ["src/**"],
242
+ "exclude": ["**/*.test.ts", "vendor/**"]
243
+ }
244
+ ```
245
+
246
+ - **`rules`** — your own checks. `id`, `pattern` (a regex string), and `message`
247
+ are required; `flags` (default `g`), `category`, `severity`
248
+ (`error|warning|info|suggestion`), `suggestion`, and `extensions` are optional.
249
+ - **`disable`** — turn off built-in rules by id (e.g. `eval-usage`,
250
+ `hardcoded-password`, `todo-comment`, `any-type`, `console-statement`,
251
+ `long-file`, `long-function`, …).
252
+ - **`include` / `exclude`** — glob scoping (`**`, `*`, `?`); `include` empty = all files.
253
+
254
+ A missing, malformed, or partially-invalid config never breaks a review — bad
255
+ entries are skipped and the run proceeds with whatever is valid.
256
+
220
257
  ### Interactive Mode
221
258
  Agent asks clarifying questions when tasks are ambiguous:
222
259
  ```
@@ -795,6 +832,28 @@ Hooks and MCP server configs are deliberately **not** synced: hooks run
795
832
  arbitrary shell, and MCP configs often embed tokens, so both stay local to each
796
833
  machine.
797
834
 
835
+ ### Privacy & telemetry
836
+
837
+ Once your CLI is linked, Codeep automatically uploads usage stats (model,
838
+ provider, token counts, estimated cost), session transcripts, `progress.md`,
839
+ and project memory notes to power your dashboard. To opt out of **all** of these
840
+ automatic uploads:
841
+
842
+ ```bash
843
+ export CODEEP_NO_TELEMETRY=1 # also honors the cross-tool DO_NOT_TRACK=1
844
+ ```
845
+
846
+ …or set it permanently in `~/.codeep/config.json`:
847
+
848
+ ```json
849
+ { "telemetry": false }
850
+ ```
851
+
852
+ With telemetry off, nothing is uploaded automatically — the agent still runs
853
+ fully and talks to your LLM provider directly. Explicit `codeep account push` /
854
+ `account sync` commands are always under your control and are never gated by
855
+ this flag.
856
+
798
857
  ### Tasks
799
858
 
800
859
  Create, view, and complete tasks directly from the CLI — or manage them on the codeep.dev dashboard:
@@ -2,7 +2,7 @@
2
2
  // Slash command handler for ACP sessions.
3
3
  // Mirrors CLI commands from renderer/commands.ts but returns plain text
4
4
  // responses (no TUI) suitable for streaming back via session/update.
5
- import { config, getCurrentProvider, getModelsForCurrentProvider, setProvider, setApiKey, isConfigured, listSessionsWithInfo, startNewSession, loadSession, saveSession, initializeAsProject, isManuallyInitializedProject, setProjectPermission, hasWritePermission, hasReadPermission, } from '../config/index.js';
5
+ import { config, getCurrentProvider, getModelsForCurrentProvider, setProvider, setApiKey, isConfigured, listSessionsWithInfo, startNewSession, loadSession, saveSession, initializeAsProject, isManuallyInitializedProject, setProjectPermission, hasWritePermission, hasReadPermission, isTelemetryEnabled, telemetryForcedOffByEnv, } from '../config/index.js';
6
6
  import { getProviderList, getProvider } from '../config/providers.js';
7
7
  import { getProjectContext } from '../utils/project.js';
8
8
  import { loadCustomCommands } from '../utils/customCommands.js';
@@ -207,14 +207,42 @@ export async function handleCommand(input, session, onChunk, abortSignal) {
207
207
  case 'apikey': {
208
208
  if (!args.length)
209
209
  return { handled: true, response: showApiKey() };
210
- return { handled: true, response: setApiKeyCmd(args[0]) };
210
+ return { handled: true, response: await setApiKeyCmd(args[0]) };
211
+ }
212
+ case 'telemetry': {
213
+ const sub = args[0]?.toLowerCase();
214
+ const envOff = telemetryForcedOffByEnv();
215
+ if (sub === 'on' || sub === 'off') {
216
+ if (envOff) {
217
+ return { handled: true, response: 'Telemetry is forced **off** by the `CODEEP_NO_TELEMETRY` / `DO_NOT_TRACK` env var — unset it to change this. The config flag can\'t override an env var.' };
218
+ }
219
+ config.set('telemetry', sub === 'on');
220
+ return {
221
+ handled: true,
222
+ response: sub === 'on'
223
+ ? 'Telemetry **on** — usage stats, session transcripts, progress and memory notes sync to codeep.dev.'
224
+ : 'Telemetry **off** — no automatic cloud uploads. Explicit `/account push` still works.',
225
+ };
226
+ }
227
+ if (sub && sub !== 'status') {
228
+ return { handled: true, response: 'Usage: `/telemetry` · `/telemetry on` · `/telemetry off`' };
229
+ }
230
+ const flag = config.get('telemetry') !== false;
231
+ const lines = [
232
+ `**Telemetry:** ${isTelemetryEnabled() ? 'on' : 'off'}`,
233
+ `- Config flag \`telemetry\`: ${flag}`,
234
+ ];
235
+ if (envOff)
236
+ lines.push('- Forced **off** by `CODEEP_NO_TELEMETRY` / `DO_NOT_TRACK` (env overrides the flag).');
237
+ lines.push('', 'Toggle with `/telemetry on` | `/telemetry off`. Controls automatic uploads of usage stats, session transcripts, progress, and memory notes.');
238
+ return { handled: true, response: lines.join('\n') };
211
239
  }
212
240
  case 'login': {
213
241
  const [providerId, apiKey] = args;
214
242
  if (!providerId || !apiKey) {
215
243
  return { handled: true, response: 'Usage: `/login <providerId> <apiKey>`\n\n' + buildProviderList() };
216
244
  }
217
- return { handled: true, response: loginCmd(providerId, apiKey) };
245
+ return { handled: true, response: await loginCmd(providerId, apiKey) };
218
246
  }
219
247
  case 'sessions':
220
248
  case 'session': {
@@ -1414,6 +1442,7 @@ function buildHelp() {
1414
1442
  '| `/model [id]` | List or switch model |',
1415
1443
  '| `/login <provider> <key>` | Set API key for a provider |',
1416
1444
  '| `/apikey [key]` | Show or set API key |',
1445
+ '| `/telemetry [on\\|off]` | Show or toggle automatic cloud telemetry |',
1417
1446
  '| `/lang [code]` | Set response language (`en`, `hr`, `auto`…) |',
1418
1447
  '| `/grant` | Grant write access for workspace |',
1419
1448
  '',
@@ -1545,18 +1574,29 @@ const INLINE_KEY_WARNING = '\n\n> ⚠️ The key you just typed is now in your
1545
1574
  ' Prefer setting the provider env var (see `/provider`) or using the' +
1546
1575
  ' settings UI in the VS Code extension. Clear the line from history if' +
1547
1576
  ' the machine is shared.';
1548
- function setApiKeyCmd(key) {
1577
+ async function setApiKeyCmd(key) {
1549
1578
  const providerId = getCurrentProvider().id;
1550
- // setApiKey is async (keychain) — fire-and-forget, config cache updated synchronously
1551
- setApiKey(key, providerId);
1579
+ // Await persistence so the confirmation is only returned once the key is
1580
+ // actually stored (keychain write resolves before the process can exit).
1581
+ try {
1582
+ await setApiKey(key, providerId);
1583
+ }
1584
+ catch {
1585
+ return `Failed to save API key for \`${providerId}\` — secure storage was unavailable. Please try again.`;
1586
+ }
1552
1587
  return `API key for \`${providerId}\` saved.${INLINE_KEY_WARNING}`;
1553
1588
  }
1554
- function loginCmd(providerId, apiKey) {
1589
+ async function loginCmd(providerId, apiKey) {
1555
1590
  const provider = getProvider(providerId);
1556
1591
  if (!provider)
1557
1592
  return `Provider \`${providerId}\` not found.\n\n${buildProviderList()}`;
1558
1593
  setProvider(providerId);
1559
- setApiKey(apiKey, providerId);
1594
+ try {
1595
+ await setApiKey(apiKey, providerId);
1596
+ }
1597
+ catch {
1598
+ return `Failed to save API key for \`${providerId}\` — secure storage was unavailable. Please try again.`;
1599
+ }
1560
1600
  return `Logged in as **${provider.name}** (\`${providerId}\`). Model: \`${provider.defaultModel}\`.${INLINE_KEY_WARNING}`;
1561
1601
  }
1562
1602
  const PREVIEW_MESSAGES = 6; // last N messages to show on session restore
@@ -12,7 +12,7 @@ import { loadMcpServerConfig, mergeMcpServers } from '../utils/mcpConfig.js';
12
12
  import { handleMcpSamplingRequest } from '../utils/mcpSamplingBridge.js';
13
13
  import { executeCommandAsync } from '../utils/shell.js';
14
14
  import { initWorkspace, loadWorkspace, handleCommand } from './commands.js';
15
- import { autoSaveSession, config, setProvider, setApiKey, listSessionsWithInfo, deleteSession as deleteSessionFile } from '../config/index.js';
15
+ import { autoSaveSession, config, setProvider, setApiKey, getApiKey, getConfiguredProviders, listSessionsWithInfo, deleteSession as deleteSessionFile } from '../config/index.js';
16
16
  import { ApiError } from '../api/index.js';
17
17
  import { PROVIDERS } from '../config/providers.js';
18
18
  import { getCurrentVersion } from '../utils/update.js';
@@ -238,15 +238,19 @@ async function collectEmbeddedContext(blocks) {
238
238
  }
239
239
  return snippets.join('\n\n');
240
240
  }
241
- /** Check if a provider has an API key stored (reads config directly, no async) */
241
+ /** Check if a provider has an API key stored (synchronous; relies on the cache
242
+ * loaded at startup via loadAllApiKeys and the non-secret configuredProviderIds
243
+ * index — never reads plaintext key material). */
242
244
  function providerHasKey(providerId) {
243
245
  // Check environment variable first
244
246
  const envKey = PROVIDERS[providerId]?.envKey;
245
247
  if (envKey && process.env[envKey])
246
248
  return true;
247
- // Check stored providerApiKeys
248
- const stored = (config.get('providerApiKeys') || []);
249
- return stored.some(k => k.providerId === providerId && !!k.apiKey);
249
+ // In-memory cache (populated from secure storage at startup)
250
+ if (getApiKey(providerId))
251
+ return true;
252
+ // Non-secret index of providers that have a key in secure storage
253
+ return getConfiguredProviders().some(p => p.id === providerId);
250
254
  }
251
255
  function buildConfigOptions() {
252
256
  const currentModel = config.get('model') ?? '';
@@ -721,8 +725,12 @@ export function startAcpServer() {
721
725
  return;
722
726
  }
723
727
  session.currentModeId = modeId;
724
- // Map ACP mode to Codeep agentConfirmation setting
725
- config.set('agentConfirmation', modeId === 'manual' ? 'dangerous' : 'never');
728
+ // Do NOT persist the global `agentConfirmation` config here. The ACP
729
+ // permission gate is driven per-session by `session.currentModeId` (see the
730
+ // onRequestPermission wiring in handleSessionPrompt). Writing it globally
731
+ // would leak this session's mode into other processes — e.g. switching ACP
732
+ // to 'auto' would silently disarm the TUI's confirmation gate. Mode stays
733
+ // on the in-memory session object only.
726
734
  transport.respond(msg.id, {});
727
735
  // Notify Zed of the mode change
728
736
  transport.notify('session/update', {
@@ -786,7 +794,11 @@ export function startAcpServer() {
786
794
  const apiKey = value.slice(colonIdx + 1);
787
795
  if (providerId && apiKey) {
788
796
  setProvider(providerId);
789
- setApiKey(apiKey, providerId);
797
+ // Cache is updated synchronously inside setApiKey; the keychain write
798
+ // resolves while the long-lived server runs. Swallow a persistence
799
+ // failure here (secondary path) so it can't become an unhandled
800
+ // rejection — the key still works for this session via the cache.
801
+ setApiKey(apiKey, providerId).catch(() => { });
790
802
  }
791
803
  }
792
804
  }
@@ -85,7 +85,21 @@ interface ConfigSchema {
85
85
  agentApiTimeout: number;
86
86
  agentInteractive: boolean;
87
87
  projectPermissions: ProjectPermission[];
88
+ /** @deprecated Legacy PLAINTEXT key store. Kept only so the one-time
89
+ * migration into secure storage can read it; emptied afterwards. New keys
90
+ * go to the OS keychain via utils/keychain.ts — never written here. */
88
91
  providerApiKeys: ProviderApiKey[];
92
+ /** Non-secret index of provider IDs that have a key in secure storage, so we
93
+ * can list/load configured providers without probing the keychain for all
94
+ * providers. Secrets themselves never live here. */
95
+ configuredProviderIds: string[];
96
+ /** True once legacy plaintext keys (providerApiKeys / apiKey) have been
97
+ * migrated into secure storage and wiped from the config file. */
98
+ keysSecured: boolean;
99
+ /** Master switch for automatic cloud uploads (usage stats, session
100
+ * transcripts, progress, memory notes). Default true; set false to opt out.
101
+ * The CODEEP_NO_TELEMETRY / DO_NOT_TRACK env vars also force it off. */
102
+ telemetry: boolean;
89
103
  githubId: string;
90
104
  githubUsername: string;
91
105
  syncToken: string;
@@ -138,9 +152,11 @@ export declare function loadAllApiKeys(): Promise<void>;
138
152
  */
139
153
  export declare function getApiKey(providerId?: string): string;
140
154
  /**
141
- * Set API key - stores in config file
155
+ * Set API key — persists to secure storage (OS keychain), never plaintext.
156
+ * Returns a promise, but updates the synchronous cache first so callers that
157
+ * fire-and-forget still see the key immediately via getApiKey().
142
158
  */
143
- export declare function setApiKey(key: string, providerId?: string): void;
159
+ export declare function setApiKey(key: string, providerId?: string): Promise<void>;
144
160
  export declare function getMaskedApiKey(providerId?: string): string;
145
161
  /**
146
162
  * Get list of providers that have API keys configured
@@ -149,10 +165,17 @@ export declare function getConfiguredProviders(): {
149
165
  id: string;
150
166
  name: string;
151
167
  }[];
168
+ export declare function isTelemetryEnabled(): boolean;
169
+ /**
170
+ * True when an env var (CODEEP_NO_TELEMETRY / DO_NOT_TRACK) is forcing telemetry
171
+ * off — in which case the `telemetry` config flag can't turn it back on. Lets
172
+ * the /telemetry command explain why a toggle had no effect.
173
+ */
174
+ export declare function telemetryForcedOffByEnv(): boolean;
152
175
  /**
153
176
  * Clear API key for a specific provider
154
177
  */
155
- export declare function clearApiKey(providerId: string): void;
178
+ export declare function clearApiKey(providerId: string): Promise<void>;
156
179
  export declare function isConfiguredAsync(providerId?: string): Promise<boolean>;
157
180
  export declare function isConfigured(providerId?: string): boolean;
158
181
  export declare function getCurrentProvider(): {
@@ -4,6 +4,7 @@ import { join, dirname } from 'path';
4
4
  import { randomUUID } from 'crypto';
5
5
  import { PROVIDERS, getProvider, getProviderBaseUrl } from './providers.js';
6
6
  import { logSession } from '../utils/logger.js';
7
+ import { createSecureStorage } from '../utils/keychain.js';
7
8
  // We'll initialize GLOBAL_SESSIONS_DIR after config is created (to use config.path)
8
9
  /**
9
10
  * Get sessions directory - local .codeep/sessions/ if in project, otherwise global
@@ -183,6 +184,9 @@ function createConfig() {
183
184
  rateLimitCommands: 10000,
184
185
  projectPermissions: [],
185
186
  providerApiKeys: [],
187
+ configuredProviderIds: [],
188
+ keysSecured: false,
189
+ telemetry: true,
186
190
  githubId: '',
187
191
  githubUsername: '',
188
192
  syncToken: '',
@@ -272,6 +276,89 @@ if (!existsSync(GLOBAL_SESSIONS_DIR)) {
272
276
  }
273
277
  // In-memory cache for API keys (populated on first access)
274
278
  const apiKeyCache = new Map();
279
+ // ── Secure key storage (OS keychain, plaintext-config fallback) ──────────────
280
+ // API keys persist in the OS keychain via utils/keychain.ts. The cache above is
281
+ // the synchronous read path (getApiKey); the keychain is the async persistence
282
+ // layer (set/load). A non-secret `configuredProviderIds` index in config tells
283
+ // us which providers have a key without probing the keychain for every provider.
284
+ let _secureKeyStore = null;
285
+ function secureKeyStore() {
286
+ if (!_secureKeyStore)
287
+ _secureKeyStore = createSecureStorage(config);
288
+ return _secureKeyStore;
289
+ }
290
+ function addConfiguredProviderId(providerId) {
291
+ const ids = config.get('configuredProviderIds') || [];
292
+ if (!ids.includes(providerId)) {
293
+ config.set('configuredProviderIds', [...ids, providerId]);
294
+ }
295
+ }
296
+ function removeConfiguredProviderId(providerId) {
297
+ const ids = config.get('configuredProviderIds') || [];
298
+ if (ids.includes(providerId)) {
299
+ config.set('configuredProviderIds', ids.filter(id => id !== providerId));
300
+ }
301
+ }
302
+ let _migrationPromise = null;
303
+ /**
304
+ * One-time migration of legacy PLAINTEXT keys (the `providerApiKeys` array and
305
+ * the very-old single `apiKey` field) into secure storage, then wipe the
306
+ * plaintext from the config file. Idempotent and guarded so concurrent callers
307
+ * share a single run. Each key's plaintext is wiped only after its write to
308
+ * secure storage actually succeeds; any key that fails to persist is retained
309
+ * and `keysSecured` stays false so the next startup retries — the last copy of
310
+ * a secret is never destroyed before a new copy is confirmed.
311
+ */
312
+ async function migrateKeysToSecureStorage() {
313
+ if (config.get('keysSecured'))
314
+ return;
315
+ // Dedupe concurrent callers (loadApiKey + loadAllApiKeys at startup) onto a
316
+ // single run; cleared in finally so a later un-secured state can re-migrate.
317
+ if (!_migrationPromise) {
318
+ _migrationPromise = (async () => {
319
+ const store = secureKeyStore();
320
+ const legacyArray = config.get('providerApiKeys') || [];
321
+ // Only drain entries we actually persisted; keep failures so the next
322
+ // startup retries instead of destroying the last copy of a key.
323
+ const failed = [];
324
+ for (const entry of legacyArray) {
325
+ if (!entry?.providerId || !entry?.apiKey)
326
+ continue;
327
+ try {
328
+ await store.setApiKey(entry.providerId, entry.apiKey);
329
+ addConfiguredProviderId(entry.providerId);
330
+ }
331
+ catch {
332
+ failed.push(entry);
333
+ }
334
+ }
335
+ const legacySingle = config.get('apiKey');
336
+ let legacySingleMigrated = false;
337
+ if (legacySingle) {
338
+ try {
339
+ await store.setApiKey('z.ai', legacySingle);
340
+ addConfiguredProviderId('z.ai');
341
+ legacySingleMigrated = true;
342
+ }
343
+ catch { /* keep the legacy field for retry */ }
344
+ }
345
+ // Wipe only the plaintext that made it into secure storage.
346
+ config.set('providerApiKeys', failed);
347
+ if (legacySingle && legacySingleMigrated)
348
+ config.set('apiKey', '');
349
+ // Mark secured only when everything migrated; a partial failure retries.
350
+ if (failed.length === 0 && (!legacySingle || legacySingleMigrated)) {
351
+ config.set('keysSecured', true);
352
+ }
353
+ })();
354
+ }
355
+ try {
356
+ await _migrationPromise;
357
+ }
358
+ finally {
359
+ _migrationPromise = null;
360
+ }
361
+ }
275
362
  export const LANGUAGES = {
276
363
  'auto': 'Auto-detect',
277
364
  'en': 'English',
@@ -316,20 +403,12 @@ export async function loadApiKey(providerId) {
316
403
  return process.env.ZHIPUAI_API_KEY;
317
404
  }
318
405
  }
319
- // Check config file
320
- const providerKeys = config.get('providerApiKeys') || [];
321
- const stored = providerKeys.find(k => k.providerId === provider);
322
- if (stored?.apiKey) {
323
- apiKeyCache.set(provider, stored.apiKey);
324
- return stored.apiKey;
325
- }
326
- // Fallback to legacy apiKey field (for z.ai)
327
- if (provider === 'z.ai') {
328
- const legacyKey = config.get('apiKey') || '';
329
- if (legacyKey) {
330
- apiKeyCache.set(provider, legacyKey);
331
- return legacyKey;
332
- }
406
+ // Secure storage (OS keychain). Migrate any legacy plaintext keys first.
407
+ await migrateKeysToSecureStorage();
408
+ const stored = await secureKeyStore().getApiKey(provider);
409
+ if (stored) {
410
+ apiKeyCache.set(provider, stored);
411
+ return stored;
333
412
  }
334
413
  return '';
335
414
  }
@@ -338,12 +417,14 @@ export async function loadApiKey(providerId) {
338
417
  * Should be called at app startup
339
418
  */
340
419
  export async function loadAllApiKeys() {
341
- // Load keys for all configured providers from providerApiKeys
342
- const providerKeys = config.get('providerApiKeys') || [];
343
- for (const { providerId, apiKey } of providerKeys) {
344
- if (apiKey) {
345
- apiKeyCache.set(providerId, apiKey);
346
- }
420
+ // Migrate any legacy plaintext keys, then load all from secure storage using
421
+ // the non-secret configuredProviderIds index (no need to probe every provider).
422
+ await migrateKeysToSecureStorage();
423
+ const store = secureKeyStore();
424
+ for (const providerId of (config.get('configuredProviderIds') || [])) {
425
+ const key = await store.getApiKey(providerId);
426
+ if (key)
427
+ apiKeyCache.set(providerId, key);
347
428
  }
348
429
  // Also check environment variables for each provider
349
430
  for (const [providerId, providerConfig] of Object.entries(PROVIDERS)) {
@@ -354,7 +435,8 @@ export async function loadAllApiKeys() {
354
435
  }
355
436
  }
356
437
  }
357
- // Legacy env vars for z.ai
438
+ // Legacy env vars for z.ai (the legacy `apiKey` config field is migrated into
439
+ // secure storage by migrateKeysToSecureStorage above).
358
440
  if (!apiKeyCache.get('z.ai')) {
359
441
  if (process.env.ZAI_API_KEY) {
360
442
  apiKeyCache.set('z.ai', process.env.ZAI_API_KEY);
@@ -362,13 +444,6 @@ export async function loadAllApiKeys() {
362
444
  else if (process.env.ZHIPUAI_API_KEY) {
363
445
  apiKeyCache.set('z.ai', process.env.ZHIPUAI_API_KEY);
364
446
  }
365
- else {
366
- // Fallback to legacy apiKey field
367
- const legacyKey = config.get('apiKey') || '';
368
- if (legacyKey) {
369
- apiKeyCache.set('z.ai', legacyKey);
370
- }
371
- }
372
447
  }
373
448
  }
374
449
  /**
@@ -379,26 +454,22 @@ export function getApiKey(providerId) {
379
454
  return apiKeyCache.get(provider) || '';
380
455
  }
381
456
  /**
382
- * Set API key - stores in config file
457
+ * Set API key — persists to secure storage (OS keychain), never plaintext.
458
+ * Returns a promise, but updates the synchronous cache first so callers that
459
+ * fire-and-forget still see the key immediately via getApiKey().
383
460
  */
384
- export function setApiKey(key, providerId) {
461
+ export async function setApiKey(key, providerId) {
385
462
  const provider = providerId || config.get('provider');
386
- // Update cache immediately
463
+ // Update cache immediately so synchronous getApiKey() works right away.
387
464
  apiKeyCache.set(provider, key);
388
- // Store in config
389
- const providerKeys = config.get('providerApiKeys') || [];
390
- const existing = providerKeys.findIndex(k => k.providerId === provider);
391
- if (existing >= 0) {
392
- providerKeys[existing].apiKey = key;
393
- }
394
- else {
395
- providerKeys.push({ providerId: provider, apiKey: key });
396
- }
397
- config.set('providerApiKeys', providerKeys);
398
- // Also set legacy field for backwards compatibility (z.ai only)
399
- if (provider === 'z.ai') {
400
- config.set('apiKey', key);
401
- }
465
+ // Persist to secure storage (keychain, or plaintext-config fallback if the
466
+ // keychain is unavailable — utils/keychain.ts warns in that case). Let a hard
467
+ // persistence failure propagate so callers can report it instead of claiming
468
+ // success; the index + secured flag are set only after a confirmed write.
469
+ await secureKeyStore().setApiKey(provider, key);
470
+ addConfiguredProviderId(provider);
471
+ // No plaintext key was written here, so the store is already "secured".
472
+ config.set('keysSecured', true);
402
473
  }
403
474
  export function getMaskedApiKey(providerId) {
404
475
  const key = getApiKey(providerId);
@@ -411,33 +482,45 @@ export function getMaskedApiKey(providerId) {
411
482
  * Get list of providers that have API keys configured
412
483
  */
413
484
  export function getConfiguredProviders() {
414
- const providerKeys = config.get('providerApiKeys') || [];
415
- const configured = [];
416
- for (const pk of providerKeys) {
417
- if (pk.apiKey && pk.apiKey.length > 0) {
418
- const provider = getProvider(pk.providerId);
419
- configured.push({
420
- id: pk.providerId,
421
- name: provider?.name || pk.providerId,
422
- });
423
- }
424
- }
425
- return configured;
485
+ const ids = config.get('configuredProviderIds') || [];
486
+ return ids.map(id => ({ id, name: getProvider(id)?.name || id }));
487
+ }
488
+ /**
489
+ * Whether automatic cloud uploads are allowed (usage stats, session
490
+ * transcripts, progress, memory notes). Opt out via the `telemetry: false`
491
+ * config flag, the CODEEP_NO_TELEMETRY env var, or the cross-tool DO_NOT_TRACK
492
+ * convention. Explicit `codeep account push/sync` commands are user-initiated
493
+ * and are NOT gated by this.
494
+ */
495
+ function envForcesTelemetryOff() {
496
+ const off = (v) => !!v && !/^(0|false|no|off)$/i.test(v.trim());
497
+ return off(process.env.CODEEP_NO_TELEMETRY) || off(process.env.DO_NOT_TRACK);
498
+ }
499
+ export function isTelemetryEnabled() {
500
+ if (envForcesTelemetryOff())
501
+ return false;
502
+ return config.get('telemetry') !== false;
503
+ }
504
+ /**
505
+ * True when an env var (CODEEP_NO_TELEMETRY / DO_NOT_TRACK) is forcing telemetry
506
+ * off — in which case the `telemetry` config flag can't turn it back on. Lets
507
+ * the /telemetry command explain why a toggle had no effect.
508
+ */
509
+ export function telemetryForcedOffByEnv() {
510
+ return envForcesTelemetryOff();
426
511
  }
427
512
  /**
428
513
  * Clear API key for a specific provider
429
514
  */
430
- export function clearApiKey(providerId) {
515
+ export async function clearApiKey(providerId) {
431
516
  // Clear from cache
432
517
  apiKeyCache.delete(providerId);
433
- // Clear from config
434
- const providerKeys = config.get('providerApiKeys') || [];
435
- const filtered = providerKeys.filter(k => k.providerId !== providerId);
436
- config.set('providerApiKeys', filtered);
437
- // Clear legacy field if z.ai
438
- if (providerId === 'z.ai') {
439
- config.set('apiKey', '');
518
+ // Clear from secure storage + the non-secret index
519
+ try {
520
+ await secureKeyStore().deleteApiKey(providerId);
440
521
  }
522
+ catch { /* ignore */ }
523
+ removeConfiguredProviderId(providerId);
441
524
  }
442
525
  export async function isConfiguredAsync(providerId) {
443
526
  const key = await loadApiKey(providerId);
@@ -82,6 +82,7 @@ const COMMAND_DESCRIPTIONS = {
82
82
  'profile': 'Save/load settings profiles',
83
83
  'tasks': 'Show pending tasks from codeep.dev dashboard',
84
84
  'sync': 'Sync learning preferences and profiles to codeep.dev',
85
+ 'telemetry': 'Show or toggle automatic cloud telemetry (on/off)',
85
86
  // 2.0 — surfaced for `/` autocomplete; documented in /help too.
86
87
  'compact': 'Summarize older messages to free up context',
87
88
  'commands': 'List custom slash commands in .codeep/commands/*.md',
@@ -232,7 +233,7 @@ export class App {
232
233
  'multiline', 'memory', 'init',
233
234
  'provider', 'model', 'protocol', 'lang', 'grant', 'login', 'logout',
234
235
  'context-save', 'context-load', 'context-clear', 'learn',
235
- 'cost', 'tasks', 'account', 'sync',
236
+ 'cost', 'tasks', 'account', 'sync', 'telemetry',
236
237
  // 2.0 — extensions, checkpoints, MCP, custom commands, OpenRouter prefs.
237
238
  // Keep in lockstep with COMMAND_DESCRIPTIONS below and helpCategories.
238
239
  'compact', 'commands', 'checkpoint', 'checkpoints', 'rewind',