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
@@ -9,11 +9,7 @@
"icon": "📝",
"gate": false,
"command": "/superpowers:brainstorming",
"aliases": [
"assigned",
"claimed",
"start"
],
"aliases": ["assigned", "claimed", "start"],
"detect": [
{
"tool": "Agent",
@@ -31,11 +27,7 @@
"icon": "🧭",
"gate": false,
"command": "/superpowers:writing-plans",
"aliases": [
"planning",
"brainstorm",
"design"
],
"aliases": ["planning", "brainstorm", "design"],
"detect": [
{
"tool": "Skill",
@@ -60,11 +52,7 @@
"icon": "🛠",
"gate": false,
"command": "/superpowers:test-driven-development",
"aliases": [
"implementing",
"coding",
"build"
],
"aliases": ["implementing", "coding", "build"],
"detect": [
{
"tool": "Skill",
@@ -85,12 +73,7 @@
"icon": "🧪",
"gate": true,
"command": "npm test",
"aliases": [
"testing",
"unit",
"gates",
"pre-push-gate"
],
"aliases": ["testing", "unit", "gates", "pre-push-gate"],
"detect": [
{
"tool": "Bash",
@@ -108,11 +91,7 @@
"icon": "👀",
"gate": true,
"command": "/code-review",
"aliases": [
"reviewing",
"code-review",
"self-review"
],
"aliases": ["reviewing", "code-review", "self-review"],
"detect": [
{
"tool": "Skill",
@@ -134,12 +113,7 @@
"icon": "🚦",
"gate": true,
"command": "/superpowers:verification-before-completion",
"aliases": [
"verify",
"verification",
"sr-gate",
"gate-blocked"
],
"aliases": ["verify", "verification", "sr-gate", "gate-blocked"],
"detect": [
{
"tool": "Skill",
@@ -157,13 +131,7 @@
"icon": "🔀",
"gate": false,
"command": "/superpowers:finishing-a-development-branch",
"aliases": [
"pr",
"pr-open",
"publishing",
"commit",
"push"
],
"aliases": ["pr", "pr-open", "publishing", "commit", "push"],
"detect": [
{
"tool": "Skill",
@@ -181,11 +149,7 @@
"icon": "✅",
"gate": false,
"command": "agenthub stage done",
"aliases": [
"complete",
"completed",
"merged"
]
"aliases": ["complete", "completed", "merged"]
}
]
}
@@ -9,12 +9,7 @@
"icon": "📝",
"gate": false,
"command": "/superpowers:brainstorming",
"aliases": [
"assigned",
"claimed",
"start",
"bootstrapping"
],
"aliases": ["assigned", "claimed", "start", "bootstrapping"],
"detect": [
{
"tool": "Skill",
@@ -36,11 +31,7 @@
"icon": "🧭",
"gate": false,
"command": "/superpowers:writing-plans",
"aliases": [
"planning",
"brainstorm",
"design"
],
"aliases": ["planning", "brainstorm", "design"],
"detect": [
{
"tool": "Skill",
@@ -65,11 +56,7 @@
"icon": "🛠",
"gate": false,
"command": "/superpowers:test-driven-development",
"aliases": [
"implement",
"coding",
"build"
],
"aliases": ["implement", "coding", "build"],
"detect": [
{
"tool": "Skill",
@@ -91,12 +78,7 @@
"icon": "🧪",
"gate": true,
"command": "agenthub lanes hook ci-gate",
"aliases": [
"pre-push-gate",
"tests",
"migration-collision",
"sync-conflict"
],
"aliases": ["pre-push-gate", "tests", "migration-collision", "sync-conflict"],
"detect": [
{
"tool": "Bash",
@@ -114,12 +96,7 @@
"icon": "🧪",
"gate": false,
"command": "agenthub lanes up --qc",
"aliases": [
"e2e",
"e2e-scoped",
"booting",
"live"
],
"aliases": ["e2e", "e2e-scoped", "booting", "live"],
"detect": [
{
"tool": "Bash",
@@ -133,9 +110,7 @@
"icon": "🧪",
"gate": true,
"command": "agenthub stage e2e-feature-passed",
"aliases": [
"e2e-passed"
]
"aliases": ["e2e-passed"]
},
{
"id": "review",
@@ -143,11 +118,7 @@
"icon": "👀",
"gate": true,
"command": "/code-review",
"aliases": [
"reviewing",
"code-review",
"self-review"
],
"aliases": ["reviewing", "code-review", "self-review"],
"detect": [
{
"tool": "Skill",
@@ -191,12 +162,7 @@
"icon": "🚦",
"gate": true,
"command": "/superpowers:verification-before-completion",
"aliases": [
"sr-gate",
"verify",
"verification",
"gate-blocked"
],
"aliases": ["sr-gate", "verify", "verification", "gate-blocked"],
"detect": [
{
"tool": "Agent",
@@ -214,9 +180,7 @@
"icon": "🔀",
"gate": false,
"command": "/superpowers:finishing-a-development-branch",
"aliases": [
"push"
],
"aliases": ["push"],
"detect": [
{
"tool": "Skill",
@@ -234,12 +198,7 @@
"icon": "🔀",
"gate": false,
"command": "gh pr create --base development",
"aliases": [
"ship",
"pr",
"push-conflict",
"push-revalidate"
],
"aliases": ["ship", "pr", "push-conflict", "push-revalidate"],
"detect": [
{
"tool": "Bash",
@@ -261,9 +220,7 @@
"icon": "👁",
"gate": false,
"command": "agenthub lanes ci status",
"aliases": [
"pr-comment-fix"
],
"aliases": ["pr-comment-fix"],
"detect": [
{
"tool": "Bash",
@@ -285,10 +242,7 @@
"icon": "✅",
"gate": false,
"command": "agenthub stage done",
"aliases": [
"complete",
"completed"
]
"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;
}
});
});
-2
View File
@@ -97,7 +97,6 @@ const remoteSourcesRouter = require("./routes/remote-sources");
const metricsRouter = require("./routes/metrics");
const lanesRouter = require("./routes/lanes");
const locksRouter = require("./routes/locks");
const skillsRouter = require("./routes/skills");
const APP_VERSION = (() => {
try {
@@ -141,7 +140,6 @@ function createApp() {
app.use("/api/webhooks", webhooksRouter);
app.use("/api/remote-sources", remoteSourcesRouter);
app.use("/api/metrics", metricsRouter);
app.use("/api/skills", skillsRouter);
app.get("/api/openapi.json", (_req, res) => {
res.json(openApiSpec);
});
+3 -66
View File
@@ -1,24 +1,11 @@
/**
* @file Installs a `claude` lane's driving instructions: the
* ship-feature-lane pipeline's agent templates
* (qc-local, senior-gate-reviewer) into a lane's own .claude/agents/, so a
* driving session can launch them by the BARE subagent_type name. Source is
* the repo's own `agents/` — the same files the agenthub plugin ships, where
* they resolve as `agenthub:qc-local`; installing them into the lane is what
* makes the unprefixed name work there too. Plain file I/O except
* for one git call — resolving where a worktree lane's shared info/exclude
* actually lives, the same git-dir/git-common-dir distinction E2's
* lane-sync.js had to get right for info/attributes.
* @file Cleans up what older builds wrote into a lane: `removeStageDoc` takes
* AgentHub's stage block back out of a codex lane's AGENTS.md. Plain file I/O.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { git } = require("./worktree");
const TEMPLATE_DIR = path.join(__dirname, "..", "..", "agents");
const TEMPLATE_FILES = ["qc-local.md", "senior-gate-reviewer.md"];
const EXCLUDE_LINE = ".claude/agents/";
// The block older builds wrote into a codex lane's AGENTS.md. Codex lanes no
// longer carry a pipeline, so `removeStageDoc` takes it back out.
@@ -52,54 +39,4 @@ function removeStageDoc(cwd) {
}
}
/** The dir shared across every worktree of a repo — where info/exclude
* lives (same as info/attributes; MERGE_HEAD/HEAD/the index are the only
* per-worktree-private state, not this). */
async function commonGitDir(cwd) {
const result = await git(cwd, ["rev-parse", "--git-common-dir"]);
const dir = result.stdout.trim();
return path.isAbsolute(dir) ? dir : path.join(cwd, dir);
}
/**
* Copy both agent templates into `<lane.cwd>/.claude/agents/` (overwriting
* any existing copy — this is a reinstall, not a merge) and idempotently
* git-exclude that directory in the shared common git dir.
*
* A `codex` or `antigravity` lane gets nothing: neither CLI reads
* `.claude/agents/`, and those lanes carry no pipeline to drive.
*
* @param {{cwd: string, tool?: string}} lane
* @returns {Promise<{installed: string[]}>}
*/
async function installAgents(lane) {
if (!fs.existsSync(path.join(lane.cwd, ".git"))) {
throw Object.assign(new Error(`lane has no .git — not a git repository: ${lane.cwd}`), {
code: "ENOTGITREPO",
});
}
if (lane.tool && lane.tool !== "claude") return { installed: [] };
const dest = path.join(lane.cwd, ".claude", "agents");
fs.mkdirSync(dest, { recursive: true });
const installed = [];
for (const name of TEMPLATE_FILES) {
fs.copyFileSync(path.join(TEMPLATE_DIR, name), path.join(dest, name));
installed.push(name);
}
const infoDir = path.join(await commonGitDir(lane.cwd), "info");
fs.mkdirSync(infoDir, { recursive: true });
const excludePath = path.join(infoDir, "exclude");
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
const lines = existing.split("\n").filter(Boolean);
if (!lines.includes(EXCLUDE_LINE)) {
lines.push(EXCLUDE_LINE);
fs.writeFileSync(excludePath, lines.join("\n") + "\n");
}
return { installed };
}
module.exports = { installAgents, removeStageDoc };
module.exports = { removeStageDoc };
+4 -1
View File
@@ -11,6 +11,7 @@
const { db } = require("../db");
const {
DEFAULT_PIPELINE_ID,
listPipelines,
getPipeline,
phaseIdx,
@@ -111,7 +112,9 @@ function validateTool(tool) {
* forever. Reject it at the write, where the caller can still be told.
*/
function validatePipeline(id) {
if (!listPipelines().some((p) => p.id === id)) {
// `default` is always valid: with no template installed it is the empty
// pipeline every lane sits on (pipelines.js), not a typo.
if (id !== DEFAULT_PIPELINE_ID && !listPipelines().some((p) => p.id === id)) {
throw Object.assign(new Error(`unknown pipeline: ${id}`), { code: "EBADPIPELINE" });
}
}
+18 -8
View File
@@ -1,10 +1,12 @@
/**
* @file Pipeline templates for lanes. A template is a plain JSON list of nodes
* (`server/data/pipelines/*.json` plus any override dropped in
* `DASHBOARD_PIPELINES_DIR`); this module resolves a lane's declared stage onto
* a node through per-node `aliases`, and derives the five render states the
* pipeline map draws. Pure functions — no DB, no I/O beyond the one-time
* template load, so it stays trivially testable.
* dropped in `DASHBOARD_PIPELINES_DIR` (`server/data/pipelines/` is read too,
* but AgentHub ships NO template any more — the pipeline feature is dormant
* until one is installed); this module resolves a lane's declared stage onto a
* node through per-node `aliases`, and derives the five render states the
* pipeline map draws. With no template, every lane is on an EMPTY `default`
* pipeline: no nodes, no states, progress 0. Pure functions — no DB, no I/O
* beyond the one-time template load, so it stays trivially testable.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,6 +16,14 @@ const path = require("node:path");
const DEFAULT_PIPELINE_ID = "default";
const BUILTIN_DIR = path.join(__dirname, "..", "data", "pipelines");
/** What every lane is on while no template is installed. */
const EMPTY_PIPELINE = Object.freeze({
id: DEFAULT_PIPELINE_ID,
name: "",
skill: "",
nodes: Object.freeze([]),
});
/** Load every template once. A malformed file is skipped, never fatal. */
function loadAll() {
const dirs = [BUILTIN_DIR];
@@ -77,10 +87,10 @@ function listPipelines() {
return [...templates().values()];
}
/** Never throws: an unknown id yields the default template. */
/** Never throws: an unknown id yields the default template, else the empty one. */
function getPipeline(id) {
const t = templates();
return t.get(id) || t.get(DEFAULT_PIPELINE_ID);
return t.get(id) || t.get(DEFAULT_PIPELINE_ID) || EMPTY_PIPELINE;
}
/** Index of the node matching `stage` by id or alias; -1 when unknown. */
@@ -138,7 +148,7 @@ function nodeStates(pipeline, lane) {
function progressPct(pipeline, lane) {
const i = phaseIdx(pipeline, lane.stage);
if (i < 0) return 0;
if (i < 0 || pipeline.nodes.length < 2) return 0;
return Math.round((i / (pipeline.nodes.length - 1)) * 100);
}
-239
View File
@@ -1,239 +0,0 @@
/**
* @file skill-plugins.js
* @description The plugins AgentHub's pipelines depend on: which ones a template
* names, which of those are actually resolvable, and the narrow set the
* SessionStart bootstrap is allowed to install on the user's behalf.
*
* Shared by `scripts/plugin-doctor.js` (reports) and
* `scripts/plugin-bootstrap.js` (installs) so the two can never disagree about
* what is missing. It lives here rather than in either script because
* plugin-doctor already requires plugin-bootstrap — the other direction would
* be a cycle.
*
* ## Reporting is derived, installing is allowlisted
* `requiredSkillPlugins()` reads the loaded pipeline templates, including any
* custom one from `DASHBOARD_PIPELINES_DIR`. That is right for a report: if a
* template names `/foo:bar`, the reader should be told `foo` is missing.
*
* It is NOT right for an install. A template is a file anyone can drop into
* that directory, so installing whatever plugin name it happens to contain
* would turn "edit a JSON file" into "run this vendor's plugin install command
* on the next session" — a supply-chain path AgentHub must not open. Auto-install
* therefore consults {@link AUTO_INSTALLABLE}, a fixed map in this file, and
* ignores every requirement outside it.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { spawnSync } = require("node:child_process");
const { getClaudeHome } = require("./claude-home");
/**
* Plugins the bootstrap may install unattended, keyed by the plugin name a
* template refers to. `spec` is the exact `claude plugin install` argument;
* `marketplace` is the `owner/repo` to register first if that spec cannot be
* resolved because the marketplace is not known on this machine.
*
* Adding an entry here is a decision to install someone else's code on a
* user's machine without asking — it belongs in a review, not in a config file.
*
* @type {Record<string, {spec: string, marketplace: string}>}
*/
const AUTO_INSTALLABLE = {
superpowers: {
spec: "superpowers@claude-plugins-official",
marketplace: "anthropics/claude-plugins-official",
},
};
/** Slash commands are addressed `/plugin:skill`; a bare `/command` has no plugin. */
const SLASH_COMMAND_RE = /^\/([A-Za-z0-9_-]+):([A-Za-z0-9_-]+)/;
/**
* Every `/plugin:skill` the loaded pipeline templates name — as a pipeline's
* own `skill` or as one node's `command` — grouped by the plugin that has to
* provide it. Derived rather than hardcoded so editing a template cannot leave
* the check behind. `agenthub` is dropped: it is the plugin asking.
*
* @returns {Map<string, Set<string>>} plugin name → the skill names it must ship.
*/
function requiredSkillPlugins() {
const requirements = new Map();
let pipelines = [];
try {
pipelines = require("./pipelines").listPipelines();
} catch {
return requirements; // templates unreadable — a different failure, not this one
}
for (const pipeline of pipelines) {
for (const command of [pipeline.skill, ...pipeline.nodes.map((n) => n.command)]) {
const match = SLASH_COMMAND_RE.exec(command || "");
if (!match) continue; // a shell command, or a bare /command
const [, plugin, skill] = match;
if (plugin === "agenthub") continue;
if (!requirements.has(plugin)) requirements.set(plugin, new Set());
requirements.get(plugin).add(skill);
}
}
return requirements;
}
/**
* Which required plugins cannot be resolved right now.
*
* A plugin present but explicitly disabled counts as missing — Claude Code will
* not resolve its skills either way. A plugin whose skills all exist as plain
* user skills under `~/.claude/skills/` counts as satisfied: hand-copying them
* is a legitimate setup, and calling it broken is how a warning teaches its
* reader to ignore it.
*
* @returns {{name: string, skills: string[], autoInstall: {spec: string, marketplace: string}|null}[]}
* Empty when everything resolves. `null` autoInstall means report only.
* @throws When the installed-plugin manifest cannot be read at all.
*/
function missingSkillPlugins() {
const requirements = requiredSkillPlugins();
if (!requirements.size) return [];
const discovery = require("./cc-discovery");
const installed = discovery.readPlugins().plugins;
const userSkills = new Set(discovery.readSkills({ scope: "user" }).map((s) => s.name));
const missing = [];
for (const [name, skills] of requirements) {
const present = installed.find(
(p) => p.name === name && p.installPathExists && p.enabled !== false
);
if (present) continue;
const absent = [...skills].filter((s) => !userSkills.has(s)).sort();
if (!absent.length) continue;
missing.push({ name, skills: absent, autoInstall: AUTO_INSTALLABLE[name] || null });
}
return missing;
}
/**
* `claude plugin …`, synchronously. The bootstrap is a synchronous script, so
* it cannot use the promise-based `cc-plugin-cli.js` the HTTP routes go
* through; the argument validation that module carries is also moot here,
* because every argv element below comes from {@link AUTO_INSTALLABLE} — a
* constant in this file, never a request or a template.
*
* Still an argv array, never a shell string. `CLAUDE_CONFIG_DIR` is pinned
* only when the operator set `CLAUDE_HOME`, matching `cc-plugin-cli.js`'s
* `runClaude()`: left unset, the CLI's own defaults already write where
* `cc-discovery` reads, and pinning it unconditionally sent `.claude.json`
* to `~/.claude/.claude.json`, which nothing reads.
*
* @returns {{ok: boolean, output: string}}
*/
function runClaudePlugin(args, timeoutMs) {
const result = runClaudePluginOnce(args, timeoutMs);
// An older Claude Code rejects `-y` and never prompts; retry without it.
if (!result.ok && args.includes("-y") && /unknown option '-y'/.test(result.output)) {
return runClaudePluginOnce(
args.filter((a) => a !== "-y"),
timeoutMs
);
}
return result;
}
function runClaudePluginOnce(args, timeoutMs) {
const env = { ...process.env };
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
const result = spawnSync("claude", args, {
timeout: timeoutMs,
encoding: "utf8",
maxBuffer: 1024 * 1024,
// Non-interactive: a would-be prompt must fail fast, not hang out the timeout.
stdio: ["ignore", "pipe", "pipe"],
env,
});
const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
if (result.error) {
const enoent = result.error.code === "ENOENT";
return {
ok: false,
output: enoent ? "the `claude` CLI is not on PATH" : String(result.error.message),
};
}
return { ok: result.status === 0, output };
}
/**
* Install the allowlisted plugins that are missing. Called from the bootstrap's
* DETACHED worker, so a slow clone never delays a session; its only channel is
* the caller's log.
*
* Best-effort by design: a failure is logged and the run moves on. The
* dashboard, hooks, CLI and MCP all work without these plugins — only the
* ship-feature pipelines need them — so nothing here may throw into the
* bootstrap.
*
* @param {(line: string) => void} log
* @param {{timeoutMs?: number, run?: typeof runClaudePlugin}} [options] `run` is
* a seam for tests: nothing else may reach a real `claude plugin install`.
* @returns {{attempted: string[], installed: string[], failed: string[], skipped: string[]}}
*/
function autoInstallSkillPlugins(log, { timeoutMs = 180_000, run = runClaudePlugin } = {}) {
const summary = { attempted: [], installed: [], failed: [], skipped: [] };
let missing = [];
try {
missing = missingSkillPlugins();
} catch {
return summary; // manifest unreadable — the doctor reports it, this stays quiet
}
for (const entry of missing) {
if (!entry.autoInstall) {
// Reported by /agenthub-doctor, never installed: not on the allowlist.
summary.skipped.push(entry.name);
continue;
}
const { spec, marketplace } = entry.autoInstall;
summary.attempted.push(entry.name);
log(`installing the ${entry.name} plugin — the pipelines drive ${entry.skills.join(", ")}`);
let result = run(["plugin", "install", spec, "--scope", "user", "-y"], timeoutMs);
if (!result.ok) {
// The most likely cause on a fresh machine is that the marketplace has
// never been registered, which `install` cannot do for us. Register it
// and retry exactly once.
const added = run(
["plugin", "marketplace", "add", marketplace, "--scope", "user"],
timeoutMs
);
if (added.ok) {
result = run(["plugin", "install", spec, "--scope", "user", "-y"], timeoutMs);
}
}
if (result.ok) {
summary.installed.push(entry.name);
log(
`installed ${spec} — run /reload-plugins to use it in THIS session ` +
`(a new session picks it up on its own)`
);
} else {
summary.failed.push(entry.name);
log(
`could not install ${spec}: ${result.output || "unknown error"} — ` +
`install it yourself with: claude plugin install ${spec}`
);
}
}
return summary;
}
module.exports = {
AUTO_INSTALLABLE,
requiredSkillPlugins,
missingSkillPlugins,
autoInstallSkillPlugins,
// Exported only so a test can pin the env it spawns with. Every other test
// injects `run` and so never reaches the real spawn — which is how an
// unconditional CLAUDE_CONFIG_DIR pin could be restored here unnoticed.
runClaudePlugin,
};
-36
View File
@@ -1,36 +0,0 @@
/**
* @file Installs skills/ship-feature-lane/ into ~/.claude/skills/, so
* /ship-feature-lane is discoverable from a session running inside any
* lane's own working directory. This is the FALLBACK path for a plain
* checkout: the skill also ships with the `agenthub` plugin (the repo root IS
* the plugin root, so `skills/` is the plugin's skills dir), where it is
* already global as `/agenthub:ship-feature-lane`. Installing it here just
* restores the bare `/ship-feature-lane` name for people who do not run the
* plugin. Shared by bin/agenthub.js's `agenthub skills install` CLI and the
* POST /api/skills/install route, so there is exactly one copy of this
* logic. Pure filesystem action.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
/**
* @param {{repoRoot: string}} options - `repoRoot` is this checkout's own
* root (bin/agenthub.js already computes this as `REPO_ROOT`; the server
* computes its own equivalent — see the route for how).
* @returns {{installed: true, path: string}}
*/
function installShipFeatureLaneSkill({ repoRoot }) {
const src = path.join(repoRoot, "skills", "ship-feature-lane");
if (!fs.existsSync(src)) {
throw Object.assign(new Error(`not found: ${src}`), { code: "ENOSKILLSRC" });
}
const dest = path.join(os.homedir(), ".claude", "skills", "ship-feature-lane");
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.cpSync(src, dest, { recursive: true, force: true });
return { installed: true, path: dest };
}
module.exports = { installShipFeatureLaneSkill };
+13 -21
View File
@@ -53,7 +53,6 @@ const {
const { runGitAction } = require("../lib/git-ops");
const { withLaneLock } = require("../lib/lane-lock");
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
const { installAgents } = require("../lib/lane-agents");
const { syncMcp } = require("../lib/lane-mcp");
const { HOOKS, runHook, resolveProfile, isIntegrationEnabled } = require("../lib/lane-profile");
const { slotDirs } = require("../lib/lane-slots");
@@ -510,6 +509,19 @@ router.post("/:id/stage", sameOriginGuard, (req, res) => {
const requested = req.body && req.body.stage;
if (requested !== undefined && requested !== null && requested !== "") {
const pipeline = getPipeline(current.pipeline);
// No template installed: the pipeline feature is dormant, so there is no
// stage to declare — say so instead of listing zero nodes.
if (!pipeline.nodes.length) {
return res.status(400).json({
error: {
code: "ENOPIPELINE",
message:
"no pipeline template is installed, so this lane has no stages to declare " +
"(drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)",
stages: [],
},
});
}
if (phaseIdx(pipeline, requested) === -1) {
return res.status(400).json({
error: {
@@ -631,26 +643,6 @@ router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
res.json(proofLib.ensureProofLink(lane));
});
/**
* Install the ship-feature-lane pipeline's agent templates (qc-local,
* senior-gate-reviewer) into this lane's own .claude/agents/. Static file
* copy — no templating, no credentials to inject (this repo has no
* seed-account system yet; see the E3 design spec's Decisions table).
* Never automatic, same as proof-link: a session calls this explicitly.
*/
router.post("/:id/agents/install", sameOriginGuard, async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
try {
res.json(await installAgents(lane));
} catch (err) {
if (err.code === "ENOTGITREPO") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
}
});
/**
* Give this lane the same MCP servers as its source repo — relocates the
* source repo's already-configured mcpServers (from ~/.claude.json) into
-28
View File
@@ -1,28 +0,0 @@
/**
* @file Machine-wide (not lane-scoped) skill installation.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const path = require("node:path");
const { installShipFeatureLaneSkill } = require("../lib/skills-install");
const { sameOriginGuard } = require("./run");
const router = Router();
/** This server's own checkout root — server/routes/skills.js is two levels
* under it (server/routes/), same computation bin/agenthub.js's REPO_ROOT
* already does from its own location. */
const REPO_ROOT = path.resolve(__dirname, "..", "..");
router.post("/install", sameOriginGuard, (req, res) => {
try {
res.json(installShipFeatureLaneSkill({ repoRoot: REPO_ROOT }));
} catch (err) {
if (err.code === "ENOSKILLSRC") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
}
});
module.exports = router;