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
+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">