codeep 1.3.41 → 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.
- package/README.md +208 -0
- package/dist/acp/commands.js +770 -7
- package/dist/acp/protocol.d.ts +11 -2
- package/dist/acp/server.js +179 -11
- package/dist/acp/session.d.ts +3 -0
- package/dist/acp/session.js +5 -0
- package/dist/api/index.js +39 -6
- package/dist/config/index.d.ts +13 -0
- package/dist/config/index.js +46 -1
- package/dist/config/providers.js +76 -1
- package/dist/renderer/App.d.ts +12 -0
- package/dist/renderer/App.js +96 -4
- package/dist/renderer/agentExecution.js +5 -0
- package/dist/renderer/commands.js +348 -2
- package/dist/renderer/components/Login.d.ts +1 -0
- package/dist/renderer/components/Login.js +24 -9
- package/dist/renderer/handlers.d.ts +11 -1
- package/dist/renderer/handlers.js +30 -0
- package/dist/renderer/main.js +73 -0
- package/dist/utils/agent.d.ts +17 -0
- package/dist/utils/agent.js +91 -7
- package/dist/utils/agentChat.d.ts +10 -2
- package/dist/utils/agentChat.js +48 -9
- package/dist/utils/agentStream.js +6 -2
- package/dist/utils/checkpoints.d.ts +93 -0
- package/dist/utils/checkpoints.js +205 -0
- package/dist/utils/context.d.ts +24 -0
- package/dist/utils/context.js +57 -0
- package/dist/utils/customCommands.d.ts +62 -0
- package/dist/utils/customCommands.js +201 -0
- package/dist/utils/hooks.d.ts +97 -0
- package/dist/utils/hooks.js +223 -0
- package/dist/utils/mcpClient.d.ts +229 -0
- package/dist/utils/mcpClient.js +497 -0
- package/dist/utils/mcpConfig.d.ts +55 -0
- package/dist/utils/mcpConfig.js +177 -0
- package/dist/utils/mcpMarketplace.d.ts +49 -0
- package/dist/utils/mcpMarketplace.js +175 -0
- package/dist/utils/mcpRegistry.d.ts +129 -0
- package/dist/utils/mcpRegistry.js +427 -0
- package/dist/utils/mcpSamplingBridge.d.ts +32 -0
- package/dist/utils/mcpSamplingBridge.js +88 -0
- package/dist/utils/mcpStreamableHttp.d.ts +65 -0
- package/dist/utils/mcpStreamableHttp.js +207 -0
- package/dist/utils/openrouterPrefs.d.ts +36 -0
- package/dist/utils/openrouterPrefs.js +83 -0
- package/dist/utils/skillBundles.d.ts +84 -0
- package/dist/utils/skillBundles.js +257 -0
- package/dist/utils/skillBundlesCloud.d.ts +66 -0
- package/dist/utils/skillBundlesCloud.js +196 -0
- package/dist/utils/tokenTracker.d.ts +14 -2
- package/dist/utils/tokenTracker.js +59 -45
- package/dist/utils/toolExecution.d.ts +17 -1
- package/dist/utils/toolExecution.js +184 -6
- package/dist/utils/tools.d.ts +22 -6
- package/dist/utils/tools.js +83 -8
- package/package.json +3 -2
- package/bin/codeep-macos-arm64 +0 -0
- package/bin/codeep-macos-x64 +0 -0
package/dist/utils/context.js
CHANGED
|
@@ -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
|
+
}
|