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:
@@ -217,9 +217,9 @@
|
|||||||
},
|
},
|
||||||
"explain": {
|
"explain": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"title": "Hooks are read-only here on purpose",
|
"title": "Editing hooks writes settings.json",
|
||||||
"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).",
|
"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": "Edit them yourself with one of:",
|
"howTo": "You can also edit them with:",
|
||||||
"cmd1": "claude config /hooks",
|
"cmd1": "claude config /hooks",
|
||||||
"cmd1Note": "Interactive hook editor inside Claude Code",
|
"cmd1Note": "Interactive hook editor inside Claude Code",
|
||||||
"cmd2": "$EDITOR ~/.claude/settings.json",
|
"cmd2": "$EDITOR ~/.claude/settings.json",
|
||||||
@@ -228,9 +228,9 @@
|
|||||||
"cmd3Note": "Hand-edit the project-level hooks block"
|
"cmd3Note": "Hand-edit the project-level hooks block"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings are read-only here on purpose",
|
"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. A dashboard write would race with the CLI and lose state.",
|
"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": "Edit settings via Claude Code itself:",
|
"howTo": "You can also edit them with:",
|
||||||
"cmd1": "claude config set <key> <value>",
|
"cmd1": "claude config set <key> <value>",
|
||||||
"cmd1Note": "Set an individual key safely",
|
"cmd1Note": "Set an individual key safely",
|
||||||
"cmd2": "/config",
|
"cmd2": "/config",
|
||||||
@@ -239,8 +239,8 @@
|
|||||||
"cmd3Note": "Hand-edit (only when no `claude` process is running)"
|
"cmd3Note": "Hand-edit (only when no `claude` process is running)"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"title": "Plugins are managed by the CLI",
|
"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 dashboard reads them but defers install/uninstall to `claude plugin …` so the registry stays consistent.",
|
"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:",
|
"install": "Install a plugin:",
|
||||||
"installCmd": "claude plugin install <marketplace>/<plugin>",
|
"installCmd": "claude plugin install <marketplace>/<plugin>",
|
||||||
"uninstall": "Uninstall this plugin:",
|
"uninstall": "Uninstall this plugin:",
|
||||||
@@ -255,10 +255,64 @@
|
|||||||
"addCmd": "claude mcp add <name> <command> [args…]",
|
"addCmd": "claude mcp add <name> <command> [args…]",
|
||||||
"remove": "Remove this server:",
|
"remove": "Remove this server:",
|
||||||
"removeButton": "Copy remove command"
|
"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": {
|
"snippet": {
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied"
|
"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}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,9 +212,9 @@
|
|||||||
},
|
},
|
||||||
"explain": {
|
"explain": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"title": "Hooks ở đây cố tình chỉ đọc",
|
"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. 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).",
|
"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": "Tự sửa bằng một trong các cách:",
|
"howTo": "Bạn cũng có thể sửa bằng:",
|
||||||
"cmd1": "claude config /hooks",
|
"cmd1": "claude config /hooks",
|
||||||
"cmd1Note": "Trình chỉnh sửa hooks tương tác trong Claude Code",
|
"cmd1Note": "Trình chỉnh sửa hooks tương tác trong Claude Code",
|
||||||
"cmd2": "$EDITOR ~/.claude/settings.json",
|
"cmd2": "$EDITOR ~/.claude/settings.json",
|
||||||
@@ -223,9 +223,9 @@
|
|||||||
"cmd3Note": "Sửa thủ công khối hooks ở phạm vi dự án"
|
"cmd3Note": "Sửa thủ công khối hooks ở phạm vi dự án"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings ở đây cố tình chỉ đọc",
|
"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… Việc dashboard ghi sẽ tranh chấp với CLI và mất trạng thái.",
|
"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": "Sửa settings qua chính Claude Code:",
|
"howTo": "Bạn cũng có thể sửa bằng:",
|
||||||
"cmd1": "claude config set <key> <value>",
|
"cmd1": "claude config set <key> <value>",
|
||||||
"cmd1Note": "Đặt một khoá riêng lẻ an toàn",
|
"cmd1Note": "Đặt một khoá riêng lẻ an toàn",
|
||||||
"cmd2": "/config",
|
"cmd2": "/config",
|
||||||
@@ -234,8 +234,8 @@
|
|||||||
"cmd3Note": "Sửa thủ công (chỉ khi không có process claude nào đang chạy)"
|
"cmd3Note": "Sửa thủ công (chỉ khi không có process claude nào đang chạy)"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"title": "Plugin được CLI quản lý",
|
"title": "Plugin chạy qua CLI `claude`",
|
||||||
"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.",
|
"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:",
|
"install": "Cài plugin:",
|
||||||
"installCmd": "claude plugin install <marketplace>/<plugin>",
|
"installCmd": "claude plugin install <marketplace>/<plugin>",
|
||||||
"uninstall": "Gỡ plugin này:",
|
"uninstall": "Gỡ plugin này:",
|
||||||
@@ -250,10 +250,64 @@
|
|||||||
"addCmd": "claude mcp add <name> <command> [args…]",
|
"addCmd": "claude mcp add <name> <command> [args…]",
|
||||||
"remove": "Gỡ server này:",
|
"remove": "Gỡ server này:",
|
||||||
"removeButton": "Sao chép lệnh gỡ"
|
"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": {
|
"snippet": {
|
||||||
"copy": "Sao chép",
|
"copy": "Sao chép",
|
||||||
"copied": "Đã 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}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1485,6 +1485,63 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
backups: (params?: { scope?: "user" | "project"; type?: CcArtifactType }) =>
|
backups: (params?: { scope?: "user" | "project"; type?: CcArtifactType }) =>
|
||||||
requestBackupsHelper(params),
|
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 ─────────────────────────────────
|
// ────────────────────────────────── Run API ─────────────────────────────────
|
||||||
@@ -2301,6 +2358,59 @@ export interface CcHookSource {
|
|||||||
hooks: Record<string, CcHookEntry[]>;
|
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. */
|
/** One settings.json layer's raw contents, from GET /api/cc-config/settings. */
|
||||||
export interface CcSettingsSource {
|
export interface CcSettingsSource {
|
||||||
scope: "user" | "project" | "project-local";
|
scope: "user" | "project" | "project-local";
|
||||||
|
|||||||
+966
-90
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -1441,7 +1441,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9
|
|||||||
|
|
||||||
### Claude Config Explorer
|
### 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
|
```http
|
||||||
GET /api/cc-config/overview
|
GET /api/cc-config/overview
|
||||||
@@ -1463,13 +1463,23 @@ GET /api/cc-config/file?path=<absolute-path>
|
|||||||
GET /api/cc-config/backups[?scope=&type=]
|
GET /api/cc-config/backups[?scope=&type=]
|
||||||
PUT /api/cc-config/file Body: { scope, type, name?, content }
|
PUT /api/cc-config/file Body: { scope, type, name?, content }
|
||||||
DELETE /api/cc-config/file Body: { scope, type, name? }
|
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>"`.
|
`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`).
|
`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.
|
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
@@ -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`.
|
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.
|
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
|
## Plugin Development
|
||||||
|
|
||||||
|
|||||||
@@ -822,4 +822,240 @@ describe("/api/cc-config", () => {
|
|||||||
assert.equal(status, 400);
|
assert.equal(status, 400);
|
||||||
assert.equal(body.error.code, "EBADCONTENT");
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -5,10 +5,17 @@
|
|||||||
* skills, subagents, slash commands, output styles, CLAUDE.md memory, and
|
* skills, subagents, slash commands, output styles, CLAUDE.md memory, and
|
||||||
* per-project file-based memory (~/.claude/projects/<slug>/memory/*.md).
|
* 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):
|
* Hard constraints (do not relax without a follow-up review):
|
||||||
* - Plugins, MCP servers, hooks-in-settings, and settings.json files are
|
* - Plugins and MCP servers are NEVER touched here. Plugin state is written
|
||||||
* NEVER touched here. Those have concurrent-write races with the live
|
* by the `claude` CLI (see cc-plugin-cli.js); MCP config stays read-only.
|
||||||
* Claude Code CLI and need different handling.
|
* - 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.
|
* - Every write/delete creates a timestamped backup BEFORE the mutation.
|
||||||
* Backups land under <root>/cc-config-backups/<type>/, well outside the
|
* Backups land under <root>/cc-config-backups/<type>/, well outside the
|
||||||
* directories Claude Code scans, so a deleted skill cannot reappear as
|
* 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 };
|
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.
|
* List backups for either all types or a specific (scope, type) bucket.
|
||||||
* Returns [{ scope, type, name, backupPath, mtime, size }].
|
* Returns [{ scope, type, name, backupPath, mtime, size }].
|
||||||
@@ -528,6 +708,7 @@ module.exports = {
|
|||||||
writeArtifact,
|
writeArtifact,
|
||||||
deleteArtifact,
|
deleteArtifact,
|
||||||
writeKeybindings,
|
writeKeybindings,
|
||||||
|
patchSettings,
|
||||||
listBackups,
|
listBackups,
|
||||||
resolveTarget, // exported for tests
|
resolveTarget, // exported for tests
|
||||||
TYPES,
|
TYPES,
|
||||||
|
|||||||
@@ -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
@@ -6,12 +6,13 @@
|
|||||||
* statusline, hook scripts. Powers the Claude Config Explorer dashboard
|
* statusline, hook scripts. Powers the Claude Config Explorer dashboard
|
||||||
* page.
|
* page.
|
||||||
*
|
*
|
||||||
* Read paths cover every surface. Write paths exist only for low-risk
|
* Read paths cover every surface. Write paths cover: low-risk text-file
|
||||||
* text-file artifacts (skills, agents, commands, output styles, memory, and
|
* artifacts (skills, agents, commands, output styles, memory, per-project
|
||||||
* per-project auto-memory files) plus the structured keybindings.json editor,
|
* auto-memory), the structured keybindings.json editor, settings.json layers
|
||||||
* and always create a timestamped backup before mutating. Plugins, MCP servers,
|
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
|
||||||
* and the live settings.json files stay read-only because they are written
|
* and plugin/marketplace management delegated to the `claude plugin …` CLI.
|
||||||
* concurrently by the running Claude Code 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
const { Router } = require("express");
|
const { Router } = require("express");
|
||||||
const cc = require("../lib/cc-discovery");
|
const cc = require("../lib/cc-discovery");
|
||||||
const ccMutate = require("../lib/cc-mutate");
|
const ccMutate = require("../lib/cc-mutate");
|
||||||
|
const ccCli = require("../lib/cc-plugin-cli");
|
||||||
const { broadcast } = require("../websocket");
|
const { broadcast } = require("../websocket");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -41,6 +43,9 @@ const ERR_TO_STATUS = {
|
|||||||
ETOOLARGE: 413,
|
ETOOLARGE: 413,
|
||||||
EOUTOFROOT: 400,
|
EOUTOFROOT: 400,
|
||||||
ENOTFOUND: 404,
|
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) {
|
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) => {
|
router.get("/backups", (req, res) => {
|
||||||
const scope =
|
const scope =
|
||||||
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
|
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user