codeep 1.3.42 → 2.0.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 (59) 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 +96 -4
  13. package/dist/renderer/agentExecution.js +5 -0
  14. package/dist/renderer/commands.js +348 -2
  15. package/dist/renderer/components/Login.d.ts +1 -0
  16. package/dist/renderer/components/Login.js +24 -9
  17. package/dist/renderer/handlers.d.ts +11 -1
  18. package/dist/renderer/handlers.js +30 -0
  19. package/dist/renderer/main.js +73 -0
  20. package/dist/utils/agent.d.ts +17 -0
  21. package/dist/utils/agent.js +91 -7
  22. package/dist/utils/agentChat.d.ts +10 -2
  23. package/dist/utils/agentChat.js +48 -9
  24. package/dist/utils/agentStream.js +6 -2
  25. package/dist/utils/checkpoints.d.ts +93 -0
  26. package/dist/utils/checkpoints.js +205 -0
  27. package/dist/utils/context.d.ts +24 -0
  28. package/dist/utils/context.js +57 -0
  29. package/dist/utils/customCommands.d.ts +62 -0
  30. package/dist/utils/customCommands.js +201 -0
  31. package/dist/utils/hooks.d.ts +97 -0
  32. package/dist/utils/hooks.js +223 -0
  33. package/dist/utils/mcpClient.d.ts +229 -0
  34. package/dist/utils/mcpClient.js +497 -0
  35. package/dist/utils/mcpConfig.d.ts +55 -0
  36. package/dist/utils/mcpConfig.js +177 -0
  37. package/dist/utils/mcpMarketplace.d.ts +49 -0
  38. package/dist/utils/mcpMarketplace.js +175 -0
  39. package/dist/utils/mcpRegistry.d.ts +129 -0
  40. package/dist/utils/mcpRegistry.js +427 -0
  41. package/dist/utils/mcpSamplingBridge.d.ts +32 -0
  42. package/dist/utils/mcpSamplingBridge.js +88 -0
  43. package/dist/utils/mcpStreamableHttp.d.ts +65 -0
  44. package/dist/utils/mcpStreamableHttp.js +207 -0
  45. package/dist/utils/openrouterPrefs.d.ts +36 -0
  46. package/dist/utils/openrouterPrefs.js +83 -0
  47. package/dist/utils/skillBundles.d.ts +84 -0
  48. package/dist/utils/skillBundles.js +257 -0
  49. package/dist/utils/skillBundlesCloud.d.ts +66 -0
  50. package/dist/utils/skillBundlesCloud.js +196 -0
  51. package/dist/utils/tokenTracker.d.ts +14 -2
  52. package/dist/utils/tokenTracker.js +59 -41
  53. package/dist/utils/toolExecution.d.ts +17 -1
  54. package/dist/utils/toolExecution.js +184 -6
  55. package/dist/utils/tools.d.ts +22 -6
  56. package/dist/utils/tools.js +83 -8
  57. package/package.json +3 -2
  58. package/bin/codeep-macos-arm64 +0 -0
  59. package/bin/codeep-macos-x64 +0 -0
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Project-local lifecycle hooks.
3
+ *
4
+ * User drops executable shell scripts in `.codeep/hooks/<event>.sh` and Codeep
5
+ * runs them at the relevant moment during a session. Generalises what the
6
+ * `agentAutoCommit` and `agentAutoVerify` config flags do — anything those
7
+ * can do, a `post_edit` hook can do too, without a code change in the CLI.
8
+ *
9
+ * Supported events:
10
+ *
11
+ * `pre_tool_call` — fires before every tool execution. NON-ZERO exit
12
+ * BLOCKS the tool call (the agent gets a clear error
13
+ * message). Use for policy enforcement: block writes
14
+ * to certain paths, refuse risky commands, etc.
15
+ *
16
+ * `post_edit` — fires after a successful write_file or edit_file.
17
+ * Exit code is ignored (auto-format / auto-lint:
18
+ * failure shouldn't block the agent). Receives the
19
+ * file path. Use for prettier/eslint/gofmt.
20
+ *
21
+ * `on_error` — fires when a tool call returns success=false.
22
+ * Exit code ignored. Use for centralised logging
23
+ * or alerting.
24
+ *
25
+ * `pre_commit` — fires before the /commit skill stages anything.
26
+ * NON-ZERO exit BLOCKS the commit. Use for test
27
+ * runs, lint gates, etc.
28
+ *
29
+ * Environment variables every hook receives:
30
+ * CODEEP_HOOK_EVENT - one of the event names above
31
+ * CODEEP_WORKSPACE - workspace root absolute path
32
+ * CODEEP_SESSION_ID - current Codeep session id
33
+ *
34
+ * Event-specific extras:
35
+ * pre_tool_call / on_error:
36
+ * CODEEP_HOOK_TOOL - tool name (read_file, write_file, …)
37
+ * CODEEP_HOOK_PARAMS - JSON-encoded tool parameters
38
+ * post_edit:
39
+ * CODEEP_HOOK_TOOL - 'write_file' | 'edit_file'
40
+ * CODEEP_HOOK_FILE - absolute path of the file that was edited
41
+ *
42
+ * Security note: hooks run arbitrary shell. They are project-scoped — a
43
+ * cloned repo with hostile hooks would execute its scripts on the user's
44
+ * machine the first time they trigger an agent tool call. The welcome
45
+ * banner warns when hooks exist (see `summarizeHooks`); we do not run
46
+ * hooks from `~/.codeep/hooks/` (global) for that reason.
47
+ */
48
+ export type HookEvent = 'pre_tool_call' | 'post_edit' | 'on_error' | 'pre_commit';
49
+ export declare const HOOK_EVENTS: readonly HookEvent[];
50
+ export interface HookContext {
51
+ event: HookEvent;
52
+ workspaceRoot: string;
53
+ sessionId?: string;
54
+ /** For pre_tool_call / on_error / post_edit */
55
+ toolName?: string;
56
+ /** For pre_tool_call / on_error */
57
+ toolParams?: Record<string, unknown>;
58
+ /** For post_edit */
59
+ filePath?: string;
60
+ }
61
+ export interface HookResult {
62
+ /** True if a hook script existed and was actually invoked. */
63
+ executed: boolean;
64
+ /** Exit code, or 0 if no hook was present. */
65
+ exitCode: number;
66
+ stdout: string;
67
+ stderr: string;
68
+ /** True if this hook event blocks downstream work AND the hook failed. */
69
+ blocked: boolean;
70
+ /** Path that was executed (useful for error messages). */
71
+ scriptPath?: string;
72
+ }
73
+ /**
74
+ * Execute the configured hook for an event, if any. Returns `executed: false`
75
+ * if no script exists. Caller is responsible for checking `blocked` and
76
+ * aborting the parent action when it's true.
77
+ */
78
+ export declare function runHook(ctx: HookContext, opts?: {
79
+ timeoutMs?: number;
80
+ }): HookResult;
81
+ /**
82
+ * Inspect a workspace and return which hook scripts are installed.
83
+ * Used by the welcome banner and `/hooks` slash command.
84
+ */
85
+ export declare function listInstalledHooks(workspaceRoot: string): {
86
+ event: HookEvent;
87
+ scriptPath: string;
88
+ }[];
89
+ /**
90
+ * Render an installed-hook list as Markdown for `/hooks` output.
91
+ */
92
+ export declare function formatHookList(hooks: ReturnType<typeof listInstalledHooks>): string;
93
+ /**
94
+ * Short one-line summary used in the welcome banner when hooks are present.
95
+ * Returns empty string if no hooks installed.
96
+ */
97
+ export declare function summarizeHooks(workspaceRoot: string): string;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Project-local lifecycle hooks.
3
+ *
4
+ * User drops executable shell scripts in `.codeep/hooks/<event>.sh` and Codeep
5
+ * runs them at the relevant moment during a session. Generalises what the
6
+ * `agentAutoCommit` and `agentAutoVerify` config flags do — anything those
7
+ * can do, a `post_edit` hook can do too, without a code change in the CLI.
8
+ *
9
+ * Supported events:
10
+ *
11
+ * `pre_tool_call` — fires before every tool execution. NON-ZERO exit
12
+ * BLOCKS the tool call (the agent gets a clear error
13
+ * message). Use for policy enforcement: block writes
14
+ * to certain paths, refuse risky commands, etc.
15
+ *
16
+ * `post_edit` — fires after a successful write_file or edit_file.
17
+ * Exit code is ignored (auto-format / auto-lint:
18
+ * failure shouldn't block the agent). Receives the
19
+ * file path. Use for prettier/eslint/gofmt.
20
+ *
21
+ * `on_error` — fires when a tool call returns success=false.
22
+ * Exit code ignored. Use for centralised logging
23
+ * or alerting.
24
+ *
25
+ * `pre_commit` — fires before the /commit skill stages anything.
26
+ * NON-ZERO exit BLOCKS the commit. Use for test
27
+ * runs, lint gates, etc.
28
+ *
29
+ * Environment variables every hook receives:
30
+ * CODEEP_HOOK_EVENT - one of the event names above
31
+ * CODEEP_WORKSPACE - workspace root absolute path
32
+ * CODEEP_SESSION_ID - current Codeep session id
33
+ *
34
+ * Event-specific extras:
35
+ * pre_tool_call / on_error:
36
+ * CODEEP_HOOK_TOOL - tool name (read_file, write_file, …)
37
+ * CODEEP_HOOK_PARAMS - JSON-encoded tool parameters
38
+ * post_edit:
39
+ * CODEEP_HOOK_TOOL - 'write_file' | 'edit_file'
40
+ * CODEEP_HOOK_FILE - absolute path of the file that was edited
41
+ *
42
+ * Security note: hooks run arbitrary shell. They are project-scoped — a
43
+ * cloned repo with hostile hooks would execute its scripts on the user's
44
+ * machine the first time they trigger an agent tool call. The welcome
45
+ * banner warns when hooks exist (see `summarizeHooks`); we do not run
46
+ * hooks from `~/.codeep/hooks/` (global) for that reason.
47
+ */
48
+ import { existsSync, readdirSync, statSync, accessSync, constants } from 'fs';
49
+ import { join } from 'path';
50
+ import { spawnSync } from 'child_process';
51
+ export const HOOK_EVENTS = ['pre_tool_call', 'post_edit', 'on_error', 'pre_commit'];
52
+ /** Events whose non-zero exit aborts the action that triggered them. */
53
+ const BLOCKING_EVENTS = new Set(['pre_tool_call', 'pre_commit']);
54
+ const NOT_EXECUTED = { executed: false, exitCode: 0, stdout: '', stderr: '', blocked: false };
55
+ function getHooksDir(workspaceRoot) {
56
+ return join(workspaceRoot, '.codeep', 'hooks');
57
+ }
58
+ function findHookScript(workspaceRoot, event) {
59
+ const dir = getHooksDir(workspaceRoot);
60
+ if (!existsSync(dir))
61
+ return null;
62
+ // Allow `.sh` and bare (no-extension) executable; users sometimes prefer
63
+ // `.codeep/hooks/post_edit` over `post_edit.sh`. Try both.
64
+ for (const name of [`${event}.sh`, event]) {
65
+ const full = join(dir, name);
66
+ if (!existsSync(full))
67
+ continue;
68
+ try {
69
+ const stat = statSync(full);
70
+ if (!stat.isFile())
71
+ continue;
72
+ // Must be executable by the current user — otherwise we'd surprise
73
+ // them with a hook that silently does nothing.
74
+ accessSync(full, constants.X_OK);
75
+ return full;
76
+ }
77
+ catch {
78
+ // Not executable or unreadable — try next candidate.
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+ /**
84
+ * Execute the configured hook for an event, if any. Returns `executed: false`
85
+ * if no script exists. Caller is responsible for checking `blocked` and
86
+ * aborting the parent action when it's true.
87
+ */
88
+ export function runHook(ctx, opts = {}) {
89
+ const script = findHookScript(ctx.workspaceRoot, ctx.event);
90
+ if (!script)
91
+ return NOT_EXECUTED;
92
+ const env = {
93
+ ...process.env,
94
+ CODEEP_HOOK_EVENT: ctx.event,
95
+ CODEEP_WORKSPACE: ctx.workspaceRoot,
96
+ };
97
+ if (ctx.sessionId)
98
+ env.CODEEP_SESSION_ID = ctx.sessionId;
99
+ if (ctx.toolName)
100
+ env.CODEEP_HOOK_TOOL = ctx.toolName;
101
+ if (ctx.toolParams) {
102
+ // Cap env var size — most shells choke at ~128KB total env, and a
103
+ // `write_file` with a 100KB content field would single-handedly blow
104
+ // past that. We truncate the serialised JSON instead and append a
105
+ // marker so hook scripts can detect overflow if they care.
106
+ const PARAMS_CAP = 8192;
107
+ let serialized = JSON.stringify(ctx.toolParams);
108
+ if (serialized.length > PARAMS_CAP) {
109
+ serialized = serialized.slice(0, PARAMS_CAP) + '...[truncated]';
110
+ }
111
+ env.CODEEP_HOOK_PARAMS = serialized;
112
+ }
113
+ if (ctx.filePath)
114
+ env.CODEEP_HOOK_FILE = ctx.filePath;
115
+ // 30s ceiling so a runaway lint / test command can't wedge the agent
116
+ // loop. Configurable per-call so tests can use a tight timeout.
117
+ const timeout = opts.timeoutMs ?? 30_000;
118
+ let proc;
119
+ try {
120
+ proc = spawnSync(script, [], {
121
+ cwd: ctx.workspaceRoot,
122
+ env,
123
+ timeout,
124
+ encoding: 'utf-8',
125
+ stdio: ['ignore', 'pipe', 'pipe'],
126
+ });
127
+ }
128
+ catch (err) {
129
+ // Should be rare — spawnSync usually returns a result rather than throwing.
130
+ return {
131
+ executed: true,
132
+ exitCode: 1,
133
+ stdout: '',
134
+ stderr: `hook spawn failed: ${err.message}`,
135
+ blocked: BLOCKING_EVENTS.has(ctx.event),
136
+ scriptPath: script,
137
+ };
138
+ }
139
+ const exitCode = proc.status ?? (proc.error ? 1 : 0);
140
+ const stdout = proc.stdout ?? '';
141
+ const stderr = proc.stderr ?? '';
142
+ const blocked = BLOCKING_EVENTS.has(ctx.event) && exitCode !== 0;
143
+ return { executed: true, exitCode, stdout, stderr, blocked, scriptPath: script };
144
+ }
145
+ /**
146
+ * Inspect a workspace and return which hook scripts are installed.
147
+ * Used by the welcome banner and `/hooks` slash command.
148
+ */
149
+ export function listInstalledHooks(workspaceRoot) {
150
+ const dir = getHooksDir(workspaceRoot);
151
+ if (!existsSync(dir))
152
+ return [];
153
+ let entries;
154
+ try {
155
+ entries = readdirSync(dir);
156
+ }
157
+ catch {
158
+ return [];
159
+ }
160
+ // Build a set of which events have a script — try both `<event>.sh` and
161
+ // bare-name forms, mirroring findHookScript.
162
+ const seen = new Set();
163
+ const out = [];
164
+ for (const event of HOOK_EVENTS) {
165
+ for (const candidate of [`${event}.sh`, event]) {
166
+ if (!entries.includes(candidate))
167
+ continue;
168
+ const full = join(dir, candidate);
169
+ try {
170
+ if (!statSync(full).isFile())
171
+ continue;
172
+ accessSync(full, constants.X_OK);
173
+ if (seen.has(event))
174
+ break;
175
+ seen.add(event);
176
+ out.push({ event, scriptPath: full });
177
+ break;
178
+ }
179
+ catch {
180
+ // Skip unreadable / non-executable.
181
+ }
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+ /**
187
+ * Render an installed-hook list as Markdown for `/hooks` output.
188
+ */
189
+ export function formatHookList(hooks) {
190
+ if (hooks.length === 0) {
191
+ return [
192
+ '_No hooks installed in this workspace._',
193
+ '',
194
+ 'Drop an executable script in `.codeep/hooks/<event>.sh` to enable one. Supported events:',
195
+ '',
196
+ ...HOOK_EVENTS.map(e => `- \`${e}\`${BLOCKING_EVENTS.has(e) ? ' *(non-zero exit blocks the action)*' : ''}`),
197
+ '',
198
+ 'Example — auto-format on save:',
199
+ '',
200
+ '```bash',
201
+ '#!/bin/bash',
202
+ '# .codeep/hooks/post_edit.sh',
203
+ 'prettier --write "$CODEEP_HOOK_FILE" 2>/dev/null',
204
+ '```',
205
+ ].join('\n');
206
+ }
207
+ const lines = ['## Installed hooks', ''];
208
+ for (const h of hooks) {
209
+ const tag = BLOCKING_EVENTS.has(h.event) ? ' *(blocking)*' : '';
210
+ lines.push(`- \`${h.event}\`${tag} — \`${h.scriptPath}\``);
211
+ }
212
+ return lines.join('\n');
213
+ }
214
+ /**
215
+ * Short one-line summary used in the welcome banner when hooks are present.
216
+ * Returns empty string if no hooks installed.
217
+ */
218
+ export function summarizeHooks(workspaceRoot) {
219
+ const hooks = listInstalledHooks(workspaceRoot);
220
+ if (hooks.length === 0)
221
+ return '';
222
+ return `${hooks.length} hook${hooks.length === 1 ? '' : 's'} active (${hooks.map(h => h.event).join(', ')})`;
223
+ }