@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.
Files changed (59) hide show
  1. package/package.json +31 -0
  2. package/src/capability/AGENTS.md +58 -0
  3. package/src/capability/commands.test.ts +414 -0
  4. package/src/capability/commands.ts +70 -0
  5. package/src/capability/docs.test.ts +199 -0
  6. package/src/capability/docs.ts +46 -0
  7. package/src/capability/index.ts +20 -0
  8. package/src/capability/loader.test.ts +815 -0
  9. package/src/capability/loader.ts +492 -0
  10. package/src/capability/registry.test.ts +473 -0
  11. package/src/capability/registry.ts +55 -0
  12. package/src/capability/rules.test.ts +145 -0
  13. package/src/capability/rules.ts +133 -0
  14. package/src/capability/skills.test.ts +316 -0
  15. package/src/capability/skills.ts +56 -0
  16. package/src/capability/sources.test.ts +338 -0
  17. package/src/capability/sources.ts +966 -0
  18. package/src/capability/subagents.test.ts +478 -0
  19. package/src/capability/subagents.ts +103 -0
  20. package/src/capability/yaml-parser.ts +81 -0
  21. package/src/config/AGENTS.md +46 -0
  22. package/src/config/capabilities.ts +82 -0
  23. package/src/config/env.test.ts +286 -0
  24. package/src/config/env.ts +96 -0
  25. package/src/config/index.ts +6 -0
  26. package/src/config/loader.test.ts +282 -0
  27. package/src/config/loader.ts +137 -0
  28. package/src/config/parser.test.ts +281 -0
  29. package/src/config/parser.ts +55 -0
  30. package/src/config/profiles.test.ts +259 -0
  31. package/src/config/profiles.ts +75 -0
  32. package/src/config/provider.test.ts +79 -0
  33. package/src/config/provider.ts +55 -0
  34. package/src/debug.ts +20 -0
  35. package/src/gitignore/manager.test.ts +219 -0
  36. package/src/gitignore/manager.ts +167 -0
  37. package/src/index.test.ts +26 -0
  38. package/src/index.ts +39 -0
  39. package/src/mcp-json/index.ts +1 -0
  40. package/src/mcp-json/manager.test.ts +415 -0
  41. package/src/mcp-json/manager.ts +118 -0
  42. package/src/state/active-profile.test.ts +131 -0
  43. package/src/state/active-profile.ts +41 -0
  44. package/src/state/index.ts +2 -0
  45. package/src/state/manifest.test.ts +548 -0
  46. package/src/state/manifest.ts +164 -0
  47. package/src/sync.ts +213 -0
  48. package/src/templates/agents.test.ts +23 -0
  49. package/src/templates/agents.ts +14 -0
  50. package/src/templates/claude.test.ts +48 -0
  51. package/src/templates/claude.ts +122 -0
  52. package/src/test-utils/helpers.test.ts +196 -0
  53. package/src/test-utils/helpers.ts +187 -0
  54. package/src/test-utils/index.ts +30 -0
  55. package/src/test-utils/mocks.test.ts +83 -0
  56. package/src/test-utils/mocks.ts +101 -0
  57. package/src/types/capability-export.ts +234 -0
  58. package/src/types/index.test.ts +28 -0
  59. 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
+ }