@omnidev-ai/core 0.1.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/package.json +31 -0
- package/src/capability/AGENTS.md +58 -0
- package/src/capability/commands.test.ts +414 -0
- package/src/capability/commands.ts +70 -0
- package/src/capability/docs.test.ts +199 -0
- package/src/capability/docs.ts +46 -0
- package/src/capability/index.ts +20 -0
- package/src/capability/loader.test.ts +815 -0
- package/src/capability/loader.ts +492 -0
- package/src/capability/registry.test.ts +473 -0
- package/src/capability/registry.ts +55 -0
- package/src/capability/rules.test.ts +145 -0
- package/src/capability/rules.ts +133 -0
- package/src/capability/skills.test.ts +316 -0
- package/src/capability/skills.ts +56 -0
- package/src/capability/sources.test.ts +338 -0
- package/src/capability/sources.ts +966 -0
- package/src/capability/subagents.test.ts +478 -0
- package/src/capability/subagents.ts +103 -0
- package/src/capability/yaml-parser.ts +81 -0
- package/src/config/AGENTS.md +46 -0
- package/src/config/capabilities.ts +82 -0
- package/src/config/env.test.ts +286 -0
- package/src/config/env.ts +96 -0
- package/src/config/index.ts +6 -0
- package/src/config/loader.test.ts +282 -0
- package/src/config/loader.ts +137 -0
- package/src/config/parser.test.ts +281 -0
- package/src/config/parser.ts +55 -0
- package/src/config/profiles.test.ts +259 -0
- package/src/config/profiles.ts +75 -0
- package/src/config/provider.test.ts +79 -0
- package/src/config/provider.ts +55 -0
- package/src/debug.ts +20 -0
- package/src/gitignore/manager.test.ts +219 -0
- package/src/gitignore/manager.ts +167 -0
- package/src/index.test.ts +26 -0
- package/src/index.ts +39 -0
- package/src/mcp-json/index.ts +1 -0
- package/src/mcp-json/manager.test.ts +415 -0
- package/src/mcp-json/manager.ts +118 -0
- package/src/state/active-profile.test.ts +131 -0
- package/src/state/active-profile.ts +41 -0
- package/src/state/index.ts +2 -0
- package/src/state/manifest.test.ts +548 -0
- package/src/state/manifest.ts +164 -0
- package/src/sync.ts +213 -0
- package/src/templates/agents.test.ts +23 -0
- package/src/templates/agents.ts +14 -0
- package/src/templates/claude.test.ts +48 -0
- package/src/templates/claude.ts +122 -0
- package/src/test-utils/helpers.test.ts +196 -0
- package/src/test-utils/helpers.ts +187 -0
- package/src/test-utils/index.ts +30 -0
- package/src/test-utils/mocks.test.ts +83 -0
- package/src/test-utils/mocks.ts +101 -0
- package/src/types/capability-export.ts +234 -0
- package/src/types/index.test.ts +28 -0
- package/src/types/index.ts +270 -0
|
@@ -0,0 +1,133 @@
|
|
|
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 });
|
|
20
|
+
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
23
|
+
const rulePath = join(rulesDir, entry.name);
|
|
24
|
+
const content = await Bun.file(rulePath).text();
|
|
25
|
+
|
|
26
|
+
rules.push({
|
|
27
|
+
name: basename(entry.name, ".md"),
|
|
28
|
+
content: content.trim(),
|
|
29
|
+
capabilityId,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return rules;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write aggregated rules and docs to .omni/instructions.md
|
|
39
|
+
* Updates the generated section between markers while preserving user content
|
|
40
|
+
* @param rules Array of rules from all enabled capabilities
|
|
41
|
+
* @param docs Array of docs from all enabled capabilities
|
|
42
|
+
*/
|
|
43
|
+
export async function writeRules(rules: Rule[], docs: Doc[] = []): Promise<void> {
|
|
44
|
+
const instructionsPath = ".omni/instructions.md";
|
|
45
|
+
|
|
46
|
+
// Generate content from rules and docs
|
|
47
|
+
const rulesContent = generateRulesContent(rules, docs);
|
|
48
|
+
|
|
49
|
+
// Read existing content or create new file
|
|
50
|
+
let content: string;
|
|
51
|
+
if (existsSync(instructionsPath)) {
|
|
52
|
+
content = await Bun.file(instructionsPath).text();
|
|
53
|
+
} else {
|
|
54
|
+
// Create new file with basic template
|
|
55
|
+
content = `# OmniDev Instructions
|
|
56
|
+
|
|
57
|
+
## Project Description
|
|
58
|
+
<!-- TODO: Add 2-3 sentences describing your project -->
|
|
59
|
+
[Describe what this project does and its main purpose]
|
|
60
|
+
|
|
61
|
+
<!-- BEGIN OMNIDEV GENERATED CONTENT - DO NOT EDIT BELOW THIS LINE -->
|
|
62
|
+
<!-- END OMNIDEV GENERATED CONTENT -->
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Replace content between markers
|
|
67
|
+
const beginMarker = "<!-- BEGIN OMNIDEV GENERATED CONTENT - DO NOT EDIT BELOW THIS LINE -->";
|
|
68
|
+
const endMarker = "<!-- END OMNIDEV GENERATED CONTENT -->";
|
|
69
|
+
|
|
70
|
+
const beginIndex = content.indexOf(beginMarker);
|
|
71
|
+
const endIndex = content.indexOf(endMarker);
|
|
72
|
+
|
|
73
|
+
if (beginIndex === -1 || endIndex === -1) {
|
|
74
|
+
// Markers not found, append to end
|
|
75
|
+
content += `\n\n${beginMarker}\n${rulesContent}\n${endMarker}\n`;
|
|
76
|
+
} else {
|
|
77
|
+
// Replace content between markers
|
|
78
|
+
content =
|
|
79
|
+
content.substring(0, beginIndex + beginMarker.length) +
|
|
80
|
+
"\n" +
|
|
81
|
+
rulesContent +
|
|
82
|
+
"\n" +
|
|
83
|
+
content.substring(endIndex);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await Bun.write(instructionsPath, content);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function generateRulesContent(rules: Rule[], docs: Doc[] = []): string {
|
|
90
|
+
if (rules.length === 0 && docs.length === 0) {
|
|
91
|
+
return `<!-- This section is automatically updated when capabilities change -->
|
|
92
|
+
|
|
93
|
+
## Capabilities
|
|
94
|
+
|
|
95
|
+
No capabilities enabled yet. Run \`omnidev capability enable <name>\` to enable capabilities.`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let content = `<!-- This section is automatically updated when capabilities change -->
|
|
99
|
+
|
|
100
|
+
## Capabilities
|
|
101
|
+
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
// Add documentation section if there are docs
|
|
105
|
+
if (docs.length > 0) {
|
|
106
|
+
content += `### Documentation
|
|
107
|
+
|
|
108
|
+
`;
|
|
109
|
+
for (const doc of docs) {
|
|
110
|
+
content += `#### ${doc.name} (from ${doc.capabilityId})
|
|
111
|
+
|
|
112
|
+
${doc.content}
|
|
113
|
+
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Add rules section if there are rules
|
|
119
|
+
if (rules.length > 0) {
|
|
120
|
+
content += `### Rules
|
|
121
|
+
|
|
122
|
+
`;
|
|
123
|
+
for (const rule of rules) {
|
|
124
|
+
content += `#### ${rule.name} (from ${rule.capabilityId})
|
|
125
|
+
|
|
126
|
+
${rule.content}
|
|
127
|
+
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return content.trim();
|
|
133
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { loadSkills } from "./skills";
|
|
5
|
+
|
|
6
|
+
describe("loadSkills", () => {
|
|
7
|
+
const testDir = join(process.cwd(), "test-skills-temp");
|
|
8
|
+
const capabilityPath = join(testDir, "test-capability");
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
mkdirSync(capabilityPath, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (testDir) {
|
|
16
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("returns empty array when skills directory does not exist", async () => {
|
|
21
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
22
|
+
expect(skills).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("returns empty array when skills directory is empty", async () => {
|
|
26
|
+
mkdirSync(join(capabilityPath, "skills"), { recursive: true });
|
|
27
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
28
|
+
expect(skills).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("loads single skill with valid frontmatter and instructions", async () => {
|
|
32
|
+
const skillsDir = join(capabilityPath, "skills", "test-skill");
|
|
33
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
34
|
+
|
|
35
|
+
const skillContent = `---
|
|
36
|
+
name: test-skill
|
|
37
|
+
description: A test skill
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
# Instructions
|
|
41
|
+
|
|
42
|
+
This is a test skill.`;
|
|
43
|
+
|
|
44
|
+
writeFileSync(join(skillsDir, "SKILL.md"), skillContent);
|
|
45
|
+
|
|
46
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
47
|
+
|
|
48
|
+
expect(skills).toHaveLength(1);
|
|
49
|
+
expect(skills[0]).toEqual({
|
|
50
|
+
name: "test-skill",
|
|
51
|
+
description: "A test skill",
|
|
52
|
+
instructions: "# Instructions\n\nThis is a test skill.",
|
|
53
|
+
capabilityId: "test-cap",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("loads multiple skills from different directories", async () => {
|
|
58
|
+
const skill1Dir = join(capabilityPath, "skills", "skill-1");
|
|
59
|
+
const skill2Dir = join(capabilityPath, "skills", "skill-2");
|
|
60
|
+
mkdirSync(skill1Dir, { recursive: true });
|
|
61
|
+
mkdirSync(skill2Dir, { recursive: true });
|
|
62
|
+
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(skill1Dir, "SKILL.md"),
|
|
65
|
+
`---
|
|
66
|
+
name: skill-1
|
|
67
|
+
description: First skill
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
Instructions for skill 1.`,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
writeFileSync(
|
|
74
|
+
join(skill2Dir, "SKILL.md"),
|
|
75
|
+
`---
|
|
76
|
+
name: skill-2
|
|
77
|
+
description: Second skill
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
Instructions for skill 2.`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
84
|
+
|
|
85
|
+
expect(skills).toHaveLength(2);
|
|
86
|
+
expect(skills[0]?.name).toBe("skill-1");
|
|
87
|
+
expect(skills[1]?.name).toBe("skill-2");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("skips skill directories without SKILL.md file", async () => {
|
|
91
|
+
const validSkillDir = join(capabilityPath, "skills", "valid-skill");
|
|
92
|
+
const invalidSkillDir = join(capabilityPath, "skills", "no-skill-file");
|
|
93
|
+
mkdirSync(validSkillDir, { recursive: true });
|
|
94
|
+
mkdirSync(invalidSkillDir, { recursive: true });
|
|
95
|
+
|
|
96
|
+
writeFileSync(
|
|
97
|
+
join(validSkillDir, "SKILL.md"),
|
|
98
|
+
`---
|
|
99
|
+
name: valid-skill
|
|
100
|
+
description: Valid skill
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
Valid instructions.`,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// No SKILL.md in invalidSkillDir
|
|
107
|
+
|
|
108
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
109
|
+
|
|
110
|
+
expect(skills).toHaveLength(1);
|
|
111
|
+
expect(skills[0]?.name).toBe("valid-skill");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("handles YAML frontmatter with quoted values", async () => {
|
|
115
|
+
const skillsDir = join(capabilityPath, "skills", "quoted-skill");
|
|
116
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
const skillContent = `---
|
|
119
|
+
name: "quoted-skill"
|
|
120
|
+
description: "A skill with quoted values"
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
Instructions here.`;
|
|
124
|
+
|
|
125
|
+
writeFileSync(join(skillsDir, "SKILL.md"), skillContent);
|
|
126
|
+
|
|
127
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
128
|
+
|
|
129
|
+
expect(skills).toHaveLength(1);
|
|
130
|
+
expect(skills[0]?.name).toBe("quoted-skill");
|
|
131
|
+
expect(skills[0]?.description).toBe("A skill with quoted values");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("handles YAML frontmatter with colons in values", async () => {
|
|
135
|
+
const skillsDir = join(capabilityPath, "skills", "colon-skill");
|
|
136
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
137
|
+
|
|
138
|
+
const skillContent = `---
|
|
139
|
+
name: colon-skill
|
|
140
|
+
description: A skill with a colon: in the description
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
Instructions here.`;
|
|
144
|
+
|
|
145
|
+
writeFileSync(join(skillsDir, "SKILL.md"), skillContent);
|
|
146
|
+
|
|
147
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
148
|
+
|
|
149
|
+
expect(skills).toHaveLength(1);
|
|
150
|
+
expect(skills[0]?.description).toBe("A skill with a colon: in the description");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("trims whitespace from instructions", async () => {
|
|
154
|
+
const skillsDir = join(capabilityPath, "skills", "whitespace-skill");
|
|
155
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
156
|
+
|
|
157
|
+
const skillContent = `---
|
|
158
|
+
name: whitespace-skill
|
|
159
|
+
description: Test whitespace trimming
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
Instructions with leading/trailing whitespace.
|
|
164
|
+
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
writeFileSync(join(skillsDir, "SKILL.md"), skillContent);
|
|
168
|
+
|
|
169
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
170
|
+
|
|
171
|
+
expect(skills).toHaveLength(1);
|
|
172
|
+
expect(skills[0]?.instructions).toBe("Instructions with leading/trailing whitespace.");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("throws error when SKILL.md has no frontmatter", async () => {
|
|
176
|
+
const skillsDir = join(capabilityPath, "skills", "no-frontmatter");
|
|
177
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
178
|
+
|
|
179
|
+
const skillContent = `# Just Instructions
|
|
180
|
+
|
|
181
|
+
No frontmatter here.`;
|
|
182
|
+
|
|
183
|
+
writeFileSync(join(skillsDir, "SKILL.md"), skillContent);
|
|
184
|
+
|
|
185
|
+
await expect(loadSkills(capabilityPath, "test-cap")).rejects.toThrow(
|
|
186
|
+
/Invalid SKILL\.md format.*missing YAML frontmatter/,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("throws error when SKILL.md is missing name field", async () => {
|
|
191
|
+
const skillsDir = join(capabilityPath, "skills", "missing-name");
|
|
192
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
193
|
+
|
|
194
|
+
const skillContent = `---
|
|
195
|
+
description: Missing name field
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
Instructions here.`;
|
|
199
|
+
|
|
200
|
+
writeFileSync(join(skillsDir, "SKILL.md"), skillContent);
|
|
201
|
+
|
|
202
|
+
await expect(loadSkills(capabilityPath, "test-cap")).rejects.toThrow(
|
|
203
|
+
/name and description required/,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("throws error when SKILL.md is missing description field", async () => {
|
|
208
|
+
const skillsDir = join(capabilityPath, "skills", "missing-description");
|
|
209
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
210
|
+
|
|
211
|
+
const skillContent = `---
|
|
212
|
+
name: missing-description
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
Instructions here.`;
|
|
216
|
+
|
|
217
|
+
writeFileSync(join(skillsDir, "SKILL.md"), skillContent);
|
|
218
|
+
|
|
219
|
+
await expect(loadSkills(capabilityPath, "test-cap")).rejects.toThrow(
|
|
220
|
+
/name and description required/,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("handles empty instructions after frontmatter", async () => {
|
|
225
|
+
const skillsDir = join(capabilityPath, "skills", "empty-instructions");
|
|
226
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
227
|
+
|
|
228
|
+
const skillContent = `---
|
|
229
|
+
name: empty-instructions
|
|
230
|
+
description: Skill with no instructions
|
|
231
|
+
---
|
|
232
|
+
`;
|
|
233
|
+
|
|
234
|
+
writeFileSync(join(skillsDir, "SKILL.md"), skillContent);
|
|
235
|
+
|
|
236
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
237
|
+
|
|
238
|
+
expect(skills).toHaveLength(1);
|
|
239
|
+
expect(skills[0]?.instructions).toBe("");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("preserves markdown formatting in instructions", async () => {
|
|
243
|
+
const skillsDir = join(capabilityPath, "skills", "markdown-skill");
|
|
244
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
245
|
+
|
|
246
|
+
const skillContent = `---
|
|
247
|
+
name: markdown-skill
|
|
248
|
+
description: Skill with markdown
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
# Header
|
|
252
|
+
|
|
253
|
+
- List item 1
|
|
254
|
+
- List item 2
|
|
255
|
+
|
|
256
|
+
**Bold text** and *italic text*.
|
|
257
|
+
|
|
258
|
+
\`\`\`typescript
|
|
259
|
+
const code = "example";
|
|
260
|
+
\`\`\``;
|
|
261
|
+
|
|
262
|
+
writeFileSync(join(skillsDir, "SKILL.md"), skillContent);
|
|
263
|
+
|
|
264
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
265
|
+
|
|
266
|
+
expect(skills).toHaveLength(1);
|
|
267
|
+
expect(skills[0]?.instructions).toContain("# Header");
|
|
268
|
+
expect(skills[0]?.instructions).toContain("- List item 1");
|
|
269
|
+
expect(skills[0]?.instructions).toContain("**Bold text**");
|
|
270
|
+
expect(skills[0]?.instructions).toContain("```typescript");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("ignores non-directory entries in skills folder", async () => {
|
|
274
|
+
const skillsDir = join(capabilityPath, "skills");
|
|
275
|
+
const validSkillDir = join(skillsDir, "valid-skill");
|
|
276
|
+
mkdirSync(validSkillDir, { recursive: true });
|
|
277
|
+
|
|
278
|
+
writeFileSync(
|
|
279
|
+
join(validSkillDir, "SKILL.md"),
|
|
280
|
+
`---
|
|
281
|
+
name: valid-skill
|
|
282
|
+
description: Valid skill
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
Valid instructions.`,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// Create a file directly in skills/ directory (should be ignored)
|
|
289
|
+
writeFileSync(join(skillsDir, "README.md"), "This should be ignored");
|
|
290
|
+
|
|
291
|
+
const skills = await loadSkills(capabilityPath, "test-cap");
|
|
292
|
+
|
|
293
|
+
expect(skills).toHaveLength(1);
|
|
294
|
+
expect(skills[0]?.name).toBe("valid-skill");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("associates skills with correct capability ID", async () => {
|
|
298
|
+
const skillsDir = join(capabilityPath, "skills", "test-skill");
|
|
299
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
300
|
+
|
|
301
|
+
writeFileSync(
|
|
302
|
+
join(skillsDir, "SKILL.md"),
|
|
303
|
+
`---
|
|
304
|
+
name: test-skill
|
|
305
|
+
description: Test skill
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
Instructions.`,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const skills = await loadSkills(capabilityPath, "my-capability");
|
|
312
|
+
|
|
313
|
+
expect(skills).toHaveLength(1);
|
|
314
|
+
expect(skills[0]?.capabilityId).toBe("my-capability");
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
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 });
|
|
20
|
+
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
const skillPath = join(skillsDir, entry.name, "SKILL.md");
|
|
24
|
+
if (existsSync(skillPath)) {
|
|
25
|
+
const skill = await parseSkillFile(skillPath, capabilityId);
|
|
26
|
+
skills.push(skill);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return skills;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function parseSkillFile(filePath: string, capabilityId: string): Promise<Skill> {
|
|
35
|
+
const content = await Bun.file(filePath).text();
|
|
36
|
+
|
|
37
|
+
const parsed = parseFrontmatterWithMarkdown<SkillFrontmatter>(content);
|
|
38
|
+
|
|
39
|
+
if (!parsed) {
|
|
40
|
+
throw new Error(`Invalid SKILL.md format at ${filePath}: missing YAML frontmatter`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const frontmatter = parsed.frontmatter;
|
|
44
|
+
const instructions = parsed.markdown;
|
|
45
|
+
|
|
46
|
+
if (!frontmatter.name || !frontmatter.description) {
|
|
47
|
+
throw new Error(`Invalid SKILL.md at ${filePath}: name and description required`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
name: frontmatter.name,
|
|
52
|
+
description: frontmatter.description,
|
|
53
|
+
instructions: instructions.trim(),
|
|
54
|
+
capabilityId,
|
|
55
|
+
};
|
|
56
|
+
}
|