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
@@ -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();
});
});