@jaybeeuu/agent-cortex 1.39.0 → 1.41.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/.agents/skills/style-versioning/SKILL.md +1 -1
- package/.github/workflows/ci.yml +47 -14
- package/AGENTS.md +35 -8
- package/CHANGELOG.md +60 -0
- package/README.md +174 -97
- package/agents/README.md +14 -11
- package/bin/agent-cortex.mjs +55 -10
- package/bin/installers/claude.mjs +674 -174
- package/bin/installers/copilot.mjs +13 -18
- package/bin/installers/pi.mjs +293 -51
- package/claude/settings.json +4 -0
- package/{claude → claude-extras}/scripts/statusline-command.sh +32 -4
- package/docs/claude-hooks.md +15 -3
- package/docs/ideas/ban-content-assertion-tests.md +73 -0
- package/docs/ideas/bead-progress-notes-for-session-resumption.md +75 -0
- package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +70 -0
- package/docs/ideas/ctx-alias-version-and-update-commands.md +65 -0
- package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +67 -0
- package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +36 -0
- package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +99 -0
- package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +33 -0
- package/docs/ideas/extension-manifests.md +55 -14
- package/docs/ideas/improve-pi-system-prompt.md +5 -2
- package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +95 -0
- package/docs/ideas/record-idea-implementation-questions.md +68 -0
- package/docs/ideas/self-flushing-session.md +72 -0
- package/docs/ideas/state-in-beads-not-md.md +69 -0
- package/docs/ideas/task-session-context.local.md +95 -0
- package/docs/ideas/test-data-creation-helpers.md +92 -0
- package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +68 -0
- package/docs/ideas/worktree-housekeeping-for-closed-beads.md +39 -0
- package/docs/technical-direction/harness-config.md +161 -0
- package/docs/technical-direction/migration-execution-labels.md +60 -23
- package/extensions/agent-guardrails/README.md +45 -0
- package/extensions/agent-guardrails/guardrails.test.ts +42 -0
- package/extensions/agent-guardrails/guardrails.ts +66 -0
- package/extensions/agent-guardrails/index.ts +22 -0
- package/extensions/agent-guardrails/node_modules/.bin/pi +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsc +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsserver +21 -0
- package/extensions/agent-guardrails/node_modules/.bin/tsx +21 -0
- package/extensions/agent-guardrails/package.json +19 -0
- package/extensions/agent-guardrails/tsconfig.json +12 -0
- package/extensions/agent-modes/README.md +12 -5
- package/extensions/agent-modes/discover.test.ts +30 -7
- package/extensions/agent-modes/discover.ts +1 -1
- package/extensions/agent-modes/index.test.ts +208 -0
- package/extensions/agent-modes/index.ts +23 -6
- package/extensions/agent-modes/package.json +1 -1
- package/extensions/skill-stats/index.test.ts +3 -2
- package/lib/cli.mjs +30 -12
- package/lib/pi-packages.mjs +169 -0
- package/package.json +8 -4
- package/pi/settings.json +23 -12
- package/plugin.json +1 -1
- package/scripts/build-copilot-agents.mjs +1 -1
- package/scripts/check-no-sync.mjs +49 -0
- package/scripts/lib/compose-agent.mjs +32 -14
- package/scripts/lib/fs.mjs +31 -0
- package/skills/engineering/grill-with-docs/SKILL.md +1 -1
- package/skills/planning/classify-bead/REFERENCE.md +85 -0
- package/skills/planning/classify-bead/SKILL.md +32 -36
- package/skills/planning/classify-bead/scripts/classify-bead.mjs +292 -0
- package/skills/planning/create-task/SKILL.md +19 -10
- package/skills/planning/write-a-prd/SKILL.md +2 -2
- package/skills/style/style-code/REFERENCE.md +32 -2
- package/skills/style/style-code/SKILL.md +5 -21
- package/skills/style/style-tests/SKILL.md +69 -17
- package/skills/workflow/hitl-collab/SKILL.md +1 -1
- package/skills/workflow/run-pipeline-stage/SKILL.md +13 -1
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +12 -0
- package/test/classify-bead.test.mjs +277 -0
- package/test/claude-installer.test.mjs +199 -139
- package/test/cli.test.mjs +95 -41
- package/test/copilot-installer.test.mjs +92 -82
- package/test/fs.test.mjs +76 -0
- package/test/helpers/fake-claude.mjs +152 -0
- package/test/installer.test.mjs +542 -57
- package/test/pi-installer.test.mjs +457 -102
- package/test/pi-packages.test.mjs +295 -0
- package/token-map.README.md +23 -12
- package/token-map.json +8 -7
- package/.claude-plugin/marketplace.json +0 -16
- package/claude/.claude-plugin/plugin.json +0 -11
- package/claude/agents/plan.md +0 -58
- package/claude/agents/ralph-plan.md +0 -121
- package/claude/agents/ralph.md +0 -193
- package/claude/agents/strategy.md +0 -111
- package/claude/hooks/scripts/notify.mjs +0 -54
- package/claude/hooks.json +0 -38
- /package/{claude → claude-extras}/.mcp.json +0 -0
package/test/installer.test.mjs
CHANGED
|
@@ -1,57 +1,101 @@
|
|
|
1
1
|
// Integration tests for `agent-cortex install claude`: the install-time
|
|
2
|
-
// generator
|
|
3
|
-
//
|
|
2
|
+
// generator that materialises the Claude Code plugin. The default target is a
|
|
3
|
+
// home-scoped directory (~/.agent-cortex/claude, overridable via HOME for
|
|
4
|
+
// tests); all tests install into mkdtemp dirs — the real ~/.agent-cortex and
|
|
5
|
+
// the repo's canonical sources (agents/, skills/, claude-extras/, hooks/claude/) are never touched.
|
|
4
6
|
|
|
5
7
|
import { describe, it } from "node:test";
|
|
6
8
|
import assert from "node:assert/strict";
|
|
7
9
|
import { execFile } from "node:child_process";
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
mkdtemp,
|
|
12
|
+
mkdir,
|
|
13
|
+
writeFile,
|
|
14
|
+
chmod,
|
|
15
|
+
rm,
|
|
16
|
+
readFile,
|
|
17
|
+
readdir,
|
|
18
|
+
stat,
|
|
19
|
+
lstat,
|
|
20
|
+
readlink,
|
|
21
|
+
symlink,
|
|
22
|
+
} from "node:fs/promises";
|
|
9
23
|
import { tmpdir } from "node:os";
|
|
10
24
|
import { fileURLToPath } from "node:url";
|
|
11
25
|
import { dirname, join, relative } from "node:path";
|
|
26
|
+
import { installClaude } from "../bin/installers/claude.mjs";
|
|
27
|
+
import { makeFakeClaude, registrationActions } from "./helpers/fake-claude.mjs";
|
|
12
28
|
|
|
13
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
30
|
const ROOT = join(__dirname, "..");
|
|
15
31
|
const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
|
|
16
32
|
|
|
17
|
-
|
|
33
|
+
/** True when a path exists (stat succeeds). */
|
|
34
|
+
async function pathExists(p) {
|
|
35
|
+
try {
|
|
36
|
+
await stat(p);
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Run the CLI; optionally pass a custom env (e.g. a fake HOME). */
|
|
44
|
+
function runCli(args, env = {}) {
|
|
18
45
|
return new Promise((resolve) => {
|
|
19
|
-
execFile(
|
|
20
|
-
|
|
21
|
-
|
|
46
|
+
execFile(
|
|
47
|
+
process.execPath,
|
|
48
|
+
[CLI_PATH, ...args],
|
|
49
|
+
{ env: { ...process.env, ...env } },
|
|
50
|
+
(error, stdout, stderr) => {
|
|
51
|
+
resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
|
|
52
|
+
},
|
|
53
|
+
);
|
|
22
54
|
});
|
|
23
55
|
}
|
|
24
56
|
|
|
25
|
-
function makeTmp() {
|
|
26
|
-
return
|
|
57
|
+
async function makeTmp() {
|
|
58
|
+
return await mkdtemp(join(tmpdir(), "agent-cortex-installer-"));
|
|
27
59
|
}
|
|
28
60
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
61
|
+
|
|
62
|
+
/** Deterministic snapshot of a generated tree: kind, and for files/symlinks
|
|
63
|
+
* their byte content / link target. */
|
|
64
|
+
async function snapshot(dir) {
|
|
32
65
|
const entries = [];
|
|
33
|
-
const walk = (p) => {
|
|
34
|
-
for (const e of
|
|
66
|
+
const walk = async (p) => {
|
|
67
|
+
for (const e of (await readdir(p, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
35
68
|
const full = join(p, e.name);
|
|
36
69
|
const rel = relative(dir, full);
|
|
37
|
-
if (e.isSymbolicLink()) entries.push([rel, "link",
|
|
70
|
+
if (e.isSymbolicLink()) entries.push([rel, "link", await readlink(full)]);
|
|
38
71
|
else if (e.isDirectory()) {
|
|
39
72
|
entries.push([rel, "dir"]);
|
|
40
|
-
walk(full);
|
|
41
|
-
} else entries.push([rel, "file",
|
|
73
|
+
await walk(full);
|
|
74
|
+
} else entries.push([rel, "file", await readFile(full, "utf-8")]);
|
|
42
75
|
}
|
|
43
76
|
};
|
|
44
|
-
walk(dir);
|
|
77
|
+
await walk(dir);
|
|
45
78
|
return entries;
|
|
46
79
|
}
|
|
47
80
|
|
|
81
|
+
async function listFiles(dir) {
|
|
82
|
+
if (!(await pathExists(dir))) return [];
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
85
|
+
const p = join(dir, entry.name);
|
|
86
|
+
if (entry.isDirectory()) out.push(...(await listFiles(p)).map((f) => join(entry.name, f)));
|
|
87
|
+
else out.push(entry.name);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
48
92
|
describe("install claude", () => {
|
|
49
93
|
it("composes agents from the composable sources (claude names, no tokens)", async () => {
|
|
50
|
-
const out = makeTmp();
|
|
94
|
+
const out = await makeTmp();
|
|
51
95
|
const { exitCode } = await runCli(["install", "claude", "--output", out]);
|
|
52
96
|
assert.equal(exitCode, 0);
|
|
53
97
|
|
|
54
|
-
const plan =
|
|
98
|
+
const plan = await readFile(join(out, "agents", "plan.md"), "utf-8");
|
|
55
99
|
assert.match(plan, /^---\n# GENERATED from agents\/plan\//);
|
|
56
100
|
// provenance names the shared installer (no build-time script), so the
|
|
57
101
|
// committed subtree can only come from the install-time generator.
|
|
@@ -60,46 +104,127 @@ describe("install claude", () => {
|
|
|
60
104
|
assert.match(plan, /\nname: plan\n/);
|
|
61
105
|
assert.doesNotMatch(plan, /\{\{(TOOL|PATH|SECTION):/);
|
|
62
106
|
|
|
63
|
-
const ralphPlan =
|
|
107
|
+
const ralphPlan = await readFile(join(out, "agents", "ralph-plan.md"), "utf-8");
|
|
64
108
|
assert.match(ralphPlan, /^---\n# GENERATED from agents\/ralph-plan\//);
|
|
65
109
|
assert.match(ralphPlan, /\nname: ralph-plan\n/);
|
|
66
110
|
});
|
|
67
111
|
|
|
68
112
|
it("copies native agents verbatim (ralph stays native, no composed counterpart)", async () => {
|
|
69
|
-
const out = makeTmp();
|
|
113
|
+
const out = await makeTmp();
|
|
70
114
|
await runCli(["install", "claude", "--output", out]);
|
|
71
|
-
const native =
|
|
72
|
-
assert.equal(
|
|
115
|
+
const native = await readFile(join(ROOT, "agents-native", "ralph.md"), "utf-8");
|
|
116
|
+
assert.equal(await readFile(join(out, "agents", "ralph.md"), "utf-8"), native);
|
|
73
117
|
});
|
|
74
118
|
|
|
75
|
-
it("
|
|
76
|
-
const out = makeTmp();
|
|
77
|
-
await runCli(["install", "claude", "--output", out]);
|
|
119
|
+
it("copies skill dirs flat with substituted tokens, no symlinks, excluding ralph-coupled skills", async () => {
|
|
120
|
+
const out = await makeTmp();
|
|
121
|
+
const { exitCode } = await runCli(["install", "claude", "--output", out]);
|
|
122
|
+
assert.equal(exitCode, 0);
|
|
123
|
+
|
|
78
124
|
const skills = join(out, "skills");
|
|
79
|
-
const names =
|
|
125
|
+
const names = await readdir(skills);
|
|
80
126
|
assert.ok(names.includes("tdd"));
|
|
81
127
|
assert.ok(names.includes("plan"));
|
|
82
128
|
assert.ok(!names.includes("ralph"));
|
|
83
129
|
assert.ok(!names.includes("run-pipeline-stage"));
|
|
84
|
-
|
|
85
|
-
|
|
130
|
+
|
|
131
|
+
// skills are real directories (copies), not symlinks
|
|
132
|
+
assert.ok((await stat(join(skills, "tdd"))).isDirectory());
|
|
133
|
+
assert.ok(await pathExists(join(skills, "tdd", "SKILL.md")));
|
|
134
|
+
assert.ok(!(await lstat(join(skills, "plan"))).isSymbolicLink());
|
|
135
|
+
|
|
136
|
+
// no symlinks anywhere in the materialised tree
|
|
137
|
+
for (const e of await snapshot(out)) {
|
|
138
|
+
assert.notEqual(e[1], "link", `unexpected symlink in tree: ${e[0]}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// tokens substituted against the claude column
|
|
142
|
+
const techDir = await readFile(join(skills, "technical-direction", "SKILL.md"), "utf-8");
|
|
143
|
+
assert.ok(techDir.includes("using Grep, Read, and Glob"), "rg/view/glob mapped to Grep/Read/Glob");
|
|
144
|
+
assert.ok(techDir.includes("via WebFetch"), "web_fetch mapped to WebFetch");
|
|
145
|
+
assert.doesNotMatch(techDir, /\{\{TOOL:/);
|
|
146
|
+
|
|
147
|
+
// null-mapped tool dropped from prose (claude has no read_agent step)
|
|
148
|
+
const design = await readFile(join(skills, "design-an-interface", "SKILL.md"), "utf-8");
|
|
149
|
+
assert.ok(design.includes("with Task,"), "task mapped to Task");
|
|
150
|
+
assert.doesNotMatch(design, /\{\{TOOL:|read_agent/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("leaves no literal tokens in any installed skill markdown", async () => {
|
|
154
|
+
const out = await makeTmp();
|
|
155
|
+
await runCli(["install", "claude", "--output", out]);
|
|
156
|
+
const files = (await listFiles(join(out, "skills"))).filter((f) => f.endsWith(".md"));
|
|
157
|
+
assert.ok(files.length > 0);
|
|
158
|
+
for (const f of files) {
|
|
159
|
+
const content = await readFile(join(out, "skills", f), "utf-8");
|
|
160
|
+
assert.doesNotMatch(content, /\{\{TOOL:|\{\{PATH:/, `no literal tokens in skill ${f}`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("copies hand-authored plugin extras (.mcp.json, scripts/) from the canonical claude-extras/ dir", async () => {
|
|
165
|
+
const out = await makeTmp();
|
|
166
|
+
await runCli(["install", "claude", "--output", out]);
|
|
167
|
+
|
|
168
|
+
const mcp = await readFile(join(ROOT, "claude-extras", ".mcp.json"), "utf-8");
|
|
169
|
+
assert.equal(await readFile(join(out, ".mcp.json"), "utf-8"), mcp);
|
|
170
|
+
|
|
171
|
+
const statusline = await readFile(join(ROOT, "claude-extras", "scripts", "statusline-command.sh"), "utf-8");
|
|
172
|
+
assert.equal(await readFile(join(out, "scripts", "statusline-command.sh"), "utf-8"), statusline);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("copies hand-authored extras from claude-extras/ with executable bits preserved", async () => {
|
|
176
|
+
// claude-extras/ is the canonical store for .mcp.json and scripts/ (the
|
|
177
|
+
// committed claude/ subtree is retired); the installer ships them into the
|
|
178
|
+
// output, keeping the executable bit, and never writes into the store.
|
|
179
|
+
const root = await makeTmp();
|
|
180
|
+
const out = join(root, "out");
|
|
181
|
+
const extras = join(root, "claude-extras");
|
|
182
|
+
await writeFile(
|
|
183
|
+
join(root, "package.json"),
|
|
184
|
+
JSON.stringify({ name: "agent-cortex", version: "1.2.3", license: "MIT" }),
|
|
185
|
+
);
|
|
186
|
+
await writeFile(
|
|
187
|
+
join(root, "token-map.json"),
|
|
188
|
+
JSON.stringify({ tools: {}, paths: { plugin_root: { claude: "PLUGIN_ROOT" } } }),
|
|
189
|
+
);
|
|
190
|
+
await mkdir(join(root, "agents"));
|
|
191
|
+
await mkdir(join(root, "skills"));
|
|
192
|
+
await mkdir(join(extras, "scripts"), { recursive: true });
|
|
193
|
+
await writeFile(join(extras, ".mcp.json"), JSON.stringify({ mcpServers: {} }, null, 2) + "\n");
|
|
194
|
+
await writeFile(join(extras, "scripts", "statusline-command.sh"), "#!/bin/sh\necho status\n");
|
|
195
|
+
await chmod(join(extras, "scripts", "statusline-command.sh"), 0o755);
|
|
196
|
+
|
|
197
|
+
const result = await installClaude({ root, output: out });
|
|
198
|
+
|
|
199
|
+
assert.deepEqual(result.handAuthored, [".mcp.json", "scripts/statusline-command.sh"]);
|
|
200
|
+
assert.equal(
|
|
201
|
+
await readFile(join(out, ".mcp.json"), "utf-8"),
|
|
202
|
+
JSON.stringify({ mcpServers: {} }, null, 2) + "\n",
|
|
203
|
+
);
|
|
204
|
+
assert.equal(
|
|
205
|
+
(await stat(join(out, "scripts", "statusline-command.sh"))).mode & 0o111,
|
|
206
|
+
0o111,
|
|
207
|
+
"executable bit survives the copy",
|
|
208
|
+
);
|
|
209
|
+
// the source store is never written to
|
|
210
|
+
assert.deepStrictEqual((await readdir(extras)).sort(), [".mcp.json", "scripts"]);
|
|
86
211
|
});
|
|
87
212
|
|
|
88
213
|
it("generates plugin.json tracking the package version and referencing hooks.json", async () => {
|
|
89
|
-
const out = makeTmp();
|
|
214
|
+
const out = await makeTmp();
|
|
90
215
|
await runCli(["install", "claude", "--output", out]);
|
|
91
|
-
const pkgVersion = JSON.parse(
|
|
92
|
-
const plugin = JSON.parse(
|
|
216
|
+
const pkgVersion = JSON.parse(await readFile(join(ROOT, "package.json"), "utf-8")).version;
|
|
217
|
+
const plugin = JSON.parse(await readFile(join(out, ".claude-plugin", "plugin.json"), "utf-8"));
|
|
93
218
|
assert.equal(plugin.name, "agent-cortex");
|
|
94
219
|
assert.equal(plugin.version, pkgVersion);
|
|
95
220
|
assert.equal(plugin.hooks, "./hooks.json");
|
|
96
221
|
});
|
|
97
222
|
|
|
98
223
|
it("generates hooks.json from the canonical hooks/claude/ source", async () => {
|
|
99
|
-
const out = makeTmp();
|
|
224
|
+
const out = await makeTmp();
|
|
100
225
|
await runCli(["install", "claude", "--output", out]);
|
|
101
|
-
const source = JSON.parse(
|
|
102
|
-
const installed = JSON.parse(
|
|
226
|
+
const source = JSON.parse(await readFile(join(ROOT, "hooks", "claude", "hooks.json"), "utf-8"));
|
|
227
|
+
const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
|
|
103
228
|
assert.deepStrictEqual(installed, source);
|
|
104
229
|
assert.ok(source.hooks.SessionStart);
|
|
105
230
|
});
|
|
@@ -108,9 +233,9 @@ describe("install claude", () => {
|
|
|
108
233
|
// The Notification matcher is an exact-string list over `notification_type` —
|
|
109
234
|
// there are no success/error types, so that matcher would never fire. Guard
|
|
110
235
|
// against it recurring (see docs/claude-hooks.md for the documented type list).
|
|
111
|
-
const out = makeTmp();
|
|
236
|
+
const out = await makeTmp();
|
|
112
237
|
await runCli(["install", "claude", "--output", out]);
|
|
113
|
-
const installed = JSON.parse(
|
|
238
|
+
const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
|
|
114
239
|
const matcher = installed.hooks.Notification.flatMap((g) => g.hooks).length
|
|
115
240
|
? installed.hooks.Notification[0].matcher
|
|
116
241
|
: "";
|
|
@@ -135,15 +260,15 @@ describe("install claude", () => {
|
|
|
135
260
|
}
|
|
136
261
|
});
|
|
137
262
|
|
|
138
|
-
it("bundles hook support scripts into
|
|
139
|
-
const out = makeTmp();
|
|
263
|
+
it("bundles hook support scripts into hooks/ and references them via the plugin root", async () => {
|
|
264
|
+
const out = await makeTmp();
|
|
140
265
|
await runCli(["install", "claude", "--output", out]);
|
|
141
266
|
|
|
142
|
-
const canonical =
|
|
143
|
-
const bundled =
|
|
267
|
+
const canonical = await readFile(join(ROOT, "hooks", "claude", "scripts", "notify.mjs"), "utf-8");
|
|
268
|
+
const bundled = await readFile(join(out, "hooks", "scripts", "notify.mjs"), "utf-8");
|
|
144
269
|
assert.equal(bundled, canonical);
|
|
145
270
|
|
|
146
|
-
const installed = JSON.parse(
|
|
271
|
+
const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
|
|
147
272
|
const notify = installed.hooks.Notification.flatMap((g) => g.hooks)
|
|
148
273
|
.filter((h) => h.command?.includes("notify.mjs"));
|
|
149
274
|
assert.ok(notify.length === 1, "hooks.json should reference the notification script");
|
|
@@ -151,29 +276,389 @@ describe("install claude", () => {
|
|
|
151
276
|
});
|
|
152
277
|
|
|
153
278
|
it("is deterministic across repeated installs into the same output", async () => {
|
|
154
|
-
const out = makeTmp();
|
|
279
|
+
const out = await makeTmp();
|
|
155
280
|
await runCli(["install", "claude", "--output", out]);
|
|
156
|
-
const first = snapshot(out);
|
|
281
|
+
const first = await snapshot(out);
|
|
157
282
|
await runCli(["install", "claude", "--output", out]);
|
|
158
|
-
assert.deepStrictEqual(snapshot(out), first);
|
|
283
|
+
assert.deepStrictEqual(await snapshot(out), first);
|
|
159
284
|
});
|
|
160
285
|
|
|
161
|
-
it("--dry-run reports the plan without writing anything", async () => {
|
|
162
|
-
const out = makeTmp();
|
|
286
|
+
it("--dry-run reports the plan (copied skills, output target) without writing anything", async () => {
|
|
287
|
+
const out = await makeTmp();
|
|
163
288
|
const { exitCode, stdout } = await runCli(["install", "claude", "--output", out, "--dry-run"]);
|
|
164
289
|
assert.equal(exitCode, 0);
|
|
165
290
|
assert.ok(/would/i.test(stdout));
|
|
166
|
-
assert.
|
|
167
|
-
assert.
|
|
168
|
-
assert.equal(
|
|
291
|
+
assert.ok(/copied/i.test(stdout), "dry-run should describe skills as copied, not symlinked");
|
|
292
|
+
assert.ok(stdout.includes(out), "dry-run should name the output target");
|
|
293
|
+
assert.equal(await pathExists(join(out, "agents")), false);
|
|
294
|
+
assert.equal(await pathExists(join(out, "skills")), false);
|
|
295
|
+
assert.equal(await pathExists(join(out, ".claude-plugin")), false);
|
|
169
296
|
});
|
|
170
297
|
|
|
171
|
-
it("leaves the
|
|
172
|
-
const out = makeTmp();
|
|
173
|
-
const
|
|
174
|
-
const
|
|
298
|
+
it("leaves the canonical sources untouched (no generated claude/ output)", async () => {
|
|
299
|
+
const out = await makeTmp();
|
|
300
|
+
const beforeExtras = (await readdir(join(ROOT, "claude-extras"))).sort();
|
|
301
|
+
const beforeExtrasScripts = (await readdir(join(ROOT, "claude-extras", "scripts"))).sort();
|
|
175
302
|
await runCli(["install", "claude", "--output", out]);
|
|
176
|
-
|
|
177
|
-
|
|
303
|
+
// the committed claude/ store carries only the settings template — the
|
|
304
|
+
// generated plugin subtree (agents/, skills/, .claude-plugin/, hooks.json)
|
|
305
|
+
// is never committed, and installs never write into the canonical sources
|
|
306
|
+
assert.deepStrictEqual((await readdir(join(ROOT, "claude"))).sort(), ["settings.json"]);
|
|
307
|
+
assert.deepStrictEqual((await readdir(join(ROOT, "claude-extras"))).sort(), beforeExtras);
|
|
308
|
+
assert.deepStrictEqual((await readdir(join(ROOT, "claude-extras", "scripts"))).sort(), beforeExtrasScripts);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("plain install materialises into ~/.agent-cortex/claude with a marketplace manifest and drives the claude plugin CLI", async () => {
|
|
312
|
+
const fakeHome = await makeTmp();
|
|
313
|
+
const fakeBin = await makeTmp();
|
|
314
|
+
const fake = await makeFakeClaude(fakeBin);
|
|
315
|
+
const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
|
|
316
|
+
|
|
317
|
+
const { exitCode, stdout } = await runCli(["install", "claude"], env);
|
|
318
|
+
assert.equal(exitCode, 0);
|
|
319
|
+
|
|
320
|
+
const pluginDir = join(fakeHome, ".agent-cortex", "claude");
|
|
321
|
+
// plugin materialised into the home install root
|
|
322
|
+
assert.ok(await pathExists(join(pluginDir, "skills", "tdd", "SKILL.md")), "skill copied into home plugin");
|
|
323
|
+
assert.ok((await stat(join(pluginDir, "skills", "tdd"))).isDirectory(), "home skills are copies, not symlinks");
|
|
324
|
+
// hand-authored extras shipped into the home plugin
|
|
325
|
+
assert.equal(
|
|
326
|
+
await readFile(join(pluginDir, ".mcp.json"), "utf-8"),
|
|
327
|
+
await readFile(join(ROOT, "claude-extras", ".mcp.json"), "utf-8"),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// marketplace manifest at the home root exposes ./claude
|
|
331
|
+
const marketPath = join(fakeHome, ".agent-cortex", ".claude-plugin", "marketplace.json");
|
|
332
|
+
const market = JSON.parse(await readFile(marketPath, "utf-8"));
|
|
333
|
+
assert.equal(market.name, "jaybeeuu");
|
|
334
|
+
assert.deepStrictEqual(market.plugins, [
|
|
335
|
+
{
|
|
336
|
+
name: "agent-cortex",
|
|
337
|
+
source: "./claude",
|
|
338
|
+
description: "Personal Claude Code plugin with custom agents and skills",
|
|
339
|
+
},
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
// registration drove the claude plugin CLI against the home install root:
|
|
343
|
+
// fresh state → marketplace add + plugin install (state queries and the
|
|
344
|
+
// availability probe are not registration actions).
|
|
345
|
+
const calls = (await readFile(fake.log, "utf-8")).trim().split("\n");
|
|
346
|
+
assert.deepEqual(registrationActions(calls), [
|
|
347
|
+
`plugin marketplace add ${join(fakeHome, ".agent-cortex")}`,
|
|
348
|
+
"plugin install agent-cortex@jaybeeuu -y",
|
|
349
|
+
]);
|
|
350
|
+
assert.ok(stdout.includes("Registering plugin"), "CLI reports registration");
|
|
351
|
+
|
|
352
|
+
// the default install also materialises ~/.claude/settings.json from the
|
|
353
|
+
// committed template, owning only the two declared keys
|
|
354
|
+
const settings = JSON.parse(await readFile(join(fakeHome, ".claude", "settings.json"), "utf-8"));
|
|
355
|
+
assert.deepEqual(Object.keys(settings).sort(), ["enabledPlugins", "extraKnownMarketplaces"]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("re-running a plain install is the update path: no marketplace re-add, no double-install", async () => {
|
|
359
|
+
const fakeHome = await makeTmp();
|
|
360
|
+
const fakeBin = await makeTmp();
|
|
361
|
+
const fake = await makeFakeClaude(fakeBin);
|
|
362
|
+
const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
|
|
363
|
+
const allCalls = async () => (await readFile(fake.log, "utf-8")).trim().split("\n").filter(Boolean);
|
|
364
|
+
|
|
365
|
+
const first = await runCli(["install", "claude"], env);
|
|
366
|
+
assert.equal(first.exitCode, 0);
|
|
367
|
+
assert.deepEqual(registrationActions(await allCalls()), [
|
|
368
|
+
`plugin marketplace add ${join(fakeHome, ".agent-cortex")}`,
|
|
369
|
+
"plugin install agent-cortex@jaybeeuu -y",
|
|
370
|
+
]);
|
|
371
|
+
const run1End = (await allCalls()).length;
|
|
372
|
+
|
|
373
|
+
// Second run: marketplace registered, plugin at the materialised version →
|
|
374
|
+
// marketplace update only — the documented no-op update path.
|
|
375
|
+
const second = await runCli(["install", "claude"], env);
|
|
376
|
+
assert.equal(second.exitCode, 0);
|
|
377
|
+
assert.deepEqual(registrationActions((await allCalls()).slice(run1End)), [
|
|
378
|
+
"plugin marketplace update jaybeeuu",
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
// Across both runs the marketplace was added exactly once and the plugin
|
|
382
|
+
// installed exactly once (idempotent — no re-add, no double-install).
|
|
383
|
+
const all = registrationActions(await allCalls());
|
|
384
|
+
assert.equal(all.filter((c) => c.startsWith("plugin marketplace add")).length, 1);
|
|
385
|
+
assert.equal(all.filter((c) => c === "plugin install agent-cortex@jaybeeuu -y").length, 1);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("warns with manual commands when the claude CLI is missing; --require-register fails the run", async () => {
|
|
389
|
+
const fakeHome = await makeTmp();
|
|
390
|
+
const env = { HOME: fakeHome, PATH: "/nonexistent" };
|
|
391
|
+
|
|
392
|
+
// Default: warn-only — the plugin is materialised, exit 0, manual
|
|
393
|
+
// registration commands printed for the user to run.
|
|
394
|
+
const warned = await runCli(["install", "claude"], env);
|
|
395
|
+
assert.equal(warned.exitCode, 0, "missing claude CLI is warn-only by default");
|
|
396
|
+
assert.match(warned.stderr, /NOT registered/);
|
|
397
|
+
assert.match(warned.stderr, /claude plugin marketplace add /);
|
|
398
|
+
assert.match(warned.stderr, /claude plugin install agent-cortex@jaybeeuu -y/);
|
|
399
|
+
// The plugin itself is still materialised into the fake home.
|
|
400
|
+
assert.ok(await pathExists(join(fakeHome, ".agent-cortex", "claude", "skills", "tdd", "SKILL.md")));
|
|
401
|
+
|
|
402
|
+
// --require-register escalates the same failure to a non-zero exit.
|
|
403
|
+
const required = await runCli(["install", "claude", "--require-register"], env);
|
|
404
|
+
assert.equal(required.exitCode, 1);
|
|
405
|
+
assert.match(required.stderr, /require-register/);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("plain --dry-run prints the home target and copied-skill plan without writing", async () => {
|
|
409
|
+
const fakeHome = await makeTmp();
|
|
410
|
+
const fakeBin = await makeTmp();
|
|
411
|
+
await makeFakeClaude(fakeBin);
|
|
412
|
+
const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
|
|
413
|
+
|
|
414
|
+
const { exitCode, stdout } = await runCli(["install", "claude", "--dry-run"], env);
|
|
415
|
+
assert.equal(exitCode, 0);
|
|
416
|
+
assert.ok(stdout.includes(join(fakeHome, ".agent-cortex", "claude")), "dry-run names the home target");
|
|
417
|
+
assert.ok(stdout.includes(join(fakeHome, ".claude", "settings.json")), "dry-run names the settings target");
|
|
418
|
+
assert.ok(/copied/i.test(stdout), "dry-run describes copied skills");
|
|
419
|
+
assert.equal(await pathExists(join(fakeHome, ".agent-cortex")), false, "nothing written in dry-run");
|
|
420
|
+
assert.equal(await pathExists(join(fakeHome, ".claude")), false, "no settings written in dry-run");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("--output stays generate-only and never touches ~/.claude/settings.json", async () => {
|
|
424
|
+
const fakeHome = await makeTmp();
|
|
425
|
+
const out = await makeTmp();
|
|
426
|
+
try {
|
|
427
|
+
const { exitCode } = await runCli(["install", "claude", "--output", out], { HOME: fakeHome });
|
|
428
|
+
assert.equal(exitCode, 0);
|
|
429
|
+
assert.ok(await pathExists(join(out, ".claude-plugin", "plugin.json")), "plugin subtree still generated");
|
|
430
|
+
assert.equal(await pathExists(join(fakeHome, ".claude")), false, "no user settings written by --output");
|
|
431
|
+
} finally {
|
|
432
|
+
await rm(fakeHome, { recursive: true, force: true });
|
|
433
|
+
await rm(out, { recursive: true, force: true });
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("rejects token-map.json versions newer than the implemented contract", async () => {
|
|
438
|
+
const fixture = await makeTmp();
|
|
439
|
+
try {
|
|
440
|
+
await mkdir(join(fixture, "agents"), { recursive: true });
|
|
441
|
+
await writeFile(
|
|
442
|
+
join(fixture, "token-map.json"),
|
|
443
|
+
JSON.stringify({ name: "fixture", version: 99, tools: {}, paths: {} }),
|
|
444
|
+
);
|
|
445
|
+
await assert.rejects(
|
|
446
|
+
installClaude({ root: fixture, output: join(fixture, "out") }),
|
|
447
|
+
/version 99/,
|
|
448
|
+
);
|
|
449
|
+
assert.equal(await pathExists(join(fixture, "out")), false, "nothing written when the contract is rejected");
|
|
450
|
+
} finally {
|
|
451
|
+
await rm(fixture, { recursive: true, force: true });
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ─── installClaude — ~/.claude/settings.json ─────────────────────────────────
|
|
457
|
+
|
|
458
|
+
/** A claude/settings.json template carrying the two CLI-owned keys. */
|
|
459
|
+
function settingsTemplate(overrides = {}) {
|
|
460
|
+
return { enabledPlugins: {}, extraKnownMarketplaces: {}, ...overrides };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** A realistic personal ~/.claude/settings.json (keys the installer must never own). */
|
|
464
|
+
function personalSettings(overrides = {}) {
|
|
465
|
+
return {
|
|
466
|
+
permissions: { allow: ["Bash(pnpm test:*)"] },
|
|
467
|
+
hooks: { SessionStart: [{ hooks: [{ type: "command", command: "echo personal" }] }] },
|
|
468
|
+
env: { PERSONAL_FLAG: "1" },
|
|
469
|
+
statusLine: { type: "command", command: "~/.claude/statusline.sh" },
|
|
470
|
+
...overrides,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Minimal package root with a claude/settings.json template, enough for
|
|
475
|
+
* installClaude to materialise the plugin subtree into a throwaway output. */
|
|
476
|
+
async function makeSettingsFixture({ template = settingsTemplate(), settings } = {}) {
|
|
477
|
+
const root = await makeTmp();
|
|
478
|
+
await writeFile(
|
|
479
|
+
join(root, "package.json"),
|
|
480
|
+
JSON.stringify({ name: "agent-cortex", version: "1.2.3", license: "MIT" }),
|
|
481
|
+
);
|
|
482
|
+
await writeFile(
|
|
483
|
+
join(root, "token-map.json"),
|
|
484
|
+
JSON.stringify({ tools: {}, paths: { plugin_root: { claude: "PLUGIN_ROOT" } } }),
|
|
485
|
+
);
|
|
486
|
+
await mkdir(join(root, "agents"));
|
|
487
|
+
await mkdir(join(root, "skills"));
|
|
488
|
+
if (template !== null) {
|
|
489
|
+
await mkdir(join(root, "claude"), { recursive: true });
|
|
490
|
+
await writeFile(join(root, "claude", "settings.json"), JSON.stringify(template, null, 2) + "\n");
|
|
491
|
+
}
|
|
492
|
+
const output = join(root, "out");
|
|
493
|
+
const settingsPath = join(root, "claude-home", "settings.json");
|
|
494
|
+
if (settings !== undefined) {
|
|
495
|
+
await mkdir(dirname(settingsPath), { recursive: true });
|
|
496
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
497
|
+
}
|
|
498
|
+
return { root, output, settingsPath, cleanup: () => rm(root, { recursive: true, force: true }) };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
describe("install claude — settings.json", () => {
|
|
502
|
+
it("materialises a real settings.json carrying the CLI-owned keys from the committed template", async () => {
|
|
503
|
+
const fx = await makeSettingsFixture({
|
|
504
|
+
template: settingsTemplate({
|
|
505
|
+
enabledPlugins: { "context7@claude-plugins-official": true },
|
|
506
|
+
extraKnownMarketplaces: {
|
|
507
|
+
"claude-plugins-official": { source: { source: "github", repo: "anthropics/claude-plugins" } },
|
|
508
|
+
},
|
|
509
|
+
}),
|
|
510
|
+
});
|
|
511
|
+
try {
|
|
512
|
+
const result = await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
|
|
513
|
+
|
|
514
|
+
assert.equal(result.settings.path, fx.settingsPath);
|
|
515
|
+
assert.equal(result.settings.action, "written");
|
|
516
|
+
assert.equal((await lstat(fx.settingsPath)).isSymbolicLink(), false, "settings.json is a real file");
|
|
517
|
+
const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
|
|
518
|
+
assert.deepEqual(settings.enabledPlugins, { "context7@claude-plugins-official": true });
|
|
519
|
+
assert.deepEqual(settings.extraKnownMarketplaces, {
|
|
520
|
+
"claude-plugins-official": { source: { source: "github", repo: "anthropics/claude-plugins" } },
|
|
521
|
+
});
|
|
522
|
+
assert.deepEqual(
|
|
523
|
+
Object.keys(settings).sort(),
|
|
524
|
+
["enabledPlugins", "extraKnownMarketplaces"],
|
|
525
|
+
"only the two CLI-owned keys are written",
|
|
526
|
+
);
|
|
527
|
+
} finally {
|
|
528
|
+
await fx.cleanup();
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("preserves personal and unknown keys on re-install (merge-keep-personal)", async () => {
|
|
533
|
+
const fx = await makeSettingsFixture({
|
|
534
|
+
template: settingsTemplate({
|
|
535
|
+
theme: { name: "dark", accent: "blue" },
|
|
536
|
+
enabledPlugins: { "team@official": true },
|
|
537
|
+
}),
|
|
538
|
+
settings: personalSettings({
|
|
539
|
+
theme: { name: "light" },
|
|
540
|
+
personalOnly: { nested: true },
|
|
541
|
+
}),
|
|
542
|
+
});
|
|
543
|
+
try {
|
|
544
|
+
await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
|
|
545
|
+
const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
|
|
546
|
+
|
|
547
|
+
assert.deepEqual(settings.permissions, { allow: ["Bash(pnpm test:*)"] });
|
|
548
|
+
assert.deepEqual(settings.hooks, { SessionStart: [{ hooks: [{ type: "command", command: "echo personal" }] }] });
|
|
549
|
+
assert.deepEqual(settings.env, { PERSONAL_FLAG: "1" });
|
|
550
|
+
assert.deepEqual(settings.statusLine, { type: "command", command: "~/.claude/statusline.sh" });
|
|
551
|
+
assert.deepEqual(settings.personalOnly, { nested: true }, "unknown key preserved");
|
|
552
|
+
assert.equal(settings.theme.name, "light", "personal value wins over the template default");
|
|
553
|
+
assert.equal(settings.theme.accent, "blue", "template default fills the unset nested key");
|
|
554
|
+
} finally {
|
|
555
|
+
await fx.cleanup();
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("owns only enabledPlugins + extraKnownMarketplaces — personal entries under them are replaced by the template", async () => {
|
|
560
|
+
const fx = await makeSettingsFixture({
|
|
561
|
+
template: settingsTemplate({
|
|
562
|
+
enabledPlugins: { "team@official": true },
|
|
563
|
+
extraKnownMarketplaces: {
|
|
564
|
+
official: { source: { source: "github", repo: "anthropics/plugins" } },
|
|
565
|
+
},
|
|
566
|
+
}),
|
|
567
|
+
settings: personalSettings({
|
|
568
|
+
enabledPlugins: { "personal@mine": true },
|
|
569
|
+
extraKnownMarketplaces: { mine: { source: { source: "github", repo: "me/mine" } } },
|
|
570
|
+
}),
|
|
571
|
+
});
|
|
572
|
+
try {
|
|
573
|
+
await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
|
|
574
|
+
const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
|
|
575
|
+
|
|
576
|
+
assert.deepEqual(settings.enabledPlugins, { "team@official": true }, "template owns enabledPlugins");
|
|
577
|
+
assert.deepEqual(
|
|
578
|
+
settings.extraKnownMarketplaces,
|
|
579
|
+
{ official: { source: { source: "github", repo: "anthropics/plugins" } } },
|
|
580
|
+
"template owns extraKnownMarketplaces",
|
|
581
|
+
);
|
|
582
|
+
assert.ok(!("personal@mine" in settings.enabledPlugins));
|
|
583
|
+
} finally {
|
|
584
|
+
await fx.cleanup();
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("dry-run plans the settings write without touching the filesystem", async () => {
|
|
589
|
+
const fx = await makeSettingsFixture({
|
|
590
|
+
template: settingsTemplate({ enabledPlugins: { "team@official": true } }),
|
|
591
|
+
});
|
|
592
|
+
try {
|
|
593
|
+
const result = await installClaude({
|
|
594
|
+
root: fx.root,
|
|
595
|
+
output: fx.output,
|
|
596
|
+
settingsPath: fx.settingsPath,
|
|
597
|
+
dryRun: true,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
assert.equal(result.settings.action, "would-write");
|
|
601
|
+
assert.equal(await pathExists(fx.settingsPath), false, "nothing written in dry-run");
|
|
602
|
+
} finally {
|
|
603
|
+
await fx.cleanup();
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("re-running is idempotent — the second install leaves settings.json unchanged", async () => {
|
|
608
|
+
const fx = await makeSettingsFixture({
|
|
609
|
+
template: settingsTemplate({ enabledPlugins: { "team@official": true } }),
|
|
610
|
+
});
|
|
611
|
+
try {
|
|
612
|
+
await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
|
|
613
|
+
const first = await readFile(fx.settingsPath, "utf-8");
|
|
614
|
+
|
|
615
|
+
const second = await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
|
|
616
|
+
assert.equal(second.settings.action, "unchanged");
|
|
617
|
+
assert.equal(await readFile(fx.settingsPath, "utf-8"), first);
|
|
618
|
+
} finally {
|
|
619
|
+
await fx.cleanup();
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("replaces a legacy symlinked settings.json with a real file, never writing through the template", async () => {
|
|
624
|
+
const fx = await makeSettingsFixture({
|
|
625
|
+
template: settingsTemplate({ enabledPlugins: { "team@official": true } }),
|
|
626
|
+
});
|
|
627
|
+
try {
|
|
628
|
+
const templatePath = join(fx.root, "claude", "settings.json");
|
|
629
|
+
const templateBefore = await readFile(templatePath, "utf-8");
|
|
630
|
+
await mkdir(dirname(fx.settingsPath), { recursive: true });
|
|
631
|
+
await symlink(templatePath, fx.settingsPath);
|
|
632
|
+
|
|
633
|
+
const result = await installClaude({ root: fx.root, output: fx.output, settingsPath: fx.settingsPath });
|
|
634
|
+
|
|
635
|
+
assert.equal(result.settings.action, "removed-symlink");
|
|
636
|
+
assert.equal(result.settings.removedSymlink, true);
|
|
637
|
+
assert.equal((await lstat(fx.settingsPath)).isSymbolicLink(), false, "symlink replaced by a real file");
|
|
638
|
+
const settings = JSON.parse(await readFile(fx.settingsPath, "utf-8"));
|
|
639
|
+
assert.deepEqual(settings.enabledPlugins, { "team@official": true });
|
|
640
|
+
assert.equal(await readFile(templatePath, "utf-8"), templateBefore, "template never written through");
|
|
641
|
+
} finally {
|
|
642
|
+
await fx.cleanup();
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("warns and skips when the package ships no claude/settings.json template", async () => {
|
|
647
|
+
const fx = await makeSettingsFixture({ template: null });
|
|
648
|
+
try {
|
|
649
|
+
const warnings = [];
|
|
650
|
+
const result = await installClaude({
|
|
651
|
+
root: fx.root,
|
|
652
|
+
output: fx.output,
|
|
653
|
+
settingsPath: fx.settingsPath,
|
|
654
|
+
warn: (m) => warnings.push(m),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
assert.equal(result.settings, null);
|
|
658
|
+
assert.ok(warnings.some((w) => w.includes("claude/settings.json")), "missing template is warned about");
|
|
659
|
+
assert.equal(await pathExists(fx.settingsPath), false, "nothing written without a template");
|
|
660
|
+
} finally {
|
|
661
|
+
await fx.cleanup();
|
|
662
|
+
}
|
|
178
663
|
});
|
|
179
664
|
});
|