codeep 2.6.0 → 2.8.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,12 +217,32 @@ 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`)
220
+ #### Custom rules (`.codeep/review.yml` or `.codeep/review.json`)
221
221
 
222
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):
223
+ built-in rules, but a project can tailor them — check a `.codeep/review.yml`
224
+ (or `.codeep/review.json`) into the repo and the CLI **and** the
225
+ [Codeep GitHub Action](https://github.com/VladoIvankovic/codeep-action) both
226
+ pick it up automatically (zero LLM cost). YAML is preferred when present and is
227
+ nicer for regexes — single-quoted YAML keeps backslashes literal
228
+ (`pattern: '\bfoo\('`) so you avoid JSON's double-escaping:
229
+
230
+ ```yaml
231
+ # .codeep/review.yml
232
+ rules:
233
+ - id: no-internal-import
234
+ pattern: "from ['\"]@acme/internal"
235
+ category: best-practice
236
+ severity: error
237
+ message: Don't import from @acme/internal outside the platform team
238
+ suggestion: Use the public @acme/sdk package
239
+ extensions: [.ts, .tsx]
240
+ disable: [todo-comment, anonymous-function]
241
+ include: ['src/**']
242
+ exclude: ['**/*.test.ts', 'vendor/**']
243
+ ```
244
+
245
+ The same config as JSON:
226
246
 
227
247
  ```json
