@jaybeeuu/agent-cortex 2.0.0 → 2.2.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/CHANGELOG.md +437 -0
- package/README.md +46 -15
- package/agents/README.md +22 -10
- package/agents/plan/agent.md +2 -2
- package/agents/ralph/agent.md +128 -91
- package/agents/ralph/claude/frontmatter.json +3 -3
- package/agents/ralph/claude/wake.md +46 -0
- package/agents/ralph/pi/{polling.md → wake.md} +35 -20
- package/agents/ralph-plan/agent.md +23 -11
- package/bin/agent-cortex.mjs +66 -4
- package/bin/installers/claude.mjs +262 -77
- package/bin/installers/pi.mjs +200 -24
- package/extensions/agent-guardrails/README.md +1 -1
- package/extensions/agent-guardrails/guardrails.ts +1 -1
- package/extensions/agent-guardrails/index.ts +1 -1
- package/extensions/agent-modes/discover.test.ts +2 -2
- package/extensions/output-style/README.md +58 -0
- package/extensions/output-style/index.ts +23 -0
- package/extensions/output-style/output-style.md +43 -0
- package/extensions/output-style/output-style.test.ts +50 -0
- package/extensions/output-style/output-style.ts +32 -0
- package/extensions/output-style/package.json +19 -0
- package/hooks/claude/scripts/notify.mjs +8 -1
- package/lib/cli.mjs +91 -2
- package/package.json +29 -4
- package/scripts/check-changeset.mjs +4 -0
- package/scripts/check-no-sync.mjs +5 -1
- package/scripts/check-skill-frontmatter.mjs +21 -0
- package/scripts/lib/compose-agent.mjs +69 -9
- package/scripts/lib/fs.mjs +10 -2
- package/scripts/lib/migrate.mjs +736 -0
- package/scripts/lib/release-gates.mjs +21 -1
- package/scripts/lib/skill-frontmatter.mjs +152 -0
- package/scripts/lib/skill-links.mjs +52 -0
- package/scripts/lib/verify-worker-tree.mjs +25 -0
- package/scripts/resolve-publish-command.mjs +4 -0
- package/skills/engineering/design-an-interface/SKILL.md +3 -3
- package/skills/engineering/grill-with-docs/SKILL.md +3 -5
- package/skills/engineering/improve-codebase-architecture/SKILL.md +5 -4
- package/skills/engineering/tdd/SKILL.md +5 -7
- package/skills/engineering/technical-direction/SKILL.md +5 -5
- package/skills/planning/classify-bead/REFERENCE.md +3 -3
- package/skills/planning/classify-bead/SKILL.md +14 -9
- package/skills/planning/classify-bead/scripts/classify-bead.mjs +5 -4
- package/skills/planning/create-task/REFERENCE.md +52 -3
- package/skills/planning/create-task/SKILL.md +70 -26
- package/skills/planning/create-task/scripts/{create-chores.ts → create-chores.mjs} +48 -23
- package/skills/planning/flesh-out-idea/REFERENCE.md +116 -0
- package/skills/planning/flesh-out-idea/SKILL.md +146 -0
- package/skills/planning/prd-to-tasks/REFERENCE.md +54 -5
- package/skills/planning/prd-to-tasks/SKILL.md +22 -16
- package/skills/planning/record-idea/FORMAT.md +66 -17
- package/skills/planning/record-idea/SKILL.md +48 -24
- package/skills/planning/request-refactor-plan/SKILL.md +4 -4
- package/skills/planning/spec-to-tickets/SKILL.md +5 -5
- package/skills/planning/write-a-prd/SKILL.md +8 -9
- package/skills/planning/write-a-ticket/SKILL.md +4 -6
- package/skills/productivity/bd-tool/SKILL.md +15 -10
- package/skills/productivity/grill-me/SKILL.md +5 -0
- package/skills/productivity/init-beads/SKILL.md +3 -3
- package/skills/productivity/migration/REFERENCE.md +227 -0
- package/skills/productivity/migration/SKILL.md +188 -0
- package/skills/productivity/validate-agent-dir/scripts/{validate-agent-dir.ts → validate-agent-dir.mjs} +49 -28
- package/skills/productivity/write-a-skill/SKILL.md +5 -5
- package/skills/review/maintain-agent-docs/SKILL.md +5 -7
- package/skills/review/refactor-skill/SKILL.md +4 -3
- package/skills/review/review-security/SKILL.md +3 -5
- package/skills/style/style-code/SKILL.md +5 -10
- package/skills/style/style-comms/SKILL.md +5 -7
- package/skills/style/style-documentation/SKILL.md +5 -6
- package/skills/style/style-tests/SKILL.md +5 -7
- package/skills/workflow/git-workflow/SKILL.md +28 -4
- package/skills/workflow/hitl-collab/SKILL.md +4 -6
- package/skills/workflow/plan/SKILL.md +41 -30
- package/skills/workflow/ralph/REFERENCE.md +209 -55
- package/skills/workflow/ralph/SKILL.md +89 -50
- package/skills/workflow/ralph/scripts/pending-gates.mjs +134 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +72 -71
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +11 -0
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +12 -0
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +12 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +11 -0
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +13 -0
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +73 -1
- package/token-map.README.md +9 -4
- package/token-map.json +13 -6
- package/.agents/skills/style-versioning/SKILL.md +0 -172
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.github/workflows/ci.yml +0 -302
- package/.junie/guidelines.md +0 -87
- package/.junie/mcp/mcp.json +0 -10
- package/AGENTS.md +0 -151
- package/agents/ralph/claude/polling.md +0 -31
- package/agents-native/ralph.md +0 -193
- package/docs/claude-hooks.md +0 -128
- package/docs/commit-signing.md +0 -231
- package/docs/ideas/auto-model-selection.md +0 -75
- package/docs/ideas/ban-content-assertion-tests.md +0 -75
- package/docs/ideas/bead-progress-notes-for-session-resumption.md +0 -75
- package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +0 -70
- package/docs/ideas/ctx-alias-version-and-update-commands.md +0 -65
- package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +0 -67
- package/docs/ideas/dedicated-review-tests-skill.md +0 -71
- package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +0 -36
- package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +0 -99
- package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +0 -42
- package/docs/ideas/extension-manifests.md +0 -121
- package/docs/ideas/improve-pi-system-prompt.md +0 -37
- package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +0 -95
- package/docs/ideas/model-selector.md +0 -40
- package/docs/ideas/pi-task-memory.md +0 -34
- package/docs/ideas/qa-extension.md +0 -43
- package/docs/ideas/record-idea-implementation-questions.md +0 -68
- package/docs/ideas/record-ideas-as-beads.md +0 -115
- package/docs/ideas/self-flushing-session.md +0 -72
- package/docs/ideas/state-in-beads-not-md.md +0 -69
- package/docs/ideas/subagent-visibility.md +0 -72
- package/docs/ideas/task-session-context.local.md +0 -95
- package/docs/ideas/task-session-context.md +0 -89
- package/docs/ideas/test-data-creation-helpers.md +0 -92
- package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +0 -68
- package/docs/ideas/unified-ci-pipeline.md +0 -28
- package/docs/ideas/unify-the-ticket-and-task-decomposition-workflows.md +0 -71
- package/docs/ideas/worktree-housekeeping-for-closed-beads.md +0 -39
- package/docs/inspirations.md +0 -33
- package/docs/research/disable-model-invocation.md +0 -82
- package/docs/research/gas-city-research.md +0 -256
- package/docs/research/skill-design-research.md +0 -286
- package/docs/research/skill-improvements-analysis.md +0 -117
- package/docs/research/skill-patterns-research.md +0 -24
- package/docs/skills/skill-anatomy.md +0 -197
- package/docs/technical-direction/harness-config.md +0 -177
- package/docs/technical-direction/migration-execution-labels.md +0 -177
- package/docs/user-preferences.md +0 -17
- package/extensions/agent-guardrails/tsconfig.json +0 -12
- package/extensions/agent-modes/tsconfig.json +0 -12
- package/extensions/session-start/tsconfig.json +0 -12
- package/extensions/skill-stats/pnpm-lock.yaml +0 -329
- package/extensions/skill-stats/tsconfig.json +0 -12
- package/extensions/subagent/agents/planner.md +0 -37
- package/extensions/subagent/agents/reviewer.md +0 -35
- package/extensions/subagent/agents/scout.md +0 -50
- package/extensions/subagent/agents/worker.md +0 -24
- package/extensions/subagent/prompts/implement-and-review.md +0 -10
- package/extensions/subagent/prompts/implement.md +0 -10
- package/extensions/subagent/prompts/scout-and-plan.md +0 -9
- package/extensions/subagent/tsconfig.json +0 -12
- package/pi/keybindings.json +0 -3
- package/pi/settings.json +0 -34
- package/pnpm-workspace.yaml +0 -14
- package/skills/planning/create-task/scripts/package.json +0 -14
- package/skills/planning/create-task/scripts/pnpm-lock.yaml +0 -342
- package/skills/planning/create-task/scripts/tsconfig.json +0 -12
- package/skills/planning/record-idea/scripts/new-idea.sh +0 -89
- package/skills/productivity/validate-agent-dir/scripts/package.json +0 -15
- package/skills/productivity/validate-agent-dir/scripts/pnpm-lock.yaml +0 -329
- package/skills/productivity/validate-agent-dir/scripts/pnpm-workspace.yaml +0 -2
- package/skills/productivity/validate-agent-dir/scripts/tsconfig.json +0 -19
- package/skills/productivity/validate-agent-dir/scripts/validate-agent-dir.test.ts +0 -317
- package/skills/workflow/run-pipeline-stage/REFERENCE.md +0 -22
- package/skills/workflow/run-pipeline-stage/scripts/generate-progress.test.ts +0 -682
- package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +0 -588
- package/skills/workflow/run-pipeline-stage/scripts/package.json +0 -15
- package/skills/workflow/run-pipeline-stage/scripts/pnpm-lock.yaml +0 -342
- package/skills/workflow/run-pipeline-stage/scripts/pnpm-workspace.yaml +0 -2
- package/skills/workflow/run-pipeline-stage/scripts/tsconfig.json +0 -12
- package/test/classify-bead.test.mjs +0 -277
- package/test/claude-installer.test.mjs +0 -308
- package/test/cli.test.mjs +0 -314
- package/test/create-chores.test.mjs +0 -85
- package/test/fake-bd.test.mjs +0 -72
- package/test/fs.test.mjs +0 -76
- package/test/helpers/fake-bd.mjs +0 -87
- package/test/helpers/fake-claude.mjs +0 -152
- package/test/helpers/fake-pi.mjs +0 -132
- package/test/installer.test.mjs +0 -664
- package/test/notify-hook.test.mjs +0 -63
- package/test/pi-installer.test.mjs +0 -785
- package/test/pi-register.test.mjs +0 -342
- package/test/pi-sweep.test.mjs +0 -619
- package/test/release-gates.test.mjs +0 -190
- package/test/verify-worker-tree.test.mjs +0 -120
package/test/installer.test.mjs
DELETED
|
@@ -1,664 +0,0 @@
|
|
|
1
|
-
// Integration tests for `agent-cortex install claude`: the install-time
|
|
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.
|
|
6
|
-
|
|
7
|
-
import { describe, it } from "node:test";
|
|
8
|
-
import assert from "node:assert/strict";
|
|
9
|
-
import { execFile } from "node:child_process";
|
|
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";
|
|
23
|
-
import { tmpdir } from "node:os";
|
|
24
|
-
import { fileURLToPath } from "node:url";
|
|
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";
|
|
28
|
-
|
|
29
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
-
const ROOT = join(__dirname, "..");
|
|
31
|
-
const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
|
|
32
|
-
|
|
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 = {}) {
|
|
45
|
-
return new Promise((resolve) => {
|
|
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
|
-
);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function makeTmp() {
|
|
58
|
-
return await mkdtemp(join(tmpdir(), "agent-cortex-installer-"));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
/** Deterministic snapshot of a generated tree: kind, and for files/symlinks
|
|
63
|
-
* their byte content / link target. */
|
|
64
|
-
async function snapshot(dir) {
|
|
65
|
-
const entries = [];
|
|
66
|
-
const walk = async (p) => {
|
|
67
|
-
for (const e of (await readdir(p, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
68
|
-
const full = join(p, e.name);
|
|
69
|
-
const rel = relative(dir, full);
|
|
70
|
-
if (e.isSymbolicLink()) entries.push([rel, "link", await readlink(full)]);
|
|
71
|
-
else if (e.isDirectory()) {
|
|
72
|
-
entries.push([rel, "dir"]);
|
|
73
|
-
await walk(full);
|
|
74
|
-
} else entries.push([rel, "file", await readFile(full, "utf-8")]);
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
await walk(dir);
|
|
78
|
-
return entries;
|
|
79
|
-
}
|
|
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
|
-
|
|
92
|
-
describe("install claude", () => {
|
|
93
|
-
it("composes agents from the composable sources (claude names, no tokens)", async () => {
|
|
94
|
-
const out = await makeTmp();
|
|
95
|
-
const { exitCode } = await runCli(["install", "claude", "--output", out]);
|
|
96
|
-
assert.equal(exitCode, 0);
|
|
97
|
-
|
|
98
|
-
const plan = await readFile(join(out, "agents", "plan.md"), "utf-8");
|
|
99
|
-
assert.match(plan, /^---\n# GENERATED from agents\/plan\//);
|
|
100
|
-
// provenance names the shared installer (no build-time script), so the
|
|
101
|
-
// committed subtree can only come from the install-time generator.
|
|
102
|
-
assert.match(plan, /bin\/installers\/claude\.mjs/);
|
|
103
|
-
assert.doesNotMatch(plan, /build-claude-agents/);
|
|
104
|
-
assert.match(plan, /\nname: plan\n/);
|
|
105
|
-
assert.doesNotMatch(plan, /\{\{(TOOL|PATH|SECTION):/);
|
|
106
|
-
|
|
107
|
-
const ralphPlan = await readFile(join(out, "agents", "ralph-plan.md"), "utf-8");
|
|
108
|
-
assert.match(ralphPlan, /^---\n# GENERATED from agents\/ralph-plan\//);
|
|
109
|
-
assert.match(ralphPlan, /\nname: ralph-plan\n/);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("copies native agents verbatim (ralph stays native, no composed counterpart)", async () => {
|
|
113
|
-
const out = await makeTmp();
|
|
114
|
-
await runCli(["install", "claude", "--output", out]);
|
|
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);
|
|
117
|
-
});
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
const skills = join(out, "skills");
|
|
125
|
-
const names = await readdir(skills);
|
|
126
|
-
assert.ok(names.includes("tdd"));
|
|
127
|
-
assert.ok(names.includes("plan"));
|
|
128
|
-
assert.ok(!names.includes("ralph"));
|
|
129
|
-
assert.ok(!names.includes("run-pipeline-stage"));
|
|
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"]);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("generates plugin.json tracking the package version and referencing hooks.json", async () => {
|
|
214
|
-
const out = await makeTmp();
|
|
215
|
-
await runCli(["install", "claude", "--output", out]);
|
|
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"));
|
|
218
|
-
assert.equal(plugin.name, "agent-cortex");
|
|
219
|
-
assert.equal(plugin.version, pkgVersion);
|
|
220
|
-
assert.equal(plugin.hooks, "./hooks.json");
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("generates hooks.json from the canonical hooks/claude/ source", async () => {
|
|
224
|
-
const out = await makeTmp();
|
|
225
|
-
await runCli(["install", "claude", "--output", out]);
|
|
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"));
|
|
228
|
-
assert.deepStrictEqual(installed, source);
|
|
229
|
-
assert.ok(source.hooks.SessionStart);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("matches the Notification hook only on documented notification types", async () => {
|
|
233
|
-
// The Notification matcher is an exact-string list over `notification_type` —
|
|
234
|
-
// there are no success/error types, so that matcher would never fire. Guard
|
|
235
|
-
// against it recurring (see docs/claude-hooks.md for the documented type list).
|
|
236
|
-
const out = await makeTmp();
|
|
237
|
-
await runCli(["install", "claude", "--output", out]);
|
|
238
|
-
const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
|
|
239
|
-
const matcher = installed.hooks.Notification.flatMap((g) => g.hooks).length
|
|
240
|
-
? installed.hooks.Notification[0].matcher
|
|
241
|
-
: "";
|
|
242
|
-
const documented = new Set([
|
|
243
|
-
"permission_prompt",
|
|
244
|
-
"idle_prompt",
|
|
245
|
-
"auth_success",
|
|
246
|
-
"elicitation_dialog",
|
|
247
|
-
"elicitation_url_dialog",
|
|
248
|
-
"elicitation_complete",
|
|
249
|
-
"elicitation_response",
|
|
250
|
-
"agent_needs_input",
|
|
251
|
-
"agent_completed",
|
|
252
|
-
"quota_auto_resume_fired",
|
|
253
|
-
"quota_auto_resume_stale",
|
|
254
|
-
"quota_auto_resume_disabled",
|
|
255
|
-
]);
|
|
256
|
-
const types = matcher ? matcher.split("|") : [];
|
|
257
|
-
assert.ok(types.length > 0, "Notification hook should list at least one matcher type");
|
|
258
|
-
for (const t of types) {
|
|
259
|
-
assert.ok(documented.has(t), `Notification matcher references unknown type "${t}"`);
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("bundles hook support scripts into hooks/ and references them via the plugin root", async () => {
|
|
264
|
-
const out = await makeTmp();
|
|
265
|
-
await runCli(["install", "claude", "--output", out]);
|
|
266
|
-
|
|
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");
|
|
269
|
-
assert.equal(bundled, canonical);
|
|
270
|
-
|
|
271
|
-
const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
|
|
272
|
-
const notify = installed.hooks.Notification.flatMap((g) => g.hooks)
|
|
273
|
-
.filter((h) => h.command?.includes("notify.mjs"));
|
|
274
|
-
assert.ok(notify.length === 1, "hooks.json should reference the notification script");
|
|
275
|
-
assert.match(notify[0].command, /^node "\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/scripts\/notify\.mjs"$/);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it("is deterministic across repeated installs into the same output", async () => {
|
|
279
|
-
const out = await makeTmp();
|
|
280
|
-
await runCli(["install", "claude", "--output", out]);
|
|
281
|
-
const first = await snapshot(out);
|
|
282
|
-
await runCli(["install", "claude", "--output", out]);
|
|
283
|
-
assert.deepStrictEqual(await snapshot(out), first);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it("--dry-run reports the plan (copied skills, output target) without writing anything", async () => {
|
|
287
|
-
const out = await makeTmp();
|
|
288
|
-
const { exitCode, stdout } = await runCli(["install", "claude", "--output", out, "--dry-run"]);
|
|
289
|
-
assert.equal(exitCode, 0);
|
|
290
|
-
assert.ok(/would/i.test(stdout));
|
|
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);
|
|
296
|
-
});
|
|
297
|
-
|
|
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();
|
|
302
|
-
await runCli(["install", "claude", "--output", out]);
|
|
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
|
-
}
|
|
663
|
-
});
|
|
664
|
-
});
|