@kuznai/inception-engine 0.18.0 → 0.20.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 (109) hide show
  1. package/README.md +72 -43
  2. package/dist/{config → src/config}/agents.js +174 -24
  3. package/dist/src/core/adapters/agent-definitions.d.ts +8 -0
  4. package/dist/src/core/adapters/agent-definitions.js +162 -0
  5. package/dist/{core → src/core}/adapters/frontmatter.d.ts +10 -0
  6. package/dist/{core → src/core}/adapters/frontmatter.js +23 -6
  7. package/dist/{core → src/core}/adapters/index.d.ts +1 -1
  8. package/dist/{core → src/core}/adapters/index.js +4 -4
  9. package/dist/{core → src/core}/adapters/mcp.d.ts +2 -2
  10. package/dist/{core → src/core}/adapters/mcp.js +21 -24
  11. package/dist/{core → src/core}/adapters/permissions.js +16 -20
  12. package/dist/{core → src/core}/adapters/rules.d.ts +2 -2
  13. package/dist/src/core/adapters/rules.js +126 -0
  14. package/dist/src/core/capabilities.d.ts +47 -0
  15. package/dist/src/core/capabilities.js +280 -0
  16. package/dist/{core → src/core}/deploy.d.ts +1 -1
  17. package/dist/{core → src/core}/deploy.js +273 -92
  18. package/dist/{core → src/core}/init.js +126 -52
  19. package/dist/src/core/merge-patch.d.ts +4 -0
  20. package/dist/src/core/merge-patch.js +46 -0
  21. package/dist/{core → src/core}/ownership.d.ts +2 -2
  22. package/dist/{core → src/core}/ownership.js +13 -0
  23. package/dist/{core → src/core}/preflight.d.ts +1 -1
  24. package/dist/src/core/preflight.js +280 -0
  25. package/dist/{core → src/core}/resolve.d.ts +1 -1
  26. package/dist/{core → src/core}/resolve.js +7 -4
  27. package/dist/{core → src/core}/revert.js +95 -33
  28. package/dist/{core → src/core}/runtime-paths.d.ts +2 -1
  29. package/dist/{core → src/core}/runtime-paths.js +30 -23
  30. package/dist/{core → src/core}/validation.d.ts +10 -1
  31. package/dist/{core → src/core}/validation.js +75 -0
  32. package/dist/src/formatters.d.ts +5 -0
  33. package/dist/src/formatters.js +58 -0
  34. package/dist/{index.js → src/index.js} +8 -24
  35. package/dist/src/logger.js +62 -0
  36. package/dist/{schemas → src/schemas}/manifest.d.ts +22 -0
  37. package/dist/{schemas → src/schemas}/manifest.js +19 -5
  38. package/dist/{schemas → src/schemas}/registry.d.ts +74 -0
  39. package/dist/{schemas → src/schemas}/registry.js +18 -3
  40. package/dist/{types.d.ts → src/types.d.ts} +60 -0
  41. package/dist/test/helpers/fs.d.ts +2 -0
  42. package/dist/test/helpers/fs.js +17 -0
  43. package/dist/test/helpers/path.d.ts +10 -0
  44. package/dist/test/helpers/path.js +15 -0
  45. package/dist/test/helpers/skill-dir-scenarios.d.ts +7 -0
  46. package/dist/test/helpers/skill-dir-scenarios.js +206 -0
  47. package/dist/test/helpers/skill-dir.d.ts +7 -0
  48. package/dist/test/helpers/skill-dir.js +46 -0
  49. package/dist/test/os/cross-platform/cross-platform.test.d.ts +1 -0
  50. package/dist/test/os/cross-platform/cross-platform.test.js +328 -0
  51. package/dist/test/os/posix/skill-dir.test.d.ts +1 -0
  52. package/dist/test/os/posix/skill-dir.test.js +19 -0
  53. package/dist/test/os/windows/revert-integration.test.d.ts +1 -0
  54. package/dist/test/os/windows/revert-integration.test.js +72 -0
  55. package/dist/test/os/windows/skill-dir.test.d.ts +1 -0
  56. package/dist/test/os/windows/skill-dir.test.js +19 -0
  57. package/dist/test/unit/adapters.test.d.ts +1 -0
  58. package/dist/test/unit/adapters.test.js +947 -0
  59. package/dist/test/unit/capabilities.test.d.ts +1 -0
  60. package/dist/test/unit/capabilities.test.js +71 -0
  61. package/dist/test/unit/cli.test.d.ts +1 -0
  62. package/dist/test/unit/cli.test.js +834 -0
  63. package/dist/test/unit/deploy.test.d.ts +1 -0
  64. package/dist/test/unit/deploy.test.js +2481 -0
  65. package/dist/test/unit/detect.test.d.ts +1 -0
  66. package/dist/test/unit/detect.test.js +192 -0
  67. package/dist/test/unit/enterprise.test.d.ts +1 -0
  68. package/dist/test/unit/enterprise.test.js +106 -0
  69. package/dist/test/unit/frontmatter.test.d.ts +1 -0
  70. package/dist/test/unit/frontmatter.test.js +102 -0
  71. package/dist/test/unit/gemini-north-star.test.d.ts +1 -0
  72. package/dist/test/unit/gemini-north-star.test.js +243 -0
  73. package/dist/test/unit/manifest.test.d.ts +1 -0
  74. package/dist/test/unit/manifest.test.js +737 -0
  75. package/dist/test/unit/migration-golden.test.d.ts +1 -0
  76. package/dist/test/unit/migration-golden.test.js +197 -0
  77. package/dist/test/unit/ownership.test.d.ts +1 -0
  78. package/dist/test/unit/ownership.test.js +587 -0
  79. package/dist/test/unit/preflight.test.d.ts +1 -0
  80. package/dist/test/unit/preflight.test.js +501 -0
  81. package/dist/test/unit/resolve.test.d.ts +1 -0
  82. package/dist/test/unit/resolve.test.js +119 -0
  83. package/dist/test/unit/revert.test.d.ts +1 -0
  84. package/dist/test/unit/revert.test.js +1004 -0
  85. package/dist/test/unit/toml.test.d.ts +1 -0
  86. package/dist/test/unit/toml.test.js +55 -0
  87. package/package.json +1 -1
  88. package/dist/core/adapters/agent-definitions.d.ts +0 -20
  89. package/dist/core/adapters/agent-definitions.js +0 -90
  90. package/dist/core/adapters/rules.js +0 -103
  91. package/dist/core/preflight.js +0 -98
  92. package/dist/logger.js +0 -61
  93. /package/dist/{config → src/config}/agents.d.ts +0 -0
  94. /package/dist/{config → src/config}/manifest.d.ts +0 -0
  95. /package/dist/{config → src/config}/manifest.js +0 -0
  96. /package/dist/{core → src/core}/adapters/permissions.d.ts +0 -0
  97. /package/dist/{core → src/core}/adapters/toml.d.ts +0 -0
  98. /package/dist/{core → src/core}/adapters/toml.js +0 -0
  99. /package/dist/{core → src/core}/detect.d.ts +0 -0
  100. /package/dist/{core → src/core}/detect.js +0 -0
  101. /package/dist/{core → src/core}/init.d.ts +0 -0
  102. /package/dist/{core → src/core}/revert.d.ts +0 -0
  103. /package/dist/{errors.d.ts → src/errors.d.ts} +0 -0
  104. /package/dist/{errors.js → src/errors.js} +0 -0
  105. /package/dist/{index.d.ts → src/index.d.ts} +0 -0
  106. /package/dist/{logger.d.ts → src/logger.d.ts} +0 -0
  107. /package/dist/{schemas → src/schemas}/errors.d.ts +0 -0
  108. /package/dist/{schemas → src/schemas}/errors.js +0 -0
  109. /package/dist/{types.js → src/types.js} +0 -0
