codeep 2.14.0 → 2.15.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 (61) hide show
  1. package/README.md +35 -24
  2. package/dist/acp/commands.js +22 -1
  3. package/dist/acp/server.js +13 -2
  4. package/dist/config/index.d.ts +10 -0
  5. package/dist/config/index.js +2 -2
  6. package/dist/config/providers.js +15 -10
  7. package/dist/renderer/App.d.ts +0 -30
  8. package/dist/renderer/App.js +149 -659
  9. package/dist/renderer/agentExecution.d.ts +1 -0
  10. package/dist/renderer/agentExecution.js +3 -2
  11. package/dist/renderer/commands/helpers.d.ts +63 -0
  12. package/dist/renderer/commands/helpers.js +108 -0
  13. package/dist/renderer/commands/registry.js +5 -0
  14. package/dist/renderer/commands.d.ts +4 -0
  15. package/dist/renderer/commands.js +179 -63
  16. package/dist/renderer/components/ActionFormatting.d.ts +17 -0
  17. package/dist/renderer/components/ActionFormatting.js +67 -0
  18. package/dist/renderer/components/Autocomplete.d.ts +33 -0
  19. package/dist/renderer/components/Autocomplete.js +40 -0
  20. package/dist/renderer/components/Intro.d.ts +9 -0
  21. package/dist/renderer/components/Intro.js +5 -15
  22. package/dist/renderer/components/MessageFormatter.d.ts +96 -0
  23. package/dist/renderer/components/MessageFormatter.js +375 -0
  24. package/dist/renderer/components/Permission.d.ts +4 -0
  25. package/dist/renderer/components/Permission.js +1 -1
  26. package/dist/renderer/components/Status.d.ts +4 -0
  27. package/dist/renderer/components/Status.js +2 -3
  28. package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
  29. package/dist/renderer/components/WelcomeFormatter.js +79 -0
  30. package/dist/renderer/components/uiConstants.d.ts +8 -0
  31. package/dist/renderer/components/uiConstants.js +24 -0
  32. package/dist/renderer/inputParsing.d.ts +22 -0
  33. package/dist/renderer/inputParsing.js +28 -0
  34. package/dist/renderer/layout.d.ts +215 -0
  35. package/dist/renderer/layout.js +326 -0
  36. package/dist/renderer/main.d.ts +2 -1
  37. package/dist/renderer/main.js +45 -10
  38. package/dist/renderer/ollamaHint.d.ts +12 -0
  39. package/dist/renderer/ollamaHint.js +29 -0
  40. package/dist/utils/agentChat.js +23 -1
  41. package/dist/utils/codeepCloud.d.ts +54 -0
  42. package/dist/utils/codeepCloud.js +95 -0
  43. package/dist/utils/export.d.ts +12 -0
  44. package/dist/utils/export.js +3 -3
  45. package/dist/utils/hooks.d.ts +26 -0
  46. package/dist/utils/hooks.js +69 -1
  47. package/dist/utils/keychain.js +45 -29
  48. package/dist/utils/logger.d.ts +12 -0
  49. package/dist/utils/logger.js +1 -1
  50. package/dist/utils/mcpConfig.d.ts +26 -0
  51. package/dist/utils/mcpConfig.js +109 -4
  52. package/dist/utils/skillBundles.d.ts +14 -0
  53. package/dist/utils/skillBundles.js +3 -3
  54. package/dist/utils/skillBundlesCloud.d.ts +7 -0
  55. package/dist/utils/skillBundlesCloud.js +1 -1
  56. package/dist/utils/tokenTracker.js +12 -2
  57. package/dist/utils/toolParsing.d.ts +11 -0
  58. package/dist/utils/toolParsing.js +6 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/package.json +2 -2
@@ -5,6 +5,18 @@ export interface ExportOptions {
5
5
  sessionName?: string;
6
6
  timestamp?: string;
7
7
  }
8
+ /**
9
+ * Export messages to Markdown format
10
+ */
11
+ export declare function exportToMarkdown(messages: Message[], sessionName?: string): string;
12
+ /**
13
+ * Export messages to JSON format
14
+ */
15
+ export declare function exportToJson(messages: Message[], sessionName?: string): string;
16
+ /**
17
+ * Export messages to plain text format
18
+ */
19
+ export declare function exportToText(messages: Message[], sessionName?: string): string;
8
20
  /**
9
21
  * Export messages to specified format
10
22
  */
@@ -3,7 +3,7 @@ import { join } from 'path';
3
3
  /**
4
4
  * Export messages to Markdown format
5
5
  */
