@kuznai/inception-engine 0.22.0 → 0.24.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 +50 -9
- package/dist/src/config/agents.js +39 -0
- package/dist/src/core/adapters/execution-config.d.ts +14 -0
- package/dist/src/core/adapters/execution-config.js +60 -0
- package/dist/src/core/adapters/index.d.ts +4 -3
- package/dist/src/core/adapters/index.js +7 -5
- package/dist/src/core/adapters/rules.js +50 -19
- package/dist/src/core/capabilities.d.ts +1 -1
- package/dist/src/core/capabilities.js +18 -2
- package/dist/src/core/deploy.js +66 -2
- package/dist/src/core/init.js +69 -13
- package/dist/src/core/ownership.d.ts +1 -0
- package/dist/src/core/ownership.js +34 -4
- package/dist/src/core/preflight.js +28 -0
- package/dist/src/core/revert.js +86 -18
- package/dist/src/core/validation.d.ts +1 -1
- package/dist/src/core/validation.js +56 -3
- package/dist/src/schemas/manifest.d.ts +31 -0
- package/dist/src/schemas/manifest.js +39 -3
- package/dist/src/types.d.ts +6 -2
- package/dist/test/helpers/path.d.ts +2 -2
- package/dist/test/helpers/path.js +3 -3
- package/dist/test/os/posix/deploy.test.js +395 -0
- package/dist/test/os/posix/revert.test.js +188 -0
- package/dist/test/os/windows/deploy.test.d.ts +1 -0
- package/dist/test/os/windows/deploy.test.js +238 -0
- package/dist/test/unit/adapters.test.js +402 -2
- package/dist/test/unit/capabilities.test.js +44 -1
- package/dist/test/unit/cli.test.js +162 -0
- package/dist/test/unit/deploy.test.js +88 -647
- package/dist/test/unit/formatters.test.d.ts +1 -0
- package/dist/test/unit/formatters.test.js +74 -0
- package/dist/test/unit/init-fixture.test.js +85 -1
- package/dist/test/unit/manifest.test.js +72 -0
- package/dist/test/unit/ownership.test.js +61 -4
- package/dist/test/unit/preflight.test.js +145 -0
- package/dist/test/unit/resolve.test.js +67 -1
- package/dist/test/unit/revert.test.js +81 -182
- package/package.json +1 -1
- package/dist/test/os/windows/agentRules-integration.test.js +0 -245
- package/dist/test/os/windows/revert-integration.test.js +0 -72
- /package/dist/test/os/{windows/agentRules-integration.test.d.ts → posix/deploy.test.d.ts} +0 -0
- /package/dist/test/os/{windows/revert-integration.test.d.ts → posix/revert.test.d.ts} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import { formatDryRunPlan } from "../../src/formatters.js";
|
|
5
|
+
function stripAnsi(value) {
|
|
6
|
+
return stripVTControlCharacters(value);
|
|
7
|
+
}
|
|
8
|
+
describe("formatDryRunPlan", () => {
|
|
9
|
+
it("returns an empty string when there are no planned changes", () => {
|
|
10
|
+
assert.equal(formatDryRunPlan([]), "");
|
|
11
|
+
});
|
|
12
|
+
it("groups changes by agent, sorts groups, and renders supported detail fields", () => {
|
|
13
|
+
const plan = [
|
|
14
|
+
{
|
|
15
|
+
agent: "opencode",
|
|
16
|
+
kind: "config-patch",
|
|
17
|
+
skill: "beta",
|
|
18
|
+
target: "/targets/opencode.json",
|
|
19
|
+
verb: "patch-config",
|
|
20
|
+
patch: { enabled: true },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
agent: "claude-code",
|
|
24
|
+
kind: "file-write",
|
|
25
|
+
skill: "alpha",
|
|
26
|
+
source: "/source/alpha.md",
|
|
27
|
+
target: "/targets/alpha.md",
|
|
28
|
+
verb: "write-file",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
agent: "claude-code",
|
|
32
|
+
kind: "config-patch",
|
|
33
|
+
skill: "gamma",
|
|
34
|
+
target: "/targets/gamma.json",
|
|
35
|
+
verb: "unapply-patch",
|
|
36
|
+
patch: { enabled: null },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
agent: "claude-code",
|
|
40
|
+
kind: "toml-patch",
|
|
41
|
+
skill: "delta",
|
|
42
|
+
target: "/targets/delta.toml",
|
|
43
|
+
verb: "patch-toml",
|
|
44
|
+
patch: { approval_policy: "suggest" },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
agent: "claude-code",
|
|
48
|
+
kind: "frontmatter-emit",
|
|
49
|
+
skill: "epsilon",
|
|
50
|
+
target: "/targets/epsilon.md",
|
|
51
|
+
verb: "emit-frontmatter",
|
|
52
|
+
frontmatter: { tools: ["github"] },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
agent: "claude-code",
|
|
56
|
+
kind: "file-write",
|
|
57
|
+
skill: "zeta",
|
|
58
|
+
target: "/targets/zeta.md",
|
|
59
|
+
verb: "remove",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
const output = stripAnsi(formatDryRunPlan(plan));
|
|
63
|
+
assert.match(output, /^claude-code\n(?:[\s\S]*?)\nopencode\n/m, `expected claude-code group before opencode, got:\n${output}`);
|
|
64
|
+
assert.match(output, /write-file alpha/);
|
|
65
|
+
assert.match(output, /source: \/source\/alpha\.md/);
|
|
66
|
+
assert.match(output, /target: \/targets\/alpha\.md/);
|
|
67
|
+
assert.match(output, /undo:\s+\{"enabled":null\}/);
|
|
68
|
+
assert.match(output, /patch:\s+\{"approval_policy":"suggest"\}/);
|
|
69
|
+
assert.match(output, /frontmatter: \{"tools":\["github"\]\}/);
|
|
70
|
+
assert.match(output, /remove zeta/);
|
|
71
|
+
assert.doesNotMatch(output, /source: \/targets\/zeta\.md/);
|
|
72
|
+
assert.match(output, /patch-config beta/);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { cp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { describe, it } from "node:test";
|
|
6
|
+
import { runInit } from "../../src/core/init.js";
|
|
7
|
+
import { logger } from "../../src/logger.js";
|
|
6
8
|
import { makeTmpDir } from "../helpers/fs.js";
|
|
7
9
|
import { assertPathEndsWith, normalizeSlashes } from "../helpers/path.js";
|
|
10
|
+
logger.silence();
|
|
8
11
|
const PROJECT_ROOT = path.resolve(import.meta.dirname, "..", "..");
|
|
9
12
|
const FIXTURE_DIR = path.join(PROJECT_ROOT, "test", "fixtures", "readme-sample");
|
|
10
13
|
function run(args, env) {
|
|
@@ -151,3 +154,84 @@ describe("init against readme-sample fixture", () => {
|
|
|
151
154
|
}
|
|
152
155
|
});
|
|
153
156
|
});
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Block 3: init discovery of GitHub Copilot native instruction surfaces
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
describe("init Copilot native instruction discovery", () => {
|
|
161
|
+
it("runInit dryRun succeeds when .github/copilot-instructions.md is present", async () => {
|
|
162
|
+
const dir = await makeTmpDir();
|
|
163
|
+
try {
|
|
164
|
+
await mkdir(path.join(dir, ".github"), { recursive: true });
|
|
165
|
+
await writeFile(path.join(dir, ".github", "copilot-instructions.md"), "# Copilot instructions");
|
|
166
|
+
// Use dryRun to avoid writing inception.json
|
|
167
|
+
const result = await runInit({
|
|
168
|
+
directory: dir,
|
|
169
|
+
agents: null,
|
|
170
|
+
dryRun: true,
|
|
171
|
+
force: false,
|
|
172
|
+
verbose: false,
|
|
173
|
+
});
|
|
174
|
+
assert.equal(result, 0);
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
await rm(dir, { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
it("init --plan output includes copilot-repo scope for .github/copilot-instructions.md", async () => {
|
|
181
|
+
const dir = await makeTmpDir();
|
|
182
|
+
try {
|
|
183
|
+
await mkdir(path.join(dir, ".github"), { recursive: true });
|
|
184
|
+
await writeFile(path.join(dir, ".github", "copilot-instructions.md"), "# Copilot instructions");
|
|
185
|
+
const { stdout, code } = await run(["init", dir, "--plan"]);
|
|
186
|
+
assert.equal(code, 0, `init --plan failed:\n${stdout}`);
|
|
187
|
+
const manifest = extractPlanJson(stdout);
|
|
188
|
+
const copilotEntry = manifest.agentRules.find((r) => r.scope === "copilot-repo");
|
|
189
|
+
assert.ok(copilotEntry, `expected a copilot-repo entry in agentRules, got: ${JSON.stringify(manifest.agentRules)}`);
|
|
190
|
+
assert.deepEqual(copilotEntry.agents, ["github-copilot"]);
|
|
191
|
+
assertPathEndsWith(copilotEntry.path, ".github/copilot-instructions.md", `copilot-repo entry path should end with .github/copilot-instructions.md`);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await rm(dir, { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
it("init --plan output includes copilot-scoped scope for .github/instructions/*.instructions.md", async () => {
|
|
198
|
+
const dir = await makeTmpDir();
|
|
199
|
+
try {
|
|
200
|
+
await mkdir(path.join(dir, ".github", "instructions"), {
|
|
201
|
+
recursive: true,
|
|
202
|
+
});
|
|
203
|
+
await writeFile(path.join(dir, ".github", "instructions", "typescript.instructions.md"), "# TypeScript scoped instructions");
|
|
204
|
+
await writeFile(path.join(dir, ".github", "instructions", "python.instructions.md"), "# Python scoped instructions");
|
|
205
|
+
const { stdout, code } = await run(["init", dir, "--plan"]);
|
|
206
|
+
assert.equal(code, 0, `init --plan failed:\n${stdout}`);
|
|
207
|
+
const manifest = extractPlanJson(stdout);
|
|
208
|
+
const scopedEntries = manifest.agentRules.filter((r) => r.scope === "copilot-scoped");
|
|
209
|
+
assert.equal(scopedEntries.length, 2, `expected 2 copilot-scoped entries, got ${scopedEntries.length}: ${JSON.stringify(manifest.agentRules)}`);
|
|
210
|
+
for (const entry of scopedEntries) {
|
|
211
|
+
assert.deepEqual(entry.agents, ["github-copilot"]);
|
|
212
|
+
}
|
|
213
|
+
const names = scopedEntries.map((e) => e.name).sort();
|
|
214
|
+
assert.deepEqual(names, ["python", "typescript"]);
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
await rm(dir, { recursive: true });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
it("non-.github/ copilot-instructions.md still maps to claude-code (backward compat)", async () => {
|
|
221
|
+
const dir = await makeTmpDir();
|
|
222
|
+
try {
|
|
223
|
+
await mkdir(path.join(dir, "rules"), { recursive: true });
|
|
224
|
+
await writeFile(path.join(dir, "rules", "copilot-instructions.md"), "# Copilot instructions");
|
|
225
|
+
const { stdout, code } = await run(["init", dir, "--plan"]);
|
|
226
|
+
assert.equal(code, 0, `init --plan failed:\n${stdout}`);
|
|
227
|
+
const manifest = extractPlanJson(stdout);
|
|
228
|
+
const entry = manifest.agentRules.find((r) => r.path.endsWith("copilot-instructions.md"));
|
|
229
|
+
assert.ok(entry, `expected a copilot-instructions.md entry, got: ${JSON.stringify(manifest.agentRules)}`);
|
|
230
|
+
assert.ok(entry.agents.includes("claude-code"), `expected agents to include claude-code, got: ${JSON.stringify(entry.agents)}`);
|
|
231
|
+
assert.notEqual(entry.scope, "copilot-repo", "rules/ copilot-instructions.md should not use copilot-repo scope");
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
await rm(dir, { recursive: true });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -554,6 +554,78 @@ describe("loadManifest", () => {
|
|
|
554
554
|
await rm(dir, { recursive: true });
|
|
555
555
|
}
|
|
556
556
|
});
|
|
557
|
+
it("agentRules accepts targetDir with scope: 'repo'", async () => {
|
|
558
|
+
const dir = await makeTmpDir();
|
|
559
|
+
try {
|
|
560
|
+
await writeFile(path.join(dir, "inception.json"), JSON.stringify({
|
|
561
|
+
skills: [],
|
|
562
|
+
agentRules: [
|
|
563
|
+
{
|
|
564
|
+
name: "my-rule",
|
|
565
|
+
agents: ["claude-code"],
|
|
566
|
+
path: "rules/CLAUDE.md",
|
|
567
|
+
scope: "repo",
|
|
568
|
+
targetDir: "apps/frontend",
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
}));
|
|
572
|
+
const manifest = await loadManifest(dir);
|
|
573
|
+
assert.equal(manifest.agentRules[0]?.targetDir, "apps/frontend");
|
|
574
|
+
}
|
|
575
|
+
finally {
|
|
576
|
+
await rm(dir, { recursive: true });
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
it("agentRules rejects targetDir with scope: 'global'", async () => {
|
|
580
|
+
const dir = await makeTmpDir();
|
|
581
|
+
try {
|
|
582
|
+
await writeFile(path.join(dir, "inception.json"), JSON.stringify({
|
|
583
|
+
skills: [],
|
|
584
|
+
agentRules: [
|
|
585
|
+
{
|
|
586
|
+
name: "my-rule",
|
|
587
|
+
agents: ["claude-code"],
|
|
588
|
+
path: "rules/CLAUDE.md",
|
|
589
|
+
scope: "global",
|
|
590
|
+
targetDir: "apps/frontend",
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
}));
|
|
594
|
+
await assert.rejects(loadManifest(dir), (err) => {
|
|
595
|
+
assert.ok(err instanceof UserError);
|
|
596
|
+
assert.match(err.message, /targetDir is only supported for scope "repo" or "workspace"/);
|
|
597
|
+
return true;
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
await rm(dir, { recursive: true });
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
it("throws when agentRules targetDir is absolute", async () => {
|
|
605
|
+
const dir = await makeTmpDir();
|
|
606
|
+
try {
|
|
607
|
+
await writeFile(path.join(dir, "inception.json"), JSON.stringify({
|
|
608
|
+
skills: [],
|
|
609
|
+
agentRules: [
|
|
610
|
+
{
|
|
611
|
+
name: "my-rule",
|
|
612
|
+
agents: ["claude-code"],
|
|
613
|
+
path: "rules/CLAUDE.md",
|
|
614
|
+
scope: "repo",
|
|
615
|
+
targetDir: "/absolute/path",
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
}));
|
|
619
|
+
await assert.rejects(loadManifest(dir), (err) => {
|
|
620
|
+
assert.ok(err instanceof UserError);
|
|
621
|
+
assert.match(err.message, /targetDir must be a relative path/);
|
|
622
|
+
return true;
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
await rm(dir, { recursive: true });
|
|
627
|
+
}
|
|
628
|
+
});
|
|
557
629
|
it("defaults mcpServers and agentRules to [] when omitted", async () => {
|
|
558
630
|
const dir = await makeTmpDir();
|
|
559
631
|
try {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { describe, it } from "node:test";
|
|
5
|
-
import { defaultRegistryPersistence, lookupDeployment, registerDeployment, registryPath, unregisterDeployment, verifyDeployment, } from "../../src/core/ownership.js";
|
|
5
|
+
import { defaultRegistryPersistence, lookupDeployment, registerDeployment, registryDirPath, registryPath, unregisterDeployment, verifyDeployment, } from "../../src/core/ownership.js";
|
|
6
6
|
import { exists, makeTmpDir } from "../helpers/fs.js";
|
|
7
7
|
describe("registerDeployment", () => {
|
|
8
8
|
it("creates registry file and adds entry", async () => {
|
|
@@ -56,7 +56,7 @@ describe("registerDeployment", () => {
|
|
|
56
56
|
await rm(home, { recursive: true });
|
|
57
57
|
}
|
|
58
58
|
});
|
|
59
|
-
it("sets registry file permissions to
|
|
59
|
+
it("sets registry file permissions to 0o600 on POSIX", {
|
|
60
60
|
skip: process.platform === "win32",
|
|
61
61
|
}, async () => {
|
|
62
62
|
const home = await makeTmpDir();
|
|
@@ -70,7 +70,27 @@ describe("registerDeployment", () => {
|
|
|
70
70
|
});
|
|
71
71
|
const { stat } = await import("node:fs/promises");
|
|
72
72
|
const statResult = await stat(registryPath(home));
|
|
73
|
-
assert.equal(statResult.mode & 0o777,
|
|
73
|
+
assert.equal(statResult.mode & 0o777, 0o600);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await rm(home, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
it("sets registry directory permissions to 0o700 on POSIX", {
|
|
80
|
+
skip: process.platform === "win32",
|
|
81
|
+
}, async () => {
|
|
82
|
+
const home = await makeTmpDir();
|
|
83
|
+
try {
|
|
84
|
+
await registerDeployment(home, "/fake/target", {
|
|
85
|
+
kind: "skill-dir",
|
|
86
|
+
source: "/fake/source",
|
|
87
|
+
skill: "my-skill",
|
|
88
|
+
agent: "claude-code",
|
|
89
|
+
method: "symlink",
|
|
90
|
+
});
|
|
91
|
+
const { stat } = await import("node:fs/promises");
|
|
92
|
+
const statResult = await stat(registryDirPath(home));
|
|
93
|
+
assert.equal(statResult.mode & 0o777, 0o700);
|
|
74
94
|
}
|
|
75
95
|
finally {
|
|
76
96
|
await rm(home, { recursive: true });
|
|
@@ -121,6 +141,26 @@ describe("registerDeployment", () => {
|
|
|
121
141
|
await rm(home, { recursive: true, force: true });
|
|
122
142
|
}
|
|
123
143
|
});
|
|
144
|
+
it("refuses to write the registry through a symlinked state directory", {
|
|
145
|
+
skip: process.platform === "win32",
|
|
146
|
+
}, async () => {
|
|
147
|
+
const home = await makeTmpDir();
|
|
148
|
+
const elsewhere = await makeTmpDir();
|
|
149
|
+
try {
|
|
150
|
+
await symlink(elsewhere, registryDirPath(home), "dir");
|
|
151
|
+
await assert.rejects(registerDeployment(home, "/fake/target", {
|
|
152
|
+
kind: "skill-dir",
|
|
153
|
+
source: "/fake/source",
|
|
154
|
+
skill: "my-skill",
|
|
155
|
+
agent: "claude-code",
|
|
156
|
+
method: "copy",
|
|
157
|
+
}), /Refusing to use registry directory symlink/);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
await rm(home, { recursive: true, force: true });
|
|
161
|
+
await rm(elsewhere, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
124
164
|
});
|
|
125
165
|
describe("unregisterDeployment", () => {
|
|
126
166
|
it("removes the entry for the given target", async () => {
|
|
@@ -219,6 +259,23 @@ describe("lookupDeployment", () => {
|
|
|
219
259
|
await rm(home, { recursive: true });
|
|
220
260
|
}
|
|
221
261
|
});
|
|
262
|
+
it("returns null when the registry file path is a symlink", {
|
|
263
|
+
skip: process.platform === "win32",
|
|
264
|
+
}, async () => {
|
|
265
|
+
const home = await makeTmpDir();
|
|
266
|
+
const elsewhere = await makeTmpDir();
|
|
267
|
+
try {
|
|
268
|
+
await mkdir(registryDirPath(home), { recursive: true });
|
|
269
|
+
await writeFile(path.join(elsewhere, "registry.json"), "{}");
|
|
270
|
+
await symlink(path.join(elsewhere, "registry.json"), registryPath(home), "file");
|
|
271
|
+
const entry = await lookupDeployment(home, "/anything");
|
|
272
|
+
assert.equal(entry, null);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
await rm(home, { recursive: true, force: true });
|
|
276
|
+
await rm(elsewhere, { recursive: true, force: true });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
222
279
|
});
|
|
223
280
|
describe("verifyDeployment", () => {
|
|
224
281
|
it("returns entry when all fields match", async () => {
|
|
@@ -124,6 +124,65 @@ describe("runPreflight", () => {
|
|
|
124
124
|
const implementationOnlyWarning = warnings.find((w) => w.kind === "config-authority" && /implementation-only/.test(w.message));
|
|
125
125
|
assert.equal(implementationOnlyWarning, undefined, `expected no implementation-only warning, got: ${implementationOnlyWarning?.message}`);
|
|
126
126
|
});
|
|
127
|
+
it("emits shared-surface config-authority guidance when github-copilot agentRules ride through claude-code", async () => {
|
|
128
|
+
const manifest = {
|
|
129
|
+
...emptyManifest,
|
|
130
|
+
agentRules: [
|
|
131
|
+
{
|
|
132
|
+
name: "shared-rules",
|
|
133
|
+
path: "CLAUDE.md",
|
|
134
|
+
agents: ["claude-code", "github-copilot"],
|
|
135
|
+
scope: "repo",
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
const warnings = await runPreflight(baseOptions, manifest, "/home/test", [
|
|
140
|
+
"claude-code",
|
|
141
|
+
"github-copilot",
|
|
142
|
+
]);
|
|
143
|
+
const warning = warnings.find((w) => w.kind === "config-authority" &&
|
|
144
|
+
w.message.includes('shared through "claude-code"'));
|
|
145
|
+
assert.ok(warning, `expected shared-surface warning, got: ${JSON.stringify(warnings)}`);
|
|
146
|
+
assert.match(warning.message, /requires the primary target to deploy/);
|
|
147
|
+
});
|
|
148
|
+
it("emits shared-surface config-authority guidance for antigravity repo rules", async () => {
|
|
149
|
+
const manifest = {
|
|
150
|
+
...emptyManifest,
|
|
151
|
+
agentRules: [
|
|
152
|
+
{
|
|
153
|
+
name: "gemini-rules",
|
|
154
|
+
path: "GEMINI.md",
|
|
155
|
+
agents: ["antigravity"],
|
|
156
|
+
scope: "repo",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
const warnings = await runPreflight(baseOptions, manifest, "/home/test", [
|
|
161
|
+
"antigravity",
|
|
162
|
+
]);
|
|
163
|
+
const warning = warnings.find((w) => w.kind === "config-authority" &&
|
|
164
|
+
w.message.includes('shared through "gemini-cli"'));
|
|
165
|
+
assert.ok(warning, `expected antigravity shared-via warning, got: ${JSON.stringify(warnings)}`);
|
|
166
|
+
assert.doesNotMatch(warning.message, /requires the primary target to deploy/);
|
|
167
|
+
});
|
|
168
|
+
it("emits provisional config-authority warning for supported gemini executionConfigs", async () => {
|
|
169
|
+
const manifest = {
|
|
170
|
+
...emptyManifest,
|
|
171
|
+
executionConfigs: [
|
|
172
|
+
{
|
|
173
|
+
name: "safe-mode",
|
|
174
|
+
agents: ["gemini-cli"],
|
|
175
|
+
config: { sandbox: "workspace-write" },
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
const warnings = await runPreflight(baseOptions, manifest, "/home/test", [
|
|
180
|
+
"gemini-cli",
|
|
181
|
+
]);
|
|
182
|
+
const capabilityWarning = warnings.find((w) => w.kind === "config-authority" && /execution-config/.test(w.message));
|
|
183
|
+
assert.ok(capabilityWarning, `expected executionConfig warning, got: ${JSON.stringify(warnings)}`);
|
|
184
|
+
assert.match(capabilityWarning.message, /provisional/);
|
|
185
|
+
});
|
|
127
186
|
});
|
|
128
187
|
describe("github-copilot devcontainer support", () => {
|
|
129
188
|
it("emits no capability warning for devcontainer MCP when Copilot is detected and devcontainer scope is targeted", async () => {
|
|
@@ -279,6 +338,92 @@ describe("instruction precedence warnings", () => {
|
|
|
279
338
|
await rm(sourceDir, { recursive: true });
|
|
280
339
|
}
|
|
281
340
|
});
|
|
341
|
+
it("emits Copilot precedence warning when github-copilot has both shared-via (scope: repo) and native (scope: copilot-repo) entries", async () => {
|
|
342
|
+
const sourceDir = await makeTmpDir();
|
|
343
|
+
try {
|
|
344
|
+
await writeFile(path.join(sourceDir, "shared.md"), "# Shared rules");
|
|
345
|
+
await writeFile(path.join(sourceDir, "native.md"), "# Native rules");
|
|
346
|
+
const manifest = {
|
|
347
|
+
...emptyManifest,
|
|
348
|
+
agentRules: [
|
|
349
|
+
{
|
|
350
|
+
name: "shared-rules",
|
|
351
|
+
path: "shared.md",
|
|
352
|
+
agents: ["claude-code", "github-copilot"],
|
|
353
|
+
scope: "repo",
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: "native-rules",
|
|
357
|
+
path: "native.md",
|
|
358
|
+
agents: ["github-copilot"],
|
|
359
|
+
scope: "copilot-repo",
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
};
|
|
363
|
+
const warnings = await runPreflight({ ...baseOptions, directory: sourceDir }, manifest, "/home/test", ["github-copilot"]);
|
|
364
|
+
const precedenceWarnings = warnings.filter((w) => w.kind === "precedence");
|
|
365
|
+
assert.ok(precedenceWarnings.length > 0, "expected a precedence warning");
|
|
366
|
+
assert.ok(precedenceWarnings.some((w) => w.message.includes("github-copilot") &&
|
|
367
|
+
w.message.includes("CLAUDE.md-shared") &&
|
|
368
|
+
w.message.includes("native Copilot")), `expected Copilot precedence warning, got: ${JSON.stringify(precedenceWarnings)}`);
|
|
369
|
+
}
|
|
370
|
+
finally {
|
|
371
|
+
await rm(sourceDir, { recursive: true });
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
it("emits Copilot precedence warning when github-copilot has both global (scope: global) and copilot-scoped entries", async () => {
|
|
375
|
+
const sourceDir = await makeTmpDir();
|
|
376
|
+
try {
|
|
377
|
+
await writeFile(path.join(sourceDir, "shared.md"), "# Shared rules");
|
|
378
|
+
await writeFile(path.join(sourceDir, "scoped.md"), "# Scoped rules");
|
|
379
|
+
const manifest = {
|
|
380
|
+
...emptyManifest,
|
|
381
|
+
agentRules: [
|
|
382
|
+
{
|
|
383
|
+
name: "shared-rules",
|
|
384
|
+
path: "shared.md",
|
|
385
|
+
agents: ["claude-code", "github-copilot"],
|
|
386
|
+
scope: "global",
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "typescript",
|
|
390
|
+
path: "scoped.md",
|
|
391
|
+
agents: ["github-copilot"],
|
|
392
|
+
scope: "copilot-scoped",
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
};
|
|
396
|
+
const warnings = await runPreflight({ ...baseOptions, directory: sourceDir }, manifest, "/home/test", ["github-copilot"]);
|
|
397
|
+
const precedenceWarnings = warnings.filter((w) => w.kind === "precedence");
|
|
398
|
+
assert.ok(precedenceWarnings.some((w) => w.message.includes("github-copilot") &&
|
|
399
|
+
w.message.includes("native Copilot")), `expected Copilot precedence warning, got: ${JSON.stringify(precedenceWarnings)}`);
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
await rm(sourceDir, { recursive: true });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
it("emits no Copilot precedence warning when github-copilot only has native entries", async () => {
|
|
406
|
+
const sourceDir = await makeTmpDir();
|
|
407
|
+
try {
|
|
408
|
+
await writeFile(path.join(sourceDir, "native.md"), "# Native rules");
|
|
409
|
+
const manifest = {
|
|
410
|
+
...emptyManifest,
|
|
411
|
+
agentRules: [
|
|
412
|
+
{
|
|
413
|
+
name: "native-rules",
|
|
414
|
+
path: "native.md",
|
|
415
|
+
agents: ["github-copilot"],
|
|
416
|
+
scope: "copilot-repo",
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
};
|
|
420
|
+
const warnings = await runPreflight({ ...baseOptions, directory: sourceDir }, manifest, "/home/test", ["github-copilot"]);
|
|
421
|
+
assert.equal(warnings.filter((w) => w.kind === "precedence").length, 0);
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
await rm(sourceDir, { recursive: true });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
282
427
|
});
|
|
283
428
|
describe("instruction budget warnings", () => {
|
|
284
429
|
it("emits budget warning for agentRules source file exceeding 50 KB", async () => {
|
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { describe, it } from "node:test";
|
|
5
5
|
import { AGENT_REGISTRY } from "../../src/config/agents.js";
|
|
6
6
|
import { resolveAgentDetectPath, resolveAgentSkillPath, resolveHome, } from "../../src/core/resolve.js";
|
|
7
|
-
import { resolveTargetTemplate } from "../../src/core/runtime-paths.js";
|
|
7
|
+
import { resolveRuntimePaths, resolveTargetTemplate, } from "../../src/core/runtime-paths.js";
|
|
8
8
|
import { UserError } from "../../src/errors.js";
|
|
9
9
|
const posixJoin = path.posix.join;
|
|
10
10
|
function getAgent(id) {
|
|
@@ -113,7 +113,73 @@ describe("resolveTargetTemplate", () => {
|
|
|
113
113
|
const result = resolveTargetTemplate("{home}/.claude/settings.json", home);
|
|
114
114
|
assert.equal(result, `${home}/.claude/settings.json`);
|
|
115
115
|
});
|
|
116
|
+
it("resolves appdata, local_appdata, xdg_config, repo, and workspace roots", () => {
|
|
117
|
+
const savedAppData = process.env.APPDATA;
|
|
118
|
+
const savedLocalAppData = process.env.LOCALAPPDATA;
|
|
119
|
+
const savedXdg = process.env.XDG_CONFIG_HOME;
|
|
120
|
+
try {
|
|
121
|
+
process.env.APPDATA = "/env/appdata";
|
|
122
|
+
process.env.LOCALAPPDATA = "/env/local";
|
|
123
|
+
process.env.XDG_CONFIG_HOME = "/env/xdg";
|
|
124
|
+
assert.equal(resolveTargetTemplate("{appdata}/opencode/opencode.json", "/home/user"), "/env/appdata/opencode/opencode.json");
|
|
125
|
+
assert.equal(resolveTargetTemplate("{local_appdata}/Temp/config.json", "/home/user"), "/env/local/Temp/config.json");
|
|
126
|
+
assert.equal(resolveTargetTemplate("{xdg_config}/opencode/config.json", "/home/user"), "/env/xdg/opencode/config.json");
|
|
127
|
+
assert.equal(resolveTargetTemplate("{repo}/.claude/mcp.json", "/home/user", "/repo"), "/repo/.claude/mcp.json");
|
|
128
|
+
assert.equal(resolveTargetTemplate("{workspace}/CLAUDE.md", "/home/user", "/repo"), "/repo/CLAUDE.md");
|
|
129
|
+
assert.equal(resolveTargetTemplate("{workspace}/CLAUDE.md", "/home/user", "/repo", "/workspace"), "/workspace/CLAUDE.md");
|
|
130
|
+
assert.equal(resolveTargetTemplate("{home}", "/home/user"), "/home/user");
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
if (savedAppData === undefined)
|
|
134
|
+
delete process.env.APPDATA;
|
|
135
|
+
else
|
|
136
|
+
process.env.APPDATA = savedAppData;
|
|
137
|
+
if (savedLocalAppData === undefined)
|
|
138
|
+
delete process.env.LOCALAPPDATA;
|
|
139
|
+
else
|
|
140
|
+
process.env.LOCALAPPDATA = savedLocalAppData;
|
|
141
|
+
if (savedXdg === undefined)
|
|
142
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
143
|
+
else
|
|
144
|
+
process.env.XDG_CONFIG_HOME = savedXdg;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
it("throws for invalid templates and missing repo or workspace roots", () => {
|
|
148
|
+
assert.throws(() => resolveTargetTemplate("relative/path.txt", "/home/user"), /Invalid target template/);
|
|
149
|
+
assert.throws(() => resolveTargetTemplate("{repo}/x", "/home/user"), /no repo directory was provided/);
|
|
150
|
+
assert.throws(() => resolveTargetTemplate("{workspace}/x", "/home/user"), /no workspace directory was provided/);
|
|
151
|
+
});
|
|
116
152
|
it("throws when the template escapes the placeholder root", () => {
|
|
117
153
|
assert.throws(() => resolveTargetTemplate("{home}/../.ssh/config", "/home/user"), /outside its placeholder root/);
|
|
118
154
|
});
|
|
119
155
|
});
|
|
156
|
+
describe("resolveRuntimePaths", () => {
|
|
157
|
+
it("uses absolute env vars and ignores relative overrides", () => {
|
|
158
|
+
const savedAppData = process.env.APPDATA;
|
|
159
|
+
const savedLocalAppData = process.env.LOCALAPPDATA;
|
|
160
|
+
const savedXdg = process.env.XDG_CONFIG_HOME;
|
|
161
|
+
try {
|
|
162
|
+
process.env.APPDATA = "/custom/appdata";
|
|
163
|
+
process.env.LOCALAPPDATA = "relative/local";
|
|
164
|
+
process.env.XDG_CONFIG_HOME = "relative/xdg";
|
|
165
|
+
const paths = resolveRuntimePaths("/home/user");
|
|
166
|
+
assert.equal(paths.appdata, "/custom/appdata");
|
|
167
|
+
assert.equal(paths.localAppdata, "/home/user/AppData/Local");
|
|
168
|
+
assert.equal(paths.xdgConfig, "/home/user/.config");
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
if (savedAppData === undefined)
|
|
172
|
+
delete process.env.APPDATA;
|
|
173
|
+
else
|
|
174
|
+
process.env.APPDATA = savedAppData;
|
|
175
|
+
if (savedLocalAppData === undefined)
|
|
176
|
+
delete process.env.LOCALAPPDATA;
|
|
177
|
+
else
|
|
178
|
+
process.env.LOCALAPPDATA = savedLocalAppData;
|
|
179
|
+
if (savedXdg === undefined)
|
|
180
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
181
|
+
else
|
|
182
|
+
process.env.XDG_CONFIG_HOME = savedXdg;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|