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:
@@ -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