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
+1 -1
View File
@@ -346,7 +346,7 @@ graph TD
| `server/routes/remote-sources.js` | HTTP surface for **Remote Data Sources**: `GET /api/remote-sources` (list), `POST /api/remote-sources` (create), `PATCH /api/remote-sources/:id`, `DELETE /api/remote-sources/:id` (`?purge=true` also deletes that source's imported sessions), `POST /api/remote-sources/:id/test` (SSH connectivity probe), and `POST /api/remote-sources/:id/sync` (on-demand pull). Delegates the pull/validation to `remote-sync.js`; broadcasts `remote_source.status` on every transition | | `server/routes/remote-sources.js` | HTTP surface for **Remote Data Sources**: `GET /api/remote-sources` (list), `POST /api/remote-sources` (create), `PATCH /api/remote-sources/:id`, `DELETE /api/remote-sources/:id` (`?purge=true` also deletes that source's imported sessions), `POST /api/remote-sources/:id/test` (SSH connectivity probe), and `POST /api/remote-sources/:id/sync` (on-demand pull). Delegates the pull/validation to `remote-sync.js`; broadcasts `remote_source.status` on every transition |
| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged | | `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged |
| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set | | `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` count by scanning the install dir + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines | | `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines |
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` | | `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses | | `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down | | `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
+20
View File
@@ -2288,6 +2288,18 @@ export interface CcMdItem {
preview: string; 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 /** Counts of what a plugin contributes to Claude Code, plus its manifest
* metadata, embedded in {@link CcPlugin}. */ * metadata, embedded in {@link CcPlugin}. */
export interface CcPluginContributions { export interface CcPluginContributions {
@@ -2296,6 +2308,14 @@ export interface CcPluginContributions {
commands: number; commands: number;
outputStyles: number; outputStyles: number;
hooks: 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. */ /** Parsed `plugin.json` fields; null if the plugin has no manifest. */
pluginJson: { pluginJson: {
name?: string; name?: string;
+76 -10
View File
@@ -114,6 +114,7 @@ import type {
CcMutationResult, CcMutationResult,
CcOverview, CcOverview,
CcPlugin, CcPlugin,
CcPluginItem,
CcPluginsResponse, CcPluginsResponse,
CcScope, CcScope,
CcSettingsSource, 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" 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" : ""}`} /> <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> </button>
</div> </div>
{lastUpdated && ( {lastUpdated && (
@@ -792,6 +805,7 @@ function Tabs({ current, onSelect, counts }: TabsProps) {
return ( return (
<button <button
key={key} key={key}
data-testid={`cc-tab-${key}`}
data-tab-active={active ? "true" : undefined} data-tab-active={active ? "true" : undefined}
onClick={() => onSelect(key)} 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 ${ 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 // Uninstall takes two clicks rather than a modal: the action is reversible
// (reinstall), so an accidental single click is the only thing to guard. // (reinstall), so an accidental single click is the only thing to guard.
const [confirmUninstall, setConfirmUninstall] = useState(false); 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 meta = p.contributes?.pluginJson;
const description = meta?.description; 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) { if (p.contributes) {
const items = p.contributes.items;
if (p.contributes.skills > 0) if (p.contributes.skills > 0)
contribCounts.push({ contribCounts.push({
key: "skills", key: "skills",
count: p.contributes.skills, count: p.contributes.skills,
label: t("plugins.skills", { count: p.contributes.skills }), label: t("plugins.skills", { count: p.contributes.skills }),
items: items?.skills,
}); });
if (p.contributes.agents > 0) if (p.contributes.agents > 0)
contribCounts.push({ contribCounts.push({
key: "agents", key: "agents",
count: p.contributes.agents, count: p.contributes.agents,
label: t("plugins.agents", { count: p.contributes.agents }), label: t("plugins.agents", { count: p.contributes.agents }),
items: items?.agents,
}); });
if (p.contributes.commands > 0) if (p.contributes.commands > 0)
contribCounts.push({ contribCounts.push({
key: "commands", key: "commands",
count: p.contributes.commands, count: p.contributes.commands,
label: t("plugins.commands", { count: p.contributes.commands }), label: t("plugins.commands", { count: p.contributes.commands }),
items: items?.commands,
}); });
if (p.contributes.outputStyles > 0) if (p.contributes.outputStyles > 0)
contribCounts.push({ contribCounts.push({
key: "outputStyles", key: "outputStyles",
count: p.contributes.outputStyles, count: p.contributes.outputStyles,
label: t("plugins.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) if (p.contributes.hooks > 0)
contribCounts.push({ contribCounts.push({
key: "hooks", key: "hooks",
@@ -1699,6 +1723,7 @@ function PluginCard({
label: t("plugins.hooks", { count: p.contributes.hooks }), label: t("plugins.hooks", { count: p.contributes.hooks }),
}); });
} }
const opened = contribCounts.find((c) => c.key === openSurface && c.items?.length);
return ( return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3"> <div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -1744,15 +1769,56 @@ function PluginCard({
{t("plugins.contributes")} {t("plugins.contributes")}
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{contribCounts.map((c) => ( {contribCounts.map((c) =>
<span c.items?.length ? (
key={c.key} <button
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border" key={c.key}
> type="button"
{c.label} aria-expanded={openSurface === c.key}
</span> 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> </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>
)} )}
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1 text-[11px] text-fg-muted"> <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" d="M8 16H3v5"
/> />
</svg> </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> </button>
</div> </div>
<span <span
@@ -873,6 +887,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 bg-accent/15 text-accent border border-accent/30" 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-tab-active="true"
data-testid="cc-tab-overview"
> >
<svg <svg
class="lucide lucide-boxes w-3.5 h-3.5" class="lucide lucide-boxes w-3.5 h-3.5"
@@ -929,6 +944,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-sparkles w-3.5 h-3.5" class="lucide lucide-sparkles w-3.5 h-3.5"
@@ -969,6 +985,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-user-round w-3.5 h-3.5" class="lucide lucide-user-round w-3.5 h-3.5"
@@ -1002,6 +1019,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-slash w-3.5 h-3.5" class="lucide lucide-slash w-3.5 h-3.5"
@@ -1030,6 +1048,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-book-open w-3.5 h-3.5" class="lucide lucide-book-open w-3.5 h-3.5"
@@ -1061,6 +1080,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-plug-zap w-3.5 h-3.5" class="lucide lucide-plug-zap w-3.5 h-3.5"
@@ -1101,6 +1121,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-store w-3.5 h-3.5" class="lucide lucide-store w-3.5 h-3.5"
@@ -1141,6 +1162,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-server w-3.5 h-3.5" class="lucide lucide-server w-3.5 h-3.5"
@@ -1194,6 +1216,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-webhook w-3.5 h-3.5" class="lucide lucide-webhook w-3.5 h-3.5"
@@ -1228,6 +1251,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-keyboard w-3.5 h-3.5" class="lucide lucide-keyboard w-3.5 h-3.5"
@@ -1284,6 +1308,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-settings w-3.5 h-3.5" class="lucide lucide-settings w-3.5 h-3.5"
@@ -1317,6 +1342,7 @@ exports[`screen snapshots > Claude Config 1`] = `
</button> </button>
<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" 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 <svg
class="lucide lucide-palette w-3.5 h-3.5" 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 { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import type { ReactNode } from "react"; 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 { MemoryRouter, Routes, Route } from "react-router-dom";
import i18n from "i18next"; import i18n from "i18next";
@@ -28,6 +28,59 @@ import i18n from "i18next";
vi.mock("../../lib/api", async (importOriginal) => { vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value); 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 items = { items: [] };
const cost = { const cost = {
total_cost: 0, total_cost: 0,
@@ -327,7 +380,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
agents: r(items), agents: r(items),
commands: r(items), commands: r(items),
outputStyles: r(items), outputStyles: r(items),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }), plugins: r({ manifestPath: "", manifestExists: false, plugins: [demoPlugin] }),
mcp: r({ servers: [], items: [] }), mcp: r({ servers: [], items: [] }),
hooks: r(items), hooks: r(items),
settings: r(items), settings: r(items),
@@ -541,3 +594,53 @@ describe("screen snapshots", () => {
await snapshot(<NotFound />, "/nope"); 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();
});
});
+2
View File
@@ -1592,6 +1592,8 @@ POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`. `scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
`GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `ccam` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts.
`GET /api/cc-config/memory` also surfaces the per-project file-based memory store — every `*.md` under `~/.claude/projects/<slug>/memory/` (the common pattern of a `MEMORY.md` index plus one file per remembered fact). Those items have `scope: "auto-memory"` and carry `project` (the `projects/<slug>` dir name), `name` (filename), `isIndex` (true for `MEMORY.md` / `INDEX-*.md`, which sort first), and parsed `frontmatter`. They are **editable**: `PUT`/`DELETE /api/cc-config/file` accept `{ scope: "auto-memory", type: "auto-memory", project, name, content? }` and create a timestamped backup under `<memory-dir>/.cc-config-backups/auto-memory/` before mutating (an invalid `project` slug returns `EBADPROJECT`). `GET /api/cc-config/backups` lists these with `scope: "auto-memory"` and `project` set. Bodies are also readable via `GET /api/cc-config/file` (they live under `CLAUDE_HOME`). `GET /api/cc-config/memory` also surfaces the per-project file-based memory store — every `*.md` under `~/.claude/projects/<slug>/memory/` (the common pattern of a `MEMORY.md` index plus one file per remembered fact). Those items have `scope: "auto-memory"` and carry `project` (the `projects/<slug>` dir name), `name` (filename), `isIndex` (true for `MEMORY.md` / `INDEX-*.md`, which sort first), and parsed `frontmatter`. They are **editable**: `PUT`/`DELETE /api/cc-config/file` accept `{ scope: "auto-memory", type: "auto-memory", project, name, content? }` and create a timestamped backup under `<memory-dir>/.cc-config-backups/auto-memory/` before mutating (an invalid `project` slug returns `EBADPROJECT`). `GET /api/cc-config/backups` lists these with `scope: "auto-memory"` and `project` set. Bodies are also readable via `GET /api/cc-config/file` (they live under `CLAUDE_HOME`).
**`PATCH /api/cc-config/settings` (key merge).** `scope` is `"user"`, `"project"`, or `"project-local"` (`settings.local.json`). `set` maps dotted key paths to JSON values; `unset` is a list of dotted key paths to delete. The server re-reads the target file **unredacted**, applies only the named keys, backs it up under `<dir>/.cc-config-backups/settings/`, and writes atomically — so keys the UI never displayed (including secret-valued ones) survive byte-for-byte. Returns `{ path, backupPath, created, changed }`. Hooks are edited through this same route, as `set: { "hooks.PreToolUse": [{ matcher, hooks: [{ type, command, timeout? }] }] }` — i.e. `settings.json`'s own nested shape, not the flattened shape `GET /hooks` returns. Errors: `EBADSCOPE` (unknown scope), `EBADNAME` (a key segment outside `[A-Za-z0-9_$][A-Za-z0-9._$-]*`, or `__proto__`/`prototype`/`constructor`), `EREDACTED` (400 — the payload contains the `<redacted>` placeholder, which would destroy a real secret), `EBADJSON` (409 — the file on disk is not valid JSON; it is left untouched rather than clobbered), `ETOOLARGE`. The project layers always resolve against the **server's** cwd; `?cwd=` is honoured on reads only, never on this write. **`PATCH /api/cc-config/settings` (key merge).** `scope` is `"user"`, `"project"`, or `"project-local"` (`settings.local.json`). `set` maps dotted key paths to JSON values; `unset` is a list of dotted key paths to delete. The server re-reads the target file **unredacted**, applies only the named keys, backs it up under `<dir>/.cc-config-backups/settings/`, and writes atomically — so keys the UI never displayed (including secret-valued ones) survive byte-for-byte. Returns `{ path, backupPath, created, changed }`. Hooks are edited through this same route, as `set: { "hooks.PreToolUse": [{ matcher, hooks: [{ type, command, timeout? }] }] }` — i.e. `settings.json`'s own nested shape, not the flattened shape `GET /hooks` returns. Errors: `EBADSCOPE` (unknown scope), `EBADNAME` (a key segment outside `[A-Za-z0-9_$][A-Za-z0-9._$-]*`, or `__proto__`/`prototype`/`constructor`), `EREDACTED` (400 — the payload contains the `<redacted>` placeholder, which would destroy a real secret), `EBADJSON` (409 — the file on disk is not valid JSON; it is left untouched rather than clobbered), `ETOOLARGE`. The project layers always resolve against the **server's** cwd; `?cwd=` is honoured on reads only, never on this write.
+1 -1
View File
@@ -462,7 +462,7 @@ These plugins query the Agent Monitor API at `http://localhost:4820`. Key data s
Rule types: `token_threshold` (`{ total_tokens }` — the spend-relevant guardrail), `event_pattern`, `inactivity`, `status_duration`. Rule types: `token_threshold` (`{ total_tokens }` — the spend-relevant guardrail), `event_pattern`, `inactivity`, `status_duration`.
### Config Explorer (`/api/cc-config/*`) ### Config Explorer (`/api/cc-config/*`)
Read every Claude Code surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, settings, keybindings, statusline, memory). `memory` includes the per-project file-based store with `scope: "auto-memory"` (carrying `project`, `name`, `isIndex`, `frontmatter`); those files plus `CLAUDE.md` are mutable via `PUT`/`DELETE /api/cc-config/file` with always-on timestamped backups. The page also **manages plugins and marketplaces**: paste a GitHub `owner/repo`, URL or local path to register a marketplace, then install / enable / disable / update / uninstall its plugins — each button runs the corresponding `claude plugin …` command on the dashboard host (argv array, never a shell string). Hooks and `settings.json` keys are editable per layer (user / project / project-local) through `PATCH /api/cc-config/settings`, a per-key merge that leaves every untouched key — including redacted secrets — exactly as it was. Read every Claude Code surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, settings, keybindings, statusline, memory). A plugin card's contribution chips are clickable: each one expands to name the skills / subagents / commands / output styles that plugin ships (from `contributes.items`), so the plugin's contents are readable without opening its install directory. `memory` includes the per-project file-based store with `scope: "auto-memory"` (carrying `project`, `name`, `isIndex`, `frontmatter`); those files plus `CLAUDE.md` are mutable via `PUT`/`DELETE /api/cc-config/file` with always-on timestamped backups. The page also **manages plugins and marketplaces**: paste a GitHub `owner/repo`, URL or local path to register a marketplace, then install / enable / disable / update / uninstall its plugins — each button runs the corresponding `claude plugin …` command on the dashboard host (argv array, never a shell string). Hooks and `settings.json` keys are editable per layer (user / project / project-local) through `PATCH /api/cc-config/settings`, a per-key merge that leaves every untouched key — including redacted secrets — exactly as it was.
## Plugin Development ## Plugin Development
+76 -9
View File
@@ -1890,38 +1890,91 @@ components:
type: string type: string
description: First 320 characters of the markdown body (after frontmatter). description: First 320 characters of the markdown body (after frontmatter).
example: Review backend route and hook logic for regressions... example: Review backend route and hook logic for regressions...
CcConfigPluginItem:
type: object
description: One named artifact a plugin contributes - a skill, subagent, slash command or output style.
required:
- name
- description
- file
properties:
name:
type: string
description: Frontmatter `name` when the file declares one (that is what Claude Code addresses it by), else the filename without its .md extension.
example: ship-feature-lane
description:
type: string
nullable: true
description: Frontmatter `description`, or null when the file declares none.
example: Autonomous end-to-end feature pipeline for ONE CCAM lane.
file:
type: string
description: Absolute path of the backing markdown file (SKILL.md for a skill).
example: /Users/son/.claude/plugins/cache/sg/ccam/skills/ship-feature-lane/SKILL.md
CcConfigPluginContributions: CcConfigPluginContributions:
type: object type: object
nullable: true nullable: true
description: Counts of artifacts a plugin contributes, plus its parsed .claude-plugin/plugin.json. Null when the plugin's installPath does not exist on disk. description: Counts of artifacts a plugin contributes, the same surfaces listed by name under `items`, plus its parsed .claude-plugin/plugin.json. Null when the plugin's installPath does not exist on disk.
required: required:
- skills - skills
- agents - agents
- commands - commands
- outputStyles - outputStyles
- hooks - hooks
- items
- pluginJson - pluginJson
properties: properties:
skills: skills:
type: integer type: integer
description: Number of skill directories (containing SKILL.md) under the plugin. description: Number of skills the plugin contributes; always equals `items.skills.length`.
example: 3 example: 3
agents: agents:
type: integer type: integer
description: Number of .md agent files under the plugin. description: Number of subagents the plugin contributes; always equals `items.agents.length`.
example: 2 example: 2
commands: commands:
type: integer type: integer
description: Number of .md command files under the plugin. description: Number of slash commands the plugin contributes; always equals `items.commands.length`.
example: 5 example: 5
outputStyles: outputStyles:
type: integer type: integer
description: Number of .md output-style files under the plugin. description: Number of output styles the plugin contributes; always equals `items.outputStyles.length`.
example: 0 example: 0
hooks: hooks:
type: integer type: integer
description: Number of files under the plugin's hooks/ directory. description: Number of files under the plugin's hooks/ directory. Has no `items` list - a plugin's hooks are matcher/command pairs declared inline in plugin.json, not named artifacts.
example: 1 example: 1
items:
type: object
description: >-
The contributed artifacts by name, so a client can show WHICH skills,
subagents, commands and output styles a plugin ships. Each surface
covers both the conventional directory (`skills/`, `agents/`,
`commands/`, `output-styles/`) and any path plugin.json declares for
that key; a declared path escaping the plugin's own install directory
is ignored.
required:
- skills
- agents
- commands
- outputStyles
properties:
skills:
type: array
items:
$ref: '#/components/schemas/CcConfigPluginItem'
agents:
type: array
items:
$ref: '#/components/schemas/CcConfigPluginItem'
commands:
type: array
items:
$ref: '#/components/schemas/CcConfigPluginItem'
outputStyles:
type: array
items:
$ref: '#/components/schemas/CcConfigPluginItem'
pluginJson: pluginJson:
type: object type: object
nullable: true nullable: true
@@ -7511,11 +7564,25 @@ paths:
installPathExists: true installPathExists: true
enabled: true enabled: true
contributes: contributes:
skills: 3 skills: 2
agents: 2 agents: 1
commands: 5 commands: 0
outputStyles: 0 outputStyles: 0
hooks: 1 hooks: 1
items:
skills:
- name: brainstorming
description: Explores intent and design before implementation.
file: /Users/son/.claude/plugins/superpowers/skills/brainstorming/SKILL.md
- name: writing-plans
description: Turns a spec into a step-by-step implementation plan.
file: /Users/son/.claude/plugins/superpowers/skills/writing-plans/SKILL.md
agents:
- name: code-reviewer
description: Reviews a diff for correctness regressions.
file: /Users/son/.claude/plugins/superpowers/agents/code-reviewer.md
commands: []
outputStyles: []
pluginJson: pluginJson:
name: superpowers name: superpowers
version: 1.2.0 version: 1.2.0
+109 -2
View File
@@ -3,8 +3,9 @@
* @description Direct unit tests for the exported helpers in * @description Direct unit tests for the exported helpers in
* `server/lib/cc-discovery.js`: parseFrontmatter, redactSettings, isUnder, * `server/lib/cc-discovery.js`: parseFrontmatter, redactSettings, isUnder,
* isFileLike, the symlinked skill-directory and agent/command markdown-file * isFileLike, the symlinked skill-directory and agent/command markdown-file
* discovery paths (readSkills / readAgents), and the MAX_FILE_BYTES * discovery paths (readSkills / readAgents), readPluginContributions (per-plugin
* constant. The integration tests in * skill/agent/command listing, including manifest-declared paths and the
* traversal guard on them), and the MAX_FILE_BYTES constant. The integration tests in
* cc-config.test.js exercise these indirectly through HTTP routes; this * cc-config.test.js exercise these indirectly through HTTP routes; this
* file pins down their behavior at the function level so future refactors * file pins down their behavior at the function level so future refactors
* surface regressions immediately. * surface regressions immediately.
@@ -21,6 +22,7 @@ const {
redactSettings, redactSettings,
isUnder, isUnder,
isFileLike, isFileLike,
readPluginContributions,
readSkills, readSkills,
readAgents, readAgents,
MAX_FILE_BYTES, MAX_FILE_BYTES,
@@ -290,6 +292,111 @@ describe("readAgents (symlinked markdown files)", () => {
}); });
}); });
describe("readPluginContributions", () => {
/** A plugin root with the conventional directories only. */
function conventionalPlugin() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-conv-"));
fs.mkdirSync(path.join(root, "skills", "ship-it"), { recursive: true });
fs.writeFileSync(
path.join(root, "skills", "ship-it", "SKILL.md"),
"---\nname: ship-it\ndescription: Ships the thing.\n---\n\nBody.\n"
);
fs.mkdirSync(path.join(root, "agents"), { recursive: true });
fs.writeFileSync(
path.join(root, "agents", "reviewer.md"),
"---\nname: reviewer\ndescription: Reviews code.\n---\n\nBody.\n"
);
fs.mkdirSync(path.join(root, "commands"), { recursive: true });
fs.writeFileSync(path.join(root, "commands", "doctor.md"), "# Doctor\n");
return root;
}
it("lists skills, agents and commands by name, with counts matching the lists", () => {
const c = readPluginContributions(conventionalPlugin());
assert.deepEqual(
c.items.skills.map((s) => s.name),
["ship-it"]
);
assert.equal(c.items.skills[0].description, "Ships the thing.");
assert.deepEqual(
c.items.agents.map((a) => a.name),
["reviewer"]
);
assert.deepEqual(
c.items.commands.map((x) => x.name),
["doctor"]
);
assert.equal(c.skills, c.items.skills.length);
assert.equal(c.agents, c.items.agents.length);
assert.equal(c.commands, c.items.commands.length);
});
it("falls back to the filename when a file declares no frontmatter name", () => {
const c = readPluginContributions(conventionalPlugin());
assert.equal(c.items.commands[0].name, "doctor");
assert.equal(c.items.commands[0].description, null);
});
// The real motivation: this repo's own `ccam` plugin keeps nothing in
// `commands/` and declares each file in plugin.json instead, so a
// directory-only reader reported "0 commands" for it.
it("includes commands the manifest declares outside the conventional directory", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-declared-"));
fs.mkdirSync(path.join(root, "plugins", "x", "commands"), { recursive: true });
fs.writeFileSync(
path.join(root, "plugins", "x", "commands", "open.md"),
"---\ndescription: Opens it.\n---\n"
);
fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true });
fs.writeFileSync(
path.join(root, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "x", commands: ["./plugins/x/commands/open.md"] })
);
const c = readPluginContributions(root);
assert.equal(c.commands, 1);
assert.deepEqual(
c.items.commands.map((x) => x.name),
["open"]
);
assert.equal(c.items.commands[0].description, "Opens it.");
});
it("counts a file only once when the manifest re-declares what the directory already holds", () => {
const root = conventionalPlugin();
fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true });
fs.writeFileSync(
path.join(root, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "dup", commands: ["./commands/doctor.md", "./commands"] })
);
assert.equal(readPluginContributions(root).commands, 1);
});
it("refuses a manifest path that escapes the plugin's own directory", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-outside-"));
fs.writeFileSync(path.join(outside, "stolen.md"), "---\nname: stolen\n---\n");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-escape-"));
fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true });
fs.writeFileSync(
path.join(root, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: "escape", agents: [path.join(outside, "stolen.md")] })
);
const c = readPluginContributions(root);
assert.equal(c.agents, 0);
assert.deepEqual(c.items.agents, []);
});
it("returns null for a plugin with no install path", () => {
assert.equal(readPluginContributions(null), null);
});
});
describe("isFileLike", () => { describe("isFileLike", () => {
it("true for a regular file dirent", () => { it("true for a regular file dirent", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-")); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-"));
+123 -23
View File
@@ -260,31 +260,121 @@ const readOutputStyles = readSimpleMdSurface("output-styles");
// ── Plugins ───────────────────────────────────────────────────────────── // ── Plugins ─────────────────────────────────────────────────────────────
function countMdIn(dir) { /**
try { * Absolute `.md` files a plugin contributes for one surface: the conventional
return fs * `<root>/<subdir>` directory PLUS whatever `plugin.json` points at for that
.readdirSync(dir, { withFileTypes: true }) * key. Both matter — a manifest entry may be a single file or a directory, and
.filter((e) => e.name.endsWith(".md") && isFileLike(e, path.join(dir, e.name))).length; * a plugin that declares its commands explicitly (this repo's own `ccam` lists
} catch { * three files under `./plugins/ccam/commands/`) keeps NOTHING in `commands/`,
return 0; * so reading only the conventional directory reported zero for it.
*
* A manifest is data from a third-party plugin, so a declared path that escapes
* the plugin's own install directory is dropped rather than read.
*
* @param {string} installPath Plugin root.
* @param {string} subdir Conventional directory name ("agents", "commands", …).
* @param {unknown} declared The manifest value for that key, any shape.
* @returns {string[]} Sorted, de-duplicated absolute file paths.
*/
function pluginSurfaceFiles(installPath, subdir, declared) {
const files = new Set();
const addDir = (dir) => {
for (const ent of listDir(dir)) {
const abs = path.join(dir, ent.name);
if (ent.name.endsWith(".md") && isFileLike(ent, abs)) files.add(abs);
}
};
addDir(path.join(installPath, subdir));
const entries = Array.isArray(declared)
? declared
: typeof declared === "string"
? [declared]
: [];
for (const rel of entries) {
if (typeof rel !== "string") continue;
const abs = path.resolve(installPath, rel);
if (!isUnder(installPath, abs)) continue;
try {
const stat = fs.statSync(abs);
if (stat.isDirectory()) addDir(abs);
else if (stat.isFile() && abs.endsWith(".md")) files.add(abs);
} catch {
/* declared in the manifest but not on disk — reported by its absence */
}
} }
return [...files].sort();
} }
function countSkillDirsIn(dir) { /**
try { * One surface's items with the names the UI shows. `name` prefers the
return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => { * frontmatter's own name (that is what Claude Code addresses the agent/command
if (!isDirLike(e, path.join(dir, e.name))) return false; * by) and falls back to the filename.
try { *
return fs.statSync(path.join(dir, e.name, "SKILL.md")).isFile(); * @returns {{name: string, description: string|null, file: string}[]}
} catch { */
return false; function readPluginMdSurface(installPath, subdir, declared) {
} const out = [];
}).length; for (const file of pluginSurfaceFiles(installPath, subdir, declared)) {
} catch { const read = safeReadText(file);
return 0; const fm = read ? parseFrontmatter(read.text).frontmatter : null;
out.push({
name: fm && typeof fm.name === "string" && fm.name ? fm.name : path.basename(file, ".md"),
description: fm && typeof fm.description === "string" ? fm.description : null,
file,
});
} }
return out.sort((a, b) => a.name.localeCompare(b.name));
} }
/**
* A plugin's skills: every directory holding a `SKILL.md`, under the
* conventional `<root>/skills` and under any directory the manifest declares.
* A declared path that IS a skill directory counts as that one skill.
*
* @returns {{name: string, description: string|null, file: string}[]}
*/
function readPluginSkills(installPath, declared) {
const found = new Map();
const addSkillDir = (dir) => {
const file = path.join(dir, "SKILL.md");
const read = safeReadText(file);
if (!read) return false;
const fm = parseFrontmatter(read.text).frontmatter;
found.set(dir, {
name: fm && typeof fm.name === "string" && fm.name ? fm.name : path.basename(dir),
description: fm && typeof fm.description === "string" ? fm.description : null,
file,
});
return true;
};
const addRoot = (root) => {
for (const ent of listDir(root)) {
const dir = path.join(root, ent.name);
if (isDirLike(ent, dir)) addSkillDir(dir);
}
};
addRoot(path.join(installPath, "skills"));
const entries = Array.isArray(declared)
? declared
: typeof declared === "string"
? [declared]
: [];
for (const rel of entries) {
if (typeof rel !== "string") continue;
const abs = path.resolve(installPath, rel);
if (!isUnder(installPath, abs)) continue;
// Either the path is one skill, or it is a directory holding several.
if (!addSkillDir(abs)) addRoot(abs);
}
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/**
* What one installed plugin contributes. The `skills`/`agents`/`commands`/
* `outputStyles` counts are unchanged (existing clients read them), and `items`
* carries the same three surfaces by name so a card can list WHICH ones a
* plugin ships instead of only how many.
*/
function readPluginContributions(installPath) { function readPluginContributions(installPath) {
if (!installPath) return null; if (!installPath) return null;
let pluginJson = null; let pluginJson = null;
@@ -294,11 +384,20 @@ function readPluginContributions(installPath) {
} catch { } catch {
pluginJson = null; pluginJson = null;
} }
const items = {
skills: readPluginSkills(installPath, pluginJson?.skills),
agents: readPluginMdSurface(installPath, "agents", pluginJson?.agents),
commands: readPluginMdSurface(installPath, "commands", pluginJson?.commands),
outputStyles: readPluginMdSurface(installPath, "output-styles", pluginJson?.outputStyles),
};
return { return {
skills: countSkillDirsIn(path.join(installPath, "skills")), // Counts stay derived from the same lists, so a plugin can never show a
agents: countMdIn(path.join(installPath, "agents")), // chip reading "3 commands" next to a list holding two.
commands: countMdIn(path.join(installPath, "commands")), skills: items.skills.length,
outputStyles: countMdIn(path.join(installPath, "output-styles")), agents: items.agents.length,
commands: items.commands.length,
outputStyles: items.outputStyles.length,
items,
hooks: (() => { hooks: (() => {
try { try {
return fs return fs
@@ -822,6 +921,7 @@ module.exports = {
readOverview, readOverview,
readFileSafe, readFileSafe,
// helpers exported for tests // helpers exported for tests
readPluginContributions,
parseFrontmatter, parseFrontmatter,
redactSettings, redactSettings,
isUnder, isUnder,