@jaybeeuu/agent-cortex 1.38.1 → 1.40.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 +19 -3
- package/CHANGELOG.md +42 -0
- package/README.md +118 -63
- package/agents/README.md +13 -10
- package/agents/ralph-plan/agent.md +1 -1
- package/agents/ralph-plan.agent.md +1 -1
- package/bin/agent-cortex.mjs +29 -8
- package/bin/installers/claude.mjs +569 -105
- package/bin/installers/copilot.mjs +22 -12
- package/bin/installers/pi.mjs +37 -34
- package/docs/claude-hooks.md +3 -3
- package/docs/ideas/extension-manifests.md +75 -0
- package/docs/ideas/record-ideas-as-beads.md +115 -0
- package/docs/ideas/subagent-visibility.md +72 -0
- package/docs/ideas/task-session-context.md +89 -0
- package/docs/research/skill-design-research.md +4 -4
- package/docs/research/skill-improvements-analysis.md +1 -1
- package/docs/skills/skill-anatomy.md +38 -2
- package/docs/technical-direction/migration-execution-labels.md +132 -0
- package/extensions/agent-modes/README.md +3 -2
- package/extensions/agent-modes/discover.test.ts +30 -7
- package/extensions/agent-modes/discover.ts +1 -1
- package/extensions/skill-stats/index.test.ts +3 -2
- package/lib/cli.mjs +24 -6
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/pnpm-workspace.yaml +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/skills/engineering/design-an-interface/SKILL.md +78 -58
- package/skills/engineering/grill-with-docs/REFERENCE.md +78 -0
- package/skills/engineering/grill-with-docs/SKILL.md +66 -63
- package/skills/engineering/improve-codebase-architecture/SKILL.md +66 -48
- package/skills/engineering/technical-direction/FORMAT.md +38 -0
- package/skills/engineering/technical-direction/SKILL.md +70 -77
- package/skills/planning/classify-bead/SKILL.md +59 -35
- package/skills/planning/create-task/REFERENCE.md +40 -0
- package/skills/planning/create-task/SKILL.md +70 -66
- package/skills/planning/create-task/scripts/create-chores.ts +6 -1
- package/skills/planning/create-task/scripts/node_modules/.bin/tsc +21 -0
- package/skills/planning/create-task/scripts/node_modules/.bin/tsserver +21 -0
- package/skills/planning/create-task/scripts/node_modules/.bin/tsx +21 -0
- package/skills/planning/prd-to-tasks/REFERENCE.md +66 -0
- package/skills/planning/prd-to-tasks/SKILL.md +86 -170
- package/skills/planning/record-idea/FORMAT.md +44 -0
- package/skills/planning/record-idea/SKILL.md +70 -53
- package/skills/planning/request-refactor-plan/FORMAT.md +45 -0
- package/skills/planning/request-refactor-plan/SKILL.md +57 -46
- package/skills/planning/write-a-prd/FORMAT.md +54 -0
- package/skills/planning/write-a-prd/SKILL.md +49 -48
- package/skills/planning/write-a-ticket/SKILL.md +68 -53
- package/skills/productivity/handoff/SKILL.md +12 -0
- package/skills/productivity/init-beads/SKILL.md +48 -40
- package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsc +21 -0
- package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsserver +21 -0
- package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsx +21 -0
- package/skills/productivity/write-a-skill/SKILL.md +2 -1
- package/skills/review/maintain-agent-docs/SKILL.md +71 -51
- package/skills/workflow/hitl-collab/SKILL.md +86 -17
- package/skills/workflow/ralph/REFERENCE.md +22 -17
- package/skills/workflow/ralph/SKILL.md +88 -74
- package/skills/workflow/run-pipeline-stage/REFERENCE.md +22 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +91 -93
- package/skills/workflow/run-pipeline-stage/scripts/generate-progress.test.ts +6 -107
- package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsc +21 -0
- package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsserver +21 -0
- package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsx +21 -0
- package/test/claude-installer.test.mjs +308 -0
- package/test/cli.test.mjs +40 -30
- package/test/copilot-installer.test.mjs +92 -82
- package/test/helpers/fake-claude.mjs +152 -0
- package/test/installer.test.mjs +309 -57
- package/test/pi-installer.test.mjs +115 -99
- package/token-map.README.md +21 -10
- package/token-map.json +7 -6
- 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/{claude → claude-extras}/scripts/statusline-command.sh +0 -0
package/test/installer.test.mjs
CHANGED
|
@@ -1,57 +1,100 @@
|
|
|
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
|
+
} from "node:fs/promises";
|
|
9
22
|
import { tmpdir } from "node:os";
|
|
10
23
|
import { fileURLToPath } from "node:url";
|
|
11
24
|
import { dirname, join, relative } from "node:path";
|
|
25
|
+
import { installClaude } from "../bin/installers/claude.mjs";
|
|
26
|
+
import { makeFakeClaude, registrationActions } from "./helpers/fake-claude.mjs";
|
|
12
27
|
|
|
13
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
29
|
const ROOT = join(__dirname, "..");
|
|
15
30
|
const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
|
|
16
31
|
|
|
17
|
-
|
|
32
|
+
/** True when a path exists (stat succeeds). */
|
|
33
|
+
async function pathExists(p) {
|
|
34
|
+
try {
|
|
35
|
+
await stat(p);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Run the CLI; optionally pass a custom env (e.g. a fake HOME). */
|
|
43
|
+
function runCli(args, env = {}) {
|
|
18
44
|
return new Promise((resolve) => {
|
|
19
|
-
execFile(
|
|
20
|
-
|
|
21
|
-
|
|
45
|
+
execFile(
|
|
46
|
+
process.execPath,
|
|
47
|
+
[CLI_PATH, ...args],
|
|
48
|
+
{ env: { ...process.env, ...env } },
|
|
49
|
+
(error, stdout, stderr) => {
|
|
50
|
+
resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
|
|
51
|
+
},
|
|
52
|
+
);
|
|
22
53
|
});
|
|
23
54
|
}
|
|
24
55
|
|
|
25
|
-
function makeTmp() {
|
|
26
|
-
return
|
|
56
|
+
async function makeTmp() {
|
|
57
|
+
return await mkdtemp(join(tmpdir(), "agent-cortex-installer-"));
|
|
27
58
|
}
|
|
28
59
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
60
|
+
|
|
61
|
+
/** Deterministic snapshot of a generated tree: kind, and for files/symlinks
|
|
62
|
+
* their byte content / link target. */
|
|
63
|
+
async function snapshot(dir) {
|
|
32
64
|
const entries = [];
|
|
33
|
-
const walk = (p) => {
|
|
34
|
-
for (const e of
|
|
65
|
+
const walk = async (p) => {
|
|
66
|
+
for (const e of (await readdir(p, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
35
67
|
const full = join(p, e.name);
|
|
36
68
|
const rel = relative(dir, full);
|
|
37
|
-
if (e.isSymbolicLink()) entries.push([rel, "link",
|
|
69
|
+
if (e.isSymbolicLink()) entries.push([rel, "link", await readlink(full)]);
|
|
38
70
|
else if (e.isDirectory()) {
|
|
39
71
|
entries.push([rel, "dir"]);
|
|
40
|
-
walk(full);
|
|
41
|
-
} else entries.push([rel, "file",
|
|
72
|
+
await walk(full);
|
|
73
|
+
} else entries.push([rel, "file", await readFile(full, "utf-8")]);
|
|
42
74
|
}
|
|
43
75
|
};
|
|
44
|
-
walk(dir);
|
|
76
|
+
await walk(dir);
|
|
45
77
|
return entries;
|
|
46
78
|
}
|
|
47
79
|
|
|
80
|
+
async function listFiles(dir) {
|
|
81
|
+
if (!(await pathExists(dir))) return [];
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
84
|
+
const p = join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) out.push(...(await listFiles(p)).map((f) => join(entry.name, f)));
|
|
86
|
+
else out.push(entry.name);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
48
91
|
describe("install claude", () => {
|
|
49
92
|
it("composes agents from the composable sources (claude names, no tokens)", async () => {
|
|
50
|
-
const out = makeTmp();
|
|
93
|
+
const out = await makeTmp();
|
|
51
94
|
const { exitCode } = await runCli(["install", "claude", "--output", out]);
|
|
52
95
|
assert.equal(exitCode, 0);
|
|
53
96
|
|
|
54
|
-
const plan =
|
|
97
|
+
const plan = await readFile(join(out, "agents", "plan.md"), "utf-8");
|
|
55
98
|
assert.match(plan, /^---\n# GENERATED from agents\/plan\//);
|
|
56
99
|
// provenance names the shared installer (no build-time script), so the
|
|
57
100
|
// committed subtree can only come from the install-time generator.
|
|
@@ -60,46 +103,127 @@ describe("install claude", () => {
|
|
|
60
103
|
assert.match(plan, /\nname: plan\n/);
|
|
61
104
|
assert.doesNotMatch(plan, /\{\{(TOOL|PATH|SECTION):/);
|
|
62
105
|
|
|
63
|
-
const ralphPlan =
|
|
106
|
+
const ralphPlan = await readFile(join(out, "agents", "ralph-plan.md"), "utf-8");
|
|
64
107
|
assert.match(ralphPlan, /^---\n# GENERATED from agents\/ralph-plan\//);
|
|
65
108
|
assert.match(ralphPlan, /\nname: ralph-plan\n/);
|
|
66
109
|
});
|
|
67
110
|
|
|
68
111
|
it("copies native agents verbatim (ralph stays native, no composed counterpart)", async () => {
|
|
69
|
-
const out = makeTmp();
|
|
112
|
+
const out = await makeTmp();
|
|
70
113
|
await runCli(["install", "claude", "--output", out]);
|
|
71
|
-
const native =
|
|
72
|
-
assert.equal(
|
|
114
|
+
const native = await readFile(join(ROOT, "agents-native", "ralph.md"), "utf-8");
|
|
115
|
+
assert.equal(await readFile(join(out, "agents", "ralph.md"), "utf-8"), native);
|
|
73
116
|
});
|
|
74
117
|
|
|
75
|
-
it("
|
|
76
|
-
const out = makeTmp();
|
|
77
|
-
await runCli(["install", "claude", "--output", out]);
|
|
118
|
+
it("copies skill dirs flat with substituted tokens, no symlinks, excluding ralph-coupled skills", async () => {
|
|
119
|
+
const out = await makeTmp();
|
|
120
|
+
const { exitCode } = await runCli(["install", "claude", "--output", out]);
|
|
121
|
+
assert.equal(exitCode, 0);
|
|
122
|
+
|
|
78
123
|
const skills = join(out, "skills");
|
|
79
|
-
const names =
|
|
124
|
+
const names = await readdir(skills);
|
|
80
125
|
assert.ok(names.includes("tdd"));
|
|
81
126
|
assert.ok(names.includes("plan"));
|
|
82
127
|
assert.ok(!names.includes("ralph"));
|
|
83
128
|
assert.ok(!names.includes("run-pipeline-stage"));
|
|
84
|
-
|
|
85
|
-
|
|
129
|
+
|
|
130
|
+
// skills are real directories (copies), not symlinks
|
|
131
|
+
assert.ok((await stat(join(skills, "tdd"))).isDirectory());
|
|
132
|
+
assert.ok(await pathExists(join(skills, "tdd", "SKILL.md")));
|
|
133
|
+
assert.ok(!(await lstat(join(skills, "plan"))).isSymbolicLink());
|
|
134
|
+
|
|
135
|
+
// no symlinks anywhere in the materialised tree
|
|
136
|
+
for (const e of await snapshot(out)) {
|
|
137
|
+
assert.notEqual(e[1], "link", `unexpected symlink in tree: ${e[0]}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// tokens substituted against the claude column
|
|
141
|
+
const techDir = await readFile(join(skills, "technical-direction", "SKILL.md"), "utf-8");
|
|
142
|
+
assert.ok(techDir.includes("using Grep, Read, and Glob"), "rg/view/glob mapped to Grep/Read/Glob");
|
|
143
|
+
assert.ok(techDir.includes("via WebFetch"), "web_fetch mapped to WebFetch");
|
|
144
|
+
assert.doesNotMatch(techDir, /\{\{TOOL:/);
|
|
145
|
+
|
|
146
|
+
// null-mapped tool dropped from prose (claude has no read_agent step)
|
|
147
|
+
const design = await readFile(join(skills, "design-an-interface", "SKILL.md"), "utf-8");
|
|
148
|
+
assert.ok(design.includes("with Task,"), "task mapped to Task");
|
|
149
|
+
assert.doesNotMatch(design, /\{\{TOOL:|read_agent/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("leaves no literal tokens in any installed skill markdown", async () => {
|
|
153
|
+
const out = await makeTmp();
|
|
154
|
+
await runCli(["install", "claude", "--output", out]);
|
|
155
|
+
const files = (await listFiles(join(out, "skills"))).filter((f) => f.endsWith(".md"));
|
|
156
|
+
assert.ok(files.length > 0);
|
|
157
|
+
for (const f of files) {
|
|
158
|
+
const content = await readFile(join(out, "skills", f), "utf-8");
|
|
159
|
+
assert.doesNotMatch(content, /\{\{TOOL:|\{\{PATH:/, `no literal tokens in skill ${f}`);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("copies hand-authored plugin extras (.mcp.json, scripts/) from the canonical claude-extras/ dir", async () => {
|
|
164
|
+
const out = await makeTmp();
|
|
165
|
+
await runCli(["install", "claude", "--output", out]);
|
|
166
|
+
|
|
167
|
+
const mcp = await readFile(join(ROOT, "claude-extras", ".mcp.json"), "utf-8");
|
|
168
|
+
assert.equal(await readFile(join(out, ".mcp.json"), "utf-8"), mcp);
|
|
169
|
+
|
|
170
|
+
const statusline = await readFile(join(ROOT, "claude-extras", "scripts", "statusline-command.sh"), "utf-8");
|
|
171
|
+
assert.equal(await readFile(join(out, "scripts", "statusline-command.sh"), "utf-8"), statusline);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("copies hand-authored extras from claude-extras/ with executable bits preserved", async () => {
|
|
175
|
+
// claude-extras/ is the canonical store for .mcp.json and scripts/ (the
|
|
176
|
+
// committed claude/ subtree is retired); the installer ships them into the
|
|
177
|
+
// output, keeping the executable bit, and never writes into the store.
|
|
178
|
+
const root = await makeTmp();
|
|
179
|
+
const out = join(root, "out");
|
|
180
|
+
const extras = join(root, "claude-extras");
|
|
181
|
+
await writeFile(
|
|
182
|
+
join(root, "package.json"),
|
|
183
|
+
JSON.stringify({ name: "agent-cortex", version: "1.2.3", license: "MIT" }),
|
|
184
|
+
);
|
|
185
|
+
await writeFile(
|
|
186
|
+
join(root, "token-map.json"),
|
|
187
|
+
JSON.stringify({ tools: {}, paths: { plugin_root: { claude: "PLUGIN_ROOT" } } }),
|
|
188
|
+
);
|
|
189
|
+
await mkdir(join(root, "agents"));
|
|
190
|
+
await mkdir(join(root, "skills"));
|
|
191
|
+
await mkdir(join(extras, "scripts"), { recursive: true });
|
|
192
|
+
await writeFile(join(extras, ".mcp.json"), JSON.stringify({ mcpServers: {} }, null, 2) + "\n");
|
|
193
|
+
await writeFile(join(extras, "scripts", "statusline-command.sh"), "#!/bin/sh\necho status\n");
|
|
194
|
+
await chmod(join(extras, "scripts", "statusline-command.sh"), 0o755);
|
|
195
|
+
|
|
196
|
+
const result = await installClaude({ root, output: out });
|
|
197
|
+
|
|
198
|
+
assert.deepEqual(result.handAuthored, [".mcp.json", "scripts/statusline-command.sh"]);
|
|
199
|
+
assert.equal(
|
|
200
|
+
await readFile(join(out, ".mcp.json"), "utf-8"),
|
|
201
|
+
JSON.stringify({ mcpServers: {} }, null, 2) + "\n",
|
|
202
|
+
);
|
|
203
|
+
assert.equal(
|
|
204
|
+
(await stat(join(out, "scripts", "statusline-command.sh"))).mode & 0o111,
|
|
205
|
+
0o111,
|
|
206
|
+
"executable bit survives the copy",
|
|
207
|
+
);
|
|
208
|
+
// the source store is never written to
|
|
209
|
+
assert.deepStrictEqual((await readdir(extras)).sort(), [".mcp.json", "scripts"]);
|
|
86
210
|
});
|
|
87
211
|
|
|
88
212
|
it("generates plugin.json tracking the package version and referencing hooks.json", async () => {
|
|
89
|
-
const out = makeTmp();
|
|
213
|
+
const out = await makeTmp();
|
|
90
214
|
await runCli(["install", "claude", "--output", out]);
|
|
91
|
-
const pkgVersion = JSON.parse(
|
|
92
|
-
const plugin = JSON.parse(
|
|
215
|
+
const pkgVersion = JSON.parse(await readFile(join(ROOT, "package.json"), "utf-8")).version;
|
|
216
|
+
const plugin = JSON.parse(await readFile(join(out, ".claude-plugin", "plugin.json"), "utf-8"));
|
|
93
217
|
assert.equal(plugin.name, "agent-cortex");
|
|
94
218
|
assert.equal(plugin.version, pkgVersion);
|
|
95
219
|
assert.equal(plugin.hooks, "./hooks.json");
|
|
96
220
|
});
|
|
97
221
|
|
|
98
222
|
it("generates hooks.json from the canonical hooks/claude/ source", async () => {
|
|
99
|
-
const out = makeTmp();
|
|
223
|
+
const out = await makeTmp();
|
|
100
224
|
await runCli(["install", "claude", "--output", out]);
|
|
101
|
-
const source = JSON.parse(
|
|
102
|
-
const installed = JSON.parse(
|
|
225
|
+
const source = JSON.parse(await readFile(join(ROOT, "hooks", "claude", "hooks.json"), "utf-8"));
|
|
226
|
+
const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
|
|
103
227
|
assert.deepStrictEqual(installed, source);
|
|
104
228
|
assert.ok(source.hooks.SessionStart);
|
|
105
229
|
});
|
|
@@ -108,9 +232,9 @@ describe("install claude", () => {
|
|
|
108
232
|
// The Notification matcher is an exact-string list over `notification_type` —
|
|
109
233
|
// there are no success/error types, so that matcher would never fire. Guard
|
|
110
234
|
// against it recurring (see docs/claude-hooks.md for the documented type list).
|
|
111
|
-
const out = makeTmp();
|
|
235
|
+
const out = await makeTmp();
|
|
112
236
|
await runCli(["install", "claude", "--output", out]);
|
|
113
|
-
const installed = JSON.parse(
|
|
237
|
+
const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
|
|
114
238
|
const matcher = installed.hooks.Notification.flatMap((g) => g.hooks).length
|
|
115
239
|
? installed.hooks.Notification[0].matcher
|
|
116
240
|
: "";
|
|
@@ -135,15 +259,15 @@ describe("install claude", () => {
|
|
|
135
259
|
}
|
|
136
260
|
});
|
|
137
261
|
|
|
138
|
-
it("bundles hook support scripts into
|
|
139
|
-
const out = makeTmp();
|
|
262
|
+
it("bundles hook support scripts into hooks/ and references them via the plugin root", async () => {
|
|
263
|
+
const out = await makeTmp();
|
|
140
264
|
await runCli(["install", "claude", "--output", out]);
|
|
141
265
|
|
|
142
|
-
const canonical =
|
|
143
|
-
const bundled =
|
|
266
|
+
const canonical = await readFile(join(ROOT, "hooks", "claude", "scripts", "notify.mjs"), "utf-8");
|
|
267
|
+
const bundled = await readFile(join(out, "hooks", "scripts", "notify.mjs"), "utf-8");
|
|
144
268
|
assert.equal(bundled, canonical);
|
|
145
269
|
|
|
146
|
-
const installed = JSON.parse(
|
|
270
|
+
const installed = JSON.parse(await readFile(join(out, "hooks.json"), "utf-8"));
|
|
147
271
|
const notify = installed.hooks.Notification.flatMap((g) => g.hooks)
|
|
148
272
|
.filter((h) => h.command?.includes("notify.mjs"));
|
|
149
273
|
assert.ok(notify.length === 1, "hooks.json should reference the notification script");
|
|
@@ -151,29 +275,157 @@ describe("install claude", () => {
|
|
|
151
275
|
});
|
|
152
276
|
|
|
153
277
|
it("is deterministic across repeated installs into the same output", async () => {
|
|
154
|
-
const out = makeTmp();
|
|
278
|
+
const out = await makeTmp();
|
|
155
279
|
await runCli(["install", "claude", "--output", out]);
|
|
156
|
-
const first = snapshot(out);
|
|
280
|
+
const first = await snapshot(out);
|
|
157
281
|
await runCli(["install", "claude", "--output", out]);
|
|
158
|
-
assert.deepStrictEqual(snapshot(out), first);
|
|
282
|
+
assert.deepStrictEqual(await snapshot(out), first);
|
|
159
283
|
});
|
|
160
284
|
|
|
161
|
-
it("--dry-run reports the plan without writing anything", async () => {
|
|
162
|
-
const out = makeTmp();
|
|
285
|
+
it("--dry-run reports the plan (copied skills, output target) without writing anything", async () => {
|
|
286
|
+
const out = await makeTmp();
|
|
163
287
|
const { exitCode, stdout } = await runCli(["install", "claude", "--output", out, "--dry-run"]);
|
|
164
288
|
assert.equal(exitCode, 0);
|
|
165
289
|
assert.ok(/would/i.test(stdout));
|
|
166
|
-
assert.
|
|
167
|
-
assert.
|
|
168
|
-
assert.equal(
|
|
290
|
+
assert.ok(/copied/i.test(stdout), "dry-run should describe skills as copied, not symlinked");
|
|
291
|
+
assert.ok(stdout.includes(out), "dry-run should name the output target");
|
|
292
|
+
assert.equal(await pathExists(join(out, "agents")), false);
|
|
293
|
+
assert.equal(await pathExists(join(out, "skills")), false);
|
|
294
|
+
assert.equal(await pathExists(join(out, ".claude-plugin")), false);
|
|
169
295
|
});
|
|
170
296
|
|
|
171
|
-
it("leaves the
|
|
172
|
-
const out = makeTmp();
|
|
173
|
-
const
|
|
174
|
-
const
|
|
297
|
+
it("leaves the canonical sources untouched (no committed claude/ output)", async () => {
|
|
298
|
+
const out = await makeTmp();
|
|
299
|
+
const beforeExtras = (await readdir(join(ROOT, "claude-extras"))).sort();
|
|
300
|
+
const beforeExtrasScripts = (await readdir(join(ROOT, "claude-extras", "scripts"))).sort();
|
|
175
301
|
await runCli(["install", "claude", "--output", out]);
|
|
176
|
-
|
|
177
|
-
|
|
302
|
+
// no committed claude/ output remains, and installs never write into the
|
|
303
|
+
// canonical sources the plugin materialises from
|
|
304
|
+
assert.equal(await pathExists(join(ROOT, "claude")), false);
|
|
305
|
+
assert.deepStrictEqual((await readdir(join(ROOT, "claude-extras"))).sort(), beforeExtras);
|
|
306
|
+
assert.deepStrictEqual((await readdir(join(ROOT, "claude-extras", "scripts"))).sort(), beforeExtrasScripts);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("plain install materialises into ~/.agent-cortex/claude with a marketplace manifest and drives the claude plugin CLI", async () => {
|
|
310
|
+
const fakeHome = await makeTmp();
|
|
311
|
+
const fakeBin = await makeTmp();
|
|
312
|
+
const fake = await makeFakeClaude(fakeBin);
|
|
313
|
+
const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
|
|
314
|
+
|
|
315
|
+
const { exitCode, stdout } = await runCli(["install", "claude"], env);
|
|
316
|
+
assert.equal(exitCode, 0);
|
|
317
|
+
|
|
318
|
+
const pluginDir = join(fakeHome, ".agent-cortex", "claude");
|
|
319
|
+
// plugin materialised into the home install root
|
|
320
|
+
assert.ok(await pathExists(join(pluginDir, "skills", "tdd", "SKILL.md")), "skill copied into home plugin");
|
|
321
|
+
assert.ok((await stat(join(pluginDir, "skills", "tdd"))).isDirectory(), "home skills are copies, not symlinks");
|
|
322
|
+
// hand-authored extras shipped into the home plugin
|
|
323
|
+
assert.equal(
|
|
324
|
+
await readFile(join(pluginDir, ".mcp.json"), "utf-8"),
|
|
325
|
+
await readFile(join(ROOT, "claude-extras", ".mcp.json"), "utf-8"),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// marketplace manifest at the home root exposes ./claude
|
|
329
|
+
const marketPath = join(fakeHome, ".agent-cortex", ".claude-plugin", "marketplace.json");
|
|
330
|
+
const market = JSON.parse(await readFile(marketPath, "utf-8"));
|
|
331
|
+
assert.equal(market.name, "jaybeeuu");
|
|
332
|
+
assert.deepStrictEqual(market.plugins, [
|
|
333
|
+
{
|
|
334
|
+
name: "agent-cortex",
|
|
335
|
+
source: "./claude",
|
|
336
|
+
description: "Personal Claude Code plugin with custom agents and skills",
|
|
337
|
+
},
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
// registration drove the claude plugin CLI against the home install root:
|
|
341
|
+
// fresh state → marketplace add + plugin install (state queries and the
|
|
342
|
+
// availability probe are not registration actions).
|
|
343
|
+
const calls = (await readFile(fake.log, "utf-8")).trim().split("\n");
|
|
344
|
+
assert.deepEqual(registrationActions(calls), [
|
|
345
|
+
`plugin marketplace add ${join(fakeHome, ".agent-cortex")}`,
|
|
346
|
+
"plugin install agent-cortex@jaybeeuu -y",
|
|
347
|
+
]);
|
|
348
|
+
assert.ok(stdout.includes("Registering plugin"), "CLI reports registration");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("re-running a plain install is the update path: no marketplace re-add, no double-install", async () => {
|
|
352
|
+
const fakeHome = await makeTmp();
|
|
353
|
+
const fakeBin = await makeTmp();
|
|
354
|
+
const fake = await makeFakeClaude(fakeBin);
|
|
355
|
+
const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
|
|
356
|
+
const allCalls = async () => (await readFile(fake.log, "utf-8")).trim().split("\n").filter(Boolean);
|
|
357
|
+
|
|
358
|
+
const first = await runCli(["install", "claude"], env);
|
|
359
|
+
assert.equal(first.exitCode, 0);
|
|
360
|
+
assert.deepEqual(registrationActions(await allCalls()), [
|
|
361
|
+
`plugin marketplace add ${join(fakeHome, ".agent-cortex")}`,
|
|
362
|
+
"plugin install agent-cortex@jaybeeuu -y",
|
|
363
|
+
]);
|
|
364
|
+
const run1End = (await allCalls()).length;
|
|
365
|
+
|
|
366
|
+
// Second run: marketplace registered, plugin at the materialised version →
|
|
367
|
+
// marketplace update only — the documented no-op update path.
|
|
368
|
+
const second = await runCli(["install", "claude"], env);
|
|
369
|
+
assert.equal(second.exitCode, 0);
|
|
370
|
+
assert.deepEqual(registrationActions((await allCalls()).slice(run1End)), [
|
|
371
|
+
"plugin marketplace update jaybeeuu",
|
|
372
|
+
]);
|
|
373
|
+
|
|
374
|
+
// Across both runs the marketplace was added exactly once and the plugin
|
|
375
|
+
// installed exactly once (idempotent — no re-add, no double-install).
|
|
376
|
+
const all = registrationActions(await allCalls());
|
|
377
|
+
assert.equal(all.filter((c) => c.startsWith("plugin marketplace add")).length, 1);
|
|
378
|
+
assert.equal(all.filter((c) => c === "plugin install agent-cortex@jaybeeuu -y").length, 1);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("warns with manual commands when the claude CLI is missing; --require-register fails the run", async () => {
|
|
382
|
+
const fakeHome = await makeTmp();
|
|
383
|
+
const env = { HOME: fakeHome, PATH: "/nonexistent" };
|
|
384
|
+
|
|
385
|
+
// Default: warn-only — the plugin is materialised, exit 0, manual
|
|
386
|
+
// registration commands printed for the user to run.
|
|
387
|
+
const warned = await runCli(["install", "claude"], env);
|
|
388
|
+
assert.equal(warned.exitCode, 0, "missing claude CLI is warn-only by default");
|
|
389
|
+
assert.match(warned.stderr, /NOT registered/);
|
|
390
|
+
assert.match(warned.stderr, /claude plugin marketplace add /);
|
|
391
|
+
assert.match(warned.stderr, /claude plugin install agent-cortex@jaybeeuu -y/);
|
|
392
|
+
// The plugin itself is still materialised into the fake home.
|
|
393
|
+
assert.ok(await pathExists(join(fakeHome, ".agent-cortex", "claude", "skills", "tdd", "SKILL.md")));
|
|
394
|
+
|
|
395
|
+
// --require-register escalates the same failure to a non-zero exit.
|
|
396
|
+
const required = await runCli(["install", "claude", "--require-register"], env);
|
|
397
|
+
assert.equal(required.exitCode, 1);
|
|
398
|
+
assert.match(required.stderr, /require-register/);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("plain --dry-run prints the home target and copied-skill plan without writing", async () => {
|
|
402
|
+
const fakeHome = await makeTmp();
|
|
403
|
+
const fakeBin = await makeTmp();
|
|
404
|
+
await makeFakeClaude(fakeBin);
|
|
405
|
+
const env = { HOME: fakeHome, PATH: `${fakeBin}:${process.env.PATH}` };
|
|
406
|
+
|
|
407
|
+
const { exitCode, stdout } = await runCli(["install", "claude", "--dry-run"], env);
|
|
408
|
+
assert.equal(exitCode, 0);
|
|
409
|
+
assert.ok(stdout.includes(join(fakeHome, ".agent-cortex", "claude")), "dry-run names the home target");
|
|
410
|
+
assert.ok(/copied/i.test(stdout), "dry-run describes copied skills");
|
|
411
|
+
assert.equal(await pathExists(join(fakeHome, ".agent-cortex")), false, "nothing written in dry-run");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("rejects token-map.json versions newer than the implemented contract", async () => {
|
|
415
|
+
const fixture = await makeTmp();
|
|
416
|
+
try {
|
|
417
|
+
await mkdir(join(fixture, "agents"), { recursive: true });
|
|
418
|
+
await writeFile(
|
|
419
|
+
join(fixture, "token-map.json"),
|
|
420
|
+
JSON.stringify({ name: "fixture", version: 99, tools: {}, paths: {} }),
|
|
421
|
+
);
|
|
422
|
+
await assert.rejects(
|
|
423
|
+
installClaude({ root: fixture, output: join(fixture, "out") }),
|
|
424
|
+
/version 99/,
|
|
425
|
+
);
|
|
426
|
+
assert.equal(await pathExists(join(fixture, "out")), false, "nothing written when the contract is rejected");
|
|
427
|
+
} finally {
|
|
428
|
+
await rm(fixture, { recursive: true, force: true });
|
|
429
|
+
}
|
|
178
430
|
});
|
|
179
431
|
});
|