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
+20
View File
@@ -2288,6 +2288,18 @@ export interface CcMdItem {
preview: string;
}
/** One named artifact a plugin contributes — a skill, subagent, command or
* output style. `name` is the frontmatter name Claude Code addresses it by
* (`/ccam:ship-feature`, `subagent_type: qc-local`), falling back to the
* filename when the file declares none. */
export interface CcPluginItem {
name: string;
/** Frontmatter `description`; null when the file declares none. */
description: string | null;
/** Absolute path of the backing `.md` file. */
file: string;
}
/** Counts of what a plugin contributes to Claude Code, plus its manifest
* metadata, embedded in {@link CcPlugin}. */
export interface CcPluginContributions {
@@ -2296,6 +2308,14 @@ export interface CcPluginContributions {
commands: number;
outputStyles: number;
hooks: number;
/** The same surfaces by name, so a card can list WHICH artifacts a plugin
* ships rather than only how many. Counts above are these lists' lengths. */
items: {
skills: CcPluginItem[];
agents: CcPluginItem[];
commands: CcPluginItem[];
outputStyles: CcPluginItem[];
};
/** Parsed `plugin.json` fields; null if the plugin has no manifest. */
pluginJson: {
name?: string;
+76 -10
View File
@@ -114,6 +114,7 @@ import type {
CcMutationResult,
CcOverview,
CcPlugin,
CcPluginItem,
CcPluginsResponse,
CcScope,
CcSettingsSource,
@@ -638,7 +639,19 @@ function Header({
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 disabled:opacity-60 transition-colors"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
{loading ? t("refreshing") : t("refresh")}
{/* Both labels occupy the same grid cell, so the button is always as
wide as the longer one ("Refreshing…" / "Đang làm mới…") and does
not resize the toolbar when the state flips. Measuring the widest
string beats a hardcoded min-width, which would be wrong in the
next translation. */}
<span className="grid">
<span aria-hidden className="invisible col-start-1 row-start-1 whitespace-nowrap">
{t("refreshing")}
</span>
<span className="col-start-1 row-start-1 whitespace-nowrap">
{loading ? t("refreshing") : t("refresh")}
</span>
</span>
</button>
</div>
{lastUpdated && (
@@ -792,6 +805,7 @@ function Tabs({ current, onSelect, counts }: TabsProps) {
return (
<button
key={key}
data-testid={`cc-tab-${key}`}
data-tab-active={active ? "true" : undefined}
onClick={() => onSelect(key)}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
@@ -1664,34 +1678,44 @@ function PluginCard({
// Uninstall takes two clicks rather than a modal: the action is reversible
// (reinstall), so an accidental single click is the only thing to guard.
const [confirmUninstall, setConfirmUninstall] = useState(false);
// Which contribution surface is expanded, if any. One at a time: the card
// sits in a list, and two open lists push everything below off the screen.
const [openSurface, setOpenSurface] = useState<string | null>(null);
const meta = p.contributes?.pluginJson;
const description = meta?.description;
const contribCounts: { key: string; count: number; label: string }[] = [];
const contribCounts: { key: string; count: number; label: string; items?: CcPluginItem[] }[] = [];
if (p.contributes) {
const items = p.contributes.items;
if (p.contributes.skills > 0)
contribCounts.push({
key: "skills",
count: p.contributes.skills,
label: t("plugins.skills", { count: p.contributes.skills }),
items: items?.skills,
});
if (p.contributes.agents > 0)
contribCounts.push({
key: "agents",
count: p.contributes.agents,
label: t("plugins.agents", { count: p.contributes.agents }),
items: items?.agents,
});
if (p.contributes.commands > 0)
contribCounts.push({
key: "commands",
count: p.contributes.commands,
label: t("plugins.commands", { count: p.contributes.commands }),
items: items?.commands,
});
if (p.contributes.outputStyles > 0)
contribCounts.push({
key: "outputStyles",
count: p.contributes.outputStyles,
label: t("plugins.outputStyles", { count: p.contributes.outputStyles }),
items: items?.outputStyles,
});
// Hooks have no per-item list: a plugin's hooks are matcher/command pairs
// declared inline in plugin.json, not addressable artifacts with names.
if (p.contributes.hooks > 0)
contribCounts.push({
key: "hooks",
@@ -1699,6 +1723,7 @@ function PluginCard({
label: t("plugins.hooks", { count: p.contributes.hooks }),
});
}
const opened = contribCounts.find((c) => c.key === openSurface && c.items?.length);
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-start justify-between gap-3">
@@ -1744,15 +1769,56 @@ function PluginCard({
{t("plugins.contributes")}
</div>
<div className="flex flex-wrap gap-1.5">
{contribCounts.map((c) => (
<span
key={c.key}
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border"
>
{c.label}
</span>
))}
{contribCounts.map((c) =>
c.items?.length ? (
<button
key={c.key}
type="button"
aria-expanded={openSurface === c.key}
data-testid={`plugin-contrib-${p.key}-${c.key}`}
onClick={() => setOpenSurface((cur) => (cur === c.key ? null : c.key))}
className={`text-[10px] font-medium px-1.5 py-0.5 rounded border inline-flex items-center gap-1 transition-colors ${
openSurface === c.key
? "bg-accent/10 text-accent border-accent/30"
: "bg-surface-3 text-fg-secondary border-border hover:border-border-light hover:text-fg-primary"
}`}
>
{c.label}
<ChevronDown
className={`w-2.5 h-2.5 transition-transform ${
openSurface === c.key ? "rotate-180" : ""
}`}
/>
</button>
) : (
<span
key={c.key}
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border"
>
{c.label}
</span>
)
)}
</div>
{opened && (
<ul
data-testid={`plugin-contrib-list-${p.key}`}
className="mt-2 rounded border border-border bg-surface-1 divide-y divide-border"
>
{opened.items?.map((item) => (
<li key={item.file} className="px-2.5 py-1.5">
<span className="font-mono text-[11px] text-fg-primary">
{opened.key === "commands" ? `/${item.name}` : item.name}
</span>
{item.description && (
<p className="mt-0.5 text-[11px] text-fg-muted leading-snug line-clamp-2">
{item.description}
</p>
)}
</li>
))}
</ul>
)}
</div>
)}
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted">
@@ -850,7 +850,21 @@ exports[`screen snapshots > Claude Config 1`] = `
d="M8 16H3v5"
/>
</svg>
Refresh
<span
class="grid"
>
<span
aria-hidden="true"
class="invisible col-start-1 row-start-1 whitespace-nowrap"
>
Refreshing…
</span>
<span
class="col-start-1 row-start-1 whitespace-nowrap"
>
Refresh
</span>
</span>
</button>
</div>
<span
@@ -873,6 +887,7 @@ exports[`screen snapshots > Claude Config 1`] = `
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap bg-accent/15 text-accent border border-accent/30"
data-tab-active="true"
data-testid="cc-tab-overview"
>
<svg
class="lucide lucide-boxes w-3.5 h-3.5"
@@ -929,6 +944,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-skills"
>
<svg
class="lucide lucide-sparkles w-3.5 h-3.5"
@@ -969,6 +985,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-agents"
>
<svg
class="lucide lucide-user-round w-3.5 h-3.5"
@@ -1002,6 +1019,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-commands"
>
<svg
class="lucide lucide-slash w-3.5 h-3.5"
@@ -1030,6 +1048,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-memory"
>
<svg
class="lucide lucide-book-open w-3.5 h-3.5"
@@ -1061,6 +1080,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-plugins"
>
<svg
class="lucide lucide-plug-zap w-3.5 h-3.5"
@@ -1101,6 +1121,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-marketplaces"
>
<svg
class="lucide lucide-store w-3.5 h-3.5"
@@ -1141,6 +1162,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-mcp"
>
<svg
class="lucide lucide-server w-3.5 h-3.5"
@@ -1194,6 +1216,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-hooks"
>
<svg
class="lucide lucide-webhook w-3.5 h-3.5"
@@ -1228,6 +1251,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-keybindings"
>
<svg
class="lucide lucide-keyboard w-3.5 h-3.5"
@@ -1284,6 +1308,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-settings"
>
<svg
class="lucide lucide-settings w-3.5 h-3.5"
@@ -1317,6 +1342,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button>
<button
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
data-testid="cc-tab-outputStyles"
>
<svg
class="lucide lucide-palette w-3.5 h-3.5"
@@ -18,7 +18,7 @@ if (nodeProcess) nodeProcess.env.TZ = "UTC";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import type { ReactNode } from "react";
import { render, act } from "@testing-library/react";
import { render, act, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import i18n from "i18next";
@@ -28,6 +28,59 @@ import i18n from "i18next";
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
// One installed plugin that actually contributes something, so the Claude Code
// tab renders PluginCard's contribution chips instead of an empty list.
const demoPlugin = {
key: "ccam@sg",
name: "ccam",
marketplace: "sg",
scope: "user",
version: "1.4.6",
installPath: "/home/test/.claude/plugins/ccam",
installedAt: "2026-05-01T12:00:00.000Z",
lastUpdated: "2026-06-01T09:30:00.000Z",
gitCommitSha: "a1b2c3d",
installPathExists: true,
enabled: true,
contributes: {
skills: 2,
agents: 1,
commands: 1,
outputStyles: 0,
hooks: 3,
items: {
skills: [
{
name: "ship-feature",
description: "Implement a feature safely end-to-end.",
file: "/home/test/.claude/plugins/ccam/skills/ship-feature/SKILL.md",
},
{
name: "file-headers",
description: "Authorship header policy.",
file: "/home/test/.claude/plugins/ccam/skills/file-headers/SKILL.md",
},
],
agents: [
{
name: "qc-local",
description: "Local browser QC.",
file: "/home/test/.claude/plugins/ccam/agents/qc-local.md",
},
],
commands: [
{
name: "ccam-doctor",
description: null,
file: "/home/test/.claude/plugins/ccam/plugins/ccam/commands/ccam-doctor.md",
},
],
outputStyles: [],
},
pluginJson: { name: "ccam", description: "The dashboard as a plugin.", version: "1.4.6" },
},
};
const items = { items: [] };
const cost = {
total_cost: 0,
@@ -327,7 +380,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
agents: r(items),
commands: r(items),
outputStyles: r(items),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [demoPlugin] }),
mcp: r({ servers: [], items: [] }),
hooks: r(items),
settings: r(items),
@@ -541,3 +594,53 @@ describe("screen snapshots", () => {
await snapshot(<NotFound />, "/nope");
});
});
// The page opens on Overview; the plugin cards live behind the Plugins tab.
async function openPluginsTab() {
render(
<MemoryRouter initialEntries={["/cc-config"]}>
<CcConfig />
</MemoryRouter>
);
await settle();
fireEvent.click(screen.getByTestId("cc-tab-plugins"));
await settle();
}
// The chips are the only way into a plugin's contents, so the toggle is the
// feature: a count that cannot be opened tells you nothing about WHICH skills
// a plugin ships.
describe("plugin contribution chips", () => {
it("expands a plugin's skills by name on click, and collapses again", async () => {
await openPluginsTab();
const chip = screen.getByTestId("plugin-contrib-ccam@sg-skills");
expect(chip).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("ship-feature")).toBeNull();
fireEvent.click(chip);
expect(chip).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("ship-feature")).toBeInTheDocument();
expect(screen.getByText("file-headers")).toBeInTheDocument();
fireEvent.click(chip);
expect(chip).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("ship-feature")).toBeNull();
});
it("shows only one surface at a time, and prefixes a command with its slash", async () => {
await openPluginsTab();
fireEvent.click(screen.getByTestId("plugin-contrib-ccam@sg-skills"));
fireEvent.click(screen.getByTestId("plugin-contrib-ccam@sg-commands"));
expect(screen.queryByText("ship-feature")).toBeNull();
expect(screen.getByText("/ccam-doctor")).toBeInTheDocument();
});
it("leaves hooks as a plain chip - they have no per-item list to open", async () => {
await openPluginsTab();
expect(screen.queryByTestId("plugin-contrib-ccam@sg-hooks")).toBeNull();
});
});