codeep 2.6.0 → 2.7.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
  ```
@@ -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,6 +96,10 @@ 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. */
@@ -186,6 +186,7 @@ function createConfig() {
186
186
  providerApiKeys: [],
187
187
  configuredProviderIds: [],
188
188
  keysSecured: false,
189
+ apiKeys: {},
189
190
  telemetry: true,
190
191
  githubId: '',
191
192
  githubUsername: '',
@@ -359,6 +360,44 @@ async function migrateKeysToSecureStorage() {
359
360
  _migrationPromise = null;
360
361
  }
361
362
  }
363
+ let _sweepPromise = null;
364
+ /**
365
+ * If the OS keychain is now available but API keys are still sitting in the
366
+ * plaintext `apiKeys` fallback map (written by utils/keychain.ts while the
367
+ * keychain was unavailable on a prior run), move each into the keychain. The
368
+ * keychain write also deletes the entry from the plaintext fallback map, so a
369
+ * successful sweep leaves no plaintext behind. Cheap no-op when the map is
370
+ * empty or the keychain is unavailable; deduped for concurrent callers. Runs
371
+ * independently of `keysSecured` so a key written during a keychain outage is
372
+ * still swept up later.
373
+ */
374
+ async function sweepFallbackKeysToKeychain() {
375
+ if (!_sweepPromise) {
376
+ _sweepPromise = (async () => {
377
+ const store = secureKeyStore();
378
+ // Only sweep when the keychain is actually usable; otherwise leave the
379
+ // plaintext fallback in place (there's nowhere safer to move it).
380
+ if (!store.isKeychainAvailable || !(await store.isKeychainAvailable()))
381
+ return;
382
+ const plaintext = config.get('apiKeys') || {};
383
+ for (const [providerId, apiKey] of Object.entries(plaintext)) {
384
+ if (typeof apiKey !== 'string' || !apiKey)
385
+ continue;
386
+ try {
387
+ await store.setApiKey(providerId, apiKey); // writes keychain + deletes from fallback map
388
+ addConfiguredProviderId(providerId);
389
+ }
390
+ catch { /* keep the plaintext entry on failure — never lose a key */ }
391
+ }
392
+ })();
393
+ }
394
+ try {
395
+ await _sweepPromise;
396
+ }
397
+ finally {
398
+ _sweepPromise = null;
399
+ }
400
+ }
362
401
  export const LANGUAGES = {
363
402
  'auto': 'Auto-detect',
364
403
  'en': 'English',
@@ -417,9 +456,11 @@ export async function loadApiKey(providerId) {
417
456
  * Should be called at app startup
418
457
  */
419
458
  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).
459
+ // Migrate any legacy plaintext keys, then sweep any keychain-fallback plaintext
460
+ // up into the keychain (no-op when none / keychain unavailable), then load all
461
+ // from secure storage using the non-secret configuredProviderIds index.
422
462
  await migrateKeysToSecureStorage();
463
+ await sweepFallbackKeysToKeychain();
423
464
  const store = secureKeyStore();
424
465
  for (const providerId of (config.get('configuredProviderIds') || [])) {
425
466
  const key = await store.getApiKey(providerId);
@@ -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()}`);
@@ -403,7 +407,9 @@ Usage:
403
407
  codeep account sync Pull keys + personalities + commands + profile from codeep.dev
404
408
  codeep account push Push local keys + personalities + commands + profile to codeep.dev
405
409
  codeep acp Start ACP server (for Zed editor integration)
406
- codeep review Offline code review for CI (--json, --fail-on <level>)
410
+ codeep review Offline code review for CI (--json, --fail-on, --rules, --ai)
411
+ codeep hook install Install a git pre-commit hook running \`codeep review\`
412
+ codeep hook uninstall Remove the Codeep git hook
407
413
  codeep --version Show version
408
414
  codeep --help Show this help
409
415
 
@@ -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
+ }
@@ -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.7.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.7.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.6.0",
3
+ "version": "2.7.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",