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
+62 -8
View File
@@ -217,9 +217,9 @@
},
"explain": {
"hooks": {
"title": "Hooks are read-only here on purpose",
"body": "Hooks live in the `hooks` block of `settings.json` files that Claude Code writes to constantly. Editing them from the dashboard would race with the live CLI and could silently break the dashboard's own ingestion (the `hook-handler.js` entry below).",
"howTo": "Edit them yourself with one of:",
"title": "Editing hooks writes settings.json",
"body": "Hooks live in the `hooks` block of the `settings.json` files Claude Code writes to constantly. Edit saves only the event you changed, backs the file up first, and leaves every other key alone — but a `claude` session already running keeps its old hooks until it restarts. Take care with the dashboard`s own ingestion entries (the `hook-handler.js` commands below).",
"howTo": "You can also edit them with:",
"cmd1": "claude config /hooks",
"cmd1Note": "Interactive hook editor inside Claude Code",
"cmd2": "$EDITOR ~/.claude/settings.json",
@@ -228,9 +228,9 @@
"cmd3Note": "Hand-edit the project-level hooks block"
},
"settings": {
"title": "Settings are read-only here on purpose",
"body": "These JSON files are rewritten by the live `claude` CLI on every model change, theme toggle, plugin enable, etc. A dashboard write would race with the CLI and lose state.",
"howTo": "Edit settings via Claude Code itself:",
"title": "Editing settings merges one key at a time",
"body": "These JSON files are rewritten by the live `claude` CLI on every model change, theme toggle, plugin enable, etc. Saving here re-reads the file and writes only the keys you changed (after a timestamped backup), so a concurrent CLI write loses nothing else — but a running session may not pick the change up until it restarts.",
"howTo": "You can also edit them with:",
"cmd1": "claude config set <key> <value>",
"cmd1Note": "Set an individual key safely",
"cmd2": "/config",
@@ -239,8 +239,8 @@
"cmd3Note": "Hand-edit (only when no `claude` process is running)"
},
"plugins": {
"title": "Plugins are managed by the CLI",
"body": "Plugins are bundles with their own registry - `installed_plugins.json` plus install paths under `plugins/cache/`. The dashboard reads them but defers install/uninstall to `claude plugin …` so the registry stays consistent.",
"title": "Plugins run through the `claude` CLI",
"body": "Plugins are bundles with their own registry — `installed_plugins.json` plus install paths under `plugins/cache/`. The buttons here run the matching `claude plugin …` command on this machine, so the registry stays consistent. Installing a plugin means running third-party code: only add marketplaces you trust.",
"install": "Install a plugin:",
"installCmd": "claude plugin install <marketplace>/<plugin>",
"uninstall": "Uninstall this plugin:",
@@ -255,10 +255,64 @@
"addCmd": "claude mcp add <name> <command> [args…]",
"remove": "Remove this server:",
"removeButton": "Copy remove command"
},
"marketplaces": {
"title": "Marketplaces are where plugins come from",
"body": "A marketplace is a git repo (or local folder) with a `.claude-plugin/marketplace.json` listing the plugins it offers. Adding one registers it in `known_marketplaces.json` and clones it locally; its plugins then appear on the Plugins tab, ready to install. Removing one does not uninstall plugins you already installed from it."
}
},
"snippet": {
"copy": "Copy",
"copied": "Copied"
},
"manage": {
"installTitle": "Install a plugin",
"installPlaceholder": "owner/repo, https://… or plugin@marketplace",
"installButton": "Add",
"installHelp": "Paste a GitHub repo, URL or local path to register its marketplace, or type <plugin>@<marketplace> to install a plugin directly. Runs the `claude plugin` CLI on this machine.",
"install": "Install",
"installed": "Installed {{name}}.",
"availableTitle": "Available from your marketplaces",
"cliUnavailable": "Could not list available plugins: {{message}}",
"working": "Working…",
"enable": "Enable",
"enabled": "Enabled {{name}}.",
"disable": "Disable",
"disabled": "Disabled {{name}}.",
"update": "Update",
"updated": "Updated {{name}}.",
"uninstall": "Uninstall",
"confirmUninstall": "Click again to uninstall",
"uninstalled": "Uninstalled {{name}}.",
"remove": "Remove",
"confirmRemove": "Click again to remove",
"addMarketplaceTitle": "Add a marketplace",
"addMarketplacePlaceholder": "owner/repo, https://github.com/… or /absolute/path",
"addMarketplaceButton": "Add",
"addMarketplaceHelp": "Registers the marketplace with `claude plugin marketplace add`; its plugins then show up on the Plugins tab.",
"marketplaceAdded": "Added marketplace {{name}}.",
"marketplaceUpdated": "Updated marketplace {{name}}.",
"marketplaceRemoved": "Removed marketplace {{name}}.",
"hookEvent": "Event name (e.g. PreToolUse)",
"hookAddEvent": "Add event",
"hookRemoveEvent": "Remove event",
"hookAddEntry": "Add hook",
"hookRemoveEntry": "Remove hook",
"hookBadEvent": "“{{event}}” is not a valid event name.",
"hookDupEvent": "Event {{event}} is listed twice.",
"hookEmptyCommand": "Every hook in {{event}} needs a command.",
"hooksSaved": "Hooks saved to {{file}}.",
"hookUneditable": "Left untouched (not in the editable shape): {{events}}",
"settingsKey": "Key",
"settingsKeyPlaceholder": "key or dotted.path",
"settingsValue": "Value (JSON)",
"settingsAddKey": "Add key",
"settingsRemoveKey": "Remove key",
"settingsEmptyKey": "A key name cannot be empty.",
"settingsDupKey": "Key {{key}} is listed twice.",
"settingsBadJson": "Value of {{key}} is not valid JSON.",
"settingsSaved": "Settings saved to {{file}}.",
"settingsHelp": "Values are JSON: \"opus\", true, 42, [\"a\"], {\"k\":1}. Only the keys you change are written; the rest of the file is left as-is.",
"settingsRedactedKeys": "Not editable here because their values are redacted: {{keys}}"
}
}
+62 -8
View File
@@ -212,9 +212,9 @@
},
"explain": {
"hooks": {
"title": "Hooks ở đây cố tình chỉ đọc",
"body": "Hooks nằm trong khối `hooks` của các tệp `settings.json` mà Claude Code liên tục ghi vào. Sửa từ dashboard sẽ tranh chấp với CLI đang chạy và có thể ngầm phá hỏng đường tiếp nhận sự kiện của chính dashboard (`hook-handler.js` bên dưới).",
"howTo": "Tự sửa bằng một trong các cách:",
"title": "Sửa hooks tức là ghi vào settings.json",
"body": "Hooks nằm trong khối `hooks` của các tệp `settings.json` mà Claude Code liên tục ghi vào. Lưu ở đây chỉ ghi đúng sự kiện bạn sửa, sao lưu file trước, và giữ nguyên mọi khoá khác — nhưng phiên `claude` đang chạy vẫn dùng hooks cũ cho tới khi khởi động lại. Cẩn thận với các mục tiếp nhận sự kiện của chính dashboard (lệnh `hook-handler.js` bên dưới).",
"howTo": "Bạn cũng có thể sửa bằng:",
"cmd1": "claude config /hooks",
"cmd1Note": "Trình chỉnh sửa hooks tương tác trong Claude Code",
"cmd2": "$EDITOR ~/.claude/settings.json",
@@ -223,9 +223,9 @@
"cmd3Note": "Sửa thủ công khối hooks ở phạm vi dự án"
},
"settings": {
"title": "Settings ở đây cố tình chỉ đọc",
"body": "Các tệp JSON này được CLI `claude` ghi đè mỗi khi đổi model, theme, bật plugin… Việc dashboard ghi sẽ tranh chấp với CLI và mất trạng thái.",
"howTo": "Sửa settings qua chính Claude Code:",
"title": "Sửa settings là gộp theo từng khoá",
"body": "Các tệp JSON này được CLI `claude` ghi đè mỗi khi đổi model, theme, bật plugin… Lưu ở đây sẽ đọc lại file và chỉ ghi những khoá bạn đổi (sau khi sao lưu kèm mốc thời gian), nên CLI ghi song song cũng không mất gì khác — nhưng phiên đang chạy có thể chỉ nhận thay đổi sau khi khởi động lại.",
"howTo": "Bạn cũng có thể sửa bằng:",
"cmd1": "claude config set <key> <value>",
"cmd1Note": "Đặt một khoá riêng lẻ an toàn",
"cmd2": "/config",
@@ -234,8 +234,8 @@
"cmd3Note": "Sửa thủ công (chỉ khi không có process claude nào đang chạy)"
},
"plugins": {
"title": "Plugin được CLI quản lý",
"body": "Plugin là bundle có registry riêng - `installed_plugins.json` và đường dẫn cài trong `plugins/cache/`. Dashboard đọc thôi; install/uninstall giao cho `claude plugin …` để giữ registry nhất quán.",
"title": "Plugin chạy qua CLI `claude`",
"body": "Plugin là các gói có registry riêng — `installed_plugins.json` cùng đường dẫn cài trong `plugins/cache/`. Các nút ở đây chạy đúng lệnh `claude plugin …` trên máy này nên registry luôn nhất quán. Cài plugin nghĩa là chạy mã của bên thứ ba: chỉ thêm marketplace mà bạn tin tưởng.",
"install": "Cài plugin:",
"installCmd": "claude plugin install <marketplace>/<plugin>",
"uninstall": "Gỡ plugin này:",
@@ -250,10 +250,64 @@
"addCmd": "claude mcp add <name> <command> [args…]",
"remove": "Gỡ server này:",
"removeButton": "Sao chép lệnh gỡ"
},
"marketplaces": {
"title": "Marketplace là nơi plugin đến từ đó",
"body": "Marketplace là một repo git (hoặc thư mục local) có `.claude-plugin/marketplace.json` liệt kê các plugin nó cung cấp. Thêm vào tức là đăng ký trong `known_marketplaces.json` và clone về máy; plugin của nó sẽ hiện ở tab Plugins để cài. Xóa marketplace không gỡ những plugin bạn đã cài từ nó."
}
},
"snippet": {
"copy": "Sao chép",
"copied": "Đã sao chép"
},
"manage": {
"installTitle": "Cài plugin",
"installPlaceholder": "owner/repo, https://… hoặc plugin@marketplace",
"installButton": "Thêm",
"installHelp": "Dán link GitHub, URL hoặc đường dẫn local để đăng ký marketplace, hoặc gõ <plugin>@<marketplace> để cài thẳng plugin. Chạy CLI `claude plugin` trên máy này.",
"install": "Cài",
"installed": "Đã cài {{name}}.",
"availableTitle": "Có sẵn từ marketplace của bạn",
"cliUnavailable": "Không liệt kê được plugin: {{message}}",
"working": "Đang chạy…",
"enable": "Bật",
"enabled": "Đã bật {{name}}.",
"disable": "Tắt",
"disabled": "Đã tắt {{name}}.",
"update": "Cập nhật",
"updated": "Đã cập nhật {{name}}.",
"uninstall": "Gỡ",
"confirmUninstall": "Bấm lần nữa để gỡ",
"uninstalled": "Đã gỡ {{name}}.",
"remove": "Xóa",
"confirmRemove": "Bấm lần nữa để xóa",
"addMarketplaceTitle": "Thêm marketplace",
"addMarketplacePlaceholder": "owner/repo, https://github.com/… hoặc /đường/dẫn/tuyệt/đối",
"addMarketplaceButton": "Thêm",
"addMarketplaceHelp": "Đăng ký marketplace bằng `claude plugin marketplace add`; plugin của nó sẽ hiện ở tab Plugins.",
"marketplaceAdded": "Đã thêm marketplace {{name}}.",
"marketplaceUpdated": "Đã cập nhật marketplace {{name}}.",
"marketplaceRemoved": "Đã xóa marketplace {{name}}.",
"hookEvent": "Tên sự kiện (vd PreToolUse)",
"hookAddEvent": "Thêm sự kiện",
"hookRemoveEvent": "Xóa sự kiện",
"hookAddEntry": "Thêm hook",
"hookRemoveEntry": "Xóa hook",
"hookBadEvent": "“{{event}}” không phải tên sự kiện hợp lệ.",
"hookDupEvent": "Sự kiện {{event}} bị lặp hai lần.",
"hookEmptyCommand": "Mọi hook trong {{event}} đều cần có lệnh.",
"hooksSaved": "Đã lưu hook vào {{file}}.",
"hookUneditable": "Giữ nguyên (không đúng dạng sửa được): {{events}}",
"settingsKey": "Khóa",
"settingsKeyPlaceholder": "key hoặc dotted.path",
"settingsValue": "Giá trị (JSON)",
"settingsAddKey": "Thêm khóa",
"settingsRemoveKey": "Xóa khóa",
"settingsEmptyKey": "Tên khóa không được để trống.",
"settingsDupKey": "Khóa {{key}} bị lặp hai lần.",
"settingsBadJson": "Giá trị của {{key}} không phải JSON hợp lệ.",
"settingsSaved": "Đã lưu settings vào {{file}}.",
"settingsHelp": "Giá trị là JSON: \"opus\", true, 42, [\"a\"], {\"k\":1}. Chỉ những khóa bạn sửa mới được ghi; phần còn lại của file giữ nguyên.",
"settingsRedactedKeys": "Không sửa được ở đây vì giá trị đã bị che: {{keys}}"
}
}
+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";
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -1441,7 +1441,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9
### Claude Config Explorer
The `/api/cc-config/*` namespace powers the Claude Config Explorer page. All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir; mutations are limited to low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory) and always create a timestamped backup before writing. Plugins, MCP servers, hooks-in-settings, and live `settings.json` files stay read-only because they are written concurrently by the running Claude Code CLI.
The `/api/cc-config/*` namespace powers the Claude Config Explorer page. All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin and marketplace management is delegated to the `claude plugin …` CLI. MCP server config stays read-only, because it is written concurrently by the running Claude Code CLI.
```http
GET /api/cc-config/overview
@@ -1463,13 +1463,23 @@ GET /api/cc-config/file?path=<absolute-path>
GET /api/cc-config/backups[?scope=&type=]
PUT /api/cc-config/file Body: { scope, type, name?, content }
DELETE /api/cc-config/file Body: { scope, type, name? }
PATCH /api/cc-config/settings Body: { scope, set?: { "dotted.key": value }, unset?: ["dotted.key"] }
GET /api/cc-config/plugins/available
POST /api/cc-config/plugins/install Body: { spec, scope? }
POST /api/cc-config/plugins/action Body: { key, action: enable|disable|update|uninstall }
POST /api/cc-config/marketplaces/add Body: { source, scope? }
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/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`).
`PUT /api/cc-config/keybindings` edits `~/.claude/keybindings.json` from a structured list of context groups (`{ groups: [{ context, bindings: [{ key, action }] }] }`). The server backs the file up first (under `<CLAUDE_HOME>/cc-config-backups/keybindings/`), preserves any top-level metadata (`$schema`/`$docs`), and replaces only the `bindings` array; duplicate contexts or duplicate keys within a context return `EBADCONTENT`. Unlike `settings.json` (which the live CLI rewrites mid-session and is therefore read-only here), `keybindings.json` is safe to edit from the dashboard.
**`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.
**Plugins and marketplaces.** These five endpoints shell out to the `claude plugin …` CLI with an explicit argv array (never a shell string), pinned to the dashboard's `CLAUDE_HOME` via `CLAUDE_CONFIG_DIR`; mutating calls are serialized in-process. `source` must be `owner/repo`, an `http(s)://` URL, or an absolute path; `spec`/`key` must be `<name>` or `<name>@<marketplace>`; `scope` is `user` (default), `project` or `local`. Validation failures return 400 `EBADARG`; a missing `claude` binary returns 503 `ECLINOTFOUND`; a CLI timeout returns 504 `ETIMEOUT`; a non-zero CLI exit returns **422 `ECLIEXIT`** with the CLI's message plus `stdout`/`stderr`. Successful runs return `{ ok: true, stdout, stderr }` and broadcast `cc_config_changed`. `GET /plugins/available` proxies `claude plugin list --available --json` as `{ installed, available }`.
`PUT /api/cc-config/keybindings` edits `~/.claude/keybindings.json` from a structured list of context groups (`{ groups: [{ context, bindings: [{ key, action }] }] }`). The server backs the file up first (under `<CLAUDE_HOME>/cc-config-backups/keybindings/`), preserves any top-level metadata (`$schema`/`$docs`), and replaces only the `bindings` array; duplicate contexts or duplicate keys within a context return `EBADCONTENT`. `keybindings.json` is replaced wholesale here because the live CLI does not rewrite it mid-session; `settings.json`, which it does, is only ever key-merged (see `PATCH /settings` above).
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
+1 -1
View File
@@ -426,7 +426,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.
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.
## Plugin Development
+236
View File
@@ -822,4 +822,240 @@ describe("/api/cc-config", () => {
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCONTENT");
});
// ── settings key-merge (PATCH /settings) ───────────────────────────────
// Also last: these mutate the fixture's user settings.json.
const userSettings = () =>
JSON.parse(fs.readFileSync(path.join(FAKE_HOME, "settings.json"), "utf8"));
it("PATCH /settings merges one key and leaves every other key intact", async () => {
const before = userSettings();
const { status, body } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user", set: { verbose: true } },
});
assert.equal(status, 200);
assert.deepEqual(body.changed, ["verbose"]);
assert.ok(body.backupPath, "an existing settings.json must be backed up first");
assert.ok(fs.existsSync(body.backupPath));
const after = userSettings();
assert.equal(after.verbose, true);
assert.equal(after.model, before.model);
// The redacted-in-the-API secret survives untouched on disk.
assert.equal(after.apiKeyHelper, "should-be-redacted");
assert.deepEqual(after.enabledPlugins, before.enabledPlugins);
});
it("PATCH /settings writes a nested hooks event without disturbing others", async () => {
const { status } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: {
scope: "user",
set: {
"hooks.Stop": [{ matcher: "*", hooks: [{ type: "command", command: "echo bye" }] }],
},
},
});
assert.equal(status, 200);
const after = userSettings();
assert.equal(after.hooks.Stop[0].hooks[0].command, "echo bye");
// PreToolUse came from the fixture and must still be there.
assert.equal(after.hooks.PreToolUse[0].hooks[0].command, "node x.js");
// And the hooks read endpoint now flattens the new event.
const { body } = await fetchJson("/api/cc-config/hooks");
const user = body.items.find((s) => s.scope === "user");
assert.equal(user.hooks.Stop[0].command, "echo bye");
});
it("PATCH /settings unsets a key", async () => {
const { status, body } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user", unset: ["hooks.Stop"] },
});
assert.equal(status, 200);
assert.deepEqual(body.changed, ["hooks.Stop"]);
assert.equal(userSettings().hooks.Stop, undefined);
assert.ok(userSettings().hooks.PreToolUse, "sibling events survive an unset");
});
it("PATCH /settings refuses a payload carrying the <redacted> placeholder", async () => {
const { status, body } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user", set: { apiKeyHelper: "<redacted>" } },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EREDACTED");
assert.equal(userSettings().apiKeyHelper, "should-be-redacted");
});
it("PATCH /settings rejects prototype-polluting and malformed key paths", async () => {
for (const key of ["__proto__.polluted", "a..b", "a b", ""]) {
const { status, body } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user", set: { [key]: 1 } },
});
assert.equal(status, 400, `accepted ${JSON.stringify(key)}`);
assert.equal(body.error.code, "EBADNAME");
}
assert.equal({}.polluted, undefined);
});
it("PATCH /settings rejects an unknown scope and an empty change set", async () => {
const bad = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "nope", set: { verbose: true } },
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADSCOPE");
const empty = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user" },
});
assert.equal(empty.status, 400);
assert.equal(empty.body.error.code, "EBADCONTENT");
});
it("PATCH /settings refuses to clobber a settings file that is not valid JSON", async () => {
const file = path.join(FAKE_HOME, "settings.json");
const good = fs.readFileSync(file, "utf8");
fs.writeFileSync(file, "{ not json ");
try {
const { status, body } = await fetchJson("/api/cc-config/settings", {
method: "PATCH",
body: { scope: "user", set: { verbose: false } },
});
assert.equal(status, 409);
assert.equal(body.error.code, "EBADJSON");
assert.equal(fs.readFileSync(file, "utf8"), "{ not json ", "the file must be left alone");
} finally {
fs.writeFileSync(file, good);
}
});
// ── plugin / marketplace routes (fake `claude` on PATH) ────────────────
function stubClaude(scriptBody) {
const bin = path.join(TMP, `bin-${process.hrtime.bigint()}`);
fs.mkdirSync(bin, { recursive: true });
const file = path.join(bin, "claude");
fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`);
fs.chmodSync(file, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
const ECHO_ARGV = 'for a in "$@"; do echo "$a"; done';
it("POST /marketplaces/add runs the CLI with the pasted source", async () => {
const restore = stubClaude(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/cc-config/marketplaces/add", {
method: "POST",
body: { source: "owner/repo" },
});
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.deepEqual(body.stdout.trim().split("\n"), [
"plugin",
"marketplace",
"add",
"owner/repo",
"--scope",
"user",
]);
} finally {
restore();
}
});
it("POST /marketplaces/add rejects a source that is not a repo, URL or path", async () => {
const { status, body } = await fetchJson("/api/cc-config/marketplaces/add", {
method: "POST",
body: { source: "; rm -rf /" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADARG");
});
it("POST /plugins/install surfaces a failed CLI run as 422", async () => {
const restore = stubClaude('echo "✘ Plugin \\"nope\\" not found" >&2\nexit 1');
try {
const { status, body } = await fetchJson("/api/cc-config/plugins/install", {
method: "POST",
body: { spec: "nope@market" },
});
assert.equal(status, 422);
assert.equal(body.error.code, "ECLIEXIT");
assert.match(body.error.message, /not found/);
} finally {
restore();
}
});
it("POST /plugins/action validates the action name", async () => {
const { status, body } = await fetchJson("/api/cc-config/plugins/action", {
method: "POST",
body: { key: "demo-plugin@market", action: "purge" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADACTION");
});
it("POST /plugins/action uninstalls through the CLI", async () => {
const restore = stubClaude(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/cc-config/plugins/action", {
method: "POST",
body: { key: "demo-plugin@market", action: "uninstall" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), [
"plugin",
"uninstall",
"demo-plugin@market",
]);
} finally {
restore();
}
});
it("GET /plugins/available returns the CLI's JSON inventory", async () => {
const payload = JSON.stringify({
installed: [{ id: "demo-plugin@market" }],
available: [{ pluginId: "other@market", name: "other", marketplaceName: "market" }],
});
const restore = stubClaude(`cat <<'JSON'\n${payload}\nJSON`);
try {
const { status, body } = await fetchJson("/api/cc-config/plugins/available");
assert.equal(status, 200);
assert.equal(body.available[0].pluginId, "other@market");
assert.equal(body.installed.length, 1);
} finally {
restore();
}
});
it("reports a missing `claude` binary as 503 rather than a crash", async () => {
const empty = path.join(TMP, `empty-${process.hrtime.bigint()}`);
fs.mkdirSync(empty, { recursive: true });
const originalPath = process.env.PATH;
process.env.PATH = empty;
try {
const { status, body } = await fetchJson("/api/cc-config/marketplaces/action", {
method: "POST",
body: { name: "demo-mp", action: "update" },
});
assert.equal(status, 503);
assert.equal(body.error.code, "ECLINOTFOUND");
} finally {
process.env.PATH = originalPath;
}
});
});
+741
View File
@@ -0,0 +1,741 @@
/**
* @file cc-mutate-settings.test.js
* @description Comprehensive tests for cc-mutate.js patchSettings — the per-key
* merge for settings.json layers. Tests deep merge, unset, prototype pollution
* guards, redaction marker handling (including nested), backup atomicity,
* large files, invalid JSON, array/null rejection, and key path validation.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
// Build fixture BEFORE requiring modules that read CLAUDE_HOME
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-mutate-settings-"));
const FAKE_HOME = path.join(TMP, "home", ".claude");
const FAKE_PROJECT = path.join(TMP, "project");
const FAKE_PROJECT_CLAUDE = path.join(FAKE_PROJECT, ".claude");
fs.mkdirSync(FAKE_HOME, { recursive: true });
fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true });
// Now set env and require
process.env.CLAUDE_HOME = FAKE_HOME;
const ccMutate = require("../lib/cc-mutate");
after(() => {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
});
describe("patchSettings — happy path", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.rmSync(FAKE_PROJECT_CLAUDE, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true });
});
it("creates a new settings.json with a single key", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { model: "opus" },
});
assert(result.ok === undefined); // Returns { path, backupPath, created, changed }
assert.equal(result.created, true);
assert.equal(result.backupPath, null);
assert.deepEqual(result.changed, ["model"]);
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.model, "opus");
});
it("merges into an existing settings.json without clobbering untouched keys", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ model: "opus", persist: "keep-me" }));
const result = ccMutate.patchSettings({
scope: "user",
set: { apiKey: "secret" },
});
assert.equal(result.created, false);
assert.notEqual(result.backupPath, null);
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.model, "opus");
assert.equal(content.persist, "keep-me");
assert.equal(content.apiKey, "secret");
});
it("unsets a top-level key", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ model: "opus", temp: "remove" }));
const result = ccMutate.patchSettings({
scope: "user",
unset: ["temp"],
});
assert.deepEqual(result.changed, ["temp"]);
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(content.model, "opus");
assert.equal(content.temp, undefined);
});
it("unsets a key that does not exist (no error, not listed)", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ model: "opus" }));
const result = ccMutate.patchSettings({
scope: "user",
unset: ["nonexistent"],
});
assert.deepEqual(result.changed, []);
});
});
describe("patchSettings — deep merge", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("merges nested objects without clobbering siblings", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(
file,
JSON.stringify({
permissions: { allow: ["Bash"], deny: ["network"] },
other: "data",
})
);
const result = ccMutate.patchSettings({
scope: "user",
set: { "permissions.allow": ["Bash", "Python"] },
});
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.deepEqual(content.permissions.allow, ["Bash", "Python"]);
assert.deepEqual(content.permissions.deny, ["network"]);
assert.equal(content.other, "data");
});
it("creates deeply nested paths in a fresh file", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { "a.b.c.d": 42 },
});
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.a.b.c.d, 42);
});
it("overwrites a non-object intermediate value during nested set", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ config: "string-value" }));
ccMutate.patchSettings({
scope: "user",
set: { "config.nested.key": "new-value" },
});
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(content.config.nested.key, "new-value");
});
it("unsets a nested key without touching siblings", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(
file,
JSON.stringify({
hooks: {
PreToolUse: [{ command: "x" }],
PostToolUse: [{ command: "y" }],
},
})
);
const result = ccMutate.patchSettings({
scope: "user",
unset: ["hooks.PreToolUse"],
});
assert.deepEqual(result.changed, ["hooks.PreToolUse"]);
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(content.hooks.PreToolUse, undefined);
assert.deepEqual(content.hooks.PostToolUse, [{ command: "y" }]);
});
it("fails to unset a nested key whose parent is not an object", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ config: "string" }));
const result = ccMutate.patchSettings({
scope: "user",
unset: ["config.nested.key"],
});
// unsetDeep returns false, so the key is not listed in changed
assert.deepEqual(result.changed, []);
});
});
describe("patchSettings — redaction marker", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("rejects a value containing <redacted> at top level", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { apiKey: "<redacted>" },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
assert.match(err.message, /placeholder/);
return true;
}
);
});
it("rejects a value containing <redacted> at nested position", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "auth.token": "<redacted>" },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
return true;
}
);
});
it("rejects <redacted> inside an object", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { config: { secret: "<redacted>", other: "ok" } },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
return true;
}
);
});
it("rejects <redacted> inside an array", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { items: ["a", "<redacted>", "b"] },
}),
(err) => {
assert.equal(err.code, "EREDACTED");
return true;
}
);
});
it("accepts a value that merely contains the string redacted (no angle brackets)", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { notes: "this value is redacted in the UI" },
});
assert.deepEqual(result.changed, ["notes"]);
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.notes, "this value is redacted in the UI");
});
});
describe("patchSettings — key path validation", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("accepts alphanumeric keys with dots for nesting", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { "my_key.sub-key": "value" },
});
assert.deepEqual(result.changed, ["my_key.sub-key"]);
});
it("accepts $ in key paths (for $schema, $docs)", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { $schema: "http://example.com" },
});
assert.deepEqual(result.changed, ["$schema"]);
});
it("rejects __proto__", () => {
// Built via JSON.parse, not an object literal: a literal `__proto__:` key
// sets the prototype instead of an own property, so it would never reach
// the validator. An HTTP body arrives parsed, i.e. as an own key.
const set = JSON.parse('{"__proto__": {"polluted": true}}');
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set,
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects prototype", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { prototype: "bad" },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects constructor", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { constructor: {} },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects __proto__ in a nested path", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "config.__proto__.polluted": true },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects empty key paths", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "": "bad" },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects keys with invalid characters", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "bad@key": "value" },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
it("rejects empty segments in dotted paths", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { "config..key": "bad" },
}),
(err) => {
assert.equal(err.code, "EBADNAME");
return true;
}
);
});
});
describe("patchSettings — scopes", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.rmSync(FAKE_PROJECT_CLAUDE, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
fs.mkdirSync(FAKE_PROJECT_CLAUDE, { recursive: true });
});
it("writes to user scope (~/.claude/settings.json)", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { model: "sonnet" },
});
assert(result.path.includes(".claude/settings.json"));
assert(fs.existsSync(result.path));
});
it("writes to project scope (./.claude/settings.json)", () => {
const result = ccMutate.patchSettings({
scope: "project",
cwd: FAKE_PROJECT,
set: { model: "haiku" },
});
assert(result.path.includes(".claude/settings.json"));
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.model, "haiku");
});
it("writes to project-local scope (./.claude/settings.local.json)", () => {
const result = ccMutate.patchSettings({
scope: "project-local",
cwd: FAKE_PROJECT,
set: { permissions: { allow: ["Bash"] } },
});
assert(result.path.includes("settings.local.json"));
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.deepEqual(content.permissions.allow, ["Bash"]);
});
it("rejects unknown scope", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "unknown",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "EBADSCOPE");
return true;
}
);
});
});
describe("patchSettings — file format validation", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("rejects a settings file that is not valid JSON", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, "not json at all {{{");
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "EBADJSON");
assert.match(err.message, /not valid JSON/);
return true;
}
);
});
it("rejects a settings file that parses to an array", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify(["a", "b", "c"]));
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "EBADJSON");
assert.match(err.message, /does not contain a JSON object/);
return true;
}
);
});
it("rejects a settings file that parses to null", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, "null");
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "EBADJSON");
return true;
}
);
});
it("rejects a settings file that parses to a plain string", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, '"not-an-object"');
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "EBADJSON");
return true;
}
);
});
it("allows empty files to be treated as {}", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, "");
const result = ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
});
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(content.key, "value");
});
it("allows files with only whitespace to be treated as {}", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, " \n \t ");
const result = ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
});
const content = JSON.parse(fs.readFileSync(file, "utf8"));
assert.equal(content.key, "value");
});
});
describe("patchSettings — size limits", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("rejects a settings file larger than MAX_FILE_BYTES", () => {
const file = path.join(FAKE_HOME, "settings.json");
// The MAX_FILE_BYTES is typically 1MB; create a string close to that
// We don't have direct access to MAX_FILE_BYTES here, so read it from the discovery module
const discovery = require("../lib/cc-discovery");
const largeString = "x".repeat(discovery.MAX_FILE_BYTES + 100);
fs.writeFileSync(file, JSON.stringify({ data: largeString }));
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
}),
(err) => {
assert.equal(err.code, "ETOOLARGE");
return true;
}
);
});
it("rejects a write that would produce output exceeding MAX_FILE_BYTES", () => {
const discovery = require("../lib/cc-discovery");
const nearLimit = Math.floor(discovery.MAX_FILE_BYTES / 2);
const largeValue = "y".repeat(nearLimit);
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: { a: largeValue, b: largeValue },
}),
(err) => {
assert.equal(err.code, "ETOOLARGE");
return true;
}
);
});
});
describe("patchSettings — backup and atomicity", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("creates a backup of an existing file before writing", () => {
const file = path.join(FAKE_HOME, "settings.json");
const original = { model: "opus", data: "original" };
fs.writeFileSync(file, JSON.stringify(original));
const result = ccMutate.patchSettings({
scope: "user",
set: { model: "sonnet" },
});
assert.notEqual(result.backupPath, null);
assert(fs.existsSync(result.backupPath));
const backup = JSON.parse(fs.readFileSync(result.backupPath, "utf8"));
assert.deepEqual(backup, original);
});
it("returns null backupPath for a fresh file", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: { key: "value" },
});
assert.equal(result.backupPath, null);
});
it("backup path includes the settings file name and a timestamp", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ existing: true }));
const result = ccMutate.patchSettings({
scope: "user",
set: { new: "key" },
});
assert(result.backupPath.includes("settings.json"));
assert(result.backupPath.includes(".bak"));
});
it("backup is stored under .cc-config-backups/settings/", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ existing: true }));
const result = ccMutate.patchSettings({
scope: "user",
set: { new: "key" },
});
assert(result.backupPath.includes(".cc-config-backups/settings/"));
});
});
describe("patchSettings — edge cases", () => {
beforeEach(() => {
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
fs.mkdirSync(FAKE_HOME, { recursive: true });
});
it("rejects when both set and unset are empty", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: {},
unset: [],
}),
(err) => {
assert.equal(err.code, "EBADCONTENT");
assert.match(err.message, /nothing to change/);
return true;
}
);
});
it("rejects when set is not an object", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: "not an object",
}),
(err) => {
assert.equal(err.code, "EBADCONTENT");
return true;
}
);
});
it("rejects when set is an array", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
set: ["a", "b"],
}),
(err) => {
assert.equal(err.code, "EBADCONTENT");
return true;
}
);
});
it("rejects when unset is not an array", () => {
assert.throws(
() =>
ccMutate.patchSettings({
scope: "user",
unset: "not an array",
}),
(err) => {
assert.equal(err.code, "EBADCONTENT");
return true;
}
);
});
it("accepts various JSON types as values", () => {
const result = ccMutate.patchSettings({
scope: "user",
set: {
string: "value",
number: 42,
boolean: true,
null_value: null,
array: [1, 2, 3],
object: { nested: "data" },
},
});
const content = JSON.parse(fs.readFileSync(result.path, "utf8"));
assert.equal(content.string, "value");
assert.equal(content.number, 42);
assert.equal(content.boolean, true);
assert.equal(content.null_value, null);
assert.deepEqual(content.array, [1, 2, 3]);
assert.deepEqual(content.object, { nested: "data" });
});
it("lists only keys that were actually changed in the response", () => {
const file = path.join(FAKE_HOME, "settings.json");
fs.writeFileSync(file, JSON.stringify({ unchanged: "kept", changed: "old" }));
const result = ccMutate.patchSettings({
scope: "user",
set: { changed: "new" },
});
assert.deepEqual(result.changed, ["changed"]);
assert(!result.changed.includes("unchanged"));
});
});
+257
View File
@@ -0,0 +1,257 @@
/**
* @file Tests for server/lib/cc-plugin-cli.js — the `claude plugin …` wrapper
* that backs plugin/marketplace management from the dashboard. Uses a real
* fake `claude` script on PATH (the fixture style server/__tests__/gh.test.js
* uses) rather than mocking child_process, and pins the exact argv each
* operation builds — that argv IS the security boundary.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ccplugin-"));
const cli = require("../lib/cc-plugin-cli");
/**
* Put a fake `claude` on PATH. The default body echoes the argv, one element
* per line, so a test can assert the exact array that was spawned.
*/
function stubClaude(scriptBody = 'for a in "$@"; do echo "$a"; done') {
const bin = path.join(ROOT, `bin-${process.hrtime.bigint()}`);
fs.mkdirSync(bin, { recursive: true });
const file = path.join(bin, "claude");
fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`);
fs.chmodSync(file, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
/** PATH with no `claude` on it at all. */
function stubMissingClaude() {
const empty = path.join(ROOT, `empty-${process.hrtime.bigint()}`);
fs.mkdirSync(empty, { recursive: true });
const originalPath = process.env.PATH;
process.env.PATH = empty;
return () => {
process.env.PATH = originalPath;
};
}
const argvOf = (result) => result.stdout.trim().split("\n");
after(() => fs.rmSync(ROOT, { recursive: true, force: true, maxRetries: 5 }));
describe("cc-plugin-cli argument validation", () => {
it("accepts owner/repo, http(s) URLs and absolute paths as a source", () => {
assert.equal(cli.assertSource("anthropics/claude-plugins"), "anthropics/claude-plugins");
assert.equal(cli.assertSource("https://github.com/a/b"), "https://github.com/a/b");
assert.equal(cli.assertSource("/opt/marketplace"), "/opt/marketplace");
});
it("rejects sources that are neither repo, URL nor absolute path", () => {
for (const bad of ["./relative", "git@github.com:a/b.git", "file:///tmp/x", "a/b/c/d e"]) {
assert.throws(() => cli.assertSource(bad), /source must be/, `accepted ${bad}`);
}
});
it("rejects a source that could become a flag or escape upward", () => {
assert.throws(() => cli.assertSource("--scope"), /may not start with/);
assert.throws(() => cli.assertSource("/opt/../etc"), /'\.\.'/);
});
it("rejects control characters and empty values", () => {
assert.throws(() => cli.assertSource("a/b\nrm -rf /"), /control characters/);
assert.throws(() => cli.assertSource(""), /required/);
assert.throws(() => cli.assertSource(null), /required/);
});
it("accepts <name> and <name>@<marketplace> plugin specs only", () => {
assert.equal(cli.assertPluginSpec("ccam"), "ccam");
assert.equal(cli.assertPluginSpec("ccam@sg"), "ccam@sg");
for (const bad of ["ccam@bad/repo", "../ccam", "ccam ccam", "@sg", "-ccam"]) {
assert.throws(() => cli.assertPluginSpec(bad), undefined, `accepted ${bad}`);
}
});
it("rejects marketplace names that are not plain identifiers", () => {
assert.equal(cli.assertMarketplaceName("sg"), "sg");
assert.throws(() => cli.assertMarketplaceName("sg/other"), /valid identifier/);
});
it("defaults the CLI scope to user and rejects unknown scopes", () => {
assert.equal(cli.assertCliScope(undefined), "user");
assert.equal(cli.assertCliScope("local"), "local");
// `project-local` is the dashboard's settings vocabulary, not the CLI's.
assert.throws(() => cli.assertCliScope("project-local"), /scope must be one of/);
});
});
describe("cc-plugin-cli argv construction", () => {
it("builds the marketplace add argv with an explicit scope", async () => {
const restore = stubClaude();
try {
const result = await cli.marketplaceAdd({ source: "owner/repo" });
assert.deepEqual(argvOf(result), [
"plugin",
"marketplace",
"add",
"owner/repo",
"--scope",
"user",
]);
} finally {
restore();
}
});
it("passes -y on install so a non-TTY run never waits for a prompt", async () => {
const restore = stubClaude();
try {
const result = await cli.pluginInstall({ spec: "demo@tmpmk", scope: "project" });
assert.deepEqual(argvOf(result), [
"plugin",
"install",
"demo@tmpmk",
"--scope",
"project",
"-y",
]);
} finally {
restore();
}
});
it("builds uninstall / enable / disable / update argvs", async () => {
const restore = stubClaude();
try {
assert.deepEqual(argvOf(await cli.pluginUninstall({ spec: "demo@m" })), [
"plugin",
"uninstall",
"demo@m",
]);
assert.deepEqual(argvOf(await cli.pluginEnable({ spec: "demo@m" })), [
"plugin",
"enable",
"demo@m",
]);
assert.deepEqual(argvOf(await cli.pluginDisable({ spec: "demo@m" })), [
"plugin",
"disable",
"demo@m",
]);
assert.deepEqual(argvOf(await cli.pluginUpdate({ spec: "demo@m" })), [
"plugin",
"update",
"demo@m",
]);
assert.deepEqual(argvOf(await cli.marketplaceRemove({ name: "m" })), [
"plugin",
"marketplace",
"remove",
"m",
]);
assert.deepEqual(argvOf(await cli.marketplaceUpdate({ name: "m" })), [
"plugin",
"marketplace",
"update",
"m",
]);
} finally {
restore();
}
});
it("never lets a source reach a shell", async () => {
// If the value were interpolated into a shell string this would create the
// file; through execFile's argv array it stays one literal argument.
const canary = path.join(ROOT, "pwned");
const restore = stubClaude();
try {
await assert.rejects(
() => cli.marketplaceAdd({ source: `owner/repo; touch ${canary}` }),
/source must be/
);
assert.equal(fs.existsSync(canary), false);
} finally {
restore();
}
});
});
describe("cc-plugin-cli run outcomes", () => {
it("reports a non-zero exit as ok:false instead of throwing", async () => {
const restore = stubClaude('echo "✘ Failed to install plugin \\"nope\\"" >&2\nexit 1');
try {
const result = await cli.pluginInstall({ spec: "nope@m" });
assert.equal(result.ok, false);
assert.equal(result.code, 1);
assert.match(cli.cliMessage(result), /Failed to install plugin/);
} finally {
restore();
}
});
it("throws a clear error when the claude CLI is not on PATH", async () => {
const restore = stubMissingClaude();
try {
await assert.rejects(
() => cli.marketplaceUpdate({ name: "m" }),
(err) => {
assert.equal(err.code, "ECLINOTFOUND");
assert.match(err.message, /not found on PATH/);
return true;
}
);
} finally {
restore();
}
});
it("parses `plugin list --available --json`", async () => {
const payload = JSON.stringify({
installed: [{ id: "demo@m" }],
available: [{ pluginId: "other@m", name: "other" }],
});
const restore = stubClaude(`cat <<'JSON'\n${payload}\nJSON`);
try {
const listed = await cli.listAvailable();
assert.equal(listed.installed.length, 1);
assert.equal(listed.available[0].pluginId, "other@m");
} finally {
restore();
}
});
it("surfaces unparseable list output as an error", async () => {
const restore = stubClaude("echo 'not json'");
try {
await assert.rejects(() => cli.listAvailable(), /could not parse/);
} finally {
restore();
}
});
it("serializes mutating calls so two clicks cannot interleave", async () => {
// Each run appends its marker around a sleep; interleaved runs would
// produce "a1 b1 a2 b2" rather than two complete pairs.
const log = path.join(ROOT, `order-${process.hrtime.bigint()}.txt`);
const restore = stubClaude(`echo "start $3" >> ${log}\nsleep 0.2\necho "end $3" >> ${log}`);
try {
await Promise.all([
cli.marketplaceUpdate({ name: "first" }),
cli.marketplaceUpdate({ name: "second" }),
]);
const lines = fs.readFileSync(log, "utf8").trim().split("\n");
assert.equal(lines[0].replace("start ", ""), lines[1].replace("end ", ""));
assert.equal(lines[2].replace("start ", ""), lines[3].replace("end ", ""));
} finally {
restore();
}
});
});
+184 -3
View File
@@ -5,10 +5,17 @@
* skills, subagents, slash commands, output styles, CLAUDE.md memory, and
* per-project file-based memory (~/.claude/projects/<slug>/memory/*.md).
*
* settings.json layers (user / project / project-local) are also writable, but
* ONLY through `patchSettings`'s per-key merge — never as a whole-blob
* overwrite. Hooks are edited through that same path (`hooks.<Event>`).
*
* Hard constraints (do not relax without a follow-up review):
* - Plugins, MCP servers, hooks-in-settings, and settings.json files are
* NEVER touched here. Those have concurrent-write races with the live
* Claude Code CLI and need different handling.
* - Plugins and MCP servers are NEVER touched here. Plugin state is written
* by the `claude` CLI (see cc-plugin-cli.js); MCP config stays read-only.
* - A settings write re-reads the file unredacted and merges the named keys
* only. Writing back what `readSettings` returned would replace a real
* secret with the literal `<redacted>`, so any payload carrying that
* marker is refused.
* - Every write/delete creates a timestamped backup BEFORE the mutation.
* Backups land under <root>/cc-config-backups/<type>/, well outside the
* directories Claude Code scans, so a deleted skill cannot reappear as
@@ -439,6 +446,179 @@ function writeKeybindings(args = {}) {
return { ok: true, file, target: file, backupPath, created: !existedBefore };
}
// ── settings.json (key-merge only) ─────────────────────────────────────
//
// The three settings layers Claude Code reads. `project-local` is the
// dashboard's name for `settings.local.json`; the plugin CLI calls the same
// layer `local` (see cc-plugin-cli.js) — the two vocabularies are separate on
// purpose and never share a validator.
const SETTINGS_SCOPES = {
user: (opts) => path.join(getClaudeHome(), "settings.json"),
project: (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
"project-local": (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
};
/** A single dotted-path segment. `$` is allowed for `$schema`-style metadata. */
const SETTINGS_SEGMENT_RE = /^[A-Za-z0-9_$][A-Za-z0-9._$-]*$/;
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
function settingsFileFor(scope, opts) {
const resolver = SETTINGS_SCOPES[scope];
if (!resolver) throw makeError("EBADSCOPE", `unknown settings scope: ${scope}`);
return resolver(opts);
}
function settingsBackupRoot(scope, opts) {
// Beside the file being edited, in the dotted dir Claude Code never scans —
// same convention memory backups use.
return path.join(path.dirname(settingsFileFor(scope, opts)), ".cc-config-backups", "settings");
}
/** Split `"permissions.allow"` into validated segments. */
function parseSettingsPath(dotted) {
if (typeof dotted !== "string" || !dotted.trim()) {
throw makeError("EBADNAME", "settings key path must be a non-empty string");
}
const parts = dotted.split(".");
for (const seg of parts) {
if (!seg || !SETTINGS_SEGMENT_RE.test(seg) || FORBIDDEN_SEGMENTS.has(seg)) {
throw makeError("EBADNAME", `invalid settings key segment: ${seg}`);
}
}
return parts;
}
/**
* Guard against the redaction trap: `readSettings` replaces secret-looking
* string values with the literal `<redacted>` before the UI ever sees them, so
* echoing a value read from the UI back into the file would overwrite a real
* credential with that placeholder. Any payload carrying the marker is
* refused outright rather than partially applied.
*/
function assertNoRedactionMarker(value) {
let json;
try {
json = JSON.stringify(value);
} catch {
throw makeError("EBADCONTENT", "settings value is not JSON-serializable");
}
if (json === undefined) throw makeError("EBADCONTENT", "settings value is not JSON-serializable");
if (json.includes("<redacted>")) {
throw makeError(
"EREDACTED",
"value contains the <redacted> placeholder — edit that key in the file directly instead"
);
}
}
function setDeep(obj, parts, value) {
let node = obj;
for (let i = 0; i < parts.length - 1; i += 1) {
const key = parts[i];
const next = node[key];
if (!next || typeof next !== "object" || Array.isArray(next)) node[key] = {};
node = node[key];
}
node[parts[parts.length - 1]] = value;
}
function unsetDeep(obj, parts) {
let node = obj;
for (let i = 0; i < parts.length - 1; i += 1) {
const next = node[parts[i]];
if (!next || typeof next !== "object") return false;
node = next;
}
const last = parts[parts.length - 1];
if (!Object.prototype.hasOwnProperty.call(node, last)) return false;
delete node[last];
return true;
}
/**
* Merge individual keys into a settings.json layer. This is the ONLY write
* path to a settings file, and it is deliberately a per-key merge: the file is
* re-read (unredacted) here, so keys the dashboard never showed — including
* secret-valued ones — survive byte-for-byte.
*
* A file that exists but does not parse as JSON is an error, never a
* clobber: overwriting a hand-edited file the CLI still owns would lose data.
*
* @param {{scope:string, cwd?:string, set?:Record<string,unknown>, unset?:string[]}} args
* @returns {{path:string, backupPath:string|null, created:boolean, changed:string[]}}
*/
function patchSettings(args) {
const { scope, cwd, set, unset } = args;
const opts = { cwd };
const file = settingsFileFor(scope, opts);
const setEntries =
set && typeof set === "object" && !Array.isArray(set) ? Object.entries(set) : [];
const unsetPaths = Array.isArray(unset) ? unset : [];
if (setEntries.length === 0 && unsetPaths.length === 0) {
throw makeError("EBADCONTENT", "nothing to change: provide `set` and/or `unset`");
}
// Validate everything before touching disk — a partial apply is worse than
// a rejected request.
const parsedSet = setEntries.map(([key, value]) => {
assertNoRedactionMarker(value);
return { parts: parseSettingsPath(key), key, value };
});
const parsedUnset = unsetPaths.map((key) => ({ parts: parseSettingsPath(key), key }));
let existed = false;
let data = {};
let raw = null;
try {
raw = fs.readFileSync(file, "utf8");
existed = true;
} catch {
/* fresh file */
}
if (existed) {
if (Buffer.byteLength(raw, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", "settings file is too large to edit safely");
}
try {
data = raw.trim() ? JSON.parse(raw) : {};
} catch {
throw makeError("EBADJSON", `${file} is not valid JSON — fix it by hand before editing here`);
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw makeError("EBADJSON", `${file} does not contain a JSON object`);
}
}
const changed = [];
for (const { parts, key, value } of parsedSet) {
setDeep(data, parts, value);
changed.push(key);
}
for (const { parts, key } of parsedUnset) {
if (unsetDeep(data, parts)) changed.push(key);
}
const output = `${JSON.stringify(data, null, 2)}\n`;
// The same ceiling the read path enforces: a value pasted into the editor
// must not produce a file the explorer can no longer open. Checked BEFORE
// the backup, so a rejected write leaves no artifacts at all.
if (Buffer.byteLength(output, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", "the resulting settings file would be too large");
}
let backupPath = null;
if (existed) {
const root = settingsBackupRoot(scope, opts);
fs.mkdirSync(root, { recursive: true });
backupPath = path.join(root, `${path.basename(file)}.${timestamp()}.bak`);
fs.copyFileSync(file, backupPath);
}
atomicWriteFile(file, output);
return { path: file, backupPath, created: !existed, changed };
}
/**
* List backups for either all types or a specific (scope, type) bucket.
* Returns [{ scope, type, name, backupPath, mtime, size }].
@@ -528,6 +708,7 @@ module.exports = {
writeArtifact,
deleteArtifact,
writeKeybindings,
patchSettings,
listBackups,
resolveTarget, // exported for tests
TYPES,
+277
View File
@@ -0,0 +1,277 @@
/**
* @file cc-plugin-cli.js
* @description Drives the official `claude plugin …` CLI so the dashboard can
* add/remove marketplaces and install/uninstall/enable/disable/update plugins
* without the user opening a terminal.
*
* Why the CLI and not direct JSON edits: `installed_plugins.json`,
* `known_marketplaces.json` and the plugin cache tree are written together by
* the CLI (it clones the marketplace repo, resolves versions, prunes the
* cache). Reproducing that here would drift the moment the CLI's schema moves.
*
* Hard constraints (do not relax without a follow-up review):
* - `execFile` with an explicit argv array. NEVER a shell string — the
* `source`/`spec` values come straight from a user-typed field.
* - Every argument is validated BEFORE the spawn: no leading `-` (a value
* can never become a flag), no control characters, and a shape allowlist
* per argument kind.
* - Mutating calls are serialized in-process; two dashboard clicks must not
* interleave writes to the same manifest.
* - `CLAUDE_CONFIG_DIR` is pinned to the same CLAUDE_HOME the dashboard
* reads, so the CLI writes where `cc-discovery` looks.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFile } = require("node:child_process");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const DEFAULT_TIMEOUT_MS = 120_000;
const MAX_OUTPUT_BYTES = 1024 * 1024;
/** `owner/repo` — the GitHub shorthand `claude plugin marketplace add` accepts. */
// Both halves must START with an alphanumeric, so `./relative` is not mistaken
// for `owner/repo`.
const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
/** `name` or `name@marketplace`. No slashes: a plugin spec is never a path. */
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
/** A marketplace's registered name (what `known_marketplaces.json` is keyed by). */
const MARKETPLACE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
/** Install scopes the CLI understands — note `local`, not `project-local`. */
const CLI_SCOPES = ["user", "project", "local"];
function makeError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/**
* Baseline check applied to every argv element we interpolate. A value that
* starts with `-` would be parsed as a flag by the CLI even inside an argv
* array, so it is rejected rather than escaped.
*/
function assertArg(value, label) {
if (typeof value !== "string" || !value.trim()) {
throw makeError("EBADARG", `${label} is required`);
}
if (value.length > 512) throw makeError("EBADARG", `${label} is too long`);
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u001f\u007f]/.test(value)) {
throw makeError("EBADARG", `${label} contains control characters`);
}
if (value.startsWith("-")) throw makeError("EBADARG", `${label} may not start with "-"`);
return value;
}
/**
* A marketplace source is one of: `owner/repo`, an http(s) URL, or an absolute
* path on this machine. Anything else (an ssh remote, a `file://` URL, a
* relative path) is refused — the set the UI advertises is the set we accept.
*/
function assertSource(value) {
assertArg(value, "source");
const v = value.trim();
if (v.includes("..")) throw makeError("EBADARG", "source may not contain '..'");
if (/^https?:\/\/\S+$/i.test(v)) return v;
if (path.isAbsolute(v)) return v;
if (REPO_RE.test(v)) return v;
throw makeError("EBADARG", "source must be owner/repo, an http(s) URL, or an absolute path");
}
function assertPluginSpec(value) {
assertArg(value, "plugin");
if (!PLUGIN_SPEC_RE.test(value)) {
throw makeError("EBADARG", "plugin must be <name> or <name>@<marketplace>");
}
return value;
}
function assertMarketplaceName(value) {
assertArg(value, "marketplace");
if (!MARKETPLACE_NAME_RE.test(value)) {
throw makeError("EBADARG", "marketplace name is not a valid identifier");
}
return value;
}
function assertCliScope(value) {
if (value === undefined || value === null || value === "") return "user";
if (!CLI_SCOPES.includes(value)) {
throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`);
}
return value;
}
// One in-process queue for every mutating CLI call. The dashboard is a single
// Node process, so a promise chain is the whole locking story — no file lock,
// no cross-process coordination. It does NOT protect against a human running
// `claude plugin …` in a terminal at the same moment; the CLI owns that race.
let mutationQueue = Promise.resolve();
function serialize(fn) {
const run = mutationQueue.then(fn, fn);
mutationQueue = run.then(
() => undefined,
() => undefined
);
return run;
}
/**
* Run `claude <args…>` and resolve with the outcome. A non-zero exit is a
* normal result (`ok:false`), not a rejection — callers map it to a response
* body. Only a spawn failure (binary missing) rejects.
*
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
* @param {{timeoutMs?:number}} [opts]
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
*/
function runClaude(args, opts = {}) {
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
const child = execFile(
"claude",
args,
{
timeout,
maxBuffer: MAX_OUTPUT_BYTES,
// Pin the CLI to the same config dir cc-discovery reads, so a write
// made here is a write the dashboard can see.
env: { ...process.env, CLAUDE_CONFIG_DIR: getClaudeHome() },
},
(err, stdout, stderr) => {
const out = String(stdout || "");
const errOut = String(stderr || "");
if (err && (err.code === "ENOENT" || err.code === "EACCES")) {
return reject(
makeError(
"ECLINOTFOUND",
"the `claude` CLI was not found on PATH — install Claude Code or add it to the dashboard's PATH"
)
);
}
if (err && err.killed) {
return reject(makeError("ETIMEOUT", `claude ${args[0]} timed out after ${timeout}ms`));
}
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
resolve({ ok: !err, code, stdout: out, stderr: errOut, args });
}
);
// Every command here is non-interactive. Closing stdin turns a would-be
// prompt into an immediate CLI error instead of a hang until `timeout`.
try {
child.stdin?.end();
} catch {
/* the child may already have exited */
}
});
}
// ── Operations ─────────────────────────────────────────────────────────
// Each validates first, then queues. Validation errors throw synchronously
// from the returned promise, which is what the routes turn into a 400.
async function marketplaceAdd({ source, scope }) {
const src = assertSource(source);
const sc = assertCliScope(scope);
return serialize(() => runClaude(["plugin", "marketplace", "add", src, "--scope", sc]));
}
async function marketplaceRemove({ name }) {
const n = assertMarketplaceName(name);
return serialize(() => runClaude(["plugin", "marketplace", "remove", n]));
}
async function marketplaceUpdate({ name }) {
const n = assertMarketplaceName(name);
return serialize(() => runClaude(["plugin", "marketplace", "update", n]));
}
async function pluginInstall({ spec, scope }) {
const s = assertPluginSpec(spec);
const sc = assertCliScope(scope);
// `-y` accepts the marketplace-declared install command without a prompt;
// required because stdin is not a TTY here.
return serialize(() => runClaude(["plugin", "install", s, "--scope", sc, "-y"]));
}
async function pluginUninstall({ spec }) {
const s = assertPluginSpec(spec);
return serialize(() => runClaude(["plugin", "uninstall", s]));
}
async function pluginEnable({ spec }) {
const s = assertPluginSpec(spec);
return serialize(() => runClaude(["plugin", "enable", s]));
}
async function pluginDisable({ spec }) {
const s = assertPluginSpec(spec);
return serialize(() => runClaude(["plugin", "disable", s]));
}
async function pluginUpdate({ spec }) {
const s = assertPluginSpec(spec);
return serialize(() => runClaude(["plugin", "update", s]));
}
/**
* `claude plugin list --available --json` — every plugin offered by a
* registered marketplace, plus what is already installed. Read-only, so it
* skips the mutation queue.
*
* @returns {Promise<{installed:object[], available:object[]}>}
*/
async function listAvailable() {
const res = await runClaude(["plugin", "list", "--available", "--json"], { timeoutMs: 30_000 });
if (!res.ok) {
throw makeError("ECLIFAILED", cliMessage(res) || "claude plugin list failed");
}
let parsed;
try {
parsed = JSON.parse(res.stdout);
} catch {
throw makeError("ECLIFAILED", "could not parse `claude plugin list --json` output");
}
return {
installed: Array.isArray(parsed.installed) ? parsed.installed : [],
available: Array.isArray(parsed.available) ? parsed.available : [],
};
}
/**
* Best-effort human message from a failed run. The CLI prints its errors with
* a `✘` marker on either stream; fall back to the last non-empty line.
*/
function cliMessage(res) {
const text = `${res.stderr || ""}\n${res.stdout || ""}`;
const lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const marked = lines.find((l) => l.includes("✘"));
const picked = marked || lines[lines.length - 1] || "";
return picked.replace(/^✘\s*/, "").slice(0, 1000);
}
module.exports = {
runClaude,
cliMessage,
listAvailable,
marketplaceAdd,
marketplaceRemove,
marketplaceUpdate,
pluginInstall,
pluginUninstall,
pluginEnable,
pluginDisable,
pluginUpdate,
// exported for tests
assertSource,
assertPluginSpec,
assertMarketplaceName,
assertCliScope,
PLUGIN_SPEC_RE,
};
+146 -6
View File
@@ -6,12 +6,13 @@
* statusline, hook scripts. Powers the Claude Config Explorer dashboard
* page.
*
* Read paths cover every surface. Write paths exist only for low-risk
* text-file artifacts (skills, agents, commands, output styles, memory, and
* per-project auto-memory files) plus the structured keybindings.json editor,
* and always create a timestamped backup before mutating. Plugins, MCP servers,
* and the live settings.json files stay read-only because they are written
* concurrently by the running Claude Code CLI.
* Read paths cover every surface. Write paths cover: low-risk text-file
* artifacts (skills, agents, commands, output styles, memory, per-project
* auto-memory), the structured keybindings.json editor, settings.json layers
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
* and plugin/marketplace management delegated to the `claude plugin …` CLI.
* Every file write is preceded by a timestamped backup. MCP server config
* stays read-only — it is written concurrently by the running CLI.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,6 +20,7 @@
const { Router } = require("express");
const cc = require("../lib/cc-discovery");
const ccMutate = require("../lib/cc-mutate");
const ccCli = require("../lib/cc-plugin-cli");
const { broadcast } = require("../websocket");
const router = Router();
@@ -41,6 +43,9 @@ const ERR_TO_STATUS = {
ETOOLARGE: 413,
EOUTOFROOT: 400,
ENOTFOUND: 404,
EREDACTED: 400,
// The file on disk is unusable as-is; the caller must fix it by hand first.
EBADJSON: 409,
};
function mutateError(res, err) {
@@ -192,6 +197,141 @@ router.delete("/file", (req, res) => {
}
});
// ── Plugins / marketplaces (via the `claude` CLI) ──────────────────────
//
// These shell out to `claude plugin …` with an argv array — never a shell
// string — because the CLI owns the manifest + cache tree that plugin state
// lives in. Identifiers travel in the body, not the URL, so a key like
// `ccam@sg` needs no encoding (same reason `DELETE /file` takes a body).
const CLI_ERR_TO_STATUS = {
EBADARG: 400,
ECLINOTFOUND: 503,
ETIMEOUT: 504,
ECLIFAILED: 502,
};
function cliError(res, err) {
const status = CLI_ERR_TO_STATUS[err.code] || 500;
return res
.status(status)
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
}
/** Map a finished CLI run onto a response; a non-zero exit is a 422, not a 500. */
function cliResult(res, result, changed) {
if (!result.ok) {
return res.status(422).json({
error: {
code: "ECLIEXIT",
message: ccCli.cliMessage(result) || `claude exited with code ${result.code}`,
},
code: result.code,
stdout: result.stdout,
stderr: result.stderr,
});
}
emitChanged(changed);
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
}
router.get("/plugins/available", async (_req, res) => {
try {
res.json(await ccCli.listAvailable());
} catch (err) {
return cliError(res, err);
}
});
router.post("/plugins/install", async (req, res) => {
const { spec, scope } = req.body || {};
try {
const result = await ccCli.pluginInstall({ spec, scope });
return cliResult(res, result, { action: "install", type: "plugin", name: spec });
} catch (err) {
return cliError(res, err);
}
});
const PLUGIN_ACTIONS = {
enable: ccCli.pluginEnable,
disable: ccCli.pluginDisable,
update: ccCli.pluginUpdate,
uninstall: ccCli.pluginUninstall,
};
router.post("/plugins/action", async (req, res) => {
const { key, action } = req.body || {};
const run = PLUGIN_ACTIONS[action];
if (!run) {
return res.status(400).json({
error: {
code: "EBADACTION",
message: `action must be one of ${Object.keys(PLUGIN_ACTIONS).join(", ")}`,
},
});
}
try {
const result = await run({ spec: key });
return cliResult(res, result, { action, type: "plugin", name: key });
} catch (err) {
return cliError(res, err);
}
});
router.post("/marketplaces/add", async (req, res) => {
const { source, scope } = req.body || {};
try {
const result = await ccCli.marketplaceAdd({ source, scope });
return cliResult(res, result, { action: "add", type: "marketplace", name: source });
} catch (err) {
return cliError(res, err);
}
});
const MARKETPLACE_ACTIONS = {
remove: ccCli.marketplaceRemove,
update: ccCli.marketplaceUpdate,
};
router.post("/marketplaces/action", async (req, res) => {
const { name, action } = req.body || {};
const run = MARKETPLACE_ACTIONS[action];
if (!run) {
return res.status(400).json({
error: {
code: "EBADACTION",
message: `action must be one of ${Object.keys(MARKETPLACE_ACTIONS).join(", ")}`,
},
});
}
try {
const result = await run({ name });
return cliResult(res, result, { action, type: "marketplace", name });
} catch (err) {
return cliError(res, err);
}
});
// PATCH /api/cc-config/settings — merge individual keys into one settings
// layer. Body: { scope: "user"|"project"|"project-local", set?: {dotted: value},
// unset?: ["dotted"] }. Hooks are edited through this same route as
// `set: { "hooks.PreToolUse": [...] }` in settings.json's own nested shape.
//
// The project layers resolve against the SERVER's cwd, never `?cwd=`: a write
// target chosen by the caller would let any request create a settings.json
// anywhere on disk. Reads keep the override; writes do not.
router.patch("/settings", (req, res) => {
const { scope, set, unset } = req.body || {};
try {
const result = ccMutate.patchSettings({ scope, cwd: process.cwd(), set, unset });
emitChanged({ action: "patch", type: "settings", name: scope, project: null });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
router.get("/backups", (req, res) => {
const scope =
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;