17d56d0e01
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).
442 lines
17 KiB
JavaScript
442 lines
17 KiB
JavaScript
/**
|
|
* @file cc-discovery-helpers.test.js
|
|
* @description Direct unit tests for the exported helpers in
|
|
* `server/lib/cc-discovery.js`: parseFrontmatter, redactSettings, isUnder,
|
|
* isFileLike, the symlinked skill-directory and agent/command markdown-file
|
|
* discovery paths (readSkills / readAgents), readPluginContributions (per-plugin
|
|
* skill/agent/command listing, including manifest-declared paths and the
|
|
* traversal guard on them), and the MAX_FILE_BYTES constant. The integration tests in
|
|
* cc-config.test.js exercise these indirectly through HTTP routes; this
|
|
* file pins down their behavior at the function level so future refactors
|
|
* surface regressions immediately.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it } = 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 {
|
|
parseFrontmatter,
|
|
redactSettings,
|
|
isUnder,
|
|
isFileLike,
|
|
readPluginContributions,
|
|
readSkills,
|
|
readAgents,
|
|
MAX_FILE_BYTES,
|
|
HOOK_EVENT_TYPES,
|
|
} = require("../lib/cc-discovery");
|
|
|
|
describe("parseFrontmatter", () => {
|
|
it("returns null frontmatter for plain markdown", () => {
|
|
const r = parseFrontmatter("# Just a heading\n\nNo frontmatter here.");
|
|
assert.equal(r.frontmatter, null);
|
|
assert.match(r.body, /Just a heading/);
|
|
});
|
|
|
|
it("parses simple key/value frontmatter", () => {
|
|
const r = parseFrontmatter("---\nname: my-skill\ndescription: A simple skill\n---\nbody text");
|
|
assert.equal(r.frontmatter.name, "my-skill");
|
|
assert.equal(r.frontmatter.description, "A simple skill");
|
|
assert.equal(r.body.trim(), "body text");
|
|
});
|
|
|
|
it("strips surrounding double quotes from values", () => {
|
|
const r = parseFrontmatter('---\nname: "quoted name"\n---\n');
|
|
assert.equal(r.frontmatter.name, "quoted name");
|
|
});
|
|
|
|
it("strips surrounding single quotes from values", () => {
|
|
const r = parseFrontmatter("---\nname: 'single quoted'\n---\n");
|
|
assert.equal(r.frontmatter.name, "single quoted");
|
|
});
|
|
|
|
it("preserves quotes inside the value", () => {
|
|
const r = parseFrontmatter('---\ndescription: text with "inner" quotes\n---\n');
|
|
assert.equal(r.frontmatter.description, 'text with "inner" quotes');
|
|
});
|
|
|
|
it("supports multiline indented continuation values", () => {
|
|
const r = parseFrontmatter(
|
|
"---\ndescription: line one\n line two\n line three\nname: x\n---\n"
|
|
);
|
|
assert.match(r.frontmatter.description, /line one/);
|
|
assert.match(r.frontmatter.description, /line two/);
|
|
assert.match(r.frontmatter.description, /line three/);
|
|
assert.equal(r.frontmatter.name, "x");
|
|
});
|
|
|
|
it("returns null frontmatter when --- is present but never closed", () => {
|
|
const r = parseFrontmatter("---\nname: never closed\nbody text below");
|
|
assert.equal(r.frontmatter, null);
|
|
// body falls back to the entire input
|
|
assert.match(r.body, /never closed/);
|
|
});
|
|
|
|
it("ignores lines that don't match key:value", () => {
|
|
const r = parseFrontmatter("---\nname: ok\nthis is not a key value\n---\n");
|
|
assert.equal(r.frontmatter.name, "ok");
|
|
assert.equal(r.frontmatter["this is not a key value"], undefined);
|
|
});
|
|
|
|
it("handles non-string input gracefully", () => {
|
|
const r1 = parseFrontmatter(null);
|
|
const r2 = parseFrontmatter(undefined);
|
|
const r3 = parseFrontmatter(123);
|
|
for (const r of [r1, r2, r3]) {
|
|
assert.equal(r.frontmatter, null);
|
|
assert.equal(r.body, "");
|
|
}
|
|
});
|
|
|
|
it("trims trailing whitespace in values", () => {
|
|
const r = parseFrontmatter("---\nname: trailing-space \n---\n");
|
|
assert.equal(r.frontmatter.name, "trailing-space");
|
|
});
|
|
|
|
it("body preserves its content verbatim after the closing ---", () => {
|
|
const r = parseFrontmatter("---\nname: x\n---\n\n# Heading\n\nParagraph.");
|
|
assert.match(r.body, /^# Heading/);
|
|
assert.match(r.body, /Paragraph\.$/);
|
|
});
|
|
});
|
|
|
|
describe("redactSettings", () => {
|
|
it("redacts string values whose key matches token/secret/password/key/auth", () => {
|
|
const input = {
|
|
apiKey: "sk-abc123",
|
|
authToken: "bearer xyz",
|
|
password: "hunter2",
|
|
secret_key: "shh",
|
|
apiSecret: "private",
|
|
regularField: "stays",
|
|
};
|
|
const out = redactSettings(input);
|
|
assert.equal(out.apiKey, "<redacted>");
|
|
assert.equal(out.authToken, "<redacted>");
|
|
assert.equal(out.password, "<redacted>");
|
|
assert.equal(out.secret_key, "<redacted>");
|
|
assert.equal(out.apiSecret, "<redacted>");
|
|
assert.equal(out.regularField, "stays");
|
|
});
|
|
|
|
it("handles api-key and api_key variants", () => {
|
|
const out = redactSettings({ "api-key": "x", api_key: "y", apikey: "z" });
|
|
assert.equal(out["api-key"], "<redacted>");
|
|
assert.equal(out.api_key, "<redacted>");
|
|
assert.equal(out.apikey, "<redacted>");
|
|
});
|
|
|
|
it("does not redact non-string values even if key matches", () => {
|
|
// The redactor only swaps STRING values; nested objects/arrays/numbers
|
|
// pass through unchanged so structure is preserved.
|
|
const out = redactSettings({ apiKey: 12345, secrets: { x: "stay" } });
|
|
assert.equal(out.apiKey, 12345);
|
|
assert.deepEqual(out.secrets, { x: "stay" });
|
|
});
|
|
|
|
it("recurses into nested objects", () => {
|
|
const out = redactSettings({
|
|
provider: { name: "anthropic", apiKey: "sk-...", endpoint: "https://api" },
|
|
});
|
|
assert.equal(out.provider.apiKey, "<redacted>");
|
|
assert.equal(out.provider.name, "anthropic");
|
|
assert.equal(out.provider.endpoint, "https://api");
|
|
});
|
|
|
|
it("recurses into arrays of objects", () => {
|
|
const out = redactSettings({
|
|
mcpServers: [
|
|
{ name: "a", apiKey: "sk-1" },
|
|
{ name: "b", token: "tok-2" },
|
|
],
|
|
});
|
|
assert.equal(out.mcpServers[0].apiKey, "<redacted>");
|
|
assert.equal(out.mcpServers[1].token, "<redacted>");
|
|
assert.equal(out.mcpServers[0].name, "a");
|
|
});
|
|
|
|
it("passes scalars through unchanged", () => {
|
|
assert.equal(redactSettings(null), null);
|
|
assert.equal(redactSettings(undefined), undefined);
|
|
assert.equal(redactSettings(42), 42);
|
|
assert.equal(redactSettings(true), true);
|
|
assert.equal(redactSettings("plain"), "plain");
|
|
});
|
|
|
|
it("is case-insensitive on key match", () => {
|
|
const out = redactSettings({ APIKEY: "x", AuthHeader: "y", ToKeN: "z" });
|
|
assert.equal(out.APIKEY, "<redacted>");
|
|
assert.equal(out.AuthHeader, "<redacted>");
|
|
assert.equal(out.ToKeN, "<redacted>");
|
|
});
|
|
|
|
it("does not match keys that contain unrelated substrings", () => {
|
|
// 'kept' contains 'kep' not 'key' - should stay
|
|
const out = redactSettings({ kept: "stays", description: "stays too" });
|
|
assert.equal(out.kept, "stays");
|
|
assert.equal(out.description, "stays too");
|
|
});
|
|
});
|
|
|
|
describe("isUnder", () => {
|
|
it("returns true when target equals root", () => {
|
|
assert.equal(isUnder("/a/b", "/a/b"), true);
|
|
});
|
|
|
|
it("returns true when target is inside root", () => {
|
|
assert.equal(isUnder("/a/b", "/a/b/c"), true);
|
|
assert.equal(isUnder("/a/b", "/a/b/c/d/e.txt"), true);
|
|
});
|
|
|
|
it("returns false when target is outside root", () => {
|
|
assert.equal(isUnder("/a/b", "/a/c"), false);
|
|
assert.equal(isUnder("/a/b", "/x/y"), false);
|
|
});
|
|
|
|
it("rejects sibling paths that share a prefix", () => {
|
|
// /a/b vs /a/bb - bb starts with b but is not under /a/b
|
|
assert.equal(isUnder("/a/b", "/a/bb"), false);
|
|
assert.equal(isUnder("/a/b", "/a/b-extra"), false);
|
|
});
|
|
|
|
it("normalises relative segments via path.resolve", () => {
|
|
// .. inside the target gets resolved away
|
|
assert.equal(isUnder("/a/b", "/a/b/c/../d"), true); // /a/b/d
|
|
assert.equal(isUnder("/a/b", "/a/b/../c"), false); // /a/c
|
|
});
|
|
|
|
it("returns true regardless of trailing slash", () => {
|
|
assert.equal(isUnder("/a/b/", "/a/b/c"), true);
|
|
assert.equal(isUnder("/a/b", "/a/b/c/"), true);
|
|
});
|
|
|
|
it("works with paths produced by path.resolve", () => {
|
|
const root = path.resolve("/tmp/cc-test");
|
|
const inside = path.resolve("/tmp/cc-test/skills/x/SKILL.md");
|
|
const outside = path.resolve("/tmp/elsewhere/file");
|
|
assert.equal(isUnder(root, inside), true);
|
|
assert.equal(isUnder(root, outside), false);
|
|
});
|
|
});
|
|
|
|
describe("readSkills (symlinked skill directories)", () => {
|
|
it("includes a skill directory that is a symlink to a real directory", () => {
|
|
// Dirent.isDirectory() returns false for a symlink even when it points
|
|
// to a directory (Node fs quirk) — readSkillsAt must still follow it.
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-symlink-"));
|
|
const realDir = path.join(tmp, "real-skills", "second-brain");
|
|
fs.mkdirSync(realDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(realDir, "SKILL.md"),
|
|
"---\nname: second-brain\n---\n\nBody text.\n"
|
|
);
|
|
const projectRoot = path.join(tmp, "project");
|
|
const skillsDir = path.join(projectRoot, ".claude", "skills");
|
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
fs.symlinkSync(realDir, path.join(skillsDir, "second-brain"), "dir");
|
|
|
|
const items = readSkills({ scope: "project", cwd: projectRoot });
|
|
const names = items.map((s) => s.name);
|
|
assert.ok(names.includes("second-brain"), `expected symlinked skill in ${names}`);
|
|
});
|
|
|
|
it("skips a broken symlink without throwing", () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-broken-symlink-"));
|
|
const projectRoot = path.join(tmp, "project");
|
|
const skillsDir = path.join(projectRoot, ".claude", "skills");
|
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
fs.symlinkSync(path.join(tmp, "does-not-exist"), path.join(skillsDir, "broken"), "dir");
|
|
|
|
let items;
|
|
assert.doesNotThrow(() => {
|
|
items = readSkills({ scope: "project", cwd: projectRoot });
|
|
});
|
|
assert.ok(!items.map((s) => s.name).includes("broken"));
|
|
});
|
|
});
|
|
|
|
describe("readAgents (symlinked markdown files)", () => {
|
|
it("includes an agent .md that is a symlink to a real file", () => {
|
|
// Same Dirent quirk as symlinked skill directories, but for files:
|
|
// ent.isFile() is false for a symlink pointing at a regular file, so a
|
|
// version-controlled agent linked into .claude/agents/ was invisible.
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-file-symlink-"));
|
|
const realFile = path.join(tmp, "repo", "reviewer.md");
|
|
fs.mkdirSync(path.dirname(realFile), { recursive: true });
|
|
fs.writeFileSync(realFile, "---\nname: reviewer\ndescription: Reviews diffs\n---\n\nBody.\n");
|
|
const projectRoot = path.join(tmp, "project");
|
|
const agentsDir = path.join(projectRoot, ".claude", "agents");
|
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
fs.symlinkSync(realFile, path.join(agentsDir, "reviewer.md"), "file");
|
|
|
|
const items = readAgents({ scope: "project", cwd: projectRoot });
|
|
const names = items.map((a) => a.name);
|
|
assert.ok(names.includes("reviewer"), `expected symlinked agent in ${names}`);
|
|
});
|
|
|
|
it("skips a broken .md symlink without throwing", () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-discovery-file-broken-"));
|
|
const projectRoot = path.join(tmp, "project");
|
|
const agentsDir = path.join(projectRoot, ".claude", "agents");
|
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
fs.symlinkSync(path.join(tmp, "gone.md"), path.join(agentsDir, "ghost.md"), "file");
|
|
|
|
let items;
|
|
assert.doesNotThrow(() => {
|
|
items = readAgents({ scope: "project", cwd: projectRoot });
|
|
});
|
|
assert.ok(!items.some((a) => /ghost/.test(a.file || "")));
|
|
});
|
|
});
|
|
|
|
describe("readPluginContributions", () => {
|
|
/** A plugin root with the conventional directories only. */
|
|
function conventionalPlugin() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-conv-"));
|
|
fs.mkdirSync(path.join(root, "skills", "ship-it"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(root, "skills", "ship-it", "SKILL.md"),
|
|
"---\nname: ship-it\ndescription: Ships the thing.\n---\n\nBody.\n"
|
|
);
|
|
fs.mkdirSync(path.join(root, "agents"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(root, "agents", "reviewer.md"),
|
|
"---\nname: reviewer\ndescription: Reviews code.\n---\n\nBody.\n"
|
|
);
|
|
fs.mkdirSync(path.join(root, "commands"), { recursive: true });
|
|
fs.writeFileSync(path.join(root, "commands", "doctor.md"), "# Doctor\n");
|
|
return root;
|
|
}
|
|
|
|
it("lists skills, agents and commands by name, with counts matching the lists", () => {
|
|
const c = readPluginContributions(conventionalPlugin());
|
|
|
|
assert.deepEqual(
|
|
c.items.skills.map((s) => s.name),
|
|
["ship-it"]
|
|
);
|
|
assert.equal(c.items.skills[0].description, "Ships the thing.");
|
|
assert.deepEqual(
|
|
c.items.agents.map((a) => a.name),
|
|
["reviewer"]
|
|
);
|
|
assert.deepEqual(
|
|
c.items.commands.map((x) => x.name),
|
|
["doctor"]
|
|
);
|
|
assert.equal(c.skills, c.items.skills.length);
|
|
assert.equal(c.agents, c.items.agents.length);
|
|
assert.equal(c.commands, c.items.commands.length);
|
|
});
|
|
|
|
it("falls back to the filename when a file declares no frontmatter name", () => {
|
|
const c = readPluginContributions(conventionalPlugin());
|
|
|
|
assert.equal(c.items.commands[0].name, "doctor");
|
|
assert.equal(c.items.commands[0].description, null);
|
|
});
|
|
|
|
// The real motivation: this repo's own `agenthub` plugin keeps nothing in
|
|
// `commands/` and declares each file in plugin.json instead, so a
|
|
// directory-only reader reported "0 commands" for it.
|
|
it("includes commands the manifest declares outside the conventional directory", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-declared-"));
|
|
fs.mkdirSync(path.join(root, "plugins", "x", "commands"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(root, "plugins", "x", "commands", "open.md"),
|
|
"---\ndescription: Opens it.\n---\n"
|
|
);
|
|
fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(root, ".claude-plugin", "plugin.json"),
|
|
JSON.stringify({ name: "x", commands: ["./plugins/x/commands/open.md"] })
|
|
);
|
|
|
|
const c = readPluginContributions(root);
|
|
|
|
assert.equal(c.commands, 1);
|
|
assert.deepEqual(
|
|
c.items.commands.map((x) => x.name),
|
|
["open"]
|
|
);
|
|
assert.equal(c.items.commands[0].description, "Opens it.");
|
|
});
|
|
|
|
it("counts a file only once when the manifest re-declares what the directory already holds", () => {
|
|
const root = conventionalPlugin();
|
|
fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(root, ".claude-plugin", "plugin.json"),
|
|
JSON.stringify({ name: "dup", commands: ["./commands/doctor.md", "./commands"] })
|
|
);
|
|
|
|
assert.equal(readPluginContributions(root).commands, 1);
|
|
});
|
|
|
|
it("refuses a manifest path that escapes the plugin's own directory", () => {
|
|
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-outside-"));
|
|
fs.writeFileSync(path.join(outside, "stolen.md"), "---\nname: stolen\n---\n");
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-escape-"));
|
|
fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(root, ".claude-plugin", "plugin.json"),
|
|
JSON.stringify({ name: "escape", agents: [path.join(outside, "stolen.md")] })
|
|
);
|
|
|
|
const c = readPluginContributions(root);
|
|
|
|
assert.equal(c.agents, 0);
|
|
assert.deepEqual(c.items.agents, []);
|
|
});
|
|
|
|
it("returns null for a plugin with no install path", () => {
|
|
assert.equal(readPluginContributions(null), null);
|
|
});
|
|
});
|
|
|
|
describe("isFileLike", () => {
|
|
it("true for a regular file dirent", () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-"));
|
|
fs.writeFileSync(path.join(tmp, "a.md"), "x");
|
|
const ent = fs.readdirSync(tmp, { withFileTypes: true })[0];
|
|
assert.equal(isFileLike(ent, path.join(tmp, ent.name)), true);
|
|
});
|
|
|
|
it("true for a symlink resolving to a file, false for one resolving to a directory", () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-sym-"));
|
|
fs.writeFileSync(path.join(tmp, "real.md"), "x");
|
|
fs.mkdirSync(path.join(tmp, "realdir"));
|
|
fs.symlinkSync(path.join(tmp, "real.md"), path.join(tmp, "file-link.md"), "file");
|
|
fs.symlinkSync(path.join(tmp, "realdir"), path.join(tmp, "dir-link"), "dir");
|
|
const ents = Object.fromEntries(
|
|
fs.readdirSync(tmp, { withFileTypes: true }).map((e) => [e.name, e])
|
|
);
|
|
assert.equal(isFileLike(ents["file-link.md"], path.join(tmp, "file-link.md")), true);
|
|
assert.equal(isFileLike(ents["dir-link"], path.join(tmp, "dir-link")), false);
|
|
});
|
|
});
|
|
|
|
describe("module exports", () => {
|
|
it("MAX_FILE_BYTES is 256 KB", () => {
|
|
assert.equal(MAX_FILE_BYTES, 256 * 1024);
|
|
});
|
|
|
|
it("HOOK_EVENT_TYPES covers all canonical Claude Code events", () => {
|
|
const expected = [
|
|
"SessionStart",
|
|
"SessionEnd",
|
|
"UserPromptSubmit",
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"Stop",
|
|
"SubagentStop",
|
|
"Notification",
|
|
"PreCompact",
|
|
];
|
|
for (const t of expected) assert.ok(HOOK_EVENT_TYPES.includes(t), `missing ${t}`);
|
|
});
|
|
});
|