@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.
- package/README.md +72 -43
- package/dist/{config → src/config}/agents.js +174 -24
- package/dist/src/core/adapters/agent-definitions.d.ts +8 -0
- package/dist/src/core/adapters/agent-definitions.js +162 -0
- package/dist/{core → src/core}/adapters/frontmatter.d.ts +10 -0
- package/dist/{core → src/core}/adapters/frontmatter.js +23 -6
- package/dist/{core → src/core}/adapters/index.d.ts +1 -1
- package/dist/{core → src/core}/adapters/index.js +4 -4
- package/dist/{core → src/core}/adapters/mcp.d.ts +2 -2
- package/dist/{core → src/core}/adapters/mcp.js +21 -24
- package/dist/{core → src/core}/adapters/permissions.js +16 -20
- package/dist/{core → src/core}/adapters/rules.d.ts +2 -2
- package/dist/src/core/adapters/rules.js +126 -0
- package/dist/src/core/capabilities.d.ts +47 -0
- package/dist/src/core/capabilities.js +280 -0
- package/dist/{core → src/core}/deploy.d.ts +1 -1
- package/dist/{core → src/core}/deploy.js +273 -92
- package/dist/{core → src/core}/init.js +126 -52
- package/dist/src/core/merge-patch.d.ts +4 -0
- package/dist/src/core/merge-patch.js +46 -0
- package/dist/{core → src/core}/ownership.d.ts +2 -2
- package/dist/{core → src/core}/ownership.js +13 -0
- package/dist/{core → src/core}/preflight.d.ts +1 -1
- package/dist/src/core/preflight.js +280 -0
- package/dist/{core → src/core}/resolve.d.ts +1 -1
- package/dist/{core → src/core}/resolve.js +7 -4
- package/dist/{core → src/core}/revert.js +95 -33
- package/dist/{core → src/core}/runtime-paths.d.ts +2 -1
- package/dist/{core → src/core}/runtime-paths.js +30 -23
- package/dist/{core → src/core}/validation.d.ts +10 -1
- package/dist/{core → src/core}/validation.js +75 -0
- package/dist/src/formatters.d.ts +5 -0
- package/dist/src/formatters.js +58 -0
- package/dist/{index.js → src/index.js} +8 -24
- package/dist/src/logger.js +62 -0
- package/dist/{schemas → src/schemas}/manifest.d.ts +22 -0
- package/dist/{schemas → src/schemas}/manifest.js +19 -5
- package/dist/{schemas → src/schemas}/registry.d.ts +74 -0
- package/dist/{schemas → src/schemas}/registry.js +18 -3
- package/dist/{types.d.ts → src/types.d.ts} +60 -0
- package/dist/test/helpers/fs.d.ts +2 -0
- package/dist/test/helpers/fs.js +17 -0
- package/dist/test/helpers/path.d.ts +10 -0
- package/dist/test/helpers/path.js +15 -0
- package/dist/test/helpers/skill-dir-scenarios.d.ts +7 -0
- package/dist/test/helpers/skill-dir-scenarios.js +206 -0
- package/dist/test/helpers/skill-dir.d.ts +7 -0
- package/dist/test/helpers/skill-dir.js +46 -0
- package/dist/test/os/cross-platform/cross-platform.test.d.ts +1 -0
- package/dist/test/os/cross-platform/cross-platform.test.js +328 -0
- package/dist/test/os/posix/skill-dir.test.d.ts +1 -0
- package/dist/test/os/posix/skill-dir.test.js +19 -0
- package/dist/test/os/windows/revert-integration.test.d.ts +1 -0
- package/dist/test/os/windows/revert-integration.test.js +72 -0
- package/dist/test/os/windows/skill-dir.test.d.ts +1 -0
- package/dist/test/os/windows/skill-dir.test.js +19 -0
- package/dist/test/unit/adapters.test.d.ts +1 -0
- package/dist/test/unit/adapters.test.js +947 -0
- package/dist/test/unit/capabilities.test.d.ts +1 -0
- package/dist/test/unit/capabilities.test.js +71 -0
- package/dist/test/unit/cli.test.d.ts +1 -0
- package/dist/test/unit/cli.test.js +834 -0
- package/dist/test/unit/deploy.test.d.ts +1 -0
- package/dist/test/unit/deploy.test.js +2481 -0
- package/dist/test/unit/detect.test.d.ts +1 -0
- package/dist/test/unit/detect.test.js +192 -0
- package/dist/test/unit/enterprise.test.d.ts +1 -0
- package/dist/test/unit/enterprise.test.js +106 -0
- package/dist/test/unit/frontmatter.test.d.ts +1 -0
- package/dist/test/unit/frontmatter.test.js +102 -0
- package/dist/test/unit/gemini-north-star.test.d.ts +1 -0
- package/dist/test/unit/gemini-north-star.test.js +243 -0
- package/dist/test/unit/manifest.test.d.ts +1 -0
- package/dist/test/unit/manifest.test.js +737 -0
- package/dist/test/unit/migration-golden.test.d.ts +1 -0
- package/dist/test/unit/migration-golden.test.js +197 -0
- package/dist/test/unit/ownership.test.d.ts +1 -0
- package/dist/test/unit/ownership.test.js +587 -0
- package/dist/test/unit/preflight.test.d.ts +1 -0
- package/dist/test/unit/preflight.test.js +501 -0
- package/dist/test/unit/resolve.test.d.ts +1 -0
- package/dist/test/unit/resolve.test.js +119 -0
- package/dist/test/unit/revert.test.d.ts +1 -0
- package/dist/test/unit/revert.test.js +1004 -0
- package/dist/test/unit/toml.test.d.ts +1 -0
- package/dist/test/unit/toml.test.js +55 -0
- package/package.json +1 -1
- package/dist/core/adapters/agent-definitions.d.ts +0 -20
- package/dist/core/adapters/agent-definitions.js +0 -90
- package/dist/core/adapters/rules.js +0 -103
- package/dist/core/preflight.js +0 -98
- package/dist/logger.js +0 -61
- /package/dist/{config → src/config}/agents.d.ts +0 -0
- /package/dist/{config → src/config}/manifest.d.ts +0 -0
- /package/dist/{config → src/config}/manifest.js +0 -0
- /package/dist/{core → src/core}/adapters/permissions.d.ts +0 -0
- /package/dist/{core → src/core}/adapters/toml.d.ts +0 -0
- /package/dist/{core → src/core}/adapters/toml.js +0 -0
- /package/dist/{core → src/core}/detect.d.ts +0 -0
- /package/dist/{core → src/core}/detect.js +0 -0
- /package/dist/{core → src/core}/init.d.ts +0 -0
- /package/dist/{core → src/core}/revert.d.ts +0 -0
- /package/dist/{errors.d.ts → src/errors.d.ts} +0 -0
- /package/dist/{errors.js → src/errors.js} +0 -0
- /package/dist/{index.d.ts → src/index.d.ts} +0 -0
- /package/dist/{logger.d.ts → src/logger.d.ts} +0 -0
- /package/dist/{schemas → src/schemas}/errors.d.ts +0 -0
- /package/dist/{schemas → src/schemas}/errors.js +0 -0
- /package/dist/{types.js → src/types.js} +0 -0
|
@@ -0,0 +1,2481 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { chmod, copyFile, lstat, mkdir, readdir, readFile, readlink, rename, rm, symlink, writeFile, } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { executeDeploy, planDeploy } from "../../src/core/deploy.js";
|
|
6
|
+
import { lookupDeployment, registerDeployment, } from "../../src/core/ownership.js";
|
|
7
|
+
import { executeRevert, planRevert } from "../../src/core/revert.js";
|
|
8
|
+
import { UserError } from "../../src/errors.js";
|
|
9
|
+
import { logger } from "../../src/logger.js";
|
|
10
|
+
import { exists, makeTmpDir } from "../helpers/fs.js";
|
|
11
|
+
import { assertPathEndsWith } from "../helpers/path.js";
|
|
12
|
+
logger.silence();
|
|
13
|
+
async function createSkillSource(baseDir, skillPath) {
|
|
14
|
+
const fullPath = path.join(baseDir, skillPath);
|
|
15
|
+
await mkdir(fullPath, { recursive: true });
|
|
16
|
+
await writeFile(path.join(fullPath, "SKILL.md"), "---\nname: test\ndescription: Test skill\n---\n# Test");
|
|
17
|
+
return fullPath;
|
|
18
|
+
}
|
|
19
|
+
const testManifest = {
|
|
20
|
+
skills: [
|
|
21
|
+
{
|
|
22
|
+
name: "test-skill",
|
|
23
|
+
path: "skills/test-skill",
|
|
24
|
+
agents: ["claude-code", "codex"],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
files: [],
|
|
28
|
+
configs: [],
|
|
29
|
+
mcpServers: [],
|
|
30
|
+
agentRules: [],
|
|
31
|
+
permissions: [],
|
|
32
|
+
agentDefinitions: [],
|
|
33
|
+
};
|
|
34
|
+
describe("planDeploy", () => {
|
|
35
|
+
it("creates actions for detected agents only", async () => {
|
|
36
|
+
const sourceDir = await makeTmpDir();
|
|
37
|
+
try {
|
|
38
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
39
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test");
|
|
40
|
+
assert.equal(actions.length, 1);
|
|
41
|
+
assert.equal(actions[0]?.agent, "claude-code");
|
|
42
|
+
assert.equal(actions[0]?.skill, "test-skill");
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await rm(sourceDir, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
it("creates actions for multiple agents", async () => {
|
|
49
|
+
const sourceDir = await makeTmpDir();
|
|
50
|
+
try {
|
|
51
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
52
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code", "codex"], "/home/test");
|
|
53
|
+
assert.equal(actions.length, 2);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await rm(sourceDir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
it("skips agents not in detected list", async () => {
|
|
60
|
+
const sourceDir = await makeTmpDir();
|
|
61
|
+
try {
|
|
62
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
63
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["gemini-cli"], "/home/test");
|
|
64
|
+
assert.equal(actions.length, 0);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await rm(sourceDir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
it("throws when skill source path does not exist", async () => {
|
|
71
|
+
const sourceDir = await makeTmpDir();
|
|
72
|
+
try {
|
|
73
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
74
|
+
assert.ok(err instanceof UserError);
|
|
75
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
76
|
+
assert.match(err.message, /source not found/);
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await rm(sourceDir, { recursive: true });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
it("throws when skill source path is a file, not a directory", async () => {
|
|
85
|
+
const sourceDir = await makeTmpDir();
|
|
86
|
+
try {
|
|
87
|
+
await mkdir(path.join(sourceDir, "skills"), { recursive: true });
|
|
88
|
+
await writeFile(path.join(sourceDir, "skills", "test-skill"), "not a dir");
|
|
89
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
90
|
+
assert.ok(err instanceof UserError);
|
|
91
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
92
|
+
assert.match(err.message, /not a directory/);
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await rm(sourceDir, { recursive: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it("throws when skill source directory is missing SKILL.md", async () => {
|
|
101
|
+
const sourceDir = await makeTmpDir();
|
|
102
|
+
try {
|
|
103
|
+
await mkdir(path.join(sourceDir, "skills", "test-skill"), {
|
|
104
|
+
recursive: true,
|
|
105
|
+
});
|
|
106
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
107
|
+
assert.ok(err instanceof UserError);
|
|
108
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
109
|
+
assert.match(err.message, /missing SKILL\.md/);
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await rm(sourceDir, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
it("throws when SKILL.md is missing YAML frontmatter", async () => {
|
|
118
|
+
const sourceDir = await makeTmpDir();
|
|
119
|
+
try {
|
|
120
|
+
const skillDir = path.join(sourceDir, "skills", "test-skill");
|
|
121
|
+
await mkdir(skillDir, { recursive: true });
|
|
122
|
+
await writeFile(path.join(skillDir, "SKILL.md"), "# Test");
|
|
123
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
124
|
+
assert.ok(err instanceof UserError);
|
|
125
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
126
|
+
assert.match(err.message, /must start with YAML frontmatter/);
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await rm(sourceDir, { recursive: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
it("throws when SKILL.md frontmatter is missing name", async () => {
|
|
135
|
+
const sourceDir = await makeTmpDir();
|
|
136
|
+
try {
|
|
137
|
+
const skillDir = path.join(sourceDir, "skills", "test-skill");
|
|
138
|
+
await mkdir(skillDir, { recursive: true });
|
|
139
|
+
await writeFile(path.join(skillDir, "SKILL.md"), "---\ndescription: Missing name\n---\n# Test");
|
|
140
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
141
|
+
assert.ok(err instanceof UserError);
|
|
142
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
143
|
+
assert.match(err.message, /must include a non-empty "name" field/);
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await rm(sourceDir, { recursive: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
it("throws when SKILL.md frontmatter is missing description", async () => {
|
|
152
|
+
const sourceDir = await makeTmpDir();
|
|
153
|
+
try {
|
|
154
|
+
const skillDir = path.join(sourceDir, "skills", "test-skill");
|
|
155
|
+
await mkdir(skillDir, { recursive: true });
|
|
156
|
+
await writeFile(path.join(skillDir, "SKILL.md"), "---\nname: test\n---\n# Test");
|
|
157
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
158
|
+
assert.ok(err instanceof UserError);
|
|
159
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
160
|
+
assert.match(err.message, /must include a non-empty "description" field/);
|
|
161
|
+
return true;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await rm(sourceDir, { recursive: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
it("throws when SKILL.md has malformed YAML frontmatter", async () => {
|
|
169
|
+
const sourceDir = await makeTmpDir();
|
|
170
|
+
try {
|
|
171
|
+
const skillDir = path.join(sourceDir, "skills", "test-skill");
|
|
172
|
+
await mkdir(skillDir, { recursive: true });
|
|
173
|
+
await writeFile(path.join(skillDir, "SKILL.md"), "---\nname: : bad\n---\n# Test");
|
|
174
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
175
|
+
assert.ok(err instanceof UserError);
|
|
176
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
177
|
+
assert.match(err.message, /malformed YAML frontmatter/);
|
|
178
|
+
return true;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
await rm(sourceDir, { recursive: true });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
it("throws when SKILL.md frontmatter name is multiline", async () => {
|
|
186
|
+
const sourceDir = await makeTmpDir();
|
|
187
|
+
try {
|
|
188
|
+
const skillDir = path.join(sourceDir, "skills", "test-skill");
|
|
189
|
+
await mkdir(skillDir, { recursive: true });
|
|
190
|
+
await writeFile(path.join(skillDir, "SKILL.md"), "---\nname: |\n multiline\n name\ndescription: test\n---\n# Test");
|
|
191
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
192
|
+
assert.ok(err instanceof UserError);
|
|
193
|
+
assert.match(err.message, /must be a single-line string/);
|
|
194
|
+
return true;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await rm(sourceDir, { recursive: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
it("throws when SKILL.md frontmatter description is multiline", async () => {
|
|
202
|
+
const sourceDir = await makeTmpDir();
|
|
203
|
+
try {
|
|
204
|
+
const skillDir = path.join(sourceDir, "skills", "test-skill");
|
|
205
|
+
await mkdir(skillDir, { recursive: true });
|
|
206
|
+
await writeFile(path.join(skillDir, "SKILL.md"), "---\nname: test\ndescription: >\n multiline\n description\n---\n# Test");
|
|
207
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
208
|
+
assert.ok(err instanceof UserError);
|
|
209
|
+
assert.match(err.message, /must be a single-line string/);
|
|
210
|
+
return true;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await rm(sourceDir, { recursive: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
it("attaches documented confidence to actions for claude-code", async () => {
|
|
218
|
+
const sourceDir = await makeTmpDir();
|
|
219
|
+
try {
|
|
220
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
221
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test");
|
|
222
|
+
assert.equal(actions.length, 1);
|
|
223
|
+
if (actions[0]?.kind !== "skill-dir")
|
|
224
|
+
assert.fail("Expected skill-dir");
|
|
225
|
+
assert.equal(actions[0].confidence, "documented");
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
await rm(sourceDir, { recursive: true });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
it("attaches implementation-only confidence for antigravity", async () => {
|
|
232
|
+
const sourceDir = await makeTmpDir();
|
|
233
|
+
const antigravityManifest = {
|
|
234
|
+
skills: [
|
|
235
|
+
{
|
|
236
|
+
name: "test-skill",
|
|
237
|
+
path: "skills/test-skill",
|
|
238
|
+
agents: ["antigravity"],
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
files: [],
|
|
242
|
+
configs: [],
|
|
243
|
+
mcpServers: [],
|
|
244
|
+
agentRules: [],
|
|
245
|
+
permissions: [],
|
|
246
|
+
agentDefinitions: [],
|
|
247
|
+
};
|
|
248
|
+
try {
|
|
249
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
250
|
+
const { actions } = await planDeploy(antigravityManifest, sourceDir, ["antigravity"], "/home/test");
|
|
251
|
+
assert.equal(actions.length, 1);
|
|
252
|
+
if (actions[0]?.kind !== "skill-dir")
|
|
253
|
+
assert.fail("Expected skill-dir");
|
|
254
|
+
assert.equal(actions[0].confidence, "implementation-only");
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
await rm(sourceDir, { recursive: true });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
it("warns and skips when a skill targets github-copilot without claude-code", async () => {
|
|
261
|
+
const sourceDir = await makeTmpDir();
|
|
262
|
+
const manifest = {
|
|
263
|
+
skills: [
|
|
264
|
+
{
|
|
265
|
+
name: "test-skill",
|
|
266
|
+
path: "skills/test-skill",
|
|
267
|
+
agents: ["github-copilot"],
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
files: [],
|
|
271
|
+
configs: [],
|
|
272
|
+
mcpServers: [],
|
|
273
|
+
agentRules: [],
|
|
274
|
+
permissions: [],
|
|
275
|
+
agentDefinitions: [],
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
279
|
+
const { actions, warnings } = await planDeploy(manifest, sourceDir, ["github-copilot"], "/home/test");
|
|
280
|
+
assert.equal(actions.length, 0);
|
|
281
|
+
assert.equal(warnings.length, 1);
|
|
282
|
+
assert.match(warnings[0]?.message ?? "", /via "claude-code"/);
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
await rm(sourceDir, { recursive: true });
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
it("deduplicates github-copilot skills behind claude-code when both are targeted", async () => {
|
|
289
|
+
const sourceDir = await makeTmpDir();
|
|
290
|
+
const manifest = {
|
|
291
|
+
skills: [
|
|
292
|
+
{
|
|
293
|
+
name: "test-skill",
|
|
294
|
+
path: "skills/test-skill",
|
|
295
|
+
agents: ["claude-code", "github-copilot"],
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
files: [],
|
|
299
|
+
configs: [],
|
|
300
|
+
mcpServers: [],
|
|
301
|
+
agentRules: [],
|
|
302
|
+
permissions: [],
|
|
303
|
+
agentDefinitions: [],
|
|
304
|
+
};
|
|
305
|
+
try {
|
|
306
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
307
|
+
const { actions, warnings } = await planDeploy(manifest, sourceDir, ["claude-code", "github-copilot"], "/home/test");
|
|
308
|
+
assert.equal(actions.length, 1);
|
|
309
|
+
assert.equal(actions[0]?.agent, "claude-code");
|
|
310
|
+
assert.equal(warnings.length, 0);
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
await rm(sourceDir, { recursive: true });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
it("emits no warnings for all-documented agents", async () => {
|
|
317
|
+
const sourceDir = await makeTmpDir();
|
|
318
|
+
try {
|
|
319
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
320
|
+
const { warnings } = await planDeploy(testManifest, sourceDir, ["claude-code", "codex"], "/home/test");
|
|
321
|
+
assert.equal(warnings.length, 0);
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
await rm(sourceDir, { recursive: true });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
it("emits ambiguity warning when both gemini-cli and antigravity are in the same agentRules entry", async () => {
|
|
328
|
+
const sourceDir = await makeTmpDir();
|
|
329
|
+
const bothManifest = {
|
|
330
|
+
skills: [],
|
|
331
|
+
files: [],
|
|
332
|
+
configs: [],
|
|
333
|
+
mcpServers: [],
|
|
334
|
+
agentRules: [
|
|
335
|
+
{
|
|
336
|
+
name: "shared-rules",
|
|
337
|
+
path: "GEMINI.md",
|
|
338
|
+
agents: ["gemini-cli", "antigravity"],
|
|
339
|
+
scope: "global",
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
permissions: [],
|
|
343
|
+
agentDefinitions: [],
|
|
344
|
+
};
|
|
345
|
+
try {
|
|
346
|
+
await writeFile(path.join(sourceDir, "GEMINI.md"), "---\nname: shared-rules\ndescription: test\n---\n# Shared Rules");
|
|
347
|
+
const { warnings } = await planDeploy(bothManifest, sourceDir, ["gemini-cli", "antigravity"], "/home/test");
|
|
348
|
+
const ambiguity = warnings.find((w) => w.kind === "ambiguity");
|
|
349
|
+
assert.ok(ambiguity, "expected an ambiguity warning");
|
|
350
|
+
// both agents now target the same GEMINI.md surface — warn that listing
|
|
351
|
+
// both is redundant but harmless
|
|
352
|
+
assert.match(ambiguity.message, /redundant but harmless/);
|
|
353
|
+
assert.match(ambiguity.message, /same surface/);
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
await rm(sourceDir, { recursive: true });
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
it("emits ambiguity warning when both gemini-cli and antigravity are detected and share settings.json", async () => {
|
|
360
|
+
const sourceDir = await makeTmpDir();
|
|
361
|
+
const bothManifest = {
|
|
362
|
+
skills: [],
|
|
363
|
+
files: [],
|
|
364
|
+
configs: [],
|
|
365
|
+
mcpServers: [
|
|
366
|
+
{
|
|
367
|
+
name: "shared-mcp",
|
|
368
|
+
agents: ["gemini-cli", "antigravity"],
|
|
369
|
+
config: { command: "my-server" },
|
|
370
|
+
scope: "global",
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
agentRules: [],
|
|
374
|
+
permissions: [],
|
|
375
|
+
agentDefinitions: [],
|
|
376
|
+
};
|
|
377
|
+
try {
|
|
378
|
+
const { warnings } = await planDeploy(bothManifest, sourceDir, ["gemini-cli", "antigravity"], "/home/test");
|
|
379
|
+
const ambiguity = warnings.find((w) => w.kind === "ambiguity");
|
|
380
|
+
assert.ok(ambiguity, "expected an ambiguity warning");
|
|
381
|
+
assert.match(ambiguity.message, /shared MCP surface/);
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
await rm(sourceDir, { recursive: true });
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
it("emits NO ambiguity warning when both gemini-cli and antigravity are detected but targets are distinct", async () => {
|
|
388
|
+
const sourceDir = await makeTmpDir();
|
|
389
|
+
const bothManifest = {
|
|
390
|
+
skills: [
|
|
391
|
+
{
|
|
392
|
+
name: "test-skill",
|
|
393
|
+
path: "skills/test-skill",
|
|
394
|
+
agents: ["gemini-cli", "antigravity"],
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
files: [],
|
|
398
|
+
configs: [],
|
|
399
|
+
mcpServers: [],
|
|
400
|
+
agentRules: [],
|
|
401
|
+
permissions: [],
|
|
402
|
+
agentDefinitions: [],
|
|
403
|
+
};
|
|
404
|
+
try {
|
|
405
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
406
|
+
const { warnings } = await planDeploy(bothManifest, sourceDir, ["gemini-cli", "antigravity"], "/home/test");
|
|
407
|
+
const ambiguity = warnings.find((w) => w.kind === "ambiguity");
|
|
408
|
+
assert.ok(!ambiguity, "did not expect an ambiguity warning");
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
await rm(sourceDir, { recursive: true });
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
it("agentRules entry with gemini-cli and antigravity (global scope) produces ONE deduplicated file-write action", async () => {
|
|
415
|
+
const sourceDir = await makeTmpDir();
|
|
416
|
+
const bothManifest = {
|
|
417
|
+
skills: [],
|
|
418
|
+
files: [],
|
|
419
|
+
configs: [],
|
|
420
|
+
mcpServers: [],
|
|
421
|
+
agentRules: [
|
|
422
|
+
{
|
|
423
|
+
name: "my-rules",
|
|
424
|
+
path: "GEMINI.md",
|
|
425
|
+
agents: ["gemini-cli", "antigravity"],
|
|
426
|
+
scope: "global",
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
permissions: [],
|
|
430
|
+
agentDefinitions: [],
|
|
431
|
+
};
|
|
432
|
+
try {
|
|
433
|
+
await writeFile(path.join(sourceDir, "GEMINI.md"), "---\nname: test-rule\ndescription: test\n---\n# Rules");
|
|
434
|
+
const { actions } = await planDeploy(bothManifest, sourceDir, ["gemini-cli", "antigravity"], "/home/test");
|
|
435
|
+
const rulesActions = actions.filter((a) => a.kind === "file-write");
|
|
436
|
+
// Both agents target ~/.gemini/GEMINI.md — deduplication emits only one action.
|
|
437
|
+
assert.equal(rulesActions.length, 1, "expected one deduplicated file-write action");
|
|
438
|
+
// First agent in list wins.
|
|
439
|
+
assert.equal(rulesActions[0]?.agent, "gemini-cli");
|
|
440
|
+
assertPathEndsWith(rulesActions[0].target, ".gemini/GEMINI.md", `expected target to end with .gemini/GEMINI.md, got: ${rulesActions[0]?.agent}`);
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
await rm(sourceDir, { recursive: true });
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
it("adapter: mcpServers entry produces a config-patch action for claude-code", async () => {
|
|
447
|
+
const sourceDir = await makeTmpDir();
|
|
448
|
+
try {
|
|
449
|
+
const manifest = {
|
|
450
|
+
skills: [],
|
|
451
|
+
files: [],
|
|
452
|
+
configs: [],
|
|
453
|
+
mcpServers: [
|
|
454
|
+
{
|
|
455
|
+
name: "my-mcp",
|
|
456
|
+
agents: ["claude-code"],
|
|
457
|
+
config: { command: "my-server" },
|
|
458
|
+
scope: "global",
|
|
459
|
+
},
|
|
460
|
+
],
|
|
461
|
+
agentRules: [],
|
|
462
|
+
permissions: [],
|
|
463
|
+
agentDefinitions: [],
|
|
464
|
+
};
|
|
465
|
+
const { actions, warnings } = await planDeploy(manifest, sourceDir, ["claude-code"], "/home/test");
|
|
466
|
+
assert.equal(actions.length, 1);
|
|
467
|
+
const action = actions[0];
|
|
468
|
+
assert.equal(action.kind, "config-patch");
|
|
469
|
+
assert.equal(action.skill, "my-mcp");
|
|
470
|
+
assert.equal(action.agent, "claude-code");
|
|
471
|
+
assertPathEndsWith(action.target, ".claude.json", `expected target to end with .claude.json, got: ${action.target}`);
|
|
472
|
+
assert.deepEqual(action.patch, {
|
|
473
|
+
mcpServers: { "my-mcp": { command: "my-server" } },
|
|
474
|
+
});
|
|
475
|
+
assert.equal(warnings.length, 0);
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
await rm(sourceDir, { recursive: true });
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
it("adapter: mcpServers entry emits a schema-aware warning for unsupported MCP surfaces", async () => {
|
|
482
|
+
const sourceDir = await makeTmpDir();
|
|
483
|
+
try {
|
|
484
|
+
const manifest = {
|
|
485
|
+
skills: [],
|
|
486
|
+
files: [],
|
|
487
|
+
configs: [],
|
|
488
|
+
mcpServers: [
|
|
489
|
+
{
|
|
490
|
+
name: "my-mcp",
|
|
491
|
+
agents: ["github-copilot"],
|
|
492
|
+
config: { command: "my-server" },
|
|
493
|
+
scope: "global",
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
agentRules: [],
|
|
497
|
+
permissions: [],
|
|
498
|
+
agentDefinitions: [],
|
|
499
|
+
};
|
|
500
|
+
const { actions, warnings } = await planDeploy(manifest, sourceDir, ["github-copilot"], "/home/test");
|
|
501
|
+
assert.equal(actions.length, 0);
|
|
502
|
+
assert.equal(warnings.length, 1);
|
|
503
|
+
assert.equal(warnings[0]?.kind, "confidence");
|
|
504
|
+
assert.match(warnings[0]?.message ?? "", /unsupported/);
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
await rm(sourceDir, { recursive: true });
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
it("adapter: empty mcpServers and agentRules produce no extra actions or warnings", async () => {
|
|
511
|
+
const sourceDir = await makeTmpDir();
|
|
512
|
+
try {
|
|
513
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
514
|
+
const { actions, warnings } = await planDeploy(testManifest, sourceDir, ["claude-code"], "/home/test");
|
|
515
|
+
// Only the one skill-dir action — no adapter output
|
|
516
|
+
assert.equal(actions.length, 1);
|
|
517
|
+
assert.equal(warnings.length, 0);
|
|
518
|
+
}
|
|
519
|
+
finally {
|
|
520
|
+
await rm(sourceDir, { recursive: true });
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
it("ignores invalid skill sources for agents that are not being deployed", async () => {
|
|
524
|
+
const sourceDir = await makeTmpDir();
|
|
525
|
+
const manifest = {
|
|
526
|
+
skills: [
|
|
527
|
+
{
|
|
528
|
+
name: "codex-only",
|
|
529
|
+
path: "skills/missing",
|
|
530
|
+
agents: ["codex"],
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
name: "claude-only",
|
|
534
|
+
path: "skills/claude-only",
|
|
535
|
+
agents: ["claude-code"],
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
files: [],
|
|
539
|
+
configs: [],
|
|
540
|
+
mcpServers: [],
|
|
541
|
+
agentRules: [],
|
|
542
|
+
permissions: [],
|
|
543
|
+
agentDefinitions: [],
|
|
544
|
+
};
|
|
545
|
+
try {
|
|
546
|
+
await createSkillSource(sourceDir, "skills/claude-only");
|
|
547
|
+
const { actions } = await planDeploy(manifest, sourceDir, ["claude-code"], "/home/test");
|
|
548
|
+
assert.equal(actions.length, 1);
|
|
549
|
+
assert.equal(actions[0]?.skill, "claude-only");
|
|
550
|
+
}
|
|
551
|
+
finally {
|
|
552
|
+
await rm(sourceDir, { recursive: true });
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
it("ignores invalid file sources for agents that are not being deployed", async () => {
|
|
556
|
+
const sourceDir = await makeTmpDir();
|
|
557
|
+
const manifest = {
|
|
558
|
+
skills: [],
|
|
559
|
+
files: [
|
|
560
|
+
{
|
|
561
|
+
name: "codex-file",
|
|
562
|
+
path: "missing.txt",
|
|
563
|
+
target: "{home}/missing.txt",
|
|
564
|
+
agents: ["codex"],
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: "claude-file",
|
|
568
|
+
path: "present.txt",
|
|
569
|
+
target: "{home}/present.txt",
|
|
570
|
+
agents: ["claude-code"],
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
configs: [],
|
|
574
|
+
mcpServers: [],
|
|
575
|
+
agentRules: [],
|
|
576
|
+
permissions: [],
|
|
577
|
+
agentDefinitions: [],
|
|
578
|
+
};
|
|
579
|
+
try {
|
|
580
|
+
await writeFile(path.join(sourceDir, "present.txt"), "present");
|
|
581
|
+
const { actions } = await planDeploy(manifest, sourceDir, ["claude-code"], "/home/test");
|
|
582
|
+
assert.equal(actions.length, 1);
|
|
583
|
+
assert.equal(actions[0]?.skill, "claude-file");
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
await rm(sourceDir, { recursive: true });
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
it("ignores invalid agentRule sources for agents that are not being deployed", async () => {
|
|
590
|
+
const sourceDir = await makeTmpDir();
|
|
591
|
+
const manifest = {
|
|
592
|
+
skills: [],
|
|
593
|
+
files: [],
|
|
594
|
+
configs: [],
|
|
595
|
+
mcpServers: [],
|
|
596
|
+
agentRules: [
|
|
597
|
+
{
|
|
598
|
+
name: "codex-rules",
|
|
599
|
+
path: "missing.md",
|
|
600
|
+
agents: ["codex"],
|
|
601
|
+
scope: "global",
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
name: "claude-rules",
|
|
605
|
+
path: "CLAUDE.md",
|
|
606
|
+
agents: ["claude-code"],
|
|
607
|
+
scope: "global",
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
permissions: [],
|
|
611
|
+
agentDefinitions: [],
|
|
612
|
+
};
|
|
613
|
+
try {
|
|
614
|
+
await writeFile(path.join(sourceDir, "CLAUDE.md"), "---\nname: test-rule\ndescription: test\n---\n# Rules");
|
|
615
|
+
const { actions } = await planDeploy(manifest, sourceDir, ["claude-code"], "/home/test");
|
|
616
|
+
assert.equal(actions.length, 1);
|
|
617
|
+
assert.equal(actions[0]?.skill, "claude-rules");
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
await rm(sourceDir, { recursive: true });
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
it("mcpServers entry with gemini-cli and antigravity produces actions at distinct targets", async () => {
|
|
624
|
+
const sourceDir = await makeTmpDir();
|
|
625
|
+
const manifest = {
|
|
626
|
+
skills: [],
|
|
627
|
+
files: [],
|
|
628
|
+
configs: [],
|
|
629
|
+
mcpServers: [
|
|
630
|
+
{
|
|
631
|
+
name: "my-mcp",
|
|
632
|
+
agents: ["gemini-cli", "antigravity"],
|
|
633
|
+
config: { command: "my-server" },
|
|
634
|
+
scope: "global",
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
agentRules: [],
|
|
638
|
+
permissions: [],
|
|
639
|
+
agentDefinitions: [],
|
|
640
|
+
};
|
|
641
|
+
try {
|
|
642
|
+
const { actions, warnings } = await planDeploy(manifest, sourceDir, ["gemini-cli", "antigravity"], "/home/test", "/repo");
|
|
643
|
+
const ambiguity = warnings.find((w) => w.kind === "ambiguity");
|
|
644
|
+
assert.ok(ambiguity, "expected an ambiguity warning");
|
|
645
|
+
const geminiAction = actions.find((a) => a.agent === "gemini-cli");
|
|
646
|
+
const antigravityAction = actions.find((a) => a.agent === "antigravity");
|
|
647
|
+
assert.ok(geminiAction, "expected a gemini-cli action");
|
|
648
|
+
assert.match(geminiAction.target, /[\\/]\.gemini[\\/]settings\.json$/);
|
|
649
|
+
assert.ok(antigravityAction, "expected an antigravity action");
|
|
650
|
+
// Antigravity's target for mcpServer is .agents/rules/{name}.md
|
|
651
|
+
assert.match(antigravityAction.target, /[\\/]\.agents[\\/]rules[\\/]my-mcp\.md$/);
|
|
652
|
+
}
|
|
653
|
+
finally {
|
|
654
|
+
await rm(sourceDir, { recursive: true });
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
it("agentRules with scope: repo and workspace emits scope-aware ambiguity warnings for Gemini CLI", async () => {
|
|
658
|
+
const sourceDir = await makeTmpDir();
|
|
659
|
+
const manifest = {
|
|
660
|
+
skills: [],
|
|
661
|
+
files: [],
|
|
662
|
+
configs: [],
|
|
663
|
+
mcpServers: [],
|
|
664
|
+
agentRules: [
|
|
665
|
+
{
|
|
666
|
+
name: "repo-rules",
|
|
667
|
+
path: "repo.md",
|
|
668
|
+
agents: ["gemini-cli", "antigravity"],
|
|
669
|
+
scope: "repo",
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
name: "workspace-rules",
|
|
673
|
+
path: "workspace.md",
|
|
674
|
+
agents: ["gemini-cli", "antigravity"],
|
|
675
|
+
scope: "workspace",
|
|
676
|
+
},
|
|
677
|
+
],
|
|
678
|
+
permissions: [],
|
|
679
|
+
agentDefinitions: [],
|
|
680
|
+
};
|
|
681
|
+
try {
|
|
682
|
+
await writeFile(path.join(sourceDir, "repo.md"), "---\nname: repo-rule\ndescription: test\n---\n# Repo");
|
|
683
|
+
await writeFile(path.join(sourceDir, "workspace.md"), "---\nname: workspace-rule\ndescription: test\n---\n# Workspace");
|
|
684
|
+
const { warnings } = await planDeploy(manifest, sourceDir, ["gemini-cli", "antigravity"], "/home/test", "/repo", "/workspace");
|
|
685
|
+
const repoWarning = warnings.find((w) => w.kind === "ambiguity" && w.message.includes("repo-rules"));
|
|
686
|
+
const workspaceWarning = warnings.find((w) => w.kind === "ambiguity" && w.message.includes("workspace-rules"));
|
|
687
|
+
assert.ok(repoWarning, "expected ambiguity warning for repo-rules");
|
|
688
|
+
assert.match(repoWarning.message, /same surface/);
|
|
689
|
+
// antigravity does not support workspace scope, so the paths do not
|
|
690
|
+
// match — no ambiguity warning should be emitted for workspace-rules
|
|
691
|
+
assert.equal(workspaceWarning, undefined, "expected no ambiguity warning for workspace-rules (antigravity lacks workspace support)");
|
|
692
|
+
}
|
|
693
|
+
finally {
|
|
694
|
+
await rm(sourceDir, { recursive: true });
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
it("agentDefinitions entry with gemini-cli and antigravity emits ambiguity warning for behavioral divergence", async () => {
|
|
698
|
+
const sourceDir = await makeTmpDir();
|
|
699
|
+
const manifest = {
|
|
700
|
+
skills: [],
|
|
701
|
+
files: [],
|
|
702
|
+
configs: [],
|
|
703
|
+
mcpServers: [],
|
|
704
|
+
agentRules: [],
|
|
705
|
+
permissions: [],
|
|
706
|
+
agentDefinitions: [
|
|
707
|
+
{
|
|
708
|
+
name: "my-agent",
|
|
709
|
+
path: "agents/my-agent.md",
|
|
710
|
+
scope: "repo",
|
|
711
|
+
agents: ["gemini-cli", "antigravity"],
|
|
712
|
+
},
|
|
713
|
+
],
|
|
714
|
+
};
|
|
715
|
+
try {
|
|
716
|
+
await mkdir(path.join(sourceDir, "agents"), { recursive: true });
|
|
717
|
+
await writeFile(path.join(sourceDir, "agents", "my-agent.md"), "---\nname: test-agent\ndescription: test\n---\n# Agent");
|
|
718
|
+
const { actions, warnings } = await planDeploy(manifest, sourceDir, ["gemini-cli", "antigravity"], "/home/test", "/repo");
|
|
719
|
+
const ambiguity = warnings.find((w) => w.kind === "ambiguity" && w.message.includes("agentDefinitions"));
|
|
720
|
+
assert.ok(ambiguity, "expected an ambiguity warning for agentDefinitions");
|
|
721
|
+
assert.match(ambiguity.message, /behavioral divergence/);
|
|
722
|
+
const geminiAction = actions.find((a) => a.agent === "gemini-cli");
|
|
723
|
+
const antigravityAction = actions.find((a) => a.agent === "antigravity");
|
|
724
|
+
assert.ok(geminiAction, "expected a geminiAction");
|
|
725
|
+
assert.ok(antigravityAction, "expected an antigravityAction");
|
|
726
|
+
assert.match(geminiAction.target, /[\\/]\.gemini[\\/]agents[\\/]my-agent\.md$/);
|
|
727
|
+
assert.match(antigravityAction.target, /[\\/]\.agents[\\/]rules[\\/]my-agent\.md$/);
|
|
728
|
+
}
|
|
729
|
+
finally {
|
|
730
|
+
await rm(sourceDir, { recursive: true });
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
it("agentRules support scope: workspace for supported agents", async () => {
|
|
734
|
+
const sourceDir = await makeTmpDir();
|
|
735
|
+
const manifest = {
|
|
736
|
+
skills: [],
|
|
737
|
+
files: [],
|
|
738
|
+
configs: [],
|
|
739
|
+
mcpServers: [],
|
|
740
|
+
agentRules: [
|
|
741
|
+
{
|
|
742
|
+
name: "workspace-rules",
|
|
743
|
+
path: "workspace.md",
|
|
744
|
+
agents: ["claude-code"],
|
|
745
|
+
scope: "workspace",
|
|
746
|
+
},
|
|
747
|
+
],
|
|
748
|
+
permissions: [],
|
|
749
|
+
agentDefinitions: [],
|
|
750
|
+
};
|
|
751
|
+
try {
|
|
752
|
+
await writeFile(path.join(sourceDir, "workspace.md"), "---\nname: workspace-rule\ndescription: test\n---\n# Workspace");
|
|
753
|
+
const { actions, warnings } = await planDeploy(manifest, sourceDir, ["claude-code"], "/home/test", "/repo", "/workspace");
|
|
754
|
+
assert.equal(actions.length, 1);
|
|
755
|
+
assert.equal(actions[0]?.kind, "file-write");
|
|
756
|
+
assert.equal(actions[0]?.agent, "claude-code");
|
|
757
|
+
assert.equal(actions[0]?.target, "/workspace/CLAUDE.md");
|
|
758
|
+
assert.equal(warnings.length, 0);
|
|
759
|
+
}
|
|
760
|
+
finally {
|
|
761
|
+
await rm(sourceDir, { recursive: true });
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
it("agentRules support scope: workspace even if unsupported by agent (emits confidence warning)", async () => {
|
|
765
|
+
const sourceDir = await makeTmpDir();
|
|
766
|
+
const manifest = {
|
|
767
|
+
skills: [],
|
|
768
|
+
files: [],
|
|
769
|
+
configs: [],
|
|
770
|
+
mcpServers: [],
|
|
771
|
+
agentRules: [
|
|
772
|
+
{
|
|
773
|
+
name: "workspace-rules",
|
|
774
|
+
path: "workspace.md",
|
|
775
|
+
agents: ["antigravity"],
|
|
776
|
+
scope: "workspace",
|
|
777
|
+
},
|
|
778
|
+
],
|
|
779
|
+
permissions: [],
|
|
780
|
+
agentDefinitions: [],
|
|
781
|
+
};
|
|
782
|
+
try {
|
|
783
|
+
await writeFile(path.join(sourceDir, "workspace.md"), "---\nname: workspace-rule\ndescription: test\n---\n# Workspace");
|
|
784
|
+
const { warnings } = await planDeploy(manifest, sourceDir, ["antigravity"], "/home/test", "/repo", "/workspace");
|
|
785
|
+
const confidence = warnings.find((w) => w.kind === "confidence" &&
|
|
786
|
+
w.message.includes("workspace") &&
|
|
787
|
+
w.message.includes("unsupported"));
|
|
788
|
+
assert.ok(confidence, "expected an unsupported confidence warning for workspace scope on antigravity");
|
|
789
|
+
}
|
|
790
|
+
finally {
|
|
791
|
+
await rm(sourceDir, { recursive: true });
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
it("throws when agentDefinitions and mcpServers share a name for antigravity", async () => {
|
|
795
|
+
const sourceDir = await makeTmpDir();
|
|
796
|
+
const manifest = {
|
|
797
|
+
skills: [],
|
|
798
|
+
files: [],
|
|
799
|
+
configs: [],
|
|
800
|
+
mcpServers: [
|
|
801
|
+
{
|
|
802
|
+
name: "my-tool",
|
|
803
|
+
agents: ["antigravity"],
|
|
804
|
+
config: { command: "npx", args: ["-y", "my-tool"] },
|
|
805
|
+
scope: "global",
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
agentRules: [],
|
|
809
|
+
permissions: [],
|
|
810
|
+
agentDefinitions: [
|
|
811
|
+
{
|
|
812
|
+
name: "my-tool",
|
|
813
|
+
path: "agents/my-tool.md",
|
|
814
|
+
agents: ["antigravity"],
|
|
815
|
+
scope: "repo",
|
|
816
|
+
},
|
|
817
|
+
],
|
|
818
|
+
};
|
|
819
|
+
try {
|
|
820
|
+
await mkdir(path.join(sourceDir, "agents"), { recursive: true });
|
|
821
|
+
await writeFile(path.join(sourceDir, "agents", "my-tool.md"), "---\nname: my-tool\ndescription: test\n---\n# Tool\n");
|
|
822
|
+
await assert.rejects(() => planDeploy(manifest, sourceDir, ["antigravity"], "/home/test", "/repo"), (err) => {
|
|
823
|
+
assert.ok(err instanceof UserError);
|
|
824
|
+
assert.equal(err.code, "MANIFEST_INVALID");
|
|
825
|
+
assert.match(err.message, /my-tool/);
|
|
826
|
+
assert.match(err.message, /\.agents\/rules\/my-tool\.md/);
|
|
827
|
+
return true;
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
await rm(sourceDir, { recursive: true });
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
it("emits no collision warning when agentDefinitions and mcpServers have different names for antigravity", async () => {
|
|
835
|
+
const sourceDir = await makeTmpDir();
|
|
836
|
+
const manifest = {
|
|
837
|
+
skills: [],
|
|
838
|
+
files: [],
|
|
839
|
+
configs: [],
|
|
840
|
+
mcpServers: [
|
|
841
|
+
{
|
|
842
|
+
name: "my-mcp",
|
|
843
|
+
agents: ["antigravity"],
|
|
844
|
+
config: { command: "npx", args: ["-y", "my-mcp"] },
|
|
845
|
+
scope: "global",
|
|
846
|
+
},
|
|
847
|
+
],
|
|
848
|
+
agentRules: [],
|
|
849
|
+
permissions: [],
|
|
850
|
+
agentDefinitions: [
|
|
851
|
+
{
|
|
852
|
+
name: "my-agent",
|
|
853
|
+
path: "agents/my-agent.md",
|
|
854
|
+
scope: "repo",
|
|
855
|
+
agents: ["antigravity"],
|
|
856
|
+
},
|
|
857
|
+
],
|
|
858
|
+
};
|
|
859
|
+
try {
|
|
860
|
+
await mkdir(path.join(sourceDir, "agents"), { recursive: true });
|
|
861
|
+
await writeFile(path.join(sourceDir, "agents", "my-agent.md"), "---\nname: my-agent\ndescription: test\n---\n# Agent\n");
|
|
862
|
+
const { warnings } = await planDeploy(manifest, sourceDir, ["antigravity"], "/home/test", "/repo");
|
|
863
|
+
const collision = warnings.find((w) => w.kind === "collision" &&
|
|
864
|
+
(w.message.includes("my-mcp") || w.message.includes("my-agent")));
|
|
865
|
+
assert.equal(collision, undefined, "should not emit a collision warning when names differ");
|
|
866
|
+
}
|
|
867
|
+
finally {
|
|
868
|
+
await rm(sourceDir, { recursive: true });
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
describe("executeDeploy", { skip: process.platform === "win32" }, () => {
|
|
873
|
+
it("creates symlinks on POSIX", async () => {
|
|
874
|
+
const sourceDir = await makeTmpDir();
|
|
875
|
+
const home = await makeTmpDir();
|
|
876
|
+
try {
|
|
877
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
878
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
879
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
880
|
+
assert.equal(succeeded, 1);
|
|
881
|
+
assert.equal(failed.length, 0);
|
|
882
|
+
const target = actions[0]?.target;
|
|
883
|
+
assert.ok(await exists(target));
|
|
884
|
+
assert.ok((await lstat(target)).isSymbolicLink());
|
|
885
|
+
const action = actions[0];
|
|
886
|
+
if (action?.kind !== "skill-dir") {
|
|
887
|
+
assert.fail("Expected skill-dir action");
|
|
888
|
+
}
|
|
889
|
+
assert.equal(await readlink(target), action.source);
|
|
890
|
+
}
|
|
891
|
+
finally {
|
|
892
|
+
await rm(sourceDir, { recursive: true });
|
|
893
|
+
await rm(home, { recursive: true, force: true });
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
it("registers deployment in registry on POSIX symlink deploy", async () => {
|
|
897
|
+
const sourceDir = await makeTmpDir();
|
|
898
|
+
const home = await makeTmpDir();
|
|
899
|
+
try {
|
|
900
|
+
const skillSource = await createSkillSource(sourceDir, "skills/test-skill");
|
|
901
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
902
|
+
await executeDeploy(actions, false, false, home);
|
|
903
|
+
const target = actions[0]?.target;
|
|
904
|
+
// Registry should have the entry
|
|
905
|
+
const entry = await lookupDeployment(home, target);
|
|
906
|
+
assert.ok(entry);
|
|
907
|
+
if (entry.kind !== "skill-dir")
|
|
908
|
+
assert.fail("Expected skill-dir");
|
|
909
|
+
assert.equal(entry.skill, "test-skill");
|
|
910
|
+
assert.equal(entry.agent, "claude-code");
|
|
911
|
+
assert.equal(entry.source, skillSource);
|
|
912
|
+
assert.equal(entry.method, "symlink");
|
|
913
|
+
// No .inception-totem should exist in source
|
|
914
|
+
assert.ok(!(await exists(path.join(skillSource, ".inception-totem"))), "no .inception-totem should be written to source directory");
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
await rm(sourceDir, { recursive: true });
|
|
918
|
+
await rm(home, { recursive: true, force: true });
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
it("does not create symlinks in dry-run mode", async () => {
|
|
922
|
+
const sourceDir = await makeTmpDir();
|
|
923
|
+
const home = await makeTmpDir();
|
|
924
|
+
try {
|
|
925
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
926
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
927
|
+
const { succeeded, failed } = await executeDeploy(actions, true, false, home);
|
|
928
|
+
assert.equal(succeeded, 1);
|
|
929
|
+
assert.equal(failed.length, 0);
|
|
930
|
+
const target = actions[0]?.target;
|
|
931
|
+
assert.ok(!(await exists(target)));
|
|
932
|
+
}
|
|
933
|
+
finally {
|
|
934
|
+
await rm(sourceDir, { recursive: true });
|
|
935
|
+
await rm(home, { recursive: true, force: true });
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
it("overwrites existing symlink (with registry entry)", async () => {
|
|
939
|
+
const sourceDir = await makeTmpDir();
|
|
940
|
+
const home = await makeTmpDir();
|
|
941
|
+
try {
|
|
942
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
943
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
944
|
+
// Deploy twice - second should overwrite (first creates registry entry)
|
|
945
|
+
await executeDeploy(actions, false, false, home);
|
|
946
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
947
|
+
assert.equal(succeeded, 1);
|
|
948
|
+
assert.equal(failed.length, 0);
|
|
949
|
+
}
|
|
950
|
+
finally {
|
|
951
|
+
await rm(sourceDir, { recursive: true });
|
|
952
|
+
await rm(home, { recursive: true, force: true });
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
it("reports error for missing source (caught at planning)", async () => {
|
|
956
|
+
const home = await makeTmpDir();
|
|
957
|
+
try {
|
|
958
|
+
await assert.rejects(planDeploy(testManifest, "/nonexistent/source", ["claude-code"], home), (err) => {
|
|
959
|
+
assert.ok(err instanceof UserError);
|
|
960
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
961
|
+
assert.match(err.message, /source not found/);
|
|
962
|
+
return true;
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
finally {
|
|
966
|
+
await rm(home, { recursive: true, force: true });
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
it("reports permission denied when source is unreadable (caught at planning)", async () => {
|
|
970
|
+
const sourceDir = await makeTmpDir();
|
|
971
|
+
const home = await makeTmpDir();
|
|
972
|
+
// Chmod the parent so traversal into the skill source dir fails with EACCES
|
|
973
|
+
const skillsDir = path.join(sourceDir, "skills");
|
|
974
|
+
try {
|
|
975
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
976
|
+
await chmod(skillsDir, 0o000);
|
|
977
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], home), (err) => {
|
|
978
|
+
assert.ok(err instanceof UserError);
|
|
979
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
980
|
+
assert.match(err.message, /Permission denied/);
|
|
981
|
+
return true;
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
finally {
|
|
985
|
+
await chmod(skillsDir, 0o755);
|
|
986
|
+
await rm(sourceDir, { recursive: true });
|
|
987
|
+
await rm(home, { recursive: true, force: true });
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
it("reports permission denied when skill directory is not readable (execute-only)", {
|
|
991
|
+
skip: process.platform === "win32" || process.getuid?.() === 0,
|
|
992
|
+
}, async () => {
|
|
993
|
+
const sourceDir = await makeTmpDir();
|
|
994
|
+
const home = await makeTmpDir();
|
|
995
|
+
const skillDir = path.join(sourceDir, "skills", "test-skill");
|
|
996
|
+
try {
|
|
997
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
998
|
+
// 0o111 = --x--x--x: directory exists and is traversable but not readable
|
|
999
|
+
await chmod(skillDir, 0o111);
|
|
1000
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], home), (err) => {
|
|
1001
|
+
assert.ok(err instanceof UserError);
|
|
1002
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
1003
|
+
assert.match(err.message, /Permission denied reading skill directory/);
|
|
1004
|
+
return true;
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
finally {
|
|
1008
|
+
try {
|
|
1009
|
+
await chmod(skillDir, 0o755);
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
/* best effort */
|
|
1013
|
+
}
|
|
1014
|
+
await rm(sourceDir, { recursive: true });
|
|
1015
|
+
await rm(home, { recursive: true, force: true });
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
it("reports permission denied when SKILL.md is not readable", {
|
|
1019
|
+
skip: process.platform === "win32" || process.getuid?.() === 0,
|
|
1020
|
+
}, async () => {
|
|
1021
|
+
const sourceDir = await makeTmpDir();
|
|
1022
|
+
const home = await makeTmpDir();
|
|
1023
|
+
const skillMdPath = path.join(sourceDir, "skills", "test-skill", "SKILL.md");
|
|
1024
|
+
try {
|
|
1025
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1026
|
+
await chmod(skillMdPath, 0o000);
|
|
1027
|
+
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], home), (err) => {
|
|
1028
|
+
assert.ok(err instanceof UserError);
|
|
1029
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
1030
|
+
assert.match(err.message, /Permission denied reading SKILL\.md/);
|
|
1031
|
+
return true;
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
finally {
|
|
1035
|
+
try {
|
|
1036
|
+
await chmod(skillMdPath, 0o644);
|
|
1037
|
+
}
|
|
1038
|
+
catch {
|
|
1039
|
+
/* best effort */
|
|
1040
|
+
}
|
|
1041
|
+
await rm(sourceDir, { recursive: true });
|
|
1042
|
+
await rm(home, { recursive: true, force: true });
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
it("refuses to overwrite unmanaged target", async () => {
|
|
1046
|
+
const sourceDir = await makeTmpDir();
|
|
1047
|
+
const home = await makeTmpDir();
|
|
1048
|
+
try {
|
|
1049
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1050
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1051
|
+
const target = actions[0]?.target;
|
|
1052
|
+
// Create an unmanaged directory at the target (no registry entry)
|
|
1053
|
+
await mkdir(target, { recursive: true });
|
|
1054
|
+
await writeFile(path.join(target, "something.txt"), "user content");
|
|
1055
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1056
|
+
assert.equal(succeeded, 0);
|
|
1057
|
+
assert.equal(failed.length, 1);
|
|
1058
|
+
assert.ok(failed[0]?.error.includes("not managed by inception-engine"));
|
|
1059
|
+
// Original content should still be there
|
|
1060
|
+
assert.ok(await exists(path.join(target, "something.txt")));
|
|
1061
|
+
}
|
|
1062
|
+
finally {
|
|
1063
|
+
await rm(sourceDir, { recursive: true });
|
|
1064
|
+
await rm(home, { recursive: true, force: true });
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
it("refuses to overwrite target with mismatched registry entry", async () => {
|
|
1068
|
+
const sourceDir = await makeTmpDir();
|
|
1069
|
+
const home = await makeTmpDir();
|
|
1070
|
+
try {
|
|
1071
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1072
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1073
|
+
const target = actions[0]?.target;
|
|
1074
|
+
// Create a directory at the target and register it under a different source/skill
|
|
1075
|
+
await mkdir(target, { recursive: true });
|
|
1076
|
+
await writeFile(path.join(target, "SKILL.md"), "other content");
|
|
1077
|
+
await registerDeployment(home, target, {
|
|
1078
|
+
kind: "skill-dir",
|
|
1079
|
+
source: "/completely/different/source",
|
|
1080
|
+
skill: "different-skill",
|
|
1081
|
+
agent: "codex",
|
|
1082
|
+
method: "symlink",
|
|
1083
|
+
});
|
|
1084
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1085
|
+
assert.equal(succeeded, 0);
|
|
1086
|
+
assert.equal(failed.length, 1);
|
|
1087
|
+
assert.ok(failed[0]?.error.includes("not managed by inception-engine"));
|
|
1088
|
+
// Original content should still be there
|
|
1089
|
+
assert.ok(await exists(path.join(target, "SKILL.md")));
|
|
1090
|
+
}
|
|
1091
|
+
finally {
|
|
1092
|
+
await rm(sourceDir, { recursive: true });
|
|
1093
|
+
await rm(home, { recursive: true, force: true });
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
it("backup is removed after successful redeploy", async () => {
|
|
1097
|
+
const sourceDir = await makeTmpDir();
|
|
1098
|
+
const home = await makeTmpDir();
|
|
1099
|
+
try {
|
|
1100
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1101
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1102
|
+
const target = actions[0]?.target;
|
|
1103
|
+
const backupPath = `${target}.inception-backup`;
|
|
1104
|
+
await executeDeploy(actions, false, false, home);
|
|
1105
|
+
await executeDeploy(actions, false, false, home);
|
|
1106
|
+
assert.ok(!(await exists(backupPath)), "backup should not exist after successful redeploy");
|
|
1107
|
+
assert.ok(await exists(target), "target should exist after redeploy");
|
|
1108
|
+
}
|
|
1109
|
+
finally {
|
|
1110
|
+
await rm(sourceDir, { recursive: true });
|
|
1111
|
+
await rm(home, { recursive: true, force: true });
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
describe("atomic redeploy behavior", {
|
|
1116
|
+
skip: process.platform === "win32",
|
|
1117
|
+
}, () => {
|
|
1118
|
+
it("cleans up stale .inception-backup from a previous failed attempt", async () => {
|
|
1119
|
+
const sourceDir = await makeTmpDir();
|
|
1120
|
+
const home = await makeTmpDir();
|
|
1121
|
+
try {
|
|
1122
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1123
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1124
|
+
const target = actions[0]?.target;
|
|
1125
|
+
const backupPath = `${target}.inception-backup`;
|
|
1126
|
+
// First deploy to establish managed target
|
|
1127
|
+
await executeDeploy(actions, false, false, home);
|
|
1128
|
+
// Simulate a stale backup left by a previous crash
|
|
1129
|
+
await mkdir(backupPath, { recursive: true });
|
|
1130
|
+
await writeFile(path.join(backupPath, "stale.txt"), "leftover");
|
|
1131
|
+
// Redeploy should clean up the stale backup and succeed
|
|
1132
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1133
|
+
assert.equal(succeeded, 1);
|
|
1134
|
+
assert.equal(failed.length, 0);
|
|
1135
|
+
assert.ok(!(await exists(backupPath)), "stale backup should be cleaned up");
|
|
1136
|
+
assert.ok(await exists(target), "target should exist after redeploy");
|
|
1137
|
+
}
|
|
1138
|
+
finally {
|
|
1139
|
+
await rm(sourceDir, { recursive: true });
|
|
1140
|
+
await rm(home, { recursive: true, force: true });
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
it("no backup is created on first deploy (no prior target)", async () => {
|
|
1144
|
+
const sourceDir = await makeTmpDir();
|
|
1145
|
+
const home = await makeTmpDir();
|
|
1146
|
+
try {
|
|
1147
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1148
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1149
|
+
const target = actions[0]?.target;
|
|
1150
|
+
const backupPath = `${target}.inception-backup`;
|
|
1151
|
+
const { succeeded } = await executeDeploy(actions, false, false, home);
|
|
1152
|
+
assert.equal(succeeded, 1);
|
|
1153
|
+
assert.ok(await exists(target));
|
|
1154
|
+
assert.ok(!(await exists(backupPath)), "no backup should be created when there was no prior target");
|
|
1155
|
+
}
|
|
1156
|
+
finally {
|
|
1157
|
+
await rm(sourceDir, { recursive: true });
|
|
1158
|
+
await rm(home, { recursive: true, force: true });
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
it("restores backup when registry write fails (registry file read-only)", async () => {
|
|
1162
|
+
const sourceDir = await makeTmpDir();
|
|
1163
|
+
const home = await makeTmpDir();
|
|
1164
|
+
const registryFile = path.join(home, ".inception-engine", "registry.json");
|
|
1165
|
+
try {
|
|
1166
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1167
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1168
|
+
const target = actions[0]?.target;
|
|
1169
|
+
const backupPath = `${target}.inception-backup`;
|
|
1170
|
+
// First deploy: establishes managed symlink and registry entry
|
|
1171
|
+
const { succeeded: firstSucceeded } = await executeDeploy(actions, false, false, home);
|
|
1172
|
+
assert.equal(firstSucceeded, 1);
|
|
1173
|
+
const originalLink = await readlink(target);
|
|
1174
|
+
// Make registry file read-only so registerDeployment fails on the next attempt.
|
|
1175
|
+
// The lookupDeployment (read) still works; only the write will throw EACCES.
|
|
1176
|
+
await chmod(registryFile, 0o444);
|
|
1177
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1178
|
+
// Deploy must report failure
|
|
1179
|
+
assert.equal(succeeded, 0);
|
|
1180
|
+
assert.equal(failed.length, 1);
|
|
1181
|
+
// Backup must be cleaned up (restored back to target)
|
|
1182
|
+
assert.ok(!(await exists(backupPath)), "backup should be gone after rollback");
|
|
1183
|
+
// Original managed symlink must be restored at the target path
|
|
1184
|
+
assert.ok(await exists(target), "original symlink must be restored");
|
|
1185
|
+
assert.ok((await lstat(target)).isSymbolicLink(), "restored target must be a symlink");
|
|
1186
|
+
assert.equal(await readlink(target), originalLink, "restored symlink must point to original source");
|
|
1187
|
+
}
|
|
1188
|
+
finally {
|
|
1189
|
+
try {
|
|
1190
|
+
await chmod(registryFile, 0o644);
|
|
1191
|
+
}
|
|
1192
|
+
catch {
|
|
1193
|
+
/* best effort */
|
|
1194
|
+
}
|
|
1195
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1196
|
+
await rm(home, { recursive: true, force: true });
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
it("restores backup when cp fails (source not readable)", async () => {
|
|
1200
|
+
const sourceDir = await makeTmpDir();
|
|
1201
|
+
const home = await makeTmpDir();
|
|
1202
|
+
try {
|
|
1203
|
+
// First deploy using copy method to establish a managed target directory
|
|
1204
|
+
const skillSource = await createSkillSource(sourceDir, "skills/test-skill");
|
|
1205
|
+
const target = path.join(home, ".claude", "skills", "test-skill");
|
|
1206
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
1207
|
+
const firstAction = {
|
|
1208
|
+
kind: "skill-dir",
|
|
1209
|
+
skill: "test-skill",
|
|
1210
|
+
agent: "claude-code",
|
|
1211
|
+
source: skillSource,
|
|
1212
|
+
target,
|
|
1213
|
+
method: "copy",
|
|
1214
|
+
confidence: "documented",
|
|
1215
|
+
};
|
|
1216
|
+
const { succeeded: firstSucceeded } = await executeDeploy([firstAction], false, false, home);
|
|
1217
|
+
assert.equal(firstSucceeded, 1);
|
|
1218
|
+
assert.ok(await exists(target));
|
|
1219
|
+
// Create an unreadable source directory for the next deploy attempt
|
|
1220
|
+
// access() (F_OK) passes — the dir exists — but cp() fails reading its contents
|
|
1221
|
+
const unreadableSource = path.join(sourceDir, "unreadable-source");
|
|
1222
|
+
await mkdir(unreadableSource, { recursive: true });
|
|
1223
|
+
await writeFile(path.join(unreadableSource, "SKILL.md"), "---");
|
|
1224
|
+
await chmod(unreadableSource, 0o000);
|
|
1225
|
+
const backupPath = `${target}.inception-backup`;
|
|
1226
|
+
const failAction = {
|
|
1227
|
+
kind: "skill-dir",
|
|
1228
|
+
skill: "test-skill",
|
|
1229
|
+
agent: "claude-code",
|
|
1230
|
+
source: unreadableSource,
|
|
1231
|
+
target,
|
|
1232
|
+
method: "copy",
|
|
1233
|
+
confidence: "documented",
|
|
1234
|
+
};
|
|
1235
|
+
const { succeeded, failed } = await executeDeploy([failAction], false, false, home);
|
|
1236
|
+
// Deploy must report failure
|
|
1237
|
+
assert.equal(succeeded, 0);
|
|
1238
|
+
assert.equal(failed.length, 1);
|
|
1239
|
+
// Backup must be cleaned up (restored back to target)
|
|
1240
|
+
assert.ok(!(await exists(backupPath)), "backup should be gone after rollback");
|
|
1241
|
+
// Original managed directory must be restored with its content intact
|
|
1242
|
+
assert.ok(await exists(target), "original target must be restored");
|
|
1243
|
+
assert.ok(await exists(path.join(target, "SKILL.md")), "restored target must have original content");
|
|
1244
|
+
}
|
|
1245
|
+
finally {
|
|
1246
|
+
try {
|
|
1247
|
+
await chmod(path.join(sourceDir, "unreadable-source"), 0o755);
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
/* best effort */
|
|
1251
|
+
}
|
|
1252
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1253
|
+
await rm(home, { recursive: true, force: true });
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
it("rename atomically replaces stale .inception-backup — no stale content leaks into target", async () => {
|
|
1257
|
+
const sourceDir = await makeTmpDir();
|
|
1258
|
+
const home = await makeTmpDir();
|
|
1259
|
+
try {
|
|
1260
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1261
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1262
|
+
const target = actions[0]?.target;
|
|
1263
|
+
const backupPath = `${target}.inception-backup`;
|
|
1264
|
+
// First deploy to establish a managed target
|
|
1265
|
+
await executeDeploy(actions, false, false, home);
|
|
1266
|
+
// Simulate a stale backup containing sentinel content
|
|
1267
|
+
await mkdir(backupPath, { recursive: true });
|
|
1268
|
+
await writeFile(path.join(backupPath, "stale.txt"), "leftover content");
|
|
1269
|
+
// Redeploy: rename(target, backupPath) atomically replaces the stale dir
|
|
1270
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1271
|
+
assert.equal(succeeded, 1);
|
|
1272
|
+
assert.equal(failed.length, 0);
|
|
1273
|
+
assert.ok(await exists(target), "target must exist after redeploy");
|
|
1274
|
+
assert.ok(!(await exists(backupPath)), "stale backup must be cleaned up");
|
|
1275
|
+
// The stale sentinel file must not appear in the new target
|
|
1276
|
+
assert.ok(!(await exists(path.join(target, "stale.txt"))), "stale content must not leak into the new target");
|
|
1277
|
+
}
|
|
1278
|
+
finally {
|
|
1279
|
+
await rm(sourceDir, { recursive: true });
|
|
1280
|
+
await rm(home, { recursive: true, force: true });
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
describe("planDeploy path traversal", () => {
|
|
1285
|
+
it("throws when skill.path resolves to the repository root itself (.)", async () => {
|
|
1286
|
+
const sourceDir = await makeTmpDir();
|
|
1287
|
+
try {
|
|
1288
|
+
const rootManifest = {
|
|
1289
|
+
skills: [{ name: "root-skill", path: ".", agents: ["claude-code"] }],
|
|
1290
|
+
files: [],
|
|
1291
|
+
configs: [],
|
|
1292
|
+
mcpServers: [],
|
|
1293
|
+
agentRules: [],
|
|
1294
|
+
permissions: [],
|
|
1295
|
+
agentDefinitions: [],
|
|
1296
|
+
};
|
|
1297
|
+
await assert.rejects(() => planDeploy(rootManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
1298
|
+
assert.ok(err instanceof UserError);
|
|
1299
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
1300
|
+
assert.match(err.message, /resolves outside the repository root/);
|
|
1301
|
+
return true;
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
finally {
|
|
1305
|
+
await rm(sourceDir, { recursive: true });
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
it("throws when skill.path escapes sourceDir via traversal (../../outside)", async () => {
|
|
1309
|
+
const sourceDir = await makeTmpDir();
|
|
1310
|
+
try {
|
|
1311
|
+
const traversalManifest = {
|
|
1312
|
+
skills: [
|
|
1313
|
+
{ name: "evil", path: "../../outside", agents: ["claude-code"] },
|
|
1314
|
+
],
|
|
1315
|
+
files: [],
|
|
1316
|
+
configs: [],
|
|
1317
|
+
mcpServers: [],
|
|
1318
|
+
agentRules: [],
|
|
1319
|
+
permissions: [],
|
|
1320
|
+
agentDefinitions: [],
|
|
1321
|
+
};
|
|
1322
|
+
await assert.rejects(() => planDeploy(traversalManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
1323
|
+
assert.ok(err instanceof UserError);
|
|
1324
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
1325
|
+
assert.match(err.message, /resolves outside the repository root/);
|
|
1326
|
+
return true;
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
finally {
|
|
1330
|
+
await rm(sourceDir, { recursive: true });
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
it("throws when skill.path escapes via symlink", async () => {
|
|
1334
|
+
const sourceDir = await makeTmpDir();
|
|
1335
|
+
const outsideDir = await makeTmpDir();
|
|
1336
|
+
try {
|
|
1337
|
+
// Create a symlink inside the repo that points outside
|
|
1338
|
+
const symlinkPath = path.join(sourceDir, "skills", "escape");
|
|
1339
|
+
await mkdir(path.join(sourceDir, "skills"), { recursive: true });
|
|
1340
|
+
await symlink(outsideDir, symlinkPath, process.platform === "win32" ? "junction" : "dir");
|
|
1341
|
+
const escapeManifest = {
|
|
1342
|
+
skills: [
|
|
1343
|
+
{ name: "evil", path: "skills/escape", agents: ["claude-code"] },
|
|
1344
|
+
],
|
|
1345
|
+
files: [],
|
|
1346
|
+
configs: [],
|
|
1347
|
+
mcpServers: [],
|
|
1348
|
+
agentRules: [],
|
|
1349
|
+
permissions: [],
|
|
1350
|
+
agentDefinitions: [],
|
|
1351
|
+
};
|
|
1352
|
+
await assert.rejects(() => planDeploy(escapeManifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
1353
|
+
assert.ok(err instanceof UserError);
|
|
1354
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
1355
|
+
assert.match(err.message, /resolves outside the repository root via symlink/);
|
|
1356
|
+
return true;
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
finally {
|
|
1360
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1361
|
+
await rm(outsideDir, { recursive: true });
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
});
|
|
1365
|
+
async function snapshotDir(dir) {
|
|
1366
|
+
const entries = await readdir(dir, { recursive: true });
|
|
1367
|
+
return entries.sort();
|
|
1368
|
+
}
|
|
1369
|
+
describe("source directory immutability", () => {
|
|
1370
|
+
it("source dir is unchanged after symlink deploy", async () => {
|
|
1371
|
+
const sourceDir = await makeTmpDir();
|
|
1372
|
+
const home = await makeTmpDir();
|
|
1373
|
+
try {
|
|
1374
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1375
|
+
const before = await snapshotDir(sourceDir);
|
|
1376
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1377
|
+
await executeDeploy(actions, false, false, home);
|
|
1378
|
+
const after = await snapshotDir(sourceDir);
|
|
1379
|
+
assert.deepEqual(after, before, "source directory must not be modified by deploy");
|
|
1380
|
+
}
|
|
1381
|
+
finally {
|
|
1382
|
+
await rm(sourceDir, { recursive: true });
|
|
1383
|
+
await rm(home, { recursive: true, force: true });
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
it("source dir is unchanged after copy deploy", async () => {
|
|
1387
|
+
const sourceDir = await makeTmpDir();
|
|
1388
|
+
const home = await makeTmpDir();
|
|
1389
|
+
try {
|
|
1390
|
+
const skillSource = await createSkillSource(sourceDir, "skills/test-skill");
|
|
1391
|
+
const target = path.join(home, ".claude", "skills", "test-skill");
|
|
1392
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
1393
|
+
const action = {
|
|
1394
|
+
kind: "skill-dir",
|
|
1395
|
+
skill: "test-skill",
|
|
1396
|
+
agent: "claude-code",
|
|
1397
|
+
source: skillSource,
|
|
1398
|
+
target,
|
|
1399
|
+
method: "copy",
|
|
1400
|
+
confidence: "documented",
|
|
1401
|
+
};
|
|
1402
|
+
const before = await snapshotDir(sourceDir);
|
|
1403
|
+
await executeDeploy([action], false, false, home);
|
|
1404
|
+
const after = await snapshotDir(sourceDir);
|
|
1405
|
+
assert.deepEqual(after, before, "source directory must not be modified by copy deploy");
|
|
1406
|
+
}
|
|
1407
|
+
finally {
|
|
1408
|
+
await rm(sourceDir, { recursive: true });
|
|
1409
|
+
await rm(home, { recursive: true, force: true });
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
it("source dir is unchanged after symlink revert", async () => {
|
|
1413
|
+
const sourceDir = await makeTmpDir();
|
|
1414
|
+
const home = await makeTmpDir();
|
|
1415
|
+
try {
|
|
1416
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1417
|
+
const { actions: deployActions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1418
|
+
await executeDeploy(deployActions, false, false, home);
|
|
1419
|
+
const before = await snapshotDir(sourceDir);
|
|
1420
|
+
const revertActions = planRevert(testManifest, ["claude-code"], home);
|
|
1421
|
+
await executeRevert(revertActions, false, false, home);
|
|
1422
|
+
const after = await snapshotDir(sourceDir);
|
|
1423
|
+
assert.deepEqual(after, before, "source directory must not be modified by revert");
|
|
1424
|
+
}
|
|
1425
|
+
finally {
|
|
1426
|
+
await rm(sourceDir, { recursive: true });
|
|
1427
|
+
await rm(home, { recursive: true, force: true });
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
it("source dir is unchanged after copy revert", async () => {
|
|
1431
|
+
const sourceDir = await makeTmpDir();
|
|
1432
|
+
const home = await makeTmpDir();
|
|
1433
|
+
try {
|
|
1434
|
+
const skillSource = await createSkillSource(sourceDir, "skills/test-skill");
|
|
1435
|
+
const target = path.join(home, ".claude", "skills", "test-skill");
|
|
1436
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
1437
|
+
const action = {
|
|
1438
|
+
kind: "skill-dir",
|
|
1439
|
+
skill: "test-skill",
|
|
1440
|
+
agent: "claude-code",
|
|
1441
|
+
source: skillSource,
|
|
1442
|
+
target,
|
|
1443
|
+
method: "copy",
|
|
1444
|
+
confidence: "documented",
|
|
1445
|
+
};
|
|
1446
|
+
await executeDeploy([action], false, false, home);
|
|
1447
|
+
const before = await snapshotDir(sourceDir);
|
|
1448
|
+
const revertActions = planRevert(testManifest, ["claude-code"], home);
|
|
1449
|
+
await executeRevert(revertActions, false, false, home);
|
|
1450
|
+
const after = await snapshotDir(sourceDir);
|
|
1451
|
+
assert.deepEqual(after, before, "source directory must not be modified by copy revert");
|
|
1452
|
+
}
|
|
1453
|
+
finally {
|
|
1454
|
+
await rm(sourceDir, { recursive: true });
|
|
1455
|
+
await rm(home, { recursive: true, force: true });
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
});
|
|
1459
|
+
describe("executeDeploy (Windows)", {
|
|
1460
|
+
skip: process.platform !== "win32",
|
|
1461
|
+
}, () => {
|
|
1462
|
+
it("creates copies on Windows", async () => {
|
|
1463
|
+
const sourceDir = await makeTmpDir();
|
|
1464
|
+
const home = await makeTmpDir();
|
|
1465
|
+
try {
|
|
1466
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1467
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1468
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1469
|
+
assert.equal(succeeded, 1);
|
|
1470
|
+
assert.equal(failed.length, 0);
|
|
1471
|
+
const target = actions[0]?.target;
|
|
1472
|
+
assert.ok(await exists(target));
|
|
1473
|
+
assert.ok(!(await lstat(target)).isSymbolicLink());
|
|
1474
|
+
assert.ok(await exists(path.join(target, "SKILL.md")));
|
|
1475
|
+
}
|
|
1476
|
+
finally {
|
|
1477
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1478
|
+
await rm(home, { recursive: true, force: true });
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
it("registers deployment in registry on Windows copy deploy", async () => {
|
|
1482
|
+
const sourceDir = await makeTmpDir();
|
|
1483
|
+
const home = await makeTmpDir();
|
|
1484
|
+
try {
|
|
1485
|
+
const skillSource = await createSkillSource(sourceDir, "skills/test-skill");
|
|
1486
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1487
|
+
await executeDeploy(actions, false, false, home);
|
|
1488
|
+
const target = actions[0]?.target;
|
|
1489
|
+
const entry = await lookupDeployment(home, target);
|
|
1490
|
+
assert.ok(entry);
|
|
1491
|
+
if (entry.kind !== "skill-dir")
|
|
1492
|
+
assert.fail("Expected skill-dir");
|
|
1493
|
+
assert.equal(entry.skill, "test-skill");
|
|
1494
|
+
assert.equal(entry.agent, "claude-code");
|
|
1495
|
+
assert.equal(entry.source, skillSource);
|
|
1496
|
+
assert.equal(entry.method, "copy");
|
|
1497
|
+
assert.ok(!(await exists(path.join(skillSource, ".inception-totem"))));
|
|
1498
|
+
}
|
|
1499
|
+
finally {
|
|
1500
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1501
|
+
await rm(home, { recursive: true, force: true });
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
it("does not create copies in dry-run mode on Windows", async () => {
|
|
1505
|
+
const sourceDir = await makeTmpDir();
|
|
1506
|
+
const home = await makeTmpDir();
|
|
1507
|
+
try {
|
|
1508
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1509
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1510
|
+
const { succeeded, failed } = await executeDeploy(actions, true, false, home);
|
|
1511
|
+
assert.equal(succeeded, 1);
|
|
1512
|
+
assert.equal(failed.length, 0);
|
|
1513
|
+
const target = actions[0]?.target;
|
|
1514
|
+
assert.ok(!(await exists(target)));
|
|
1515
|
+
}
|
|
1516
|
+
finally {
|
|
1517
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1518
|
+
await rm(home, { recursive: true, force: true });
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
it("overwrites existing copy (with registry entry) on Windows", async () => {
|
|
1522
|
+
const sourceDir = await makeTmpDir();
|
|
1523
|
+
const home = await makeTmpDir();
|
|
1524
|
+
try {
|
|
1525
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1526
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1527
|
+
await executeDeploy(actions, false, false, home);
|
|
1528
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1529
|
+
assert.equal(succeeded, 1);
|
|
1530
|
+
assert.equal(failed.length, 0);
|
|
1531
|
+
}
|
|
1532
|
+
finally {
|
|
1533
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1534
|
+
await rm(home, { recursive: true, force: true });
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
it("reports error for missing source (caught at planning)", async () => {
|
|
1538
|
+
const home = await makeTmpDir();
|
|
1539
|
+
try {
|
|
1540
|
+
await assert.rejects(planDeploy(testManifest, "/nonexistent/source", ["claude-code"], home), (err) => {
|
|
1541
|
+
assert.ok(err instanceof UserError);
|
|
1542
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
1543
|
+
assert.match(err.message, /source not found/);
|
|
1544
|
+
return true;
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
finally {
|
|
1548
|
+
await rm(home, { recursive: true, force: true });
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
it("refuses to overwrite unmanaged target on Windows", async () => {
|
|
1552
|
+
const sourceDir = await makeTmpDir();
|
|
1553
|
+
const home = await makeTmpDir();
|
|
1554
|
+
try {
|
|
1555
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1556
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1557
|
+
const target = actions[0]?.target;
|
|
1558
|
+
await mkdir(target, { recursive: true });
|
|
1559
|
+
await writeFile(path.join(target, "something.txt"), "user content");
|
|
1560
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1561
|
+
assert.equal(succeeded, 0);
|
|
1562
|
+
assert.equal(failed.length, 1);
|
|
1563
|
+
assert.ok(failed[0]?.error.includes("not managed by inception-engine"));
|
|
1564
|
+
assert.ok(await exists(path.join(target, "something.txt")));
|
|
1565
|
+
}
|
|
1566
|
+
finally {
|
|
1567
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1568
|
+
await rm(home, { recursive: true, force: true });
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
it("refuses to overwrite target with mismatched registry entry on Windows", async () => {
|
|
1572
|
+
const sourceDir = await makeTmpDir();
|
|
1573
|
+
const home = await makeTmpDir();
|
|
1574
|
+
try {
|
|
1575
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1576
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1577
|
+
const target = actions[0]?.target;
|
|
1578
|
+
await mkdir(target, { recursive: true });
|
|
1579
|
+
await writeFile(path.join(target, "SKILL.md"), "other content");
|
|
1580
|
+
await registerDeployment(home, target, {
|
|
1581
|
+
kind: "skill-dir",
|
|
1582
|
+
source: "/completely/different/source",
|
|
1583
|
+
skill: "different-skill",
|
|
1584
|
+
agent: "codex",
|
|
1585
|
+
method: "copy",
|
|
1586
|
+
});
|
|
1587
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1588
|
+
assert.equal(succeeded, 0);
|
|
1589
|
+
assert.equal(failed.length, 1);
|
|
1590
|
+
assert.ok(failed[0]?.error.includes("not managed by inception-engine"));
|
|
1591
|
+
assert.ok(await exists(path.join(target, "SKILL.md")));
|
|
1592
|
+
}
|
|
1593
|
+
finally {
|
|
1594
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1595
|
+
await rm(home, { recursive: true, force: true });
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
it("backup is removed after successful redeploy on Windows", async () => {
|
|
1599
|
+
const sourceDir = await makeTmpDir();
|
|
1600
|
+
const home = await makeTmpDir();
|
|
1601
|
+
try {
|
|
1602
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1603
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1604
|
+
const target = actions[0]?.target;
|
|
1605
|
+
const backupPath = `${target}.inception-backup`;
|
|
1606
|
+
await executeDeploy(actions, false, false, home);
|
|
1607
|
+
await executeDeploy(actions, false, false, home);
|
|
1608
|
+
assert.ok(!(await exists(backupPath)), "backup should not exist after successful redeploy");
|
|
1609
|
+
assert.ok(await exists(target), "target should exist after redeploy");
|
|
1610
|
+
}
|
|
1611
|
+
finally {
|
|
1612
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1613
|
+
await rm(home, { recursive: true, force: true });
|
|
1614
|
+
}
|
|
1615
|
+
});
|
|
1616
|
+
});
|
|
1617
|
+
describe("atomic redeploy behavior (Windows)", {
|
|
1618
|
+
skip: process.platform !== "win32",
|
|
1619
|
+
}, () => {
|
|
1620
|
+
it("cleans up stale .inception-backup from a previous failed attempt on Windows", async () => {
|
|
1621
|
+
const sourceDir = await makeTmpDir();
|
|
1622
|
+
const home = await makeTmpDir();
|
|
1623
|
+
try {
|
|
1624
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1625
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1626
|
+
const target = actions[0]?.target;
|
|
1627
|
+
const backupPath = `${target}.inception-backup`;
|
|
1628
|
+
await executeDeploy(actions, false, false, home);
|
|
1629
|
+
await mkdir(backupPath, { recursive: true });
|
|
1630
|
+
await writeFile(path.join(backupPath, "stale.txt"), "leftover");
|
|
1631
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1632
|
+
assert.equal(succeeded, 1);
|
|
1633
|
+
assert.equal(failed.length, 0);
|
|
1634
|
+
assert.ok(!(await exists(backupPath)), "stale backup should be cleaned up");
|
|
1635
|
+
assert.ok(await exists(target), "target should exist after redeploy");
|
|
1636
|
+
}
|
|
1637
|
+
finally {
|
|
1638
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1639
|
+
await rm(home, { recursive: true, force: true });
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
it("no backup is created on first deploy (no prior target) on Windows", async () => {
|
|
1643
|
+
const sourceDir = await makeTmpDir();
|
|
1644
|
+
const home = await makeTmpDir();
|
|
1645
|
+
try {
|
|
1646
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1647
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1648
|
+
const target = actions[0]?.target;
|
|
1649
|
+
const backupPath = `${target}.inception-backup`;
|
|
1650
|
+
const { succeeded } = await executeDeploy(actions, false, false, home);
|
|
1651
|
+
assert.equal(succeeded, 1);
|
|
1652
|
+
assert.ok(await exists(target));
|
|
1653
|
+
assert.ok(!(await exists(backupPath)), "no backup should be created when there was no prior target");
|
|
1654
|
+
}
|
|
1655
|
+
finally {
|
|
1656
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1657
|
+
await rm(home, { recursive: true, force: true });
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1660
|
+
it("restores backup when deploy fails (registry not writable) on Windows", async () => {
|
|
1661
|
+
const sourceDir = await makeTmpDir();
|
|
1662
|
+
const home = await makeTmpDir();
|
|
1663
|
+
try {
|
|
1664
|
+
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1665
|
+
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1666
|
+
const target = actions[0]?.target;
|
|
1667
|
+
const backupPath = `${target}.inception-backup`;
|
|
1668
|
+
const { succeeded: firstSucceeded } = await executeDeploy(actions, false, false, home);
|
|
1669
|
+
assert.equal(firstSucceeded, 1);
|
|
1670
|
+
const registryFile = path.join(home, ".inception-engine", "registry.json");
|
|
1671
|
+
await chmod(registryFile, 0o444); // trigger EPERM on Windows
|
|
1672
|
+
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1673
|
+
assert.equal(succeeded, 0);
|
|
1674
|
+
assert.equal(failed.length, 1);
|
|
1675
|
+
assert.ok(!(await exists(backupPath)), "backup should be gone after rollback");
|
|
1676
|
+
assert.ok(await exists(target), "original target must be restored");
|
|
1677
|
+
assert.ok(await exists(path.join(target, "SKILL.md")), "original content must be present");
|
|
1678
|
+
}
|
|
1679
|
+
finally {
|
|
1680
|
+
try {
|
|
1681
|
+
await chmod(path.join(home, ".inception-engine", "registry.json"), 0o666);
|
|
1682
|
+
}
|
|
1683
|
+
catch {
|
|
1684
|
+
/* best effort */
|
|
1685
|
+
}
|
|
1686
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
1687
|
+
await rm(home, { recursive: true, force: true });
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
});
|
|
1691
|
+
describe("executeDeploy — file-write", () => {
|
|
1692
|
+
it("copies source file to target", async () => {
|
|
1693
|
+
const sourceDir = await makeTmpDir();
|
|
1694
|
+
const home = await makeTmpDir();
|
|
1695
|
+
try {
|
|
1696
|
+
const sourceFile = path.join(sourceDir, "my-file.txt");
|
|
1697
|
+
const targetFile = path.join(home, "target-dir", "my-file.txt");
|
|
1698
|
+
await writeFile(sourceFile, "hello from source");
|
|
1699
|
+
const action = {
|
|
1700
|
+
kind: "file-write",
|
|
1701
|
+
skill: "test-skill",
|
|
1702
|
+
agent: "claude-code",
|
|
1703
|
+
source: sourceFile,
|
|
1704
|
+
target: targetFile,
|
|
1705
|
+
};
|
|
1706
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
1707
|
+
assert.equal(succeeded, 1);
|
|
1708
|
+
assert.equal(failed.length, 0);
|
|
1709
|
+
assert.ok(await exists(targetFile));
|
|
1710
|
+
assert.equal(await readFile(targetFile, "utf-8"), "hello from source");
|
|
1711
|
+
}
|
|
1712
|
+
finally {
|
|
1713
|
+
await rm(sourceDir, { recursive: true });
|
|
1714
|
+
await rm(home, { recursive: true, force: true });
|
|
1715
|
+
}
|
|
1716
|
+
});
|
|
1717
|
+
it("registers file-write entry in registry", async () => {
|
|
1718
|
+
const sourceDir = await makeTmpDir();
|
|
1719
|
+
const home = await makeTmpDir();
|
|
1720
|
+
try {
|
|
1721
|
+
const sourceFile = path.join(sourceDir, "file.txt");
|
|
1722
|
+
const targetFile = path.join(home, "file.txt");
|
|
1723
|
+
await writeFile(sourceFile, "content");
|
|
1724
|
+
const action = {
|
|
1725
|
+
kind: "file-write",
|
|
1726
|
+
skill: "test-skill",
|
|
1727
|
+
agent: "claude-code",
|
|
1728
|
+
source: sourceFile,
|
|
1729
|
+
target: targetFile,
|
|
1730
|
+
};
|
|
1731
|
+
await executeDeploy([action], false, false, home);
|
|
1732
|
+
const entry = await lookupDeployment(home, targetFile);
|
|
1733
|
+
assert.ok(entry);
|
|
1734
|
+
assert.equal(entry.kind, "file-write");
|
|
1735
|
+
assert.equal(entry.skill, "test-skill");
|
|
1736
|
+
assert.equal(entry.agent, "claude-code");
|
|
1737
|
+
if (entry.kind === "file-write") {
|
|
1738
|
+
assert.equal(entry.source, sourceFile);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
finally {
|
|
1742
|
+
await rm(sourceDir, { recursive: true });
|
|
1743
|
+
await rm(home, { recursive: true, force: true });
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
it("overwrites managed target on redeploy (same source path, updated content)", async () => {
|
|
1747
|
+
const sourceDir = await makeTmpDir();
|
|
1748
|
+
const home = await makeTmpDir();
|
|
1749
|
+
try {
|
|
1750
|
+
// The realistic redeploy scenario: same source file, content has changed
|
|
1751
|
+
const sourceFile = path.join(sourceDir, "file.txt");
|
|
1752
|
+
const targetFile = path.join(home, "file.txt");
|
|
1753
|
+
await writeFile(sourceFile, "version 1");
|
|
1754
|
+
const action = {
|
|
1755
|
+
kind: "file-write",
|
|
1756
|
+
skill: "test-skill",
|
|
1757
|
+
agent: "claude-code",
|
|
1758
|
+
source: sourceFile,
|
|
1759
|
+
target: targetFile,
|
|
1760
|
+
};
|
|
1761
|
+
await executeDeploy([action], false, false, home);
|
|
1762
|
+
// Simulate updated source content
|
|
1763
|
+
await writeFile(sourceFile, "version 2");
|
|
1764
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
1765
|
+
assert.equal(succeeded, 1);
|
|
1766
|
+
assert.equal(failed.length, 0);
|
|
1767
|
+
assert.equal(await readFile(targetFile, "utf-8"), "version 2");
|
|
1768
|
+
}
|
|
1769
|
+
finally {
|
|
1770
|
+
await rm(sourceDir, { recursive: true });
|
|
1771
|
+
await rm(home, { recursive: true, force: true });
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
it("refuses to overwrite unmanaged file at target", async () => {
|
|
1775
|
+
const sourceDir = await makeTmpDir();
|
|
1776
|
+
const home = await makeTmpDir();
|
|
1777
|
+
try {
|
|
1778
|
+
const sourceFile = path.join(sourceDir, "file.txt");
|
|
1779
|
+
const targetFile = path.join(home, "existing.txt");
|
|
1780
|
+
await writeFile(sourceFile, "new content");
|
|
1781
|
+
await writeFile(targetFile, "original content");
|
|
1782
|
+
const action = {
|
|
1783
|
+
kind: "file-write",
|
|
1784
|
+
skill: "test-skill",
|
|
1785
|
+
agent: "claude-code",
|
|
1786
|
+
source: sourceFile,
|
|
1787
|
+
target: targetFile,
|
|
1788
|
+
};
|
|
1789
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
1790
|
+
assert.equal(succeeded, 0);
|
|
1791
|
+
assert.equal(failed.length, 1);
|
|
1792
|
+
assert.ok(failed[0]?.error.includes("not managed by inception-engine"));
|
|
1793
|
+
assert.equal(await readFile(targetFile, "utf-8"), "original content");
|
|
1794
|
+
}
|
|
1795
|
+
finally {
|
|
1796
|
+
await rm(sourceDir, { recursive: true });
|
|
1797
|
+
await rm(home, { recursive: true, force: true });
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1800
|
+
it("dry-run does not write file and returns planned change", async () => {
|
|
1801
|
+
const sourceDir = await makeTmpDir();
|
|
1802
|
+
const home = await makeTmpDir();
|
|
1803
|
+
try {
|
|
1804
|
+
const sourceFile = path.join(sourceDir, "file.txt");
|
|
1805
|
+
const targetFile = path.join(home, "subdir", "file.txt");
|
|
1806
|
+
await writeFile(sourceFile, "content");
|
|
1807
|
+
const action = {
|
|
1808
|
+
kind: "file-write",
|
|
1809
|
+
skill: "test-skill",
|
|
1810
|
+
agent: "claude-code",
|
|
1811
|
+
source: sourceFile,
|
|
1812
|
+
target: targetFile,
|
|
1813
|
+
};
|
|
1814
|
+
const { succeeded, failed, planned } = await executeDeploy([action], true, false, home);
|
|
1815
|
+
assert.equal(succeeded, 1);
|
|
1816
|
+
assert.equal(failed.length, 0);
|
|
1817
|
+
assert.ok(!(await exists(targetFile)), "file should not be written in dry-run");
|
|
1818
|
+
assert.equal(planned.length, 1);
|
|
1819
|
+
assert.equal(planned[0]?.verb, "write-file");
|
|
1820
|
+
assert.equal(planned[0]?.skill, "test-skill");
|
|
1821
|
+
assert.equal(planned[0]?.agent, "claude-code");
|
|
1822
|
+
}
|
|
1823
|
+
finally {
|
|
1824
|
+
await rm(sourceDir, { recursive: true });
|
|
1825
|
+
await rm(home, { recursive: true, force: true });
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
it("fails gracefully when source file does not exist", async () => {
|
|
1829
|
+
const home = await makeTmpDir();
|
|
1830
|
+
try {
|
|
1831
|
+
const action = {
|
|
1832
|
+
kind: "file-write",
|
|
1833
|
+
skill: "test-skill",
|
|
1834
|
+
agent: "claude-code",
|
|
1835
|
+
source: "/nonexistent/file.txt",
|
|
1836
|
+
target: path.join(home, "out.txt"),
|
|
1837
|
+
};
|
|
1838
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
1839
|
+
assert.equal(succeeded, 0);
|
|
1840
|
+
assert.equal(failed.length, 1);
|
|
1841
|
+
assert.match(failed[0]?.error ?? "", /Source not found/);
|
|
1842
|
+
}
|
|
1843
|
+
finally {
|
|
1844
|
+
await rm(home, { recursive: true, force: true });
|
|
1845
|
+
}
|
|
1846
|
+
});
|
|
1847
|
+
it("rolls back a newly written file when registry persistence fails", async () => {
|
|
1848
|
+
const sourceDir = await makeTmpDir();
|
|
1849
|
+
const home = await makeTmpDir();
|
|
1850
|
+
try {
|
|
1851
|
+
const sourceFile = path.join(sourceDir, "file.txt");
|
|
1852
|
+
const targetFile = path.join(home, "out.txt");
|
|
1853
|
+
await writeFile(sourceFile, "content");
|
|
1854
|
+
const action = {
|
|
1855
|
+
kind: "file-write",
|
|
1856
|
+
skill: "test-skill",
|
|
1857
|
+
agent: "claude-code",
|
|
1858
|
+
source: sourceFile,
|
|
1859
|
+
target: targetFile,
|
|
1860
|
+
};
|
|
1861
|
+
const failingRegistry = {
|
|
1862
|
+
async load() {
|
|
1863
|
+
return { version: 1, deployments: {} };
|
|
1864
|
+
},
|
|
1865
|
+
async save() {
|
|
1866
|
+
throw new Error("registry unavailable");
|
|
1867
|
+
},
|
|
1868
|
+
};
|
|
1869
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home, { registry: failingRegistry });
|
|
1870
|
+
assert.equal(succeeded, 0);
|
|
1871
|
+
assert.equal(failed.length, 1);
|
|
1872
|
+
assert.ok(!(await exists(targetFile)), "target file should be rolled back");
|
|
1873
|
+
assert.ok(!(await exists(`${targetFile}.inception-backup`)));
|
|
1874
|
+
}
|
|
1875
|
+
finally {
|
|
1876
|
+
await rm(sourceDir, { recursive: true });
|
|
1877
|
+
await rm(home, { recursive: true, force: true });
|
|
1878
|
+
}
|
|
1879
|
+
});
|
|
1880
|
+
it("restores the previous managed file when registry persistence fails during overwrite", async () => {
|
|
1881
|
+
const sourceDir = await makeTmpDir();
|
|
1882
|
+
const home = await makeTmpDir();
|
|
1883
|
+
try {
|
|
1884
|
+
const sourceFile = path.join(sourceDir, "file.txt");
|
|
1885
|
+
const targetFile = path.join(home, "managed.txt");
|
|
1886
|
+
await writeFile(sourceFile, "new content");
|
|
1887
|
+
await writeFile(targetFile, "old content");
|
|
1888
|
+
let loadCount = 0;
|
|
1889
|
+
const failingRegistry = {
|
|
1890
|
+
async load() {
|
|
1891
|
+
loadCount += 1;
|
|
1892
|
+
return {
|
|
1893
|
+
version: 1,
|
|
1894
|
+
deployments: loadCount === 1
|
|
1895
|
+
? {
|
|
1896
|
+
[targetFile]: {
|
|
1897
|
+
kind: "file-write",
|
|
1898
|
+
source: sourceFile,
|
|
1899
|
+
skill: "test-skill",
|
|
1900
|
+
agent: "claude-code",
|
|
1901
|
+
deployed: new Date().toISOString(),
|
|
1902
|
+
},
|
|
1903
|
+
}
|
|
1904
|
+
: {},
|
|
1905
|
+
};
|
|
1906
|
+
},
|
|
1907
|
+
async save() {
|
|
1908
|
+
throw new Error("registry unavailable");
|
|
1909
|
+
},
|
|
1910
|
+
};
|
|
1911
|
+
const action = {
|
|
1912
|
+
kind: "file-write",
|
|
1913
|
+
skill: "test-skill",
|
|
1914
|
+
agent: "claude-code",
|
|
1915
|
+
source: sourceFile,
|
|
1916
|
+
target: targetFile,
|
|
1917
|
+
};
|
|
1918
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home, { registry: failingRegistry });
|
|
1919
|
+
assert.equal(succeeded, 0);
|
|
1920
|
+
assert.equal(failed.length, 1);
|
|
1921
|
+
assert.equal(await readFile(targetFile, "utf-8"), "old content");
|
|
1922
|
+
assert.ok(!(await exists(`${targetFile}.inception-backup`)));
|
|
1923
|
+
}
|
|
1924
|
+
finally {
|
|
1925
|
+
await rm(sourceDir, { recursive: true });
|
|
1926
|
+
await rm(home, { recursive: true, force: true });
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
it("leaves the original managed file untouched when the final swap fails", async () => {
|
|
1930
|
+
const sourceDir = await makeTmpDir();
|
|
1931
|
+
const home = await makeTmpDir();
|
|
1932
|
+
try {
|
|
1933
|
+
const sourceFile = path.join(sourceDir, "file.txt");
|
|
1934
|
+
const targetFile = path.join(home, "managed.txt");
|
|
1935
|
+
await writeFile(sourceFile, "new content");
|
|
1936
|
+
await writeFile(targetFile, "old content");
|
|
1937
|
+
await registerDeployment(home, targetFile, {
|
|
1938
|
+
kind: "file-write",
|
|
1939
|
+
source: sourceFile,
|
|
1940
|
+
skill: "test-skill",
|
|
1941
|
+
agent: "claude-code",
|
|
1942
|
+
});
|
|
1943
|
+
const action = {
|
|
1944
|
+
kind: "file-write",
|
|
1945
|
+
skill: "test-skill",
|
|
1946
|
+
agent: "claude-code",
|
|
1947
|
+
source: sourceFile,
|
|
1948
|
+
target: targetFile,
|
|
1949
|
+
};
|
|
1950
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home, {
|
|
1951
|
+
fileOps: {
|
|
1952
|
+
copyFile,
|
|
1953
|
+
writeFile,
|
|
1954
|
+
rm,
|
|
1955
|
+
async rename(source, target) {
|
|
1956
|
+
if (source.includes(".inception-tmp-") && target === targetFile) {
|
|
1957
|
+
throw new Error("swap failed");
|
|
1958
|
+
}
|
|
1959
|
+
await rename(source, target);
|
|
1960
|
+
},
|
|
1961
|
+
},
|
|
1962
|
+
});
|
|
1963
|
+
assert.equal(succeeded, 0);
|
|
1964
|
+
assert.equal(failed.length, 1);
|
|
1965
|
+
assert.equal(await readFile(targetFile, "utf-8"), "old content");
|
|
1966
|
+
assert.ok(!(await exists(`${targetFile}.inception-backup`)));
|
|
1967
|
+
assert.deepEqual((await readdir(path.dirname(targetFile))).filter((entry) => entry.includes(".inception-tmp-")), []);
|
|
1968
|
+
}
|
|
1969
|
+
finally {
|
|
1970
|
+
await rm(sourceDir, { recursive: true });
|
|
1971
|
+
await rm(home, { recursive: true, force: true });
|
|
1972
|
+
}
|
|
1973
|
+
});
|
|
1974
|
+
it("cleans up a stale file-write backup before replacing a managed file", async () => {
|
|
1975
|
+
const sourceDir = await makeTmpDir();
|
|
1976
|
+
const home = await makeTmpDir();
|
|
1977
|
+
try {
|
|
1978
|
+
const sourceFile = path.join(sourceDir, "file.txt");
|
|
1979
|
+
const targetFile = path.join(home, "managed.txt");
|
|
1980
|
+
const backupPath = `${targetFile}.inception-backup`;
|
|
1981
|
+
await writeFile(sourceFile, "new content");
|
|
1982
|
+
await writeFile(targetFile, "old content");
|
|
1983
|
+
await writeFile(backupPath, "stale backup");
|
|
1984
|
+
await registerDeployment(home, targetFile, {
|
|
1985
|
+
kind: "file-write",
|
|
1986
|
+
source: sourceFile,
|
|
1987
|
+
skill: "test-skill",
|
|
1988
|
+
agent: "claude-code",
|
|
1989
|
+
});
|
|
1990
|
+
const action = {
|
|
1991
|
+
kind: "file-write",
|
|
1992
|
+
skill: "test-skill",
|
|
1993
|
+
agent: "claude-code",
|
|
1994
|
+
source: sourceFile,
|
|
1995
|
+
target: targetFile,
|
|
1996
|
+
};
|
|
1997
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
1998
|
+
assert.equal(succeeded, 1);
|
|
1999
|
+
assert.equal(failed.length, 0);
|
|
2000
|
+
assert.equal(await readFile(targetFile, "utf-8"), "new content");
|
|
2001
|
+
assert.ok(!(await exists(backupPath)));
|
|
2002
|
+
}
|
|
2003
|
+
finally {
|
|
2004
|
+
await rm(sourceDir, { recursive: true });
|
|
2005
|
+
await rm(home, { recursive: true, force: true });
|
|
2006
|
+
}
|
|
2007
|
+
});
|
|
2008
|
+
});
|
|
2009
|
+
describe("executeDeploy — config-patch", () => {
|
|
2010
|
+
it("applies a JSON merge patch to an existing config file", async () => {
|
|
2011
|
+
const home = await makeTmpDir();
|
|
2012
|
+
try {
|
|
2013
|
+
const configFile = path.join(home, "config.json");
|
|
2014
|
+
await writeFile(configFile, JSON.stringify({ a: 1, b: 2 }));
|
|
2015
|
+
const action = {
|
|
2016
|
+
kind: "config-patch",
|
|
2017
|
+
skill: "test-skill",
|
|
2018
|
+
agent: "claude-code",
|
|
2019
|
+
target: configFile,
|
|
2020
|
+
patch: { b: 99, c: 3 },
|
|
2021
|
+
};
|
|
2022
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
2023
|
+
assert.equal(succeeded, 1);
|
|
2024
|
+
assert.equal(failed.length, 0);
|
|
2025
|
+
const result = JSON.parse(await readFile(configFile, "utf-8"));
|
|
2026
|
+
assert.equal(result.a, 1);
|
|
2027
|
+
assert.equal(result.b, 99);
|
|
2028
|
+
assert.equal(result.c, 3);
|
|
2029
|
+
}
|
|
2030
|
+
finally {
|
|
2031
|
+
await rm(home, { recursive: true, force: true });
|
|
2032
|
+
}
|
|
2033
|
+
});
|
|
2034
|
+
it("stores correct undoPatch in registry", async () => {
|
|
2035
|
+
const home = await makeTmpDir();
|
|
2036
|
+
try {
|
|
2037
|
+
const configFile = path.join(home, "config.json");
|
|
2038
|
+
await writeFile(configFile, JSON.stringify({ a: 1, b: 2 }));
|
|
2039
|
+
const action = {
|
|
2040
|
+
kind: "config-patch",
|
|
2041
|
+
skill: "test-skill",
|
|
2042
|
+
agent: "claude-code",
|
|
2043
|
+
target: configFile,
|
|
2044
|
+
patch: { b: 99, c: 3 },
|
|
2045
|
+
};
|
|
2046
|
+
await executeDeploy([action], false, false, home);
|
|
2047
|
+
const entry = await lookupDeployment(home, configFile);
|
|
2048
|
+
assert.ok(entry);
|
|
2049
|
+
assert.equal(entry.kind, "config-patch");
|
|
2050
|
+
if (entry.kind === "config-patch") {
|
|
2051
|
+
assert.equal(entry.undoPatch.b, 2); // original value
|
|
2052
|
+
assert.equal(entry.undoPatch.c, null); // key was absent
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
finally {
|
|
2056
|
+
await rm(home, { recursive: true, force: true });
|
|
2057
|
+
}
|
|
2058
|
+
});
|
|
2059
|
+
it("null patch values remove keys per RFC 7396", async () => {
|
|
2060
|
+
const home = await makeTmpDir();
|
|
2061
|
+
try {
|
|
2062
|
+
const configFile = path.join(home, "config.json");
|
|
2063
|
+
await writeFile(configFile, JSON.stringify({ a: 1, b: 2 }));
|
|
2064
|
+
const action = {
|
|
2065
|
+
kind: "config-patch",
|
|
2066
|
+
skill: "test-skill",
|
|
2067
|
+
agent: "claude-code",
|
|
2068
|
+
target: configFile,
|
|
2069
|
+
patch: { b: null },
|
|
2070
|
+
};
|
|
2071
|
+
await executeDeploy([action], false, false, home);
|
|
2072
|
+
const result = JSON.parse(await readFile(configFile, "utf-8"));
|
|
2073
|
+
assert.equal(result.a, 1);
|
|
2074
|
+
assert.equal("b" in result, false);
|
|
2075
|
+
}
|
|
2076
|
+
finally {
|
|
2077
|
+
await rm(home, { recursive: true, force: true });
|
|
2078
|
+
}
|
|
2079
|
+
});
|
|
2080
|
+
it("fails gracefully when target config does not exist", async () => {
|
|
2081
|
+
const home = await makeTmpDir();
|
|
2082
|
+
try {
|
|
2083
|
+
const action = {
|
|
2084
|
+
kind: "config-patch",
|
|
2085
|
+
skill: "test-skill",
|
|
2086
|
+
agent: "claude-code",
|
|
2087
|
+
target: path.join(home, "nonexistent.json"),
|
|
2088
|
+
patch: { key: "value" },
|
|
2089
|
+
};
|
|
2090
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
2091
|
+
assert.equal(succeeded, 0);
|
|
2092
|
+
assert.equal(failed.length, 1);
|
|
2093
|
+
assert.match(failed[0]?.error ?? "", /Config file not found/);
|
|
2094
|
+
}
|
|
2095
|
+
finally {
|
|
2096
|
+
await rm(home, { recursive: true, force: true });
|
|
2097
|
+
}
|
|
2098
|
+
});
|
|
2099
|
+
it("fails gracefully when target is not valid JSON", async () => {
|
|
2100
|
+
const home = await makeTmpDir();
|
|
2101
|
+
try {
|
|
2102
|
+
const configFile = path.join(home, "config.json");
|
|
2103
|
+
await writeFile(configFile, "not json at all");
|
|
2104
|
+
const action = {
|
|
2105
|
+
kind: "config-patch",
|
|
2106
|
+
skill: "test-skill",
|
|
2107
|
+
agent: "claude-code",
|
|
2108
|
+
target: configFile,
|
|
2109
|
+
patch: { key: "value" },
|
|
2110
|
+
};
|
|
2111
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
2112
|
+
assert.equal(succeeded, 0);
|
|
2113
|
+
assert.equal(failed.length, 1);
|
|
2114
|
+
assert.match(failed[0]?.error ?? "", /not valid JSON/);
|
|
2115
|
+
}
|
|
2116
|
+
finally {
|
|
2117
|
+
await rm(home, { recursive: true, force: true });
|
|
2118
|
+
}
|
|
2119
|
+
});
|
|
2120
|
+
it("dry-run does not modify config and returns planned change", async () => {
|
|
2121
|
+
const home = await makeTmpDir();
|
|
2122
|
+
try {
|
|
2123
|
+
const configFile = path.join(home, "config.json");
|
|
2124
|
+
await writeFile(configFile, JSON.stringify({ a: 1, b: 2 }));
|
|
2125
|
+
const action = {
|
|
2126
|
+
kind: "config-patch",
|
|
2127
|
+
skill: "test-skill",
|
|
2128
|
+
agent: "claude-code",
|
|
2129
|
+
target: configFile,
|
|
2130
|
+
patch: { b: 99 },
|
|
2131
|
+
};
|
|
2132
|
+
const { succeeded, failed, planned } = await executeDeploy([action], true, false, home);
|
|
2133
|
+
assert.equal(succeeded, 1);
|
|
2134
|
+
assert.equal(failed.length, 0);
|
|
2135
|
+
const after = JSON.parse(await readFile(configFile, "utf-8"));
|
|
2136
|
+
assert.equal(after.b, 2, "config should be unchanged after dry-run");
|
|
2137
|
+
assert.equal(planned.length, 1);
|
|
2138
|
+
assert.equal(planned[0]?.verb, "patch-config");
|
|
2139
|
+
assert.equal(planned[0]?.skill, "test-skill");
|
|
2140
|
+
}
|
|
2141
|
+
finally {
|
|
2142
|
+
await rm(home, { recursive: true, force: true });
|
|
2143
|
+
}
|
|
2144
|
+
});
|
|
2145
|
+
it("refuses to double-patch config already patched by different skill/agent", async () => {
|
|
2146
|
+
const home = await makeTmpDir();
|
|
2147
|
+
try {
|
|
2148
|
+
const configFile = path.join(home, "config.json");
|
|
2149
|
+
await writeFile(configFile, JSON.stringify({ x: 1 }));
|
|
2150
|
+
await registerDeployment(home, configFile, {
|
|
2151
|
+
kind: "config-patch",
|
|
2152
|
+
patch: { x: 99 },
|
|
2153
|
+
undoPatch: { x: 1 },
|
|
2154
|
+
skill: "other-skill",
|
|
2155
|
+
agent: "codex",
|
|
2156
|
+
});
|
|
2157
|
+
const action = {
|
|
2158
|
+
kind: "config-patch",
|
|
2159
|
+
skill: "test-skill",
|
|
2160
|
+
agent: "claude-code",
|
|
2161
|
+
target: configFile,
|
|
2162
|
+
patch: { y: 2 },
|
|
2163
|
+
};
|
|
2164
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
2165
|
+
assert.equal(succeeded, 0);
|
|
2166
|
+
assert.equal(failed.length, 1);
|
|
2167
|
+
assert.match(failed[0]?.error ?? "", /already patched/);
|
|
2168
|
+
}
|
|
2169
|
+
finally {
|
|
2170
|
+
await rm(home, { recursive: true, force: true });
|
|
2171
|
+
}
|
|
2172
|
+
});
|
|
2173
|
+
it("fails gracefully when patch is not a plain object", async () => {
|
|
2174
|
+
const home = await makeTmpDir();
|
|
2175
|
+
try {
|
|
2176
|
+
const configFile = path.join(home, "config.json");
|
|
2177
|
+
await writeFile(configFile, JSON.stringify({ a: 1 }));
|
|
2178
|
+
const action = {
|
|
2179
|
+
kind: "config-patch",
|
|
2180
|
+
skill: "test-skill",
|
|
2181
|
+
agent: "claude-code",
|
|
2182
|
+
target: configFile,
|
|
2183
|
+
patch: ["not", "an", "object"],
|
|
2184
|
+
};
|
|
2185
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
2186
|
+
assert.equal(succeeded, 0);
|
|
2187
|
+
assert.equal(failed.length, 1);
|
|
2188
|
+
assert.match(failed[0]?.error ?? "", /plain object/);
|
|
2189
|
+
}
|
|
2190
|
+
finally {
|
|
2191
|
+
await rm(home, { recursive: true, force: true });
|
|
2192
|
+
}
|
|
2193
|
+
});
|
|
2194
|
+
it("applies nested JSON merge patch, preserving sibling keys", async () => {
|
|
2195
|
+
const home = await makeTmpDir();
|
|
2196
|
+
try {
|
|
2197
|
+
const configFile = path.join(home, "config.json");
|
|
2198
|
+
await writeFile(configFile, JSON.stringify({
|
|
2199
|
+
mcpServers: { "existing-server": { command: "old" } },
|
|
2200
|
+
}));
|
|
2201
|
+
const action = {
|
|
2202
|
+
kind: "config-patch",
|
|
2203
|
+
skill: "new-server",
|
|
2204
|
+
agent: "claude-code",
|
|
2205
|
+
target: configFile,
|
|
2206
|
+
patch: { mcpServers: { "new-server": { command: "new" } } },
|
|
2207
|
+
};
|
|
2208
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
2209
|
+
assert.equal(succeeded, 1);
|
|
2210
|
+
assert.equal(failed.length, 0);
|
|
2211
|
+
const result = JSON.parse(await readFile(configFile, "utf-8"));
|
|
2212
|
+
assert.deepEqual(result.mcpServers["existing-server"], {
|
|
2213
|
+
command: "old",
|
|
2214
|
+
});
|
|
2215
|
+
assert.deepEqual(result.mcpServers["new-server"], { command: "new" });
|
|
2216
|
+
}
|
|
2217
|
+
finally {
|
|
2218
|
+
await rm(home, { recursive: true, force: true });
|
|
2219
|
+
}
|
|
2220
|
+
});
|
|
2221
|
+
it("stores correct deep undoPatch for nested patch", async () => {
|
|
2222
|
+
const home = await makeTmpDir();
|
|
2223
|
+
try {
|
|
2224
|
+
const configFile = path.join(home, "config.json");
|
|
2225
|
+
await writeFile(configFile, JSON.stringify({
|
|
2226
|
+
mcpServers: { "existing-server": { command: "old" } },
|
|
2227
|
+
}));
|
|
2228
|
+
const action = {
|
|
2229
|
+
kind: "config-patch",
|
|
2230
|
+
skill: "new-server",
|
|
2231
|
+
agent: "claude-code",
|
|
2232
|
+
target: configFile,
|
|
2233
|
+
patch: { mcpServers: { "new-server": { command: "new" } } },
|
|
2234
|
+
};
|
|
2235
|
+
await executeDeploy([action], false, false, home);
|
|
2236
|
+
const entry = await lookupDeployment(home, configFile);
|
|
2237
|
+
assert.ok(entry);
|
|
2238
|
+
assert.equal(entry.kind, "config-patch");
|
|
2239
|
+
if (entry.kind === "config-patch") {
|
|
2240
|
+
// The undo patch should be nested, not shallow
|
|
2241
|
+
const mcpUndo = entry.undoPatch.mcpServers;
|
|
2242
|
+
assert.ok(typeof mcpUndo === "object" && mcpUndo !== null, "undoPatch.mcpServers should be a nested object");
|
|
2243
|
+
// Only the new server key should be in the undo patch
|
|
2244
|
+
assert.equal(mcpUndo["new-server"], null); // was absent, so null = delete on undo
|
|
2245
|
+
assert.equal("existing-server" in mcpUndo, false); // sibling not touched
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
finally {
|
|
2249
|
+
await rm(home, { recursive: true, force: true });
|
|
2250
|
+
}
|
|
2251
|
+
});
|
|
2252
|
+
it("null patch value inside nested object deletes only that leaf key", async () => {
|
|
2253
|
+
const home = await makeTmpDir();
|
|
2254
|
+
try {
|
|
2255
|
+
const configFile = path.join(home, "config.json");
|
|
2256
|
+
await writeFile(configFile, JSON.stringify({
|
|
2257
|
+
mcpServers: {
|
|
2258
|
+
"server-a": { command: "a" },
|
|
2259
|
+
"server-b": { command: "b" },
|
|
2260
|
+
},
|
|
2261
|
+
}));
|
|
2262
|
+
const action = {
|
|
2263
|
+
kind: "config-patch",
|
|
2264
|
+
skill: "remove-server-a",
|
|
2265
|
+
agent: "claude-code",
|
|
2266
|
+
target: configFile,
|
|
2267
|
+
patch: { mcpServers: { "server-a": null } },
|
|
2268
|
+
};
|
|
2269
|
+
await executeDeploy([action], false, false, home);
|
|
2270
|
+
const result = JSON.parse(await readFile(configFile, "utf-8"));
|
|
2271
|
+
assert.equal("server-a" in result.mcpServers, false);
|
|
2272
|
+
assert.deepEqual(result.mcpServers["server-b"], { command: "b" });
|
|
2273
|
+
}
|
|
2274
|
+
finally {
|
|
2275
|
+
await rm(home, { recursive: true, force: true });
|
|
2276
|
+
}
|
|
2277
|
+
});
|
|
2278
|
+
it("restores the original config when registry persistence fails after patching", async () => {
|
|
2279
|
+
const home = await makeTmpDir();
|
|
2280
|
+
try {
|
|
2281
|
+
const configFile = path.join(home, "config.json");
|
|
2282
|
+
await writeFile(configFile, JSON.stringify({ a: 1, b: 2 }));
|
|
2283
|
+
const action = {
|
|
2284
|
+
kind: "config-patch",
|
|
2285
|
+
skill: "test-skill",
|
|
2286
|
+
agent: "claude-code",
|
|
2287
|
+
target: configFile,
|
|
2288
|
+
patch: { b: 99 },
|
|
2289
|
+
};
|
|
2290
|
+
const failingRegistry = {
|
|
2291
|
+
async load() {
|
|
2292
|
+
return { version: 1, deployments: {} };
|
|
2293
|
+
},
|
|
2294
|
+
async save() {
|
|
2295
|
+
throw new Error("registry unavailable");
|
|
2296
|
+
},
|
|
2297
|
+
};
|
|
2298
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home, { registry: failingRegistry });
|
|
2299
|
+
assert.equal(succeeded, 0);
|
|
2300
|
+
assert.equal(failed.length, 1);
|
|
2301
|
+
assert.deepEqual(JSON.parse(await readFile(configFile, "utf-8")), {
|
|
2302
|
+
a: 1,
|
|
2303
|
+
b: 2,
|
|
2304
|
+
});
|
|
2305
|
+
assert.ok(!(await exists(`${configFile}.inception-backup`)));
|
|
2306
|
+
}
|
|
2307
|
+
finally {
|
|
2308
|
+
await rm(home, { recursive: true, force: true });
|
|
2309
|
+
}
|
|
2310
|
+
});
|
|
2311
|
+
it("leaves the original config untouched when the final swap fails", async () => {
|
|
2312
|
+
const home = await makeTmpDir();
|
|
2313
|
+
try {
|
|
2314
|
+
const configFile = path.join(home, "config.json");
|
|
2315
|
+
await writeFile(configFile, JSON.stringify({ a: 1, b: 2 }));
|
|
2316
|
+
const action = {
|
|
2317
|
+
kind: "config-patch",
|
|
2318
|
+
skill: "test-skill",
|
|
2319
|
+
agent: "claude-code",
|
|
2320
|
+
target: configFile,
|
|
2321
|
+
patch: { b: 99 },
|
|
2322
|
+
};
|
|
2323
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home, {
|
|
2324
|
+
fileOps: {
|
|
2325
|
+
copyFile,
|
|
2326
|
+
writeFile,
|
|
2327
|
+
rm,
|
|
2328
|
+
async rename(source, target) {
|
|
2329
|
+
if (source.includes(".inception-tmp-") && target === configFile) {
|
|
2330
|
+
throw new Error("swap failed");
|
|
2331
|
+
}
|
|
2332
|
+
await rename(source, target);
|
|
2333
|
+
},
|
|
2334
|
+
},
|
|
2335
|
+
});
|
|
2336
|
+
assert.equal(succeeded, 0);
|
|
2337
|
+
assert.equal(failed.length, 1);
|
|
2338
|
+
assert.deepEqual(JSON.parse(await readFile(configFile, "utf-8")), {
|
|
2339
|
+
a: 1,
|
|
2340
|
+
b: 2,
|
|
2341
|
+
});
|
|
2342
|
+
assert.ok(!(await exists(`${configFile}.inception-backup`)));
|
|
2343
|
+
assert.deepEqual((await readdir(path.dirname(configFile))).filter((entry) => entry.includes(".inception-tmp-")), []);
|
|
2344
|
+
}
|
|
2345
|
+
finally {
|
|
2346
|
+
await rm(home, { recursive: true, force: true });
|
|
2347
|
+
}
|
|
2348
|
+
});
|
|
2349
|
+
it("cleans up a stale config-patch backup before replacing the config", async () => {
|
|
2350
|
+
const home = await makeTmpDir();
|
|
2351
|
+
try {
|
|
2352
|
+
const configFile = path.join(home, "config.json");
|
|
2353
|
+
const backupPath = `${configFile}.inception-backup`;
|
|
2354
|
+
await writeFile(configFile, JSON.stringify({ a: 1, b: 2 }));
|
|
2355
|
+
await writeFile(backupPath, "stale backup");
|
|
2356
|
+
const action = {
|
|
2357
|
+
kind: "config-patch",
|
|
2358
|
+
skill: "test-skill",
|
|
2359
|
+
agent: "claude-code",
|
|
2360
|
+
target: configFile,
|
|
2361
|
+
patch: { b: 99 },
|
|
2362
|
+
};
|
|
2363
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
2364
|
+
assert.equal(succeeded, 1);
|
|
2365
|
+
assert.equal(failed.length, 0);
|
|
2366
|
+
assert.deepEqual(JSON.parse(await readFile(configFile, "utf-8")), {
|
|
2367
|
+
a: 1,
|
|
2368
|
+
b: 99,
|
|
2369
|
+
});
|
|
2370
|
+
assert.ok(!(await exists(backupPath)));
|
|
2371
|
+
}
|
|
2372
|
+
finally {
|
|
2373
|
+
await rm(home, { recursive: true, force: true });
|
|
2374
|
+
}
|
|
2375
|
+
});
|
|
2376
|
+
});
|
|
2377
|
+
describe("executeDeploy — frontmatter-emit", () => {
|
|
2378
|
+
it("merges engine-owned frontmatter while preserving unrelated keys and body", async () => {
|
|
2379
|
+
const home = await makeTmpDir();
|
|
2380
|
+
try {
|
|
2381
|
+
const targetFile = path.join(home, ".agents", "rules", "my-mcp.md");
|
|
2382
|
+
await mkdir(path.dirname(targetFile), { recursive: true });
|
|
2383
|
+
await writeFile(targetFile, "---\nname: Existing Agent\ndescription: Keep me\n---\n\n# Body\n");
|
|
2384
|
+
const action = {
|
|
2385
|
+
kind: "frontmatter-emit",
|
|
2386
|
+
skill: "my-mcp",
|
|
2387
|
+
agent: "antigravity",
|
|
2388
|
+
target: targetFile,
|
|
2389
|
+
frontmatter: {
|
|
2390
|
+
"mcp-servers": {
|
|
2391
|
+
"my-mcp": { command: "npx", args: ["-y", "serve"] },
|
|
2392
|
+
},
|
|
2393
|
+
},
|
|
2394
|
+
};
|
|
2395
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
2396
|
+
assert.equal(succeeded, 1);
|
|
2397
|
+
assert.equal(failed.length, 0);
|
|
2398
|
+
const content = await readFile(targetFile, "utf-8");
|
|
2399
|
+
assert.match(content, /name: Existing Agent/);
|
|
2400
|
+
assert.match(content, /description: Keep me/);
|
|
2401
|
+
assert.match(content, /mcp-servers:/);
|
|
2402
|
+
assert.match(content, /# Body/);
|
|
2403
|
+
const entry = await lookupDeployment(home, targetFile);
|
|
2404
|
+
assert.ok(entry);
|
|
2405
|
+
assert.equal(entry?.kind, "frontmatter-emit");
|
|
2406
|
+
if (entry?.kind === "frontmatter-emit") {
|
|
2407
|
+
assert.deepEqual(entry.patch, action.frontmatter);
|
|
2408
|
+
assert.deepEqual(entry.undoPatch, { "mcp-servers": null });
|
|
2409
|
+
assert.equal(entry.created, false);
|
|
2410
|
+
assert.equal(entry.hadFrontmatter, true);
|
|
2411
|
+
assert.equal(entry.surfaceId, "frontmatter-emit:antigravity:my-mcp");
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
finally {
|
|
2415
|
+
await rm(home, { recursive: true, force: true });
|
|
2416
|
+
}
|
|
2417
|
+
});
|
|
2418
|
+
it("refuses to double-patch frontmatter already owned by another skill", async () => {
|
|
2419
|
+
const home = await makeTmpDir();
|
|
2420
|
+
try {
|
|
2421
|
+
const targetFile = path.join(home, ".agents", "rules", "shared.md");
|
|
2422
|
+
await mkdir(path.dirname(targetFile), { recursive: true });
|
|
2423
|
+
await writeFile(targetFile, "---\nname: Shared\n---\n");
|
|
2424
|
+
await registerDeployment(home, targetFile, {
|
|
2425
|
+
kind: "frontmatter-emit",
|
|
2426
|
+
patch: { "mcp-servers": { other: { command: "other" } } },
|
|
2427
|
+
undoPatch: { "mcp-servers": null },
|
|
2428
|
+
created: false,
|
|
2429
|
+
hadFrontmatter: true,
|
|
2430
|
+
skill: "other-skill",
|
|
2431
|
+
agent: "antigravity",
|
|
2432
|
+
});
|
|
2433
|
+
const action = {
|
|
2434
|
+
kind: "frontmatter-emit",
|
|
2435
|
+
skill: "my-mcp",
|
|
2436
|
+
agent: "antigravity",
|
|
2437
|
+
target: targetFile,
|
|
2438
|
+
frontmatter: { "mcp-servers": { "my-mcp": { command: "npx" } } },
|
|
2439
|
+
};
|
|
2440
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home);
|
|
2441
|
+
assert.equal(succeeded, 0);
|
|
2442
|
+
assert.equal(failed.length, 1);
|
|
2443
|
+
assert.match(failed[0]?.error ?? "", /already patched/);
|
|
2444
|
+
}
|
|
2445
|
+
finally {
|
|
2446
|
+
await rm(home, { recursive: true, force: true });
|
|
2447
|
+
}
|
|
2448
|
+
});
|
|
2449
|
+
it("restores the original markdown file when registry persistence fails", async () => {
|
|
2450
|
+
const home = await makeTmpDir();
|
|
2451
|
+
try {
|
|
2452
|
+
const targetFile = path.join(home, ".agents", "rules", "my-mcp.md");
|
|
2453
|
+
await mkdir(path.dirname(targetFile), { recursive: true });
|
|
2454
|
+
const original = "---\nname: Existing Agent\n---\n\n# Body\n";
|
|
2455
|
+
await writeFile(targetFile, original);
|
|
2456
|
+
const action = {
|
|
2457
|
+
kind: "frontmatter-emit",
|
|
2458
|
+
skill: "my-mcp",
|
|
2459
|
+
agent: "antigravity",
|
|
2460
|
+
target: targetFile,
|
|
2461
|
+
frontmatter: { "mcp-servers": { "my-mcp": { command: "npx" } } },
|
|
2462
|
+
};
|
|
2463
|
+
const failingRegistry = {
|
|
2464
|
+
async load() {
|
|
2465
|
+
return { version: 1, deployments: {} };
|
|
2466
|
+
},
|
|
2467
|
+
async save() {
|
|
2468
|
+
throw new Error("registry unavailable");
|
|
2469
|
+
},
|
|
2470
|
+
};
|
|
2471
|
+
const { succeeded, failed } = await executeDeploy([action], false, false, home, { registry: failingRegistry });
|
|
2472
|
+
assert.equal(succeeded, 0);
|
|
2473
|
+
assert.equal(failed.length, 1);
|
|
2474
|
+
assert.equal(await readFile(targetFile, "utf-8"), original);
|
|
2475
|
+
assert.ok(!(await exists(`${targetFile}.inception-backup`)));
|
|
2476
|
+
}
|
|
2477
|
+
finally {
|
|
2478
|
+
await rm(home, { recursive: true, force: true });
|
|
2479
|
+
}
|
|
2480
|
+
});
|
|
2481
|
+
});
|