17d56d0e01
Clean break, no compatibility alias. Renames the product name, the CLI binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` -> `AGENTHUB_`), the Prometheus metric namespace (`ccam_*` -> `agenthub_*`), the tmux session prefix (`ccam-lane-<id>` -> `agenthub-lane-<id>`), the per-repo runtime profile directory (`.ccam/profile` -> `.agenthub/profile`), the lanes state root (`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` -> `/agenthub:*`). Because there is no alias, an existing install has to be updated by hand: rename its `.ccam/profile` directory and any `CCAM_*` variables, and reinstall the plugin under its new name. Two other name families are deliberately left alone here, since they are not just strings: the `Claude-Code-Monitor` repository/marketplace name, and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard` runtime directory, which holds the live database and needs a migration rather than a rename. Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
241 lines
8.5 KiB
JavaScript
241 lines
8.5 KiB
JavaScript
/**
|
|
* @file Tests server/lib/skill-plugins.js — the dependency the ship-feature
|
|
* pipelines have on other plugins (`superpowers` today): deriving it from the
|
|
* templates, deciding what is actually missing, and the unattended install the
|
|
* SessionStart bootstrap performs.
|
|
*
|
|
* The security-relevant case is covered first: a pipeline template is a file
|
|
* anyone may drop into DASHBOARD_PIPELINES_DIR, so a plugin name appearing
|
|
* there must be REPORTED but never INSTALLED. Every case injects a fake `run`
|
|
* — no test may reach a real `claude plugin install`.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, after, beforeEach } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skill-plugins-home-"));
|
|
const TMP_PIPELINES = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skill-plugins-tpl-"));
|
|
process.env.CLAUDE_HOME = TMP_HOME;
|
|
|
|
const skillPlugins = require("../lib/skill-plugins");
|
|
const pipelines = require("../lib/pipelines");
|
|
|
|
after(() => {
|
|
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
|
fs.rmSync(TMP_PIPELINES, { recursive: true, force: true });
|
|
delete process.env.CLAUDE_HOME;
|
|
delete process.env.DASHBOARD_PIPELINES_DIR;
|
|
pipelines.reload();
|
|
});
|
|
|
|
/** An empty Claude home: no plugins installed, no user skills. */
|
|
function emptyHome() {
|
|
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
|
fs.mkdirSync(path.join(TMP_HOME, "plugins"), { recursive: true });
|
|
fs.mkdirSync(path.join(TMP_HOME, "skills"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(TMP_HOME, "plugins", "installed_plugins.json"),
|
|
JSON.stringify({ plugins: {} })
|
|
);
|
|
fs.writeFileSync(path.join(TMP_HOME, "settings.json"), JSON.stringify({}));
|
|
}
|
|
|
|
/** Point the loader at a throwaway template naming `command` on one node. */
|
|
function useTemplate(doc) {
|
|
fs.writeFileSync(path.join(TMP_PIPELINES, "test.json"), JSON.stringify(doc));
|
|
process.env.DASHBOARD_PIPELINES_DIR = TMP_PIPELINES;
|
|
pipelines.reload();
|
|
}
|
|
|
|
function forgetTemplate() {
|
|
fs.rmSync(path.join(TMP_PIPELINES, "test.json"), { force: true });
|
|
delete process.env.DASHBOARD_PIPELINES_DIR;
|
|
pipelines.reload();
|
|
}
|
|
|
|
/** Records the argv of every call and answers with a fixed result. */
|
|
function fakeRun(results) {
|
|
const calls = [];
|
|
const queue = [...results];
|
|
const run = (args) => {
|
|
calls.push(args);
|
|
return queue.length > 1 ? queue.shift() : queue[0];
|
|
};
|
|
run.calls = calls;
|
|
return run;
|
|
}
|
|
|
|
const collect = () => {
|
|
const lines = [];
|
|
const log = (line) => lines.push(line);
|
|
log.lines = lines;
|
|
return log;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
emptyHome();
|
|
forgetTemplate();
|
|
});
|
|
|
|
describe("requiredSkillPlugins", () => {
|
|
it("collects the plugin behind every /plugin:skill a template names, node or pipeline level", () => {
|
|
const required = skillPlugins.requiredSkillPlugins();
|
|
|
|
assert.ok(required.has("superpowers"), "the built-in templates name superpowers");
|
|
assert.ok(required.get("superpowers").has("writing-plans"));
|
|
});
|
|
|
|
it("ignores agenthub's own commands and anything that is not a /plugin:skill", () => {
|
|
useTemplate({
|
|
id: "tpl-shell",
|
|
skill: "/agenthub:ship-feature",
|
|
nodes: [
|
|
{ id: "a", command: "npm test" },
|
|
{ id: "b", command: "/reload-plugins" },
|
|
{ id: "c", command: "" },
|
|
],
|
|
});
|
|
|
|
const required = skillPlugins.requiredSkillPlugins();
|
|
|
|
assert.equal(required.has("agenthub"), false, "agenthub is the plugin asking");
|
|
// Only the built-ins' superpowers requirement survives.
|
|
assert.deepEqual([...required.keys()], ["superpowers"]);
|
|
});
|
|
});
|
|
|
|
describe("missingSkillPlugins", () => {
|
|
it("reports a required plugin that is absent, with the skills that need it", () => {
|
|
const missing = skillPlugins.missingSkillPlugins();
|
|
const sp = missing.find((m) => m.name === "superpowers");
|
|
|
|
assert.ok(sp, "superpowers must be reported missing in an empty home");
|
|
assert.ok(sp.skills.includes("writing-plans"));
|
|
assert.equal(sp.autoInstall.spec, "superpowers@claude-plugins-official");
|
|
});
|
|
|
|
it("counts the skills as satisfied when they sit in ~/.claude/skills instead", () => {
|
|
for (const name of skillPlugins.requiredSkillPlugins().get("superpowers")) {
|
|
fs.mkdirSync(path.join(TMP_HOME, "skills", name), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(TMP_HOME, "skills", name, "SKILL.md"),
|
|
`---\nname: ${name}\n---\n\nbody\n`
|
|
);
|
|
}
|
|
|
|
assert.deepEqual(skillPlugins.missingSkillPlugins(), []);
|
|
});
|
|
|
|
it("counts an installed but explicitly disabled plugin as missing", () => {
|
|
const dir = path.join(TMP_HOME, "plugins", "sp");
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(TMP_HOME, "plugins", "installed_plugins.json"),
|
|
JSON.stringify({ plugins: { "superpowers@x": [{ installPath: dir, scope: "user" }] } })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(TMP_HOME, "settings.json"),
|
|
JSON.stringify({ enabledPlugins: { "superpowers@x": false } })
|
|
);
|
|
|
|
assert.ok(skillPlugins.missingSkillPlugins().some((m) => m.name === "superpowers"));
|
|
});
|
|
});
|
|
|
|
describe("autoInstallSkillPlugins", () => {
|
|
it("installs an allowlisted plugin with an argv array, never a shell string", () => {
|
|
const run = fakeRun([{ ok: true, output: "" }]);
|
|
const log = collect();
|
|
|
|
const summary = skillPlugins.autoInstallSkillPlugins(log, { run });
|
|
|
|
assert.deepEqual(summary.installed, ["superpowers"]);
|
|
assert.deepEqual(run.calls, [
|
|
["plugin", "install", "superpowers@claude-plugins-official", "--scope", "user", "-y"],
|
|
]);
|
|
assert.ok(
|
|
log.lines.some((l) => l.includes("/reload-plugins")),
|
|
"the user must be told the current session will not see it yet"
|
|
);
|
|
});
|
|
|
|
it("registers the marketplace and retries exactly once when the install fails", () => {
|
|
const run = fakeRun([
|
|
{ ok: false, output: "unknown marketplace" },
|
|
{ ok: true, output: "" },
|
|
{ ok: true, output: "" },
|
|
]);
|
|
|
|
const summary = skillPlugins.autoInstallSkillPlugins(collect(), { run });
|
|
|
|
assert.deepEqual(summary.installed, ["superpowers"]);
|
|
assert.equal(run.calls.length, 3);
|
|
assert.deepEqual(run.calls[1], [
|
|
"plugin",
|
|
"marketplace",
|
|
"add",
|
|
"anthropics/claude-plugins-official",
|
|
"--scope",
|
|
"user",
|
|
]);
|
|
});
|
|
|
|
it("reports a failure with the manual command, and never throws", () => {
|
|
const run = fakeRun([{ ok: false, output: "the `claude` CLI is not on PATH" }]);
|
|
const log = collect();
|
|
|
|
const summary = skillPlugins.autoInstallSkillPlugins(log, { run });
|
|
|
|
assert.deepEqual(summary.failed, ["superpowers"]);
|
|
assert.ok(
|
|
log.lines.some((l) =>
|
|
l.includes("claude plugin install superpowers@claude-plugins-official")
|
|
),
|
|
"a failed auto-install must leave the user a command to run"
|
|
);
|
|
});
|
|
|
|
// The one that matters. A template is a file on disk; if the plugin name in
|
|
// it drove the install, dropping a JSON file into DASHBOARD_PIPELINES_DIR
|
|
// would run an arbitrary vendor's install command on the next session.
|
|
it("REFUSES to install a plugin a template names but the allowlist does not", () => {
|
|
useTemplate({
|
|
id: "tpl-evil",
|
|
name: "Untrusted",
|
|
nodes: [{ id: "a", command: "/attacker-plugin:payload" }],
|
|
});
|
|
const run = fakeRun([{ ok: true, output: "" }]);
|
|
|
|
const summary = skillPlugins.autoInstallSkillPlugins(collect(), { run });
|
|
|
|
assert.ok(summary.skipped.includes("attacker-plugin"), "it must be reported as skipped");
|
|
assert.equal(
|
|
run.calls.some((args) => args.join(" ").includes("attacker-plugin")),
|
|
false,
|
|
"no claude invocation may ever mention a plugin outside AUTO_INSTALLABLE"
|
|
);
|
|
// It is still visible to the reader, just never installed.
|
|
assert.ok(skillPlugins.missingSkillPlugins().some((m) => m.name === "attacker-plugin"));
|
|
});
|
|
|
|
it("does nothing at all when every requirement already resolves", () => {
|
|
for (const name of skillPlugins.requiredSkillPlugins().get("superpowers")) {
|
|
fs.mkdirSync(path.join(TMP_HOME, "skills", name), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(TMP_HOME, "skills", name, "SKILL.md"),
|
|
`---\nname: ${name}\n---\n\nbody\n`
|
|
);
|
|
}
|
|
const run = fakeRun([{ ok: true, output: "" }]);
|
|
|
|
const summary = skillPlugins.autoInstallSkillPlugins(collect(), { run });
|
|
|
|
assert.deepEqual(summary.attempted, []);
|
|
assert.deepEqual(run.calls, []);
|
|
});
|
|
});
|