6
- function exportToMarkdown(messages, sessionName) {
6
+ export function exportToMarkdown(messages, sessionName) {
7
7
  const timestamp = new Date().toLocaleString('hr-HR');
8
8
  let markdown = `# Codeep Chat Export\n\n`;
9
9
  if (sessionName) {
@@ -24,7 +24,7 @@ function exportToMarkdown(messages, sessionName) {
24
24
  /**
25
25
  * Export messages to JSON format
26
26
  */
27
- function exportToJson(messages, sessionName) {
27
+ export function exportToJson(messages, sessionName) {
28
28
  const exportData = {
29
29
  session: sessionName || 'Unnamed',
30
30
  exportedAt: new Date().toISOString(),
@@ -36,7 +36,7 @@ function exportToJson(messages, sessionName) {
36
36
  /**
37
37
  * Export messages to plain text format
38
38
  */
39
- function exportToText(messages, sessionName) {
39
+ export function exportToText(messages, sessionName) {
40
40
  const timestamp = new Date().toLocaleString('hr-HR');
41
41
  let text = `Codeep Chat Export\n`;
42
42
  text += `===================\n\n`;
@@ -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
@@ -61,6 +61,19 @@ export interface SkillBundle extends SkillBundleMeta {
61
61
  /** Body content (everything after the frontmatter). */
62
62
  body: string;
63
63
  }
64
+ interface ParsedFrontmatter {
65
+ meta: Record<string, unknown>;
66
+ body: string;
67
+ }
68
+ /**
69
+ * Tolerant YAML-frontmatter parser — handles `key: value`, `key: [a, b]`,
70
+ * and `key:` followed by `- item` block-list lines. Quoted strings are
71
+ * unquoted. We don't ship a real YAML dep for this — the keys we care
72
+ * about are scalars or simple arrays.
73
+ */
74
+ export declare function parseFrontmatter(raw: string): ParsedFrontmatter;
75
+ export declare function stripQuotes(s: string): string;
76
+ export declare function asStringArray(v: unknown): string[] | null;
64
77
  /**
65
78
  * Load all skill bundles available in this workspace. Project entries
66
79
  * shadow global entries with the same name.
@@ -82,3 +95,4 @@ export declare function formatBundleList(bundles: SkillBundle[]): string;
82
95
  * pattern as custom commands and hooks. Empty string if no bundles.
83
96
  */
84
97
  export declare function summarizeBundles(workspaceRoot: string): string;
98
+ export {};
@@ -42,7 +42,7 @@ import { homedir } from 'os';
42
42
  * unquoted. We don't ship a real YAML dep for this — the keys we care
43
43
  * about are scalars or simple arrays.
44
44
  */
45
- function parseFrontmatter(raw) {
45
+ export function parseFrontmatter(raw) {
46
46
  // BOM + CRLF normalisation. Real-world files copy/paste from various
47
47
  // editors and pick up either; YAML strictly forbids tabs in scalars
48
48
  // but we don't care for the keys we read.
@@ -94,7 +94,7 @@ function parseFrontmatter(raw) {
94
94
  }
95
95
  return { meta, body: match[2].trimStart() };
96
96
  }
97
- function stripQuotes(s) {
97
+ export function stripQuotes(s) {
98
98
  return s.replace(/^["']|["']$/g, '');
99
99
  }
100
100
  function loadFromDir(dir, scope) {
@@ -158,7 +158,7 @@ function loadFromDir(dir, scope) {
158
158
  }
159
159
  return bundles;
160
160
  }
161
- function asStringArray(v) {
161
+ export function asStringArray(v) {
162
162
  if (Array.isArray(v))
163
163
  return v.filter(x => typeof x === 'string');
164
164
  if (typeof v === 'string')
@@ -10,6 +10,7 @@
10
10
  * Auth uses the same `x-sync-token` header `codeepCloud.ts` already sends
11
11
  * for /api/tasks and friends.
12
12
  */
13
+ import { type SkillBundle } from './skillBundles.js';
13
14
  export interface RemoteSkill {
14
15
  id: number;
15
16
  github_id: string;
@@ -63,6 +64,12 @@ export declare function unpublishBundle(idOrPath: string): Promise<{
63
64
  ok: boolean;
64
65
  error?: string;
65
66
  }>;
67
+ /**
68
+ * Re-serialise a loaded SkillBundle back into the SKILL.md text format.
69
+ * Used by publish so the round-trip is lossless (sort of — we drop
70
+ * unknown frontmatter keys for now to keep the published format stable).
71
+ */
72
+ export declare function serialiseSkillMd(bundle: SkillBundle): string;
66
73
  /** Read raw SKILL.md from disk — used when we want the unmodified bytes. */
67
74
  export declare function readRawSkillMd(workspaceRoot: string, slug: string): string | null;
68
75
  /** Delete the local copy of an installed skill bundle (for /skills uninstall). */
@@ -144,7 +144,7 @@ export async function unpublishBundle(idOrPath) {
144
144
  * Used by publish so the round-trip is lossless (sort of — we drop
145
145
  * unknown frontmatter keys for now to keep the published format stable).
146
146
  */
147
- function serialiseSkillMd(bundle) {
147
+ export function serialiseSkillMd(bundle) {
148
148
  const meta = ['---'];
149
149
  meta.push(`name: ${bundle.name}`);
150
150
  meta.push(`description: ${bundle.description}`);