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.
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 +46 -1
  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 -45
  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
@@ -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
+ }