refactor(lanes): drop the workflow skills and Superpowers link; pipelines go dormant

The plugin stops shipping its workflow layer:
- skills/ship-feature and skills/ship-feature-lane, the 18 stage/ship slash
  commands (plugin.json keeps agenthub-doctor/update/open), and all four
  agents/ (qc-local, senior-gate-reviewer, backend-reviewer, frontend-reviewer).
- CLI `agenthub skills install`, `agenthub lanes agents install`,
  `agenthub ship-full|ship-quick`; routes POST /api/skills/install and
  POST /api/lanes/:id/agents/install; the Settings button, the lane Tools
  menu item and the Add-lane setup step that called them.
- The Superpowers auto-install: server/lib/skill-plugins.js, the bootstrap's
  autoInstallSkillPlugins / skillPluginsSettled gate and the doctor row.

Pipelines are kept but dormant: server/data/pipelines/ is deleted, so no
template ships. `default` resolves to EMPTY_PIPELINE (no nodes, progress 0),
createLane still accepts it, POST /api/lanes/:id/stage answers 400
ENOPIPELINE, and the Workspace hides the stage timer, pickers, PipelineMap
and strip stage chip for a lane with no nodes. A template dropped into
DASHBOARD_PIPELINES_DIR turns it all back on. The server suite loads its
templates from server/__tests__/fixtures/pipelines/.

