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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Streamable HTTP transport — the spec successor to the original
|
|
3
|
+
* HTTP+SSE transport, used by cloud-hosted MCP servers (Anthropic remote
|
|
4
|
+
* servers, internal HTTP wrappers, etc.).
|
|
5
|
+
*
|
|
6
|
+
* Per the 2025-03 spec, a single URL endpoint accepts both:
|
|
7
|
+
* - POST { jsonrpc, id, method, params } → JSON response, or
|
|
8
|
+
* text/event-stream of one-or-more JSON-RPC frames.
|
|
9
|
+
* - GET → text/event-stream channel
|
|
10
|
+
* for server-initiated notifications and requests (sampling, etc.).
|
|
11
|
+
*
|
|
12
|
+
* This client opens the GET stream lazily on the first message the server
|
|
13
|
+
* tells us to expect — many simple HTTP servers never send anything
|
|
14
|
+
* unsolicited, so we don't burn a TCP connection waiting.
|
|
15
|
+
*
|
|
16
|
+
* Session continuity uses the `mcp-session-id` header. The server sets it
|
|
17
|
+
* on the initialize response; we echo it on every subsequent request.
|
|
18
|
+
*/
|
|
19
|
+
/** Hard cap on a single SSE response stream. Bounds OOM risk from a
|
|
20
|
+
* remote server that pushes unbounded data on either the POST reply or
|
|
21
|
+
* the long-lived GET notification channel. 10 MB is comfortably above
|
|
22
|
+
* any legitimate MCP frame (tools/list, resources/read) and well below
|
|
23
|
+
* default Node heap limits. */
|
|
24
|
+
const MAX_SSE_BYTES = 10 * 1024 * 1024;
|
|
25
|
+
export class StreamableHttpClient {
|
|
26
|
+
opts;
|
|
27
|
+
sessionId = null;
|
|
28
|
+
notificationAbort = null;
|
|
29
|
+
stopped = false;
|
|
30
|
+
/** True after the server has set a session id (i.e. it tracks state). */
|
|
31
|
+
get hasServerSession() {
|
|
32
|
+
return this.sessionId !== null;
|
|
33
|
+
}
|
|
34
|
+
constructor(opts) {
|
|
35
|
+
this.opts = opts;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Issue a JSON-RPC frame as POST. Reply may be a single JSON response
|
|
39
|
+
* (synchronous tools/call), or an SSE stream of one-or-more responses
|
|
40
|
+
* + notifications. The transport invokes `onFrame` for every message
|
|
41
|
+
* it sees on the response, regardless of shape.
|
|
42
|
+
*/
|
|
43
|
+
async send(frame) {
|
|
44
|
+
if (this.stopped)
|
|
45
|
+
throw new Error('StreamableHttpClient.stop() already called');
|
|
46
|
+
const headers = {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
// Accept both shapes so the server can pick. Spec requires both.
|
|
49
|
+
'Accept': 'application/json, text/event-stream',
|
|
50
|
+
...this.opts.headers,
|
|
51
|
+
};
|
|
52
|
+
if (this.sessionId)
|
|
53
|
+
headers['mcp-session-id'] = this.sessionId;
|
|
54
|
+
const res = await fetch(this.opts.url, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers,
|
|
57
|
+
body: JSON.stringify(frame),
|
|
58
|
+
});
|
|
59
|
+
// 202 Accepted = fire-and-forget (notifications); nothing to parse.
|
|
60
|
+
if (res.status === 202)
|
|
61
|
+
return;
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const text = await res.text().catch(() => '');
|
|
64
|
+
throw new Error(`MCP HTTP ${res.status} ${res.statusText}: ${text.slice(0, 300)}`);
|
|
65
|
+
}
|
|
66
|
+
// Server may set the session id on the first response. Capture and
|
|
67
|
+
// open the notification stream if it did (the server now has state
|
|
68
|
+
// and may want to push us notifications/requests).
|
|
69
|
+
const newSession = res.headers.get('mcp-session-id');
|
|
70
|
+
if (newSession && newSession !== this.sessionId) {
|
|
71
|
+
this.sessionId = newSession;
|
|
72
|
+
// Don't await — we want POST to return as soon as the response is
|
|
73
|
+
// parsed; the notification stream lives independently.
|
|
74
|
+
void this.openNotificationStream();
|
|
75
|
+
}
|
|
76
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
77
|
+
if (contentType.includes('text/event-stream')) {
|
|
78
|
+
// Process inline streaming response (multiple frames possible).
|
|
79
|
+
await this.consumeSseBody(res);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Single JSON response.
|
|
83
|
+
const body = await res.json().catch(() => null);
|
|
84
|
+
if (body && typeof body === 'object') {
|
|
85
|
+
this.opts.onFrame(body);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Open the server-push SSE channel. Idempotent — won't open twice if
|
|
91
|
+
* already streaming. Errors are surfaced via `onError`, not thrown, so
|
|
92
|
+
* a transient network blip doesn't crash the agent loop.
|
|
93
|
+
*/
|
|
94
|
+
async openNotificationStream() {
|
|
95
|
+
if (this.notificationAbort || this.stopped)
|
|
96
|
+
return;
|
|
97
|
+
this.notificationAbort = new AbortController();
|
|
98
|
+
const headers = {
|
|
99
|
+
'Accept': 'text/event-stream',
|
|
100
|
+
...this.opts.headers,
|
|
101
|
+
};
|
|
102
|
+
if (this.sessionId)
|
|
103
|
+
headers['mcp-session-id'] = this.sessionId;
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch(this.opts.url, {
|
|
106
|
+
method: 'GET',
|
|
107
|
+
headers,
|
|
108
|
+
signal: this.notificationAbort.signal,
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok || !res.body) {
|
|
111
|
+
// Some servers don't support the GET channel — that's fine, they
|
|
112
|
+
// just won't push anything. Don't escalate to an error.
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
await this.consumeSseBody(res);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.name === 'AbortError')
|
|
119
|
+
return;
|
|
120
|
+
this.opts.onError?.(err);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
this.notificationAbort = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Parse a `text/event-stream` body, invoking `onFrame` for each JSON
|
|
128
|
+
* payload. SSE framing is intentionally permissive — we treat any line
|
|
129
|
+
* starting with `data:` as one event's payload and join multi-line
|
|
130
|
+
* data: blocks until a blank line.
|
|
131
|
+
*
|
|
132
|
+
* Bounded by `MAX_SSE_BYTES`: a misbehaving or malicious remote server
|
|
133
|
+
* can push unbounded data on an SSE channel — without a cap, the
|
|
134
|
+
* accumulating buffer would OOM the agent. We track cumulative bytes
|
|
135
|
+
* read and bail with `onError` past the cap.
|
|
136
|
+
*/
|
|
137
|
+
async consumeSseBody(res) {
|
|
138
|
+
if (!res.body)
|
|
139
|
+
return;
|
|
140
|
+
const reader = res.body.getReader();
|
|
141
|
+
const decoder = new TextDecoder('utf-8');
|
|
142
|
+
let buffer = '';
|
|
143
|
+
let bytesRead = 0;
|
|
144
|
+
try {
|
|
145
|
+
while (true) {
|
|
146
|
+
const { value, done } = await reader.read();
|
|
147
|
+
if (done)
|
|
148
|
+
break;
|
|
149
|
+
bytesRead += value.byteLength;
|
|
150
|
+
if (bytesRead > MAX_SSE_BYTES) {
|
|
151
|
+
// Abort the reader so we don't keep allocating; surface via
|
|
152
|
+
// onError so the registry can mark the server as failed.
|
|
153
|
+
try {
|
|
154
|
+
await reader.cancel();
|
|
155
|
+
}
|
|
156
|
+
catch { /* best-effort */ }
|
|
157
|
+
this.opts.onError?.(new Error(`mcp http: SSE body exceeded ${MAX_SSE_BYTES} bytes; aborting (likely a misbehaving server)`));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
buffer += decoder.decode(value, { stream: true });
|
|
161
|
+
// Split on the SSE event boundary (two consecutive newlines).
|
|
162
|
+
// CRLF and LF both legal — normalise first.
|
|
163
|
+
buffer = buffer.replace(/\r\n/g, '\n');
|
|
164
|
+
let boundary = buffer.indexOf('\n\n');
|
|
165
|
+
while (boundary >= 0) {
|
|
166
|
+
const eventBlock = buffer.slice(0, boundary);
|
|
167
|
+
buffer = buffer.slice(boundary + 2);
|
|
168
|
+
this.dispatchSseEvent(eventBlock);
|
|
169
|
+
boundary = buffer.indexOf('\n\n');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
if (err.name === 'AbortError')
|
|
175
|
+
return;
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
dispatchSseEvent(block) {
|
|
180
|
+
// Each event is a set of `field:value` lines. We only care about the
|
|
181
|
+
// `data:` field — `event:` types are server-defined; the MCP spec
|
|
182
|
+
// doesn't use them for transport framing.
|
|
183
|
+
const dataLines = [];
|
|
184
|
+
for (const line of block.split('\n')) {
|
|
185
|
+
if (line.startsWith('data:'))
|
|
186
|
+
dataLines.push(line.slice(5).trimStart());
|
|
187
|
+
}
|
|
188
|
+
if (dataLines.length === 0)
|
|
189
|
+
return;
|
|
190
|
+
const raw = dataLines.join('\n');
|
|
191
|
+
try {
|
|
192
|
+
const msg = JSON.parse(raw);
|
|
193
|
+
this.opts.onFrame(msg);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Malformed JSON in the stream — skip rather than blow up.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Tear down the notification stream and refuse further sends. */
|
|
200
|
+
async stop() {
|
|
201
|
+
if (this.stopped)
|
|
202
|
+
return;
|
|
203
|
+
this.stopped = true;
|
|
204
|
+
this.notificationAbort?.abort();
|
|
205
|
+
this.notificationAbort = null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter provider-routing preferences.
|
|
3
|
+
*
|
|
4
|
+
* OpenRouter lets the caller bias which upstream provider its router
|
|
5
|
+
* picks for a given model — useful for cost, latency, geography, or
|
|
6
|
+
* privacy reasons. The shape we send in the request body's `provider`
|
|
7
|
+
* field follows the OpenRouter spec
|
|
8
|
+
* (https://openrouter.ai/docs#provider-routing):
|
|
9
|
+
*
|
|
10
|
+
* {
|
|
11
|
+
* "order": ["DeepInfra", "Together"], // try first in order
|
|
12
|
+
* "allow_fallbacks": true, // fall back to others if order fails
|
|
13
|
+
* "ignore": ["OpenAI"], // never use these
|
|
14
|
+
* "data_collection": "deny" | "allow", // privacy gate
|
|
15
|
+
* "require_parameters": true, // strict spec compliance
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* We store the user's preferences in `conf` so they persist across CLI
|
|
19
|
+
* launches. Empty / unset means "let OpenRouter route freely".
|
|
20
|
+
*/
|
|
21
|
+
export interface OpenRouterPreferences {
|
|
22
|
+
order?: string[];
|
|
23
|
+
allow_fallbacks?: boolean;
|
|
24
|
+
ignore?: string[];
|
|
25
|
+
data_collection?: 'allow' | 'deny';
|
|
26
|
+
require_parameters?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Return the user's stored preferences, or null if none set. Returning
|
|
30
|
+
* null (vs empty object) lets `agentChat` omit the `provider` field
|
|
31
|
+
* entirely — OpenRouter is happier with no field than with an empty one.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readOpenRouterPreferences(): OpenRouterPreferences | null;
|
|
34
|
+
export declare function writeOpenRouterPreferences(prefs: OpenRouterPreferences | null): void;
|
|
35
|
+
/** Render preferences for `/openrouter` command output. */
|
|
36
|
+
export declare function formatOpenRouterPreferences(prefs: OpenRouterPreferences | null): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter provider-routing preferences.
|
|
3
|
+
*
|
|
4
|
+
* OpenRouter lets the caller bias which upstream provider its router
|
|
5
|
+
* picks for a given model — useful for cost, latency, geography, or
|
|
6
|
+
* privacy reasons. The shape we send in the request body's `provider`
|
|
7
|
+
* field follows the OpenRouter spec
|
|
8
|
+
* (https://openrouter.ai/docs#provider-routing):
|
|
9
|
+
*
|
|
10
|
+
* {
|
|
11
|
+
* "order": ["DeepInfra", "Together"], // try first in order
|
|
12
|
+
* "allow_fallbacks": true, // fall back to others if order fails
|
|
13
|
+
* "ignore": ["OpenAI"], // never use these
|
|
14
|
+
* "data_collection": "deny" | "allow", // privacy gate
|
|
15
|
+
* "require_parameters": true, // strict spec compliance
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* We store the user's preferences in `conf` so they persist across CLI
|
|
19
|
+
* launches. Empty / unset means "let OpenRouter route freely".
|
|
20
|
+
*/
|
|
21
|
+
import { config } from '../config/index.js';
|
|
22
|
+
/**
|
|
23
|
+
* Return the user's stored preferences, or null if none set. Returning
|
|
24
|
+
* null (vs empty object) lets `agentChat` omit the `provider` field
|
|
25
|
+
* entirely — OpenRouter is happier with no field than with an empty one.
|
|
26
|
+
*/
|
|
27
|
+
export function readOpenRouterPreferences() {
|
|
28
|
+
const raw = config.get('openrouterPreferences');
|
|
29
|
+
if (!raw || typeof raw !== 'object')
|
|
30
|
+
return null;
|
|
31
|
+
// Strip empty arrays — those would tell OpenRouter "use exactly nothing".
|
|
32
|
+
const cleaned = {};
|
|
33
|
+
if (Array.isArray(raw.order) && raw.order.length > 0)
|
|
34
|
+
cleaned.order = raw.order;
|
|
35
|
+
if (Array.isArray(raw.ignore) && raw.ignore.length > 0)
|
|
36
|
+
cleaned.ignore = raw.ignore;
|
|
37
|
+
if (typeof raw.allow_fallbacks === 'boolean')
|
|
38
|
+
cleaned.allow_fallbacks = raw.allow_fallbacks;
|
|
39
|
+
if (raw.data_collection === 'allow' || raw.data_collection === 'deny')
|
|
40
|
+
cleaned.data_collection = raw.data_collection;
|
|
41
|
+
if (typeof raw.require_parameters === 'boolean')
|
|
42
|
+
cleaned.require_parameters = raw.require_parameters;
|
|
43
|
+
return Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
44
|
+
}
|
|
45
|
+
export function writeOpenRouterPreferences(prefs) {
|
|
46
|
+
if (!prefs) {
|
|
47
|
+
// conf's typing wants a value of the right shape; `undefined` is a
|
|
48
|
+
// valid clearing signal but doesn't match TS' strict optional.
|
|
49
|
+
config.set('openrouterPreferences', undefined);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
config.set('openrouterPreferences', prefs);
|
|
53
|
+
}
|
|
54
|
+
/** Render preferences for `/openrouter` command output. */
|
|
55
|
+
export function formatOpenRouterPreferences(prefs) {
|
|
56
|
+
if (!prefs) {
|
|
57
|
+
return [
|
|
58
|
+
'## OpenRouter preferences',
|
|
59
|
+
'',
|
|
60
|
+
'_No routing preferences set — OpenRouter picks freely._',
|
|
61
|
+
'',
|
|
62
|
+
'Tune routing with:',
|
|
63
|
+
'- `/openrouter prefer <provider1>,<provider2>` — try these providers first (in order)',
|
|
64
|
+
'- `/openrouter ignore <provider1>,<provider2>` — never route through these',
|
|
65
|
+
'- `/openrouter fallbacks on|off` — allow fallback when preferred providers fail',
|
|
66
|
+
'- `/openrouter privacy strict|allow` — strict = `data_collection: deny`',
|
|
67
|
+
'- `/openrouter clear` — drop all preferences',
|
|
68
|
+
].join('\n');
|
|
69
|
+
}
|
|
70
|
+
const lines = ['## OpenRouter preferences', ''];
|
|
71
|
+
if (prefs.order)
|
|
72
|
+
lines.push(`- **Prefer**: ${prefs.order.map(p => `\`${p}\``).join(', ')}`);
|
|
73
|
+
if (prefs.ignore)
|
|
74
|
+
lines.push(`- **Ignore**: ${prefs.ignore.map(p => `\`${p}\``).join(', ')}`);
|
|
75
|
+
if (typeof prefs.allow_fallbacks === 'boolean')
|
|
76
|
+
lines.push(`- **Fallbacks**: ${prefs.allow_fallbacks ? 'allowed' : 'disabled'}`);
|
|
77
|
+
if (prefs.data_collection)
|
|
78
|
+
lines.push(`- **Data collection**: ${prefs.data_collection}`);
|
|
79
|
+
if (prefs.require_parameters)
|
|
80
|
+
lines.push(`- **Require parameters**: strict`);
|
|
81
|
+
lines.push('', 'Clear with `/openrouter clear`.');
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured skill bundles — Codeep's answer to Claude Code-style skills.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the JSON-manifest "skills" in `skills.ts` (which are sequential
|
|
5
|
+
* step lists triggered by the user via `/<name>`), bundles are
|
|
6
|
+
* agent-discovered capabilities the model picks up on its own. Each
|
|
7
|
+
* bundle lives in a directory:
|
|
8
|
+
*
|
|
9
|
+
* <workspace>/.codeep/skills/<name>/SKILL.md (project-scoped)
|
|
10
|
+
* ~/.codeep/skills/<name>/SKILL.md (global)
|
|
11
|
+
*
|
|
12
|
+
* Project bundles shadow global ones with the same name. The bundle dir
|
|
13
|
+
* may also contain auxiliary files (`assets/`, `scripts/`, …) that the
|
|
14
|
+
* SKILL.md body refers to — we don't enforce any sub-structure.
|
|
15
|
+
*
|
|
16
|
+
* The SKILL.md format is a deliberate superset of Claude Code's skills
|
|
17
|
+
* format so existing skills can be dropped in unchanged. Frontmatter
|
|
18
|
+
* keys we recognise:
|
|
19
|
+
*
|
|
20
|
+
* name: (required) short slug; matches dir name by default
|
|
21
|
+
* description: (required) one-sentence summary for the catalog
|
|
22
|
+
* allowed-tools: (optional) array of tool names this skill may call
|
|
23
|
+
* triggers: (optional) array of phrases that hint when to use
|
|
24
|
+
* version: (optional) semver string
|
|
25
|
+
* author: (optional) free text
|
|
26
|
+
*
|
|
27
|
+
* Codeep-specific extensions (skipped by Claude Code parsers, valid YAML):
|
|
28
|
+
*
|
|
29
|
+
* codeep-min-version: (optional) require Codeep CLI ≥ this version
|
|
30
|
+
* codeep-requires-mcp: (optional) array of MCP server names that must
|
|
31
|
+
* be registered for this skill to run
|
|
32
|
+
*
|
|
33
|
+
* The body of SKILL.md is freeform Markdown — instructions the agent
|
|
34
|
+
* reads when it decides to invoke the skill.
|
|
35
|
+
*/
|
|
36
|
+
export interface SkillBundleMeta {
|
|
37
|
+
/** Slug — defaults to the directory name if frontmatter `name` is missing. */
|
|
38
|
+
name: string;
|
|
39
|
+
/** One-line summary shown in the catalog. */
|
|
40
|
+
description: string;
|
|
41
|
+
/** Filesystem path of the bundle directory. */
|
|
42
|
+
source: string;
|
|
43
|
+
/** 'project' if loaded from `<workspace>/.codeep/skills`, else 'global'. */
|
|
44
|
+
scope: 'project' | 'global';
|
|
45
|
+
/** Subset of tools the skill is allowed to call (advisory in v2.0; enforced in 2.1+). */
|
|
46
|
+
allowedTools: string[];
|
|
47
|
+
/** Hint phrases that suggest when to use this skill (sysprompt-only signal). */
|
|
48
|
+
triggers: string[];
|
|
49
|
+
/** Optional semver string. */
|
|
50
|
+
version?: string;
|
|
51
|
+
/** Optional author free text. */
|
|
52
|
+
author?: string;
|
|
53
|
+
/** Optional minimum Codeep version (semver string). */
|
|
54
|
+
codeepMinVersion?: string;
|
|
55
|
+
/** Optional list of MCP servers the skill needs registered. */
|
|
56
|
+
requiresMcp: string[];
|
|
57
|
+
/** Raw frontmatter — kept for `/skills detail <name>` introspection. */
|
|
58
|
+
frontmatterRaw: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
export interface SkillBundle extends SkillBundleMeta {
|
|
61
|
+
/** Body content (everything after the frontmatter). */
|
|
62
|
+
body: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Load all skill bundles available in this workspace. Project entries
|
|
66
|
+
* shadow global entries with the same name.
|
|
67
|
+
*/
|
|
68
|
+
export declare function loadSkillBundles(workspaceRoot?: string): SkillBundle[];
|
|
69
|
+
/** Find a single bundle by name (case-insensitive). */
|
|
70
|
+
export declare function findSkillBundle(name: string, workspaceRoot?: string): SkillBundle | null;
|
|
71
|
+
/**
|
|
72
|
+
* Build a compact catalog block for the agent's system prompt. Each entry
|
|
73
|
+
* is `name — description (triggers)` so the model can pattern-match user
|
|
74
|
+
* intent to a skill name. Capped so a workspace with hundreds of skills
|
|
75
|
+
* can't blow the token budget.
|
|
76
|
+
*/
|
|
77
|
+
export declare function formatBundlesForSysprompt(bundles: SkillBundle[]): string;
|
|
78
|
+
/** Render a bundle list as a Markdown block for `/skills bundles`. */
|
|
79
|
+
export declare function formatBundleList(bundles: SkillBundle[]): string;
|
|
80
|
+
/**
|
|
81
|
+
* One-line summary for the welcome banner — same informed-consent
|
|
82
|
+
* pattern as custom commands and hooks. Empty string if no bundles.
|
|
83
|
+
*/
|
|
84
|
+
export declare function summarizeBundles(workspaceRoot: string): string;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured skill bundles — Codeep's answer to Claude Code-style skills.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the JSON-manifest "skills" in `skills.ts` (which are sequential
|
|
5
|
+
* step lists triggered by the user via `/<name>`), bundles are
|
|
6
|
+
* agent-discovered capabilities the model picks up on its own. Each
|
|
7
|
+
* bundle lives in a directory:
|
|
8
|
+
*
|
|
9
|
+
* <workspace>/.codeep/skills/<name>/SKILL.md (project-scoped)
|
|
10
|
+
* ~/.codeep/skills/<name>/SKILL.md (global)
|
|
11
|
+
*
|
|
12
|
+
* Project bundles shadow global ones with the same name. The bundle dir
|
|
13
|
+
* may also contain auxiliary files (`assets/`, `scripts/`, …) that the
|
|
14
|
+
* SKILL.md body refers to — we don't enforce any sub-structure.
|
|
15
|
+
*
|
|
16
|
+
* The SKILL.md format is a deliberate superset of Claude Code's skills
|
|
17
|
+
* format so existing skills can be dropped in unchanged. Frontmatter
|
|
18
|
+
* keys we recognise:
|
|
19
|
+
*
|
|
20
|
+
* name: (required) short slug; matches dir name by default
|
|
21
|
+
* description: (required) one-sentence summary for the catalog
|
|
22
|
+
* allowed-tools: (optional) array of tool names this skill may call
|
|
23
|
+
* triggers: (optional) array of phrases that hint when to use
|
|
24
|
+
* version: (optional) semver string
|
|
25
|
+
* author: (optional) free text
|
|
26
|
+
*
|
|
27
|
+
* Codeep-specific extensions (skipped by Claude Code parsers, valid YAML):
|
|
28
|
+
*
|
|
29
|
+
* codeep-min-version: (optional) require Codeep CLI ≥ this version
|
|
30
|
+
* codeep-requires-mcp: (optional) array of MCP server names that must
|
|
31
|
+
* be registered for this skill to run
|
|
32
|
+
*
|
|
33
|
+
* The body of SKILL.md is freeform Markdown — instructions the agent
|
|
34
|
+
* reads when it decides to invoke the skill.
|
|
35
|
+
*/
|
|
36
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
37
|
+
import { join } from 'path';
|
|
38
|
+
import { homedir } from 'os';
|
|
39
|
+
/**
|
|
40
|
+
* Tolerant YAML-frontmatter parser — handles `key: value`, `key: [a, b]`,
|
|
41
|
+
* and `key:` followed by `- item` block-list lines. Quoted strings are
|
|
42
|
+
* unquoted. We don't ship a real YAML dep for this — the keys we care
|
|
43
|
+
* about are scalars or simple arrays.
|
|
44
|
+
*/
|
|
45
|
+
function parseFrontmatter(raw) {
|
|
46
|
+
// BOM + CRLF normalisation. Real-world files copy/paste from various
|
|
47
|
+
// editors and pick up either; YAML strictly forbids tabs in scalars
|
|
48
|
+
// but we don't care for the keys we read.
|
|
49
|
+
const normalised = raw.replace(/^/, '').replace(/\r\n/g, '\n');
|
|
50
|
+
const match = normalised.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
51
|
+
if (!match)
|
|
52
|
+
return { meta: {}, body: normalised };
|
|
53
|
+
const meta = {};
|
|
54
|
+
const lines = match[1].split('\n');
|
|
55
|
+
let currentKey = null;
|
|
56
|
+
let currentList = null;
|
|
57
|
+
for (const rawLine of lines) {
|
|
58
|
+
const line = rawLine.replace(/\s+$/, '');
|
|
59
|
+
if (!line.trim()) {
|
|
60
|
+
currentKey = null;
|
|
61
|
+
currentList = null;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// Block-list item: ` - foo`
|
|
65
|
+
const listItem = line.match(/^\s+-\s+(.*)$/);
|
|
66
|
+
if (listItem && currentList) {
|
|
67
|
+
currentList.push(stripQuotes(listItem[1]));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// `key: value` or `key:` (open list)
|
|
71
|
+
const kv = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*?)\s*$/);
|
|
72
|
+
if (!kv)
|
|
73
|
+
continue;
|
|
74
|
+
const key = kv[1];
|
|
75
|
+
let value = kv[2];
|
|
76
|
+
if (value === '') {
|
|
77
|
+
// Empty → expecting a block list below
|
|
78
|
+
currentKey = key;
|
|
79
|
+
currentList = [];
|
|
80
|
+
meta[key] = currentList;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// Inline list: `[a, b, c]`
|
|
84
|
+
const inline = value.match(/^\[(.*)\]$/);
|
|
85
|
+
if (inline) {
|
|
86
|
+
value = inline[1].split(',').map(s => stripQuotes(s.trim())).filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
value = stripQuotes(value);
|
|
90
|
+
}
|
|
91
|
+
meta[key] = value;
|
|
92
|
+
currentKey = null;
|
|
93
|
+
currentList = null;
|
|
94
|
+
}
|
|
95
|
+
return { meta, body: match[2].trimStart() };
|
|
96
|
+
}
|
|
97
|
+
function stripQuotes(s) {
|
|
98
|
+
return s.replace(/^["']|["']$/g, '');
|
|
99
|
+
}
|
|
100
|
+
function loadFromDir(dir, scope) {
|
|
101
|
+
if (!existsSync(dir))
|
|
102
|
+
return [];
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(dir);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
const bundles = [];
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const bundleDir = join(dir, entry);
|
|
113
|
+
let stat;
|
|
114
|
+
try {
|
|
115
|
+
stat = statSync(bundleDir);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!stat.isDirectory())
|
|
121
|
+
continue;
|
|
122
|
+
const skillFile = join(bundleDir, 'SKILL.md');
|
|
123
|
+
if (!existsSync(skillFile))
|
|
124
|
+
continue;
|
|
125
|
+
let raw;
|
|
126
|
+
try {
|
|
127
|
+
raw = readFileSync(skillFile, 'utf-8');
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// Cap at 256 KB — any bigger and the user is shipping something
|
|
133
|
+
// that doesn't belong in a SKILL.md. Skip silently to avoid OOM
|
|
134
|
+
// surprises when an agent run loads dozens of bundles.
|
|
135
|
+
if (raw.length > 256 * 1024)
|
|
136
|
+
continue;
|
|
137
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
138
|
+
const name = typeof meta.name === 'string' && meta.name ? meta.name : entry;
|
|
139
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(name))
|
|
140
|
+
continue; // sanitise
|
|
141
|
+
const description = typeof meta.description === 'string' ? meta.description : '';
|
|
142
|
+
if (!description)
|
|
143
|
+
continue; // catalog entry without a description is noise
|
|
144
|
+
bundles.push({
|
|
145
|
+
name: name.toLowerCase(),
|
|
146
|
+
description,
|
|
147
|
+
source: bundleDir,
|
|
148
|
+
scope,
|
|
149
|
+
allowedTools: asStringArray(meta['allowed-tools']) ?? [],
|
|
150
|
+
triggers: asStringArray(meta.triggers) ?? [],
|
|
151
|
+
version: typeof meta.version === 'string' ? meta.version : undefined,
|
|
152
|
+
author: typeof meta.author === 'string' ? meta.author : undefined,
|
|
153
|
+
codeepMinVersion: typeof meta['codeep-min-version'] === 'string' ? meta['codeep-min-version'] : undefined,
|
|
154
|
+
requiresMcp: asStringArray(meta['codeep-requires-mcp']) ?? [],
|
|
155
|
+
frontmatterRaw: meta,
|
|
156
|
+
body,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return bundles;
|
|
160
|
+
}
|
|
161
|
+
function asStringArray(v) {
|
|
162
|
+
if (Array.isArray(v))
|
|
163
|
+
return v.filter(x => typeof x === 'string');
|
|
164
|
+
if (typeof v === 'string')
|
|
165
|
+
return [v];
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Load all skill bundles available in this workspace. Project entries
|
|
170
|
+
* shadow global entries with the same name.
|
|
171
|
+
*/
|
|
172
|
+
export function loadSkillBundles(workspaceRoot) {
|
|
173
|
+
const global = loadFromDir(join(homedir(), '.codeep', 'skills'), 'global');
|
|
174
|
+
const project = workspaceRoot
|
|
175
|
+
? loadFromDir(join(workspaceRoot, '.codeep', 'skills'), 'project')
|
|
176
|
+
: [];
|
|
177
|
+
const byName = new Map();
|
|
178
|
+
for (const b of global)
|
|
179
|
+
byName.set(b.name, b);
|
|
180
|
+
for (const b of project)
|
|
181
|
+
byName.set(b.name, b); // project wins
|
|
182
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
183
|
+
}
|
|
184
|
+
/** Find a single bundle by name (case-insensitive). */
|
|
185
|
+
export function findSkillBundle(name, workspaceRoot) {
|
|
186
|
+
const lower = name.toLowerCase();
|
|
187
|
+
return loadSkillBundles(workspaceRoot).find(b => b.name === lower) ?? null;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Build a compact catalog block for the agent's system prompt. Each entry
|
|
191
|
+
* is `name — description (triggers)` so the model can pattern-match user
|
|
192
|
+
* intent to a skill name. Capped so a workspace with hundreds of skills
|
|
193
|
+
* can't blow the token budget.
|
|
194
|
+
*/
|
|
195
|
+
export function formatBundlesForSysprompt(bundles) {
|
|
196
|
+
if (bundles.length === 0)
|
|
197
|
+
return '';
|
|
198
|
+
const CAP_PER_LINE = 200;
|
|
199
|
+
const CAP_TOTAL = 4000;
|
|
200
|
+
const lines = [
|
|
201
|
+
'## Available skill bundles',
|
|
202
|
+
'',
|
|
203
|
+
'You can invoke any of these by calling the `invoke_skill` tool with `{"name": "<skill_name>"}`. The tool returns the skill\'s SKILL.md content — follow its instructions step by step. Each skill is a curated workflow the user has installed; prefer it over ad-hoc steps when the user\'s request matches a skill\'s purpose.',
|
|
204
|
+
'',
|
|
205
|
+
];
|
|
206
|
+
let used = lines.join('\n').length;
|
|
207
|
+
let skipped = 0;
|
|
208
|
+
for (const b of bundles) {
|
|
209
|
+
const triggerHint = b.triggers.length > 0 ? ` _(triggers: ${b.triggers.slice(0, 3).join(', ')})_` : '';
|
|
210
|
+
const line = `- **${b.name}** — ${b.description}${triggerHint}`;
|
|
211
|
+
const truncated = line.length > CAP_PER_LINE ? line.slice(0, CAP_PER_LINE) + '…' : line;
|
|
212
|
+
if (used + truncated.length + 1 > CAP_TOTAL) {
|
|
213
|
+
skipped++;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
lines.push(truncated);
|
|
217
|
+
used += truncated.length + 1;
|
|
218
|
+
}
|
|
219
|
+
if (skipped > 0)
|
|
220
|
+
lines.push(`_(${skipped} more skills omitted to stay under the catalog budget — use \`/skills bundles\` to see all.)_`);
|
|
221
|
+
return lines.join('\n');
|
|
222
|
+
}
|
|
223
|
+
/** Render a bundle list as a Markdown block for `/skills bundles`. */
|
|
224
|
+
export function formatBundleList(bundles) {
|
|
225
|
+
if (bundles.length === 0) {
|
|
226
|
+
return [
|
|
227
|
+
'_No skill bundles installed yet._',
|
|
228
|
+
'',
|
|
229
|
+
'Create one with `/skills create-bundle <name>` (project) or drop a directory into `~/.codeep/skills/<name>/` (global). Each needs a `SKILL.md` with at least `name` and `description` in the frontmatter.',
|
|
230
|
+
].join('\n');
|
|
231
|
+
}
|
|
232
|
+
const project = bundles.filter(b => b.scope === 'project');
|
|
233
|
+
const global = bundles.filter(b => b.scope === 'global');
|
|
234
|
+
const lines = ['## Skill bundles', ''];
|
|
235
|
+
if (project.length) {
|
|
236
|
+
lines.push('**Project**');
|
|
237
|
+
for (const b of project)
|
|
238
|
+
lines.push(`- **${b.name}** ${b.version ? `\`v${b.version}\` ` : ''}— ${b.description}`);
|
|
239
|
+
lines.push('');
|
|
240
|
+
}
|
|
241
|
+
if (global.length) {
|
|
242
|
+
lines.push('**Global**');
|
|
243
|
+
for (const b of global)
|
|
244
|
+
lines.push(`- **${b.name}** ${b.version ? `\`v${b.version}\` ` : ''}— ${b.description}`);
|
|
245
|
+
}
|
|
246
|
+
return lines.join('\n');
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* One-line summary for the welcome banner — same informed-consent
|
|
250
|
+
* pattern as custom commands and hooks. Empty string if no bundles.
|
|
251
|
+
*/
|
|
252
|
+
export function summarizeBundles(workspaceRoot) {
|
|
253
|
+
const project = loadSkillBundles(workspaceRoot).filter(b => b.scope === 'project');
|
|
254
|
+
if (project.length === 0)
|
|
255
|
+
return '';
|
|
256
|
+
return `${project.length} project skill${project.length === 1 ? '' : 's'}`;
|
|
257
|
+
}
|