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