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:
@@ -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-"));
|
||||
|
||||
+123
-23
@@ -260,31 +260,121 @@ const readOutputStyles = readSimpleMdSurface("output-styles");
|
||||
|
||||
// ── Plugins ─────────────────────────────────────────────────────────────
|
||||
|
||||
function countMdIn(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.name.endsWith(".md") && isFileLike(e, path.join(dir, e.name))).length;
|
||||
} catch {
|
||||
return 0;
|
||||
/**
|
||||
* Absolute `.md` files a plugin contributes for one surface: the conventional
|
||||
* `<root>/<subdir>` directory PLUS whatever `plugin.json` points at for that
|
||||
* key. Both matter — a manifest entry may be a single file or a directory, and
|
||||
* a plugin that declares its commands explicitly (this repo's own `ccam` lists
|
||||
* three files under `./plugins/ccam/commands/`) keeps NOTHING in `commands/`,
|
||||
* so reading only the conventional directory reported zero for it.
|
||||
*
|
||||
* A manifest is data from a third-party plugin, so a declared path that escapes
|
||||
* the plugin's own install directory is dropped rather than read.
|
||||
*
|
||||
* @param {string} installPath Plugin root.
|
||||
* @param {string} subdir Conventional directory name ("agents", "commands", …).
|
||||
* @param {unknown} declared The manifest value for that key, any shape.
|
||||
* @returns {string[]} Sorted, de-duplicated absolute file paths.
|
||||
*/
|
||||
function pluginSurfaceFiles(installPath, subdir, declared) {
|
||||
const files = new Set();
|
||||
const addDir = (dir) => {
|
||||
for (const ent of listDir(dir)) {
|
||||
const abs = path.join(dir, ent.name);
|
||||
if (ent.name.endsWith(".md") && isFileLike(ent, abs)) files.add(abs);
|
||||
}
|
||||
};
|
||||
addDir(path.join(installPath, subdir));
|
||||
const entries = Array.isArray(declared)
|
||||
? declared
|
||||
: typeof declared === "string"
|
||||
? [declared]
|
||||
: [];
|
||||
for (const rel of entries) {
|
||||
if (typeof rel !== "string") continue;
|
||||
const abs = path.resolve(installPath, rel);
|
||||
if (!isUnder(installPath, abs)) continue;
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
if (stat.isDirectory()) addDir(abs);
|
||||
else if (stat.isFile() && abs.endsWith(".md")) files.add(abs);
|
||||
} catch {
|
||||
/* declared in the manifest but not on disk — reported by its absence */
|
||||
}
|
||||
}
|
||||
return [...files].sort();
|
||||
}
|
||||
|
||||
function countSkillDirsIn(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => {
|
||||
if (!isDirLike(e, path.join(dir, e.name))) return false;
|
||||
try {
|
||||
return fs.statSync(path.join(dir, e.name, "SKILL.md")).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}).length;
|
||||
} catch {
|
||||
return 0;
|
||||
/**
|
||||
* One surface's items with the names the UI shows. `name` prefers the
|
||||
* frontmatter's own name (that is what Claude Code addresses the agent/command
|
||||
* by) and falls back to the filename.
|
||||
*
|
||||
* @returns {{name: string, description: string|null, file: string}[]}
|
||||
*/
|
||||
function readPluginMdSurface(installPath, subdir, declared) {
|
||||
const out = [];
|
||||
for (const file of pluginSurfaceFiles(installPath, subdir, declared)) {
|
||||
const read = safeReadText(file);
|
||||
const fm = read ? parseFrontmatter(read.text).frontmatter : null;
|
||||
out.push({
|
||||
name: fm && typeof fm.name === "string" && fm.name ? fm.name : path.basename(file, ".md"),
|
||||
description: fm && typeof fm.description === "string" ? fm.description : null,
|
||||
file,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin's skills: every directory holding a `SKILL.md`, under the
|
||||
* conventional `<root>/skills` and under any directory the manifest declares.
|
||||
* A declared path that IS a skill directory counts as that one skill.
|
||||
*
|
||||
* @returns {{name: string, description: string|null, file: string}[]}
|
||||
*/
|
||||
function readPluginSkills(installPath, declared) {
|
||||
const found = new Map();
|
||||
const addSkillDir = (dir) => {
|
||||
const file = path.join(dir, "SKILL.md");
|
||||
const read = safeReadText(file);
|
||||
if (!read) return false;
|
||||
const fm = parseFrontmatter(read.text).frontmatter;
|
||||
found.set(dir, {
|
||||
name: fm && typeof fm.name === "string" && fm.name ? fm.name : path.basename(dir),
|
||||
description: fm && typeof fm.description === "string" ? fm.description : null,
|
||||
file,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const addRoot = (root) => {
|
||||
for (const ent of listDir(root)) {
|
||||
const dir = path.join(root, ent.name);
|
||||
if (isDirLike(ent, dir)) addSkillDir(dir);
|
||||
}
|
||||
};
|
||||
addRoot(path.join(installPath, "skills"));
|
||||
const entries = Array.isArray(declared)
|
||||
? declared
|
||||
: typeof declared === "string"
|
||||
? [declared]
|
||||
: [];
|
||||
for (const rel of entries) {
|
||||
if (typeof rel !== "string") continue;
|
||||
const abs = path.resolve(installPath, rel);
|
||||
if (!isUnder(installPath, abs)) continue;
|
||||
// Either the path is one skill, or it is a directory holding several.
|
||||
if (!addSkillDir(abs)) addRoot(abs);
|
||||
}
|
||||
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* What one installed plugin contributes. The `skills`/`agents`/`commands`/
|
||||
* `outputStyles` counts are unchanged (existing clients read them), and `items`
|
||||
* carries the same three surfaces by name so a card can list WHICH ones a
|
||||
* plugin ships instead of only how many.
|
||||
*/
|
||||
function readPluginContributions(installPath) {
|
||||
if (!installPath) return null;
|
||||
let pluginJson = null;
|
||||
@@ -294,11 +384,20 @@ function readPluginContributions(installPath) {
|
||||
} catch {
|
||||
pluginJson = null;
|
||||
}
|
||||
const items = {
|
||||
skills: readPluginSkills(installPath, pluginJson?.skills),
|
||||
agents: readPluginMdSurface(installPath, "agents", pluginJson?.agents),
|
||||
commands: readPluginMdSurface(installPath, "commands", pluginJson?.commands),
|
||||
outputStyles: readPluginMdSurface(installPath, "output-styles", pluginJson?.outputStyles),
|
||||
};
|
||||
return {
|
||||
skills: countSkillDirsIn(path.join(installPath, "skills")),
|
||||
agents: countMdIn(path.join(installPath, "agents")),
|
||||
commands: countMdIn(path.join(installPath, "commands")),
|
||||
outputStyles: countMdIn(path.join(installPath, "output-styles")),
|
||||
// Counts stay derived from the same lists, so a plugin can never show a
|
||||
// chip reading "3 commands" next to a list holding two.
|
||||
skills: items.skills.length,
|
||||
agents: items.agents.length,
|
||||
commands: items.commands.length,
|
||||
outputStyles: items.outputStyles.length,
|
||||
items,
|
||||
hooks: (() => {
|
||||
try {
|
||||
return fs
|
||||
@@ -822,6 +921,7 @@ module.exports = {
|
||||
readOverview,
|
||||
readFileSafe,
|
||||
// helpers exported for tests
|
||||
readPluginContributions,
|
||||
parseFrontmatter,
|
||||
redactSettings,
|
||||
isUnder,
|
||||
|
||||
Reference in New Issue
Block a user