@jaybeeuu/agent-cortex 1.37.1 → 1.38.1
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 +9 -8
- package/.github/workflows/ci.yml +190 -8
- package/AGENTS.md +25 -6
- package/CHANGELOG.md +42 -0
- package/README.md +132 -33
- package/agents/README.md +47 -13
- package/agents/plan/agent.md +51 -0
- package/agents/plan/claude/frontmatter.json +6 -0
- package/agents/plan/copilot/frontmatter.json +6 -0
- package/agents/plan/pi/frontmatter.json +6 -0
- package/agents/plan.agent.md +3 -0
- package/agents/ralph/agent.md +262 -0
- package/agents/ralph/claude/frontmatter.json +6 -0
- package/agents/ralph/claude/polling.md +31 -0
- package/agents/ralph/copilot/frontmatter.json +6 -0
- package/agents/ralph/copilot/polling.md +62 -0
- package/agents/ralph/pi/frontmatter.json +6 -0
- package/agents/ralph/pi/polling.md +74 -0
- package/agents/ralph-plan/agent.md +114 -0
- package/agents/ralph-plan/claude/frontmatter.json +6 -0
- package/agents/ralph-plan/copilot/frontmatter.json +6 -0
- package/agents/ralph-plan/pi/frontmatter.json +6 -0
- package/agents/ralph-plan.agent.md +5 -2
- package/agents/ralph.agent.md +83 -57
- package/agents/strategy/agent.md +104 -0
- package/agents/strategy/claude/frontmatter.json +6 -0
- package/agents/strategy/copilot/frontmatter.json +6 -0
- package/agents/strategy/pi/frontmatter.json +6 -0
- package/agents/strategy.agent.md +3 -0
- package/bin/agent-cortex.mjs +68 -1
- package/bin/installers/claude.mjs +262 -0
- package/bin/installers/copilot.mjs +129 -0
- package/bin/installers/pi.mjs +197 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/agents/plan.md +3 -1
- package/claude/agents/ralph-plan.md +5 -3
- package/claude/agents/strategy.md +3 -1
- package/claude/hooks/scripts/notify.mjs +54 -0
- package/claude/hooks.json +15 -0
- package/docs/claude-hooks.md +115 -0
- package/docs/ideas/auto-model-selection.md +75 -0
- package/docs/ideas/improve-pi-system-prompt.md +34 -0
- package/docs/ideas/model-selector.md +40 -0
- package/docs/ideas/pi-task-memory.md +34 -0
- package/docs/ideas/qa-extension.md +43 -0
- package/docs/ideas/unified-ci-pipeline.md +28 -0
- package/docs/skills/skill-anatomy.md +9 -0
- package/extensions/agent-modes/README.md +54 -0
- package/extensions/agent-modes/discover.test.ts +241 -0
- package/extensions/agent-modes/discover.ts +458 -0
- package/extensions/agent-modes/index.ts +13 -224
- package/extensions/agent-modes/node_modules/.bin/pi +21 -0
- package/extensions/agent-modes/node_modules/.bin/tsc +21 -0
- package/extensions/agent-modes/node_modules/.bin/tsserver +21 -0
- package/extensions/agent-modes/node_modules/.bin/tsx +21 -0
- package/extensions/agent-modes/package.json +20 -0
- package/extensions/agent-modes/tsconfig.json +12 -0
- package/extensions/subagent/README.md +19 -0
- package/extensions/subagent/index.ts +131 -45
- package/extensions/subagent/node_modules/.bin/pi +21 -0
- package/extensions/subagent/node_modules/.bin/pi-ai +21 -0
- package/extensions/subagent/node_modules/.bin/tsc +21 -0
- package/extensions/subagent/node_modules/.bin/tsserver +21 -0
- package/extensions/subagent/node_modules/.bin/tsx +21 -0
- package/extensions/subagent/package.json +23 -0
- package/extensions/subagent/tsconfig.json +12 -0
- package/extensions/subagent/wait.test.ts +202 -0
- package/extensions/subagent/wait.ts +118 -0
- package/hooks/claude/hooks.json +38 -0
- package/hooks/claude/scripts/notify.mjs +54 -0
- package/lib/cli.mjs +62 -3
- package/package.json +6 -4
- package/plugin.json +1 -1
- package/scripts/build-copilot-agents.mjs +30 -0
- package/scripts/lib/compose-agent.mjs +241 -0
- package/skills/engineering/design-an-interface/SKILL.md +1 -1
- package/skills/engineering/improve-codebase-architecture/SKILL.md +1 -1
- package/skills/workflow/ralph/REFERENCE.md +2 -2
- package/skills/workflow/ralph/SKILL.md +6 -6
- package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +4 -2
- package/skills/workflow/using-agent-skills/SKILL.md +122 -0
- package/test/cli.test.mjs +115 -2
- package/test/copilot-installer.test.mjs +298 -0
- package/test/installer.test.mjs +179 -0
- package/test/notify-hook.test.mjs +63 -0
- package/test/pi-installer.test.mjs +371 -0
- package/token-map.README.md +128 -0
- package/token-map.json +106 -0
- package/.github/workflows/ci.test.ts +0 -217
- package/.github/workflows/release.yml +0 -58
- package/scripts/build-claude-agents.mjs +0 -179
- package/scripts/publish-release.sh +0 -32
package/test/cli.test.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
7
|
import { dirname, join } from "node:path";
|
|
6
8
|
import { parseArgs, buildHelpText, validateHarness } from "../lib/cli.mjs";
|
|
@@ -56,6 +58,84 @@ describe("parseArgs", () => {
|
|
|
56
58
|
assert.deepStrictEqual(result, { command: "install", harness: null });
|
|
57
59
|
});
|
|
58
60
|
|
|
61
|
+
it("returns output for install claude --output <dir>", () => {
|
|
62
|
+
const result = parseArgs(["install", "claude", "--output", "/tmp/x"]);
|
|
63
|
+
assert.deepStrictEqual(result, { command: "install", harness: "claude", output: "/tmp/x" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns output for install claude --output=<dir>", () => {
|
|
67
|
+
const result = parseArgs(["install", "claude", "--output=/tmp/x"]);
|
|
68
|
+
assert.deepStrictEqual(result, { command: "install", harness: "claude", output: "/tmp/x" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns dryRun for install claude --dry-run", () => {
|
|
72
|
+
const result = parseArgs(["install", "claude", "--dry-run"]);
|
|
73
|
+
assert.deepStrictEqual(result, { command: "install", harness: "claude", dryRun: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("combines --dry-run with --output", () => {
|
|
77
|
+
const result = parseArgs(["install", "claude", "--output", "/tmp/x", "--dry-run"]);
|
|
78
|
+
assert.deepStrictEqual(result, { command: "install", harness: "claude", output: "/tmp/x", dryRun: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reports an unknown option for claude", () => {
|
|
82
|
+
const result = parseArgs(["install", "claude", "--bogus"]);
|
|
83
|
+
assert.equal(result.command, "install");
|
|
84
|
+
assert.equal(result.harness, "claude");
|
|
85
|
+
assert.ok(result.optionError.includes("--bogus"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("reports a missing value for --output for claude", () => {
|
|
89
|
+
const result = parseArgs(["install", "claude", "--output"]);
|
|
90
|
+
assert.equal(result.command, "install");
|
|
91
|
+
assert.equal(result.harness, "claude");
|
|
92
|
+
assert.ok(result.optionError.includes("--output"));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("parses --dry-run for install", () => {
|
|
96
|
+
const result = parseArgs(["install", "pi", "--dry-run"]);
|
|
97
|
+
assert.deepStrictEqual(result, { command: "install", harness: "pi", dryRun: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("parses --output <dir> for install", () => {
|
|
101
|
+
const result = parseArgs(["install", "pi", "--output", "/tmp/out"]);
|
|
102
|
+
assert.deepStrictEqual(result, { command: "install", harness: "pi", output: "/tmp/out" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("parses --plugin-root <dir> for install", () => {
|
|
106
|
+
const result = parseArgs(["install", "pi", "--plugin-root", "/tmp/plugin"]);
|
|
107
|
+
assert.deepStrictEqual(result, { command: "install", harness: "pi", pluginRoot: "/tmp/plugin" });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("combines multiple install options", () => {
|
|
111
|
+
const result = parseArgs(["install", "pi", "--dry-run", "--output", "/tmp/out", "--plugin-root", "/tmp/plugin"]);
|
|
112
|
+
assert.deepStrictEqual(result, {
|
|
113
|
+
command: "install",
|
|
114
|
+
harness: "pi",
|
|
115
|
+
dryRun: true,
|
|
116
|
+
output: "/tmp/out",
|
|
117
|
+
pluginRoot: "/tmp/plugin",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reports a missing value for --output", () => {
|
|
122
|
+
const result = parseArgs(["install", "pi", "--output"]);
|
|
123
|
+
assert.equal(result.command, "install");
|
|
124
|
+
assert.equal(result.harness, "pi");
|
|
125
|
+
assert.ok(result.optionError.includes("--output"));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("reports an unknown option", () => {
|
|
129
|
+
const result = parseArgs(["install", "pi", "--bogus"]);
|
|
130
|
+
assert.equal(result.command, "install");
|
|
131
|
+
assert.equal(result.harness, "pi");
|
|
132
|
+
assert.ok(result.optionError.includes("--bogus"));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("keeps the old shape when no options are given", () => {
|
|
136
|
+
assert.deepStrictEqual(parseArgs(["install", "pi"]), { command: "install", harness: "pi" });
|
|
137
|
+
});
|
|
138
|
+
|
|
59
139
|
it("returns { command: 'unknown', name: 'foo' } for unknown command", () => {
|
|
60
140
|
const result = parseArgs(["foo"]);
|
|
61
141
|
assert.deepStrictEqual(result, { command: "unknown", name: "foo" });
|
|
@@ -117,8 +197,9 @@ describe("CLI integration", () => {
|
|
|
117
197
|
assert.ok(stdout.includes("copilot"));
|
|
118
198
|
});
|
|
119
199
|
|
|
120
|
-
it("exits 0 for install claude", async () => {
|
|
121
|
-
const
|
|
200
|
+
it("exits 0 for install claude (into a temp output dir)", async () => {
|
|
201
|
+
const dir = mkdtempSync(join(tmpdir(), "agent-cortex-cli-"));
|
|
202
|
+
const { exitCode, stdout } = await runCli(["install", "claude", "--output", dir]);
|
|
122
203
|
assert.equal(exitCode, 0);
|
|
123
204
|
assert.ok(stdout.includes("claude"));
|
|
124
205
|
});
|
|
@@ -135,6 +216,38 @@ describe("CLI integration", () => {
|
|
|
135
216
|
assert.ok(stderr.includes("Missing harness"));
|
|
136
217
|
});
|
|
137
218
|
|
|
219
|
+
it("exits 1 and prints error for install with an unknown option", async () => {
|
|
220
|
+
const { exitCode, stderr } = await runCli(["install", "pi", "--bogus"]);
|
|
221
|
+
assert.equal(exitCode, 1);
|
|
222
|
+
assert.ok(stderr.includes("--bogus"));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("exits 1 and prints error for install with a missing option value", async () => {
|
|
226
|
+
const { exitCode, stderr } = await runCli(["install", "pi", "--output"]);
|
|
227
|
+
assert.equal(exitCode, 1);
|
|
228
|
+
assert.ok(stderr.includes("--output"));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("exits 0 for install pi --dry-run and writes nothing", async () => {
|
|
232
|
+
const { exitCode, stdout } = await runCli(["install", "pi", "--dry-run"]);
|
|
233
|
+
assert.equal(exitCode, 0);
|
|
234
|
+
assert.ok(stdout.includes("ralph.agent.md"));
|
|
235
|
+
assert.ok(stdout.includes("dry-run"));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("exits 0 for install pi --output <tmp> and writes agent files", async () => {
|
|
239
|
+
const outDir = mkdtempSync(join(tmpdir(), "cli-pi-install-"));
|
|
240
|
+
try {
|
|
241
|
+
const { exitCode, stdout } = await runCli(["install", "pi", "--output", outDir]);
|
|
242
|
+
assert.equal(exitCode, 0);
|
|
243
|
+
assert.ok(stdout.includes("ralph.agent.md"));
|
|
244
|
+
const ralph = readFileSync(join(outDir, "agents", "ralph.agent.md"), "utf-8");
|
|
245
|
+
assert.ok(ralph.includes("agent-cortex:ralph"));
|
|
246
|
+
} finally {
|
|
247
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
138
251
|
it("exits 1 and prints error for install with unknown harness", async () => {
|
|
139
252
|
const { exitCode, stderr } = await runCli(["install", "docker"]);
|
|
140
253
|
assert.equal(exitCode, 1);
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// Integration tests for `agent-cortex install copilot`: the install-time
|
|
2
|
+
// generator for the Copilot CLI flat agent files (agents/*.agent.md). All tests
|
|
3
|
+
// install into mkdtemp dirs so the real repo agents/ is never touched.
|
|
4
|
+
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import { installCopilot } from "../bin/installers/copilot.mjs";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const ROOT = join(__dirname, "..");
|
|
16
|
+
const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
|
|
17
|
+
|
|
18
|
+
function runCli(args) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile(process.execPath, [CLI_PATH, ...args], (error, stdout, stderr) => {
|
|
21
|
+
resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function tokenMap() {
|
|
29
|
+
return {
|
|
30
|
+
name: "fixture token map",
|
|
31
|
+
version: 1,
|
|
32
|
+
harnesses: ["copilot"],
|
|
33
|
+
tools: {
|
|
34
|
+
bash: { copilot: "bash" },
|
|
35
|
+
view: { copilot: "view" },
|
|
36
|
+
task: { copilot: "task" },
|
|
37
|
+
ask_user: { copilot: "ask_user" },
|
|
38
|
+
},
|
|
39
|
+
paths: {
|
|
40
|
+
plugin_root: { copilot: "~/.copilot/installed-plugins/_direct/agent-cortex" },
|
|
41
|
+
agents_dir: { copilot: { base: "plugin_root", relative: "agents" } },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeFixture() {
|
|
47
|
+
const root = mkdtempSync(join(tmpdir(), "copilot-installer-"));
|
|
48
|
+
return {
|
|
49
|
+
root,
|
|
50
|
+
output: join(root, "generated"),
|
|
51
|
+
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeFixture(fx, relPath, content) {
|
|
56
|
+
const p = join(fx.root, relPath);
|
|
57
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
58
|
+
writeFileSync(p, content);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Seed a minimal but realistic package tree (agents + token-map). */
|
|
62
|
+
function seedCopilotPackage(fx, opts = {}) {
|
|
63
|
+
const { agents = {} } = opts;
|
|
64
|
+
writeFixture(fx, "token-map.json", JSON.stringify(tokenMap()));
|
|
65
|
+
for (const [name, def] of Object.entries(agents)) {
|
|
66
|
+
writeFixture(fx, `agents/${name}/agent.md`, def.body ?? `# ${name}\nBody of ${name}.`);
|
|
67
|
+
writeFixture(
|
|
68
|
+
fx,
|
|
69
|
+
`agents/${name}/copilot/frontmatter.json`,
|
|
70
|
+
JSON.stringify(
|
|
71
|
+
def.frontmatter ?? {
|
|
72
|
+
name: `agent-cortex:${name}`,
|
|
73
|
+
description: `Description of ${name}`,
|
|
74
|
+
tools: ["bash", "view"],
|
|
75
|
+
argumentHint: `Hint for ${name}`,
|
|
76
|
+
},
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
for (const [section, content] of Object.entries(def.sections ?? {})) {
|
|
80
|
+
writeFixture(fx, `agents/${name}/copilot/${section}.md`, content);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Deterministic snapshot of a directory: relative paths + byte content. */
|
|
86
|
+
function snapshot(dir) {
|
|
87
|
+
const entries = [];
|
|
88
|
+
const walk = (p) => {
|
|
89
|
+
for (const e of readdirSync(p, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
90
|
+
const full = join(p, e.name);
|
|
91
|
+
if (e.isDirectory()) {
|
|
92
|
+
entries.push([e.name, "dir"]);
|
|
93
|
+
walk(full);
|
|
94
|
+
} else entries.push([e.name, "file", readFileSync(full, "utf-8")]);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(dir);
|
|
98
|
+
return entries;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Agent composition ───────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe("installCopilot — agent composition", () => {
|
|
104
|
+
it("writes one flat <name>.agent.md per composable agent dir with copilot frontmatter and no leftover tokens", () => {
|
|
105
|
+
const fx = makeFixture();
|
|
106
|
+
try {
|
|
107
|
+
seedCopilotPackage(fx, {
|
|
108
|
+
agents: {
|
|
109
|
+
alpha: {
|
|
110
|
+
body: "# alpha\nUse {{TOOL:view}} to read.\n{{SECTION:polling}}\nRead {{PATH:agents_dir}}.",
|
|
111
|
+
sections: { polling: "Spawn with {{TOOL:task}}." },
|
|
112
|
+
frontmatter: {
|
|
113
|
+
name: "agent-cortex:alpha",
|
|
114
|
+
description: "Reads things.",
|
|
115
|
+
tools: ["bash", "view"],
|
|
116
|
+
argumentHint: "Read something",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const result = installCopilot({ root: fx.root, output: fx.output });
|
|
123
|
+
|
|
124
|
+
const file = join(fx.output, "alpha.agent.md");
|
|
125
|
+
assert.ok(existsSync(file), "flat agent file written");
|
|
126
|
+
const content = readFileSync(file, "utf-8");
|
|
127
|
+
assert.ok(content.startsWith("---\n"), "yaml frontmatter");
|
|
128
|
+
assert.ok(content.includes("# GENERATED from agents/alpha/ by scripts/build-copilot-agents.mjs — DO NOT EDIT."));
|
|
129
|
+
// Committed flat-file field order: description, name, tools (YAML array), argument-hint.
|
|
130
|
+
const order = ["description:", "name: \"agent-cortex:alpha\"", "tools: [", "argument-hint:"].map((s) => content.indexOf(s));
|
|
131
|
+
assert.ok(order.every((i) => i !== -1), "all frontmatter fields present");
|
|
132
|
+
assert.deepEqual([...order].sort((a, b) => a - b), order, "frontmatter fields in committed order");
|
|
133
|
+
assert.match(content, /tools: \["bash", "view"\]/, "copilot tools kept as a YAML array (no translation)");
|
|
134
|
+
assert.ok(content.includes("Use view to read."), "{{TOOL:view}} substituted");
|
|
135
|
+
assert.ok(content.includes("Spawn with task."), "{{SECTION:polling}} composed from copilot/polling.md");
|
|
136
|
+
assert.ok(!/{{/.test(content), "no literal tokens remain");
|
|
137
|
+
assert.equal(result.agents.length, 1);
|
|
138
|
+
assert.deepEqual(result.agents, ["alpha"]);
|
|
139
|
+
assert.equal(result.output, fx.output);
|
|
140
|
+
} finally {
|
|
141
|
+
fx.cleanup();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("skips non-composable entries in agents/ and never clobbers composable sources", () => {
|
|
146
|
+
const fx = makeFixture();
|
|
147
|
+
try {
|
|
148
|
+
seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
149
|
+
writeFixture(fx, "agents/README.md", "# agents dir docs\n");
|
|
150
|
+
|
|
151
|
+
const result = installCopilot({ root: fx.root, output: fx.output });
|
|
152
|
+
|
|
153
|
+
assert.deepEqual(readdirSync(fx.output).sort(), ["alpha.agent.md"], "no README, no subdirectories written");
|
|
154
|
+
assert.deepEqual(result.agents, ["alpha"]);
|
|
155
|
+
// The composable source dir next to the generated file stays untouched.
|
|
156
|
+
assert.ok(existsSync(join(fx.root, "agents", "alpha", "agent.md")), "composable source preserved");
|
|
157
|
+
assert.ok(existsSync(join(fx.root, "agents", "alpha", "copilot", "frontmatter.json")), "harness dir preserved");
|
|
158
|
+
} finally {
|
|
159
|
+
fx.cleanup();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("throws when a composable dir lacks copilot/frontmatter.json", () => {
|
|
164
|
+
const fx = makeFixture();
|
|
165
|
+
try {
|
|
166
|
+
seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
167
|
+
rmSync(join(fx.root, "agents", "alpha", "copilot"), { recursive: true, force: true });
|
|
168
|
+
assert.throws(
|
|
169
|
+
() => installCopilot({ root: fx.root, output: fx.output }),
|
|
170
|
+
/without copilot\/frontmatter\.json/,
|
|
171
|
+
);
|
|
172
|
+
} finally {
|
|
173
|
+
fx.cleanup();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("defaults to the plugin-scanned agents/ dir of the package root (plugin.json \"agents\": \"agents/\")", () => {
|
|
178
|
+
const fx = makeFixture();
|
|
179
|
+
try {
|
|
180
|
+
seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
181
|
+
|
|
182
|
+
const result = installCopilot({ root: fx.root });
|
|
183
|
+
|
|
184
|
+
assert.equal(result.output, join(fx.root, "agents"), "default output is the dir plugin.json scans");
|
|
185
|
+
assert.ok(existsSync(join(fx.root, "agents", "alpha.agent.md")), "flat file lands next to the composable dir");
|
|
186
|
+
assert.ok(existsSync(join(fx.root, "agents", "alpha", "agent.md")), "composable source untouched");
|
|
187
|
+
} finally {
|
|
188
|
+
fx.cleanup();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("throws on an unknown tool token", () => {
|
|
193
|
+
const fx = makeFixture();
|
|
194
|
+
try {
|
|
195
|
+
seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha\nUse {{TOOL:nope}} now." } } });
|
|
196
|
+
assert.throws(() => installCopilot({ root: fx.root, output: fx.output }), /unknown tool token {{TOOL:nope}}/);
|
|
197
|
+
} finally {
|
|
198
|
+
fx.cleanup();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ─── Output / dry-run ────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
describe("installCopilot — output & dry-run", () => {
|
|
206
|
+
it("dry-run reports the plan without writing anything", () => {
|
|
207
|
+
const fx = makeFixture();
|
|
208
|
+
try {
|
|
209
|
+
seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
|
|
210
|
+
|
|
211
|
+
const result = installCopilot({ root: fx.root, output: fx.output, dryRun: true });
|
|
212
|
+
|
|
213
|
+
assert.equal(result.dryRun, true);
|
|
214
|
+
assert.equal(existsSync(fx.output), false, "no output dir created in dry-run");
|
|
215
|
+
assert.deepEqual(result.agents, ["alpha"], "planned files still reported");
|
|
216
|
+
} finally {
|
|
217
|
+
fx.cleanup();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("is deterministic across repeated installs into the same output", () => {
|
|
222
|
+
const fx = makeFixture();
|
|
223
|
+
try {
|
|
224
|
+
seedCopilotPackage(fx, {
|
|
225
|
+
agents: {
|
|
226
|
+
alpha: { body: "# alpha\n{{SECTION:polling}}", sections: { polling: "Poll with {{TOOL:task}}." } },
|
|
227
|
+
beta: { body: "# beta" },
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
installCopilot({ root: fx.root, output: fx.output });
|
|
232
|
+
const first = snapshot(fx.output);
|
|
233
|
+
installCopilot({ root: fx.root, output: fx.output });
|
|
234
|
+
assert.deepEqual(snapshot(fx.output), first);
|
|
235
|
+
} finally {
|
|
236
|
+
fx.cleanup();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ─── Integration against the real repo ───────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
describe("installCopilot — real repo integration", () => {
|
|
244
|
+
it("regenerates byte-identical copies of the committed agents/*.agent.md files", () => {
|
|
245
|
+
const fx = makeFixture();
|
|
246
|
+
try {
|
|
247
|
+
const result = installCopilot({ root: ROOT, output: fx.output });
|
|
248
|
+
|
|
249
|
+
assert.deepEqual(result.agents.sort(), ["plan", "ralph", "ralph-plan", "strategy"]);
|
|
250
|
+
for (const name of result.agents) {
|
|
251
|
+
const generated = readFileSync(join(fx.output, `${name}.agent.md`), "utf-8");
|
|
252
|
+
const committed = readFileSync(join(ROOT, "agents", `${name}.agent.md`), "utf-8");
|
|
253
|
+
assert.equal(generated, committed, `${name}.agent.md must match the committed flat file (CI drift check)`);
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
fx.cleanup();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ─── CLI wiring ──────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
describe("CLI integration", () => {
|
|
264
|
+
it("exits 0 for install copilot --output <tmp> and writes the flat files", async () => {
|
|
265
|
+
const fx = makeFixture();
|
|
266
|
+
try {
|
|
267
|
+
const { exitCode, stdout } = await runCli(["install", "copilot", "--output", fx.output]);
|
|
268
|
+
assert.equal(exitCode, 0);
|
|
269
|
+
assert.ok(stdout.includes("copilot"), "prints the harness name");
|
|
270
|
+
assert.ok(stdout.includes("Generated"), "prints the generation summary");
|
|
271
|
+
assert.ok(existsSync(join(fx.output, "ralph.agent.md")), "agent files written");
|
|
272
|
+
assert.ok(existsSync(join(fx.output, "plan.agent.md")));
|
|
273
|
+
} finally {
|
|
274
|
+
fx.cleanup();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("exits 0 for install copilot --dry-run and writes nothing", async () => {
|
|
279
|
+
const fx = makeFixture();
|
|
280
|
+
try {
|
|
281
|
+
const { exitCode, stdout } = await runCli(["install", "copilot", "--output", fx.output, "--dry-run"]);
|
|
282
|
+
assert.equal(exitCode, 0);
|
|
283
|
+
assert.ok(/dry-run/i.test(stdout));
|
|
284
|
+
assert.equal(existsSync(fx.output), false, "no files written in dry-run");
|
|
285
|
+
} finally {
|
|
286
|
+
fx.cleanup();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("exits 0 for install copilot (default output) without touching committed files", async () => {
|
|
291
|
+
const before = readFileSync(join(ROOT, "agents", "ralph.agent.md"), "utf-8");
|
|
292
|
+
const { exitCode } = await runCli(["install", "copilot"]);
|
|
293
|
+
assert.equal(exitCode, 0);
|
|
294
|
+
// Default output regenerates the shared agents/ dir in place — output must
|
|
295
|
+
// stay byte-identical to what is committed.
|
|
296
|
+
assert.equal(readFileSync(join(ROOT, "agents", "ralph.agent.md"), "utf-8"), before);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Integration tests for `agent-cortex install claude`: the install-time
|
|
2
|
+
// generator for the Claude Code plugin subtree. All tests install into
|
|
3
|
+
// mkdtemp dirs so the real repo claude/ and ~/.claude are never touched.
|
|
4
|
+
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { mkdtempSync, readFileSync, readdirSync, existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { dirname, join, relative } from "node:path";
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const ROOT = join(__dirname, "..");
|
|
15
|
+
const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
|
|
16
|
+
|
|
17
|
+
function runCli(args) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
execFile(process.execPath, [CLI_PATH, ...args], (error, stdout, stderr) => {
|
|
20
|
+
resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeTmp() {
|
|
26
|
+
return mkdtempSync(join(tmpdir(), "agent-cortex-installer-"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Deterministic snapshot of a generated tree: relative paths, kind, and for
|
|
30
|
+
* files/symlinks their byte content / link target. */
|
|
31
|
+
function snapshot(dir) {
|
|
32
|
+
const entries = [];
|
|
33
|
+
const walk = (p) => {
|
|
34
|
+
for (const e of readdirSync(p, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
35
|
+
const full = join(p, e.name);
|
|
36
|
+
const rel = relative(dir, full);
|
|
37
|
+
if (e.isSymbolicLink()) entries.push([rel, "link", readlinkSync(full)]);
|
|
38
|
+
else if (e.isDirectory()) {
|
|
39
|
+
entries.push([rel, "dir"]);
|
|
40
|
+
walk(full);
|
|
41
|
+
} else entries.push([rel, "file", readFileSync(full, "utf-8")]);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
walk(dir);
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("install claude", () => {
|
|
49
|
+
it("composes agents from the composable sources (claude names, no tokens)", async () => {
|
|
50
|
+
const out = makeTmp();
|
|
51
|
+
const { exitCode } = await runCli(["install", "claude", "--output", out]);
|
|
52
|
+
assert.equal(exitCode, 0);
|
|
53
|
+
|
|
54
|
+
const plan = readFileSync(join(out, "agents", "plan.md"), "utf-8");
|
|
55
|
+
assert.match(plan, /^---\n# GENERATED from agents\/plan\//);
|
|
56
|
+
// provenance names the shared installer (no build-time script), so the
|
|
57
|
+
// committed subtree can only come from the install-time generator.
|
|
58
|
+
assert.match(plan, /bin\/installers\/claude\.mjs/);
|
|
59
|
+
assert.doesNotMatch(plan, /build-claude-agents/);
|
|
60
|
+
assert.match(plan, /\nname: plan\n/);
|
|
61
|
+
assert.doesNotMatch(plan, /\{\{(TOOL|PATH|SECTION):/);
|
|
62
|
+
|
|
63
|
+
const ralphPlan = readFileSync(join(out, "agents", "ralph-plan.md"), "utf-8");
|
|
64
|
+
assert.match(ralphPlan, /^---\n# GENERATED from agents\/ralph-plan\//);
|
|
65
|
+
assert.match(ralphPlan, /\nname: ralph-plan\n/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("copies native agents verbatim (ralph stays native, no composed counterpart)", async () => {
|
|
69
|
+
const out = makeTmp();
|
|
70
|
+
await runCli(["install", "claude", "--output", out]);
|
|
71
|
+
const native = readFileSync(join(ROOT, "agents-native", "ralph.md"), "utf-8");
|
|
72
|
+
assert.equal(readFileSync(join(out, "agents", "ralph.md"), "utf-8"), native);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("symlinks flattened skills and excludes ralph-coupled skills", async () => {
|
|
76
|
+
const out = makeTmp();
|
|
77
|
+
await runCli(["install", "claude", "--output", out]);
|
|
78
|
+
const skills = join(out, "skills");
|
|
79
|
+
const names = readdirSync(skills);
|
|
80
|
+
assert.ok(names.includes("tdd"));
|
|
81
|
+
assert.ok(names.includes("plan"));
|
|
82
|
+
assert.ok(!names.includes("ralph"));
|
|
83
|
+
assert.ok(!names.includes("run-pipeline-stage"));
|
|
84
|
+
assert.ok(lstatSync(join(skills, "tdd")).isSymbolicLink());
|
|
85
|
+
assert.equal(realpathSync(join(skills, "tdd")), realpathSync(join(ROOT, "skills", "engineering", "tdd")));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("generates plugin.json tracking the package version and referencing hooks.json", async () => {
|
|
89
|
+
const out = makeTmp();
|
|
90
|
+
await runCli(["install", "claude", "--output", out]);
|
|
91
|
+
const pkgVersion = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8")).version;
|
|
92
|
+
const plugin = JSON.parse(readFileSync(join(out, ".claude-plugin", "plugin.json"), "utf-8"));
|
|
93
|
+
assert.equal(plugin.name, "agent-cortex");
|
|
94
|
+
assert.equal(plugin.version, pkgVersion);
|
|
95
|
+
assert.equal(plugin.hooks, "./hooks.json");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("generates hooks.json from the canonical hooks/claude/ source", async () => {
|
|
99
|
+
const out = makeTmp();
|
|
100
|
+
await runCli(["install", "claude", "--output", out]);
|
|
101
|
+
const source = JSON.parse(readFileSync(join(ROOT, "hooks", "claude", "hooks.json"), "utf-8"));
|
|
102
|
+
const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
|
|
103
|
+
assert.deepStrictEqual(installed, source);
|
|
104
|
+
assert.ok(source.hooks.SessionStart);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("matches the Notification hook only on documented notification types", async () => {
|
|
108
|
+
// The Notification matcher is an exact-string list over `notification_type` —
|
|
109
|
+
// there are no success/error types, so that matcher would never fire. Guard
|
|
110
|
+
// against it recurring (see docs/claude-hooks.md for the documented type list).
|
|
111
|
+
const out = makeTmp();
|
|
112
|
+
await runCli(["install", "claude", "--output", out]);
|
|
113
|
+
const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
|
|
114
|
+
const matcher = installed.hooks.Notification.flatMap((g) => g.hooks).length
|
|
115
|
+
? installed.hooks.Notification[0].matcher
|
|
116
|
+
: "";
|
|
117
|
+
const documented = new Set([
|
|
118
|
+
"permission_prompt",
|
|
119
|
+
"idle_prompt",
|
|
120
|
+
"auth_success",
|
|
121
|
+
"elicitation_dialog",
|
|
122
|
+
"elicitation_url_dialog",
|
|
123
|
+
"elicitation_complete",
|
|
124
|
+
"elicitation_response",
|
|
125
|
+
"agent_needs_input",
|
|
126
|
+
"agent_completed",
|
|
127
|
+
"quota_auto_resume_fired",
|
|
128
|
+
"quota_auto_resume_stale",
|
|
129
|
+
"quota_auto_resume_disabled",
|
|
130
|
+
]);
|
|
131
|
+
const types = matcher ? matcher.split("|") : [];
|
|
132
|
+
assert.ok(types.length > 0, "Notification hook should list at least one matcher type");
|
|
133
|
+
for (const t of types) {
|
|
134
|
+
assert.ok(documented.has(t), `Notification matcher references unknown type "${t}"`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("bundles hook support scripts into claude/hooks/ and references them via the plugin root", async () => {
|
|
139
|
+
const out = makeTmp();
|
|
140
|
+
await runCli(["install", "claude", "--output", out]);
|
|
141
|
+
|
|
142
|
+
const canonical = readFileSync(join(ROOT, "hooks", "claude", "scripts", "notify.mjs"), "utf-8");
|
|
143
|
+
const bundled = readFileSync(join(out, "hooks", "scripts", "notify.mjs"), "utf-8");
|
|
144
|
+
assert.equal(bundled, canonical);
|
|
145
|
+
|
|
146
|
+
const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
|
|
147
|
+
const notify = installed.hooks.Notification.flatMap((g) => g.hooks)
|
|
148
|
+
.filter((h) => h.command?.includes("notify.mjs"));
|
|
149
|
+
assert.ok(notify.length === 1, "hooks.json should reference the notification script");
|
|
150
|
+
assert.match(notify[0].command, /^node "\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/scripts\/notify\.mjs"$/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("is deterministic across repeated installs into the same output", async () => {
|
|
154
|
+
const out = makeTmp();
|
|
155
|
+
await runCli(["install", "claude", "--output", out]);
|
|
156
|
+
const first = snapshot(out);
|
|
157
|
+
await runCli(["install", "claude", "--output", out]);
|
|
158
|
+
assert.deepStrictEqual(snapshot(out), first);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("--dry-run reports the plan without writing anything", async () => {
|
|
162
|
+
const out = makeTmp();
|
|
163
|
+
const { exitCode, stdout } = await runCli(["install", "claude", "--output", out, "--dry-run"]);
|
|
164
|
+
assert.equal(exitCode, 0);
|
|
165
|
+
assert.ok(/would/i.test(stdout));
|
|
166
|
+
assert.equal(existsSync(join(out, "agents")), false);
|
|
167
|
+
assert.equal(existsSync(join(out, "skills")), false);
|
|
168
|
+
assert.equal(existsSync(join(out, ".claude-plugin")), false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("leaves the committed claude/ subtree untouched when --output is given", async () => {
|
|
172
|
+
const out = makeTmp();
|
|
173
|
+
const beforeSkills = readdirSync(join(ROOT, "claude", "skills")).sort();
|
|
174
|
+
const beforePlugin = readFileSync(join(ROOT, "claude", ".claude-plugin", "plugin.json"), "utf-8");
|
|
175
|
+
await runCli(["install", "claude", "--output", out]);
|
|
176
|
+
assert.deepStrictEqual(readdirSync(join(ROOT, "claude", "skills")).sort(), beforeSkills);
|
|
177
|
+
assert.equal(readFileSync(join(ROOT, "claude", ".claude-plugin", "plugin.json"), "utf-8"), beforePlugin);
|
|
178
|
+
});
|
|
179
|
+
});
|