228
248
  {
@@ -246,14 +266,28 @@ both pick it up automatically (zero LLM cost):
246
266
  - **`rules`** — your own checks. `id`, `pattern` (a regex string), and `message`
247
267
  are required; `flags` (default `g`), `category`, `severity`
248
268
  (`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`, …).
269
+ - **`disable`** — turn off built-in rules by id. Run `codeep review --rules` for
270
+ the live list; the built-in ids are: `eval-usage`, `inner-html`,
271
+ `dangerously-set-inner-html`, `hardcoded-password`, `hardcoded-api-key`,
272
+ `foreach-await`, `await-in-loop`, `select-star`, `loose-null-check`,
273
+ `empty-catch`, `console-statement`, `todo-comment`, `any-type`, `ts-ignore`,
274
+ `as-any`, `var-usage`, `anonymous-function`, `missing-jsdoc`, `long-file`,
275
+ `long-function`.
252
276
  - **`include` / `exclude`** — glob scoping (`**`, `*`, `?`); `include` empty = all files.
253
277
 
254
278
  A missing, malformed, or partially-invalid config never breaks a review — bad
255
279
  entries are skipped and the run proceeds with whatever is valid.
256
280
 
281
+ **More review commands:**
282
+ - `codeep review --rules` — list the built-in rule ids (above) and exit.
283
+ - `codeep review --ai` — after the offline pass, get an advisory AI second
284
+ opinion on the working-tree diff from your configured provider (needs an API
285
+ key; never changes the exit code, so CI stays deterministic).
286
+ - `codeep hook install` — install a git pre-commit hook that reviews the
287
+ working-tree content of your staged files and blocks the commit on failures
288
+ (`--pre-push` for pre-push, `--fail-on <level>` to set the threshold,
289
+ `codeep hook uninstall` to remove). Stage changes fully before committing.
290
+
257
291
  ### Interactive Mode
258
292
  Agent asks clarifying questions when tasks are ambiguous:
259
293
  ```
@@ -1443,6 +1443,7 @@ function buildHelp() {
1443
1443
  '| `/login <provider> <key>` | Set API key for a provider |',
1444
1444
  '| `/apikey [key]` | Show or set API key |',
1445
1445
  '| `/telemetry [on\\|off]` | Show or toggle automatic cloud telemetry |',
1446
+ '| `/keysync [on\\|off]` | Show or toggle syncing API keys to codeep.dev |',
1446
1447
  '| `/lang [code]` | Set response language (`en`, `hr`, `auto`…) |',
1447
1448
  '| `/grant` | Grant write access for workspace |',
1448
1449
  '',
@@ -973,11 +973,10 @@ export function startAcpServer() {
973
973
  });
974
974
  };
975
975
  resetTokenTracking();
976
- // In manual mode, confirm write/edit operations
977
- const prevConfirmWrite = config.get('agentConfirmWriteFile');
978
- if (session.currentModeId === 'manual') {
979
- config.set('agentConfirmWriteFile', true);
980
- }
976
+ // Manual mode gates write/edit for THIS run via a per-call option passed to
977
+ // runAgentSession (extraDangerousTools, below) — NOT by mutating the global
978
+ // `agentConfirmWriteFile` config, which leaked the session's mode into the
979
+ // TUI/other processes and raced on a non-atomic restore.
981
980
  const agentResponseChunks = [];
982
981
  const sendChunk = (text) => {
983
982
  agentResponseChunks.push(text);
@@ -1083,6 +1082,9 @@ export function startAcpServer() {
1083
1082
  }
1084
1083
  }
1085
1084
  },
1085
+ // Manual mode gates write_file/edit_file for this run only (per-call,
1086
+ // no global config mutation).
1087
+ extraDangerousTools: session.currentModeId === 'manual' ? ['write_file', 'edit_file'] : undefined,
1086
1088
  // Only request permission in Manual mode
1087
1089
  onRequestPermission: session.currentModeId === 'manual'
1088
1090
  ? async (toolCall) => {
@@ -1183,7 +1185,6 @@ export function startAcpServer() {
1183
1185
  if (agentResponse) {
1184
1186
  session.history.push({ role: 'assistant', content: agentResponse });
1185
1187
  }
1186
- config.set('agentConfirmWriteFile', prevConfirmWrite);
1187
1188
  autoSaveSession(session.history, session.workspaceRoot);
1188
1189
  // Report token usage to dashboard
1189
1190
  const projectCtx = getProjectContext(session.workspaceRoot);
@@ -1247,7 +1248,6 @@ export function startAcpServer() {
1247
1248
  transport.error(msg.id, -32000, err.message);
1248
1249
  }
1249
1250
  }).finally(() => {
1250
- config.set('agentConfirmWriteFile', prevConfirmWrite);
1251
1251
  if (session)
1252
1252
  session.abortController = null;
1253
1253
  planEntries.clear();
@@ -11,6 +11,8 @@ export interface AgentSessionOptions {
11
11
  onThought?: (text: string) => void;
12
12
  onToolCall?: (toolCallId: string, toolName: string, kind: string, title: string, status: 'pending' | 'running' | 'finished' | 'error', locations?: string[], rawOutput?: string) => void;
13
13
  onRequestPermission?: (toolCall: ToolCall) => Promise<PermissionOutcome>;
14
+ /** Tools to force into the per-run dangerous set (ACP manual mode). */
15
+ extraDangerousTools?: string[];
14
16
  onExecuteCommand?: (command: string, args: string[], cwd: string) => Promise<{
15
17
  stdout: string;
16
18
  stderr: string;
@@ -145,6 +145,7 @@ export async function runAgentSession(opts) {
145
145
  }
146
146
  },
147
147
  onRequestPermission: opts.onRequestPermission,
148
+ extraDangerousTools: opts.extraDangerousTools,
148
149
  onExecuteCommand: opts.onExecuteCommand,
149
150
  fs: opts.fs,
150
151
  // Route MCP-prefixed tool calls through the per-session registry.
@@ -96,10 +96,20 @@ interface ConfigSchema {
96
96
  /** True once legacy plaintext keys (providerApiKeys / apiKey) have been
97
97
  * migrated into secure storage and wiped from the config file. */
98
98
  keysSecured: boolean;
99
+ /** Plaintext fallback key map used by utils/keychain.ts ONLY when the OS
100
+ * keychain is unavailable. Swept into the keychain once it becomes available
101
+ * (see sweepFallbackKeysToKeychain). Empty {} on keychain-capable systems. */
102
+ apiKeys?: Record<string, string>;
99
103
  /** Master switch for automatic cloud uploads (usage stats, session
100
104
  * transcripts, progress, memory notes). Default true; set false to opt out.
101
105
  * The CODEEP_NO_TELEMETRY / DO_NOT_TRACK env vars also force it off. */
102
106
  telemetry: boolean;
107
+ /** Opt-in to syncing API keys to codeep.dev (`codeep account push`/`sync`).
108
+ * OFF by default — keys live only in the OS keychain unless you enable this.
109
+ * Synced keys are stored server-readable (AES from a server-held secret), so
110
+ * this is an explicit consent switch. Enable via `/keysync on` or Settings;
111
+ * the CODEEP_NO_KEY_SYNC env var forces it off (org-policy hard switch). */
112
+ syncKeysToCloud: boolean;
103
113
  githubId: string;
104
114
  githubUsername: string;
105
115
  syncToken: string;
@@ -172,6 +182,13 @@ export declare function isTelemetryEnabled(): boolean;
172
182
  * the /telemetry command explain why a toggle had no effect.
173
183
  */
174
184
  export declare function telemetryForcedOffByEnv(): boolean;
185
+ export declare function isKeySyncEnabled(): boolean;
186
+ /**
187
+ * True when CODEEP_NO_KEY_SYNC is forcing key sync off — so the `syncKeysToCloud`
188
+ * flag can't turn it back on. Lets the /keysync command explain why a toggle had
189
+ * no effect.
190
+ */
191
+ export declare function keySyncForcedOffByEnv(): boolean;
175
192
  /**
176
193
  * Clear API key for a specific provider
177
194
  */
@@ -186,7 +186,9 @@ function createConfig() {
186
186
  providerApiKeys: [],
187
187
  configuredProviderIds: [],
188
188
  keysSecured: false,
189
+ apiKeys: {},
189
190
  telemetry: true,
191
+ syncKeysToCloud: false,
190
192
  githubId: '',
191
193
  githubUsername: '',
192
194
  syncToken: '',
@@ -359,6 +361,44 @@ async function migrateKeysToSecureStorage() {
359
361
  _migrationPromise = null;
360
362
  }
361
363
  }
364
+ let _sweepPromise = null;
365
+ /**
366
+ * If the OS keychain is now available but API keys are still sitting in the
367
+ * plaintext `apiKeys` fallback map (written by utils/keychain.ts while the
368
+ * keychain was unavailable on a prior run), move each into the keychain. The
369
+ * keychain write also deletes the entry from the plaintext fallback map, so a
370
+ * successful sweep leaves no plaintext behind. Cheap no-op when the map is
371
+ * empty or the keychain is unavailable; deduped for concurrent callers. Runs
372
+ * independently of `keysSecured` so a key written during a keychain outage is
373
+ * still swept up later.
374
+ */
375
+ async function sweepFallbackKeysToKeychain() {
376
+ if (!_sweepPromise) {
377
+ _sweepPromise = (async () => {
378
+ const store = secureKeyStore();
379
+ // Only sweep when the keychain is actually usable; otherwise leave the
380
+ // plaintext fallback in place (there's nowhere safer to move it).
381
+ if (!store.isKeychainAvailable || !(await store.isKeychainAvailable()))
382
+ return;
383
+ const plaintext = config.get('apiKeys') || {};
384
+ for (const [providerId, apiKey] of Object.entries(plaintext)) {
385
+ if (typeof apiKey !== 'string' || !apiKey)
386
+ continue;
387
+ try {
388
+ await store.setApiKey(providerId, apiKey); // writes keychain + deletes from fallback map
389
+ addConfiguredProviderId(providerId);
390
+ }
391
+ catch { /* keep the plaintext entry on failure — never lose a key */ }
392
+ }
393
+ })();
394
+ }
395
+ try {
396
+ await _sweepPromise;
397
+ }
398
+ finally {
399
+ _sweepPromise = null;
400
+ }
401
+ }
362
402
  export const LANGUAGES = {
363
403
  'auto': 'Auto-detect',
364
404
  'en': 'English',
@@ -417,9 +457,11 @@ export async function loadApiKey(providerId) {
417
457
  * Should be called at app startup
418
458
  */
419
459
  export async function loadAllApiKeys() {
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).
460
+ // Migrate any legacy plaintext keys, then sweep any keychain-fallback plaintext
461
+ // up into the keychain (no-op when none / keychain unavailable), then load all
462
+ // from secure storage using the non-secret configuredProviderIds index.
422
463
  await migrateKeysToSecureStorage();
464
+ await sweepFallbackKeysToKeychain();
423
465
  const store = secureKeyStore();
424
466
  for (const providerId of (config.get('configuredProviderIds') || [])) {
425
467
  const key = await store.getApiKey(providerId);
@@ -509,6 +551,30 @@ export function isTelemetryEnabled() {
509
551
  export function telemetryForcedOffByEnv() {
510
552
  return envForcesTelemetryOff();
511
553
  }
554
+ /**
555
+ * Whether syncing API keys to the cloud is allowed. OFF by default (opt-in):
556
+ * unlike telemetry, this also gates the EXPLICIT `codeep account push` and the
557
+ * key-download half of `account sync`, because pushing a key stores it
558
+ * server-readable. The CODEEP_NO_KEY_SYNC env var forces it off as an
559
+ * org-policy hard switch the config flag can't override.
560
+ */
561
+ function envForcesKeySyncOff() {
562
+ const off = (v) => !!v && !/^(0|false|no|off)$/i.test(v.trim());
563
+ return off(process.env.CODEEP_NO_KEY_SYNC);
564
+ }
565
+ export function isKeySyncEnabled() {
566
+ if (envForcesKeySyncOff())
567
+ return false;
568
+ return config.get('syncKeysToCloud') === true; // default OFF — must be explicitly true
569
+ }
570
+ /**
571
+ * True when CODEEP_NO_KEY_SYNC is forcing key sync off — so the `syncKeysToCloud`
572
+ * flag can't turn it back on. Lets the /keysync command explain why a toggle had
573
+ * no effect.
574
+ */
575
+ export function keySyncForcedOffByEnv() {
576
+ return envForcesKeySyncOff();
577
+ }
512
578
  /**
513
579
  * Clear API key for a specific provider
514
580
  */
@@ -83,6 +83,7 @@ const COMMAND_DESCRIPTIONS = {
83
83
  'tasks': 'Show pending tasks from codeep.dev dashboard',
84
84
  'sync': 'Sync learning preferences and profiles to codeep.dev',
85
85
  'telemetry': 'Show or toggle automatic cloud telemetry (on/off)',
86
+ 'keysync': 'Show or toggle syncing API keys to codeep.dev (on/off)',
86
87
  // 2.0 — surfaced for `/` autocomplete; documented in /help too.
87
88
  'compact': 'Summarize older messages to free up context',
88
89
  'commands': 'List custom slash commands in .codeep/commands/*.md',
@@ -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, isTelemetryEnabled, telemetryForcedOffByEnv, 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, isKeySyncEnabled, keySyncForcedOffByEnv, 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';
@@ -274,6 +274,35 @@ export async function handleCommand(command, args, ctx) {
274
274
  ctx.app.addMessage({ role: 'system', content: tLines.join('\n') });
275
275
  break;
276
276
  }
277
+ case 'keysync': {
278
+ const sub = args[0]?.toLowerCase();
279
+ const envOff = keySyncForcedOffByEnv();
280
+ if (sub === 'on' || sub === 'off') {
281
+ if (envOff) {
282
+ ctx.app.notify('Cloud key sync is forced OFF by CODEEP_NO_KEY_SYNC — unset that env var to change it.');
283
+ break;
284
+ }
285
+ config.set('syncKeysToCloud', sub === 'on');
286
+ ctx.app.notify(sub === 'on'
287
+ ? 'Cloud key sync on — `codeep account push/sync` will now upload/download API keys. Note: synced keys are stored server-readable on codeep.dev.'
288
+ : 'Cloud key sync off — API keys stay in your OS keychain only. (Run `codeep account purge-keys` to also wipe any keys already on the server.)');
289
+ break;
290
+ }
291
+ if (sub && sub !== 'status') {
292
+ ctx.app.notify('Usage: /keysync · /keysync on · /keysync off');
293
+ break;
294
+ }
295
+ const flag = config.get('syncKeysToCloud') === true;
296
+ const kLines = ['## Cloud key sync', ''];
297
+ kLines.push(`**State** ${isKeySyncEnabled() ? 'on' : 'off'}`);
298
+ kLines.push(`**Flag** syncKeysToCloud = ${flag}`);
299
+ if (envOff)
300
+ kLines.push('**Env** forced off by CODEEP_NO_KEY_SYNC (overrides the flag)');
301
+ kLines.push('');
302
+ kLines.push('OFF by default. API keys live only in your OS keychain unless you turn this on. When on, `codeep account push`/`sync` upload/download keys, which are stored **server-readable** on codeep.dev. Toggle with `/keysync on` or `/keysync off`; wipe server copies with `codeep account purge-keys`.');
303
+ ctx.app.addMessage({ role: 'system', content: kLines.join('\n') });
304
+ break;
305
+ }
277
306
  case 'grant': {
278
307
  setProjectPermission(ctx.projectPath, true, true);
279
308
  ctx.setHasWriteAccess(true);
@@ -250,6 +250,16 @@ export const SETTINGS = [
250
250
  { value: false, label: 'Off' },
251
251
  ],
252
252
  },
253
+ {
254
+ key: 'syncKeysToCloud',
255
+ label: 'Sync API Keys to Cloud (server-readable)',
256
+ getValue: () => config.get('syncKeysToCloud') === true,
257
+ type: 'select',
258
+ options: [
259
+ { value: false, label: 'Off (keychain only)' },
260
+ { value: true, label: 'On (push/sync to codeep.dev)' },
261
+ ],
262
+ },
253
263
  ];
254
264
  /**
255
265
  * Format value for display
@@ -387,7 +387,11 @@ async function main() {
387
387
  // the review usage rather than the top-level help.
388
388
  if (args[0] === 'review') {
389
389
  const { runHeadlessReview } = await import('../utils/headlessReview.js');
390
- process.exit(runHeadlessReview(args.slice(1)));
390
+ process.exit(await runHeadlessReview(args.slice(1)));
391
+ }
392
+ if (args[0] === 'hook') {
393
+ const { runHookCommand } = await import('../utils/gitHookInstaller.js');
394
+ process.exit(runHookCommand(args.slice(1)));
391
395
  }
392
396
  if (args.includes('--version') || args.includes('-v')) {
393
397
  console.log(`Codeep v${getCurrentVersion()}`);
@@ -400,10 +404,13 @@ Codeep - AI-powered coding assistant TUI
400
404
  Usage:
401
405
  codeep Start interactive chat
402
406
  codeep account Link CLI to your codeep.dev dashboard
403
- codeep account sync Pull keys + personalities + commands + profile from codeep.dev
404
- codeep account push Push local keys + personalities + commands + profile to codeep.dev
407
+ codeep account sync Pull personalities + commands + profile (+ keys if cloud key sync is on)
408
+ codeep account push Push personalities + commands + profile (+ keys if cloud key sync is on)
409
+ codeep account purge-keys Delete all your API keys stored on codeep.dev (cloud only; local keychain untouched)
405
410
  codeep acp Start ACP server (for Zed editor integration)
406
- codeep review Offline code review for CI (--json, --fail-on <level>)
411
+ codeep review Offline code review for CI (--json, --fail-on, --rules, --ai)
412
+ codeep hook install Install a git pre-commit hook running \`codeep review\`
413
+ codeep hook uninstall Remove the Codeep git hook
407
414
  codeep --version Show version
408
415
  codeep --help Show this help
409
416
 
@@ -419,9 +426,7 @@ Commands (in chat):
419
426
  if (args[0] === 'account') {
420
427
  const sub = args[1];
421
428
  if (sub === 'sync' || sub === 'pull') {
422
- // Pull API keys from codeep.dev and save to local config
423
- const { pullKeys } = await import('../utils/codeepCloud.js');
424
- const { getSyncToken, setApiKey, loadAllApiKeys: loadKeys } = await import('../config/index.js');
429
+ const { getSyncToken, setApiKey, loadAllApiKeys: loadKeys, isKeySyncEnabled } = await import('../config/index.js');
425
430
  if (!getSyncToken()) {
426
431
  console.log('\n Not linked to codeep.dev. Run: codeep account\n');
427
432
  process.exit(1);
@@ -430,28 +435,36 @@ Commands (in chat):
430
435
  // key. Otherwise the first setApiKey flips keysSecured=true and any local
431
436
  // legacy plaintext keys would never migrate (orphaned, invisible).
432
437
  await loadKeys();
433
- process.stdout.write(' Pulling keys from codeep.dev...');
434
- const keys = await pullKeys();
435
- if (!keys) {
436
- console.log(' failed.\n Check your connection or re-link with: codeep account\n');
437
- process.exit(1);
438
- }
439
- const count = Object.keys(keys).length;
440
- if (count === 0) {
441
- console.log(' no keys found.\n Add keys at codeep.dev/dashboard');
442
- }
443
- else {
444
- let synced = 0;
445
- for (const [provider, key] of Object.entries(keys)) {
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}.`);
438
+ // API keys are opt-in (default OFF). Pull them only when cloud key sync is
439
+ // enabled; the personal config below always syncs (no secrets).
440
+ if (isKeySyncEnabled()) {
441
+ const { pullKeys } = await import('../utils/codeepCloud.js');
442
+ process.stdout.write(' Pulling keys from codeep.dev...');
443
+ const keys = await pullKeys();
444
+ if (!keys) {
445
+ console.log(' failed.\n Check your connection or re-link with: codeep account\n');
446
+ process.exit(1);
447
+ }
448
+ const count = Object.keys(keys).length;
449
+ if (count === 0) {
450
+ console.log(' no keys found.\n Add keys at codeep.dev/dashboard');
451
+ }
452
+ else {
453
+ let synced = 0;
454
+ for (const [provider, key] of Object.entries(keys)) {
455
+ try {
456
+ await setApiKey(key, provider);
457
+ synced++;
458
+ }
459
+ catch {
460
+ console.log(`\n Warning: could not securely store the key for ${provider}.`);
461
+ }
452
462
  }
463
+ console.log(` synced ${synced} key${synced !== 1 ? 's' : ''}.`);
453
464
  }
454
- console.log(` synced ${synced} key${synced !== 1 ? 's' : ''}.`);
465
+ }
466
+ else {
467
+ console.log(' Cloud key sync is off — skipping API keys. Enable with: /keysync on');
455
468
  }
