codeep 2.14.0 → 2.16.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.
Files changed (70) hide show
  1. package/README.md +47 -27
  2. package/dist/acp/commands.js +22 -1
  3. package/dist/acp/server.js +13 -2
  4. package/dist/acp/session.js +22 -1
  5. package/dist/config/index.d.ts +10 -0
  6. package/dist/config/index.js +2 -2
  7. package/dist/config/providers.js +35 -24
  8. package/dist/renderer/App.d.ts +77 -30
  9. package/dist/renderer/App.js +429 -659
  10. package/dist/renderer/agentExecution.d.ts +1 -0
  11. package/dist/renderer/agentExecution.js +3 -2
  12. package/dist/renderer/commands/helpers.d.ts +251 -0
  13. package/dist/renderer/commands/helpers.js +450 -0
  14. package/dist/renderer/commands/registry.js +7 -1
  15. package/dist/renderer/commands.d.ts +4 -0
  16. package/dist/renderer/commands.js +363 -318
  17. package/dist/renderer/components/ActionFormatting.d.ts +17 -0
  18. package/dist/renderer/components/ActionFormatting.js +67 -0
  19. package/dist/renderer/components/Autocomplete.d.ts +58 -0
  20. package/dist/renderer/components/Autocomplete.js +75 -0
  21. package/dist/renderer/components/Intro.d.ts +9 -0
  22. package/dist/renderer/components/Intro.js +5 -15
  23. package/dist/renderer/components/MessageFormatter.d.ts +96 -0
  24. package/dist/renderer/components/MessageFormatter.js +375 -0
  25. package/dist/renderer/components/Permission.d.ts +4 -0
  26. package/dist/renderer/components/Permission.js +1 -1
  27. package/dist/renderer/components/Status.d.ts +4 -0
  28. package/dist/renderer/components/Status.js +2 -3
  29. package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
  30. package/dist/renderer/components/WelcomeFormatter.js +79 -0
  31. package/dist/renderer/components/uiConstants.d.ts +8 -0
  32. package/dist/renderer/components/uiConstants.js +24 -0
  33. package/dist/renderer/inputParsing.d.ts +22 -0
  34. package/dist/renderer/inputParsing.js +28 -0
  35. package/dist/renderer/layout.d.ts +219 -0
  36. package/dist/renderer/layout.js +338 -0
  37. package/dist/renderer/main.d.ts +2 -1
  38. package/dist/renderer/main.js +79 -11
  39. package/dist/renderer/ollamaHint.d.ts +12 -0
  40. package/dist/renderer/ollamaHint.js +29 -0
  41. package/dist/utils/agentChat.js +23 -1
  42. package/dist/utils/codeepCloud.d.ts +54 -0
  43. package/dist/utils/codeepCloud.js +95 -0
  44. package/dist/utils/diffPreview.d.ts +31 -0
  45. package/dist/utils/diffPreview.js +102 -0
  46. package/dist/utils/export.d.ts +12 -0
  47. package/dist/utils/export.js +3 -3
  48. package/dist/utils/git.d.ts +28 -0
  49. package/dist/utils/git.js +111 -1
  50. package/dist/utils/hooks.d.ts +26 -0
  51. package/dist/utils/hooks.js +69 -1
  52. package/dist/utils/keychain.js +45 -29
  53. package/dist/utils/logger.d.ts +12 -0
  54. package/dist/utils/logger.js +1 -1
  55. package/dist/utils/mcpConfig.d.ts +26 -0
  56. package/dist/utils/mcpConfig.js +109 -4
  57. package/dist/utils/mentions.d.ts +195 -0
  58. package/dist/utils/mentions.js +672 -0
  59. package/dist/utils/skillBundles.d.ts +14 -0
  60. package/dist/utils/skillBundles.js +3 -3
  61. package/dist/utils/skillBundlesCloud.d.ts +7 -0
  62. package/dist/utils/skillBundlesCloud.js +1 -1
  63. package/dist/utils/tokenTracker.js +21 -5
  64. package/dist/utils/toolParsing.d.ts +11 -0
  65. package/dist/utils/toolParsing.js +6 -0
  66. package/dist/utils/webFetch.d.ts +101 -0
  67. package/dist/utils/webFetch.js +375 -0
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/package.json +2 -2
package/dist/utils/git.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync, spawnSync } from 'child_process';
1
+ import { execSync, execFileSync, spawnSync } from 'child_process';
2
2
  import { existsSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  /**
@@ -397,3 +397,113 @@ export function createBranchAndCommit(prompt, actions, cwd = process.cwd()) {
397
397
  hash: commitResult.hash,
398
398
  };
399
399
  }
400
+ /** Max bytes we'll inline from a single `@git` mention. */
401
+ export const MAX_GIT_BYTES = 64 * 1024;
402
+ /**
403
+ * Characters a git ref/pathspec may contain for `@git` mentions. Deliberately
404
+ * conservative: word chars plus the punctuation real refs use
405
+ * (`main..feature`, `HEAD~3`, `v1.2.0^{}`, `main:src/x.ts`, `origin/main`).
406
+ * A leading `-` is rejected separately so a ref can never be read as a flag.
407
+ */
408
+ const SAFE_GIT_REF = /^[A-Za-z0-9._/:~^@{}-]+$/;
409
+ export function isSafeGitRef(token) {
410
+ return token.length > 0 && !token.startsWith('-') && SAFE_GIT_REF.test(token);
411
+ }
412
+ /**
413
+ * The only flags `@git diff …` may pass through. An allowlist rather than a
414
+ * deny-list because several git flags write files or run commands
415
+ * (`--output=`, `--ext-diff`), which would turn a mention into a side effect.
416
+ */
417
+ const GIT_DIFF_FLAG_ALLOWLIST = new Set([
418
+ '--staged', '--cached', '--stat', '--numstat', '--shortstat',
419
+ '--name-only', '--name-status', '--patch', '-p', '--no-color',
420
+ ]);
421
+ /**
422
+ * Resolve a `@git <ref>` mention to inline content. The `ref` can be:
423
+ *
424
+ * - `diff` — unstaged changes (`git diff`)
425
+ * - `diff --staged` — staged changes (`git diff --cached`)
426
+ * - `diff a..b` — diff between two refs (`git diff a..b`)
427
+ * - `HEAD` — the latest commit's full diff vs its parent
428
+ * - `<sha>` — a specific commit's patch (`git show <sha>`)
429
+ * - `<ref>:<path>` — a file at a ref (`git show main:src/x.ts`)
430
+ * - `<ref>` — any other git ref → `git show`
431
+ *
432
+ * Sync (spawn-based) so it slots into the mention-expansion pipeline.
433
+ */
434
+ export function getGitContent(ref, cwd = process.cwd()) {
435
+ if (!isGitRepository(cwd)) {
436
+ return { success: false, content: '', label: ref, error: 'not a git repository' };
437
+ }
438
+ const trimmed = ref.trim();
439
+ if (!trimmed) {
440
+ return { success: false, content: '', label: ref, error: 'empty git ref' };
441
+ }
442
+ // Pick the git subcommand based on the ref shape. NOTE: the ref comes from
443
+ // free-form prompt text (`@git <ref>`), which may be pasted from an issue,
444
+ // a log, or model output — so it is UNTRUSTED. We therefore (a) build an
445
+ // argv array and spawn git directly with `execFileSync` (no `/bin/sh`, so
446
+ // `;`, `|`, backticks and friends are inert), and (b) validate every token,
447
+ // because argv alone doesn't stop *argument* injection — a ref that starts
448
+ // with `-` would still be read by git as a flag (e.g. `--output=…` writes a
449
+ // file). Anything unrecognized is rejected rather than guessed at.
450
+ let args;
451
+ let label;
452
+ if (trimmed === 'diff') {
453
+ args = ['diff'];
454
+ label = 'diff (unstaged)';
455
+ }
456
+ else if (trimmed === 'diff --staged' || trimmed === 'diff --cached' || trimmed === 'staged') {
457
+ args = ['diff', '--cached'];
458
+ label = 'diff (staged)';
459
+ }
460
+ else if (trimmed.startsWith('diff ')) {
461
+ // e.g. `diff main..feature` or `diff HEAD~3` or `diff --stat`
462
+ const rest = trimmed.slice('diff '.length).trim();
463
+ const tokens = rest.split(/\s+/).filter(Boolean);
464
+ const bad = tokens.find(t => !(isSafeGitRef(t) || GIT_DIFF_FLAG_ALLOWLIST.has(t)));
465
+ if (bad) {
466
+ return { success: false, content: '', label: ref, error: `unsupported git argument: ${bad}` };
467
+ }
468
+ args = ['diff', ...tokens];
469
+ label = `diff (${rest})`;
470
+ }
471
+ else if (trimmed === 'HEAD' || trimmed === '@') {
472
+ // Show the latest commit's patch.
473
+ args = ['show', 'HEAD'];
474
+ label = 'HEAD';
475
+ }
476
+ else {
477
+ // Any other ref → `git show`. Works for SHAs, tags, branches, and
478
+ // `<ref>:<path>` (file-at-ref) forms.
479
+ if (!isSafeGitRef(trimmed)) {
480
+ return { success: false, content: '', label: ref, error: `unsupported git ref: ${trimmed}` };
481
+ }
482
+ args = ['show', trimmed];
483
+ label = trimmed;
484
+ }
485
+ try {
486
+ const out = execFileSync('git', args, {
487
+ cwd,
488
+ encoding: 'utf-8',
489
+ maxBuffer: 4 * 1024 * 1024,
490
+ });
491
+ const content = (out ?? '').trimEnd();
492
+ if (!content) {
493
+ return { success: false, content: '', label, error: 'empty result (no changes / unknown ref)' };
494
+ }
495
+ // Truncate to the cap so a massive diff can't blow the context.
496
+ const capped = content.length > MAX_GIT_BYTES
497
+ ? content.slice(0, MAX_GIT_BYTES) + `\n\n… (truncated at ${MAX_GIT_BYTES / 1024}KB)`
498
+ : content;
499
+ return { success: true, content: capped, label };
500
+ }
501
+ catch (error) {
502
+ const msg = error instanceof Error ? error.message : String(error);
503
+ // git show exits non-zero on unknown refs; surface a friendly reason.
504
+ const reason = /unknown revision|bad revision|ambiguous argument/i.test(msg)
505
+ ? `unknown git ref: ${trimmed}`
506
+ : msg;
507
+ return { success: false, content: '', label, error: reason };
508
+ }
509
+ }
@@ -44,6 +44,11 @@
44
44
  * machine the first time they trigger an agent tool call. The welcome
45
45
  * banner warns when hooks exist (see `summarizeHooks`); we do not run
46
46
  * hooks from `~/.codeep/hooks/` (global) for that reason.
47
+ *
48
+ * Platform note: hooks are POSIX shell (`.sh`) scripts. On macOS/Linux they
49
+ * run directly; on Windows they run through Git Bash's `sh` if installed.
50
+ * Windows without a POSIX shell → hooks are reported `unsupported` and skipped
51
+ * (never blocking). See `resolveShellMode` and the README "Windows notes".
47
52
  */
48
53
  export declare function isHooksTrusted(workspaceRoot: string): boolean;
49
54
  export declare function trustWorkspaceHooks(workspaceRoot: string): void;
@@ -75,7 +80,28 @@ export interface HookResult {
75
80
  /** True when a hook script exists but the workspace isn't trusted, so it was
76
81
  * skipped (not run). Lets callers surface "run /hooks trust to enable". */
77
82
  untrusted?: boolean;
83
+ /** True when a hook script exists but this OS can't run it — Codeep hooks are
84
+ * POSIX shell (`.sh`) scripts and no `sh` was found (e.g. Windows without
85
+ * Git Bash). A non-blocking skip; surfaced by `/hooks` + the welcome banner. */
86
+ unsupported?: boolean;
78
87
  }
88
+ /**
89
+ * Decide how a `.sh` hook runs on this platform. On POSIX it's executed
90
+ * directly (shebang); on Windows it needs a `sh`/`bash` interpreter, and if
91
+ * none is installed hooks are `unsupported` (skipped, never blocking). Pure +
92
+ * injectable so the platform matrix is unit-testable.
93
+ */
94
+ export declare function resolveShellMode(platform?: NodeJS.Platform, findShell?: () => string | null): {
95
+ mode: 'direct';
96
+ } | {
97
+ mode: 'shell';
98
+ shell: string;
99
+ } | {
100
+ mode: 'unsupported';
101
+ };
102
+ /** True when this OS can actually run `.sh` hooks. Drives the `/hooks` and
103
+ * welcome-banner "unsupported" state. */
104
+ export declare function hooksExecutable(platform?: NodeJS.Platform, findShell?: () => string | null): boolean;
79
105
  /**
80
106
  * Execute the configured hook for an event, if any. Returns `executed: false`
81
107
  * if no script exists. Caller is responsible for checking `blocked` and
@@ -44,6 +44,11 @@
44
44
  * machine the first time they trigger an agent tool call. The welcome
45
45
  * banner warns when hooks exist (see `summarizeHooks`); we do not run
46
46
  * hooks from `~/.codeep/hooks/` (global) for that reason.
47
+ *
48
+ * Platform note: hooks are POSIX shell (`.sh`) scripts. On macOS/Linux they
49
+ * run directly; on Windows they run through Git Bash's `sh` if installed.
50
+ * Windows without a POSIX shell → hooks are reported `unsupported` and skipped
51
+ * (never blocking). See `resolveShellMode` and the README "Windows notes".
47
52
  */
48
53
  import { existsSync, readdirSync, statSync, accessSync, constants } from 'fs';
49
54
  import { join } from 'path';
@@ -80,6 +85,45 @@ const NOT_EXECUTED = { executed: false, exitCode: 0, stdout: '', stderr: '', blo
80
85
  function getHooksDir(workspaceRoot) {
81
86
  return join(workspaceRoot, '.codeep', 'hooks');
82
87
  }
88
+ /**
89
+ * Locate a POSIX shell able to run `.sh` hooks on Windows. `.sh` scripts can't
90
+ * be `spawn`ed directly there (no shebang support), so we invoke them through
91
+ * `sh`/`bash`. Scans PATH plus the default Git-for-Windows install locations.
92
+ * Returns the shell's path, or null if none is installed.
93
+ */
94
+ function findWindowsPosixShell() {
95
+ const candidates = [];
96
+ for (const dir of (process.env.PATH ?? '').split(';')) {
97
+ if (dir)
98
+ candidates.push(join(dir, 'sh.exe'), join(dir, 'bash.exe'));
99
+ }
100
+ candidates.push('C:\\Program Files\\Git\\bin\\sh.exe', 'C:\\Program Files\\Git\\usr\\bin\\sh.exe', 'C:\\Program Files (x86)\\Git\\bin\\sh.exe');
101
+ for (const c of candidates) {
102
+ try {
103
+ if (statSync(c).isFile())
104
+ return c;
105
+ }
106
+ catch { /* next candidate */ }
107
+ }
108
+ return null;
109
+ }
110
+ /**
111
+ * Decide how a `.sh` hook runs on this platform. On POSIX it's executed
112
+ * directly (shebang); on Windows it needs a `sh`/`bash` interpreter, and if
113
+ * none is installed hooks are `unsupported` (skipped, never blocking). Pure +
114
+ * injectable so the platform matrix is unit-testable.
115
+ */
116
+ export function resolveShellMode(platform = process.platform, findShell = findWindowsPosixShell) {
117
+ if (platform !== 'win32')
118
+ return { mode: 'direct' };
119
+ const shell = findShell();
120
+ return shell ? { mode: 'shell', shell } : { mode: 'unsupported' };
121
+ }
122
+ /** True when this OS can actually run `.sh` hooks. Drives the `/hooks` and
123
+ * welcome-banner "unsupported" state. */
124
+ export function hooksExecutable(platform = process.platform, findShell = findWindowsPosixShell) {
125
+ return resolveShellMode(platform, findShell).mode !== 'unsupported';
126
+ }
83
127
  function findHookScript(workspaceRoot, event) {
84
128
  const dir = getHooksDir(workspaceRoot);
85
129
  if (!existsSync(dir))
@@ -120,6 +164,14 @@ export function runHook(ctx, opts = {}) {
120
164
  if (!isHooksTrusted(ctx.workspaceRoot)) {
121
165
  return { executed: false, exitCode: 0, stdout: '', stderr: '', blocked: false, untrusted: true, scriptPath: script };
122
166
  }
167
+ // Platform gate: a `.sh` hook needs a POSIX shell. On Windows without one we
168
+ // must NOT spawn the script directly — that fails, and for a blocking event
169
+ // (pre_tool_call / pre_commit) a spawn error would wedge every tool call.
170
+ // Skip cleanly instead and let `/hooks` explain why.
171
+ const shellMode = resolveShellMode();
172
+ if (shellMode.mode === 'unsupported') {
173
+ return { executed: false, exitCode: 0, stdout: '', stderr: '', blocked: false, unsupported: true, scriptPath: script };
174
+ }
123
175
  const env = {
124
176
  ...process.env,
125
177
  CODEEP_HOOK_EVENT: ctx.event,
@@ -146,9 +198,12 @@ export function runHook(ctx, opts = {}) {
146
198
  // 30s ceiling so a runaway lint / test command can't wedge the agent
147
199
  // loop. Configurable per-call so tests can use a tight timeout.
148
200
  const timeout = opts.timeoutMs ?? 30_000;
201
+ // POSIX: run the script directly (shebang). Windows-with-shell: invoke it
202
+ // through the located `sh`/`bash` so the shebang isn't required.
203
+ const [cmd, cmdArgs] = shellMode.mode === 'shell' ? [shellMode.shell, [script]] : [script, []];
149
204
  let proc;
150
205
  try {
151
- proc = spawnSync(script, [], {
206
+ proc = spawnSync(cmd, cmdArgs, {
152
207
  cwd: ctx.workspaceRoot,
153
208
  env,
154
209
  timeout,
@@ -250,6 +305,16 @@ export function formatHookTrust(workspaceRoot) {
250
305
  const hooks = listInstalledHooks(workspaceRoot);
251
306
  if (hooks.length === 0)
252
307
  return '';
308
+ if (!hooksExecutable()) {
309
+ return [
310
+ '⚠️ These hooks **cannot run on this system.** Codeep hooks are POSIX shell',
311
+ '(`.sh`) scripts, and no `sh` was found — on Windows this means Git Bash',
312
+ "isn't installed or isn't on your PATH.",
313
+ '',
314
+ 'Install [Git for Windows](https://git-scm.com/download/win) (it provides',
315
+ '`sh.exe`) or add a POSIX shell to PATH. See the “Windows notes” in the README.',
316
+ ].join('\n');
317
+ }
253
318
  if (isHooksTrusted(workspaceRoot)) {
254
319
  return '✓ This workspace is **trusted** — its hooks will run. Use `/hooks untrust` to revoke.';
255
320
  }
@@ -267,6 +332,9 @@ export function summarizeHooks(workspaceRoot) {
267
332
  if (hooks.length === 0)
268
333
  return '';
269
334
  const list = hooks.map(h => h.event).join(', ');
335
+ if (!hooksExecutable()) {
336
+ return `${hooks.length} hook${hooks.length === 1 ? '' : 's'} present but this system has no POSIX shell — they won't run (see README “Windows notes”) (${list})`;
337
+ }
270
338
  if (!isHooksTrusted(workspaceRoot)) {
271
339
  return `${hooks.length} hook${hooks.length === 1 ? '' : 's'} present but NOT trusted — run /hooks trust to enable (${list})`;
272
340
  }
@@ -1,22 +1,17 @@
1
1
  import { logger } from './logger.js';
2
- // keytar is a native addon — load it LAZILY (on first use), never at module
3
- // top level. A top-level `await import()` here gives the module a top-level
4
- // await, which makes `bun build --compile` reject any CommonJS require() that
5
- // transitively depends on this file (renderer/main.js → codeepCloud → config →
6
- // keychain). Lazy loading keeps the module side-effect-free at import time.
7
- let _keytar = null;
8
- let _keytarTried = false;
9
- async function loadKeytar() {
10
- if (!_keytarTried) {
11
- _keytarTried = true;
2
+ let _keyring = null;
3
+ let _keyringTried = false;
4
+ async function loadKeyring() {
5
+ if (!_keyringTried) {
6
+ _keyringTried = true;
12
7
  try {
13
- _keytar = (await import('keytar')).default;
8
+ _keyring = (await import('@napi-rs/keyring'));
14
9
  }
15
10
  catch {
16
- _keytar = null; /* native addon unavailable */
11
+ _keyring = null; /* native binary unavailable (headless / minimal install) */
17
12
  }
18
13
  }
19
- return _keytar;
14
+ return _keyring;
20
15
  }
21
16
  const SERVICE_NAME = 'codeep';
22
17
  class KeychainStorage {
@@ -25,11 +20,12 @@ class KeychainStorage {
25
20
  }
26
21
  async getApiKey(providerId) {
27
22
  try {
28
- const kt = await loadKeytar();
29
- if (!kt)
23
+ const kr = await loadKeyring();
24
+ if (!kr)
30
25
  return null;
31
26
  const account = this.getAccountName(providerId);
32
- return await kt.getPassword(SERVICE_NAME, account);
27
+ const entry = new kr.AsyncEntry(SERVICE_NAME, account);
28
+ return await entry.getPassword();
33
29
  }
34
30
  catch (error) {
35
31
  logger.debug(`Failed to get API key from keychain: ${error}`);
@@ -38,11 +34,12 @@ class KeychainStorage {
38
34
  }
39
35
  async setApiKey(providerId, apiKey) {
40
36
  try {
41
- const kt = await loadKeytar();
42
- if (!kt)
43
- throw new Error('keytar unavailable');
37
+ const kr = await loadKeyring();
38
+ if (!kr)
39
+ throw new Error('keyring unavailable');
44
40
  const account = this.getAccountName(providerId);
45
- await kt.setPassword(SERVICE_NAME, account, apiKey);
41
+ const entry = new kr.AsyncEntry(SERVICE_NAME, account);
42
+ await entry.setPassword(apiKey);
46
43
  }
47
44
  catch (error) {
48
45
  throw new Error(`Failed to store API key in keychain: ${error}`);
@@ -50,11 +47,12 @@ class KeychainStorage {
50
47
  }
51
48
  async deleteApiKey(providerId) {
52
49
  try {
53
- const kt = await loadKeytar();
54
- if (!kt)
50
+ const kr = await loadKeyring();
51
+ if (!kr)
55
52
  return;
56
53
  const account = this.getAccountName(providerId);
57
- await kt.deletePassword(SERVICE_NAME, account);
54
+ const entry = new kr.AsyncEntry(SERVICE_NAME, account);
55
+ await entry.deletePassword();
58
56
  }
59
57
  catch (error) {
60
58
  logger.debug(`Failed to delete API key from keychain: ${error}`);
@@ -99,22 +97,26 @@ class FallbackStorage {
99
97
  class SmartStorage {
100
98
  keychain;
101
99
  fallback;
100
+ config;
102
101
  useKeychain = true;
103
102
  keychainTested = false;
103
+ warnedLegacyKeytar = false;
104
104
  constructor(config) {
105
105
  this.keychain = new KeychainStorage();
106
106
  this.fallback = new FallbackStorage(config);
107
+ this.config = config;
107
108
  }
108
109
  async ensureKeychainTested() {
109
110
  if (this.keychainTested)
110
111
  return;
111
112
  try {
112
113
  const testKey = '__codeep_test__';
113
- const kt = await loadKeytar();
114
- if (!kt)
115
- throw new Error('keytar unavailable');
116
- await kt.setPassword(SERVICE_NAME, testKey, 'test');
117
- await kt.deletePassword(SERVICE_NAME, testKey);
114
+ const kr = await loadKeyring();
115
+ if (!kr)
116
+ throw new Error('keyring unavailable');
117
+ const entry = new kr.AsyncEntry(SERVICE_NAME, testKey);
118
+ await entry.setPassword('test');
119
+ await entry.deletePassword();
118
120
  this.useKeychain = true;
119
121
  }
120
122
  catch {
@@ -135,7 +137,21 @@ class SmartStorage {
135
137
  if (key)
136
138
  return key;
137
139
  }
138
- return this.fallback.getApiKey(providerId);
140
+ const fromFallback = await this.fallback.getApiKey(providerId);
141
+ // Migration note (Linux/Windows only): keys stored by the old keytar
142
+ // addon live under a different credential-store naming than keyring-rs
143
+ // uses, so they're invisible here — and the plaintext copies were
144
+ // already purged by the keysSecured migration. Nothing to silently
145
+ // recover; tell the user once so a missing key isn't a mystery.
146
+ // (macOS is unaffected — both libraries share the same Keychain items.)
147
+ if (fromFallback === null && !this.warnedLegacyKeytar
148
+ && process.platform !== 'darwin' && this.useKeychain
149
+ && this.config?.get?.('keysSecured') === true) {
150
+ this.warnedLegacyKeytar = true;
151
+ logger.warn('API keys saved by Codeep ≤ 2.14 (keytar) can\'t be read by the new keychain backend on this OS. ' +
152
+ 'Re-add the affected key with /login <provider> <key> — it will be stored under the new backend.');
153
+ }
154
+ return fromFallback;
139
155
  }
140
156
  async setApiKey(providerId, apiKey) {
141
157
  await this.ensureKeychainTested();
@@ -2,6 +2,17 @@
2
2
  * Set current project path for local logging
3
3
  */
4
4
  export declare function setLogProjectPath(projectPath: string | null): void;
5
+ type LogLevel = 'info' | 'warn' | 'error' | 'debug';
6
+ export interface LogEntry {
7
+ timestamp: string;
8
+ level: LogLevel;
9
+ message: string;
10
+ data?: any;
11
+ }
12
+ /**
13
+ * Format log entry as string
14
+ */
15
+ export declare function formatLogEntry(entry: LogEntry): string;
5
16
  /**
6
17
  * Logger API
7
18
  */
@@ -31,3 +42,4 @@ export declare function logStartup(version: string): void;
31
42
  * Log application error
32
43
  */
33
44
  export declare function logAppError(error: Error, context?: string): void;
45
+ export {};
@@ -49,7 +49,7 @@ function getLogFilePaths() {
49
49
  /**
50
50
  * Format log entry as string
51
51
  */
52
- function formatLogEntry(entry) {
52
+ export function formatLogEntry(entry) {
53
53
  const dataStr = entry.data ? ` ${JSON.stringify(entry.data)}` : '';
54
54
  return `[${entry.timestamp}] [${entry.level.toUpperCase()}] ${entry.message}${dataStr}\n`;
55
55
  }
@@ -34,8 +34,34 @@ export interface McpConfigFile {
34
34
  * Load MCP server definitions for a workspace. Project entries shadow
35
35
  * global entries with the same server name. Workspace-less calls
36
36
  * (TUI without project) return only the global config.
37
+ *
38
+ * Sources read (highest precedence first on name collisions):
39
+ * 1. <workspace>/.codeep/mcp_servers.json (Codeep-native project file)
40
+ * 2. <workspace>/.mcp.json (cross-tool standard — same
41
+ * shape Claude Code/Cursor/Kilo Code read, so users can keep one MCP
42
+ * config for their whole fleet)
43
+ * 3. ~/.codeep/mcp_servers.json (global — user's machine)
37
44
  */
38
45
  export declare function loadMcpServerConfig(workspaceRoot?: string): McpServer[];
46
+ /**
47
+ * Same sources as `loadMcpServerConfig`, but split by trust domain:
48
+ * `global` (~/.codeep — the user's own machine-wide file) vs `workspace`
49
+ * (files that arrive WITH a repo: `.codeep/mcp_servers.json` + `.mcp.json`).
50
+ *
51
+ * Workspace entries are attacker-controllable — anyone who clones a repo
52
+ * containing one of these files would otherwise spawn arbitrary commands
53
+ * at startup — so callers must gate them behind `isWorkspaceMcpTrusted`
54
+ * before spawning (mirrors the `trustedHookProjects` gate for hooks).
55
+ * On name collisions a workspace entry shadows a global one, matching
56
+ * the merged loader's precedence.
57
+ */
58
+ export declare function loadMcpServerConfigSplit(workspaceRoot?: string): {
59
+ global: McpServer[];
60
+ workspace: McpServer[];
61
+ };
62
+ export declare function isWorkspaceMcpTrusted(workspaceRoot: string): boolean;
63
+ export declare function trustWorkspaceMcp(workspaceRoot: string): void;
64
+ export declare function untrustWorkspaceMcp(workspaceRoot: string): void;
39
65
  /**
40
66
  * Merge two server lists: ACP-provided + on-disk. ACP wins on collisions
41
67
  * — the client knows its own config, so a Zed-passed server overrides a
@@ -28,8 +28,50 @@
28
28
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
29
29
  import { join, dirname } from 'path';
30
30
  import { homedir } from 'os';
31
+ import { config } from '../config/index.js';
31
32
  const PROJECT_CONFIG_PATH = '.codeep/mcp_servers.json';
33
+ const PROJECT_DOTMCP_PATH = '.mcp.json';
32
34
  const GLOBAL_CONFIG_PATH = '.codeep/mcp_servers.json';
35
+ /**
36
+ * Expand `${VAR}` / `${VAR:-default}` references from the process env —
37
+ * the same substitution Claude Code applies to `.mcp.json`, so a shared
38
+ * config with `"env": {"GITHUB_TOKEN": "${GITHUB_TOKEN}"}` works verbatim.
39
+ *
40
+ * Unset vars WITHOUT a default keep the literal `${VAR}` text: silently
41
+ * substituting '' would hide the misconfiguration, and the literal at
42
+ * least shows up as-is in error messages / `/mcp` output. (Previously the
43
+ * literal also SHADOWED a real env var of the same name at spawn time —
44
+ * expansion when the var IS set fixes that.)
45
+ */
46
+ function expandEnvRefs(value) {
47
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (match, name, def) => {
48
+ const fromEnv = process.env[name];
49
+ if (fromEnv !== undefined)
50
+ return fromEnv;
51
+ if (def !== undefined)
52
+ return def;
53
+ return match;
54
+ });
55
+ }
56
+ function expandRecord(rec) {
57
+ if (!rec)
58
+ return undefined;
59
+ const out = {};
60
+ for (const [k, v] of Object.entries(rec))
61
+ out[k] = typeof v === 'string' ? expandEnvRefs(v) : v;
62
+ return out;
63
+ }
64
+ /** Apply ${VAR} expansion to every string field of a server entry. */
65
+ function expandServer(s) {
66
+ return {
67
+ ...s,
68
+ command: typeof s.command === 'string' ? expandEnvRefs(s.command) : s.command,
69
+ args: Array.isArray(s.args) ? s.args.map(a => expandEnvRefs(a)) : s.args,
70
+ env: expandRecord(s.env),
71
+ url: typeof s.url === 'string' ? expandEnvRefs(s.url) : s.url,
72
+ headers: expandRecord(s.headers),
73
+ };
74
+ }
33
75
  function parseEntries(raw, source) {
34
76
  let parsed;
35
77
  try {
@@ -46,7 +88,9 @@ function parseEntries(raw, source) {
46
88
  return [];
47
89
  if (Array.isArray(servers)) {
48
90
  // Defensively filter — any entry needs name + either command or url.
49
- return servers.filter(s => s && typeof s.name === 'string' && (typeof s.command === 'string' || typeof s.url === 'string'));
91
+ return servers
92
+ .filter(s => s && typeof s.name === 'string' && (typeof s.command === 'string' || typeof s.url === 'string'))
93
+ .map(expandServer);
50
94
  }
51
95
  return Object.entries(servers).flatMap(([name, cfg]) => {
52
96
  if (!cfg)
@@ -56,7 +100,7 @@ function parseEntries(raw, source) {
56
100
  const hasHttp = typeof cfg.url === 'string';
57
101
  if (!hasStdio && !hasHttp)
58
102
  return [];
59
- return [{
103
+ return [expandServer({
60
104
  name,
61
105
  command: hasStdio ? cfg.command : undefined,
62
106
  args: Array.isArray(cfg.args) ? cfg.args.filter(a => typeof a === 'string') : [],
@@ -65,7 +109,7 @@ function parseEntries(raw, source) {
65
109
  headers: cfg.headers && typeof cfg.headers === 'object'
66
110
  ? cfg.headers
67
111
  : undefined,
68
- }];
112
+ })];
69
113
  });
70
114
  }
71
115
  function loadFromFile(path) {
@@ -83,20 +127,81 @@ function loadFromFile(path) {
83
127
  * Load MCP server definitions for a workspace. Project entries shadow
84
128
  * global entries with the same server name. Workspace-less calls
85
129
  * (TUI without project) return only the global config.
130
+ *
131
+ * Sources read (highest precedence first on name collisions):
132
+ * 1. <workspace>/.codeep/mcp_servers.json (Codeep-native project file)
133
+ * 2. <workspace>/.mcp.json (cross-tool standard — same
134
+ * shape Claude Code/Cursor/Kilo Code read, so users can keep one MCP
135
+ * config for their whole fleet)
136
+ * 3. ~/.codeep/mcp_servers.json (global — user's machine)
86
137
  */
87
138
  export function loadMcpServerConfig(workspaceRoot) {
88
139
  const globalServers = loadFromFile(join(homedir(), GLOBAL_CONFIG_PATH));
89
140
  const projectServers = workspaceRoot
90
141
  ? loadFromFile(join(workspaceRoot, PROJECT_CONFIG_PATH))
91
142
  : [];
92
- // Project wins on name collisions.
143
+ const dotMcpServers = workspaceRoot
144
+ ? loadFromFile(join(workspaceRoot, PROJECT_DOTMCP_PATH))
145
+ : [];
146
+ // Higher-precedence sources win on name collisions: project (Codeep-native)
147
+ // beats .mcp.json beats global.
93
148
  const byName = new Map();
94
149
  for (const s of globalServers)
95
150
  byName.set(s.name, s);
151
+ for (const s of dotMcpServers)
152
+ byName.set(s.name, s);
96
153
  for (const s of projectServers)
97
154
  byName.set(s.name, s);
98
155
  return [...byName.values()];
99
156
  }
157
+ /**
158
+ * Same sources as `loadMcpServerConfig`, but split by trust domain:
159
+ * `global` (~/.codeep — the user's own machine-wide file) vs `workspace`
160
+ * (files that arrive WITH a repo: `.codeep/mcp_servers.json` + `.mcp.json`).
161
+ *
162
+ * Workspace entries are attacker-controllable — anyone who clones a repo
163
+ * containing one of these files would otherwise spawn arbitrary commands
164
+ * at startup — so callers must gate them behind `isWorkspaceMcpTrusted`
165
+ * before spawning (mirrors the `trustedHookProjects` gate for hooks).
166
+ * On name collisions a workspace entry shadows a global one, matching
167
+ * the merged loader's precedence.
168
+ */
169
+ export function loadMcpServerConfigSplit(workspaceRoot) {
170
+ const globalServers = loadFromFile(join(homedir(), GLOBAL_CONFIG_PATH));
171
+ if (!workspaceRoot)
172
+ return { global: globalServers, workspace: [] };
173
+ const projectServers = loadFromFile(join(workspaceRoot, PROJECT_CONFIG_PATH));
174
+ const dotMcpServers = loadFromFile(join(workspaceRoot, PROJECT_DOTMCP_PATH));
175
+ const byName = new Map();
176
+ for (const s of dotMcpServers)
177
+ byName.set(s.name, s);
178
+ for (const s of projectServers)
179
+ byName.set(s.name, s);
180
+ const workspace = [...byName.values()];
181
+ const workspaceNames = new Set(workspace.map(s => s.name));
182
+ return {
183
+ global: globalServers.filter(s => !workspaceNames.has(s.name)),
184
+ workspace,
185
+ };
186
+ }
187
+ // ── Workspace MCP trust ────────────────────────────────────────────────────────
188
+ // Workspace-sourced MCP servers spawn child processes with repo-author-chosen
189
+ // command/args/env, so they need a one-time per-workspace approval — the same
190
+ // model as `trustedHookProjects` for hooks. Global (~/.codeep) servers are the
191
+ // user's own config and never need approval.
192
+ export function isWorkspaceMcpTrusted(workspaceRoot) {
193
+ const cur = config.get('trustedMcpProjects') ?? [];
194
+ return cur.includes(workspaceRoot);
195
+ }
196
+ export function trustWorkspaceMcp(workspaceRoot) {
197
+ const cur = config.get('trustedMcpProjects') ?? [];
198
+ if (!cur.includes(workspaceRoot))
199
+ config.set('trustedMcpProjects', [...cur, workspaceRoot]);
200
+ }
201
+ export function untrustWorkspaceMcp(workspaceRoot) {
202
+ const cur = config.get('trustedMcpProjects') ?? [];
203
+ config.set('trustedMcpProjects', cur.filter((p) => p !== workspaceRoot));
204
+ }
100
205
  /**
101
206
  * Merge two server lists: ACP-provided + on-disk. ACP wins on collisions
102
207
  * — the client knows its own config, so a Zed-passed server overrides a