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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user