456
469
  // Also pull portable personal config — personalities + custom commands +
457
470
  // the user profile. Additive merge (never clobbers local files).
@@ -472,29 +485,38 @@ Commands (in chat):
472
485
  process.exit(0);
473
486
  }
474
487
  if (sub === 'push') {
475
- // Push local API keys to codeep.dev
476
- const { pushKeys } = await import('../utils/codeepCloud.js');
477
- const { getSyncToken, getApiKey } = await import('../config/index.js');
478
- const { PROVIDERS } = await import('../config/providers.js');
488
+ const { getSyncToken, getApiKey, isKeySyncEnabled } = await import('../config/index.js');
479
489
  if (!getSyncToken()) {
480
490
  console.log('\n Not linked to codeep.dev. Run: codeep account\n');
481
491
  process.exit(1);
482
492
  }
483
- await loadAllApiKeys();
484
- const keys = {};
485
- for (const providerId of Object.keys(PROVIDERS)) {
486
- const key = getApiKey(providerId);
487
- if (key)
488
- keys[providerId] = key;
489
- }
490
- const count = Object.keys(keys).length;
491
- if (count === 0) {
492
- console.log('\n No local API keys to push.\n');
493
- process.exit(0);
494
- }
495
- process.stdout.write(` Pushing ${count} key${count !== 1 ? 's' : ''} to codeep.dev...`);
496
- const ok = await pushKeys(keys);
497
- console.log(ok ? ' done.' : ' failed.');
493
+ // API keys are opt-in (default OFF). Push them only when cloud key sync is
494
+ // enabled; the personal config below always pushes (no secrets).
495
+ let keyPushFailed = false;
496
+ if (isKeySyncEnabled()) {
497
+ const { pushKeys } = await import('../utils/codeepCloud.js');
498
+ const { PROVIDERS } = await import('../config/providers.js');
499
+ await loadAllApiKeys();
500
+ const keys = {};
501
+ for (const providerId of Object.keys(PROVIDERS)) {
502
+ const key = getApiKey(providerId);
503
+ if (key)
504
+ keys[providerId] = key;
505
+ }
506
+ const count = Object.keys(keys).length;
507
+ if (count === 0) {
508
+ console.log(' No local API keys to push.');
509
+ }
510
+ else {
511
+ process.stdout.write(` Pushing ${count} key${count !== 1 ? 's' : ''} to codeep.dev...`);
512
+ const ok = await pushKeys(keys);
513
+ console.log(ok ? ' done.' : ' failed.');
514
+ keyPushFailed = !ok;
515
+ }
516
+ }
517
+ else {
518
+ console.log(' Cloud key sync is off — skipping API keys. Enable with: /keysync on');
519
+ }
498
520
  // Also push portable personal config — personalities + commands + profile.
499
521
  const { pushPersonalities, pushCommands, pushUserProfile } = await import('../utils/codeepCloud.js');
500
522
  const pCount = await pushPersonalities();
@@ -509,6 +531,19 @@ Commands (in chat):
509
531
  console.log(' Pushed your profile (about you).');
510
532
  }
