codeep 1.3.42 → 2.0.1

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 (60) hide show
  1. package/README.md +208 -0
  2. package/dist/acp/commands.js +770 -7
  3. package/dist/acp/protocol.d.ts +11 -2
  4. package/dist/acp/server.js +179 -11
  5. package/dist/acp/session.d.ts +3 -0
  6. package/dist/acp/session.js +5 -0
  7. package/dist/api/index.js +39 -6
  8. package/dist/config/index.d.ts +13 -0
  9. package/dist/config/index.js +45 -0
  10. package/dist/config/providers.js +76 -1
  11. package/dist/renderer/App.d.ts +12 -0
  12. package/dist/renderer/App.js +109 -4
  13. package/dist/renderer/agentExecution.js +5 -0
  14. package/dist/renderer/commands.js +638 -2
  15. package/dist/renderer/components/Help.js +28 -0
  16. package/dist/renderer/components/Login.d.ts +1 -0
  17. package/dist/renderer/components/Login.js +24 -9
  18. package/dist/renderer/handlers.d.ts +11 -1
  19. package/dist/renderer/handlers.js +30 -0
  20. package/dist/renderer/main.js +73 -0
  21. package/dist/utils/agent.d.ts +17 -0
  22. package/dist/utils/agent.js +91 -7
  23. package/dist/utils/agentChat.d.ts +10 -2
  24. package/dist/utils/agentChat.js +48 -9
  25. package/dist/utils/agentStream.js +6 -2
  26. package/dist/utils/checkpoints.d.ts +93 -0
  27. package/dist/utils/checkpoints.js +205 -0
  28. package/dist/utils/context.d.ts +24 -0
  29. package/dist/utils/context.js +57 -0
  30. package/dist/utils/customCommands.d.ts +62 -0
  31. package/dist/utils/customCommands.js +201 -0
  32. package/dist/utils/hooks.d.ts +97 -0
  33. package/dist/utils/hooks.js +223 -0
  34. package/dist/utils/mcpClient.d.ts +229 -0
  35. package/dist/utils/mcpClient.js +497 -0
  36. package/dist/utils/mcpConfig.d.ts +55 -0
  37. package/dist/utils/mcpConfig.js +177 -0
  38. package/dist/utils/mcpMarketplace.d.ts +49 -0
  39. package/dist/utils/mcpMarketplace.js +175 -0
  40. package/dist/utils/mcpRegistry.d.ts +129 -0
  41. package/dist/utils/mcpRegistry.js +427 -0
  42. package/dist/utils/mcpSamplingBridge.d.ts +32 -0
  43. package/dist/utils/mcpSamplingBridge.js +88 -0
  44. package/dist/utils/mcpStreamableHttp.d.ts +65 -0
  45. package/dist/utils/mcpStreamableHttp.js +207 -0
  46. package/dist/utils/openrouterPrefs.d.ts +36 -0
  47. package/dist/utils/openrouterPrefs.js +83 -0
  48. package/dist/utils/skillBundles.d.ts +84 -0
  49. package/dist/utils/skillBundles.js +257 -0
  50. package/dist/utils/skillBundlesCloud.d.ts +69 -0
  51. package/dist/utils/skillBundlesCloud.js +202 -0
  52. package/dist/utils/tokenTracker.d.ts +14 -2
  53. package/dist/utils/tokenTracker.js +59 -41
  54. package/dist/utils/toolExecution.d.ts +17 -1
  55. package/dist/utils/toolExecution.js +184 -6
  56. package/dist/utils/tools.d.ts +22 -6
  57. package/dist/utils/tools.js +83 -8
  58. package/package.json +3 -2
  59. package/bin/codeep-macos-arm64 +0 -0
  60. package/bin/codeep-macos-x64 +0 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Session checkpoints — `/checkpoint` and `/rewind`.
