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:
+1
-1
@@ -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/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 |
|
||||
| `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()` |
|
||||
| `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 |
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>"`.
|
||||
|
||||
`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`).
|
||||
|
||||
**`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
@@ -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`.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+76
-9
@@ -1890,38 +1890,91 @@ components:
|
||||
type: string
|
||||
description: First 320 characters of the markdown body (after frontmatter).
|
||||
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:
|
||||
type: object
|
||||
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:
|
||||
- skills
|
||||
- agents
|
||||
- commands
|
||||
- outputStyles
|
||||
- hooks
|
||||
- items
|
||||
- pluginJson
|
||||
properties:
|
||||
skills:
|
||||
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
|
||||
agents:
|
||||
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
|
||||
commands:
|
||||
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
|
||||
outputStyles:
|
||||
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
|
||||
hooks:
|
||||
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
|
||||
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:
|
||||
type: object
|
||||
nullable: true
|
||||
@@ -7511,11 +7564,25 @@ paths:
|
||||
installPathExists: true
|
||||
enabled: true
|
||||
contributes:
|
||||
skills: 3
|
||||
agents: 2
|
||||
commands: 5
|
||||
skills: 2
|
||||
agents: 1
|
||||
commands: 0
|
||||
outputStyles: 0
|
||||
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:
|
||||
name: superpowers
|
||||
version: 1.2.0
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* @description Direct unit tests for the exported helpers in
|
||||
* `server/lib/cc-discovery.js`: parseFrontmatter, redactSettings, isUnder,
|
||||
* isFileLike, the symlinked skill-directory and agent/command markdown-file
|
||||
* discovery paths (readSkills / readAgents), and the MAX_FILE_BYTES
|
||||
* constant. The integration tests in
|
||||
* discovery paths (readSkills / readAgents), readPluginContributions (per-plugin
|
||||
* 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
|
||||
* file pins down their behavior at the function level so future refactors
|
||||
* surface regressions immediately.
|
||||
@@ -21,6 +22,7 @@ const {
|
||||
redactSettings,
|
||||
isUnder,
|
||||
isFileLike,
|
||||
readPluginContributions,
|
||||
readSkills,
|
||||
readAgents,
|
||||
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", () => {
|
||||
it("true for a regular file dirent", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cc-isfilelike-"));
|
||||
|
||||
+123
-23
@@ -260,31 +260,121 @@ const readOutputStyles = readSimpleMdSurface("output-styles");
|
||||
|
||||
// ── Plugins ─────────────────────────────────────────────────────────────
|
||||
|
||||
function countMdIn(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.name.endsWith(".md") && isFileLike(e, path.join(dir, e.name))).length;
|
||||
} catch {
|
||||
return 0;
|
||||
/**
|
||||
* Absolute `.md` files a plugin contributes for one surface: the conventional
|
||||
* `<root>/<subdir>` directory PLUS whatever `plugin.json` points at for that
|
||||
* key. Both matter — a manifest entry may be a single file or a directory, and
|
||||
* a plugin that declares its commands explicitly (this repo's own `ccam` lists
|
||||
* three files under `./plugins/ccam/commands/`) keeps NOTHING in `commands/`,
|
||||
* 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 {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => {
|
||||
if (!isDirLike(e, path.join(dir, e.name))) return false;
|
||||
try {
|
||||
return fs.statSync(path.join(dir, e.name, "SKILL.md")).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}).length;
|
||||
} catch {
|
||||
return 0;
|
||||
/**
|
||||
* One surface's items with the names the UI shows. `name` prefers the
|
||||
* frontmatter's own name (that is what Claude Code addresses the agent/command
|
||||
* by) and falls back to the filename.
|
||||
*
|
||||
* @returns {{name: string, description: string|null, file: string}[]}
|
||||
*/
|
||||
function readPluginMdSurface(installPath, subdir, declared) {
|
||||
const out = [];
|
||||
for (const file of pluginSurfaceFiles(installPath, subdir, declared)) {
|
||||
const read = safeReadText(file);
|
||||
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) {
|
||||
if (!installPath) return null;
|
||||
let pluginJson = null;
|
||||
@@ -294,11 +384,20 @@ function readPluginContributions(installPath) {
|
||||
} catch {
|
||||
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 {
|
||||
skills: countSkillDirsIn(path.join(installPath, "skills")),
|
||||
agents: countMdIn(path.join(installPath, "agents")),
|
||||
commands: countMdIn(path.join(installPath, "commands")),
|
||||
outputStyles: countMdIn(path.join(installPath, "output-styles")),
|
||||
// Counts stay derived from the same lists, so a plugin can never show a
|
||||
// chip reading "3 commands" next to a list holding two.
|
||||
skills: items.skills.length,
|
||||
agents: items.agents.length,
|
||||
commands: items.commands.length,
|
||||
outputStyles: items.outputStyles.length,
|
||||
items,
|
||||
hooks: (() => {
|
||||
try {
|
||||
return fs
|
||||
@@ -822,6 +921,7 @@ module.exports = {
|
||||
readOverview,
|
||||
readFileSafe,
|
||||
// helpers exported for tests
|
||||
readPluginContributions,
|
||||
parseFrontmatter,
|
||||
redactSettings,
|
||||
isUnder,
|
||||
|
||||
Reference in New Issue
Block a user