@@ -0,0 +1,1004 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmod, mkdir, readFile, rm, symlink, writeFile, } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { lookupDeployment, registerDeployment, } from "../../src/core/ownership.js";
6
+ import { executeRevert, planRevert, planRevertAll, } from "../../src/core/revert.js";
7
+ import { logger } from "../../src/logger.js";
8
+ import { exists, makeTmpDir } from "../helpers/fs.js";
9
+ logger.silence();
10
+ const testManifest = {
11
+ skills: [
12
+ { name: "test-skill", path: "skills/test-skill", agents: ["claude-code"] },
13
+ ],
14
+ files: [],
15
+ configs: [],
16
+ mcpServers: [],
17
+ agentRules: [],
18
+ permissions: [],
19
+ agentDefinitions: [],
20
+ };
21
+ describe("planRevert", () => {
22
+ it("creates revert actions for detected agents", () => {
23
+ const actions = planRevert(testManifest, ["claude-code"], "/home/test");
24
+ assert.equal(actions.length, 1);
25
+ assert.equal(actions[0]?.skill, "test-skill");
26
+ assert.equal(actions[0]?.agent, "claude-code");
27
+ });
28
+ it("skips agents not detected", () => {
29
+ const actions = planRevert(testManifest, ["codex"], "/home/test");
30
+ assert.equal(actions.length, 0);
31
+ });
32
+ });
33
+ describe("planRevertAll", () => {
34
+ it("creates revert actions for all agents in manifest regardless of detection", () => {
35
+ const multiAgentManifest = {
36
+ skills: [
37
+ {
38
+ name: "test-skill",
39
+ path: "skills/test-skill",
40
+ agents: ["claude-code", "codex", "gemini-cli"],
41
+ },
42
+ ],
43
+ files: [],
44
+ configs: [],
45
+ mcpServers: [],
46
+ agentRules: [],
47
+ permissions: [],
48
+ agentDefinitions: [],
49
+ };
50
+ const actions = planRevertAll(multiAgentManifest, "/home/test");
51
+ assert.equal(actions.length, 3);
52
+ const agentIds = actions.map((a) => a.agent);
53
+ assert.ok(agentIds.includes("claude-code"));
54
+ assert.ok(agentIds.includes("codex"));
55
+ assert.ok(agentIds.includes("gemini-cli"));
56
+ });
57
+ it("keeps skill-dir revert coverage for current agent-definition surfaces", () => {
58
+ const manifest = {
59
+ skills: [
60
+ {
61
+ name: "test-skill",
62
+ path: "skills/test-skill",
63
+ agents: [
64
+ "claude-code",
65
+ "codex",
66
+ "gemini-cli",
67
+ "antigravity",
68
+ "opencode",
69
+ ],
70
+ },
71
+ ],
72
+ files: [],
73
+ configs: [],
74
+ mcpServers: [],
75
+ agentRules: [],
76
+ permissions: [],
77
+ agentDefinitions: [],
78
+ };
79
+ const actions = planRevertAll(manifest, "/home/test");
80
+ assert.equal(actions.length, 5);
81
+ const targets = new Map(actions.map((action) => [action.agent, action.target]));
82
+ assert.match(targets.get("claude-code") ?? "", /\.claude[\\/]skills[\\/]test-skill$/);
83
+ assert.match(targets.get("codex") ?? "", /\.codex[\\/]skills[\\/]test-skill$/);
84
+ assert.match(targets.get("gemini-cli") ?? "", /\.gemini[\\/]skills[\\/]test-skill$/);
85
+ assert.match(targets.get("antigravity") ?? "", /\.gemini[\\/]antigravity[\\/]skills[\\/]test-skill$/);
86
+ assert.match(targets.get("opencode") ?? "", /opencode[\\/]skills[\\/]test-skill$/);
87
+ });
88
+ });
89
+ describe("executeRevert", { skip: process.platform === "win32" }, () => {
90
+ it("removes a symlink registered in the deployment registry", async () => {
91
+ const home = await makeTmpDir();
92
+ const sourceDir = await makeTmpDir();
93
+ try {
94
+ const actions = planRevert(testManifest, ["claude-code"], home);
95
+ const target = actions[0]?.target;
96
+ // Create symlink and register in registry
97
+ await mkdir(path.dirname(target), { recursive: true });
98
+ await symlink(sourceDir, target, "dir");
99
+ await registerDeployment(home, target, {
100
+ kind: "skill-dir",
101
+ source: sourceDir,
102
+ skill: "test-skill",
103
+ agent: "claude-code",
104
+ method: "symlink",
105
+ });
106
+ assert.ok(await exists(target));
107
+ const { succeeded, skipped } = await executeRevert(actions, false, false, home);
108
+ assert.equal(succeeded, 1);
109
+ assert.equal(skipped, 0);
110
+ assert.ok(!(await exists(target)));
111
+ // Registry entry should be removed
112
+ const entry = await lookupDeployment(home, target);
113
+ assert.equal(entry, null);
114
+ }
115
+ finally {
116
+ await rm(home, { recursive: true, force: true });
117
+ await rm(sourceDir, { recursive: true });
118
+ }
119
+ });
120
+ it("removes a copied directory registered in the deployment registry", async () => {
121
+ const home = await makeTmpDir();
122
+ try {
123
+ const actions = planRevert(testManifest, ["claude-code"], home);
124
+ const target = actions[0]?.target;
125
+ await mkdir(target, { recursive: true });
126
+ await writeFile(path.join(target, "SKILL.md"), "test");
127
+ await registerDeployment(home, target, {
128
+ kind: "skill-dir",
129
+ source: "/original/source",
130
+ skill: "test-skill",
131
+ agent: "claude-code",
132
+ method: "copy",
133
+ });
134
+ const { succeeded, skipped } = await executeRevert(actions, false, false, home);
135
+ assert.equal(succeeded, 1);
136
+ assert.equal(skipped, 0);
137
+ assert.ok(!(await exists(target)));
138
+ // Registry entry should be removed
139
+ const entry = await lookupDeployment(home, target);
140
+ assert.equal(entry, null);
141
+ }
142
+ finally {
143
+ await rm(home, { recursive: true, force: true });
144
+ }
145
+ });
146
+ it("skips missing targets", async () => {
147
+ const home = await makeTmpDir();
148
+ try {
149
+ const actions = planRevert(testManifest, ["claude-code"], home);
150
+ const { succeeded, skipped } = await executeRevert(actions, false, false, home);
151
+ assert.equal(succeeded, 0);
152
+ assert.equal(skipped, 1);
153
+ }
154
+ finally {
155
+ await rm(home, { recursive: true });
156
+ }
157
+ });
158
+ it("records a failure when removal is blocked by permissions", async () => {
159
+ const home = await makeTmpDir();
160
+ const sourceDir = await makeTmpDir();
161
+ const actions = planRevert(testManifest, ["claude-code"], home);
162
+ const target = actions[0]?.target ?? "";
163
+ const targetParent = path.dirname(target);
164
+ try {
165
+ // Create the target directory and register it
166
+ await mkdir(target, { recursive: true });
167
+ await writeFile(path.join(target, "SKILL.md"), "test");
168
+ await registerDeployment(home, target, {
169
+ kind: "skill-dir",
170
+ source: sourceDir,
171
+ skill: "test-skill",
172
+ agent: "claude-code",
173
+ method: "copy",
174
+ });
175
+ // Make the parent directory non-writable so rm() fails
176
+ await chmod(targetParent, 0o555);
177
+ const { succeeded, skipped, failed } = await executeRevert(actions, false, false, home);
178
+ assert.equal(succeeded, 0);
179
+ assert.equal(skipped, 0);
180
+ assert.equal(failed.length, 1);
181
+ }
182
+ finally {
183
+ await chmod(targetParent, 0o755);
184
+ await rm(home, { recursive: true, force: true });
185
+ await rm(sourceDir, { recursive: true, force: true });
186
+ }
187
+ });
188
+ it("does not remove in dry-run mode", async () => {
189
+ const home = await makeTmpDir();
190
+ const sourceDir = await makeTmpDir();
191
+ try {
192
+ const actions = planRevert(testManifest, ["claude-code"], home);
193
+ const target = actions[0]?.target;
194
+ await mkdir(path.dirname(target), { recursive: true });
195
+ await symlink(sourceDir, target, "dir");
196
+ await registerDeployment(home, target, {
197
+ kind: "skill-dir",
198
+ source: sourceDir,
199
+ skill: "test-skill",
200
+ agent: "claude-code",
201
+ method: "symlink",
202
+ });
203
+ const { succeeded } = await executeRevert(actions, true, false, home);
204
+ assert.equal(succeeded, 1);
205
+ assert.ok(await exists(target), "symlink should still exist after dry-run");
206
+ // Registry entry should still exist after dry-run
207
+ const entry = await lookupDeployment(home, target);
208
+ assert.ok(entry, "registry entry should still exist after dry-run");
209
+ }
210
+ finally {
211
+ await rm(home, { recursive: true, force: true });
212
+ await rm(sourceDir, { recursive: true });
213
+ }
214
+ });
215
+ it("skips target that exists but is not in registry", async () => {
216
+ const home = await makeTmpDir();
217
+ const sourceDir = await makeTmpDir();
218
+ try {
219
+ const actions = planRevert(testManifest, ["claude-code"], home);
220
+ const target = actions[0]?.target;
221
+ // Create symlink but do NOT register — should be treated as unmanaged
222
+ await writeFile(path.join(sourceDir, "SKILL.md"), "---");
223
+ await mkdir(path.dirname(target), { recursive: true });
224
+ await symlink(sourceDir, target, "dir");
225
+ const { succeeded, skipped } = await executeRevert(actions, false, false, home);
226
+ assert.equal(succeeded, 0);
227
+ assert.equal(skipped, 1);
228
+ assert.ok(await exists(target), "symlink should still exist — not managed");
229
+ }
230
+ finally {
231
+ await rm(home, { recursive: true, force: true });
232
+ await rm(sourceDir, { recursive: true });
233
+ }
234
+ });
235
+ it("skips directory that exists but is not in registry", async () => {
236
+ const home = await makeTmpDir();
237
+ try {
238
+ const actions = planRevert(testManifest, ["claude-code"], home);
239
+ const target = actions[0]?.target;
240
+ await mkdir(target, { recursive: true });
241
+ await writeFile(path.join(target, "SKILL.md"), "user content");
242
+ const { succeeded, skipped } = await executeRevert(actions, false, false, home);
243
+ assert.equal(succeeded, 0);
244
+ assert.equal(skipped, 1);
245
+ assert.ok(await exists(target), "directory should still exist — not in registry");
246
+ }
247
+ finally {
248
+ await rm(home, { recursive: true, force: true });
249
+ }
250
+ });
251
+ it("skips target whose registry entry has mismatched skill or agent", async () => {
252
+ const home = await makeTmpDir();
253
+ const sourceDir = await makeTmpDir();
254
+ try {
255
+ const actions = planRevert(testManifest, ["claude-code"], home);
256
+ const target = actions[0]?.target;
257
+ // Create symlink and register under a different skill/agent
258
+ await mkdir(path.dirname(target), { recursive: true });
259
+ await symlink(sourceDir, target, "dir");
260
+ await registerDeployment(home, target, {
261
+ kind: "skill-dir",
262
+ source: sourceDir,
263
+ skill: "different-skill",
264
+ agent: "codex",
265
+ method: "symlink",
266
+ });
267
+ const { succeeded, skipped } = await executeRevert(actions, false, false, home);
268
+ assert.equal(succeeded, 0);
269
+ assert.equal(skipped, 1);
270
+ assert.ok(await exists(target), "symlink should still exist — registry entry does not match");
271
+ }
272
+ finally {
273
+ await rm(home, { recursive: true, force: true });
274
+ await rm(sourceDir, { recursive: true });
275
+ }
276
+ });
277
+ });
278
+ // Copy-method revert does not rely on symlinks and works identically on all
279
+ // platforms. This suite has no Windows guard intentionally.
280
+ describe("executeRevert — copy method (cross-platform)", () => {
281
+ it("removes a copy-deployed skill registered in the deployment registry", async () => {
282
+ const home = await makeTmpDir();
283
+ try {
284
+ const actions = planRevert(testManifest, ["claude-code"], home);
285
+ const target = actions[0]?.target;
286
+ assert.ok(target, "planRevert should produce an action");
287
+ await mkdir(target, { recursive: true });
288
+ await writeFile(path.join(target, "SKILL.md"), "---\nname: test-skill\n");
289
+ await registerDeployment(home, target, {
290
+ kind: "skill-dir",
291
+ source: "/original/source/skills/test-skill",
292
+ skill: "test-skill",
293
+ agent: "claude-code",
294
+ method: "copy",
295
+ });
296
+ assert.ok(await exists(target));
297
+ const { succeeded, skipped } = await executeRevert(actions, false, false, home);
298
+ assert.equal(succeeded, 1);
299
+ assert.equal(skipped, 0);
300
+ assert.ok(!(await exists(target)), "copy directory should be removed");
301
+ const entry = await lookupDeployment(home, target);
302
+ assert.equal(entry, null, "registry entry should be cleared");
303
+ }
304
+ finally {
305
+ await rm(home, { recursive: true, force: true });
306
+ }
307
+ });
308
+ it("skips an unmanaged directory that is not in the registry", async () => {
309
+ const home = await makeTmpDir();
310
+ try {
311
+ const actions = planRevert(testManifest, ["claude-code"], home);
312
+ const target = actions[0]?.target;
313
+ assert.ok(target);
314
+ // Create the directory but do NOT register it
315
+ await mkdir(target, { recursive: true });
316
+ await writeFile(path.join(target, "SKILL.md"), "---\nname: test-skill\n");
317
+ const { succeeded, skipped } = await executeRevert(actions, false, false, home);
318
+ assert.equal(succeeded, 0);
319
+ assert.equal(skipped, 1);
320
+ assert.ok(await exists(target), "unmanaged directory should be untouched");
321
+ }
322
+ finally {
323
+ await rm(home, { recursive: true, force: true });
324
+ }
325
+ });
326
+ it("skips when the target does not exist at all", async () => {
327
+ const home = await makeTmpDir();
328
+ try {
329
+ const actions = planRevert(testManifest, ["claude-code"], home);
330
+ const { succeeded, skipped } = await executeRevert(actions, false, false, home);
331
+ assert.equal(succeeded, 0);
332
+ assert.equal(skipped, 1);
333
+ }
334
+ finally {
335
+ await rm(home, { recursive: true, force: true });
336
+ }
337
+ });
338
+ it("records a failure when unregistering is blocked by permissions on Windows", {
339
+ skip: process.platform !== "win32",
340
+ }, async () => {
341
+ const home = await makeTmpDir();
342
+ const sourceDir = await makeTmpDir();
343
+ const actions = planRevert(testManifest, ["claude-code"], home);
344
+ const target = actions[0]?.target ?? "";
345
+ try {
346
+ await mkdir(target, { recursive: true });
347
+ const blockedFile = path.join(target, "SKILL.md");
348
+ await writeFile(blockedFile, "test");
349
+ await registerDeployment(home, target, {
350
+ kind: "skill-dir",
351
+ source: sourceDir,
352
+ skill: "test-skill",
353
+ agent: "claude-code",
354
+ method: "copy",
355
+ });
356
+ const registryFile = path.join(home, ".inception-engine", "registry.json");
357
+ await chmod(registryFile, 0o444);
358
+ const { succeeded, skipped, failed } = await executeRevert(actions, false, false, home);
359
+ assert.equal(succeeded, 0);
360
+ assert.equal(skipped, 0);
361
+ assert.equal(failed.length, 1);
362
+ }
363
+ finally {
364
+ try {
365
+ await chmod(path.join(home, ".inception-engine", "registry.json"), 0o666);
366
+ }
367
+ catch {
368
+ /* best effort */
369
+ }
370
+ await rm(home, { recursive: true, force: true });
371
+ await rm(sourceDir, { recursive: true, force: true });
372
+ }
373
+ });
374
+ });
375
+ describe("executeRevert — file-write", () => {
376
+ it("deletes the written file and unregisters", async () => {
377
+ const home = await makeTmpDir();
378
+ try {
379
+ const targetFile = path.join(home, "written-file.txt");
380
+ await writeFile(targetFile, "managed content");
381
+ await registerDeployment(home, targetFile, {
382
+ kind: "file-write",
383
+ source: "/some/source.txt",
384
+ skill: "test-skill",
385
+ agent: "claude-code",
386
+ });
387
+ const action = {
388
+ kind: "file-write",
389
+ skill: "test-skill",
390
+ agent: "claude-code",
391
+ target: targetFile,
392
+ };
393
+ const { succeeded, skipped } = await executeRevert([action], false, false, home);
394
+ assert.equal(succeeded, 1);
395
+ assert.equal(skipped, 0);
396
+ assert.ok(!(await exists(targetFile)), "file should be deleted");
397
+ const entry = await lookupDeployment(home, targetFile);
398
+ assert.equal(entry, null, "registry entry should be removed");
399
+ }
400
+ finally {
401
+ await rm(home, { recursive: true, force: true });
402
+ }
403
+ });
404
+ it("skips missing target", async () => {
405
+ const home = await makeTmpDir();
406
+ try {
407
+ const action = {
408
+ kind: "file-write",
409
+ skill: "test-skill",
410
+ agent: "claude-code",
411
+ target: path.join(home, "nonexistent.txt"),
412
+ };
413
+ const { succeeded, skipped } = await executeRevert([action], false, false, home);
414
+ assert.equal(succeeded, 0);
415
+ assert.equal(skipped, 1);
416
+ }
417
+ finally {
418
+ await rm(home, { recursive: true, force: true });
419
+ }
420
+ });
421
+ it("skips unmanaged file not in registry", async () => {
422
+ const home = await makeTmpDir();
423
+ try {
424
+ const targetFile = path.join(home, "unmanaged.txt");
425
+ await writeFile(targetFile, "user content");
426
+ const action = {
427
+ kind: "file-write",
428
+ skill: "test-skill",
429
+ agent: "claude-code",
430
+ target: targetFile,
431
+ };
432
+ const { succeeded, skipped } = await executeRevert([action], false, false, home);
433
+ assert.equal(succeeded, 0);
434
+ assert.equal(skipped, 1);
435
+ assert.ok(await exists(targetFile), "unmanaged file should not be deleted");
436
+ }
437
+ finally {
438
+ await rm(home, { recursive: true, force: true });
439
+ }
440
+ });
441
+ it("dry-run does not delete file and returns planned change", async () => {
442
+ const home = await makeTmpDir();
443
+ try {
444
+ const targetFile = path.join(home, "file.txt");
445
+ await writeFile(targetFile, "content");
446
+ await registerDeployment(home, targetFile, {
447
+ kind: "file-write",
448
+ source: "/src/file.txt",
449
+ skill: "test-skill",
450
+ agent: "claude-code",
451
+ });
452
+ const action = {
453
+ kind: "file-write",
454
+ skill: "test-skill",
455
+ agent: "claude-code",
456
+ target: targetFile,
457
+ };
458
+ const { succeeded, planned } = await executeRevert([action], true, false, home);
459
+ assert.equal(succeeded, 1);
460
+ assert.ok(await exists(targetFile), "file should still exist after dry-run");
461
+ assert.equal(planned.length, 1);
462
+ assert.equal(planned[0]?.verb, "remove");
463
+ assert.equal(planned[0]?.kind, "file-write");
464
+ }
465
+ finally {
466
+ await rm(home, { recursive: true, force: true });
467
+ }
468
+ });
469
+ });
470
+ describe("executeRevert — config-patch", () => {
471
+ it("restores original values from undoPatch", async () => {
472
+ const home = await makeTmpDir();
473
+ try {
474
+ const configFile = path.join(home, "config.json");
475
+ // Current state after patching: b was changed to 99, c was added
476
+ await writeFile(configFile, JSON.stringify({ a: 1, b: 99, c: 3 }));
477
+ await registerDeployment(home, configFile, {
478
+ kind: "config-patch",
479
+ patch: { b: 99, c: 3 },
480
+ undoPatch: { b: 2, c: null }, // b was 2, c was absent
481
+ skill: "test-skill",
482
+ agent: "claude-code",
483
+ });
484
+ const action = {
485
+ kind: "config-patch",
486
+ skill: "test-skill",
487
+ agent: "claude-code",
488
+ target: configFile,
489
+ };
490
+ const { succeeded, skipped } = await executeRevert([action], false, false, home);
491
+ assert.equal(succeeded, 1);
492
+ assert.equal(skipped, 0);
493
+ const restored = JSON.parse(await readFile(configFile, "utf-8"));
494
+ assert.equal(restored.a, 1);
495
+ assert.equal(restored.b, 2); // restored to original
496
+ assert.equal("c" in restored, false); // deleted (was absent before)
497
+ const entry = await lookupDeployment(home, configFile);
498
+ assert.equal(entry, null);
499
+ }
500
+ finally {
501
+ await rm(home, { recursive: true, force: true });
502
+ }
503
+ });
504
+ it("deletes keys that were added by the patch (undoPatch value null)", async () => {
505
+ const home = await makeTmpDir();
506
+ try {
507
+ const configFile = path.join(home, "config.json");
508
+ await writeFile(configFile, JSON.stringify({ a: 1, newKey: "added" }));
509
+ await registerDeployment(home, configFile, {
510
+ kind: "config-patch",
511
+ patch: { newKey: "added" },
512
+ undoPatch: { newKey: null },
513
+ skill: "test-skill",
514
+ agent: "claude-code",
515
+ });
516
+ const action = {
517
+ kind: "config-patch",
518
+ skill: "test-skill",
519
+ agent: "claude-code",
520
+ target: configFile,
521
+ };
522
+ await executeRevert([action], false, false, home);
523
+ const restored = JSON.parse(await readFile(configFile, "utf-8"));
524
+ assert.equal(restored.a, 1);
525
+ assert.equal("newKey" in restored, false);
526
+ }
527
+ finally {
528
+ await rm(home, { recursive: true, force: true });
529
+ }
530
+ });
531
+ it("skips when config file is gone", async () => {
532
+ const home = await makeTmpDir();
533
+ try {
534
+ const action = {
535
+ kind: "config-patch",
536
+ skill: "test-skill",
537
+ agent: "claude-code",
538
+ target: path.join(home, "gone.json"),
539
+ };
540
+ const { succeeded, skipped } = await executeRevert([action], false, false, home);
541
+ assert.equal(succeeded, 0);
542
+ assert.equal(skipped, 1);
543
+ }
544
+ finally {
545
+ await rm(home, { recursive: true, force: true });
546
+ }
547
+ });
548
+ it("skips when no registry entry exists", async () => {
549
+ const home = await makeTmpDir();
550
+ try {
551
+ const configFile = path.join(home, "config.json");
552
+ await writeFile(configFile, JSON.stringify({ a: 1 }));
553
+ const action = {
554
+ kind: "config-patch",
555
+ skill: "test-skill",
556
+ agent: "claude-code",
557
+ target: configFile,
558
+ };
559
+ const { succeeded, skipped } = await executeRevert([action], false, false, home);
560
+ assert.equal(succeeded, 0);
561
+ assert.equal(skipped, 1);
562
+ // File should be untouched
563
+ const content = JSON.parse(await readFile(configFile, "utf-8"));
564
+ assert.equal(content.a, 1);
565
+ }
566
+ finally {
567
+ await rm(home, { recursive: true, force: true });
568
+ }
569
+ });
570
+ it("dry-run does not modify config and returns planned change", async () => {
571
+ const home = await makeTmpDir();
572
+ try {
573
+ const configFile = path.join(home, "config.json");
574
+ await writeFile(configFile, JSON.stringify({ a: 1, b: 99 }));
575
+ await registerDeployment(home, configFile, {
576
+ kind: "config-patch",
577
+ patch: { b: 99 },
578
+ undoPatch: { b: 2 },
579
+ skill: "test-skill",
580
+ agent: "claude-code",
581
+ });
582
+ const action = {
583
+ kind: "config-patch",
584
+ skill: "test-skill",
585
+ agent: "claude-code",
586
+ target: configFile,
587
+ };
588
+ const { succeeded, planned } = await executeRevert([action], true, false, home);
589
+ assert.equal(succeeded, 1);
590
+ const after = JSON.parse(await readFile(configFile, "utf-8"));
591
+ assert.equal(after.b, 99, "config should be unchanged after dry-run");
592
+ assert.equal(planned.length, 1);
593
+ assert.equal(planned[0]?.verb, "unapply-patch");
594
+ assert.equal(planned[0]?.kind, "config-patch");
595
+ }
596
+ finally {
597
+ await rm(home, { recursive: true, force: true });
598
+ }
599
+ });
600
+ });
601
+ describe("executeRevert — frontmatter-emit", () => {
602
+ it("removes only engine-owned frontmatter and preserves unrelated content", async () => {
603
+ const home = await makeTmpDir();
604
+ try {
605
+ const targetFile = path.join(home, ".agents", "rules", "my-mcp.md");
606
+ await mkdir(path.dirname(targetFile), { recursive: true });
607
+ await writeFile(targetFile, "---\nname: Existing Agent\ndescription: Keep me\nmcp-servers:\n my-mcp:\n command: npx\n---\n\n# Body\n");
608
+ await registerDeployment(home, targetFile, {
609
+ kind: "frontmatter-emit",
610
+ patch: { "mcp-servers": { "my-mcp": { command: "npx" } } },
611
+ undoPatch: { "mcp-servers": null },
612
+ created: false,
613
+ hadFrontmatter: true,
614
+ skill: "my-mcp",
615
+ agent: "antigravity",
616
+ });
617
+ const action = {
618
+ kind: "frontmatter-emit",
619
+ skill: "my-mcp",
620
+ agent: "antigravity",
621
+ target: targetFile,
622
+ };
623
+ const { succeeded, failed } = await executeRevert([action], false, false, home);
624
+ assert.equal(succeeded, 1);
625
+ assert.equal(failed.length, 0);
626
+ const content = await readFile(targetFile, "utf-8");
627
+ assert.match(content, /name: Existing Agent/);
628
+ assert.match(content, /description: Keep me/);
629
+ assert.doesNotMatch(content, /mcp-servers:/);
630
+ assert.match(content, /# Body/);
631
+ assert.equal(await lookupDeployment(home, targetFile), null);
632
+ }
633
+ finally {
634
+ await rm(home, { recursive: true, force: true });
635
+ }
636
+ });
637
+ it("removes a frontmatter-only file that inception created", async () => {
638
+ const home = await makeTmpDir();
639
+ try {
640
+ const targetFile = path.join(home, ".agents", "rules", "my-mcp.md");
641
+ await mkdir(path.dirname(targetFile), { recursive: true });
642
+ await writeFile(targetFile, "---\nmcp-servers:\n my-mcp:\n command: npx\n---\n");
643
+ await registerDeployment(home, targetFile, {
644
+ kind: "frontmatter-emit",
645
+ patch: { "mcp-servers": { "my-mcp": { command: "npx" } } },
646
+ undoPatch: { "mcp-servers": null },
647
+ created: true,
648
+ hadFrontmatter: false,
649
+ skill: "my-mcp",
650
+ agent: "antigravity",
651
+ });
652
+ const action = {
653
+ kind: "frontmatter-emit",
654
+ skill: "my-mcp",
655
+ agent: "antigravity",
656
+ target: targetFile,
657
+ };
658
+ const { succeeded, failed } = await executeRevert([action], false, false, home);
659
+ assert.equal(succeeded, 1);
660
+ assert.equal(failed.length, 0);
661
+ assert.ok(!(await exists(targetFile)));
662
+ assert.equal(await lookupDeployment(home, targetFile), null);
663
+ }
664
+ finally {
665
+ await rm(home, { recursive: true, force: true });
666
+ }
667
+ });
668
+ it("restores a body-only markdown file by removing the injected frontmatter", async () => {
669
+ const home = await makeTmpDir();
670
+ try {
671
+ const targetFile = path.join(home, ".agents", "rules", "my-mcp.md");
672
+ await mkdir(path.dirname(targetFile), { recursive: true });
673
+ await writeFile(targetFile, "---\nmcp-servers:\n my-mcp:\n command: npx\n---\n\n# Body\n");
674
+ await registerDeployment(home, targetFile, {
675
+ kind: "frontmatter-emit",
676
+ patch: { "mcp-servers": { "my-mcp": { command: "npx" } } },
677
+ undoPatch: { "mcp-servers": null },
678
+ created: false,
679
+ hadFrontmatter: false,
680
+ skill: "my-mcp",
681
+ agent: "antigravity",
682
+ });
683
+ const action = {
684
+ kind: "frontmatter-emit",
685
+ skill: "my-mcp",
686
+ agent: "antigravity",
687
+ target: targetFile,
688
+ };
689
+ await executeRevert([action], false, false, home);
690
+ assert.equal(await readFile(targetFile, "utf-8"), "# Body\n");
691
+ }
692
+ finally {
693
+ await rm(home, { recursive: true, force: true });
694
+ }
695
+ });
696
+ });
697
+ describe("planRevert — mcpServers and agentRules", () => {
698
+ const home = "/home/test";
699
+ it("produces a config-patch action for an mcpServer matched to a detected agent", () => {
700
+ const manifest = {
701
+ skills: [],
702
+ files: [],
703
+ configs: [],
704
+ mcpServers: [
705
+ {
706
+ name: "my-mcp",
707
+ agents: ["claude-code"],
708
+ config: { command: "npx", args: ["-y", "my-mcp"] },
709
+ scope: "global",
710
+ },
711
+ ],
712
+ agentRules: [],
713
+ permissions: [],
714
+ agentDefinitions: [],
715
+ };
716
+ const actions = planRevert(manifest, ["claude-code"], home);
717
+ assert.equal(actions.length, 1);
718
+ assert.equal(actions[0]?.kind, "config-patch");
719
+ assert.equal(actions[0]?.skill, "my-mcp");
720
+ assert.equal(actions[0]?.agent, "claude-code");
721
+ });
722
+ it("skips mcpServer agents not in detectedAgents", () => {
723
+ const manifest = {
724
+ skills: [],
725
+ files: [],
726
+ configs: [],
727
+ mcpServers: [
728
+ {
729
+ name: "my-mcp",
730
+ agents: ["claude-code"],
731
+ config: { command: "npx" },
732
+ scope: "global",
733
+ },
734
+ ],
735
+ agentRules: [],
736
+ permissions: [],
737
+ agentDefinitions: [],
738
+ };
739
+ const actions = planRevert(manifest, ["codex"], home);
740
+ assert.equal(actions.length, 0);
741
+ });
742
+ it("produces a file-write action for an agentRule matched to a detected agent", () => {
743
+ const manifest = {
744
+ skills: [],
745
+ files: [],
746
+ configs: [],
747
+ mcpServers: [],
748
+ agentRules: [
749
+ {
750
+ name: "my-rules",
751
+ path: "rules/CLAUDE.md",
752
+ agents: ["claude-code"],
753
+ scope: "global",
754
+ },
755
+ ],
756
+ permissions: [],
757
+ agentDefinitions: [],
758
+ };
759
+ const actions = planRevert(manifest, ["claude-code"], home);
760
+ assert.equal(actions.length, 1);
761
+ assert.equal(actions[0]?.kind, "file-write");
762
+ assert.equal(actions[0]?.skill, "my-rules");
763
+ assert.equal(actions[0]?.agent, "claude-code");
764
+ });
765
+ it("skips agentRule agents not in detectedAgents", () => {
766
+ const manifest = {
767
+ skills: [],
768
+ files: [],
769
+ configs: [],
770
+ mcpServers: [],
771
+ agentRules: [
772
+ {
773
+ name: "my-rules",
774
+ path: "rules/CLAUDE.md",
775
+ agents: ["claude-code"],
776
+ scope: "global",
777
+ },
778
+ ],
779
+ permissions: [],
780
+ agentDefinitions: [],
781
+ };
782
+ const actions = planRevert(manifest, ["codex"], home);
783
+ assert.equal(actions.length, 0);
784
+ });
785
+ });
786
+ describe("planRevertAll — mcpServers and agentRules", () => {
787
+ const home = "/home/test";
788
+ it("includes mcpServer actions for all listed agents regardless of detection", () => {
789
+ const manifest = {
790
+ skills: [],
791
+ files: [],
792
+ configs: [],
793
+ mcpServers: [
794
+ {
795
+ name: "my-mcp",
796
+ agents: ["claude-code", "gemini-cli"],
797
+ config: { command: "npx" },
798
+ scope: "global",
799
+ },
800
+ ],
801
+ agentRules: [],
802
+ permissions: [],
803
+ agentDefinitions: [],
804
+ };
805
+ const actions = planRevertAll(manifest, home);
806
+ assert.equal(actions.length, 2);
807
+ const agents = actions.map((a) => a.agent);
808
+ assert.ok(agents.includes("claude-code"));
809
+ assert.ok(agents.includes("gemini-cli"));
810
+ });
811
+ it("includes agentRule actions for all listed agents regardless of detection", () => {
812
+ const manifest = {
813
+ skills: [],
814
+ files: [],
815
+ configs: [],
816
+ mcpServers: [],
817
+ agentRules: [
818
+ {
819
+ name: "my-rules",
820
+ path: "rules/CLAUDE.md",
821
+ agents: ["claude-code", "codex"],
822
+ scope: "global",
823
+ },
824
+ ],
825
+ permissions: [],
826
+ agentDefinitions: [],
827
+ };
828
+ const actions = planRevertAll(manifest, home);
829
+ assert.equal(actions.length, 2);
830
+ const agents = actions.map((a) => a.agent);
831
+ assert.ok(agents.includes("claude-code"));
832
+ assert.ok(agents.includes("codex"));
833
+ });
834
+ });
835
+ describe("executeRevert — mcpServer and agentRule integration", {
836
+ skip: process.platform === "win32",
837
+ }, () => {
838
+ it("reverts a deployed mcpServer config-patch and unregisters it", async () => {
839
+ const home = await makeTmpDir();
840
+ try {
841
+ // Simulate a deployed mcpServer: config file with mcpServers key patched in
842
+ const configFile = path.join(home, ".claude.json");
843
+ const original = { other: "value" };
844
+ const patched = {
845
+ other: "value",
846
+ mcpServers: { "my-mcp": { command: "npx" } },
847
+ };
848
+ await writeFile(configFile, JSON.stringify(patched));
849
+ await registerDeployment(home, configFile, {
850
+ kind: "config-patch",
851
+ patch: { mcpServers: { "my-mcp": { command: "npx" } } },
852
+ undoPatch: { mcpServers: null },
853
+ skill: "my-mcp",
854
+ agent: "claude-code",
855
+ });
856
+ const action = {
857
+ kind: "config-patch",
858
+ skill: "my-mcp",
859
+ agent: "claude-code",
860
+ target: configFile,
861
+ };
862
+ const { succeeded, failed } = await executeRevert([action], false, false, home);
863
+ assert.equal(succeeded, 1);
864
+ assert.equal(failed.length, 0);
865
+ const restored = JSON.parse(await readFile(configFile, "utf-8"));
866
+ assert.deepEqual(restored, original);
867
+ const entry = await lookupDeployment(home, configFile);
868
+ assert.equal(entry, null, "registry entry should be removed after revert");
869
+ }
870
+ finally {
871
+ await rm(home, { recursive: true, force: true });
872
+ }
873
+ });
874
+ it("reverts nested config-patch, preserving sibling MCP server", async () => {
875
+ const home = await makeTmpDir();
876
+ try {
877
+ // Original config already has one MCP server; we deployed a second one
878
+ const configFile = path.join(home, ".claude.json");
879
+ const patched = {
880
+ mcpServers: {
881
+ "existing-server": { command: "existing" },
882
+ "new-server": { command: "new" },
883
+ },
884
+ };
885
+ await writeFile(configFile, JSON.stringify(patched));
886
+ // Deep undo patch: only remove "new-server" under mcpServers
887
+ await registerDeployment(home, configFile, {
888
+ kind: "config-patch",
889
+ patch: { mcpServers: { "new-server": { command: "new" } } },
890
+ undoPatch: { mcpServers: { "new-server": null } },
891
+ skill: "new-server",
892
+ agent: "claude-code",
893
+ });
894
+ const action = {
895
+ kind: "config-patch",
896
+ skill: "new-server",
897
+ agent: "claude-code",
898
+ target: configFile,
899
+ };
900
+ const { succeeded, failed } = await executeRevert([action], false, false, home);
901
+ assert.equal(succeeded, 1);
902
+ assert.equal(failed.length, 0);
903
+ const restored = JSON.parse(await readFile(configFile, "utf-8"));
904
+ // "new-server" should be gone, "existing-server" should be preserved
905
+ assert.equal("new-server" in restored.mcpServers, false);
906
+ assert.deepEqual(restored.mcpServers["existing-server"], {
907
+ command: "existing",
908
+ });
909
+ const entry = await lookupDeployment(home, configFile);
910
+ assert.equal(entry, null, "registry entry should be removed after revert");
911
+ }
912
+ finally {
913
+ await rm(home, { recursive: true, force: true });
914
+ }
915
+ });
916
+ it("reverts a deployed agentRule file-write and unregisters it", async () => {
917
+ const home = await makeTmpDir();
918
+ try {
919
+ const rulesFile = path.join(home, ".claude", "CLAUDE.md");
920
+ await mkdir(path.dirname(rulesFile), { recursive: true });
921
+ await writeFile(rulesFile, "# My Rules\n");
922
+ await registerDeployment(home, rulesFile, {
923
+ kind: "file-write",
924
+ source: "/some/source/CLAUDE.md",
925
+ skill: "my-rules",
926
+ agent: "claude-code",
927
+ });
928
+ const action = {
929
+ kind: "file-write",
930
+ skill: "my-rules",
931
+ agent: "claude-code",
932
+ target: rulesFile,
933
+ };
934
+ const { succeeded, failed } = await executeRevert([action], false, false, home);
935
+ assert.equal(succeeded, 1);
936
+ assert.equal(failed.length, 0);
937
+ assert.ok(!(await exists(rulesFile)), "rules file should be removed after revert");
938
+ const entry = await lookupDeployment(home, rulesFile);
939
+ assert.equal(entry, null, "registry entry should be removed after revert");
940
+ }
941
+ finally {
942
+ await rm(home, { recursive: true, force: true });
943
+ }
944
+ });
945
+ it("dry-run for mcpServer revert returns planned change without modifying config", async () => {
946
+ const home = await makeTmpDir();
947
+ try {
948
+ const configFile = path.join(home, ".claude.json");
949
+ const patched = { mcpServers: { "my-mcp": { command: "npx" } } };
950
+ await writeFile(configFile, JSON.stringify(patched));
951
+ await registerDeployment(home, configFile, {
952
+ kind: "config-patch",
953
+ patch: { mcpServers: { "my-mcp": { command: "npx" } } },
954
+ undoPatch: { mcpServers: null },
955
+ skill: "my-mcp",
956
+ agent: "claude-code",
957
+ });
958
+ const action = {
959
+ kind: "config-patch",
960
+ skill: "my-mcp",
961
+ agent: "claude-code",
962
+ target: configFile,
963
+ };
964
+ const { succeeded, planned } = await executeRevert([action], true, false, home);
965
+ assert.equal(succeeded, 1);
966
+ assert.equal(planned.length, 1);
967
+ assert.equal(planned[0]?.verb, "unapply-patch");
968
+ const unchanged = JSON.parse(await readFile(configFile, "utf-8"));
969
+ assert.deepEqual(unchanged, patched, "config should be unchanged during dry-run");
970
+ }
971
+ finally {
972
+ await rm(home, { recursive: true, force: true });
973
+ }
974
+ });
975
+ it("dry-run for agentRule revert returns planned change without deleting file", async () => {
976
+ const home = await makeTmpDir();
977
+ try {
978
+ const rulesFile = path.join(home, ".claude", "CLAUDE.md");
979
+ await mkdir(path.dirname(rulesFile), { recursive: true });
980
+ await writeFile(rulesFile, "# My Rules\n");
981
+ await registerDeployment(home, rulesFile, {
982
+ kind: "file-write",
983
+ source: "/some/source/CLAUDE.md",
984
+ skill: "my-rules",
985
+ agent: "claude-code",
986
+ });
987
+ const action = {
988
+ kind: "file-write",
989
+ skill: "my-rules",
990
+ agent: "claude-code",
991
+ target: rulesFile,
992
+ };
993
+ const { succeeded, planned } = await executeRevert([action], true, false, home);
994
+ assert.equal(succeeded, 1);
995
+ assert.equal(planned.length, 1);
996
+ assert.equal(planned[0]?.verb, "remove");
997
+ assert.equal(planned[0]?.kind, "file-write");
998
+ assert.ok(await exists(rulesFile), "rules file should still exist after dry-run");
999
+ }
1000
+ finally {
1001
+ await rm(home, { recursive: true, force: true });
1002
+ }
1003
+ });
1004
+ });