511
533
  console.log('');
534
+ process.exit(keyPushFailed ? 1 : 0);
535
+ }
536
+ if (sub === 'purge-keys') {
537
+ const { getSyncToken } = await import('../config/index.js');
538
+ if (!getSyncToken()) {
539
+ console.log('\n Not linked to codeep.dev. Run: codeep account\n');
540
+ process.exit(1);
541
+ }
542
+ const { purgeKeys } = await import('../utils/codeepCloud.js');
543
+ process.stdout.write(' Deleting all your API keys from codeep.dev...');
544
+ const ok = await purgeKeys();
545
+ console.log(ok ? ' done. (Local keychain keys are untouched.)' : ' failed.');
546
+ console.log('');
512
547
  process.exit(ok ? 0 : 1);
513
548
  }
514
549
  const { runAccountFlow } = await import('../utils/codeepCloud.js');
@@ -22,6 +22,14 @@ export type PermissionDecision = 'allow-once' | 'allow-always' | 'deny-once' | '
22
22
  * invariant is unit-tested independently of the agent loop.
23
23
  */
24
24
  export declare function classifyPermissionOutcome(outcome: string | undefined | null): PermissionDecision;
25
+ /**
26
+ * Build the set of tools that require a permission prompt this run. Derived from
27
+ * the global agentConfirm* settings, plus any `extra` tools forced in for this
28
+ * run only (ACP manual mode passes ['write_file','edit_file'] this way instead
29
+ * of mutating the global `agentConfirmWriteFile` config — which would leak the
30
+ * session's mode into the TUI and race on restore). Exported for unit testing.
31
+ */
32
+ export declare function buildDangerousTools(extra?: string[]): Set<string>;
25
33
  export interface AgentOptions {
26
34
  maxIterations: number;
27
35
  maxDuration: number;
@@ -34,6 +42,10 @@ export interface AgentOptions {
34
42
  onTaskPlan?: (plan: TaskPlan) => void;
35
43
  onTaskUpdate?: (task: SubTask) => void;
36
44
  onRequestPermission?: (toolCall: ToolCall) => Promise<PermissionOutcome>;
45
+ /** Tool names to force into the per-run dangerous set, on top of the global
46
+ * agentConfirm* settings. ACP manual mode passes ['write_file','edit_file']
47
+ * here to gate them for THIS run only, instead of mutating global config. */
48
+ extraDangerousTools?: string[];
37
49
  onExecuteCommand?: (command: string, args: string[], cwd: string) => Promise<{
38
50
  stdout: string;
39
51
  stderr: string;
@@ -119,6 +119,23 @@ export function classifyPermissionOutcome(outcome) {
119
119
  return 'deny-always';
120
120
  return 'deny-once'; // 'reject_once' OR anything unexpected → fail closed
121
121
  }
122
+ /**
123
+ * Build the set of tools that require a permission prompt this run. Derived from
124
+ * the global agentConfirm* settings, plus any `extra` tools forced in for this
125
+ * run only (ACP manual mode passes ['write_file','edit_file'] this way instead
126
+ * of mutating the global `agentConfirmWriteFile` config — which would leak the
127
+ * session's mode into the TUI and race on restore). Exported for unit testing.
128
+ */
129
+ export function buildDangerousTools(extra = []) {
130
+ const tools = new Set([
131
+ ...(config.get('agentConfirmDeleteFile') !== false ? ['delete_file'] : []),
132
+ ...(config.get('agentConfirmExecuteCommand') !== false ? ['execute_command'] : []),
133
+ ...(config.get('agentConfirmWriteFile') === true ? ['write_file', 'edit_file'] : []),
134
+ ]);
135
+ for (const t of extra)
136
+ tools.add(t);
137
+ return tools;
138
+ }
122
139
  /**
123
140
  * Build the result for a run that paused at a safety limit. Pausing is a normal,
124
141
  * resumable state — not an error — so the summary tells the user how to resume.
@@ -384,11 +401,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
384
401
  // Track tools permanently rejected this session via reject_always
385
402
  const alwaysRejectedTools = new Set();
386
403
  // Tools that require permission when onRequestPermission is set (configurable)
387
- const dangerousTools = new Set([
388
- ...(config.get('agentConfirmDeleteFile') !== false ? ['delete_file'] : []),
389
- ...(config.get('agentConfirmExecuteCommand') !== false ? ['execute_command'] : []),
390
- ...(config.get('agentConfirmWriteFile') === true ? ['write_file', 'edit_file'] : []),
391
- ]);
404
+ const dangerousTools = buildDangerousTools(opts.extraDangerousTools);
392
405
  // Delegation handler: run a named (or generic) sub-agent in its own fresh
393
406
  // context and return its summary as the tool result. Reachable only when the
394
407
  // `delegate` tool was advertised (depth 0). The sub-agent runs nested (no own
@@ -54,3 +54,24 @@ export declare function formatReviewResult(result: ReviewResult): string;
54
54
  * Get review prompt for AI-enhanced review
55
55
  */
56
56
  export declare function getReviewSystemPrompt(result: ReviewResult): string;
57
+ export interface BuiltinRuleInfo {
58
+ id: string;
59
+ category: ReviewCategory;
60
+ severity: ReviewIssue['severity'];
61
+ description: string;
62
+ }
63
+ /**
64
+ * Built-in rule metadata (id + what each flags) — the ids that
65
+ * `.codeep/review.{json,yml}` "disable" accepts. Includes the two line-count
66
+ * heuristics (long-file / long-function) that live outside CODE_PATTERNS.
67
+ */
68
+ export declare function listBuiltinRules(): BuiltinRuleInfo[];
69
+ /**
70
+ * Append an advisory "AI second opinion" section to a markdown review report.
71
+ * Advisory only — it never changes the score or the exit code; the deterministic
72
+ * review remains authoritative.
73
+ */
74
+ export declare function appendAiSection(markdown: string, aiText: string | null, meta?: {
75
+ provider?: string;
76
+ model?: string;
77
+ }): string;
@@ -462,3 +462,34 @@ ${formatReviewResult(result)}
462
462
 
463
463
  Be concise and practical. Focus on issues that matter most for code quality and maintainability.`;
464
464
  }
465
+ /**
466
+ * Built-in rule metadata (id + what each flags) — the ids that
467
+ * `.codeep/review.{json,yml}` "disable" accepts. Includes the two line-count
468
+ * heuristics (long-file / long-function) that live outside CODE_PATTERNS.
469
+ */
470
+ export function listBuiltinRules() {
471
+ return [
472
+ ...CODE_PATTERNS.map((p) => ({
473
+ id: p.id,
474
+ category: p.category,
475
+ severity: p.severity,
476
+ description: p.message,
477
+ })),
478
+ { id: 'long-file', category: 'maintainability', severity: 'info', description: 'File exceeds 500 lines — consider splitting into smaller modules' },
479
+ { id: 'long-function', category: 'maintainability', severity: 'info', description: 'Function exceeds 50 lines — consider breaking it down' },
480
+ ];
481
+ }
482
+ /**
483
+ * Append an advisory "AI second opinion" section to a markdown review report.
484
+ * Advisory only — it never changes the score or the exit code; the deterministic
485
+ * review remains authoritative.
486
+ */
487
+ export function appendAiSection(markdown, aiText, meta = {}) {
488
+ const label = meta.model
489
+ ? `${meta.model}${meta.provider ? ` via ${meta.provider}` : ''}`
490
+ : (meta.provider || 'your provider');
491
+ if (!aiText || !aiText.trim()) {
492
+ return `${markdown}\n\n## AI Second Opinion\n_Skipped — no response from ${label}._`;
493
+ }
494
+ return `${markdown}\n\n## AI Second Opinion (${label})\n_Advisory — does not affect the score or exit code; the deterministic review above is authoritative._\n\n${aiText.trim()}`;
495
+ }
@@ -65,6 +65,12 @@ export declare function pullKeys(): Promise<Record<string, string> | null>;
65
65
  * Returns true on success.
66
66
  */
67
67
  export declare function pushKeys(keys: Record<string, string>): Promise<boolean>;
68
+ /**
69
+ * Purge ALL of the user's API keys stored on codeep.dev (cloud-only — local
70
+ * keychain keys are untouched). A clean exit for anyone who synced keys and
71
+ * later wants them off the server. Returns true on success.
72
+ */
73
+ export declare function purgeKeys(): Promise<boolean>;
68
74
  export declare const pullPersonalities: () => Promise<number | null>;
69
75
  export declare const pushPersonalities: () => Promise<number | null>;
70
76
  export declare const pullCommands: () => Promise<number | null>;
@@ -221,6 +221,22 @@ export async function pushKeys(keys) {
221
221
  });
