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:
+8
-44
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
+12
-58
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user