3
+ *
4
+ * A checkpoint is a snapshot of the agent conversation at a point in time:
5
+ * session history, provider/model state, and the list of files the agent has
6
+ * touched in this session. It does NOT snapshot file content — that's git's
7
+ * job, and trying to do it ourselves means either a 100KB-per-file ceiling
8
+ * with surprise truncation, or a real-time disk hog. We surface a hint at
9
+ * rewind time telling the user how to restore files via git.
10
+ *
11
+ * Use cases this is for:
12
+ * - User suspects the agent is going off the rails after N steps and
13
+ * wants to roll the conversation back to before that turn.
14
+ * - User is about to start a risky multi-step refactor and wants a
15
+ * named bookmark to return to if it gets messy.
16
+ *
17
+ * Use cases this is NOT for:
18
+ * - Replacing git. If you only need file rollback, `git restore` /
19
+ * `git stash` is faster and reliable.
20
+ * - Cross-session persistence beyond the workspace's `.codeep/checkpoints/`
21
+ * folder — checkpoints are per-project, not global.
22
+ *
23
+ * On-disk shape (`.codeep/checkpoints/<id>.json`):
24
+ * {
25
+ * "id": "ck-2026-05-18-abc123",
26
+ * "name": "before big refactor",
27
+ * "createdAt": "...",
28
+ * "sessionId": "session-2026-05-18-...",
29
+ * "provider": "z.ai",
30
+ * "model": "glm-5.1",
31
+ * "messages": [ ... ],
32
+ * "filesTouched": ["src/a.ts", "src/b.ts"],
33
+ * "gitHead": "abcdef0" // optional, recorded only if cwd is a git repo
34
+ * }
35
+ */
36
+ import type { Message } from '../config/index.js';
37
+ export interface Checkpoint {
38
+ id: string;
39
+ name?: string;
40
+ createdAt: string;
41
+ sessionId: string;
42
+ provider: string;
43
+ model: string;
44
+ messages: Message[];
45
+ filesTouched: string[];
46
+ gitHead?: string;
47
+ }
48
+ /** Lightweight metadata for `/checkpoints` list — avoids loading full message arrays. */
49
+ export interface CheckpointMeta {
50
+ id: string;
51
+ name?: string;
52
+ createdAt: string;
53
+ sessionId: string;
54
+ messageCount: number;
55
+ filesTouchedCount: number;
56
+ gitHead?: string;
57
+ }
58
+ /**
59
+ * Create a new checkpoint snapshot of the current session state.
60
+ * Returns the persisted checkpoint object.
61
+ */
62
+ export declare function createCheckpoint(opts: {
63
+ workspaceRoot: string;
64
+ sessionId: string;
65
+ provider: string;
66
+ model: string;
67
+ messages: Message[];
68
+ filesTouched: string[];
69
+ name?: string;
70
+ }): Checkpoint;
71
+ /**
72
+ * Load a single checkpoint by id. Returns null if not found or unreadable.
73
+ */
74
+ export declare function loadCheckpoint(workspaceRoot: string, id: string): Checkpoint | null;
75
+ /**
76
+ * List checkpoints in the workspace, newest first. Returns metadata only —
77
+ * the full `messages` array is not loaded so this is cheap for `/checkpoints`.
78
+ */
79
+ export declare function listCheckpoints(workspaceRoot: string): CheckpointMeta[];
80
+ /**
81
+ * Delete a checkpoint by id. Returns true if a file was removed.
82
+ */
83
+ export declare function deleteCheckpoint(workspaceRoot: string, id: string): boolean;
84
+ /**
85
+ * Format a checkpoint list as a Markdown block for `/checkpoints` output.
86
+ */
87
+ export declare function formatCheckpointList(metas: CheckpointMeta[]): string;
88
+ /**
89
+ * Build the user-facing hint shown after a successful /rewind. Tells the
90
+ * user how to bring their files back to checkpoint state — checkpoints
91
+ * don't snapshot file content, so this is the only restore path.
92
+ */
93
+ export declare function buildRewindGitHint(cp: Checkpoint): string;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Session checkpoints — `/checkpoint` and `/rewind`.
3
+ *
4
+ * A checkpoint is a snapshot of the agent conversation at a point in time:
5
+ * session history, provider/model state, and the list of files the agent has
6
+ * touched in this session. It does NOT snapshot file content — that's git's
7
+ * job, and trying to do it ourselves means either a 100KB-per-file ceiling
8
+ * with surprise truncation, or a real-time disk hog. We surface a hint at
9
+ * rewind time telling the user how to restore files via git.
10
+ *
11
+ * Use cases this is for:
12
+ * - User suspects the agent is going off the rails after N steps and
13
+ * wants to roll the conversation back to before that turn.
14
+ * - User is about to start a risky multi-step refactor and wants a
15
+ * named bookmark to return to if it gets messy.
16
+ *
17
+ * Use cases this is NOT for:
18
+ * - Replacing git. If you only need file rollback, `git restore` /
19
+ * `git stash` is faster and reliable.
20
+ * - Cross-session persistence beyond the workspace's `.codeep/checkpoints/`
21
+ * folder — checkpoints are per-project, not global.
22
+ *
23
+ * On-disk shape (`.codeep/checkpoints/<id>.json`):
24
+ * {
25
+ * "id": "ck-2026-05-18-abc123",
26
+ * "name": "before big refactor",
27
+ * "createdAt": "...",
28
+ * "sessionId": "session-2026-05-18-...",
29
+ * "provider": "z.ai",
30
+ * "model": "glm-5.1",
31
+ * "messages": [ ... ],
32
+ * "filesTouched": ["src/a.ts", "src/b.ts"],
33
+ * "gitHead": "abcdef0" // optional, recorded only if cwd is a git repo
34
+ * }
35
+ */
36
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from 'fs';
37
+ import { join } from 'path';
38
+ import { randomUUID } from 'crypto';
39
+ import { execSync } from 'child_process';
40
+ function getCheckpointsDir(workspaceRoot) {
41
+ const dir = join(workspaceRoot, '.codeep', 'checkpoints');
42
+ if (!existsSync(dir))
43
+ mkdirSync(dir, { recursive: true });
44
+ return dir;
45
+ }
46
+ function readGitHead(workspaceRoot) {
47
+ try {
48
+ // Short SHA is enough for human display; full SHA available via git if needed.
49
+ const out = execSync('git rev-parse --short HEAD', {
50
+ cwd: workspaceRoot,
51
+ encoding: 'utf-8',
52
+ stdio: ['ignore', 'pipe', 'ignore'],
53
+ timeout: 2000,
54
+ }).trim();
55
+ return out || undefined;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ /**
62
+ * Generate a unique, human-recognizable checkpoint id.
63
+ * Format: `ck-YYYY-MM-DD-<8-char-uuid>`
64
+ */
65
+ function generateCheckpointId() {
66
+ const date = new Date().toISOString().slice(0, 10);
67
+ return `ck-${date}-${randomUUID().slice(0, 8)}`;
68
+ }
69
+ /**
70
+ * Create a new checkpoint snapshot of the current session state.
71
+ * Returns the persisted checkpoint object.
72
+ */
73
+ export function createCheckpoint(opts) {
74
+ const checkpoint = {
75
+ id: generateCheckpointId(),
76
+ name: opts.name,
77
+ createdAt: new Date().toISOString(),
78
+ sessionId: opts.sessionId,
79
+ provider: opts.provider,
80
+ model: opts.model,
81
+ messages: opts.messages,
82
+ filesTouched: opts.filesTouched,
83
+ gitHead: readGitHead(opts.workspaceRoot),
84
+ };
85
+ const dir = getCheckpointsDir(opts.workspaceRoot);
86
+ writeFileSync(join(dir, `${checkpoint.id}.json`), JSON.stringify(checkpoint, null, 2));
87
+ return checkpoint;
88
+ }
89
+ /**
90
+ * Load a single checkpoint by id. Returns null if not found or unreadable.
91
+ */
92
+ export function loadCheckpoint(workspaceRoot, id) {
93
+ // Defensive: reject ids that look like path traversal attempts. Real ids are
94
+ // `ck-YYYY-MM-DD-<hex>` so the regex is tight enough to catch typos too.
95
+ if (!/^ck-\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/.test(id))
96
+ return null;
97
+ const file = join(getCheckpointsDir(workspaceRoot), `${id}.json`);
98
+ if (!existsSync(file))
99
+ return null;
100
+ try {
101
+ return JSON.parse(readFileSync(file, 'utf-8'));
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ /**
108
+ * List checkpoints in the workspace, newest first. Returns metadata only —
109
+ * the full `messages` array is not loaded so this is cheap for `/checkpoints`.
110
+ */
111
+ export function listCheckpoints(workspaceRoot) {
112
+ const dir = getCheckpointsDir(workspaceRoot);
113
+ let files;
114
+ try {
115
+ files = readdirSync(dir).filter(f => f.endsWith('.json'));
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ const metas = [];
121
+ for (const file of files) {
122
+ try {
123
+ const full = join(dir, file);
124
+ const stat = statSync(full);
125
+ const cp = JSON.parse(readFileSync(full, 'utf-8'));
126
+ metas.push({
127
+ id: cp.id,
128
+ name: cp.name,
129
+ createdAt: cp.createdAt || stat.mtime.toISOString(),
130
+ sessionId: cp.sessionId,
131
+ messageCount: cp.messages?.length ?? 0,
132
+ filesTouchedCount: cp.filesTouched?.length ?? 0,
133
+ gitHead: cp.gitHead,
134
+ });
135
+ }
136
+ catch {
137
+ // Skip corrupt entries — don't let one bad checkpoint block the list.
138
+ }
139
+ }
140
+ // Newest first by createdAt.
141
+ metas.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
142
+ return metas;
143
+ }
144
+ /**
145
+ * Delete a checkpoint by id. Returns true if a file was removed.
146
+ */
147
+ export function deleteCheckpoint(workspaceRoot, id) {
148
+ if (!/^ck-\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/.test(id))
149
+ return false;
150
+ const file = join(getCheckpointsDir(workspaceRoot), `${id}.json`);
151
+ if (!existsSync(file))
152
+ return false;
153
+ try {
154
+ unlinkSync(file);
155
+ return true;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ /**
162
+ * Format a checkpoint list as a Markdown block for `/checkpoints` output.
163
+ */
164
+ export function formatCheckpointList(metas) {
165
+ if (metas.length === 0) {
166
+ return [
167
+ '_No checkpoints yet._',
168
+ '',
169
+ 'Create one with `/checkpoint [name]` before risky refactors so you can `/rewind` if things go sideways.',
170
+ ].join('\n');
171
+ }
172
+ const lines = ['## Checkpoints', ''];
173
+ for (const m of metas) {
174
+ const label = m.name ? `**${m.name}** (\`${m.id}\`)` : `\`${m.id}\``;
175
+ const gitFragment = m.gitHead ? ` · git \`${m.gitHead}\`` : '';
176
+ const date = m.createdAt.slice(0, 19).replace('T', ' ');
177
+ lines.push(`- ${label}`, ` ${date} · ${m.messageCount} message${m.messageCount === 1 ? '' : 's'} · ${m.filesTouchedCount} file${m.filesTouchedCount === 1 ? '' : 's'} touched${gitFragment}`);
178
+ }
179
+ lines.push('', 'Use `/rewind <id>` to restore a checkpoint, or `/checkpoint delete <id>` to drop one.');
180
+ return lines.join('\n');
181
+ }
182
+ /**
183
+ * Build the user-facing hint shown after a successful /rewind. Tells the
184
+ * user how to bring their files back to checkpoint state — checkpoints
185
+ * don't snapshot file content, so this is the only restore path.
186
+ */
187
+ export function buildRewindGitHint(cp) {
188
+ if (cp.gitHead) {
189
+ return [
190
+ `**Files were NOT restored** — only the conversation. To bring files back to the state at checkpoint time:`,
191
+ '',
192
+ '```bash',
193
+ `git stash # save current uncommitted changes if you want to keep them`,
194
+ `git checkout ${cp.gitHead} -- ${cp.filesTouched.length > 0 ? cp.filesTouched.map(f => `'${f}'`).join(' ') : '.'}`,
195
+ '```',
196
+ ].join('\n');
197
+ }
198
+ return [
199
+ '**Files were NOT restored** — only the conversation.',
200
+ cp.filesTouched.length > 0
201
+ ? `Files the agent touched between checkpoint and now: ${cp.filesTouched.map(f => `\`${f}\``).join(', ')}.`
202
+ : '',
203
+ 'Use `git` (or your editor\'s undo) to revert any file changes you don\'t want.',
204
+ ].filter(Boolean).join('\n');
205
+ }
@@ -27,6 +27,30 @@ export declare function clearContext(projectPath: string): boolean;
27
27
  * Get all saved contexts
28
28
  */
29
29
  export declare function getAllContexts(): ConversationContext[];
30
+ /**
31
+ * AI-powered compaction of a conversation history.
32
+ *
33
+ * Used by the `/compact` slash command. Sends the older portion of the
34
+ * conversation to the active provider with a summarization prompt, then
35
+ * replaces those messages with a single system message containing the
36
+ * summary. Keeps the last `keepRecent` messages verbatim so the
37
+ * conversation can continue without losing the most recent context.
38
+ *
39
+ * Returns the same `history` (untouched) if there isn't enough to
40
+ * meaningfully compact.
41
+ */
42
+ export declare function compactHistory(history: Message[], options?: {
43
+ keepRecent?: number;
44
+ projectContext?: import('./project').ProjectContext | null;
45
+ /** Cap on how long the summarization API call can take, in ms. Defaults to 60s. */
46
+ timeoutMs?: number;
47
+ /** External abort signal (e.g. user pressed /stop). Combined with the timeout. */
48
+ abortSignal?: AbortSignal;
49
+ }): Promise<{
50
+ compacted: Message[];
51
+ replaced: number;
52
+ summary: string;
53
+ }>;
30
54
  /**
31
55
  * Summarize messages for context persistence
32
56
  * Keeps recent messages and summarizes older ones
@@ -113,6 +113,63 @@ export function getAllContexts() {
113
113
  return [];
114
114
  }
115
115
  }
116
+ /**
117
+ * AI-powered compaction of a conversation history.
118
+ *
119
+ * Used by the `/compact` slash command. Sends the older portion of the
120
+ * conversation to the active provider with a summarization prompt, then
121
+ * replaces those messages with a single system message containing the
122
+ * summary. Keeps the last `keepRecent` messages verbatim so the
123
+ * conversation can continue without losing the most recent context.
124
+ *
125
+ * Returns the same `history` (untouched) if there isn't enough to
126
+ * meaningfully compact.
127
+ */
128
+ export async function compactHistory(history, options = {}) {
129
+ const keepRecent = options.keepRecent ?? 4;
130
+ const timeoutMs = options.timeoutMs ?? 60_000;
131
+ // Need at least one full exchange to compact (user + assistant) plus the
132
+ // recent tail — otherwise compaction is just overhead.
133
+ if (history.length <= keepRecent + 2) {
134
+ return { compacted: history, replaced: 0, summary: '' };
135
+ }
136
+ const toCompact = history.slice(0, history.length - keepRecent);
137
+ const recent = history.slice(history.length - keepRecent);
138
+ const transcript = toCompact
139
+ .map(m => `[${m.role.toUpperCase()}]\n${m.content}`)
140
+ .join('\n\n---\n\n');
141
+ const prompt = 'Summarize the following conversation between a user and a coding assistant.' +
142
+ ' Capture concisely: (1) what the user was trying to accomplish, (2) key decisions and rationale,' +
143
+ ' (3) files or components touched, (4) outstanding questions or unfinished work.' +
144
+ ' The summary will replace these messages in the agent\'s context, so include anything needed' +
145
+ ' to continue the work without re-reading the originals.\n\nConversation:\n\n' +
146
+ transcript;
147
+ // Cap how long we'll wait. /compact otherwise blocks the whole session
148
+ // with no way to interrupt (the regular /stop targets the agent loop,
149
+ // not arbitrary chat calls). If the user passed their own AbortSignal,
150
+ // combine it with our timer so either can cancel.
151
+ const timeoutController = new AbortController();
152
+ const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
153
+ const onExternalAbort = () => timeoutController.abort();
154
+ options.abortSignal?.addEventListener('abort', onExternalAbort);
155
+ try {
156
+ const { chat } = await import('../api/index.js');
157
+ const summary = await chat(prompt, [], undefined, undefined, options.projectContext ?? undefined, timeoutController.signal);
158
+ const summaryMessage = {
159
+ role: 'system',
160
+ content: `[Conversation compacted — ${toCompact.length} earlier message${toCompact.length === 1 ? '' : 's'} summarized below]\n\n${summary}`,
161
+ };
162
+ return {
163
+ compacted: [summaryMessage, ...recent],
164
+ replaced: toCompact.length,
165
+ summary,
166
+ };
167
+ }
168
+ finally {
169
+ clearTimeout(timer);
170
+ options.abortSignal?.removeEventListener('abort', onExternalAbort);
171
+ }
172
+ }
116
173
  /**
117
174
  * Summarize messages for context persistence
118
175
  * Keeps recent messages and summarizes older ones
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Custom slash commands.
3
+ *
4
+ * Users drop `.md` files in either:
5
+ * - `<workspace>/.codeep/commands/<name>.md` (project-scoped)
6
+ * - `~/.codeep/commands/<name>.md` (global, all projects)
7
+ *
8
+ * Each file becomes a `/<name>` slash command. Project files take precedence
9
+ * over global files with the same name.
10
+ *
11
+ * File format (frontmatter optional):
12
+ *
13
+ * ---
14
+ * description: Detailed security review of a file
15
+ * aliases: [sec, secrev]
16
+ * ---
17
+ *
18
+ * Please perform a thorough security review of: {{args}}
19
+ *
20
+ * The body is expanded with the user's arguments and sent as a user message
21
+ * to the agent. Placeholders supported:
22
+ * - `{{args}}` and `$ARGUMENTS` — full args string (Claude Code compat)
23
+ * - `{{arg1}}` … `{{argN}}` — positional args (1-indexed)
24
+ *
25
+ * Custom commands are not invoked automatically — they only fire when the
26
+ * user types `/<name>`. The agent never sees them in its tool catalog.
27
+ */
28
+ export interface CustomCommand {
29
+ /** Slash name without the leading `/` */
30
+ name: string;
31
+ /** One-line description for /help and ACP autocomplete */
32
+ description: string;
33
+ /** Body text after frontmatter, with placeholders unresolved */
34
+ body: string;
35
+ /** Filesystem path the command was loaded from */
36
+ source: string;
37
+ /** 'project' if loaded from <workspace>/.codeep/commands, else 'global' */
38
+ scope: 'project' | 'global';
39
+ /** Optional alternative names that also trigger this command */
40
+ aliases: string[];
41
+ }
42
+ /**
43
+ * Load all custom commands available in this workspace.
44
+ * Project commands shadow global commands with the same name.
45
+ */
46
+ export declare function loadCustomCommands(workspaceRoot?: string): CustomCommand[];
47
+ /**
48
+ * Resolve a user-typed slash name to a custom command, checking primary
49
+ * name first then aliases. Project scope wins over global on collisions
50
+ * (handled inside loadCustomCommands).
51
+ */
52
+ export declare function findCustomCommand(name: string, workspaceRoot?: string): CustomCommand | null;
53
+ /**
54
+ * Expand `{{args}}`, `$ARGUMENTS`, and `{{argN}}` placeholders in the
55
+ * command body. If the body has no placeholders, the args are appended
56
+ * on a new line so the agent still sees them.
57
+ */
58
+ export declare function expandCommand(cmd: CustomCommand, args: string[]): string;
59
+ /**
60
+ * Render the catalog as a Markdown block for `/commands`.
61
+ */
62
+ export declare function formatCommandList(commands: CustomCommand[]): string;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Custom slash commands.
3
+ *
4
+ * Users drop `.md` files in either:
5
+ * - `<workspace>/.codeep/commands/<name>.md` (project-scoped)
6
+ * - `~/.codeep/commands/<name>.md` (global, all projects)
7
+ *
8
+ * Each file becomes a `/<name>` slash command. Project files take precedence
9
+ * over global files with the same name.
10
+ *
11
+ * File format (frontmatter optional):
12
+ *
13
+ * ---
14
+ * description: Detailed security review of a file
15
+ * aliases: [sec, secrev]
16
+ * ---
17
+ *
18
+ * Please perform a thorough security review of: {{args}}
19
+ *
20
+ * The body is expanded with the user's arguments and sent as a user message
21
+ * to the agent. Placeholders supported:
22
+ * - `{{args}}` and `$ARGUMENTS` — full args string (Claude Code compat)
23
+ * - `{{arg1}}` … `{{argN}}` — positional args (1-indexed)
24
+ *
25
+ * Custom commands are not invoked automatically — they only fire when the
26
+ * user types `/<name>`. The agent never sees them in its tool catalog.
27
+ */
28
+ import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
29
+ import { join } from 'path';
30
+ import { homedir } from 'os';
31
+ /**
32
+ * Minimal YAML frontmatter parser — handles `key: value` and
33
+ * `key: [a, b, c]`. We deliberately avoid a YAML dependency because the
34
+ * surface area we care about is tiny.
35
+ */
36
+ function parseFrontmatter(raw) {
37
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
38
+ if (!match)
39
+ return { meta: {}, body: raw };
40
+ const meta = {};
41
+ for (const line of match[1].split(/\r?\n/)) {
42
+ const kv = line.match(/^\s*([a-zA-Z_][\w-]*)\s*:\s*(.*?)\s*$/);
43
+ if (!kv)
44
+ continue;
45
+ const key = kv[1];
46
+ let value = kv[2];
47
+ const arr = value.match(/^\[(.*)\]$/);
48
+ if (arr) {
49
+ value = arr[1]
50
+ .split(',')
51
+ .map(s => s.trim().replace(/^["']|["']$/g, ''))
52
+ .filter(Boolean);
53
+ }
54
+ else {
55
+ value = value.replace(/^["']|["']$/g, '');
56
+ }
57
+ if (key === 'description' && typeof value === 'string')
58
+ meta.description = value;
59
+ if (key === 'aliases' && Array.isArray(value))
60
+ meta.aliases = value;
61
+ }
62
+ return { meta, body: match[2].trimStart() };
63
+ }
64
+ function loadFromDir(dir, scope) {
65
+ if (!existsSync(dir))
66
+ return [];
67
+ let entries;
68
+ try {
69
+ entries = readdirSync(dir);
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ const commands = [];
75
+ for (const entry of entries) {
76
+ if (!entry.endsWith('.md'))
77
+ continue;
78
+ const fullPath = join(dir, entry);
79
+ try {
80
+ const stat = statSync(fullPath);
81
+ if (!stat.isFile())
82
+ continue;
83
+ // Sanity ceiling — a slash-command template above 64KB is almost
84
+ // certainly someone dumping a doc in the wrong folder.
85
+ if (stat.size > 65_536)
86
+ continue;
87
+ const raw = readFileSync(fullPath, 'utf-8');
88
+ const { meta, body } = parseFrontmatter(raw);
89
+ const name = entry.replace(/\.md$/, '').toLowerCase();
90
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name))
91
+ continue; // names match `/foo-bar`
92
+ commands.push({
93
+ name,
94
+ description: meta.description ?? `Custom ${scope} command`,
95
+ body: body.trim(),
96
+ source: fullPath,
97
+ scope,
98
+ aliases: (meta.aliases ?? []).map(a => a.toLowerCase()).filter(a => /^[a-z0-9][a-z0-9-]*$/.test(a)),
99
+ });
100
+ }
101
+ catch {
102
+ // Skip unreadable / malformed files silently — they shouldn't block
103
+ // the rest of the catalog from loading.
104
+ }
105
+ }
106
+ return commands;
107
+ }
108
+ /**
109
+ * Load all custom commands available in this workspace.
110
+ * Project commands shadow global commands with the same name.
111
+ */
112
+ export function loadCustomCommands(workspaceRoot) {
113
+ const global = loadFromDir(join(homedir(), '.codeep', 'commands'), 'global');
114
+ const project = workspaceRoot
115
+ ? loadFromDir(join(workspaceRoot, '.codeep', 'commands'), 'project')
116
+ : [];
117
+ // Project wins on name collisions; collapse to a single map keyed by name.
118
+ const byName = new Map();
119
+ for (const cmd of global)
120
+ byName.set(cmd.name, cmd);
121
+ for (const cmd of project)
122
+ byName.set(cmd.name, cmd);
123
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
124
+ }
125
+ /**
126
+ * Resolve a user-typed slash name to a custom command, checking primary
127
+ * name first then aliases. Project scope wins over global on collisions
128
+ * (handled inside loadCustomCommands).
129
+ */
130
+ export function findCustomCommand(name, workspaceRoot) {
131
+ const lower = name.toLowerCase();
132
+ const all = loadCustomCommands(workspaceRoot);
133
+ return all.find(c => c.name === lower || c.aliases.includes(lower)) ?? null;
134
+ }
135
+ /**
136
+ * Expand `{{args}}`, `$ARGUMENTS`, and `{{argN}}` placeholders in the
137
+ * command body. If the body has no placeholders, the args are appended
138
+ * on a new line so the agent still sees them.
139
+ */
140
+ export function expandCommand(cmd, args) {
141
+ const joined = args.join(' ');
142
+ const hasPlaceholder = /\{\{args\}\}|\$ARGUMENTS|\{\{arg\d+\}\}/.test(cmd.body);
143
+ let expanded = cmd.body
144
+ .replace(/\{\{args\}\}/g, joined)
145
+ .replace(/\$ARGUMENTS/g, joined);
146
+ // Positional: {{arg1}}, {{arg2}}, ...
147
+ expanded = expanded.replace(/\{\{arg(\d+)\}\}/g, (_m, n) => {
148
+ const idx = parseInt(n, 10) - 1;
149
+ return args[idx] ?? '';
150
+ });
151
+ if (!hasPlaceholder && joined) {
152
+ expanded += `\n\n${joined}`;
153
+ }
154
+ return expanded;
155
+ }
156
+ /**
157
+ * Render the catalog as a Markdown block for `/commands`.
158
+ */
159
+ export function formatCommandList(commands) {
160
+ if (!commands.length) {
161
+ return [
162
+ '_No custom commands yet._',
163
+ '',
164
+ 'Create one by adding a Markdown file:',
165
+ '',
166
+ '- `<workspace>/.codeep/commands/<name>.md` — project-scoped',
167
+ '- `~/.codeep/commands/<name>.md` — global (all projects)',
168
+ '',
169
+ 'Example:',
170
+ '',
171
+ '```markdown',
172
+ '---',
173
+ 'description: Detailed security review of a file',
174
+ '---',
175
+ '',
176
+ 'Please perform a thorough security review of: {{args}}',
177
+ '```',
178
+ '',
179
+ 'Then call it as `/<name> <args>`.',
180
+ ].join('\n');
181
+ }
182
+ const projectCmds = commands.filter(c => c.scope === 'project');
183
+ const globalCmds = commands.filter(c => c.scope === 'global');
184
+ const lines = ['## Custom Commands', ''];
185
+ if (projectCmds.length) {
186
+ lines.push('**Project**');
187
+ for (const c of projectCmds) {
188
+ const aliasNote = c.aliases.length ? ` (aliases: ${c.aliases.map(a => `\`/${a}\``).join(', ')})` : '';
189
+ lines.push(`- \`/${c.name}\`${aliasNote} — ${c.description}`);
190
+ }
191
+ lines.push('');
192
+ }
193
+ if (globalCmds.length) {
194
+ lines.push('**Global**');
195
+ for (const c of globalCmds) {
196
+ const aliasNote = c.aliases.length ? ` (aliases: ${c.aliases.map(a => `\`/${a}\``).join(', ')})` : '';
197
+ lines.push(`- \`/${c.name}\`${aliasNote} — ${c.description}`);
198
+ }
199
+ }
200
+ return lines.join('\n');
201
+ }