@omnidev-ai/core 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/index.d.ts +600 -664
  2. package/dist/index.js +1841 -1915
  3. package/dist/shared/chunk-1dqs11h6.js +20 -0
  4. package/dist/test-utils/index.d.ts +97 -101
  5. package/dist/test-utils/index.js +203 -234
  6. package/package.json +5 -3
  7. package/src/capability/AGENTS.md +58 -0
  8. package/src/capability/commands.ts +72 -0
  9. package/src/capability/docs.ts +48 -0
  10. package/src/capability/index.ts +20 -0
  11. package/src/capability/loader.ts +431 -0
  12. package/src/capability/registry.ts +55 -0
  13. package/src/capability/rules.ts +135 -0
  14. package/src/capability/skills.ts +58 -0
  15. package/src/capability/sources.ts +998 -0
  16. package/src/capability/subagents.ts +105 -0
  17. package/src/capability/yaml-parser.ts +81 -0
  18. package/src/config/AGENTS.md +46 -0
  19. package/src/config/capabilities.ts +54 -0
  20. package/src/config/env.ts +96 -0
  21. package/src/config/index.ts +6 -0
  22. package/src/config/loader.ts +207 -0
  23. package/src/config/parser.ts +55 -0
  24. package/src/config/profiles.ts +75 -0
  25. package/src/config/provider.ts +55 -0
  26. package/src/debug.ts +20 -0
  27. package/src/index.ts +37 -0
  28. package/src/mcp-json/index.ts +1 -0
  29. package/src/mcp-json/manager.ts +106 -0
  30. package/src/state/active-profile.ts +41 -0
  31. package/src/state/index.ts +3 -0
  32. package/src/state/manifest.ts +137 -0
  33. package/src/state/providers.ts +69 -0
  34. package/src/sync.ts +288 -0
  35. package/src/templates/agents.ts +14 -0
  36. package/src/templates/claude.ts +57 -0
  37. package/src/test-utils/helpers.ts +289 -0
  38. package/src/test-utils/index.ts +34 -0
  39. package/src/test-utils/mocks.ts +101 -0
  40. package/src/types/capability-export.ts +157 -0
  41. package/src/types/index.ts +314 -0
