refactor!: rebrand CCAM to AgentHub

Clean break, no compatibility alias. Renames the product name, the CLI
binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` ->
`AGENTHUB_`), the Prometheus metric namespace (`ccam_*` ->
`agenthub_*`), the tmux session prefix (`ccam-lane-<id>` ->
`agenthub-lane-<id>`), the per-repo runtime profile directory
(`.ccam/profile` -> `.agenthub/profile`), the lanes state root
(`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled
plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash
commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` ->
`/agenthub:*`).

Because there is no alias, an existing install has to be updated by
hand: rename its `.ccam/profile` directory and any `CCAM_*` variables,
and reinstall the plugin under its new name.

Two other name families are deliberately left alone here, since they are
not just strings: the `Claude-Code-Monitor` repository/marketplace name,
and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard`
runtime directory, which holds the live database and needs a migration
rather than a rename.

Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across
tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
This commit is contained in:
2026-09-07 12:09:15 +07:00
parent ae9b3dbcd5
commit 17d56d0e01
345 changed files with 2705 additions and 2727 deletions
+48 -48
View File
@@ -1,5 +1,5 @@
/**
* @file Tests for the `ccam stage` / `ccam lanes` CLI subcommands: lane
* @file Tests for the `agenthub stage` / `agenthub lanes` CLI subcommands: lane
* resolution from the current directory, the stage round-trip through the HTTP
* API, and the non-zero exit when the cwd belongs to no lane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -14,10 +14,10 @@ const http = require("http");
const { spawn } = require("child_process");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-cli-${Date.now()}-${process.pid}.db`);
const PIPELINE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-pipeline-fixture-"));
const LANES_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-managed-lanes-cli-"));
const SOURCE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-worktree-source-"));
const REMOTE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-worktree-remote-"));
const PIPELINE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-pipeline-fixture-"));
const LANES_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-managed-lanes-cli-"));
const SOURCE_REPO = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-worktree-source-"));
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;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
@@ -39,8 +39,8 @@ fs.writeFileSync(
const { createApp, startServer } = require("../index");
const LANE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-cli-"));
const CLI = path.join(__dirname, "..", "..", "bin", "ccam.js");
const LANE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-cli-"));
const CLI = path.join(__dirname, "..", "..", "bin", "agenthub.js");
let server;
let BASE;
@@ -188,7 +188,7 @@ after(() => {
}
});
describe("ccam stage", () => {
describe("agenthub stage", () => {
it("reports a stage for the lane owning the current directory", async () => {
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
@@ -231,9 +231,9 @@ describe("ccam stage", () => {
// A cwd can back one `claude` lane AND one `codex` lane (idx_lanes_cwd_tool),
// so cwd alone no longer identifies a lane. Picking the first of the tie means
// a codex session's `ccam stage` silently drives the claude lane's card.
describe("ccam stage in a directory shared by two lanes", () => {
const DUAL_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-dual-lane-"));
// a codex session's `agenthub stage` silently drives the claude lane's card.
describe("agenthub stage in a directory shared by two lanes", () => {
const DUAL_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-dual-lane-"));
let claudeLane;
let codexLane;
// The suite itself runs under Claude Code, whose CLAUDECODE marker would
@@ -251,10 +251,10 @@ describe("ccam stage in a directory shared by two lanes", () => {
return (await get(`/api/lanes/${id}`)).body.lane.stage;
}
it("CCAM_LANE_ID wins — a dashboard-started pane always knows its own lane", async () => {
it("AGENTHUB_LANE_ID wins — a dashboard-started pane always knows its own lane", async () => {
const r = await cli(["stage", "gate"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: String(codexLane.id),
AGENTHUB_LANE_ID: String(codexLane.id),
});
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(codexLane.id), "gate");
@@ -287,7 +287,7 @@ describe("ccam stage in a directory shared by two lanes", () => {
});
it("refuses when the requested tool owns no lane here", async () => {
const solo = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-solo-lane-"));
const solo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-solo-lane-"));
await post("/api/lanes", { cwd: solo, title: "solo claude" });
const r = await cli(["stage", "plan", "--tool", "codex"], solo, NO_HARNESS);
assert.notEqual(r.status, 0);
@@ -302,20 +302,20 @@ describe("ccam stage in a directory shared by two lanes", () => {
assert.notEqual(await stageOf(claudeLane.id), "review");
});
it("positional numeric ID acts like --lane for commands that support it (e.g. ccam lanes pipeline)", async () => {
// Commands like `ccam lanes pipeline` treat a leading numeric arg as the lane ID.
// Other commands like `ccam stage` have their own positional argument and don't support it.
it("positional numeric ID acts like --lane for commands that support it (e.g. agenthub lanes pipeline)", async () => {
// Commands like `agenthub lanes pipeline` treat a leading numeric arg as the lane ID.
// Other commands like `agenthub stage` have their own positional argument and don't support it.
const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`lane #${codexLane.id}`));
});
it("non-numeric CCAM_LANE_ID is ignored", async () => {
// If CCAM_LANE_ID is garbage (not a number), it should be skipped and
it("non-numeric AGENTHUB_LANE_ID is ignored", async () => {
// If AGENTHUB_LANE_ID is garbage (not a number), it should be skipped and
// fall through to --tool or env markers
const r = await cli(["stage", "ship"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: "not-a-number",
AGENTHUB_LANE_ID: "not-a-number",
CLAUDECODE: "1", // This env marker should pick claude lane
});
assert.equal(r.status, 0, r.stderr);
@@ -323,27 +323,27 @@ describe("ccam stage in a directory shared by two lanes", () => {
assert.notEqual(await stageOf(codexLane.id), "ship");
});
it("a CCAM_LANE_ID naming no lane falls through instead of addressing it", async () => {
it("a AGENTHUB_LANE_ID naming no lane falls through instead of addressing it", async () => {
// A pane outlives the lane it was started for, and lane ids are reused
// after a delete. Trusting the number blind would either 404 or, worse,
// write this session's stage onto whatever lane inherited that id.
const r = await cli(["stage", "gate"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: "999999",
AGENTHUB_LANE_ID: "999999",
CLAUDECODE: "1",
});
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(claudeLane.id), "gate");
});
it("a CCAM_LANE_ID for a lane outside this directory is not used", async () => {
it("a AGENTHUB_LANE_ID for a lane outside this directory is not used", async () => {
// The pane's lane is the truth only while the command runs inside it; an
// agent that cd'd into a different repo must resolve by that directory.
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-elsewhere-"));
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-elsewhere-"));
const other = (await post("/api/lanes", { cwd: elsewhere, title: "elsewhere" })).body.lane;
const r = await cli(["stage", "review"], DUAL_DIR, {
...NO_HARNESS,
CCAM_LANE_ID: String(other.id),
AGENTHUB_LANE_ID: String(other.id),
CLAUDECODE: "1",
});
assert.equal(r.status, 0, r.stderr);
@@ -352,9 +352,9 @@ describe("ccam stage in a directory shared by two lanes", () => {
});
});
describe("ccam lanes add", () => {
describe("agenthub lanes add", () => {
it("creates a lane and it appears in lanes list", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-add-"));
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-add-"));
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
assert.equal(r.status, 0, r.stderr);
@@ -369,7 +369,7 @@ describe("ccam lanes add", () => {
});
it("defaults to 'default' pipeline when --pipeline is omitted", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-no-pipeline-"));
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-no-pipeline-"));
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
assert.equal(r.status, 0, r.stderr);
@@ -385,7 +385,7 @@ describe("ccam lanes add", () => {
});
it("accepts --pipeline flag and creates lane with custom pipeline", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-custom-pipeline-"));
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-custom-pipeline-"));
try {
const r = await cli(
[
@@ -458,7 +458,7 @@ describe("ccam lanes add", () => {
});
it("reports the failure notes, not success, when provisioning fails", async () => {
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-cli-unborn-repo-"));
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cli-unborn-repo-"));
try {
await git(["init", "-b", "main"], emptyRepo);
const r = await cli(
@@ -475,7 +475,7 @@ describe("ccam lanes add", () => {
});
});
describe("ccam lanes destructive lifecycle", () => {
describe("agenthub lanes destructive lifecycle", () => {
// Every test provisions its OWN lane. These tests used to share one mutable
// `managedLane` across describe blocks, which meant they only ever exercised
// whatever state the previous test happened to leave behind — the same
@@ -557,7 +557,7 @@ describe("ccam lanes destructive lifecycle", () => {
});
it("refuses to RESET an adopted lane but FORGETS it on remove, leaving the directory intact", async () => {
const adoptedDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-adopted-remove-"));
const adoptedDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-adopted-remove-"));
const preserved = path.join(adoptedDir, "preserve.txt");
fs.writeFileSync(preserved, "do not delete\n");
try {
@@ -602,9 +602,9 @@ describe("ccam lanes destructive lifecycle", () => {
});
});
describe("ccam lanes — inferred stage detection", () => {
describe("agenthub lanes — inferred stage detection", () => {
it("prints the inferred stage when detection leads the declared stage", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-detect-lead-"));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-lead-"));
try {
const created = await post("/api/lanes", { cwd: dir, title: "Detect Lead" });
const id = created.body.lane.id;
@@ -626,7 +626,7 @@ describe("ccam lanes — inferred stage detection", () => {
const r = await cli(["lanes"], dir);
assert.equal(r.status, 0, r.stderr);
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
assert.ok(line, "lane row not found in ccam lanes output");
assert.ok(line, "lane row not found in agenthub lanes output");
assert.match(line, /⇢ detected:tests/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
@@ -634,7 +634,7 @@ describe("ccam lanes — inferred stage detection", () => {
});
it("does not print an inferred stage when the declaration already leads", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-detect-behind-"));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-behind-"));
try {
const created = await post("/api/lanes", { cwd: dir, title: "Detect Behind" });
const id = created.body.lane.id;
@@ -658,7 +658,7 @@ describe("ccam lanes — inferred stage detection", () => {
const r = await cli(["lanes"], dir);
assert.equal(r.status, 0, r.stderr);
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
assert.ok(line, "lane row not found in ccam lanes output");
assert.ok(line, "lane row not found in agenthub lanes output");
assert.doesNotMatch(line, /detected:/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
@@ -666,15 +666,15 @@ describe("ccam lanes — inferred stage detection", () => {
});
});
describe("ccam ship-full / ccam ship-quick", () => {
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 "\/ccam:ship-feature add an export button"/);
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 "\/ccam:ship-feature-lane add an export button"/);
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 () => {
@@ -689,21 +689,21 @@ describe("ccam ship-full / ccam ship-quick", () => {
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: ccam ship-full/);
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: ccam ship-quick/);
assert.match(q.stderr, /usage: agenthub ship-quick/);
});
});
describe("ccam lanes node", () => {
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);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /\bintake\b/);
assert.match(r.stdout, /\bdone\b/);
assert.match(r.stderr, /usage: ccam lanes node/);
assert.match(r.stderr, /usage: agenthub lanes node/);
});
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
@@ -717,7 +717,7 @@ describe("ccam lanes node", () => {
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const r = await cli(["lanes", "node", "gates", "--print"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /"ccam" "lanes" "hook" "ci-gate"/);
assert.match(r.stdout, /"agenthub" "lanes" "hook" "ci-gate"/);
});
it("every node of both shipped templates declares a runnable command", async () => {
@@ -731,7 +731,7 @@ describe("ccam lanes node", () => {
it("matches a node by alias, so one slash command works on either template", async () => {
// `default` calls the step `implement`, `ship-feature` calls it
// `implementing` — /ccam:implement must not depend on which one is active.
// `implementing` — /agenthub:implement must not depend on which one is active.
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const ship = await cli(["lanes", "node", "implement", "--print"], LANE_DIR);
assert.equal(ship.status, 0, ship.stderr);
@@ -751,12 +751,12 @@ describe("ccam lanes node", () => {
});
});
describe("ccam lanes ci status", () => {
describe("agenthub lanes ci status", () => {
let restorePath;
let bin;
before(() => {
bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-cli-bin-"));
bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-cli-bin-"));
fs.writeFileSync(
path.join(bin, "gh"),
`#!/usr/bin/env bash