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
+123 -23
View File
@@ -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,