Docs: CLAUDE.md (marker, retired rules), README(.vi), INSTALL, ARCHITECTURE,
docs/LANES|PLUGINS|CLI|API, agenthub-introduction.html.
This commit is contained in:
2026-09-28 17:44:27 +07:00
parent 3c238bc5c4
commit 2b8f83d2dd
72 changed files with 325 additions and 3305 deletions
@@ -0,0 +1,155 @@
{
"id": "default",
"name": "Default feature pipeline",
"skill": "/agenthub:ship-feature",
"nodes": [
{
"id": "intake",
"label": "Intake",
"icon": "📝",
"gate": false,
"command": "/superpowers:brainstorming",
"aliases": ["assigned", "claimed", "start"],
"detect": [
{
"tool": "Agent",
"match": "^Explore$"
},
{
"tool": "Skill",
"match": "brainstorming"
}
]
},
{
"id": "plan",
"label": "Plan",
"icon": "🧭",
"gate": false,
"command": "/superpowers:writing-plans",
"aliases": ["planning", "brainstorm", "design"],
"detect": [
{
"tool": "Skill",
"match": "writing-plans"
},
{
"tool": "Write",
"match": "docs/.*plan.*\\.md"
},
{
"tool": "Agent",
"match": "^Plan$|Software Architect|Backend Architect|UX Architect"
},
{
"tool": "TaskCreate"
}
]
},
{
"id": "implement",
"label": "Implement",
"icon": "🛠",
"gate": false,
"command": "/superpowers:test-driven-development",
"aliases": ["implementing", "coding", "build"],
"detect": [
{
"tool": "Skill",
"match": "executing-plans|subagent-driven-development|test-driven-development"
},
{
"tool": "Edit"
},
{
"tool": "Write",
"match": "^(?!.*(?:^|/)(?:docs|\\.superpowers)/)"
}
]
},
{
"id": "tests",
"label": "Tests",
"icon": "🧪",
"gate": true,
"command": "npm test",
"aliases": ["testing", "unit", "gates", "pre-push-gate"],
"detect": [
{
"tool": "Bash",
"match": "\\b(npm (run )?test|pytest|vitest|jest|go test|cargo test|node\\s+--test|rspec|phpunit|dotnet test|mvn test|gradle test)\\b"
},
{
"tool": "Agent",
"match": "Test Results Analyzer|API Tester|Performance Benchmarker"
}
]
},
{
"id": "review",
"label": "Review",
"icon": "👀",
"gate": true,
"command": "/code-review",
"aliases": ["reviewing", "code-review", "self-review"],
"detect": [
{
"tool": "Skill",
"match": "code-review|requesting-code-review|receiving-code-review"
},
{
"tool": "Bash",
"match": "\\bgh\\b[^;&|]*\\bpr diff\\b"
},
{
"tool": "Agent",
"match": "(?<!senior-gate-)[Rr]eviewer|Evidence Collector|Accessibility Auditor"
}
]
},
{
"id": "gate",
"label": "Gate",
"icon": "🚦",
"gate": true,
"command": "/superpowers:verification-before-completion",
"aliases": ["verify", "verification", "sr-gate", "gate-blocked"],
"detect": [
{
"tool": "Skill",
"match": "verification-before-completion"
},
{
"tool": "Agent",
"match": "senior-gate-reviewer|Reality Checker"
}
]
},
{
"id": "ship",
"label": "Ship",
"icon": "🔀",
"gate": false,
"command": "/superpowers:finishing-a-development-branch",
"aliases": ["pr", "pr-open", "publishing", "commit", "push"],
"detect": [
{
"tool": "Skill",
"match": "finishing-a-development-branch"
},
{
"tool": "Bash",
"match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b|\\bgh\\b[^;&|]*\\bpr create\\b"
}
]
},
{
"id": "done",
"label": "Done",
"icon": "✅",
"gate": false,
"command": "agenthub stage done",
"aliases": ["complete", "completed", "merged"]
}
]
}
@@ -0,0 +1,248 @@
{
"id": "ship-feature",
"name": "Ship feature (lane pipeline)",
"skill": "/agenthub:ship-feature-lane",
"nodes": [
{
"id": "intake",
"label": "Intake",
"icon": "📝",
"gate": false,
"command": "/superpowers:brainstorming",
"aliases": ["assigned", "claimed", "start", "bootstrapping"],
"detect": [
{
"tool": "Skill",
"match": "brainstorming"
},
{
"tool": "Bash",
"match": "\\bagenthub\\b[^;&|]*\\bfeature activate\\b"
},
{
"tool": "Agent",
"match": "^Explore$"
}
]
},
{
"id": "plan",
"label": "Plan",
"icon": "🧭",
"gate": false,
"command": "/superpowers:writing-plans",
"aliases": ["planning", "brainstorm", "design"],
"detect": [
{
"tool": "Skill",
"match": "writing-plans"
},
{
"tool": "Write",
"match": "docs/superpowers/specs/lane-.*\\.md"
},
{
"tool": "Agent",
"match": "^Plan$|Software Architect"
},
{
"tool": "TaskCreate"
}
]
},
{
"id": "implementing",
"label": "Implement (TDD)",
"icon": "🛠",
"gate": false,
"command": "/superpowers:test-driven-development",
"aliases": ["implement", "coding", "build"],
"detect": [
{
"tool": "Skill",
"match": "test-driven-development|executing-plans|subagent-driven-development|systematic-debugging"
},
{
"tool": "Edit",
"match": "^(?!.*docs/superpowers/specs/)"
},
{
"tool": "Write",
"match": "^(?!.*(?:^|/)docs/)"
}
]
},
{
"id": "gates",
"label": "CI gates + preflight",
"icon": "🧪",
"gate": true,
"command": "agenthub lanes hook ci-gate",
"aliases": ["pre-push-gate", "tests", "migration-collision", "sync-conflict"],
"detect": [
{
"tool": "Bash",
"match": "\\bagenthub\\b[^;&|]*(\\bhook ci-gate\\b|\\bsync-base --check\\b)"
},
{
"tool": "Bash",
"match": "\\b(npm (run )?test|pytest|vitest|jest|go test|cargo test|node\\s+--test|rspec|phpunit|dotnet test|mvn test|gradle test)\\b"
}
]
},
{
"id": "e2e-feature",
"label": "E2e on feature branch",
"icon": "🧪",
"gate": false,
"command": "agenthub lanes up --qc",
"aliases": ["e2e", "e2e-scoped", "booting", "live"],
"detect": [
{
"tool": "Bash",
"match": "\\bagenthub\\b[^;&|]*(\\bup --qc\\b|\\bhook e2e\\b)"
}
]
},
{
"id": "e2e-feature-passed",
"label": "E2e passed",
"icon": "🧪",
"gate": true,
"command": "agenthub stage e2e-feature-passed",
"aliases": ["e2e-passed"]
},
{
"id": "review",
"label": "Code review",
"icon": "👀",
"gate": true,
"command": "/code-review",
"aliases": ["reviewing", "code-review", "self-review"],
"detect": [
{
"tool": "Skill",
"match": "code-review|requesting-code-review|receiving-code-review"
},
{
"tool": "Agent",
"match": "(?<!senior-gate-)[Rr]eviewer"
}
]
},
{
"id": "qc-plan",
"label": "QC plan",
"icon": "📋",
"gate": false,
"command": "agenthub stage qc-plan",
"aliases": []
},
{
"id": "qc",
"label": "Browser QC",
"icon": "🔍",
"gate": true,
"command": "agenthub lanes proof-link",
"aliases": [],
"detect": [
{
"tool": "Agent",
"match": "qc-local|Evidence Collector|Accessibility Auditor"
},
{
"tool": "Bash",
"match": "\\bagenthub\\b[^;&|]*\\blanes proof-link\\b"
}
]
},
{
"id": "gate",
"label": "Senior GO/NO-GO gate",
"icon": "🚦",
"gate": true,
"command": "/superpowers:verification-before-completion",
"aliases": ["sr-gate", "verify", "verification", "gate-blocked"],
"detect": [
{
"tool": "Agent",
"match": "senior-gate-reviewer|Reality Checker"
},
{
"tool": "Skill",
"match": "verification-before-completion"
}
]
},
{
"id": "publishing",
"label": "Publish PR",
"icon": "🔀",
"gate": false,
"command": "/superpowers:finishing-a-development-branch",
"aliases": ["push"],
"detect": [
{
"tool": "Skill",
"match": "finishing-a-development-branch"
},
{
"tool": "Bash",
"match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b"
}
]
},
{
"id": "pr-open",
"label": "PR open",
"icon": "🔀",
"gate": false,
"command": "gh pr create --base development",
"aliases": ["ship", "pr", "push-conflict", "push-revalidate"],
"detect": [
{
"tool": "Bash",
"match": "\\bgh\\b[^;&|]*\\bpr create\\b"
}
]
},
{
"id": "reported",
"label": "Reported",
"icon": "📣",
"gate": false,
"command": "agenthub stage reported",
"aliases": []
},
{
"id": "watching-pr",
"label": "Watching PR",
"icon": "👁",
"gate": false,
"command": "agenthub lanes ci status",
"aliases": ["pr-comment-fix"],
"detect": [
{
"tool": "Bash",
"match": "\\bgh\\b[^;&|]*\\bpr view\\b"
}
]
},
{
"id": "merged",
"label": "Merged — post-verify",
"icon": "🔗",
"gate": false,
"command": "agenthub stage merged",
"aliases": []
},
{
"id": "done",
"label": "Done",
"icon": "✅",
"gate": false,
"command": "agenthub stage done",
"aliases": ["complete", "completed"]
}
]
}
+3 -85
View File
@@ -1,7 +1,6 @@
/**
* @file Tests for lane-agents.js: agent-template installation into a lane's
* .claude/agents/, including path resolution, git-exclude idempotence, and
* missing-repo rejection.
* @file Tests for lane-agents.js's removeStageDoc and the codex pipeline
* cleanup it pairs with (clearCodexPipelines).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,7 +17,7 @@ const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const lanesLib = require("../lib/lanes");
const { installAgents, removeStageDoc } = require("../lib/lane-agents");
const { removeStageDoc } = require("../lib/lane-agents");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
@@ -48,87 +47,6 @@ function makeRepoDir() {
return cwd;
}
/** Create a lane record backed by an existing directory. */
function makeLane(cwd) {
return lanesLib.createLane({ title: "agents-test", cwd, kind: "managed" });
}
describe("installAgents", () => {
it("copies agent templates from agents/ dir into lane's .claude/agents/", async () => {
const cwd = makeRepoDir();
const lane = makeLane(cwd);
const result = await installAgents(lane);
assert.deepEqual([...result.installed].sort(), ["qc-local.md", "senior-gate-reviewer.md"]);
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents", "qc-local.md")));
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents", "senior-gate-reviewer.md")));
// Verify the source path is new (agents/) not old (data/agent-templates/)
const oldPath = path.join(__dirname, "..", "data", "agent-templates", "ship-feature-lane");
assert.equal(fs.existsSync(oldPath), false);
});
it("throws ENOTGITREPO when lane cwd has no .git", async () => {
const cwd = path.join(SUITE_ROOT, "no-git");
fs.mkdirSync(cwd, { recursive: true });
const lane = makeLane(cwd);
await assert.rejects(
() => installAgents(lane),
(err) => err.code === "ENOTGITREPO"
);
});
it("adds .claude/agents/ line to git info/exclude idempotently", async () => {
const cwd = makeRepoDir();
const lane = makeLane(cwd);
await installAgents(lane); // first
await installAgents(lane); // reinstall — should not duplicate
const excludePath = path.join(cwd, ".git", "info", "exclude");
const content = fs.readFileSync(excludePath, "utf8");
const matches = content.split("\n").filter((l) => l === ".claude/agents/");
assert.equal(matches.length, 1);
});
it("overwrites existing agents on reinstall", async () => {
const cwd = makeRepoDir();
const lane = makeLane(cwd);
await installAgents(lane);
// Tamper with the installed file
const agentPath = path.join(cwd, ".claude", "agents", "qc-local.md");
fs.writeFileSync(agentPath, "tampered");
// Reinstall
await installAgents(lane);
const content = fs.readFileSync(agentPath, "utf8");
assert.ok(content.includes("#"), "reinstalled file should look like markdown, not 'tampered'");
assert.notEqual(content, "tampered");
});
it("creates .claude/agents/ dir if it does not exist", async () => {
const cwd = makeRepoDir();
const lane = makeLane(cwd);
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
await installAgents(lane);
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents")));
});
for (const tool of ["codex", "antigravity"]) {
it(`installs nothing for a ${tool} lane — no .claude/agents/, no AGENTS.md`, async () => {
const cwd = makeRepoDir();
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool });
const result = await installAgents(lane);
assert.deepEqual(result.installed, []);
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
});
}
});
describe("codex pipeline cleanup", () => {
const START = "<!-- agenthub:stage:start -->";
const END = "<!-- agenthub:stage:end -->";
+5 -31
View File
@@ -20,6 +20,11 @@ const SOURCE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-worktree-sou
const REMOTE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-worktree-remote-"));
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_PIPELINES_DIR = PIPELINE_DIR;
// AgentHub ships no pipeline template; the CLI's stage commands are tested
// against the two old ones kept as fixtures.
for (const f of ["default.json", "ship-feature.json"]) {
fs.copyFileSync(path.join(__dirname, "fixtures", "pipelines", f), path.join(PIPELINE_DIR, f));
}
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.LANES_ROOT = LANES_ROOT;
@@ -675,37 +680,6 @@ describe("agenthub lanes — inferred stage detection", () => {
});
});
describe("agenthub ship-full / agenthub ship-quick", () => {
it("ship-quick prints the 8-stage pipeline's skill, ship-full the 16-stage one", async () => {
const quick = await cli(["ship-quick", "add", "an", "export", "button", "--print"], LANE_DIR);
assert.equal(quick.status, 0, quick.stderr);
assert.match(quick.stdout, /claude "\/agenthub:ship-feature add an export button"/);
const full = await cli(["ship-full", "add", "an", "export", "button", "--print"], LANE_DIR);
assert.equal(full.status, 0, full.stderr);
assert.match(full.stdout, /claude "\/agenthub:ship-feature-lane add an export button"/);
});
it("--print switches nothing: the lane keeps whatever template it was on", async () => {
const before = await get(`/api/lanes`);
const lane = before.body.lanes.find((l) => l.cwd === LANE_DIR);
const r = await cli(["ship-full", "anything", "--print"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
const after = await get(`/api/lanes`);
assert.equal(after.body.lanes.find((l) => l.id === lane.id).pipeline, lane.pipeline);
});
it("exits non-zero with the command's own usage when no requirement is given", async () => {
const r = await cli(["ship-full"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /usage: agenthub ship-full/);
const q = await cli(["ship-quick"], LANE_DIR);
assert.notEqual(q.status, 0);
assert.match(q.stderr, /usage: agenthub ship-quick/);
});
});
describe("agenthub lanes node", () => {
it("with no node id, lists every node and its command, and exits non-zero with usage", async () => {
const r = await cli(["lanes", "node"], LANE_DIR);
@@ -0,0 +1,82 @@
/**
* @file With NO pipeline template installed (the shipped state since the
* built-in templates were removed), lanes must still be created, rendered and
* reported on: getPipeline() answers an empty pipeline, `default` stays a
* valid id, and a stage declaration is refused with a plain explanation.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "pipelines-dormant-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
process.env.AGENTHUB_SHELL_PATH = "0";
const savedDir = process.env.DASHBOARD_PIPELINES_DIR;
delete process.env.DASHBOARD_PIPELINES_DIR;
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const http = require("node:http");
const pipelines = require("../lib/pipelines");
pipelines.reload();
const lanes = require("../lib/lanes");
const { createApp } = require("../index");
let app;
let base;
const request = (method, p, body) =>
fetch(`${base}${p}`, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
before(async () => {
app = http.createServer(createApp());
await new Promise((r) => app.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${app.address().port}`;
});
after(async () => {
await new Promise((r) => app.close(r));
if (savedDir !== undefined) process.env.DASHBOARD_PIPELINES_DIR = savedDir;
pipelines.reload();
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("no pipeline template installed", () => {
it("ships no template and answers an empty default pipeline", () => {
assert.deepEqual(pipelines.listPipelines(), []);
const p = pipelines.getPipeline("default");
assert.equal(p.id, "default");
assert.deepEqual(p.nodes, []);
assert.equal(pipelines.getPipeline("anything").nodes.length, 0);
assert.deepEqual(pipelines.nodeStates(p, { stage: "plan", stages: {} }), []);
assert.equal(pipelines.progressPct(p, { stage: "plan" }), 0);
});
it("still creates a lane on the default pipeline and renders it", () => {
const lane = lanes.createLane({ cwd: path.join(ROOT, "a"), title: "a" });
assert.equal(lane.pipeline, "default");
const payload = lanes.lanePayload(lane);
assert.deepEqual(payload.pipeline_nodes, []);
assert.equal(payload.progress, 0);
});
it("refuses a stage declaration with a plain reason", async () => {
const lane = lanes.createLane({ cwd: path.join(ROOT, "b"), title: "b" });
const r = await request("POST", `/api/lanes/${lane.id}/stage`, { stage: "plan" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ENOPIPELINE");
assert.match(r.body.error.message, /no pipeline template is installed/);
});
it("lists no pipelines over the API and still serves the lane list", async () => {
assert.deepEqual((await request("GET", "/api/lanes/pipelines")).body, { pipelines: [] });
assert.equal((await request("GET", "/api/lanes")).status, 200);
});
});
-30
View File
@@ -185,7 +185,6 @@ describe("fast path", () => {
pluginVersion: pkg.version,
depsHash: boot.depsHash(),
pluginRoot: ROOT,
skillPluginsSettled: "nothing-missing",
},
RT
);
@@ -206,35 +205,6 @@ describe("fast path", () => {
});
});
describe("skill-plugin auto-install gate", () => {
// Both cases here must return WITHOUT reaching server/lib/skill-plugins.js —
// that module shells out to `claude plugin install`, and no test may.
beforeEach(resetRuntime);
after(() => delete process.env.AGENTHUB_AUTO_INSTALL_PLUGINS);
for (const flag of ["0", "false", "off"]) {
it(`is off when AGENTHUB_AUTO_INSTALL_PLUGINS=${flag}`, () => {
process.env.AGENTHUB_AUTO_INSTALL_PLUGINS = flag;
assert.equal(boot.autoInstallSkillPlugins(RT), "off");
});
}
it("runs once: a settled state is returned verbatim, not re-attempted", () => {
delete process.env.AGENTHUB_AUTO_INSTALL_PLUGINS;
boot.writeState({ skillPluginsSettled: "installed" }, RT);
// Verbatim matters: writeState records whatever comes back, so a marker
// here would erase the only record of what actually happened.
assert.equal(boot.autoInstallSkillPlugins(RT), "installed");
});
it("is off under the flag even when forced, so /agenthub-update honours the opt-out", () => {
process.env.AGENTHUB_AUTO_INSTALL_PLUGINS = "0";
boot.writeState({ skillPluginsSettled: "failed" }, RT);
assert.equal(boot.autoInstallSkillPlugins(RT, { force: true }), "off");
});
});
describe("bootstrap lock", () => {
beforeEach(resetRuntime);
@@ -1,123 +0,0 @@
/**
* @file Tests the `Skill plugins` row of scripts/plugin-doctor.js — the check
* that tells a fresh install its pipelines name slash commands no installed
* plugin provides (today: `superpowers`, which `/agenthub:ship-feature-lane` drives
* step by step). The row must WARN and never FAIL: without that plugin the
* dashboard, hooks, CLI and MCP all still work, so failing a doctor run over it
* would cry wolf. Every case runs against an injected temp CLAUDE_HOME.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, after } = 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-doctor-skills-"));
process.env.CLAUDE_HOME = TMP_HOME;
const { diagnose } = require("../../scripts/plugin-doctor");
after(() => {
fs.rmSync(TMP_HOME, { recursive: true, force: true });
delete process.env.CLAUDE_HOME;
});
/** Rewrite the injected Claude home, then read back just the row under test. */
function rowFor({ plugins = {}, enabledPlugins = {}, userSkills = [], installDirs = [] }) {
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({ enabledPlugins }));
// Created AFTER the wipe above: readPlugins() only counts an instance whose
// installPath still exists on disk.
for (const dir of installDirs) fs.mkdirSync(dir, { recursive: true });
for (const name of userSkills) {
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 row = diagnose().find((r) => r.label === "Skill plugins");
assert.ok(row, "the doctor must always emit a Skill plugins row");
return row;
}
const SP_DIR = path.join(TMP_HOME, "plugins", "sp");
/** An installed superpowers instance; `enabled` mirrors settings.json. */
function installedSuperpowers(enabled) {
return {
plugins: { "superpowers@official": [{ installPath: SP_DIR, scope: "user", version: "1.0.0" }] },
enabledPlugins: { "superpowers@official": enabled },
installDirs: [SP_DIR],
};
}
/** The skills the built-in templates name, read from the templates themselves. */
function requiredSuperpowersSkills() {
const { listPipelines } = require("../lib/pipelines");
const names = new Set();
for (const p of listPipelines()) {
for (const cmd of [p.skill, ...p.nodes.map((n) => n.command)]) {
const m = /^\/superpowers:([A-Za-z0-9_-]+)/.exec(cmd || "");
if (m) names.add(m[1]);
}
}
return [...names];
}
describe("plugin-doctor: Skill plugins row", () => {
it("warns, never fails, when a plugin the pipelines name is not installed", () => {
const row = rowFor({});
assert.equal(row.level, "warn", "a missing skill plugin must not fail the doctor run");
assert.match(row.detail, /superpowers/);
assert.match(row.detail, /claude plugin install superpowers/);
});
it("names the specific skills that are unavailable, so the reader can verify the claim", () => {
const row = rowFor({});
for (const skill of requiredSuperpowersSkills()) {
assert.match(row.detail, new RegExp(skill.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
}
});
it("passes once the plugin is installed and enabled", () => {
const row = rowFor(installedSuperpowers(true));
assert.equal(row.level, "ok", row.detail);
assert.match(row.detail, /superpowers available/);
});
it("treats an explicitly disabled plugin as missing - Claude Code will not resolve its skills", () => {
const row = rowFor(installedSuperpowers(false));
assert.equal(row.level, "warn", row.detail);
assert.match(row.detail, /superpowers/);
});
it("accepts the skills copied into ~/.claude/skills instead of installed as a plugin", () => {
// A legitimate setup. Reporting it as missing would train the reader to
// ignore this row, which is the only failure mode a warning really has.
const row = rowFor({ userSkills: requiredSuperpowersSkills() });
assert.equal(row.level, "ok", row.detail);
});
it("still warns when only SOME of the required skills are present locally", () => {
const [first, ...rest] = requiredSuperpowersSkills();
const row = rowFor({ userSkills: [first] });
assert.equal(row.level, "warn", row.detail);
assert.ok(!row.detail.includes(`${first},`), `${first} is present and must not be listed`);
for (const skill of rest) assert.match(row.detail, new RegExp(skill));
});
});
@@ -242,21 +242,5 @@ describe("plugin marketplace", () => {
const listed = new Set(manifest.commands.map((rel) => path.basename(rel)));
for (const f of onDisk) assert.ok(listed.has(f), `${f} is not in plugin.json commands`);
});
it("gives every pipeline node of the shipped templates its own slash command", () => {
// /intake, /qc, /gate … are how a node is run from inside a session; a
// node with no command file can only be reached from a terminal.
const { listPipelines } = require("../lib/pipelines");
const listed = new Set(manifest.commands.map((rel) => path.basename(rel, ".md")));
for (const p of listPipelines().filter((x) => ["default", "ship-feature"].includes(x.id))) {
for (const n of p.nodes) {
const names = [n.id, ...(n.aliases || [])];
assert.ok(
names.some((name) => listed.has(name)),
`${p.id}/${n.id} has no slash command (tried ${names.join(", ")})`
);
}
}
});
});
});
-289
View File
@@ -1,289 +0,0 @@
/**
* @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, []);
});
});
describe("runClaudePlugin: the env it spawns with", () => {
// Every other test in this file injects `run`, so nothing exercises the real
// spawn. That gap is how a wrong CLAUDE_CONFIG_DIR pin lived here: the CLI
// writes `.claude.json` INSIDE that dir, while cc-discovery reads it beside
// ~/.claude by default, so pinning it unconditionally aimed writes at a file
// nothing reads. cc-plugin-cli.js's runClaude has the mirror of this test.
const PRINT_ENV = 'printf "%s\\n" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-<unset>}"';
function stubClaude(body) {
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skillplug-bin-"));
const file = path.join(bin, "claude");
fs.writeFileSync(file, `#!/usr/bin/env bash\n${body}\n`);
fs.chmodSync(file, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
fs.rmSync(bin, { recursive: true, force: true });
};
}
it("leaves CLAUDE_CONFIG_DIR unset when CLAUDE_HOME is not set", () => {
const original = process.env.CLAUDE_HOME;
delete process.env.CLAUDE_HOME;
const restore = stubClaude(PRINT_ENV);
try {
const res = skillPlugins.runClaudePlugin(["plugin", "list"], 10_000);
assert.equal(res.output.trim(), "CLAUDE_CONFIG_DIR=<unset>");
} finally {
restore();
if (original !== undefined) process.env.CLAUDE_HOME = original;
}
});
it("pins CLAUDE_CONFIG_DIR to CLAUDE_HOME when the operator set it", () => {
const original = process.env.CLAUDE_HOME;
process.env.CLAUDE_HOME = "/tmp/some-other-claude-home";
const restore = stubClaude(PRINT_ENV);
try {
const res = skillPlugins.runClaudePlugin(["plugin", "list"], 10_000);
assert.equal(res.output.trim(), "CLAUDE_CONFIG_DIR=/tmp/some-other-claude-home");
} finally {
restore();
if (original === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = original;
}
});
});
-83
View File
@@ -1,83 +0,0 @@
/**
* @file Tests for skills-install.js: skill-copy path resolution (source:
* repoRoot/skills/ not repoRoot/.claude/skills/) and error handling.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skills-"));
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const { installShipFeatureLaneSkill } = require("../lib/skills-install");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
describe("installShipFeatureLaneSkill", () => {
it("resolves source from repoRoot/skills/ (new path), not .claude/skills/", () => {
// Build a fake repoRoot with the skill only under skills/ship-feature-lane/
const repoRoot = path.join(SUITE_ROOT, "mock-repo");
const src = path.join(repoRoot, "skills", "ship-feature-lane");
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, "SKILL.md"), "# Fake Ship Feature Lane Skill");
// The old path should NOT exist
const oldPath = path.join(repoRoot, ".claude", "skills", "ship-feature-lane", "SKILL.md");
assert.equal(fs.existsSync(oldPath), false);
// Override HOME so we don't touch real ~/.claude
const origHome = process.env.HOME;
const fakeHome = path.join(SUITE_ROOT, "fake-home");
process.env.HOME = fakeHome;
try {
const result = installShipFeatureLaneSkill({ repoRoot });
assert.equal(result.installed, true);
assert.ok(result.path.startsWith(fakeHome));
const installedFile = path.join(result.path, "SKILL.md");
assert.ok(fs.existsSync(installedFile));
assert.equal(fs.readFileSync(installedFile, "utf8"), "# Fake Ship Feature Lane Skill");
} finally {
process.env.HOME = origHome;
}
});
it("throws ENOSKILLSRC when skills/ship-feature-lane/ is missing", () => {
const repoRoot = path.join(SUITE_ROOT, "empty-repo");
fs.mkdirSync(repoRoot, { recursive: true });
assert.throws(
() => installShipFeatureLaneSkill({ repoRoot }),
(err) => err.code === "ENOSKILLSRC"
);
});
it("overwrites an existing installed skill (force: true)", () => {
const repoRoot = path.join(SUITE_ROOT, "mock-repo-overwrite");
const src = path.join(repoRoot, "skills", "ship-feature-lane");
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, "SKILL.md"), "# Updated skill");
const origHome = process.env.HOME;
const fakeHome = path.join(SUITE_ROOT, "fake-home-overwrite");
process.env.HOME = fakeHome;
try {
// First install
installShipFeatureLaneSkill({ repoRoot });
// Tamper with installed copy
const existing = path.join(fakeHome, ".claude", "skills", "ship-feature-lane", "SKILL.md");
fs.writeFileSync(existing, "# Tampered");
// Reinstall — should overwrite
installShipFeatureLaneSkill({ repoRoot });
assert.equal(fs.readFileSync(existing, "utf8"), "# Updated skill");
} finally {
process.env.HOME = origHome;
}
});
});