feat(cc-config): manage plugins, marketplaces, hooks and settings from the dashboard

The Claude Config page was read-only for these four surfaces. It can now:

- register a marketplace from a pasted GitHub `owner/repo`, http(s) URL or
  absolute path, and install / enable / disable / update / uninstall plugins,
  all delegated to the `claude plugin …` CLI so its registry stays consistent;
- edit hooks and settings.json keys in the user, project and project-local
  layers.

Server:

- `server/lib/cc-plugin-cli.js` wraps the CLI with `execFile` and an explicit
  argv array — never a shell string. Every argument is validated before the
  spawn (shape allowlist per kind, no control characters, no leading `-`, no
  `..`), `CLAUDE_CONFIG_DIR` is pinned to the dashboard's CLAUDE_HOME, stdin is
  closed so a prompt errors instead of hanging, and mutating calls are
  serialized in-process.
- `cc-mutate.patchSettings` merges individual dotted keys into one settings
  layer. It re-reads the file unredacted, so keys the UI never displayed —
  including secret-valued ones — survive byte-for-byte. A payload carrying the
  `<redacted>` placeholder is refused (EREDACTED), as are `__proto__` /
  `prototype` / `constructor` segments; a settings file that does not parse is
  reported (EBADJSON) rather than clobbered. Backup first, then atomic write.
- Six additive routes on /api/cc-config; a non-zero CLI exit is a 422 carrying
  the CLI's own message, a missing binary a 503.

Client: install/add bars, per-card plugin and marketplace actions behind a
two-click confirm, and edit modes for hooks and settings that send only the
keys that changed. New i18n keys in en + vi; the explainer banners that said
these surfaces were read-only now describe what the writes actually do.

Tests: 61 new server tests (CLI argv construction and validation, the settings
key-merge, and the six routes against a fake `claude` on PATH).
This commit is contained in:
2026-08-25 15:58:03 +07:00
parent 03a178a718
commit 008ebf1c5c
12 changed files with 3054 additions and 118 deletions
+110
View File
@@ -1485,6 +1485,63 @@ export const api = {
*/
backups: (params?: { scope?: "user" | "project"; type?: CcArtifactType }) =>
requestBackupsHelper(params),
/**
* GET /api/cc-config/plugins/available - every plugin offered by a
* registered marketplace, from `claude plugin list --available --json`.
* @returns `{ installed, available }` — the CLI's own inventory.
*/
pluginsAvailable: () => request<CcAvailablePlugins>("/cc-config/plugins/available"),
/**
* POST /api/cc-config/plugins/install - install `spec` via the `claude`
* CLI. `scope` defaults to `"user"` server-side.
* @returns {@link CcCliResult} — the CLI's stdout/stderr on success.
*/
installPlugin: (spec: string, scope?: CcCliScope) =>
request<CcCliResult>("/cc-config/plugins/install", {
method: "POST",
body: JSON.stringify({ spec, scope }),
}),
/**
* POST /api/cc-config/plugins/action - enable/disable/update/uninstall an
* installed plugin by its `<name>@<marketplace>` key.
*/
pluginAction: (key: string, action: CcPluginAction) =>
request<CcCliResult>("/cc-config/plugins/action", {
method: "POST",
body: JSON.stringify({ key, action }),
}),
/**
* POST /api/cc-config/marketplaces/add - register a marketplace from a
* GitHub `owner/repo`, an http(s) URL, or an absolute local path.
*/
addMarketplace: (source: string, scope?: CcCliScope) =>
request<CcCliResult>("/cc-config/marketplaces/add", {
method: "POST",
body: JSON.stringify({ source, scope }),
}),
/** POST /api/cc-config/marketplaces/action - update or remove a marketplace. */
marketplaceAction: (name: string, action: CcMarketplaceAction) =>
request<CcCliResult>("/cc-config/marketplaces/action", {
method: "POST",
body: JSON.stringify({ name, action }),
}),
/**
* PATCH /api/cc-config/settings - merge individual keys into one
* settings.json layer (this is also how hooks are edited, as
* `set: { "hooks.<Event>": [...] }` in settings.json's nested shape).
*
* Only the named keys are touched: the server re-reads the file
* unredacted, so keys the UI never displayed survive untouched. A value
* containing the `<redacted>` placeholder is rejected.
*
* @param args Target `scope` plus dotted-path `set` and/or `unset`.
* @returns {@link CcSettingsPatchResult} — path, backup path, changed keys.
*/
patchSettings: (args: CcSettingsPatchArgs) =>
request<CcSettingsPatchResult>("/cc-config/settings", {
method: "PATCH",
body: JSON.stringify(args),
}),
},
// ────────────────────────────────── Run API ─────────────────────────────────
@@ -2301,6 +2358,59 @@ export interface CcHookSource {
hooks: Record<string, CcHookEntry[]>;
}
/** Install scope understood by the `claude plugin` CLI. Note `"local"`, which
* is the same layer {@link CcSettingsSource} calls `"project-local"`. */
export type CcCliScope = "user" | "project" | "local";
/** What POST /api/cc-config/plugins/action can do to an installed plugin. */
export type CcPluginAction = "enable" | "disable" | "update" | "uninstall";
/** What POST /api/cc-config/marketplaces/action can do to a marketplace. */
export type CcMarketplaceAction = "update" | "remove";
/** Result of a `claude plugin …` run the dashboard triggered. A non-zero CLI
* exit arrives as an HTTP 422 error instead of one of these. */
export interface CcCliResult {
ok: true;
stdout: string;
stderr: string;
}
/** One entry of `claude plugin list --available --json`'s `available` array. */
export interface CcAvailablePlugin {
/** `<name>@<marketplace>` — the exact spec to pass to `installPlugin`. */
pluginId: string;
name: string;
description?: string;
marketplaceName?: string;
source?: string;
}
/** GET /api/cc-config/plugins/available - the CLI's own plugin inventory. */
export interface CcAvailablePlugins {
installed: { id?: string; version?: string; enabled?: boolean }[];
available: CcAvailablePlugin[];
}
/** Body of PATCH /api/cc-config/settings - a per-key merge, never a whole-file
* overwrite. Keys are dotted paths (`"permissions.allow"`, `"hooks.Stop"`). */
export interface CcSettingsPatchArgs {
scope: "user" | "project" | "project-local";
set?: Record<string, unknown>;
unset?: string[];
}
/** Result of a settings key-merge. */
export interface CcSettingsPatchResult {
/** Absolute path of the settings file that was written. */
path: string;
/** Pre-write backup, or null when the file did not exist yet. */
backupPath: string | null;
created: boolean;
/** Keys actually changed - an `unset` of an absent key is not listed. */
changed: string[];
}
/** One settings.json layer's raw contents, from GET /api/cc-config/settings. */
export interface CcSettingsSource {
scope: "user" | "project" | "project-local";