@@ -0,0 +1,20 @@
1
+ export { loadCommands } from "./commands";
2
+ export { loadDocs } from "./docs";
3
+ export { discoverCapabilities, loadCapability, loadCapabilityConfig } from "./loader";
4
+ export type { CapabilityRegistry } from "./registry";
5
+ export { buildCapabilityRegistry } from "./registry";
6
+ export { loadRules, writeRules } from "./rules";
7
+ export { loadSkills } from "./skills";
8
+ export {
9
+ fetchAllCapabilitySources,
10
+ fetchCapabilitySource,
11
+ checkForUpdates,
12
+ loadLockFile,
13
+ saveLockFile,
14
+ parseSourceConfig,
15
+ sourceToGitUrl,
16
+ getSourceCapabilityPath,
17
+ getLockFilePath,
18
+ } from "./sources";
19
+ export type { FetchResult, SourceUpdateInfo, DiscoveredContent } from "./sources";
20
+ export { loadSubagents } from "./subagents";
@@ -0,0 +1,431 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { validateEnv } from "../config/env";
4
+ import { parseCapabilityConfig } from "../config/parser";
5
+ import type {
6
+ CapabilityConfig,
7
+ Command,
8
+ Doc,
9
+ LoadedCapability,
10
+ Rule,
11
+ Skill,
12
+ Subagent,
13
+ } from "../types";
14
+ import type {
15
+ CommandExport,
16
+ DocExport,
17
+ SkillExport,
18
+ SubagentExport,
19
+ } from "../types/capability-export";
20
+ import { loadCommands } from "./commands";
21
+ import { loadDocs } from "./docs";
22
+ import { loadRules } from "./rules";
23
+ import { loadSkills } from "./skills";
24
+ import { loadSubagents } from "./subagents";
25
+
26
+ const CAPABILITIES_DIR = ".omni/capabilities";
27
+
28
+ /**
29
+ * Discovers capabilities by scanning the .omni/capabilities directory.
30
+ * A directory is considered a capability if it contains a capability.toml file.
31
+ *
32
+ * @returns Array of capability directory paths
33
+ */
34
+ export async function discoverCapabilities(): Promise<string[]> {
35
+ const capabilities: string[] = [];
36
+
37
+ if (existsSync(CAPABILITIES_DIR)) {
38
+ const entries = readdirSync(CAPABILITIES_DIR, { withFileTypes: true }).sort((a, b) =>
39
+ a.name.localeCompare(b.name),
40
+ );
41
+
42
+ for (const entry of entries) {
43
+ if (entry.isDirectory()) {
44
+ const entryPath = join(CAPABILITIES_DIR, entry.name);
45
+ const configPath = join(entryPath, "capability.toml");
46
+ if (existsSync(configPath)) {
47
+ capabilities.push(entryPath);
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ return capabilities;
54
+ }
55
+
56
+ /**
57
+ * Loads and parses a capability configuration file.
58
+ * Validates required fields.
59
+ *
60
+ * @param capabilityPath - Path to the capability directory
61
+ * @returns Parsed capability configuration
62
+ * @throws Error if the config is invalid
63
+ */
64
+ export async function loadCapabilityConfig(capabilityPath: string): Promise<CapabilityConfig> {
65
+ const configPath = join(capabilityPath, "capability.toml");
66
+ const content = await Bun.file(configPath).text();
67
+ const config = parseCapabilityConfig(content);
68
+
69
+ return config;
70
+ }
71
+
72
+ /**
73
+ * Dynamically imports capability exports from index.ts.
74
+ * Returns an empty object if index.ts doesn't exist.
75
+ *
76
+ * @param capabilityPath - Path to the capability directory
77
+ * @returns Exported module or empty object
78
+ * @throws Error if import fails
79
+ */
80
+ async function importCapabilityExports(capabilityPath: string): Promise<Record<string, unknown>> {
81
+ const indexPath = join(capabilityPath, "index.ts");
82
+
83
+ if (!existsSync(indexPath)) {
84
+ return {};
85
+ }
86
+
87
+ try {
88
+ const absolutePath = join(process.cwd(), indexPath);
89
+ const module = await import(absolutePath);
90
+ return module;
91
+ } catch (error) {
92
+ // Check if it's a module resolution error
93
+ const errorMessage = String(error);
94
+ if (errorMessage.includes("Cannot find module")) {
95
+ const match = errorMessage.match(/Cannot find module '([^']+)'/);
96
+ const missingModule = match ? match[1] : "unknown";
97
+ throw new Error(
98
+ `Missing dependency '${missingModule}' for capability at ${capabilityPath}.\n` +
99
+ `If this is a project-specific capability, install dependencies or remove it from .omni/capabilities/`,
100
+ );
101
+ }
102
+ throw new Error(`Failed to import capability at ${capabilityPath}: ${error}`);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Loads type definitions from types.d.ts if it exists.
108
+ *
109
+ * @param capabilityPath - Path to the capability directory
110
+ * @returns Type definitions as string or undefined
111
+ */
112
+ async function loadTypeDefinitions(capabilityPath: string): Promise<string | undefined> {
113
+ const typesPath = join(capabilityPath, "types.d.ts");
114
+
115
+ if (!existsSync(typesPath)) {
116
+ return undefined;
117
+ }
118
+
119
+ return Bun.file(typesPath).text();
120
+ }
121
+
122
+ /**
123
+ * Convert programmatic skill exports to Skill objects
124
+ * Expects SkillExport format with skillMd (markdown with YAML frontmatter)
125
+ */
126
+ function convertSkillExports(skillExports: unknown[], capabilityId: string): Skill[] {
127
+ return skillExports.map((skillExport) => {
128
+ const exportObj = skillExport as SkillExport;
129
+ const lines = exportObj.skillMd.split("\n");
130
+ let name = "unnamed";
131
+ let description = "";
132
+ let instructions = exportObj.skillMd;
133
+
134
+ // Simple YAML frontmatter parser
135
+ if (lines[0]?.trim() === "---") {
136
+ const endIndex = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
137
+ if (endIndex > 0) {
138
+ const frontmatter = lines.slice(1, endIndex);
139
+ instructions = lines
140
+ .slice(endIndex + 1)
141
+ .join("\n")
142
+ .trim();
143
+
144
+ for (const line of frontmatter) {
145
+ const match = line.match(/^(\w+):\s*(.+)$/);
146
+ if (match?.[1] && match[2]) {
147
+ const key = match[1];
148
+ const value = match[2];
149
+ if (key === "name") {
150
+ name = value.replace(/^["']|["']$/g, "");
151
+ } else if (key === "description") {
152
+ description = value.replace(/^["']|["']$/g, "");
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ return {
160
+ name,
161
+ description,
162
+ instructions,
163
+ capabilityId,
164
+ };
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Convert programmatic rule exports to Rule objects
170
+ * Expects array of string content (markdown)
171
+ */
172
+ function convertRuleExports(ruleExports: unknown[], capabilityId: string): Rule[] {
173
+ return ruleExports.map((ruleExport, index) => {
174
+ return {
175
+ name: `rule-${index + 1}`,
176
+ content: String(ruleExport).trim(),
177
+ capabilityId,
178
+ };
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Convert programmatic doc exports to Doc objects
184
+ * Expects DocExport format with title and content
185
+ */
186
+ function convertDocExports(docExports: unknown[], capabilityId: string): Doc[] {
187
+ return docExports.map((docExport) => {
188
+ const exportObj = docExport as DocExport;
189
+ return {
190
+ name: exportObj.title,
191
+ content: exportObj.content.trim(),
192
+ capabilityId,
193
+ };
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Convert programmatic subagent exports to Subagent objects
199
+ * Parses SubagentExport markdown with YAML frontmatter
200
+ */
201
+ function convertSubagentExports(subagentExports: unknown[], capabilityId: string): Subagent[] {
202
+ return subagentExports.map((subagentExport) => {
203
+ const exportObj = subagentExport as SubagentExport;
204
+ const lines = exportObj.subagentMd.split("\n");
205
+ let name = "unnamed";
206
+ let description = "";
207
+ let systemPrompt = exportObj.subagentMd;
208
+ let tools: string[] | undefined;
209
+ let disallowedTools: string[] | undefined;
210
+ let model: string | undefined;
211
+ let permissionMode: string | undefined;
212
+ let skills: string[] | undefined;
213
+
214
+ // Simple YAML frontmatter parser
215
+ if (lines[0]?.trim() === "---") {
216
+ const endIndex = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
217
+ if (endIndex > 0) {
218
+ const frontmatter = lines.slice(1, endIndex);
219
+ systemPrompt = lines
220
+ .slice(endIndex + 1)
221
+ .join("\n")
222
+ .trim();
223
+
224
+ for (const line of frontmatter) {
225
+ const match = line.match(/^(\w+):\s*(.+)$/);
226
+ if (match?.[1] && match[2]) {
227
+ const key = match[1];
228
+ const value = match[2].replace(/^["']|["']$/g, "");
229
+ switch (key) {
230
+ case "name":
231
+ name = value;
232
+ break;
233
+ case "description":
234
+ description = value;
235
+ break;
236
+ case "tools":
237
+ tools = value.split(",").map((t) => t.trim());
238
+ break;
239
+ case "disallowedTools":
240
+ disallowedTools = value.split(",").map((t) => t.trim());
241
+ break;
242
+ case "model":
243
+ model = value;
244
+ break;
245
+ case "permissionMode":
246
+ permissionMode = value;
247
+ break;
248
+ case "skills":
249
+ skills = value.split(",").map((s) => s.trim());
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ const result: Subagent = {
258
+ name,
259
+ description,
260
+ systemPrompt,
261
+ capabilityId,
262
+ };
263
+
264
+ if (tools) result.tools = tools;
265
+ if (disallowedTools) result.disallowedTools = disallowedTools;
266
+ if (model) {
267
+ result.model = model as NonNullable<Subagent["model"]>;
268
+ }
269
+ if (permissionMode) {
270
+ result.permissionMode = permissionMode as NonNullable<Subagent["permissionMode"]>;
271
+ }
272
+ if (skills) result.skills = skills;
273
+
274
+ return result;
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Convert programmatic command exports to Command objects
280
+ * Parses CommandExport markdown with YAML frontmatter
281
+ */
282
+ function convertCommandExports(commandExports: unknown[], capabilityId: string): Command[] {
283
+ return commandExports.map((commandExport) => {
284
+ const exportObj = commandExport as CommandExport;
285
+ const lines = exportObj.commandMd.split("\n");
286
+ let name = "unnamed";
287
+ let description = "";
288
+ let prompt = exportObj.commandMd;
289
+ let allowedTools: string | undefined;
290
+
291
+ // Simple YAML frontmatter parser
292
+ if (lines[0]?.trim() === "---") {
293
+ const endIndex = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
294
+ if (endIndex > 0) {
295
+ const frontmatter = lines.slice(1, endIndex);
296
+ prompt = lines
297
+ .slice(endIndex + 1)
298
+ .join("\n")
299
+ .trim();
300
+
301
+ for (const line of frontmatter) {
302
+ const match = line.match(/^(\w+):\s*(.+)$/);
303
+ if (match?.[1] && match[2]) {
304
+ const key = match[1];
305
+ const value = match[2].replace(/^["']|["']$/g, "");
306
+ switch (key) {
307
+ case "name":
308
+ name = value;
309
+ break;
310
+ case "description":
311
+ description = value;
312
+ break;
313
+ case "allowedTools":
314
+ case "allowed-tools":
315
+ allowedTools = value;
316
+ break;
317
+ }
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ const result: Command = {
324
+ name,
325
+ description,
326
+ prompt,
327
+ capabilityId,
328
+ };
329
+
330
+ if (allowedTools) {
331
+ result.allowedTools = allowedTools;
332
+ }
333
+
334
+ return result;
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Loads a complete capability including config, skills, rules, docs, and exports.
340
+ * Validates environment requirements before loading.
341
+ *
342
+ * @param capabilityPath - Path to the capability directory
343
+ * @param env - Environment variables to validate against
344
+ * @returns Fully loaded capability
345
+ * @throws Error if validation fails or loading errors occur
346
+ */
347
+ export async function loadCapability(
348
+ capabilityPath: string,
349
+ env: Record<string, string>,
350
+ ): Promise<LoadedCapability> {
351
+ const config = await loadCapabilityConfig(capabilityPath);
352
+ const id = config.capability.id;
353
+
354
+ // Validate environment
355
+ if (config.env) {
356
+ validateEnv(config.env, env, id);
357
+ }
358
+
359
+ // Load content - programmatic takes precedence
360
+ const exports = await importCapabilityExports(capabilityPath);
361
+
362
+ // Check if exports contains programmatic skills/rules/docs
363
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic module exports need runtime type checking
364
+ const exportsAny = exports as any;
365
+
366
+ const skills =
367
+ "skills" in exports && Array.isArray(exportsAny.skills)
368
+ ? convertSkillExports(exportsAny.skills, id)
369
+ : await loadSkills(capabilityPath, id);
370
+
371
+ const rules =
372
+ "rules" in exports && Array.isArray(exportsAny.rules)
373
+ ? convertRuleExports(exportsAny.rules, id)
374
+ : await loadRules(capabilityPath, id);
375
+
376
+ const docs =
377
+ "docs" in exports && Array.isArray(exportsAny.docs)
378
+ ? convertDocExports(exportsAny.docs, id)
379
+ : await loadDocs(capabilityPath, id);
380
+
381
+ const subagents =
382
+ "subagents" in exports && Array.isArray(exportsAny.subagents)
383
+ ? convertSubagentExports(exportsAny.subagents, id)
384
+ : await loadSubagents(capabilityPath, id);
385
+
386
+ const commands =
387
+ "commands" in exports && Array.isArray(exportsAny.commands)
388
+ ? convertCommandExports(exportsAny.commands, id)
389
+ : await loadCommands(capabilityPath, id);
390
+
391
+ const typeDefinitionsFromExports =
392
+ "typeDefinitions" in exports && typeof exportsAny.typeDefinitions === "string"
393
+ ? (exportsAny.typeDefinitions as string)
394
+ : undefined;
395
+
396
+ const typeDefinitions =
397
+ typeDefinitionsFromExports !== undefined
398
+ ? typeDefinitionsFromExports
399
+ : await loadTypeDefinitions(capabilityPath);
400
+
401
+ // Extract gitignore patterns from exports
402
+ const gitignore =
403
+ "gitignore" in exports && Array.isArray(exportsAny.gitignore)
404
+ ? (exportsAny.gitignore as string[])
405
+ : undefined;
406
+
407
+ // Build result object with explicit handling for optional typeDefinitions
408
+ const result: LoadedCapability = {
409
+ id,
410
+ path: capabilityPath,
411
+ config,
412
+ skills,
413
+ rules,
414
+ docs,
415
+ subagents,
416
+ commands,
417
+ exports,
418
+ };
419
+
420
+ // Only add typeDefinitions if it exists
421
+ if (typeDefinitions !== undefined) {
422
+ result.typeDefinitions = typeDefinitions;
423
+ }
424
+
425
+ // Only add gitignore if it exists
426
+ if (gitignore !== undefined) {
427
+ result.gitignore = gitignore;
428
+ }
429
+
430
+ return result;
431
+ }
@@ -0,0 +1,55 @@
1
+ import { getEnabledCapabilities } from "../config/capabilities";
2
+ import { loadEnvironment } from "../config/env";
3
+ import type { Doc, LoadedCapability, Rule, Skill } from "../types";
4
+ import { discoverCapabilities, loadCapability } from "./loader";
5
+
6
+ /**
7
+ * Registry of loaded capabilities with helper functions.
8
+ */
9
+ export interface CapabilityRegistry {
10
+ capabilities: Map<string, LoadedCapability>;
11
+ getCapability(id: string): LoadedCapability | undefined;
12
+ getAllCapabilities(): LoadedCapability[];
13
+ getAllSkills(): Skill[];
14
+ getAllRules(): Rule[];
15
+ getAllDocs(): Doc[];
16
+ }
17
+
18
+ /**
19
+ * Builds a capability registry by discovering, loading, and filtering capabilities.
20
+ * Only enabled capabilities (based on active profile) are included.
21
+ *
22
+ * @returns Capability registry with helper functions
23
+ */
24
+ export async function buildCapabilityRegistry(): Promise<CapabilityRegistry> {
25
+ const env = await loadEnvironment();
26
+ const enabledIds = await getEnabledCapabilities();
27
+
28
+ const capabilityPaths = await discoverCapabilities();
29
+ const capabilities = new Map<string, LoadedCapability>();
30
+
31
+ for (const path of capabilityPaths) {
32
+ try {
33
+ const cap = await loadCapability(path, env);
34
+
35
+ // Only add if enabled
36
+ if (enabledIds.includes(cap.id)) {
37
+ capabilities.set(cap.id, cap);
38
+ }
39
+ } catch (error) {
40
+ // Extract just the error message without stack trace for cleaner output
41
+ const errorMessage = error instanceof Error ? error.message : String(error);
42
+ console.warn(`Warning: Skipping capability at ${path}`);
43
+ console.warn(` ${errorMessage}`);
44
+ }
45
+ }
46
+
47
+ return {
48
+ capabilities,
49
+ getCapability: (id: string) => capabilities.get(id),
50
+ getAllCapabilities: () => [...capabilities.values()],
51
+ getAllSkills: () => [...capabilities.values()].flatMap((c) => c.skills),
52
+ getAllRules: () => [...capabilities.values()].flatMap((c) => c.rules),
53
+ getAllDocs: () => [...capabilities.values()].flatMap((c) => c.docs),
54
+ };
55
+ }
@@ -0,0 +1,135 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import type { Doc, Rule } from "../types";
4
+
5
+ /**
6
+ * Load rules from a capability's rules/ directory
7
+ * @param capabilityPath Path to the capability directory
8
+ * @param capabilityId ID of the capability
9
+ * @returns Array of Rule objects
10
+ */
11
+ export async function loadRules(capabilityPath: string, capabilityId: string): Promise<Rule[]> {
12
+ const rulesDir = join(capabilityPath, "rules");
13
+
14
+ if (!existsSync(rulesDir)) {
15
+ return [];
16
+ }
17
+
18
+ const rules: Rule[] = [];
19
+ const entries = readdirSync(rulesDir, { withFileTypes: true }).sort((a, b) =>
20
+ a.name.localeCompare(b.name),
21
+ );
22
+
23
+ for (const entry of entries) {
24
+ if (entry.isFile() && entry.name.endsWith(".md")) {
25
+ const rulePath = join(rulesDir, entry.name);
26
+ const content = await Bun.file(rulePath).text();
27
+
28
+ rules.push({
29
+ name: basename(entry.name, ".md"),
30
+ content: content.trim(),
31
+ capabilityId,
32
+ });
33
+ }
34
+ }
35
+
36
+ return rules;
37
+ }
38
+
39
+ /**
40
+ * Write aggregated rules and docs to .omni/instructions.md
41
+ * Updates the generated section between markers while preserving user content
42
+ * @param rules Array of rules from all enabled capabilities
43
+ * @param docs Array of docs from all enabled capabilities
44
+ */
45
+ export async function writeRules(rules: Rule[], docs: Doc[] = []): Promise<void> {
46
+ const instructionsPath = ".omni/instructions.md";
47
+
48
+ // Generate content from rules and docs
49
+ const rulesContent = generateRulesContent(rules, docs);
50
+
51
+ // Read existing content or create new file
52
+ let content: string;
53
+ if (existsSync(instructionsPath)) {
54
+ content = await Bun.file(instructionsPath).text();
55
+ } else {
56
+ // Create new file with basic template
57
+ content = `# OmniDev Instructions
58
+
59
+ ## Project Description
60
+ <!-- TODO: Add 2-3 sentences describing your project -->
61
+ [Describe what this project does and its main purpose]
62
+
63
+ <!-- BEGIN OMNIDEV GENERATED CONTENT - DO NOT EDIT BELOW THIS LINE -->
64
+ <!-- END OMNIDEV GENERATED CONTENT -->
65
+ `;
66
+ }
67
+
68
+ // Replace content between markers
69
+ const beginMarker = "<!-- BEGIN OMNIDEV GENERATED CONTENT - DO NOT EDIT BELOW THIS LINE -->";
70
+ const endMarker = "<!-- END OMNIDEV GENERATED CONTENT -->";
71
+
72
+ const beginIndex = content.indexOf(beginMarker);
73
+ const endIndex = content.indexOf(endMarker);
74
+
75
+ if (beginIndex === -1 || endIndex === -1) {
76
+ // Markers not found, append to end
77
+ content += `\n\n${beginMarker}\n${rulesContent}\n${endMarker}\n`;
78
+ } else {
79
+ // Replace content between markers
80
+ content =
81
+ content.substring(0, beginIndex + beginMarker.length) +
82
+ "\n" +
83
+ rulesContent +
84
+ "\n" +
85
+ content.substring(endIndex);
86
+ }
87
+
88
+ await Bun.write(instructionsPath, content);
89
+ }
90
+
91
+ function generateRulesContent(rules: Rule[], docs: Doc[] = []): string {
92
+ if (rules.length === 0 && docs.length === 0) {
93
+ return `<!-- This section is automatically updated when capabilities change -->
94
+
95
+ ## Capabilities
96
+
97
+ No capabilities enabled yet. Run \`omnidev capability enable <name>\` to enable capabilities.`;
98
+ }
99
+
100
+ let content = `<!-- This section is automatically updated when capabilities change -->
101
+
102
+ ## Capabilities
103
+
104
+ `;
105
+
106
+ // Add documentation section if there are docs
107
+ if (docs.length > 0) {
108
+ content += `### Documentation
109
+
110
+ `;
111
+ for (const doc of docs) {
112
+ content += `#### ${doc.name} (from ${doc.capabilityId})
113
+
114
+ ${doc.content}
115
+
116
+ `;
117
+ }
118
+ }
119
+
120
+ // Add rules section if there are rules
121
+ if (rules.length > 0) {
122
+ content += `### Rules
123
+
124
+ `;
125
+ for (const rule of rules) {
126
+ content += `#### ${rule.name} (from ${rule.capabilityId})
127
+
128
+ ${rule.content}
129
+
130
+ `;
131
+ }
132
+ }
133
+
134
+ return content.trim();
135
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Skill } from "../types";
4
+ import { parseFrontmatterWithMarkdown } from "./yaml-parser";
5
+
6
+ interface SkillFrontmatter {
7
+ name: string;
8
+ description: string;
9
+ }
10
+
11
+ export async function loadSkills(capabilityPath: string, capabilityId: string): Promise<Skill[]> {
12
+ const skillsDir = join(capabilityPath, "skills");
13
+
14
+ if (!existsSync(skillsDir)) {
15
+ return [];
16
+ }
17
+
18
+ const skills: Skill[] = [];
19
+ const entries = readdirSync(skillsDir, { withFileTypes: true }).sort((a, b) =>
20
+ a.name.localeCompare(b.name),
21
+ );
22
+
23
+ for (const entry of entries) {
24
+ if (entry.isDirectory()) {
25
+ const skillPath = join(skillsDir, entry.name, "SKILL.md");
26
+ if (existsSync(skillPath)) {
27
+ const skill = await parseSkillFile(skillPath, capabilityId);
28
+ skills.push(skill);
29
+ }
30
+ }
31
+ }
32
+
33
+ return skills;
34
+ }
35
+
36
+ async function parseSkillFile(filePath: string, capabilityId: string): Promise<Skill> {
37
+ const content = await Bun.file(filePath).text();
38
+
39
+ const parsed = parseFrontmatterWithMarkdown<SkillFrontmatter>(content);
40
+
41
+ if (!parsed) {
42
+ throw new Error(`Invalid SKILL.md format at ${filePath}: missing YAML frontmatter`);
43
+ }
44
+
45
+ const frontmatter = parsed.frontmatter;
46
+ const instructions = parsed.markdown;
47
+
48
+ if (!frontmatter.name || !frontmatter.description) {
49
+ throw new Error(`Invalid SKILL.md at ${filePath}: name and description required`);
50
+ }
51
+
52
+ return {
53
+ name: frontmatter.name,
54
+ description: frontmatter.description,
55
+ instructions: instructions.trim(),
56
+ capabilityId,
57
+ };
58
+ }