@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,548 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import type { LoadedCapability } from "../types";
|
|
6
|
+
import {
|
|
7
|
+
buildManifestFromCapabilities,
|
|
8
|
+
cleanupStaleResources,
|
|
9
|
+
loadManifest,
|
|
10
|
+
saveManifest,
|
|
11
|
+
type ResourceManifest,
|
|
12
|
+
} from "./manifest";
|
|
13
|
+
|
|
14
|
+
describe("manifest", () => {
|
|
15
|
+
let originalCwd: string;
|
|
16
|
+
let tempDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
originalCwd = process.cwd();
|
|
20
|
+
tempDir = mkdtempSync(join(tmpdir(), "manifest-test-"));
|
|
21
|
+
mkdirSync(join(tempDir, ".omni"), { recursive: true });
|
|
22
|
+
process.chdir(tempDir);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
process.chdir(originalCwd);
|
|
27
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("loadManifest", () => {
|
|
31
|
+
test("returns empty manifest when file does not exist", async () => {
|
|
32
|
+
const manifest = await loadManifest();
|
|
33
|
+
|
|
34
|
+
expect(manifest.version).toBe(1);
|
|
35
|
+
expect(manifest.capabilities).toEqual({});
|
|
36
|
+
expect(manifest.syncedAt).toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("loads existing manifest from disk", async () => {
|
|
40
|
+
const existingManifest: ResourceManifest = {
|
|
41
|
+
version: 1,
|
|
42
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
43
|
+
capabilities: {
|
|
44
|
+
"test-cap": {
|
|
45
|
+
skills: ["skill1"],
|
|
46
|
+
rules: ["rule1"],
|
|
47
|
+
commands: ["cmd1"],
|
|
48
|
+
subagents: ["agent1"],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
await Bun.write(".omni/state/manifest.json", JSON.stringify(existingManifest));
|
|
54
|
+
|
|
55
|
+
const manifest = await loadManifest();
|
|
56
|
+
|
|
57
|
+
expect(manifest).toEqual(existingManifest);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("saveManifest", () => {
|
|
62
|
+
test("creates state directory and writes manifest", async () => {
|
|
63
|
+
const manifest: ResourceManifest = {
|
|
64
|
+
version: 1,
|
|
65
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
66
|
+
capabilities: {
|
|
67
|
+
"my-cap": {
|
|
68
|
+
skills: ["s1", "s2"],
|
|
69
|
+
rules: ["r1"],
|
|
70
|
+
commands: [],
|
|
71
|
+
subagents: [],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
await saveManifest(manifest);
|
|
77
|
+
|
|
78
|
+
const content = await Bun.file(".omni/state/manifest.json").text();
|
|
79
|
+
expect(JSON.parse(content)).toEqual(manifest);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("buildManifestFromCapabilities", () => {
|
|
84
|
+
test("builds manifest from loaded capabilities", () => {
|
|
85
|
+
const capabilities: LoadedCapability[] = [
|
|
86
|
+
{
|
|
87
|
+
id: "cap1",
|
|
88
|
+
path: "/path/to/cap1",
|
|
89
|
+
config: { capability: { id: "cap1", name: "Cap 1", version: "1.0.0", description: "" } },
|
|
90
|
+
skills: [
|
|
91
|
+
{ name: "skill-a", description: "A", instructions: "", capabilityId: "cap1" },
|
|
92
|
+
{ name: "skill-b", description: "B", instructions: "", capabilityId: "cap1" },
|
|
93
|
+
],
|
|
94
|
+
rules: [{ name: "rule-a", content: "", capabilityId: "cap1" }],
|
|
95
|
+
docs: [],
|
|
96
|
+
subagents: [
|
|
97
|
+
{
|
|
98
|
+
name: "agent-a",
|
|
99
|
+
description: "",
|
|
100
|
+
systemPrompt: "",
|
|
101
|
+
capabilityId: "cap1",
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
commands: [
|
|
105
|
+
{
|
|
106
|
+
name: "cmd-a",
|
|
107
|
+
description: "",
|
|
108
|
+
prompt: "",
|
|
109
|
+
capabilityId: "cap1",
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
exports: {},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "cap2",
|
|
116
|
+
path: "/path/to/cap2",
|
|
117
|
+
config: { capability: { id: "cap2", name: "Cap 2", version: "1.0.0", description: "" } },
|
|
118
|
+
skills: [],
|
|
119
|
+
rules: [],
|
|
120
|
+
docs: [],
|
|
121
|
+
subagents: [],
|
|
122
|
+
commands: [],
|
|
123
|
+
exports: {},
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const manifest = buildManifestFromCapabilities(capabilities);
|
|
128
|
+
|
|
129
|
+
expect(manifest.version).toBe(1);
|
|
130
|
+
expect(manifest.capabilities.cap1).toEqual({
|
|
131
|
+
skills: ["skill-a", "skill-b"],
|
|
132
|
+
rules: ["rule-a"],
|
|
133
|
+
commands: ["cmd-a"],
|
|
134
|
+
subagents: ["agent-a"],
|
|
135
|
+
});
|
|
136
|
+
expect(manifest.capabilities.cap2).toEqual({
|
|
137
|
+
skills: [],
|
|
138
|
+
rules: [],
|
|
139
|
+
commands: [],
|
|
140
|
+
subagents: [],
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("handles empty capabilities array", () => {
|
|
145
|
+
const manifest = buildManifestFromCapabilities([]);
|
|
146
|
+
|
|
147
|
+
expect(manifest.version).toBe(1);
|
|
148
|
+
expect(manifest.capabilities).toEqual({});
|
|
149
|
+
expect(manifest.syncedAt).toBeDefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("includes MCP entry when capability has MCP config", () => {
|
|
153
|
+
const capabilities: LoadedCapability[] = [
|
|
154
|
+
{
|
|
155
|
+
id: "context7",
|
|
156
|
+
path: "/path/to/context7",
|
|
157
|
+
config: {
|
|
158
|
+
capability: {
|
|
159
|
+
id: "context7",
|
|
160
|
+
name: "Context7",
|
|
161
|
+
version: "1.0.0",
|
|
162
|
+
description: "",
|
|
163
|
+
},
|
|
164
|
+
mcp: {
|
|
165
|
+
command: "npx",
|
|
166
|
+
args: ["-y", "@upstash/context7-mcp"],
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
skills: [],
|
|
170
|
+
rules: [],
|
|
171
|
+
docs: [],
|
|
172
|
+
subagents: [],
|
|
173
|
+
commands: [],
|
|
174
|
+
exports: {},
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const manifest = buildManifestFromCapabilities(capabilities);
|
|
179
|
+
|
|
180
|
+
expect(manifest.capabilities.context7.mcp).toEqual({
|
|
181
|
+
serverName: "omni-context7",
|
|
182
|
+
command: "npx",
|
|
183
|
+
args: ["-y", "@upstash/context7-mcp"],
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("includes MCP env when present", () => {
|
|
188
|
+
const capabilities: LoadedCapability[] = [
|
|
189
|
+
{
|
|
190
|
+
id: "my-mcp",
|
|
191
|
+
path: "/path/to/my-mcp",
|
|
192
|
+
config: {
|
|
193
|
+
capability: {
|
|
194
|
+
id: "my-mcp",
|
|
195
|
+
name: "My MCP",
|
|
196
|
+
version: "1.0.0",
|
|
197
|
+
description: "",
|
|
198
|
+
},
|
|
199
|
+
mcp: {
|
|
200
|
+
command: "node",
|
|
201
|
+
args: ["server.js"],
|
|
202
|
+
env: { API_KEY: "secret" },
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
skills: [],
|
|
206
|
+
rules: [],
|
|
207
|
+
docs: [],
|
|
208
|
+
subagents: [],
|
|
209
|
+
commands: [],
|
|
210
|
+
exports: {},
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const manifest = buildManifestFromCapabilities(capabilities);
|
|
215
|
+
|
|
216
|
+
expect(manifest.capabilities["my-mcp"].mcp).toEqual({
|
|
217
|
+
serverName: "omni-my-mcp",
|
|
218
|
+
command: "node",
|
|
219
|
+
args: ["server.js"],
|
|
220
|
+
env: { API_KEY: "secret" },
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("does not include MCP entry when capability has no MCP config", () => {
|
|
225
|
+
const capabilities: LoadedCapability[] = [
|
|
226
|
+
{
|
|
227
|
+
id: "tasks",
|
|
228
|
+
path: "/path/to/tasks",
|
|
229
|
+
config: {
|
|
230
|
+
capability: { id: "tasks", name: "Tasks", version: "1.0.0", description: "" },
|
|
231
|
+
},
|
|
232
|
+
skills: [],
|
|
233
|
+
rules: [],
|
|
234
|
+
docs: [],
|
|
235
|
+
subagents: [],
|
|
236
|
+
commands: [],
|
|
237
|
+
exports: {},
|
|
238
|
+
},
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
const manifest = buildManifestFromCapabilities(capabilities);
|
|
242
|
+
|
|
243
|
+
expect(manifest.capabilities.tasks.mcp).toBeUndefined();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("handles mixed capabilities with and without MCP", () => {
|
|
247
|
+
const capabilities: LoadedCapability[] = [
|
|
248
|
+
{
|
|
249
|
+
id: "tasks",
|
|
250
|
+
path: "/path/to/tasks",
|
|
251
|
+
config: {
|
|
252
|
+
capability: { id: "tasks", name: "Tasks", version: "1.0.0", description: "" },
|
|
253
|
+
},
|
|
254
|
+
skills: [],
|
|
255
|
+
rules: [],
|
|
256
|
+
docs: [],
|
|
257
|
+
subagents: [],
|
|
258
|
+
commands: [],
|
|
259
|
+
exports: {},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: "context7",
|
|
263
|
+
path: "/path/to/context7",
|
|
264
|
+
config: {
|
|
265
|
+
capability: {
|
|
266
|
+
id: "context7",
|
|
267
|
+
name: "Context7",
|
|
268
|
+
version: "1.0.0",
|
|
269
|
+
description: "",
|
|
270
|
+
},
|
|
271
|
+
mcp: {
|
|
272
|
+
command: "npx",
|
|
273
|
+
args: ["context7-mcp"],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
skills: [],
|
|
277
|
+
rules: [],
|
|
278
|
+
docs: [],
|
|
279
|
+
subagents: [],
|
|
280
|
+
commands: [],
|
|
281
|
+
exports: {},
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
const manifest = buildManifestFromCapabilities(capabilities);
|
|
286
|
+
|
|
287
|
+
expect(manifest.capabilities.tasks.mcp).toBeUndefined();
|
|
288
|
+
expect(manifest.capabilities.context7.mcp).toBeDefined();
|
|
289
|
+
expect(manifest.capabilities.context7.mcp?.serverName).toBe("omni-context7");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("saveManifest and loadManifest round-trip", () => {
|
|
294
|
+
test("save then load returns same manifest", async () => {
|
|
295
|
+
const manifest: ResourceManifest = {
|
|
296
|
+
version: 1,
|
|
297
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
298
|
+
capabilities: {
|
|
299
|
+
cap1: { skills: ["s1"], rules: ["r1"], commands: ["c1"], subagents: ["a1"] },
|
|
300
|
+
cap2: { skills: [], rules: [], commands: [], subagents: [] },
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
await saveManifest(manifest);
|
|
305
|
+
const loaded = await loadManifest();
|
|
306
|
+
|
|
307
|
+
expect(loaded).toEqual(manifest);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("overwrites existing manifest", async () => {
|
|
311
|
+
const manifest1: ResourceManifest = {
|
|
312
|
+
version: 1,
|
|
313
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
314
|
+
capabilities: { old: { skills: ["old"], rules: [], commands: [], subagents: [] } },
|
|
315
|
+
};
|
|
316
|
+
const manifest2: ResourceManifest = {
|
|
317
|
+
version: 1,
|
|
318
|
+
syncedAt: "2025-01-02T00:00:00.000Z",
|
|
319
|
+
capabilities: { new: { skills: ["new"], rules: [], commands: [], subagents: [] } },
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
await saveManifest(manifest1);
|
|
323
|
+
await saveManifest(manifest2);
|
|
324
|
+
const loaded = await loadManifest();
|
|
325
|
+
|
|
326
|
+
expect(loaded).toEqual(manifest2);
|
|
327
|
+
expect(loaded.capabilities.old).toBeUndefined();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("cleanupStaleResources", () => {
|
|
332
|
+
test("deletes skills and rules from disabled capabilities", async () => {
|
|
333
|
+
// Create skill directory
|
|
334
|
+
await Bun.write(".claude/skills/old-skill/SKILL.md", "old skill content");
|
|
335
|
+
|
|
336
|
+
// Create rule file
|
|
337
|
+
await Bun.write(".cursor/rules/omnidev-old-rule.mdc", "old rule content");
|
|
338
|
+
|
|
339
|
+
const previousManifest: ResourceManifest = {
|
|
340
|
+
version: 1,
|
|
341
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
342
|
+
capabilities: {
|
|
343
|
+
"disabled-cap": {
|
|
344
|
+
skills: ["old-skill"],
|
|
345
|
+
rules: ["old-rule"],
|
|
346
|
+
commands: [],
|
|
347
|
+
subagents: [],
|
|
348
|
+
},
|
|
349
|
+
"enabled-cap": {
|
|
350
|
+
skills: ["keep-skill"],
|
|
351
|
+
rules: ["keep-rule"],
|
|
352
|
+
commands: [],
|
|
353
|
+
subagents: [],
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Only enabled-cap is in the current set
|
|
359
|
+
const currentCapabilityIds = new Set(["enabled-cap"]);
|
|
360
|
+
|
|
361
|
+
const result = await cleanupStaleResources(previousManifest, currentCapabilityIds);
|
|
362
|
+
|
|
363
|
+
expect(result.deletedSkills).toEqual(["old-skill"]);
|
|
364
|
+
expect(result.deletedRules).toEqual(["old-rule"]);
|
|
365
|
+
|
|
366
|
+
// Verify files are deleted
|
|
367
|
+
const { existsSync } = await import("node:fs");
|
|
368
|
+
expect(existsSync(".claude/skills/old-skill")).toBe(false);
|
|
369
|
+
expect(existsSync(".cursor/rules/omnidev-old-rule.mdc")).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("preserves resources from still-enabled capabilities", async () => {
|
|
373
|
+
// Create skill directory for enabled capability
|
|
374
|
+
await Bun.write(".claude/skills/keep-skill/SKILL.md", "keep this");
|
|
375
|
+
|
|
376
|
+
const previousManifest: ResourceManifest = {
|
|
377
|
+
version: 1,
|
|
378
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
379
|
+
capabilities: {
|
|
380
|
+
"enabled-cap": {
|
|
381
|
+
skills: ["keep-skill"],
|
|
382
|
+
rules: [],
|
|
383
|
+
commands: [],
|
|
384
|
+
subagents: [],
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const currentCapabilityIds = new Set(["enabled-cap"]);
|
|
390
|
+
|
|
391
|
+
const result = await cleanupStaleResources(previousManifest, currentCapabilityIds);
|
|
392
|
+
|
|
393
|
+
expect(result.deletedSkills).toEqual([]);
|
|
394
|
+
|
|
395
|
+
// Verify file still exists
|
|
396
|
+
const { existsSync } = await import("node:fs");
|
|
397
|
+
expect(existsSync(".claude/skills/keep-skill/SKILL.md")).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("handles missing files gracefully", async () => {
|
|
401
|
+
const previousManifest: ResourceManifest = {
|
|
402
|
+
version: 1,
|
|
403
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
404
|
+
capabilities: {
|
|
405
|
+
"disabled-cap": {
|
|
406
|
+
skills: ["nonexistent-skill"],
|
|
407
|
+
rules: ["nonexistent-rule"],
|
|
408
|
+
commands: [],
|
|
409
|
+
subagents: [],
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const currentCapabilityIds = new Set<string>();
|
|
415
|
+
|
|
416
|
+
// Should not throw
|
|
417
|
+
const result = await cleanupStaleResources(previousManifest, currentCapabilityIds);
|
|
418
|
+
|
|
419
|
+
expect(result.deletedSkills).toEqual([]);
|
|
420
|
+
expect(result.deletedRules).toEqual([]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("handles empty previous manifest", async () => {
|
|
424
|
+
const previousManifest: ResourceManifest = {
|
|
425
|
+
version: 1,
|
|
426
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
427
|
+
capabilities: {},
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const currentCapabilityIds = new Set(["new-cap"]);
|
|
431
|
+
|
|
432
|
+
const result = await cleanupStaleResources(previousManifest, currentCapabilityIds);
|
|
433
|
+
|
|
434
|
+
expect(result.deletedSkills).toEqual([]);
|
|
435
|
+
expect(result.deletedRules).toEqual([]);
|
|
436
|
+
expect(result.deletedCommands).toEqual([]);
|
|
437
|
+
expect(result.deletedSubagents).toEqual([]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("deletes multiple skills and rules from same capability", async () => {
|
|
441
|
+
// Create multiple skills
|
|
442
|
+
await Bun.write(".claude/skills/skill-1/SKILL.md", "skill 1");
|
|
443
|
+
await Bun.write(".claude/skills/skill-2/SKILL.md", "skill 2");
|
|
444
|
+
await Bun.write(".claude/skills/skill-3/SKILL.md", "skill 3");
|
|
445
|
+
|
|
446
|
+
// Create multiple rules
|
|
447
|
+
await Bun.write(".cursor/rules/omnidev-rule-1.mdc", "rule 1");
|
|
448
|
+
await Bun.write(".cursor/rules/omnidev-rule-2.mdc", "rule 2");
|
|
449
|
+
|
|
450
|
+
const previousManifest: ResourceManifest = {
|
|
451
|
+
version: 1,
|
|
452
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
453
|
+
capabilities: {
|
|
454
|
+
"multi-resource-cap": {
|
|
455
|
+
skills: ["skill-1", "skill-2", "skill-3"],
|
|
456
|
+
rules: ["rule-1", "rule-2"],
|
|
457
|
+
commands: [],
|
|
458
|
+
subagents: [],
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const currentCapabilityIds = new Set<string>();
|
|
464
|
+
|
|
465
|
+
const result = await cleanupStaleResources(previousManifest, currentCapabilityIds);
|
|
466
|
+
|
|
467
|
+
expect(result.deletedSkills).toEqual(["skill-1", "skill-2", "skill-3"]);
|
|
468
|
+
expect(result.deletedRules).toEqual(["rule-1", "rule-2"]);
|
|
469
|
+
|
|
470
|
+
const { existsSync } = await import("node:fs");
|
|
471
|
+
expect(existsSync(".claude/skills/skill-1")).toBe(false);
|
|
472
|
+
expect(existsSync(".claude/skills/skill-2")).toBe(false);
|
|
473
|
+
expect(existsSync(".claude/skills/skill-3")).toBe(false);
|
|
474
|
+
expect(existsSync(".cursor/rules/omnidev-rule-1.mdc")).toBe(false);
|
|
475
|
+
expect(existsSync(".cursor/rules/omnidev-rule-2.mdc")).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("deletes resources from multiple disabled capabilities", async () => {
|
|
479
|
+
// Create resources for multiple capabilities
|
|
480
|
+
await Bun.write(".claude/skills/cap1-skill/SKILL.md", "cap1 skill");
|
|
481
|
+
await Bun.write(".claude/skills/cap2-skill/SKILL.md", "cap2 skill");
|
|
482
|
+
await Bun.write(".cursor/rules/omnidev-cap1-rule.mdc", "cap1 rule");
|
|
483
|
+
await Bun.write(".cursor/rules/omnidev-cap2-rule.mdc", "cap2 rule");
|
|
484
|
+
|
|
485
|
+
const previousManifest: ResourceManifest = {
|
|
486
|
+
version: 1,
|
|
487
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
488
|
+
capabilities: {
|
|
489
|
+
cap1: {
|
|
490
|
+
skills: ["cap1-skill"],
|
|
491
|
+
rules: ["cap1-rule"],
|
|
492
|
+
commands: [],
|
|
493
|
+
subagents: [],
|
|
494
|
+
},
|
|
495
|
+
cap2: {
|
|
496
|
+
skills: ["cap2-skill"],
|
|
497
|
+
rules: ["cap2-rule"],
|
|
498
|
+
commands: [],
|
|
499
|
+
subagents: [],
|
|
500
|
+
},
|
|
501
|
+
cap3: {
|
|
502
|
+
skills: ["cap3-skill"],
|
|
503
|
+
rules: [],
|
|
504
|
+
commands: [],
|
|
505
|
+
subagents: [],
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// Only cap3 remains enabled
|
|
511
|
+
const currentCapabilityIds = new Set(["cap3"]);
|
|
512
|
+
|
|
513
|
+
const result = await cleanupStaleResources(previousManifest, currentCapabilityIds);
|
|
514
|
+
|
|
515
|
+
expect(result.deletedSkills).toContain("cap1-skill");
|
|
516
|
+
expect(result.deletedSkills).toContain("cap2-skill");
|
|
517
|
+
expect(result.deletedSkills).not.toContain("cap3-skill");
|
|
518
|
+
expect(result.deletedRules).toContain("cap1-rule");
|
|
519
|
+
expect(result.deletedRules).toContain("cap2-rule");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("cleans up when all capabilities are disabled", async () => {
|
|
523
|
+
await Bun.write(".claude/skills/only-skill/SKILL.md", "only");
|
|
524
|
+
await Bun.write(".cursor/rules/omnidev-only-rule.mdc", "only");
|
|
525
|
+
|
|
526
|
+
const previousManifest: ResourceManifest = {
|
|
527
|
+
version: 1,
|
|
528
|
+
syncedAt: "2025-01-01T00:00:00.000Z",
|
|
529
|
+
capabilities: {
|
|
530
|
+
"the-only-cap": {
|
|
531
|
+
skills: ["only-skill"],
|
|
532
|
+
rules: ["only-rule"],
|
|
533
|
+
commands: [],
|
|
534
|
+
subagents: [],
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// Empty set - all capabilities disabled
|
|
540
|
+
const currentCapabilityIds = new Set<string>();
|
|
541
|
+
|
|
542
|
+
const result = await cleanupStaleResources(previousManifest, currentCapabilityIds);
|
|
543
|
+
|
|
544
|
+
expect(result.deletedSkills).toEqual(["only-skill"]);
|
|
545
|
+
expect(result.deletedRules).toEqual(["only-rule"]);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import type { LoadedCapability } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MCP entry for a capability
|
|
6
|
+
*/
|
|
7
|
+
export interface McpEntry {
|
|
8
|
+
/** Server name in .mcp.json (e.g., "omni-{capabilityId}") */
|
|
9
|
+
serverName: string;
|
|
10
|
+
/** Command to run the MCP server */
|
|
11
|
+
command: string;
|
|
12
|
+
/** Arguments for the command */
|
|
13
|
+
args?: string[];
|
|
14
|
+
/** Environment variables */
|
|
15
|
+
env?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resources provided by a single capability
|
|
20
|
+
*/
|
|
21
|
+
export interface CapabilityResources {
|
|
22
|
+
skills: string[];
|
|
23
|
+
rules: string[];
|
|
24
|
+
commands: string[];
|
|
25
|
+
subagents: string[];
|
|
26
|
+
/** MCP configuration if capability has [mcp] section */
|
|
27
|
+
mcp?: McpEntry;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Manifest tracking which resources each capability provides.
|
|
32
|
+
* Used to clean up stale resources when capabilities are disabled.
|
|
33
|
+
*/
|
|
34
|
+
export interface ResourceManifest {
|
|
35
|
+
/** Schema version for future migrations */
|
|
36
|
+
version: 1;
|
|
37
|
+
/** Last sync timestamp (ISO 8601) */
|
|
38
|
+
syncedAt: string;
|
|
39
|
+
/** Map of capability ID → resources it provides */
|
|
40
|
+
capabilities: Record<string, CapabilityResources>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Result of cleaning up stale resources
|
|
45
|
+
*/
|
|
46
|
+
export interface CleanupResult {
|
|
47
|
+
deletedSkills: string[];
|
|
48
|
+
deletedRules: string[];
|
|
49
|
+
deletedCommands: string[];
|
|
50
|
+
deletedSubagents: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const MANIFEST_PATH = ".omni/state/manifest.json";
|
|
54
|
+
const CURRENT_VERSION = 1;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Load the previous manifest from disk.
|
|
58
|
+
* Returns an empty manifest if the file doesn't exist.
|
|
59
|
+
*/
|
|
60
|
+
export async function loadManifest(): Promise<ResourceManifest> {
|
|
61
|
+
if (!existsSync(MANIFEST_PATH)) {
|
|
62
|
+
return {
|
|
63
|
+
version: CURRENT_VERSION,
|
|
64
|
+
syncedAt: new Date().toISOString(),
|
|
65
|
+
capabilities: {},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const content = await Bun.file(MANIFEST_PATH).text();
|
|
70
|
+
return JSON.parse(content) as ResourceManifest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Save the manifest to disk.
|
|
75
|
+
*/
|
|
76
|
+
export async function saveManifest(manifest: ResourceManifest): Promise<void> {
|
|
77
|
+
mkdirSync(".omni/state", { recursive: true });
|
|
78
|
+
await Bun.write(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build a manifest from the current registry capabilities.
|
|
83
|
+
*/
|
|
84
|
+
export function buildManifestFromCapabilities(capabilities: LoadedCapability[]): ResourceManifest {
|
|
85
|
+
const manifest: ResourceManifest = {
|
|
86
|
+
version: CURRENT_VERSION,
|
|
87
|
+
syncedAt: new Date().toISOString(),
|
|
88
|
+
capabilities: {},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for (const cap of capabilities) {
|
|
92
|
+
const resources: CapabilityResources = {
|
|
93
|
+
skills: cap.skills.map((s) => s.name),
|
|
94
|
+
rules: cap.rules.map((r) => r.name),
|
|
95
|
+
commands: cap.commands.map((c) => c.name),
|
|
96
|
+
subagents: cap.subagents.map((s) => s.name),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Track MCP if capability has one
|
|
100
|
+
if (cap.config.mcp) {
|
|
101
|
+
const mcpEntry: McpEntry = {
|
|
102
|
+
serverName: `omni-${cap.id}`,
|
|
103
|
+
command: cap.config.mcp.command,
|
|
104
|
+
};
|
|
105
|
+
if (cap.config.mcp.args) {
|
|
106
|
+
mcpEntry.args = cap.config.mcp.args;
|
|
107
|
+
}
|
|
108
|
+
if (cap.config.mcp.env) {
|
|
109
|
+
mcpEntry.env = cap.config.mcp.env;
|
|
110
|
+
}
|
|
111
|
+
resources.mcp = mcpEntry;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
manifest.capabilities[cap.id] = resources;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return manifest;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Delete resources for capabilities that are no longer enabled.
|
|
122
|
+
* Compares the previous manifest against current capability IDs
|
|
123
|
+
* and removes files/directories for capabilities not in the current set.
|
|
124
|
+
*/
|
|
125
|
+
export async function cleanupStaleResources(
|
|
126
|
+
previousManifest: ResourceManifest,
|
|
127
|
+
currentCapabilityIds: Set<string>,
|
|
128
|
+
): Promise<CleanupResult> {
|
|
129
|
+
const result: CleanupResult = {
|
|
130
|
+
deletedSkills: [],
|
|
131
|
+
deletedRules: [],
|
|
132
|
+
deletedCommands: [],
|
|
133
|
+
deletedSubagents: [],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
for (const [capId, resources] of Object.entries(previousManifest.capabilities)) {
|
|
137
|
+
// Skip if capability is still enabled
|
|
138
|
+
if (currentCapabilityIds.has(capId)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Delete skills (directories)
|
|
143
|
+
for (const skillName of resources.skills) {
|
|
144
|
+
const skillDir = `.claude/skills/${skillName}`;
|
|
145
|
+
if (existsSync(skillDir)) {
|
|
146
|
+
rmSync(skillDir, { recursive: true });
|
|
147
|
+
result.deletedSkills.push(skillName);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Delete rules (individual files)
|
|
152
|
+
for (const ruleName of resources.rules) {
|
|
153
|
+
const rulePath = `.cursor/rules/omnidev-${ruleName}.mdc`;
|
|
154
|
+
if (existsSync(rulePath)) {
|
|
155
|
+
rmSync(rulePath);
|
|
156
|
+
result.deletedRules.push(ruleName);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Future: Delete commands and subagents if they become file-based
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return result;
|
|
164
|
+
}
|