codeep 2.5.0 → 2.5.2

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
@@ -795,6 +795,28 @@ Hooks and MCP server configs are deliberately **not** synced: hooks run
795
795
  arbitrary shell, and MCP configs often embed tokens, so both stay local to each
796
796
  machine.
797
797
 
798
+ ### Privacy & telemetry
799
+
800
+ Once your CLI is linked, Codeep automatically uploads usage stats (model,
801
+ provider, token counts, estimated cost), session transcripts, `progress.md`,
802
+ and project memory notes to power your dashboard. To opt out of **all** of these
803
+ automatic uploads:
804
+
805
+ ```bash
806
+ export CODEEP_NO_TELEMETRY=1 # also honors the cross-tool DO_NOT_TRACK=1
807
+ ```
808
+
809
+ …or set it permanently in `~/.codeep/config.json`:
810
+
811
+ ```json
812
+ { "telemetry": false }
813
+ ```
814
+
815
+ With telemetry off, nothing is uploaded automatically — the agent still runs
816
+ fully and talks to your LLM provider directly. Explicit `codeep account push` /
817
+ `account sync` commands are always under your control and are never gated by
818
+ this flag.
819
+
798
820
  ### Tasks
799
821
 
800
822
  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',
@@ -5,7 +5,7 @@
5
5
  * decoupled from global state. Import-heavy commands use dynamic imports
6
6
  * to keep startup time low.
7
7
  */
8
- import { config, getCurrentProvider, getModelsForCurrentProvider, PROTOCOLS, LANGUAGES, setProvider, setApiKey, clearApiKey, getApiKey, saveSession, startNewSession, loadSession, listSessionsWithInfo, deleteSession, renameSession, setProjectPermission, saveProfile, loadProfile, applyProfile, listProfiles, deleteProfile, initializeAsProject, isManuallyInitializedProject, } from '../config/index.js';
8
+ import { config, getCurrentProvider, getModelsForCurrentProvider, PROTOCOLS, LANGUAGES, setProvider, setApiKey, clearApiKey, getApiKey, isTelemetryEnabled, telemetryForcedOffByEnv, saveSession, startNewSession, loadSession, listSessionsWithInfo, deleteSession, renameSession, setProjectPermission, saveProfile, loadProfile, applyProfile, listProfiles, deleteProfile, initializeAsProject, isManuallyInitializedProject, } from '../config/index.js';
9
9
  import { getProjectContext } from '../utils/project.js';
10
10
  import { getCurrentVersion } from '../utils/update.js';
11
11
  import { getProviderList, getProvider } from '../config/providers.js';
@@ -245,6 +245,35 @@ export async function handleCommand(command, args, ctx) {
245
245
  ctx.app.addMessage({ role: 'system', content: lines.join('\n') });
246
246
  break;
247
247
  }
