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:
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 -->";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(", ")})`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user