@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,478 @@
|
|
|
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 { loadSubagents } from "./subagents";
|
|
5
|
+
|
|
6
|
+
describe("loadSubagents", () => {
|
|
7
|
+
const testDir = join(process.cwd(), "test-subagents-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 subagents directory does not exist", async () => {
|
|
21
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
22
|
+
expect(subagents).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("returns empty array when subagents directory is empty", async () => {
|
|
26
|
+
mkdirSync(join(capabilityPath, "subagents"), { recursive: true });
|
|
27
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
28
|
+
expect(subagents).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("loads single subagent with valid frontmatter and system prompt", async () => {
|
|
32
|
+
const subagentDir = join(capabilityPath, "subagents", "test-subagent");
|
|
33
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
34
|
+
|
|
35
|
+
const subagentContent = `---
|
|
36
|
+
name: test-subagent
|
|
37
|
+
description: A test subagent
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
You are a test subagent.
|
|
41
|
+
|
|
42
|
+
## Instructions
|
|
43
|
+
|
|
44
|
+
Do testing things.`;
|
|
45
|
+
|
|
46
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
47
|
+
|
|
48
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
49
|
+
|
|
50
|
+
expect(subagents).toHaveLength(1);
|
|
51
|
+
expect(subagents[0]).toEqual({
|
|
52
|
+
name: "test-subagent",
|
|
53
|
+
description: "A test subagent",
|
|
54
|
+
systemPrompt: "You are a test subagent.\n\n## Instructions\n\nDo testing things.",
|
|
55
|
+
capabilityId: "test-cap",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("loads subagent with tools field", async () => {
|
|
60
|
+
const subagentDir = join(capabilityPath, "subagents", "tools-subagent");
|
|
61
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
62
|
+
|
|
63
|
+
const subagentContent = `---
|
|
64
|
+
name: tools-subagent
|
|
65
|
+
description: Subagent with tools
|
|
66
|
+
tools: Read, Glob, Grep
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
System prompt here.`;
|
|
70
|
+
|
|
71
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
72
|
+
|
|
73
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
74
|
+
|
|
75
|
+
expect(subagents).toHaveLength(1);
|
|
76
|
+
expect(subagents[0]?.tools).toEqual(["Read", "Glob", "Grep"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("loads subagent with disallowedTools field", async () => {
|
|
80
|
+
const subagentDir = join(capabilityPath, "subagents", "disallowed-subagent");
|
|
81
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
const subagentContent = `---
|
|
84
|
+
name: disallowed-subagent
|
|
85
|
+
description: Subagent with disallowed tools
|
|
86
|
+
disallowedTools: Write, Edit
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
System prompt here.`;
|
|
90
|
+
|
|
91
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
92
|
+
|
|
93
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
94
|
+
|
|
95
|
+
expect(subagents).toHaveLength(1);
|
|
96
|
+
expect(subagents[0]?.disallowedTools).toEqual(["Write", "Edit"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("loads subagent with model field", async () => {
|
|
100
|
+
const subagentDir = join(capabilityPath, "subagents", "model-subagent");
|
|
101
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
const subagentContent = `---
|
|
104
|
+
name: model-subagent
|
|
105
|
+
description: Subagent with model
|
|
106
|
+
model: haiku
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
System prompt here.`;
|
|
110
|
+
|
|
111
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
112
|
+
|
|
113
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
114
|
+
|
|
115
|
+
expect(subagents).toHaveLength(1);
|
|
116
|
+
expect(subagents[0]?.model).toBe("haiku");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("loads subagent with permissionMode field", async () => {
|
|
120
|
+
const subagentDir = join(capabilityPath, "subagents", "permission-subagent");
|
|
121
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
122
|
+
|
|
123
|
+
const subagentContent = `---
|
|
124
|
+
name: permission-subagent
|
|
125
|
+
description: Subagent with permission mode
|
|
126
|
+
permissionMode: dontAsk
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
System prompt here.`;
|
|
130
|
+
|
|
131
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
132
|
+
|
|
133
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
134
|
+
|
|
135
|
+
expect(subagents).toHaveLength(1);
|
|
136
|
+
expect(subagents[0]?.permissionMode).toBe("dontAsk");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("loads subagent with skills field", async () => {
|
|
140
|
+
const subagentDir = join(capabilityPath, "subagents", "skills-subagent");
|
|
141
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
142
|
+
|
|
143
|
+
const subagentContent = `---
|
|
144
|
+
name: skills-subagent
|
|
145
|
+
description: Subagent with skills
|
|
146
|
+
skills: prd, ralph
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
System prompt here.`;
|
|
150
|
+
|
|
151
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
152
|
+
|
|
153
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
154
|
+
|
|
155
|
+
expect(subagents).toHaveLength(1);
|
|
156
|
+
expect(subagents[0]?.skills).toEqual(["prd", "ralph"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("loads subagent with all optional fields", async () => {
|
|
160
|
+
const subagentDir = join(capabilityPath, "subagents", "full-subagent");
|
|
161
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
162
|
+
|
|
163
|
+
const subagentContent = `---
|
|
164
|
+
name: full-subagent
|
|
165
|
+
description: Subagent with all fields
|
|
166
|
+
tools: Read, Glob, Grep, Bash
|
|
167
|
+
disallowedTools: Write, Edit
|
|
168
|
+
model: sonnet
|
|
169
|
+
permissionMode: acceptEdits
|
|
170
|
+
skills: prd, ralph, mcp-builder
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
You are a fully configured subagent.`;
|
|
174
|
+
|
|
175
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
176
|
+
|
|
177
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
178
|
+
|
|
179
|
+
expect(subagents).toHaveLength(1);
|
|
180
|
+
expect(subagents[0]?.name).toBe("full-subagent");
|
|
181
|
+
expect(subagents[0]?.description).toBe("Subagent with all fields");
|
|
182
|
+
expect(subagents[0]?.tools).toEqual(["Read", "Glob", "Grep", "Bash"]);
|
|
183
|
+
expect(subagents[0]?.disallowedTools).toEqual(["Write", "Edit"]);
|
|
184
|
+
expect(subagents[0]?.model).toBe("sonnet");
|
|
185
|
+
expect(subagents[0]?.permissionMode).toBe("acceptEdits");
|
|
186
|
+
expect(subagents[0]?.skills).toEqual(["prd", "ralph", "mcp-builder"]);
|
|
187
|
+
expect(subagents[0]?.systemPrompt).toBe("You are a fully configured subagent.");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("loads multiple subagents from different directories", async () => {
|
|
191
|
+
const subagent1Dir = join(capabilityPath, "subagents", "subagent-1");
|
|
192
|
+
const subagent2Dir = join(capabilityPath, "subagents", "subagent-2");
|
|
193
|
+
mkdirSync(subagent1Dir, { recursive: true });
|
|
194
|
+
mkdirSync(subagent2Dir, { recursive: true });
|
|
195
|
+
|
|
196
|
+
writeFileSync(
|
|
197
|
+
join(subagent1Dir, "SUBAGENT.md"),
|
|
198
|
+
`---
|
|
199
|
+
name: subagent-1
|
|
200
|
+
description: First subagent
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
First subagent prompt.`,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
writeFileSync(
|
|
207
|
+
join(subagent2Dir, "SUBAGENT.md"),
|
|
208
|
+
`---
|
|
209
|
+
name: subagent-2
|
|
210
|
+
description: Second subagent
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
Second subagent prompt.`,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
217
|
+
|
|
218
|
+
expect(subagents).toHaveLength(2);
|
|
219
|
+
expect(subagents[0]?.name).toBe("subagent-1");
|
|
220
|
+
expect(subagents[1]?.name).toBe("subagent-2");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("skips subagent directories without SUBAGENT.md file", async () => {
|
|
224
|
+
const validDir = join(capabilityPath, "subagents", "valid-subagent");
|
|
225
|
+
const invalidDir = join(capabilityPath, "subagents", "no-file");
|
|
226
|
+
mkdirSync(validDir, { recursive: true });
|
|
227
|
+
mkdirSync(invalidDir, { recursive: true });
|
|
228
|
+
|
|
229
|
+
writeFileSync(
|
|
230
|
+
join(validDir, "SUBAGENT.md"),
|
|
231
|
+
`---
|
|
232
|
+
name: valid-subagent
|
|
233
|
+
description: Valid subagent
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
Valid prompt.`,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// No SUBAGENT.md in invalidDir
|
|
240
|
+
|
|
241
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
242
|
+
|
|
243
|
+
expect(subagents).toHaveLength(1);
|
|
244
|
+
expect(subagents[0]?.name).toBe("valid-subagent");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("handles YAML frontmatter with quoted values", async () => {
|
|
248
|
+
const subagentDir = join(capabilityPath, "subagents", "quoted-subagent");
|
|
249
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
250
|
+
|
|
251
|
+
const subagentContent = `---
|
|
252
|
+
name: "quoted-subagent"
|
|
253
|
+
description: "A subagent with quoted values"
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
System prompt here.`;
|
|
257
|
+
|
|
258
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
259
|
+
|
|
260
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
261
|
+
|
|
262
|
+
expect(subagents).toHaveLength(1);
|
|
263
|
+
expect(subagents[0]?.name).toBe("quoted-subagent");
|
|
264
|
+
expect(subagents[0]?.description).toBe("A subagent with quoted values");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("trims whitespace from system prompt", async () => {
|
|
268
|
+
const subagentDir = join(capabilityPath, "subagents", "whitespace-subagent");
|
|
269
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
270
|
+
|
|
271
|
+
const subagentContent = `---
|
|
272
|
+
name: whitespace-subagent
|
|
273
|
+
description: Test whitespace trimming
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
System prompt with leading/trailing whitespace.
|
|
278
|
+
|
|
279
|
+
`;
|
|
280
|
+
|
|
281
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
282
|
+
|
|
283
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
284
|
+
|
|
285
|
+
expect(subagents).toHaveLength(1);
|
|
286
|
+
expect(subagents[0]?.systemPrompt).toBe("System prompt with leading/trailing whitespace.");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("throws error when SUBAGENT.md has no frontmatter", async () => {
|
|
290
|
+
const subagentDir = join(capabilityPath, "subagents", "no-frontmatter");
|
|
291
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
292
|
+
|
|
293
|
+
const subagentContent = `# Just Instructions
|
|
294
|
+
|
|
295
|
+
No frontmatter here.`;
|
|
296
|
+
|
|
297
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
298
|
+
|
|
299
|
+
await expect(loadSubagents(capabilityPath, "test-cap")).rejects.toThrow(
|
|
300
|
+
/Invalid SUBAGENT\.md format.*missing YAML frontmatter/,
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("throws error when SUBAGENT.md is missing name field", async () => {
|
|
305
|
+
const subagentDir = join(capabilityPath, "subagents", "missing-name");
|
|
306
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
307
|
+
|
|
308
|
+
const subagentContent = `---
|
|
309
|
+
description: Missing name field
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
System prompt here.`;
|
|
313
|
+
|
|
314
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
315
|
+
|
|
316
|
+
await expect(loadSubagents(capabilityPath, "test-cap")).rejects.toThrow(
|
|
317
|
+
/name and description required/,
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("throws error when SUBAGENT.md is missing description field", async () => {
|
|
322
|
+
const subagentDir = join(capabilityPath, "subagents", "missing-description");
|
|
323
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
324
|
+
|
|
325
|
+
const subagentContent = `---
|
|
326
|
+
name: missing-description
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
System prompt here.`;
|
|
330
|
+
|
|
331
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
332
|
+
|
|
333
|
+
await expect(loadSubagents(capabilityPath, "test-cap")).rejects.toThrow(
|
|
334
|
+
/name and description required/,
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("handles empty system prompt after frontmatter", async () => {
|
|
339
|
+
const subagentDir = join(capabilityPath, "subagents", "empty-prompt");
|
|
340
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
341
|
+
|
|
342
|
+
const subagentContent = `---
|
|
343
|
+
name: empty-prompt
|
|
344
|
+
description: Subagent with no prompt
|
|
345
|
+
---
|
|
346
|
+
`;
|
|
347
|
+
|
|
348
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
349
|
+
|
|
350
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
351
|
+
|
|
352
|
+
expect(subagents).toHaveLength(1);
|
|
353
|
+
expect(subagents[0]?.systemPrompt).toBe("");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("preserves markdown formatting in system prompt", async () => {
|
|
357
|
+
const subagentDir = join(capabilityPath, "subagents", "markdown-subagent");
|
|
358
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
359
|
+
|
|
360
|
+
const subagentContent = `---
|
|
361
|
+
name: markdown-subagent
|
|
362
|
+
description: Subagent with markdown
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
# Header
|
|
366
|
+
|
|
367
|
+
- List item 1
|
|
368
|
+
- List item 2
|
|
369
|
+
|
|
370
|
+
**Bold text** and *italic text*.
|
|
371
|
+
|
|
372
|
+
\`\`\`typescript
|
|
373
|
+
const code = "example";
|
|
374
|
+
\`\`\``;
|
|
375
|
+
|
|
376
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
377
|
+
|
|
378
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
379
|
+
|
|
380
|
+
expect(subagents).toHaveLength(1);
|
|
381
|
+
expect(subagents[0]?.systemPrompt).toContain("# Header");
|
|
382
|
+
expect(subagents[0]?.systemPrompt).toContain("- List item 1");
|
|
383
|
+
expect(subagents[0]?.systemPrompt).toContain("**Bold text**");
|
|
384
|
+
expect(subagents[0]?.systemPrompt).toContain("```typescript");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("ignores non-directory entries in subagents folder", async () => {
|
|
388
|
+
const subagentsDir = join(capabilityPath, "subagents");
|
|
389
|
+
const validDir = join(subagentsDir, "valid-subagent");
|
|
390
|
+
mkdirSync(validDir, { recursive: true });
|
|
391
|
+
|
|
392
|
+
writeFileSync(
|
|
393
|
+
join(validDir, "SUBAGENT.md"),
|
|
394
|
+
`---
|
|
395
|
+
name: valid-subagent
|
|
396
|
+
description: Valid subagent
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
Valid prompt.`,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// Create a file directly in subagents/ directory (should be ignored)
|
|
403
|
+
writeFileSync(join(subagentsDir, "README.md"), "This should be ignored");
|
|
404
|
+
|
|
405
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
406
|
+
|
|
407
|
+
expect(subagents).toHaveLength(1);
|
|
408
|
+
expect(subagents[0]?.name).toBe("valid-subagent");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("associates subagents with correct capability ID", async () => {
|
|
412
|
+
const subagentDir = join(capabilityPath, "subagents", "test-subagent");
|
|
413
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
414
|
+
|
|
415
|
+
writeFileSync(
|
|
416
|
+
join(subagentDir, "SUBAGENT.md"),
|
|
417
|
+
`---
|
|
418
|
+
name: test-subagent
|
|
419
|
+
description: Test subagent
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
System prompt.`,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
const subagents = await loadSubagents(capabilityPath, "my-capability");
|
|
426
|
+
|
|
427
|
+
expect(subagents).toHaveLength(1);
|
|
428
|
+
expect(subagents[0]?.capabilityId).toBe("my-capability");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("model field accepts inherit value", async () => {
|
|
432
|
+
const subagentDir = join(capabilityPath, "subagents", "inherit-model");
|
|
433
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
434
|
+
|
|
435
|
+
const subagentContent = `---
|
|
436
|
+
name: inherit-model
|
|
437
|
+
description: Subagent with inherit model
|
|
438
|
+
model: inherit
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
System prompt here.`;
|
|
442
|
+
|
|
443
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
444
|
+
|
|
445
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
446
|
+
|
|
447
|
+
expect(subagents).toHaveLength(1);
|
|
448
|
+
expect(subagents[0]?.model).toBe("inherit");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("permissionMode field accepts all valid values", async () => {
|
|
452
|
+
const modes = ["default", "acceptEdits", "dontAsk", "bypassPermissions", "plan"] as const;
|
|
453
|
+
|
|
454
|
+
for (const mode of modes) {
|
|
455
|
+
// Clean up from previous iteration
|
|
456
|
+
const subagentsDir = join(capabilityPath, "subagents");
|
|
457
|
+
rmSync(subagentsDir, { recursive: true, force: true });
|
|
458
|
+
|
|
459
|
+
const subagentDir = join(capabilityPath, "subagents", `${mode}-subagent`);
|
|
460
|
+
mkdirSync(subagentDir, { recursive: true });
|
|
461
|
+
|
|
462
|
+
const subagentContent = `---
|
|
463
|
+
name: ${mode}-subagent
|
|
464
|
+
description: Subagent with ${mode} permission mode
|
|
465
|
+
permissionMode: ${mode}
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
System prompt here.`;
|
|
469
|
+
|
|
470
|
+
writeFileSync(join(subagentDir, "SUBAGENT.md"), subagentContent);
|
|
471
|
+
|
|
472
|
+
const subagents = await loadSubagents(capabilityPath, "test-cap");
|
|
473
|
+
|
|
474
|
+
expect(subagents).toHaveLength(1);
|
|
475
|
+
expect(subagents[0]?.permissionMode).toBe(mode);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Subagent, SubagentHooks, SubagentModel, SubagentPermissionMode } from "../types";
|
|
4
|
+
import { parseFrontmatterWithMarkdown } from "./yaml-parser";
|
|
5
|
+
|
|
6
|
+
interface SubagentFrontmatter {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
tools?: string;
|
|
10
|
+
disallowedTools?: string;
|
|
11
|
+
model?: SubagentModel;
|
|
12
|
+
permissionMode?: SubagentPermissionMode;
|
|
13
|
+
skills?: string;
|
|
14
|
+
hooks?: SubagentHooks;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Load subagents from the subagents/ directory of a capability.
|
|
19
|
+
* Each subagent is a SUBAGENT.md file in its own subdirectory.
|
|
20
|
+
*/
|
|
21
|
+
export async function loadSubagents(
|
|
22
|
+
capabilityPath: string,
|
|
23
|
+
capabilityId: string,
|
|
24
|
+
): Promise<Subagent[]> {
|
|
25
|
+
const subagentsDir = join(capabilityPath, "subagents");
|
|
26
|
+
|
|
27
|
+
if (!existsSync(subagentsDir)) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const subagents: Subagent[] = [];
|
|
32
|
+
const entries = readdirSync(subagentsDir, { withFileTypes: true });
|
|
33
|
+
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
const subagentPath = join(subagentsDir, entry.name, "SUBAGENT.md");
|
|
37
|
+
if (existsSync(subagentPath)) {
|
|
38
|
+
const subagent = await parseSubagentFile(subagentPath, capabilityId);
|
|
39
|
+
subagents.push(subagent);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return subagents;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function parseSubagentFile(filePath: string, capabilityId: string): Promise<Subagent> {
|
|
48
|
+
const content = await Bun.file(filePath).text();
|
|
49
|
+
|
|
50
|
+
const parsed = parseFrontmatterWithMarkdown<SubagentFrontmatter>(content);
|
|
51
|
+
|
|
52
|
+
if (!parsed) {
|
|
53
|
+
throw new Error(`Invalid SUBAGENT.md format at ${filePath}: missing YAML frontmatter`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const frontmatter = parsed.frontmatter;
|
|
57
|
+
const systemPrompt = parsed.markdown;
|
|
58
|
+
|
|
59
|
+
if (!frontmatter.name || !frontmatter.description) {
|
|
60
|
+
throw new Error(`Invalid SUBAGENT.md at ${filePath}: name and description required`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result: Subagent = {
|
|
64
|
+
name: frontmatter.name,
|
|
65
|
+
description: frontmatter.description,
|
|
66
|
+
systemPrompt: systemPrompt.trim(),
|
|
67
|
+
capabilityId,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Add optional fields if present
|
|
71
|
+
if (frontmatter.tools) {
|
|
72
|
+
result.tools = parseCommaSeparatedList(frontmatter.tools);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (frontmatter.disallowedTools) {
|
|
76
|
+
result.disallowedTools = parseCommaSeparatedList(frontmatter.disallowedTools);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (frontmatter.model) {
|
|
80
|
+
result.model = frontmatter.model;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (frontmatter.permissionMode) {
|
|
84
|
+
result.permissionMode = frontmatter.permissionMode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (frontmatter.skills) {
|
|
88
|
+
result.skills = parseCommaSeparatedList(frontmatter.skills);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (frontmatter.hooks) {
|
|
92
|
+
result.hooks = frontmatter.hooks;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseCommaSeparatedList(value: string): string[] {
|
|
99
|
+
return value
|
|
100
|
+
.split(",")
|
|
101
|
+
.map((item) => item.trim())
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML Frontmatter Parser Utility
|
|
3
|
+
*
|
|
4
|
+
* Consolidates YAML frontmatter parsing logic that was duplicated across
|
|
5
|
+
* skills.ts, commands.ts, and subagents.ts.
|
|
6
|
+
*
|
|
7
|
+
* This utility provides:
|
|
8
|
+
* - parseSimpleYamlFrontmatter: Parse key: value pairs from YAML
|
|
9
|
+
* - parseFrontmatterWithMarkdown: Extract frontmatter + markdown content
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse simple YAML key: value pairs
|
|
14
|
+
* Supports basic key: value syntax (not full YAML spec)
|
|
15
|
+
* Handles quoted and unquoted values
|
|
16
|
+
*
|
|
17
|
+
* @param yaml - YAML content string to parse
|
|
18
|
+
* @returns Record of key-value pairs
|
|
19
|
+
*/
|
|
20
|
+
export function parseSimpleYamlFrontmatter<T>(yaml: string): T {
|
|
21
|
+
const result: Record<string, string> = {};
|
|
22
|
+
|
|
23
|
+
for (const line of yaml.split("\n")) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Support: key: value and key: "quoted value"
|
|
30
|
+
const colonIndex = trimmed.indexOf(":");
|
|
31
|
+
if (colonIndex === -1) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rawKey = trimmed.slice(0, colonIndex).trim();
|
|
36
|
+
let value = trimmed.slice(colonIndex + 1).trim();
|
|
37
|
+
|
|
38
|
+
// Remove quotes if present (double or single)
|
|
39
|
+
if (
|
|
40
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
41
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
42
|
+
) {
|
|
43
|
+
value = value.slice(1, -1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Convert kebab-case to camelCase
|
|
47
|
+
const key = rawKey.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
48
|
+
|
|
49
|
+
result[key] = value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return result as unknown as T;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse content with YAML frontmatter separated from markdown
|
|
57
|
+
* Expected format:
|
|
58
|
+
* ```
|
|
59
|
+
* ---
|
|
60
|
+
* key: value
|
|
61
|
+
* ---
|
|
62
|
+
* Markdown content here
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* @param content - Full content with frontmatter and markdown
|
|
66
|
+
* @returns Object with frontmatter (parsed) and markdown (remaining) or null if no frontmatter
|
|
67
|
+
*/
|
|
68
|
+
export function parseFrontmatterWithMarkdown<T>(
|
|
69
|
+
content: string,
|
|
70
|
+
): { frontmatter: T; markdown: string } | null {
|
|
71
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/s);
|
|
72
|
+
|
|
73
|
+
if (!match?.[1] || match[2] === undefined) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const frontmatter = parseSimpleYamlFrontmatter<T>(match[1]);
|
|
78
|
+
const markdown = match[2];
|
|
79
|
+
|
|
80
|
+
return { frontmatter, markdown };
|
|
81
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# CONFIGURATION SUBSYSTEM
|
|
2
|
+
|
|
3
|
+
**Generated:** 2026-01-12T10:36:46
|
|
4
|
+
**Commit:** (not specified)
|
|
5
|
+
**Branch:** (not specified)
|
|
6
|
+
|
|
7
|
+
## OVERVIEW
|
|
8
|
+
Configuration loading and parsing system using TOML format with profile-based capability management.
|
|
9
|
+
|
|
10
|
+
## WHERE TO LOOK
|
|
11
|
+
| Task | Location | Notes |
|
|
12
|
+
|------|----------|-------|
|
|
13
|
+
| Config merge logic | loader.ts | Merges config.toml + config.local.toml |
|
|
14
|
+
| Environment loading | env.ts | Loads .omni/.env, validates declarations |
|
|
15
|
+
| TOML parsing | parser.ts | Uses smol-toml, validates capability.toml |
|
|
16
|
+
| Provider selection | provider.ts | Loads provider.toml, parses CLI flags |
|
|
17
|
+
| Profile management | profiles.ts | Active profile tracking, resolves capabilities |
|
|
18
|
+
| Capability enable/disable | capabilities.ts | Updates profiles + gitignore patterns |
|
|
19
|
+
|
|
20
|
+
## CONVENTIONS
|
|
21
|
+
|
|
22
|
+
**Config File Locations:**
|
|
23
|
+
- `.omni/config.toml` - main config (project name, default profile, profiles)
|
|
24
|
+
- `.omni/config.local.toml` - local overrides (gitignored)
|
|
25
|
+
- `.omni/provider.toml` - provider selection (claude/codex/both)
|
|
26
|
+
- `.omni/.env` - secrets, always gitignored
|
|
27
|
+
|
|
28
|
+
**Profile-Based Capability Management:**
|
|
29
|
+
- Profiles define capability sets in `[profiles.name].capabilities`
|
|
30
|
+
- `active_profile` in config.toml selects current profile
|
|
31
|
+
- `always_enabled_capabilities` list merged with profile capabilities
|
|
32
|
+
- Use `enableCapability()` / `disableCapability()` to update active profile
|
|
33
|
+
|
|
34
|
+
**Environment Variable Handling:**
|
|
35
|
+
- capability.toml `[env]` section declares requirements
|
|
36
|
+
- `required = true` → must be present or default
|
|
37
|
+
- `secret = true` → masked in logs/error messages
|
|
38
|
+
- `default = "value"` → optional with fallback
|
|
39
|
+
|
|
40
|
+
## ANTI-PATTERNS (THIS SUBSYSTEM)
|
|
41
|
+
|
|
42
|
+
- **NEVER** edit config.toml directly for capability changes - use enableCapability()/disableCapability()
|
|
43
|
+
- **NEVER** commit .omni/.env or config.local.toml - both gitignored
|
|
44
|
+
- **NEVER** parse TOML manually - use parseOmniConfig() / parseCapabilityConfig()
|
|
45
|
+
- **NEVER** ignore validateEnv() errors - required env vars block capability load
|
|
46
|
+
- **NEVER** assume process.env is complete - merge with .omni/.env via loadEnvironment()
|