diff --git a/client/src/i18n/locales/en/ccConfig.json b/client/src/i18n/locales/en/ccConfig.json index 073f2e24..0a99d748 100644 --- a/client/src/i18n/locales/en/ccConfig.json +++ b/client/src/i18n/locales/en/ccConfig.json @@ -217,9 +217,9 @@ }, "explain": { "hooks": { - "title": "Hooks are read-only here on purpose", - "body": "Hooks live in the `hooks` block of `settings.json` files that Claude Code writes to constantly. Editing them from the dashboard would race with the live CLI and could silently break the dashboard's own ingestion (the `hook-handler.js` entry below).", - "howTo": "Edit them yourself with one of:", + "title": "Editing hooks writes settings.json", + "body": "Hooks live in the `hooks` block of the `settings.json` files Claude Code writes to constantly. Edit saves only the event you changed, backs the file up first, and leaves every other key alone — but a `claude` session already running keeps its old hooks until it restarts. Take care with the dashboard`s own ingestion entries (the `hook-handler.js` commands below).", + "howTo": "You can also edit them with:", "cmd1": "claude config /hooks", "cmd1Note": "Interactive hook editor inside Claude Code", "cmd2": "$EDITOR ~/.claude/settings.json", @@ -228,9 +228,9 @@ "cmd3Note": "Hand-edit the project-level hooks block" }, "settings": { - "title": "Settings are read-only here on purpose", - "body": "These JSON files are rewritten by the live `claude` CLI on every model change, theme toggle, plugin enable, etc. A dashboard write would race with the CLI and lose state.", - "howTo": "Edit settings via Claude Code itself:", + "title": "Editing settings merges one key at a time", + "body": "These JSON files are rewritten by the live `claude` CLI on every model change, theme toggle, plugin enable, etc. Saving here re-reads the file and writes only the keys you changed (after a timestamped backup), so a concurrent CLI write loses nothing else — but a running session may not pick the change up until it restarts.", + "howTo": "You can also edit them with:", "cmd1": "claude config set ", "cmd1Note": "Set an individual key safely", "cmd2": "/config", @@ -239,8 +239,8 @@ "cmd3Note": "Hand-edit (only when no `claude` process is running)" }, "plugins": { - "title": "Plugins are managed by the CLI", - "body": "Plugins are bundles with their own registry - `installed_plugins.json` plus install paths under `plugins/cache/`. The dashboard reads them but defers install/uninstall to `claude plugin …` so the registry stays consistent.", + "title": "Plugins run through the `claude` CLI", + "body": "Plugins are bundles with their own registry — `installed_plugins.json` plus install paths under `plugins/cache/`. The buttons here run the matching `claude plugin …` command on this machine, so the registry stays consistent. Installing a plugin means running third-party code: only add marketplaces you trust.", "install": "Install a plugin:", "installCmd": "claude plugin install /", "uninstall": "Uninstall this plugin:", @@ -255,10 +255,64 @@ "addCmd": "claude mcp add [args…]", "remove": "Remove this server:", "removeButton": "Copy remove command" + }, + "marketplaces": { + "title": "Marketplaces are where plugins come from", + "body": "A marketplace is a git repo (or local folder) with a `.claude-plugin/marketplace.json` listing the plugins it offers. Adding one registers it in `known_marketplaces.json` and clones it locally; its plugins then appear on the Plugins tab, ready to install. Removing one does not uninstall plugins you already installed from it." } }, "snippet": { "copy": "Copy", "copied": "Copied" + }, + "manage": { + "installTitle": "Install a plugin", + "installPlaceholder": "owner/repo, https://… or plugin@marketplace", + "installButton": "Add", + "installHelp": "Paste a GitHub repo, URL or local path to register its marketplace, or type @ 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}}" } } diff --git a/client/src/i18n/locales/vi/ccConfig.json b/client/src/i18n/locales/vi/ccConfig.json index 82af72cf..8b722cd1 100644 --- a/client/src/i18n/locales/vi/ccConfig.json +++ b/client/src/i18n/locales/vi/ccConfig.json @@ -212,9 +212,9 @@ }, "explain": { "hooks": { - "title": "Hooks ở đây cố tình chỉ đọc", - "body": "Hooks nằm trong khối `hooks` của các tệp `settings.json` mà Claude Code liên tục ghi vào. Sửa từ dashboard sẽ tranh chấp với CLI đang chạy và có thể ngầm phá hỏng đường tiếp nhận sự kiện của chính dashboard (`hook-handler.js` bên dưới).", - "howTo": "Tự sửa bằng một trong các cách:", + "title": "Sửa hooks tức là ghi vào settings.json", + "body": "Hooks nằm trong khối `hooks` của các tệp `settings.json` mà Claude Code liên tục ghi vào. Lưu ở đây chỉ ghi đúng sự kiện bạn sửa, sao lưu file trước, và giữ nguyên mọi khoá khác — nhưng phiên `claude` đang chạy vẫn dùng hooks cũ cho tới khi khởi động lại. Cẩn thận với các mục tiếp nhận sự kiện của chính dashboard (lệnh `hook-handler.js` bên dưới).", + "howTo": "Bạn cũng có thể sửa bằng:", "cmd1": "claude config /hooks", "cmd1Note": "Trình chỉnh sửa hooks tương tác trong Claude Code", "cmd2": "$EDITOR ~/.claude/settings.json", @@ -223,9 +223,9 @@ "cmd3Note": "Sửa thủ công khối hooks ở phạm vi dự án" }, "settings": { - "title": "Settings ở đây cố tình chỉ đọc", - "body": "Các tệp JSON này được CLI `claude` ghi đè mỗi khi đổi model, theme, bật plugin… Việc dashboard ghi sẽ tranh chấp với CLI và mất trạng thái.", - "howTo": "Sửa settings qua chính Claude Code:", + "title": "Sửa settings là gộp theo từng khoá", + "body": "Các tệp JSON này được CLI `claude` ghi đè mỗi khi đổi model, theme, bật plugin… Lưu ở đây sẽ đọc lại file và chỉ ghi những khoá bạn đổi (sau khi sao lưu kèm mốc thời gian), nên CLI ghi song song cũng không mất gì khác — nhưng phiên đang chạy có thể chỉ nhận thay đổi sau khi khởi động lại.", + "howTo": "Bạn cũng có thể sửa bằng:", "cmd1": "claude config set ", "cmd1Note": "Đặt một khoá riêng lẻ an toàn", "cmd2": "/config", @@ -234,8 +234,8 @@ "cmd3Note": "Sửa thủ công (chỉ khi không có process claude nào đang chạy)" }, "plugins": { - "title": "Plugin được CLI quản lý", - "body": "Plugin là bundle có registry riêng - `installed_plugins.json` và đường dẫn cài trong `plugins/cache/`. Dashboard đọc thôi; install/uninstall giao cho `claude plugin …` để giữ registry nhất quán.", + "title": "Plugin chạy qua CLI `claude`", + "body": "Plugin là các gói có registry riêng — `installed_plugins.json` cùng đường dẫn cài trong `plugins/cache/`. Các nút ở đây chạy đúng lệnh `claude plugin …` trên máy này nên registry luôn nhất quán. Cài plugin nghĩa là chạy mã của bên thứ ba: chỉ thêm marketplace mà bạn tin tưởng.", "install": "Cài plugin:", "installCmd": "claude plugin install /", "uninstall": "Gỡ plugin này:", @@ -250,10 +250,64 @@ "addCmd": "claude mcp add [args…]", "remove": "Gỡ server này:", "removeButton": "Sao chép lệnh gỡ" + }, + "marketplaces": { + "title": "Marketplace là nơi plugin đến từ đó", + "body": "Marketplace là một repo git (hoặc thư mục local) có `.claude-plugin/marketplace.json` liệt kê các plugin nó cung cấp. Thêm vào tức là đăng ký trong `known_marketplaces.json` và clone về máy; plugin của nó sẽ hiện ở tab Plugins để cài. Xóa marketplace không gỡ những plugin bạn đã cài từ nó." } }, "snippet": { "copy": "Sao chép", "copied": "Đã sao chép" + }, + "manage": { + "installTitle": "Cài plugin", + "installPlaceholder": "owner/repo, https://… hoặc plugin@marketplace", + "installButton": "Thêm", + "installHelp": "Dán link GitHub, URL hoặc đường dẫn local để đăng ký marketplace, hoặc gõ @ để 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}}" } } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index fe588f5f..4c739199 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1485,6 +1485,63 @@ export const api = { */ backups: (params?: { scope?: "user" | "project"; type?: CcArtifactType }) => requestBackupsHelper(params), + /** + * GET /api/cc-config/plugins/available - every plugin offered by a + * registered marketplace, from `claude plugin list --available --json`. + * @returns `{ installed, available }` — the CLI's own inventory. + */ + pluginsAvailable: () => request("/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("/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 `@` key. + */ + pluginAction: (key: string, action: CcPluginAction) => + request("/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("/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("/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.": [...] }` 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 `` 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("/cc-config/settings", { + method: "PATCH", + body: JSON.stringify(args), + }), }, // ────────────────────────────────── Run API ───────────────────────────────── @@ -2301,6 +2358,59 @@ export interface CcHookSource { hooks: Record; } +/** 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 { + /** `@` — 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; + unset?: string[]; +} + +/** Result of a settings key-merge. */ +export interface CcSettingsPatchResult { + /** Absolute path of the settings file that was written. */ + path: string; + /** Pre-write backup, or null when the file did not exist yet. */ + backupPath: string | null; + created: boolean; + /** Keys actually changed - an `unset` of an absent key is not listed. */ + changed: string[]; +} + /** One settings.json layer's raw contents, from GET /api/cc-config/settings. */ export interface CcSettingsSource { scope: "user" | "project" | "project-local"; diff --git a/client/src/pages/CcConfig.tsx b/client/src/pages/CcConfig.tsx index cf21f4b5..767f295e 100644 --- a/client/src/pages/CcConfig.tsx +++ b/client/src/pages/CcConfig.tsx @@ -98,8 +98,10 @@ import { import { api } from "../lib/api"; import type { CcArtifactType, + CcAvailablePlugin, CcBackup, CcFileResponse, + CcHookEntry, CcHookScripts, CcHookSource, CcKeybindings, @@ -545,7 +547,7 @@ export function CcConfig() { onEditAuto={openEditAuto} onDeleteAuto={openDeleteAuto} onCreateAuto={openCreateAuto} - onKeybindingsSaved={fetchAll} + onChanged={fetchAll} onToast={setToast} /> @@ -854,7 +856,8 @@ interface TabPanelProps { onEditAuto: (item: CcMemoryItem) => void; onDeleteAuto: (item: CcMemoryItem) => void; onCreateAuto: (project: string) => void; - onKeybindingsSaved: () => void; + /** Refetch every config surface after a mutation. */ + onChanged: () => void; onToast: (toast: NonNullable) => void; } @@ -869,7 +872,7 @@ function TabPanel({ onEditAuto, onDeleteAuto, onCreateAuto, - onKeybindingsSaved, + onChanged, onToast, }: TabPanelProps) { switch (tab) { @@ -920,9 +923,18 @@ function TabPanel({ /> ); case "plugins": - return ; + return ( + + ); case "marketplaces": - return ; + return ( + + ); case "mcp": return ; case "hooks": @@ -932,6 +944,8 @@ function TabPanel({ scripts={data.hookScripts} search={search} onOpen={onOpenFile} + onToast={onToast} + onChanged={onChanged} /> ); case "keybindings": @@ -939,13 +953,19 @@ function TabPanel({ ); case "settings": return ( - + ); case "memory": return ( @@ -1382,11 +1402,143 @@ function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) { // ── Plugins ─────────────────────────────────────────────────────────── -function PluginsPanel({ data, search }: { data: CcPluginsResponse | null; search: string }) { +/** + * Shared runner for the `claude`-CLI-backed actions (plugins, marketplaces) + * and the settings key-merge. Tracks which row is mid-flight so its buttons + * can disable, and funnels both outcomes into the page's toast. + */ +function useConfigAction(onToast: (t: NonNullable) => void, onChanged: () => void) { + const [busy, setBusy] = useState(null); + const run = useCallback( + async (id: string, fn: () => Promise, successMessage: string) => { + setBusy(id); + try { + await fn(); + onToast({ kind: "success", message: successMessage }); + onChanged(); + return true; + } catch (err: unknown) { + onToast({ + kind: "error", + message: err instanceof Error ? err.message : "unknown error", + }); + return false; + } finally { + setBusy(null); + } + }, + [onChanged, onToast] + ); + return { busy, run }; +} + +/** + * Does this look like a marketplace SOURCE (`owner/repo`, an http(s) URL, or an + * absolute path) rather than a plugin spec (`name` / `name@marketplace`)? Lets + * one input field accept either — pasting a GitHub link registers the + * marketplace, typing a spec installs the plugin. The server re-validates; this + * only decides which endpoint to call. + */ +function looksLikeMarketplaceSource(value: string): boolean { + const v = value.trim(); + if (!v) return false; + if (/^https?:\/\//i.test(v)) return true; + if (v.startsWith("/")) return true; + return /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v); +} + +/** Small action button used across the plugin/marketplace cards. */ +function ActionButton({ + onClick, + disabled, + danger, + icon: Icon, + children, +}: { + onClick: () => void; + disabled?: boolean; + danger?: boolean; + icon: typeof Sparkles; + children: React.ReactNode; +}) { + return ( + + ); +} + +function PluginsPanel({ + data, + search, + onToast, + onChanged, +}: { + data: CcPluginsResponse | null; + search: string; + onToast: (t: NonNullable) => void; + onChanged: () => void; +}) { const { t } = useTranslation("ccConfig"); + const { busy, run } = useConfigAction(onToast, onChanged); + const [input, setInput] = useState(""); + const [available, setAvailable] = useState(null); + const [availableError, setAvailableError] = useState(null); + + // The available inventory comes from the CLI, which may not be installed — + // a failure here is a normal state, not a page error. + const loadAvailable = useCallback(async () => { + try { + const res = await api.ccConfig.pluginsAvailable(); + setAvailable(res.available); + setAvailableError(null); + } catch (err: unknown) { + setAvailable([]); + setAvailableError(err instanceof Error ? err.message : "unknown error"); + } + }, []); + + useEffect(() => { + void loadAvailable(); + }, [loadAvailable, data]); + + const submit = useCallback(async () => { + const value = input.trim(); + if (!value) return; + const isSource = looksLikeMarketplaceSource(value); + const ok = await run( + "add", + () => (isSource ? api.ccConfig.addMarketplace(value) : api.ccConfig.installPlugin(value)), + isSource + ? t("manage.marketplaceAdded", { name: value }) + : t("manage.installed", { name: value }) + ); + if (ok) { + setInput(""); + void loadAvailable(); + } + }, [input, loadAvailable, run, t]); + if (!data) return ; - const filtered = data.plugins.filter( - (p) => !search || p.key.toLowerCase().includes(search.toLowerCase()) + const needle = search.toLowerCase(); + const filtered = data.plugins.filter((p) => !search || p.key.toLowerCase().includes(needle)); + const installedKeys = new Set(data.plugins.map((p) => p.key)); + const installable = (available ?? []).filter( + (a) => + !installedKeys.has(a.pluginId) && + (!search || + a.pluginId.toLowerCase().includes(needle) || + (a.description || "").toLowerCase().includes(needle)) ); return ( @@ -1397,6 +1549,37 @@ function PluginsPanel({ data, search }: { data: CcPluginsResponse | null; search howTo={t("explain.plugins.install")} commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]} /> + +
+
+ {t("manage.installTitle")} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void submit(); + }} + placeholder={t("manage.installPlaceholder")} + aria-label={t("manage.installTitle")} + className="h-8 flex-1 min-w-60 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" + /> + +
+

+ {t("manage.installHelp")} +

+
+
{data.manifestPath} @@ -1406,17 +1589,81 @@ function PluginsPanel({ data, search }: { data: CcPluginsResponse | null; search )}
+ {filtered.length === 0 ? ( ) : ( - filtered.map((p) => ) + filtered.map((p) => ) + )} + + {installable.length > 0 && ( +
+

+ {t("manage.availableTitle")} +

+
+ {installable.map((a) => ( +
+
+
+ {a.name} + {a.marketplaceName && ( + + {a.marketplaceName} + + )} +
+ {a.description && ( +

+ {a.description} +

+ )} +
+ + void run( + a.pluginId, + () => api.ccConfig.installPlugin(a.pluginId), + t("manage.installed", { name: a.pluginId }) + ) + } + > + {busy === a.pluginId ? t("manage.working") : t("manage.install")} + +
+ ))} +
+
+ )} + + {availableError && ( +
+ + {t("manage.cliUnavailable", { message: availableError })} +
)} ); } -function PluginCard({ plugin: p }: { plugin: CcPlugin }) { +function PluginCard({ + plugin: p, + busy, + run, +}: { + plugin: CcPlugin; + busy: string | null; + run: (id: string, fn: () => Promise, message: string) => Promise; +}) { const { t } = useTranslation("ccConfig"); + // Uninstall takes two clicks rather than a modal: the action is reversible + // (reinstall), so an accidental single click is the only thing to guard. + const [confirmUninstall, setConfirmUninstall] = useState(false); const meta = p.contributes?.pluginJson; const description = meta?.description; const contribCounts: { key: string; count: number; label: string }[] = []; @@ -1559,11 +1806,72 @@ function PluginCard({ plugin: p }: { plugin: CcPlugin }) { )} -
- +
+ {p.enabled === false ? ( + + void run( + p.key, + () => api.ccConfig.pluginAction(p.key, "enable"), + t("manage.enabled", { name: p.key }) + ) + } + > + {t("manage.enable")} + + ) : ( + + void run( + p.key, + () => api.ccConfig.pluginAction(p.key, "disable"), + t("manage.disabled", { name: p.key }) + ) + } + > + {t("manage.disable")} + + )} + + void run( + p.key, + () => api.ccConfig.pluginAction(p.key, "update"), + t("manage.updated", { name: p.key }) + ) + } + > + {t("manage.update")} + + { + if (!confirmUninstall) { + setConfirmUninstall(true); + return; + } + setConfirmUninstall(false); + void run( + p.key, + () => api.ccConfig.pluginAction(p.key, "uninstall"), + t("manage.uninstalled", { name: p.key }) + ); + }} + > + {busy === p.key + ? t("manage.working") + : confirmUninstall + ? t("manage.confirmUninstall") + : t("manage.uninstall")} +
@@ -1686,16 +1994,51 @@ function Field({ label, children }: { label: string; children: React.ReactNode } // ── Hooks ───────────────────────────────────────────────────────────── +/** + * `readHooks` FLATTENS settings.json's `[{matcher, hooks:[…]}]` into one row + * per command; this rebuilds the nested shape the file actually stores, + * regrouping consecutive rows that share a matcher. Rows the editor never + * created (an unknown event's raw objects) are filtered out upstream — those + * events are simply never sent, so they stay untouched on disk. + */ +function toNestedHooks(entries: CcHookEntry[]): unknown[] { + const groups: { matcher: string; hooks: Record[] }[] = []; + for (const e of entries) { + const matcher = e.matcher || "*"; + let group = groups.find((g) => g.matcher === matcher); + if (!group) { + group = { matcher, hooks: [] }; + groups.push(group); + } + const hook: Record = { + type: e.type || "command", + command: e.command || "", + }; + if (e.timeout != null) hook.timeout = e.timeout; + group.hooks.push(hook); + } + return groups; +} + +/** A hook row the editor can round-trip — i.e. one `readHooks` flattened. */ +function isEditableHookEntry(entry: unknown): entry is CcHookEntry { + return !!entry && typeof entry === "object" && "command" in (entry as object); +} + function HooksPanel({ sources, scripts, search, onOpen, + onToast, + onChanged, }: { sources: CcHookSource[] | null; scripts: CcHookScripts | null; search: string; onOpen: (p: string) => void; + onToast: (t: NonNullable) => void; + onChanged: () => void; }) { const { t } = useTranslation("ccConfig"); if (!sources) return ; @@ -1711,74 +2054,16 @@ function HooksPanel({ { cmd: t("explain.hooks.cmd3"), note: t("explain.hooks.cmd3Note") }, ]} /> - {sources.map((src) => { - const events = Object.entries(src.hooks); - const filteredEvents = search - ? events.filter(([event]) => event.toLowerCase().includes(search.toLowerCase())) - : events; - return ( -
-
- - - {src.file} - - {src.exists ? ( - - ) : ( - {t("hooks.fileMissing")} - )} -
-
- {filteredEvents.length === 0 ? ( -
{t("hooks.noHooks")}
- ) : ( -
- {filteredEvents.map(([event, entries]) => ( -
-
- - {event} - ({entries.length}) -
-
- {entries.map((h, idx) => ( -
-
- - {t("hooks.matcher")}={h.matcher} - - · - {h.type} - {h.timeout != null && ( - {h.timeout}ms - )} -
- {h.command && ( -
- {h.command} -
- )} -
- ))} -
-
- ))} -
- )} -
-
- ); - })} + {sources.map((src) => ( + + ))} {scripts && scripts.items.length > 0 && (
@@ -1813,6 +2098,338 @@ function HooksPanel({ ); } +/** + * One settings layer's hooks, readable and editable. Saving writes ONLY the + * `hooks.` keys this editor owns, through `PATCH /settings`'s key + * merge — every other key in the file, and every event whose entries the + * editor could not parse, is left exactly as it was. + */ +function HookSourceBlock({ + source: src, + search, + onOpen, + onToast, + onChanged, +}: { + source: CcHookSource; + search: string; + onOpen: (p: string) => void; + onToast: (t: NonNullable) => void; + onChanged: () => void; +}) { + const { t } = useTranslation("ccConfig"); + const { busy, run } = useConfigAction(onToast, onChanged); + const [draft, setDraft] = useState<{ event: string; entries: CcHookEntry[] }[] | null>(null); + const [err, setErr] = useState(null); + + const editableEvents = useMemo( + () => + Object.entries(src.hooks).filter(([, entries]) => + (entries as unknown[]).every(isEditableHookEntry) + ), + [src.hooks] + ); + const readOnlyEvents = useMemo( + () => + Object.entries(src.hooks).filter( + ([, entries]) => !(entries as unknown[]).every(isEditableHookEntry) + ), + [src.hooks] + ); + + const startEdit = () => { + setErr(null); + setDraft( + editableEvents.map(([event, entries]) => ({ + event, + entries: entries.map((e) => ({ ...e })), + })) + ); + }; + + const save = async () => { + if (!draft) return; + const set: Record = {}; + const seen = new Set(); + for (const { event, entries } of draft) { + const name = event.trim(); + if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) { + setErr(t("manage.hookBadEvent", { event: event || "-" })); + return; + } + if (seen.has(name)) { + setErr(t("manage.hookDupEvent", { event: name })); + return; + } + seen.add(name); + if (entries.some((e) => !e.command || !e.command.trim())) { + setErr(t("manage.hookEmptyCommand", { event: name })); + return; + } + set[`hooks.${name}`] = toNestedHooks(entries); + } + // An event dropped from the draft is removed from the file; one that was + // never editable here is not in either list, so it is never touched. + const unset = editableEvents + .map(([event]) => event) + .filter((event) => !seen.has(event)) + .map((event) => `hooks.${event}`); + + setErr(null); + const ok = await run( + "save", + () => + api.ccConfig.patchSettings({ + scope: src.scope, + set, + ...(unset.length ? { unset } : {}), + }), + t("manage.hooksSaved", { file: src.file }) + ); + if (ok) setDraft(null); + }; + + const updateEntry = (ei: number, hi: number, field: keyof CcHookEntry, value: string) => + setDraft((d) => + d + ? d.map((g, i) => + i === ei + ? { + ...g, + entries: g.entries.map((h, j) => + j === hi + ? { + ...h, + [field]: field === "timeout" ? (value ? Number(value) : null) : value, + } + : h + ), + } + : g + ) + : d + ); + + const filteredEvents = search + ? Object.entries(src.hooks).filter(([event]) => + event.toLowerCase().includes(search.toLowerCase()) + ) + : Object.entries(src.hooks); + + return ( +
+
+ + + {src.file} + + {src.exists ? ( + + ) : ( + {t("hooks.fileMissing")} + )} + {draft ? ( +
+ setDraft(null)} disabled={busy === "save"}> + {t("edit.cancel")} + + void save()} disabled={busy === "save"}> + {busy === "save" ? t("edit.saving") : t("edit.save")} + +
+ ) : ( + + {t("edit.editButton")} + + )} +
+ + {err && ( +
+ + {err} +
+ )} + + {draft ? ( +
+ {draft.map((g, ei) => ( +
+
+ + + setDraft((d) => + d ? d.map((x, i) => (i === ei ? { ...x, event: e.target.value } : x)) : d + ) + } + placeholder={t("manage.hookEvent")} + aria-label={t("manage.hookEvent")} + className="h-7 flex-1 min-w-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" + /> + +
+
+ {g.entries.map((h, hi) => ( +
+
+ updateEntry(ei, hi, "matcher", e.target.value)} + placeholder={t("hooks.matcher")} + aria-label={t("hooks.matcher")} + className="h-7 w-32 flex-shrink-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" + /> + updateEntry(ei, hi, "timeout", e.target.value)} + placeholder={t("hooks.timeout")} + aria-label={t("hooks.timeout")} + inputMode="numeric" + className="h-7 w-24 flex-shrink-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" + /> + +
+ updateEntry(ei, hi, "command", e.target.value)} + placeholder={t("hooks.command")} + aria-label={t("hooks.command")} + className="h-7 w-full bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" + /> +
+ ))} +
+ + setDraft((d) => + d + ? d.map((x, i) => + i === ei + ? { + ...x, + entries: [ + ...x.entries, + { matcher: "*", type: "command", command: "", timeout: null }, + ], + } + : x + ) + : d + ) + } + > + {t("manage.hookAddEntry")} + +
+
+
+ ))} + + {readOnlyEvents.length > 0 && ( +

+ {t("manage.hookUneditable", { + events: readOnlyEvents.map(([e]) => e).join(", "), + })} +

+ )} +
+ ) : ( +
+ {filteredEvents.length === 0 ? ( +
{t("hooks.noHooks")}
+ ) : ( +
+ {filteredEvents.map(([event, entries]) => ( +
+
+ + {event} + ({entries.length}) +
+
+ {entries.map((h, idx) => ( +
+
+ + {t("hooks.matcher")}={h.matcher} + + · + {h.type} + {h.timeout != null && ( + {h.timeout}ms + )} +
+ {h.command && ( +
+ {h.command} +
+ )} +
+ ))} +
+
+ ))} +
+ )} +
+ )} +
+ ); +} + // ── Settings ────────────────────────────────────────────────────────── // The settings that the TUI's `/config` editor manages, in display order. @@ -1925,10 +2542,14 @@ function SettingsPanel({ sources, statusline, onOpen, + onToast, + onChanged, }: { sources: CcSettingsSource[] | null; statusline: CcStatusline | null; onOpen: (p: string) => void; + onToast: (t: NonNullable) => void; + onChanged: () => void; }) { const { t } = useTranslation("ccConfig"); if (!sources) return ; @@ -1953,7 +2574,13 @@ function SettingsPanel({ )} {sources.map((src) => ( - + ))}
); @@ -2012,15 +2639,94 @@ function StatuslineBlock({ data, onOpen }: { data: CcStatusline; onOpen: (p: str ); } +/** A key whose value the API redacted cannot be edited here — echoing the + * placeholder back would destroy the real secret (the server rejects it too). */ +function isRedacted(value: unknown): boolean { + try { + return JSON.stringify(value)?.includes("") ?? false; + } catch { + return false; + } +} + +type SettingsDraftRow = { key: string; text: string; original: string; isNew: boolean }; + function SettingsBlock({ source, onOpen, + onToast, + onChanged, }: { source: CcSettingsSource; onOpen: (p: string) => void; + onToast: (t: NonNullable) => void; + onChanged: () => void; }) { const { t } = useTranslation("ccConfig"); + const { busy, run } = useConfigAction(onToast, onChanged); const [showRaw, setShowRaw] = useState(false); + const [draft, setDraft] = useState(null); + const [err, setErr] = useState(null); + + const data = (source.data as Record | null | undefined) || {}; + // Redacted keys stay out of the draft entirely, so a save can never mention + // them; they keep rendering read-only below the editor. + const editableKeys = Object.entries(data).filter(([, v]) => !isRedacted(v)); + const redactedKeys = Object.entries(data).filter(([, v]) => isRedacted(v)); + + const startEdit = () => { + setErr(null); + setDraft( + editableKeys.map(([key, value]) => { + const text = JSON.stringify(value, null, 2); + return { key, text, original: text, isNew: false }; + }) + ); + }; + + const save = async () => { + if (!draft) return; + const set: Record = {}; + const seen = new Set(); + for (const row of draft) { + const key = row.key.trim(); + if (!key) { + setErr(t("manage.settingsEmptyKey")); + return; + } + if (seen.has(key)) { + setErr(t("manage.settingsDupKey", { key })); + return; + } + seen.add(key); + // Only send what actually changed — an untouched key is left alone. + if (!row.isNew && row.text === row.original) continue; + try { + set[key] = JSON.parse(row.text); + } catch { + setErr(t("manage.settingsBadJson", { key })); + return; + } + } + const unset = editableKeys.map(([key]) => key).filter((key) => !seen.has(key)); + if (Object.keys(set).length === 0 && unset.length === 0) { + setDraft(null); + return; + } + setErr(null); + const ok = await run( + "save", + () => + api.ccConfig.patchSettings({ + scope: source.scope, + ...(Object.keys(set).length ? { set } : {}), + ...(unset.length ? { unset } : {}), + }), + t("manage.settingsSaved", { file: source.file }) + ); + if (ok) setDraft(null); + }; + return (
@@ -2047,15 +2753,99 @@ function SettingsBlock({ ) : ( {t("settings.fileMissing")} )} + {draft ? ( +
+ setDraft(null)} disabled={busy === "save"}> + {t("edit.cancel")} + + void save()} disabled={busy === "save"}> + {busy === "save" ? t("edit.saving") : t("edit.save")} + +
+ ) : ( + + {t("edit.editButton")} + + )}
- {source.exists && + + {err && ( +
+ + {err} +
+ )} + + {draft ? ( +
+ {draft.map((row, i) => ( +
+
+ + setDraft((d) => + d ? d.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)) : d + ) + } + placeholder={t("manage.settingsKeyPlaceholder")} + aria-label={t("manage.settingsKey")} + className="h-7 flex-1 min-w-0 bg-surface-2 border border-border rounded px-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" + /> + +
+