feat(cc-config): list a plugin's skills, agents and commands by name

A plugin card showed "7 skills, 5 agents" and nothing more — the counts
were the whole story, so finding out WHICH skills a plugin ships meant
opening its install directory by hand. The contribution chips are now
buttons: clicking one expands a list naming each artifact with its
description, one surface open at a time. Commands render with their
slash. Hooks stay a plain chip — a plugin's hooks are matcher/command
pairs declared inline in plugin.json, not named artifacts.

`contributes.items` carries the four surfaces server-side; the existing
counts keep their type and are now derived from those lists, so a chip
reading "3 commands" can never sit beside a list holding two.

That also fixes a real blind spot. Each surface was read only from the
conventional install subdirectory, but plugin.json may declare its own
paths — this repo's own `ccam` lists three files under
plugins/ccam/commands/ and keeps commands/ empty, so it reported zero
commands. Both sources are now read and de-duplicated. A declared path
resolving outside the plugin's install directory is dropped: a manifest
is third-party data and must not turn the dashboard into an arbitrary
file reader.

Also pins the Refresh button's width: both labels share one grid cell so
the button is always as wide as "Refreshing…" / "Đang làm mới…" and no
longer resizes the toolbar when the state flips. Measuring the longest
real string beats a min-width that would be wrong in the next language.

Verified: npm run test:server (1370 tests, +6 for the new discovery),
npm run test:client (457, +3 for the expand behaviour), tsc --noEmit,
prettier and check-headers clean. Docs synced: docs/API.md, openapi.yaml
(new CcConfigPluginItem schema), ARCHITECTURE.md, docs/PLUGINS.md.
This commit is contained in:
2026-09-04 11:21:12 +07:00
parent 965c62c5cf
commit 677cdf007b
10 changed files with 540 additions and 49 deletions
+109 -2
View File
@@ -3,8 +3,9 @@
* @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), and the MAX_FILE_BYTES
* constant. The integration tests in
* 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.
@@ -21,6 +22,7 @@ const {
redactSettings,
isUnder,
isFileLike,
readPluginContributions,
readSkills,
readAgents,
MAX_FILE_BYTES,
@@ -290,6 +292,111 @@ describe("readAgents (symlinked markdown files)", () => {
});
});
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 `ccam` 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-"));