248
+ case 'telemetry': {
249
+ const sub = args[0]?.toLowerCase();
250
+ const envOff = telemetryForcedOffByEnv();
251
+ if (sub === 'on' || sub === 'off') {
252
+ if (envOff) {
253
+ ctx.app.notify('Telemetry is forced OFF by CODEEP_NO_TELEMETRY / DO_NOT_TRACK — unset that env var to change it.');
254
+ break;
255
+ }
256
+ config.set('telemetry', sub === 'on');
257
+ ctx.app.notify(sub === 'on'
258
+ ? 'Telemetry on — usage stats, transcripts, progress & notes sync to codeep.dev.'
259
+ : 'Telemetry off — no automatic cloud uploads.');
260
+ break;
261
+ }
262
+ if (sub && sub !== 'status') {
263
+ ctx.app.notify('Usage: /telemetry · /telemetry on · /telemetry off');
264
+ break;
265
+ }
266
+ const flag = config.get('telemetry') !== false;
267
+ const tLines = ['## Telemetry', ''];
268
+ tLines.push(`**State** ${isTelemetryEnabled() ? 'on' : 'off'}`);
269
+ tLines.push(`**Flag** telemetry = ${flag}`);
270
+ if (envOff)
271
+ tLines.push('**Env** forced off by CODEEP_NO_TELEMETRY / DO_NOT_TRACK (overrides the flag)');
272
+ tLines.push('');
273
+ tLines.push('Toggle with `/telemetry on` or `/telemetry off`. Controls automatic uploads of usage stats, session transcripts, progress, and memory notes.');
274
+ ctx.app.addMessage({ role: 'system', content: tLines.join('\n') });
275
+ break;
276
+ }
248
277
  case 'grant': {
249
278
  setProjectPermission(ctx.projectPath, true, true);
250
279
  ctx.setHasWriteAccess(true);
@@ -880,8 +909,13 @@ Format: use headers per category, only include categories where you found issues
880
909
  ctx.app.showLogin(providers.map(p => ({ id: p.id, name: p.name, description: p.description, subscribeUrl: p.subscribeUrl, noApiKey: p.noApiKey })), async (result) => {
881
910
  if (result) {
882
911
  setProvider(result.providerId);
883
- await setApiKey(result.apiKey);
884
- ctx.app.notify('Logged in successfully');
912
+ try {
913
+ await setApiKey(result.apiKey);
914
+ ctx.app.notify('Logged in successfully');
915
+ }
916
+ catch {
917
+ ctx.app.notify('Could not save the API key (secure storage unavailable).');
918
+ }
885
919
  }
886
920
  });
887
921
  break;
@@ -901,11 +935,11 @@ Format: use headers per category, only include categories where you found issues
901
935
  return;
902
936
  if (result === 'all') {
903
937
  for (const p of configuredProviders)
904
- clearApiKey(p.id);
938
+ void clearApiKey(p.id);
905
939
  ctx.app.notify('Logged out from all providers. Use /login to sign in.');
906
940
  }
907
941
  else {
908
- clearApiKey(result);
942
+ void clearApiKey(result);
909
943
  const provider = configuredProviders.find(p => p.id === result);
910
944
  ctx.app.notify(`Logged out from ${provider?.name || result}`);
911
945
  if (result === currentProvider.id) {
@@ -278,7 +278,14 @@ async function showLoginFlow() {
278
278
  renderCurrentStep();
279
279
  return;
280
280
  }
281
- await setApiKey(key);
281
+ try {
282
+ await setApiKey(key);
283
+ }
284
+ catch {
285
+ loginError = 'Could not save the API key (secure storage unavailable). Please try again.';
286
+ renderCurrentStep();
287
+ return;
288
+ }
282
289
  cleanup();
283
290
  resolve(key);
284
291
  },
@@ -414,11 +421,15 @@ Commands (in chat):
414
421
  if (sub === 'sync' || sub === 'pull') {
415
422
  // Pull API keys from codeep.dev and save to local config
416
423
  const { pullKeys } = await import('../utils/codeepCloud.js');
417
- const { getSyncToken, setApiKey } = await import('../config/index.js');
424
+ const { getSyncToken, setApiKey, loadAllApiKeys: loadKeys } = await import('../config/index.js');
418
425
  if (!getSyncToken()) {
419
426
  console.log('\n Not linked to codeep.dev. Run: codeep account\n');
420
427
  process.exit(1);
421
428
  }
429
+ // Run the one-time plaintext->keychain migration BEFORE storing any pulled
430
+ // key. Otherwise the first setApiKey flips keysSecured=true and any local
431
+ // legacy plaintext keys would never migrate (orphaned, invisible).
432
+ await loadKeys();
422
433
  process.stdout.write(' Pulling keys from codeep.dev...');
423
434
  const keys = await pullKeys();
424
435
  if (!keys) {
@@ -430,10 +441,17 @@ Commands (in chat):
430
441
  console.log(' no keys found.\n Add keys at codeep.dev/dashboard');
431
442
  }
432
443
  else {
444
+ let synced = 0;
433
445
  for (const [provider, key] of Object.entries(keys)) {
434
- setApiKey(key, provider);
446
+ try {
447
+ await setApiKey(key, provider);
448
+ synced++;
449
+ }
450
+ catch {
451
+ console.log(`\n Warning: could not securely store the key for ${provider}.`);
452
+ }
435
453
  }
436
- console.log(` synced ${count} key${count !== 1 ? 's' : ''}.`);
454
+ console.log(` synced ${synced} key${synced !== 1 ? 's' : ''}.`);
437
455
  }
438
456
  // Also pull portable personal config — personalities + custom commands +
439
457
  // the user profile. Additive merge (never clobbers local files).
@@ -13,6 +13,15 @@ import { undoLastAction, undoAllActions, getCurrentSession, getRecentSessions, f
13
13
  import { VerifyResult } from './verify';
14
14
  import { TaskPlan, SubTask } from './taskPlanner';
15
15
  export type PermissionOutcome = 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always';
16
+ export type PermissionDecision = 'allow-once' | 'allow-always' | 'deny-once' | 'deny-always';
17
+ /**
18
+ * Map a permission outcome to a decision, FAILING CLOSED: a dangerous tool is
19
+ * allowed only on an explicit allow outcome. `reject_*` deny, and — critically —
20
+ * any unknown/malformed outcome from a buggy or hostile client also denies
21
+ * (deny-once) rather than slipping through to execution. Pure + exported so the
22
+ * invariant is unit-tested independently of the agent loop.
23
+ */
24
+ export declare function classifyPermissionOutcome(outcome: string | undefined | null): PermissionDecision;
16
25
  export interface AgentOptions {
17
26
  maxIterations: number;
18
27
  maxDuration: number;
@@ -103,6 +103,22 @@ function compressMessages(messages, actions) {
103
103
  debug(`Context compressed: ${totalChars} chars → keeping first + summary + last ${keep} messages`);
104
104
  return [firstMessage, summaryMessage, ...recentMessages];
105
105
  }
106
+ /**
107
+ * Map a permission outcome to a decision, FAILING CLOSED: a dangerous tool is
108
+ * allowed only on an explicit allow outcome. `reject_*` deny, and — critically —
109
+ * any unknown/malformed outcome from a buggy or hostile client also denies
110
+ * (deny-once) rather than slipping through to execution. Pure + exported so the
111
+ * invariant is unit-tested independently of the agent loop.
112
+ */
113
+ export function classifyPermissionOutcome(outcome) {
114
+ if (outcome === 'allow_always')
115
+ return 'allow-always';
116
+ if (outcome === 'allow_once')
117
+ return 'allow-once';
118
+ if (outcome === 'reject_always')
119
+ return 'deny-always';
120
+ return 'deny-once'; // 'reject_once' OR anything unexpected → fail closed
121
+ }
106
122
  /**
107
123
  * Build the result for a run that paused at a safety limit. Pausing is a normal,
108
124
  * resumable state — not an error — so the summary tells the user how to resume.
@@ -779,15 +795,18 @@ export async function runAgent(prompt, projectContext, options = {}) {
779
795
  continue;
780
796
  }
781
797
  const outcome = await opts.onRequestPermission(toolCall);
782
- if (outcome === 'allow_always') {
798
+ // Fail CLOSED: allow ONLY on an explicit allow outcome; reject_* and
799
+ // any malformed/unknown outcome deny (see classifyPermissionOutcome).
800
+ const decision = classifyPermissionOutcome(outcome);
801
+ if (decision === 'allow-always') {
783
802
  alwaysAllowedTools.add(toolCall.tool);
784
803
  }
785
- else if (outcome === 'reject_always') {
786
- alwaysRejectedTools.add(toolCall.tool);
787
- rejectResult();
788
- continue;
804
+ else if (decision === 'allow-once') {
805
+ // proceed this once
789
806
  }
790
- else if (outcome === 'reject_once') {
807
+ else {
808
+ if (decision === 'deny-always')
809
+ alwaysRejectedTools.add(toolCall.tool);
791
810
  rejectResult();
792
811
  continue;
793
812
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { randomBytes, createHash } from 'crypto';
9
9
  import { spawn } from 'child_process';
10
- import { getGithubId, getSyncToken, setGithubAccount, setSyncToken, getDeviceId } from '../config/index.js';
10
+ import { getGithubId, getSyncToken, setGithubAccount, setSyncToken, getDeviceId, isTelemetryEnabled } from '../config/index.js';
11
11
  import { hostname } from 'os';
12
12
  const API_BASE = 'https://codeep.dev';
13
13
  const POLL_INTERVAL_MS = 2000;
@@ -110,6 +110,8 @@ export function generateProjectId(projectRoot) {
110
110
  * Retries up to 2 times on network errors or 5xx responses.
111
111
  */
112
112
  export function reportStats(payload) {
113
+ if (!isTelemetryEnabled())
114
+ return; // user opted out of automatic uploads
113
115
  const githubId = getGithubId();
114
116
  if (!githubId)
115
117
  return; // not linked, skip silently
@@ -124,6 +126,8 @@ export function reportStats(payload) {
124
126
  }).catch(() => { });
125
127
  }
126
128
  export async function reportStatsAsync(payload) {
129
+ if (!isTelemetryEnabled())
130
+ return; // user opted out of automatic uploads
127
131
  const githubId = getGithubId();
128
132
  if (!githubId)
129
133
  return;
@@ -320,6 +324,8 @@ export const pushCommands = () => pushBundle('commands');
320
324
  * Fire-and-forget. Only sends if linked and sync_token is available.
321
325
  */
322
326
  export function syncSession(payload) {
327
+ if (!isTelemetryEnabled())
328
+ return; // user opted out of conversation/session upload
323
329
  const githubId = getGithubId();
324
330
  const syncToken = getSyncToken();
325
331
  if (!githubId || !syncToken)
@@ -335,6 +341,8 @@ export function syncSession(payload) {
335
341
  }).catch(() => { });
336
342
  }
337
343
  export async function syncSessionAsync(payload) {
344
+ if (!isTelemetryEnabled())
345
+ return; // user opted out of conversation/session upload
338
346
  const githubId = getGithubId();
339
347
  const syncToken = getSyncToken();
340
348
  if (!githubId || !syncToken)
@@ -354,6 +362,8 @@ export async function syncSessionAsync(payload) {
354
362
  * Fire-and-forget. Only sends if linked (githubId + syncToken).
355
363
  */
356
364
  export function syncProgress(payload) {
365
+ if (!isTelemetryEnabled())
366
+ return; // user opted out of automatic uploads
357
367
  const githubId = getGithubId();
358
368
  const syncToken = getSyncToken();
359
369
  if (!githubId || !syncToken)
@@ -483,6 +493,8 @@ export async function pullUserProfile() {
483
493
  }
484
494
  }
485
495
  export async function syncMemoryNotes(projectName, notes) {
496
+ if (!isTelemetryEnabled())
497
+ return; // user opted out of automatic uploads
486
498
  const syncToken = getSyncToken();
487
499
  if (!syncToken)
488
500
  return;
@@ -1,10 +1,23 @@
1
1
  import { logger } from './logger.js';
2
- // keytar is a native addon — load dynamically so compiled binaries fall back gracefully
3
- let keytar = null;
4
- try {
5
- keytar = (await import('keytar')).default;
2
+ // keytar is a native addon — load it LAZILY (on first use), never at module
3
+ // top level. A top-level `await import()` here gives the module a top-level
4
+ // await, which makes `bun build --compile` reject any CommonJS require() that
5
+ // transitively depends on this file (renderer/main.js → codeepCloud → config →
6
+ // keychain). Lazy loading keeps the module side-effect-free at import time.
7
+ let _keytar = null;
8
+ let _keytarTried = false;
9
+ async function loadKeytar() {
10
+ if (!_keytarTried) {
11
+ _keytarTried = true;
12
+ try {
13
+ _keytar = (await import('keytar')).default;
14
+ }
15
+ catch {
16
+ _keytar = null; /* native addon unavailable */
17
+ }
18
+ }
19
+ return _keytar;
6
20
  }
7
- catch { /* native addon unavailable */ }
8
21
  const SERVICE_NAME = 'codeep';
9
22
  class KeychainStorage {
10
23
  getAccountName(providerId) {
@@ -12,9 +25,11 @@ class KeychainStorage {
12
25
  }
13
26
  async getApiKey(providerId) {
14
27
  try {
28
+ const kt = await loadKeytar();
29
+ if (!kt)
30
+ return null;
15
31
  const account = this.getAccountName(providerId);
16
- const password = await keytar.getPassword(SERVICE_NAME, account);
17
- return password;
32
+ return await kt.getPassword(SERVICE_NAME, account);
18
33
  }
19
34
  catch (error) {
20
35
  logger.debug(`Failed to get API key from keychain: ${error}`);
@@ -23,8 +38,11 @@ class KeychainStorage {
23
38
  }
24
39
  async setApiKey(providerId, apiKey) {
25
40
  try {
41
+ const kt = await loadKeytar();
42
+ if (!kt)
43
+ throw new Error('keytar unavailable');
26
44
  const account = this.getAccountName(providerId);
27
- await keytar.setPassword(SERVICE_NAME, account, apiKey);
45
+ await kt.setPassword(SERVICE_NAME, account, apiKey);
28
46
  }
29
47
  catch (error) {
30
48
  throw new Error(`Failed to store API key in keychain: ${error}`);
@@ -32,8 +50,11 @@ class KeychainStorage {
32
50
  }
33
51
  async deleteApiKey(providerId) {
34
52
  try {
53
+ const kt = await loadKeytar();
54
+ if (!kt)
55
+ return;
35
56
  const account = this.getAccountName(providerId);
36
- await keytar.deletePassword(SERVICE_NAME, account);
57
+ await kt.deletePassword(SERVICE_NAME, account);
37
58
  }
38
59
  catch (error) {
39
60
  logger.debug(`Failed to delete API key from keychain: ${error}`);
@@ -89,10 +110,11 @@ class SmartStorage {
89
110
  return;
90
111
  try {
91
112
  const testKey = '__codeep_test__';
92
- if (!keytar)
113
+ const kt = await loadKeytar();
114
+ if (!kt)
93
115
  throw new Error('keytar unavailable');
94
- await keytar.setPassword(SERVICE_NAME, testKey, 'test');
95
- await keytar.deletePassword(SERVICE_NAME, testKey);
116
+ await kt.setPassword(SERVICE_NAME, testKey, 'test');
117
+ await kt.deletePassword(SERVICE_NAME, testKey);
96
118
  this.useKeychain = true;
97
119
  }
98
120
  catch {
@@ -126,6 +126,74 @@ export function saveProjectIntelligence(projectPath, intelligence) {
126
126
  return false;
127
127
  }
128
128
  }
129
+ /**
130
+ * Complete default intelligence skeleton — every nested section is present so
131
+ * consumers (e.g. generateContextFromIntelligence) never dereference an
132
+ * undefined section. Used as the merge base when normalizing a loaded file.
133
+ */
134
+ function createBaseIntelligence(projectPath) {
135
+ return {
136
+ version: INTELLIGENCE_VERSION,
137
+ scannedAt: new Date().toISOString(),
138
+ projectPath,
139
+ name: basename(projectPath),
140
+ type: 'Unknown',
141
+ description: '',
142
+ structure: {
143
+ totalFiles: 0,
144
+ totalDirectories: 0,
145
+ languages: {},
146
+ topDirectories: [],
147
+ },
148
+ dependencies: {
149
+ runtime: [],
150
+ dev: [],
151
+ frameworks: [],
152
+ },
153
+ keyFiles: [],
154
+ entryPoints: [],
155
+ scripts: {},
156
+ architecture: {
157
+ patterns: [],
158
+ mainModules: [],
159
+ ciSystem: null,
160
+ containerization: [],
161
+ monorepoTool: null,
162
+ },
163
+ conventions: {
164
+ indentation: 'spaces',
165
+ quotes: 'single',
166
+ semicolons: true,
167
+ namingStyle: 'camelCase',
168
+ },
169
+ testing: {
170
+ framework: null,
171
+ testDirectory: null,
172
+ hasTests: false,
173
+ },
174
+ notes: [],
175
+ };
176
+ }
177
+ /**
178
+ * Merge a loaded (possibly partial or older-schema) intelligence object over
179
+ * the complete default skeleton so every nested section is guaranteed present.
180
+ * An interrupted scan or an older CLI can write a file that is missing whole
181
+ * sections (e.g. `conventions`); without this, reading `conventions.indentation`
182
+ * downstream throws "Cannot read properties of undefined". Nested objects are
183
+ * merged per-section so partial sub-objects are also backfilled.
184
+ */
185
+ function normalizeIntelligence(data, projectPath) {
186
+ const base = createBaseIntelligence(projectPath);
187
+ return {
188
+ ...base,
189
+ ...data,
190
+ structure: { ...base.structure, ...data.structure },
191
+ dependencies: { ...base.dependencies, ...data.dependencies },
192
+ architecture: { ...base.architecture, ...data.architecture },
193
+ conventions: { ...base.conventions, ...data.conventions },
194
+ testing: { ...base.testing, ...data.testing },
195
+ };
196
+ }
129
197
  /**
130
198
  * Load intelligence from .codeep/intelligence.json
131
199
  */
@@ -135,9 +203,13 @@ export function loadProjectIntelligence(projectPath) {
135
203
  if (!existsSync(filePath))
136
204
  return null;
137
205
  const data = JSON.parse(readFileSync(filePath, 'utf-8'));
206
+ if (!data || typeof data !== 'object')
207
+ return null;
138
208
  if (data.version !== INTELLIGENCE_VERSION)
139
209
  return null;
140
- return data;
210
+ // Backfill any missing sections so a partial/interrupted file can't crash
211
+ // downstream consumers that assume a complete shape.
212
+ return normalizeIntelligence(data, projectPath);
141
213
  }
142
214
  catch {
143
215
  return null;
@@ -159,6 +231,10 @@ export function isIntelligenceFresh(projectPath, maxAgeHours = 24) {
159
231
  * Generate AI-friendly context from intelligence
160
232
  */
161
233
  export function generateContextFromIntelligence(intelligence) {
234
+ // Defensive: backfill any missing sections so a partial object (e.g. from an
235
+ // older/interrupted scan, or an external SDK caller) can't crash the
236
+ // formatter on a nested dereference like `conventions.indentation`.
237
+ intelligence = normalizeIntelligence((intelligence ?? {}), intelligence?.projectPath ?? '');
162
238
  const lines = [];
163
239
  lines.push(`# Project: ${intelligence.name}`);
164
240
  lines.push(`Type: ${intelligence.type}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",