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.
- package/README.md +47 -27
- package/dist/acp/commands.js +22 -1
- package/dist/acp/server.js +13 -2
- package/dist/acp/session.js +22 -1
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +2 -2
- package/dist/config/providers.js +35 -24
- package/dist/renderer/App.d.ts +77 -30
- package/dist/renderer/App.js +429 -659
- package/dist/renderer/agentExecution.d.ts +1 -0
- package/dist/renderer/agentExecution.js +3 -2
- package/dist/renderer/commands/helpers.d.ts +251 -0
- package/dist/renderer/commands/helpers.js +450 -0
- package/dist/renderer/commands/registry.js +7 -1
- package/dist/renderer/commands.d.ts +4 -0
- package/dist/renderer/commands.js +363 -318
- package/dist/renderer/components/ActionFormatting.d.ts +17 -0
- package/dist/renderer/components/ActionFormatting.js +67 -0
- package/dist/renderer/components/Autocomplete.d.ts +58 -0
- package/dist/renderer/components/Autocomplete.js +75 -0
- package/dist/renderer/components/Intro.d.ts +9 -0
- package/dist/renderer/components/Intro.js +5 -15
- package/dist/renderer/components/MessageFormatter.d.ts +96 -0
- package/dist/renderer/components/MessageFormatter.js +375 -0
- package/dist/renderer/components/Permission.d.ts +4 -0
- package/dist/renderer/components/Permission.js +1 -1
- package/dist/renderer/components/Status.d.ts +4 -0
- package/dist/renderer/components/Status.js +2 -3
- package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
- package/dist/renderer/components/WelcomeFormatter.js +79 -0
- package/dist/renderer/components/uiConstants.d.ts +8 -0
- package/dist/renderer/components/uiConstants.js +24 -0
- package/dist/renderer/inputParsing.d.ts +22 -0
- package/dist/renderer/inputParsing.js +28 -0
- package/dist/renderer/layout.d.ts +219 -0
- package/dist/renderer/layout.js +338 -0
- package/dist/renderer/main.d.ts +2 -1
- package/dist/renderer/main.js +79 -11
- package/dist/renderer/ollamaHint.d.ts +12 -0
- package/dist/renderer/ollamaHint.js +29 -0
- package/dist/utils/agentChat.js +23 -1
- package/dist/utils/codeepCloud.d.ts +54 -0
- package/dist/utils/codeepCloud.js +95 -0
- package/dist/utils/diffPreview.d.ts +31 -0
- package/dist/utils/diffPreview.js +102 -0
- package/dist/utils/export.d.ts +12 -0
- package/dist/utils/export.js +3 -3
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +111 -1
- package/dist/utils/hooks.d.ts +26 -0
- package/dist/utils/hooks.js +69 -1
- package/dist/utils/keychain.js +45 -29
- package/dist/utils/logger.d.ts +12 -0
- package/dist/utils/logger.js +1 -1
- package/dist/utils/mcpConfig.d.ts +26 -0
- package/dist/utils/mcpConfig.js +109 -4
- package/dist/utils/mentions.d.ts +195 -0
- package/dist/utils/mentions.js +672 -0
- package/dist/utils/skillBundles.d.ts +14 -0
- package/dist/utils/skillBundles.js +3 -3
- package/dist/utils/skillBundlesCloud.d.ts +7 -0
- package/dist/utils/skillBundlesCloud.js +1 -1
- package/dist/utils/tokenTracker.js +21 -5
- package/dist/utils/toolParsing.d.ts +11 -0
- package/dist/utils/toolParsing.js +6 -0
- package/dist/utils/webFetch.d.ts +101 -0
- package/dist/utils/webFetch.js +375 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
+
}
|
package/dist/utils/hooks.d.ts
CHANGED
|
@@ -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
|
package/dist/utils/hooks.js
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/dist/utils/keychain.js
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
import { logger } from './logger.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
8
|
+
_keyring = (await import('@napi-rs/keyring'));
|
|
14
9
|
}
|
|
15
10
|
catch {
|
|
16
|
-
|
|
11
|
+
_keyring = null; /* native binary unavailable (headless / minimal install) */
|
|
17
12
|
}
|
|
18
13
|
}
|
|
19
|
-
return
|
|
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
|
|
29
|
-
if (!
|
|
23
|
+
const kr = await loadKeyring();
|
|
24
|
+
if (!kr)
|
|
30
25
|
return null;
|
|
31
26
|
const account = this.getAccountName(providerId);
|
|
32
|
-
|
|
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
|
|
42
|
-
if (!
|
|
43
|
-
throw new Error('
|
|
37
|
+
const kr = await loadKeyring();
|
|
38
|
+
if (!kr)
|
|
39
|
+
throw new Error('keyring unavailable');
|
|
44
40
|
const account = this.getAccountName(providerId);
|
|
45
|
-
|
|
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
|
|
54
|
-
if (!
|
|
50
|
+
const kr = await loadKeyring();
|
|
51
|
+
if (!kr)
|
|
55
52
|
return;
|
|
56
53
|
const account = this.getAccountName(providerId);
|
|
57
|
-
|
|
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
|
|
114
|
-
if (!
|
|
115
|
-
throw new Error('
|
|
116
|
-
|
|
117
|
-
await
|
|
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
|
-
|
|
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();
|
package/dist/utils/logger.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/utils/logger.js
CHANGED
|
@@ -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
|
package/dist/utils/mcpConfig.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|