222
222
  return res?.ok ?? false;
223
223
  }
224
+ /**
225
+ * Purge ALL of the user's API keys stored on codeep.dev (cloud-only — local
226
+ * keychain keys are untouched). A clean exit for anyone who synced keys and
227
+ * later wants them off the server. Returns true on success.
228
+ */
229
+ export async function purgeKeys() {
230
+ const syncToken = getSyncToken();
231
+ if (!syncToken)
232
+ return false;
233
+ const res = await fetchWithRetry(`${API_BASE}/api/keys`, {
234
+ method: 'DELETE',
235
+ headers: { 'Content-Type': 'application/json', 'x-sync-token': syncToken },
236
+ body: JSON.stringify({ all: true }),
237
+ });
238
+ return res?.ok ?? false;
239
+ }
224
240
  // ─── Portable personal config sync (personalities + commands) ──────────────────
225
241
  //
226
242
  // Both are name → raw-.md-body bundles stored in a global dir
@@ -0,0 +1,26 @@
1
+ import type { FailOn } from './headlessReview.js';
2
+ export type HookType = 'pre-commit' | 'pre-push';
3
+ export type HookAction = 'install' | 'uninstall' | 'help';
4
+ export interface HookArgs {
5
+ action: HookAction;
6
+ hookType: HookType;
7
+ failOn: FailOn;
8
+ help: boolean;
9
+ }
10
+ export declare const HOOK_HELP = "Usage: codeep hook <install|uninstall> [options]\n\nInstall a git hook that runs `codeep review` on your changes, blocking the\ncommit/push when issues at/above the threshold are found. Honors a project's\n.codeep/review.yml (or .json).\n\nActions:\n install Install the hook (pre-commit by default)\n uninstall Remove the Codeep-managed hook\n\nOptions:\n --pre-push Manage the pre-push hook instead of pre-commit\n --fail-on <level> Severity that blocks: error | warning | info | none (default: error)\n -h, --help Show this help\n\nThe pre-commit hook reviews the working-tree content of staged files, so stage\nyour changes fully before committing for the most accurate result.\nCodeep never overwrites a pre-existing hook it didn't create.";
11
+ /** Parse argv after `hook`. Pure. */
12
+ export declare function parseHookArgs(argv: string[]): HookArgs;
13
+ /** The hook script body. Pure. pre-commit scopes to staged files; pre-push reviews changes. */
14
+ export declare function buildHookScript(hookType: HookType, failOn: FailOn): string;
15
+ /** True when a hook file was created by Codeep (safe to overwrite/remove). */
16
+ export declare function isCodeepHook(content: string): boolean;
17
+ /** Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if not a repo. */
18
+ export declare function resolveHooksDir(cwd: string): string | null;
19
+ export interface HookDeps {
20
+ resolveHooksDir: (cwd: string) => string | null;
21
+ readHook: (path: string) => string | null;
22
+ writeHook: (path: string, content: string) => void;
23
+ removeHook: (path: string) => void;
24
+ write: (text: string) => void;
25
+ }
26
+ export declare function runHookCommand(argv: string[], deps?: HookDeps): number;
@@ -0,0 +1,168 @@
1
+ // `codeep hook install|uninstall` — installs a GIT hook (pre-commit / pre-push)
2
+ // that runs the offline reviewer on your changes and blocks the commit/push when
3
+ // issues at/above the threshold are found. Honors .codeep/review.{yml,json}.
4
+ //
5
+ // This is a GIT-hook installer — distinct from the lifecycle SHELL hooks in
6
+ // utils/hooks.ts (.codeep/hooks/<event>.sh) and the React hook in src/hooks/.
7
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from 'fs';
8
+ import { join, dirname, isAbsolute } from 'path';
9
+ import { execSync } from 'child_process';
10
+ const FAIL_ON_VALUES = ['error', 'warning', 'info', 'none'];
11
+ const MARKER_START = '# >>> codeep hook >>>';
12
+ const MARKER_END = '# <<< codeep hook <<<';
13
+ export const HOOK_HELP = `Usage: codeep hook <install|uninstall> [options]
14
+
15
+ Install a git hook that runs \`codeep review\` on your changes, blocking the
16
+ commit/push when issues at/above the threshold are found. Honors a project's
17
+ .codeep/review.yml (or .json).
18
+
19
+ Actions:
20
+ install Install the hook (pre-commit by default)
21
+ uninstall Remove the Codeep-managed hook
22
+
23
+ Options:
24
+ --pre-push Manage the pre-push hook instead of pre-commit
25
+ --fail-on <level> Severity that blocks: error | warning | info | none (default: error)
26
+ -h, --help Show this help
27
+
28
+ The pre-commit hook reviews the working-tree content of staged files, so stage
29
+ your changes fully before committing for the most accurate result.
30
+ Codeep never overwrites a pre-existing hook it didn't create.`;
31
+ /** Parse argv after `hook`. Pure. */
32
+ export function parseHookArgs(argv) {
33
+ const out = { action: 'help', hookType: 'pre-commit', failOn: 'error', help: false };
34
+ let i = 0;
35
+ if (argv[0] === 'install' || argv[0] === 'uninstall') {
36
+ out.action = argv[0];
37
+ i = 1;
38
+ }
39
+ for (; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a === '--pre-push')
42
+ out.hookType = 'pre-push';
43
+ else if (a === '--pre-commit')
44
+ out.hookType = 'pre-commit';
45
+ else if (a === '-h' || a === '--help')
46
+ out.help = true;
47
+ else if (a === '--fail-on') {
48
+ const v = argv[++i];
49
+ if (FAIL_ON_VALUES.includes(v))
50
+ out.failOn = v;
51
+ }
52
+ else if (a.startsWith('--fail-on=')) {
53
+ const v = a.slice('--fail-on='.length);
54
+ if (FAIL_ON_VALUES.includes(v))
55
+ out.failOn = v;
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+ /** The hook script body. Pure. pre-commit scopes to staged files; pre-push reviews changes. */
61
+ export function buildHookScript(hookType, failOn) {
62
+ const lines = [
63
+ '#!/bin/sh',
64
+ MARKER_START,
65
+ '# Managed by Codeep: `codeep hook install` to update, `codeep hook uninstall` to remove.',
66
+ 'command -v codeep >/dev/null 2>&1 || exit 0', // no codeep on PATH → skip, don't block
67
+ ];
68
+ if (hookType === 'pre-commit') {
69
+ // NUL-delimited + xargs -0 so staged paths with spaces/metachars survive as
70
+ // single arguments (an unquoted $files would word-split and silently skip
71
+ // them — a fail-open gate). Temp file keeps it portable: BSD/macOS xargs has
72
+ // no `-r`, so we guard emptiness with `[ -s ]` instead.
73
+ lines.push('tmp=$(mktemp)');
74
+ lines.push('git diff --cached --name-only -z --diff-filter=ACMR > "$tmp"');
75
+ lines.push('if [ -s "$tmp" ]; then');
76
+ lines.push(` xargs -0 codeep review --fail-on ${failOn} < "$tmp"`);
77
+ lines.push(' status=$?');
78
+ lines.push('else');
79
+ lines.push(' status=0');
80
+ lines.push('fi');
81
+ lines.push('rm -f "$tmp"');
82
+ lines.push('exit $status');
83
+ }
84
+ else {
85
+ lines.push(`codeep review --fail-on ${failOn}`);
86
+ }
87
+ lines.push(MARKER_END, '');
88
+ return lines.join('\n');
89
+ }
90
+ /** True when a hook file was created by Codeep (safe to overwrite/remove). */
91
+ export function isCodeepHook(content) {
92
+ return content.includes(MARKER_START);
93
+ }
94
+ /** Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if not a repo. */
95
+ export function resolveHooksDir(cwd) {
96
+ try {
97
+ execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore' });
98
+ const hooks = execSync('git rev-parse --git-path hooks', { cwd, encoding: 'utf8' }).trim();
99
+ return isAbsolute(hooks) ? hooks : join(cwd, hooks);
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ export function runHookCommand(argv, deps = defaultHookDeps()) {
106
+ const args = parseHookArgs(argv);
107
+ if (args.help || args.action === 'help') {
108
+ deps.write(HOOK_HELP);
109
+ return 0;
110
+ }
111
+ const hooksDir = deps.resolveHooksDir(process.cwd());
112
+ if (!hooksDir) {
113
+ deps.write('Not a git repository — run `codeep hook` inside a repo.');
114
+ return 1;
115
+ }
116
+ const target = join(hooksDir, args.hookType);
117
+ const existing = deps.readHook(target);
118
+ if (args.action === 'uninstall') {
119
+ if (existing === null) {
120
+ deps.write(`No ${args.hookType} hook to remove.`);
121
+ return 0;
122
+ }
123
+ if (!isCodeepHook(existing)) {
124
+ deps.write(`Refusing to remove ${args.hookType}: it was not created by Codeep.`);
125
+ return 1;
126
+ }
127
+ deps.removeHook(target);
128
+ deps.write(`Removed the Codeep ${args.hookType} hook.`);
129
+ return 0;
130
+ }
131
+ // install
132
+ if (existing !== null && !isCodeepHook(existing)) {
133
+ deps.write(`A ${args.hookType} hook already exists and was not created by Codeep — refusing to overwrite it. Remove it first, or add a \`codeep review\` call manually.`);
134
+ return 1;
135
+ }
136
+ deps.writeHook(target, buildHookScript(args.hookType, args.failOn));
137
+ const when = args.hookType === 'pre-commit' ? 'commit' : 'push';
138
+ deps.write(`Installed the Codeep ${args.hookType} hook → runs \`codeep review --fail-on ${args.failOn}\` on each ${when}.`);
139
+ return 0;
140
+ }
141
+ function defaultHookDeps() {
142
+ return {
143
+ resolveHooksDir,
144
+ readHook: (p) => {
145
+ try {
146
+ return readFileSync(p, 'utf8');
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ },
152
+ writeHook: (p, content) => {
153
+ mkdirSync(dirname(p), { recursive: true });
154
+ writeFileSync(p, content, { mode: 0o755 });
155
+ try {
156
+ chmodSync(p, 0o755);
157
+ }
158
+ catch { /* best effort (e.g. Windows) */ }
159
+ },
160
+ removeHook: (p) => {
161
+ try {
162
+ rmSync(p);
163
+ }
164
+ catch { /* ignore */ }
165
+ },
166
+ write: (t) => process.stdout.write(t + '\n'),
167
+ };
168
+ }
@@ -4,21 +4,35 @@ export interface ReviewArgs {
4
4
  files: string[];
5
5
  json: boolean;
6
6
  failOn: FailOn;
7
+ rules: boolean;
8
+ ai: boolean;
7
9
  help: boolean;
8
10
  }
9
- export declare const REVIEW_HELP = "Usage: codeep review [options] [files...]\n\nRun a deterministic, offline code review (no API key required). With no files,\nreviews your unstaged git changes, falling back to a src/ scan when the tree is\nclean. Pass files (or let your CI pass the PR's changed files) to scope it.\n\nOptions:\n --json Print the result as JSON instead of the markdown report\n --fail-on <level> Exit non-zero when an issue at or above <level> is found:\n error | warning | info | none (default: error)\n -h, --help Show this help\n\nExit code: 0 when nothing at/above --fail-on is found, 1 otherwise.";
11
+ export declare const REVIEW_HELP = "Usage: codeep review [options] [files...]\n\nRun a deterministic, offline code review (no API key required). With no files,\nreviews your unstaged git changes, falling back to a src/ scan when the tree is\nclean. Pass files (or let your CI pass the PR's changed files) to scope it.\n\nCustom/disabled rules come from .codeep/review.yml (or .json) in the repo.\n\nOptions:\n --json Print the result as JSON instead of the markdown report\n --fail-on <level> Exit non-zero when an issue at or above <level> is found:\n error | warning | info | none (default: error)\n --rules List the built-in rule ids (for \"disable\" in .codeep/review.*) and exit\n --ai After the offline pass, ask your configured provider for a\n contextual second opinion on the working-tree diff\n (advisory; needs an API key; never affects the exit code)\n -h, --help Show this help\n\nExit code: 0 when nothing at/above --fail-on is found, 1 otherwise.";
10
12
  /** Parse `codeep review` argv (everything after the subcommand). Pure. */
11
13
  export declare function parseReviewArgs(argv: string[]): ReviewArgs;
12
14
  /** Exit code for a result under a fail-on threshold. Pure. */
13
15
  export declare function exitCodeForResult(result: ReviewResult, failOn: FailOn): number;
16
+ /** Render the built-in rule ids for `--rules`. Pure. */
17
+ export declare function formatBuiltinRules(): string;
14
18
  export interface ReviewDeps {
15
19
  /** Run the review over optional specific files. */
16
20
  review: (files?: string[]) => ReviewResult;
17
21
  /** Sink for the report (one call). */
18
22
  write: (text: string) => void;
23
+ /** List of built-in rule ids (for --rules). */
24
+ listRules: () => string;
25
+ /** Optional AI second opinion; returns null when unavailable (no key / error). */
26
+ aiReview: (result: ReviewResult) => Promise<string | null>;
27
+ /** Provider/model label for the AI section header. */
28
+ aiMeta: () => {
29
+ provider?: string;
30
+ model?: string;
31
+ };
19
32
  }
20
33
  /**
21
34
  * Orchestrate a headless review and return the process exit code. Side effects
22
- * (filesystem, stdout) live behind `deps` so the flow is unit-testable.
35
+ * (filesystem, stdout, provider call) live behind `deps` so the flow is
36
+ * unit-testable. The exit code is ALWAYS deterministic — `--ai` is advisory.
23
37
  */
24
- export declare function runHeadlessReview(argv: string[], deps?: ReviewDeps): number;
38
+ export declare function runHeadlessReview(argv: string[], deps?: ReviewDeps): Promise<number>;
@@ -2,7 +2,12 @@
2
2
  // deterministic reviewer in codeReview.ts. No API key, no TUI: it scans, prints
3
3
  // a report (markdown or JSON), and exits non-zero when issues at/above a chosen
4
4
  // severity are found, so it drops cleanly into CI (e.g. a GitHub Action).
5
- import { performCodeReview, formatReviewResult } from './codeReview.js';
5
+ //
6
+ // `--ai` is the one opt-in online mode: after the offline pass it asks the
7
+ // configured provider for a contextual second opinion (advisory only — it never
8
+ // affects the exit code), and degrades to deterministic-only when no key is set.
9
+ import { performCodeReview, formatReviewResult, getReviewSystemPrompt, listBuiltinRules, appendAiSection, } from './codeReview.js';
10
+ import { config, getCurrentProvider } from '../config/index.js';
6
11
  const FAIL_ON_VALUES = ['error', 'warning', 'info', 'none'];
7
12
  // Higher = more severe. `suggestion` sits below `info` so `--fail-on info`
8
13
  // never trips on a mere suggestion.
@@ -13,21 +18,33 @@ Run a deterministic, offline code review (no API key required). With no files,
13
18
  reviews your unstaged git changes, falling back to a src/ scan when the tree is
14
19
  clean. Pass files (or let your CI pass the PR's changed files) to scope it.
15
20
 
21
+ Custom/disabled rules come from .codeep/review.yml (or .json) in the repo.
22
+
16
23
  Options:
17
24
  --json Print the result as JSON instead of the markdown report
18
25
  --fail-on <level> Exit non-zero when an issue at or above <level> is found:
19
26
  error | warning | info | none (default: error)
27
+ --rules List the built-in rule ids (for "disable" in .codeep/review.*) and exit
28
+ --ai After the offline pass, ask your configured provider for a
29
+ contextual second opinion on the working-tree diff
30
+ (advisory; needs an API key; never affects the exit code)
20
31
  -h, --help Show this help
21
32
 
22
33
  Exit code: 0 when nothing at/above --fail-on is found, 1 otherwise.`;
23
34
  /** Parse `codeep review` argv (everything after the subcommand). Pure. */
24
35
  export function parseReviewArgs(argv) {
25
- const out = { files: [], json: false, failOn: 'error', help: false };
36
+ const out = { files: [], json: false, failOn: 'error', rules: false, ai: false, help: false };
26
37
  for (let i = 0; i < argv.length; i++) {
27
38
  const arg = argv[i];
28
39
  if (arg === '--json') {
29
40
  out.json = true;
30
41
  }
42
+ else if (arg === '--rules') {
43
+ out.rules = true;
44
+ }
45
+ else if (arg === '--ai') {
46
+ out.ai = true;
47
+ }
31
48
  else if (arg === '-h' || arg === '--help') {
32
49
  out.help = true;
33
50
  }
@@ -56,18 +73,44 @@ export function exitCodeForResult(result, failOn) {
56
73
  const tripped = result.issues.some((i) => (SEVERITY_RANK[i.severity] ?? 0) >= threshold);
57
74
  return tripped ? 1 : 0;
58
75
  }
76
+ /** Render the built-in rule ids for `--rules`. Pure. */
77
+ export function formatBuiltinRules() {
78
+ const rules = listBuiltinRules();
79
+ const idW = Math.max(...rules.map((r) => r.id.length));
80
+ const sevW = Math.max(...rules.map((r) => r.severity.length));
81
+ const out = [
82
+ 'Built-in review rules — put any id in "disable" in .codeep/review.yml|json:',
83
+ '',
84
+ ];
85
+ for (const r of rules) {
86
+ out.push(` ${r.id.padEnd(idW)} ${r.severity.padEnd(sevW)} ${r.description}`);
87
+ }
88
+ return out.join('\n');
89
+ }
59
90
  /**
60
91
  * Orchestrate a headless review and return the process exit code. Side effects
61
- * (filesystem, stdout) live behind `deps` so the flow is unit-testable.
92
+ * (filesystem, stdout, provider call) live behind `deps` so the flow is
93
+ * unit-testable. The exit code is ALWAYS deterministic — `--ai` is advisory.
62
94
  */
63
- export function runHeadlessReview(argv, deps = defaultDeps()) {
95
+ export async function runHeadlessReview(argv, deps = defaultDeps()) {
64
96
  const args = parseReviewArgs(argv);
65
97
  if (args.help) {
66
98
  deps.write(REVIEW_HELP);
67
99
  return 0;
68
100
  }
101
+ if (args.rules) {
102
+ deps.write(deps.listRules());
103
+ return 0;
104
+ }
69
105
  const result = deps.review(args.files.length ? args.files : undefined);
70
- deps.write(args.json ? JSON.stringify(result, null, 2) : formatReviewResult(result));
106
+ const aiText = args.ai ? await deps.aiReview(result) : null;
107
+ if (args.json) {
108
+ deps.write(JSON.stringify(args.ai ? { ...result, aiReview: aiText } : result, null, 2));
109
+ }
110
+ else {
111
+ const md = formatReviewResult(result);
112
+ deps.write(args.ai ? appendAiSection(md, aiText, deps.aiMeta()) : md);
113
+ }
71
114
  return exitCodeForResult(result, args.failOn);
72
115
  }
73
116
  // Only the reviewer's `.root` is read, so a minimal context rooted at cwd is
@@ -84,8 +127,41 @@ function minimalContext(root) {
84
127
  };
85
128
  }
86
129
  function defaultDeps() {
130
+ const cwd = process.cwd();
87
131
  return {
88
- review: (files) => performCodeReview(minimalContext(process.cwd()), files),
132
+ review: (files) => performCodeReview(minimalContext(cwd), files),
89
133
  write: (text) => process.stdout.write(text + '\n'),
134
+ listRules: () => formatBuiltinRules(),
135
+ aiMeta: () => {
136
+ try {
137
+ return { provider: getCurrentProvider().name, model: String(config.get('model') || '') };
138
+ }
139
+ catch {
140
+ return {};
141
+ }
142
+ },
143
+ aiReview: async (result) => {
144
+ try {
145
+ const { loadAllApiKeys, isConfigured } = await import('../config/index.js');
146
+ await loadAllApiKeys();
147
+ if (!isConfigured()) {
148
+ process.stderr.write('codeep review --ai: no API key configured for the current provider; skipping the AI pass (deterministic results below).\n');
149
+ return null;
150
+ }
151
+ const { getGitDiff } = await import('./git.js');
152
+ const { chat } = await import('../api/index.js');
153
+ const d = getGitDiff(false, cwd);
154
+ const diff = d.success && d.diff ? d.diff.slice(0, 50000) : '(no textual diff available — reviewing the deterministic findings only)';
155
+ // Fold the prompt + findings into the user message (not a system-role
156
+ // history entry): chat() does not hoist a history `system` entry into
157
+ // the Anthropic top-level `system` field, so it would be dropped on
158
+ // Anthropic-protocol providers. As the user message it reaches every provider.
159
+ const message = `${getReviewSystemPrompt(result)}\n\n## Diff under review\n\`\`\`diff\n${diff}\n\`\`\``;
160
+ return await chat(message, []);
161
+ }
162
+ catch {
163
+ return null; // never hard-fail the review on an AI error
164
+ }
165
+ },
90
166
  };
91
167
  }
@@ -9,6 +9,9 @@ export interface SecureStorage {
9
9
  setApiKey(providerId: string, apiKey: string): Promise<void>;
10
10
  deleteApiKey(providerId: string): Promise<void>;
11
11
  hasApiKey(providerId: string): Promise<boolean>;
12
+ /** Whether the OS keychain is usable (vs the plaintext-config fallback).
13
+ * Optional — only SmartStorage (what createSecureStorage returns) implements it. */
14
+ isKeychainAvailable?(): Promise<boolean>;
12
15
  }
13
16
  /**
14
17
  * Migrate existing plain-text API keys to keychain
@@ -124,6 +124,10 @@ class SmartStorage {
124
124
  }
125
125
  this.keychainTested = true;
126
126
  }
127
+ async isKeychainAvailable() {
128
+ await this.ensureKeychainTested();
129
+ return this.useKeychain;
130
+ }
127
131
  async getApiKey(providerId) {
128
132
  await this.ensureKeychainTested();
129
133
  if (this.useKeychain) {
@@ -23,7 +23,10 @@
23
23
  */
24
24
  import { existsSync, readFileSync } from 'fs';
25
25
  import { join } from 'path';
26
- const CONFIG_PATH = '.codeep/review.json';
26
+ import yaml from 'js-yaml';
27
+ // Candidate config files, in precedence order: YAML preferred (nicer for a
28
+ // human-authored rules file — comments, single-quoted regex), JSON as fallback.
29
+ const CONFIG_PATHS = ['.codeep/review.yml', '.codeep/review.yaml', '.codeep/review.json'];
27
30
  const MAX_RULES = 200;
28
31
  const VALID_CATEGORIES = [
29
32
  'security', 'performance', 'maintainability', 'bug', 'style', 'types', 'best-practice', 'documentation',
@@ -53,19 +56,35 @@ function asStringArray(v) {
53
56
  : [];
54
57
  }
55
58
  export function loadReviewConfig(projectRoot) {
56
- const filePath = join(projectRoot, CONFIG_PATH);
57
- if (!existsSync(filePath))
59
+ // First existing candidate wins (.yml > .yaml > .json).
60
+ let chosen = null;
61
+ let text = '';
62
+ for (const candidate of CONFIG_PATHS) {
63
+ const fp = join(projectRoot, candidate);
64
+ if (existsSync(fp)) {
65
+ chosen = candidate;
66
+ try {
67
+ text = readFileSync(fp, 'utf-8');
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ break;
73
+ }
74
+ }
75
+ if (!chosen)
58
76
  return null;
77
+ const isJson = chosen.endsWith('.json');
59
78
  let data;
60
79
  try {
61
- data = JSON.parse(readFileSync(filePath, 'utf-8'));
80
+ data = isJson ? JSON.parse(text) : yaml.load(text);
62
81
  }
63
82
  catch {
64
- console.warn(`[codeep] Ignoring ${CONFIG_PATH}: not valid JSON.`);
83
+ console.warn(`[codeep] Ignoring ${chosen}: not valid ${isJson ? 'JSON' : 'YAML'}.`);
65
84
  return null;
66
85
  }
67
86
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
68
- console.warn(`[codeep] Ignoring ${CONFIG_PATH}: expected a JSON object.`);
87
+ console.warn(`[codeep] Ignoring ${chosen}: expected a top-level object.`);
69
88
  return null;
70
89
  }
71
90
  const cfg = data;
@@ -82,12 +101,12 @@ export function loadReviewConfig(projectRoot) {
82
101
  const message = typeof r.message === 'string' && r.message.trim() ? r.message.trim() : null;
83
102
  const patternSrc = typeof r.pattern === 'string' && r.pattern ? r.pattern : null;
84
103
  if (!id || !message || !patternSrc) {
85
- console.warn(`[codeep] Skipping a rule in ${CONFIG_PATH}: each rule needs id, pattern and message.`);
104
+ console.warn(`[codeep] Skipping a rule in ${chosen}: each rule needs id, pattern and message.`);
86
105
  continue;
87
106
  }
88
107
  // Reject oversized patterns outright — keeps regex compilation/run bounded.
89
108
  if (patternSrc.length > 1000) {
90
- console.warn(`[codeep] Skipping rule "${id}" in ${CONFIG_PATH}: pattern is too long (>1000 chars).`);
109
+ console.warn(`[codeep] Skipping rule "${id}" in ${chosen}: pattern is too long (>1000 chars).`);
91
110
  continue;
92
111
  }
93
112
  // Conservative ReDoS screen: reject the classic catastrophic shape — a group
@@ -95,7 +114,7 @@ export function loadReviewConfig(projectRoot) {
95
114
  // (\d*)*, (.*)+, (x+){2,}. Not exhaustive (the GitHub Action also bounds
96
115
  // wall-clock), but it blocks the common foot-guns in an untrusted review.json.
97
116
  if (/\([^)]*[+*]\)\s*[+*{]/.test(patternSrc)) {
98
- console.warn(`[codeep] Skipping rule "${id}" in ${CONFIG_PATH}: nested quantifiers risk catastrophic backtracking (ReDoS).`);
117
+ console.warn(`[codeep] Skipping rule "${id}" in ${chosen}: nested quantifiers risk catastrophic backtracking (ReDoS).`);
99
118
  continue;
100
119
  }
101
120
  // Always include the global flag so every match in a file is found.
@@ -107,7 +126,7 @@ export function loadReviewConfig(projectRoot) {
107
126
  pattern = new RegExp(patternSrc, flags);
108
127
  }
109
128
  catch {
110
- console.warn(`[codeep] Skipping rule "${id}" in ${CONFIG_PATH}: invalid regex.`);
129
+ console.warn(`[codeep] Skipping rule "${id}" in ${chosen}: invalid regex.`);
111
130
  continue;
112
131
  }
113
132
  const category = VALID_CATEGORIES.includes(r.category)
@@ -5,7 +5,9 @@ export interface VersionInfo {
5
5
  error?: string;
6
6
  }
7
7
  /**
8
- * Get current version from package.json
8
+ * Current Codeep version. Uses the build-time-baked VERSION constant (works in
9
+ * the bun-compiled binary, which has no package.json on disk), falling back to
10
+ * reading package.json for an unbuilt dev tree.
9
11
  */
10
12
  export declare function getCurrentVersion(): string;
11
13
  /**
@@ -1,14 +1,18 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
+ import { VERSION } from '../version.js';
4
5
  const __filename = fileURLToPath(import.meta.url);
5
6
  const __dirname = dirname(__filename);
6
7
  /**
7
- * Get current version from package.json
8
+ * Current Codeep version. Uses the build-time-baked VERSION constant (works in
9
+ * the bun-compiled binary, which has no package.json on disk), falling back to
10
+ * reading package.json for an unbuilt dev tree.
8
11
  */
9
12
  export function getCurrentVersion() {
13
+ if (VERSION)
14
+ return VERSION;
10
15
  try {
11
- // In built version, package.json is in parent directory
12
16
  const packagePath = join(__dirname, '../../package.json');
13
17
  const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'));
14
18
  return packageJson.version;
@@ -0,0 +1 @@
1
+ export declare const VERSION = "2.8.0";
@@ -0,0 +1,4 @@
1
+ // AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
2
+ // Baked from package.json at build time so the bun-compiled binary reports
3
+ // the right version (it has no package.json on disk to read at runtime).
4
+ export const VERSION = '2.8.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
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",
@@ -8,9 +8,9 @@
8
8
  "codeep": "bin/codeep.js"
9
9
  },
10
10
  "scripts": {
11
- "dev": "node --import tsx src/renderer/main.ts",
12
- "prepack": "tsc; node scripts/fix-imports.js",
13
- "build": "tsc && node scripts/fix-imports.js",
11
+ "dev": "node scripts/gen-version.js && node --import tsx src/renderer/main.ts",
12
+ "prepack": "node scripts/gen-version.js && tsc; node scripts/fix-imports.js",
13
+ "build": "node scripts/gen-version.js && tsc && node scripts/fix-imports.js",
14
14
  "start": "node dist/renderer/main.js",
15
15
  "demo:renderer": "node --import tsx src/renderer/demo.ts",
16
16
  "demo:app": "node --import tsx src/renderer/demo-app.ts",
@@ -42,10 +42,12 @@
42
42
  "dependencies": {
43
43
  "clipboardy": "^4.0.0",
44
44
  "conf": "^12.0.0",
45
+ "js-yaml": "^4.1.0",
45
46
  "keytar": "^7.9.0",
46
47
  "open": "^10.0.0"
47
48
  },
48
49
  "devDependencies": {
50
+ "@types/js-yaml": "^4.0.9",
49
51
  "@types/node": "^20.10.0",
50
52
  "@vitest/coverage-v8": "^4.0.18",
51
53
  "pkg": "^5.8.1",