Merge branch 'feat/claude-settings-editable' into main

Makes the Claude Code MCP surface editable from the dashboard (add and
remove via the claude CLI), corrects which .claude.json the dashboard
reads so it matches the one the CLI writes, and adds the project-memory
route both panels use to create a new project's CLAUDE.md / AGENTS.md.
This commit is contained in:
2026-09-09 13:56:02 +07:00
26 changed files with 2262 additions and 77 deletions
@@ -0,0 +1,180 @@
/**
* @file ProjectMemoryModal.tsx
* @description Create a project's memory file — CLAUDE.md or AGENTS.md — in a
* directory that is not the dashboard's own. Shared by the Claude Code and
* Codex config pages; the caller supplies its i18n namespace and the create
* call, so this component knows about neither tool.
*
* The directory list is `GET /api/run/cwds` (the dashboard's known projects),
* offered as suggestions on a plain text input rather than a closed dropdown:
* a project you have never run through the dashboard is exactly the case this
* modal exists for, so it must stay typeable.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookOpen, X, Save, AlertCircle } from "lucide-react";
import { api, type CwdSuggestion } from "../../lib/api";
interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
ns: string;
/** The file this will create, e.g. "CLAUDE.md". Shown, never sent. */
filename: string;
/** Starting body text, so the user edits a skeleton instead of a blank box. */
template: string;
onClose: () => void;
/** Resolves with the path actually written (the server realpaths the dir). */
onCreate: (args: { dir: string; content: string }) => Promise<string>;
}
export function ProjectMemoryModal({
ns,
filename,
template,
onClose,
onCreate,
}: ProjectMemoryModalProps) {
// Every key lives under `projectMemory.*` in BOTH namespaces, so this
// component never depends on the calling page's other key groups lining up.
const { t } = useTranslation(ns);
const listId = useId();
const [dir, setDir] = useState("");
const [content, setContent] = useState(template);
const [suggestions, setSuggestions] = useState<CwdSuggestion[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.run
.cwds()
.then((r) => setSuggestions(r.items))
// Suggestions are a convenience; the input still works without them.
.catch(() => undefined);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleCreate = useCallback(async () => {
const target = dir.trim();
if (!target) {
setError(t("projectMemory.dirRequired"));
return;
}
setSaving(true);
setError(null);
try {
await onCreate({ dir: target, content });
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "unknown");
} finally {
setSaving(false);
}
}, [dir, content, onCreate, t]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
role="dialog"
aria-label={t("projectMemory.title", { file: filename })}
className="relative w-full max-w-3xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<BookOpen className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">
{t("projectMemory.title", { file: filename })}
</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("projectMemory.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4 space-y-3">
<div>
<label
htmlFor={`${listId}-dir`}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
{t("projectMemory.dirLabel")}
</label>
<input
id={`${listId}-dir`}
list={listId}
value={dir}
onChange={(e) => setDir(e.target.value)}
placeholder="/home/you/projects/my-app"
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s.path} value={s.path}>
{s.label}
</option>
))}
</datalist>
<p className="mt-1 text-[10px] text-fg-muted">
{t("projectMemory.dirHelp", { file: filename })}
</p>
</div>
<div>
<label
htmlFor={`${listId}-content`}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
{t("projectMemory.contentLabel")}
</label>
<textarea
id={`${listId}-content`}
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
className="w-full h-64 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:border-accent/50 resize-y"
/>
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("projectMemory.cancel")}
</button>
<button
onClick={handleCreate}
disabled={saving}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
{saving ? t("projectMemory.saving") : t("projectMemory.createButton")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
/**
* @file ProjectMemoryModal.test.tsx
* @description Unit tests for `ProjectMemoryModal`: it must send the directory
* the user typed (not a suggestion it picked for them), must refuse to submit
* an empty one, and must render its labels from whichever namespace the
* calling page passes — the Codex page shares this component with the Claude
* page, and a key group that only exists in `ccConfig` would render raw.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProjectMemoryModal } from "../ProjectMemoryModal";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { run: { cwds: vi.fn() } },
}));
const cwds = vi.mocked(api.run.cwds);
beforeEach(() => {
cwds.mockResolvedValue({
items: [{ kind: "recent", path: "/home/me/known-project", label: "known-project" }],
});
});
function renderModal(over: Partial<Parameters<typeof ProjectMemoryModal>[0]> = {}) {
const onCreate = vi.fn().mockResolvedValue("/home/me/typed/CLAUDE.md");
render(
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template="# Project memory"
onClose={() => {}}
onCreate={onCreate}
{...over}
/>
);
return { onCreate };
}
describe("ProjectMemoryModal", () => {
it("sends the typed directory and the edited content", async () => {
const { onCreate } = renderModal();
await userEvent.type(screen.getByLabelText(/project directory/i), "/home/me/typed");
const body = screen.getByLabelText(/content/i);
await userEvent.clear(body);
await userEvent.type(body, "hello");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(onCreate).toHaveBeenCalledWith({ dir: "/home/me/typed", content: "hello" });
});
it("refuses to submit without a directory", async () => {
const { onCreate } = renderModal();
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(onCreate).not.toHaveBeenCalled();
expect(await screen.findByText(/directory is required/i)).toBeInTheDocument();
});
it("offers the dashboard's known projects as suggestions, not as the only choice", async () => {
renderModal();
// A datalist suggests; it does not constrain. A project the dashboard has
// never run in is precisely this modal's reason to exist, so the input
// stays free text.
const input = screen.getByLabelText(/project directory/i);
expect(input.tagName).toBe("INPUT");
// jsdom exposes no accessible role for datalist options, so query the DOM.
await vi.waitFor(() => {
const list = document.getElementById(input.getAttribute("list")!);
expect(list?.querySelector('option[value="/home/me/known-project"]')).not.toBeNull();
});
});
it("renders from the Codex namespace too", async () => {
renderModal({ ns: "codexConfig", filename: "AGENTS.md" });
expect(await screen.findByText(/Create AGENTS\.md for another project/i)).toBeInTheDocument();
// A missing key renders as the bare key path — catch that directly.
expect(screen.queryByText(/projectMemory\./)).not.toBeInTheDocument();
});
it("shows the server's rejection instead of closing", async () => {
const onCreate = vi.fn().mockRejectedValue(new Error("CLAUDE.md already exists in /p"));
render(
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template=""
onClose={() => {}}
onCreate={onCreate}
/>
);
await userEvent.type(screen.getByLabelText(/project directory/i), "/p");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(await screen.findByText(/already exists in \/p/i)).toBeInTheDocument();
});
});
+31 -5
View File
@@ -129,6 +129,10 @@
"mcp": {
"userScope": "User-scoped servers",
"projectScope": "Project-scoped servers",
"name": "Name",
"scope": "Scope",
"scopeUser": "User",
"scopeLocal": "This project",
"command": "Command",
"args": "Args",
"env": "Env names",
@@ -136,7 +140,8 @@
"headers": "Header names",
"kind": "Kind",
"source": "Source",
"noServers": "No MCP servers found."
"noServers": "No MCP servers found.",
"notRemovable": "Configured in settings.json — remove it there; no CLI scope addresses this entry."
},
"hooks": {
"noHooks": "No hooks configured.",
@@ -169,6 +174,20 @@
"projectLabel": "Project",
"projectHelp": "Created in this project's memory dir."
},
"projectMemory": {
"newButton": "New project memory",
"title": "Create {{file}} for another project",
"dirLabel": "Project directory",
"dirHelp": "Absolute path to a project that already exists on this machine. {{file}} is created inside it; an existing one is never overwritten.",
"dirRequired": "A project directory is required",
"createButton": "Create",
"created": "Created {{path}}",
"template": "# Project memory\n\nWhat this project is, and anything an agent should know before touching it.\n\n## Stack\n\n## Conventions\n\n## Commands\n",
"contentLabel": "Content",
"cancel": "Cancel",
"saving": "Creating…",
"close": "Close"
},
"edit": {
"newButton": "New",
"editButton": "Edit",
@@ -275,10 +294,7 @@
"body": "MCP server config lives inside `~/.claude.json`, the same file the live CLI writes to constantly. Concurrent writes from the dashboard would corrupt project state, so add/remove flow goes through Claude Code itself.",
"list": "List configured servers:",
"listCmd": "claude mcp list",
"add": "Add a server:",
"addCmd": "claude mcp add <name> <command> [args…]",
"remove": "Remove this server:",
"removeButton": "Copy remove command"
"add": "Add a server:"
},
"marketplaces": {
"title": "Marketplaces are where plugins come from",
@@ -317,6 +333,16 @@
"marketplaceAdded": "Added marketplace {{name}}.",
"marketplaceUpdated": "Updated marketplace {{name}}.",
"marketplaceRemoved": "Removed marketplace {{name}}.",
"addMcpTitle": "Add an MCP server",
"addMcpNamePlaceholder": "name",
"addMcpTransportStdio": "Command (stdio)",
"addMcpTransportHttp": "URL (http)",
"addMcpCommandPlaceholder": "npx -y @modelcontextprotocol/server-github",
"addMcpUrlPlaceholder": "https://mcp.example.com",
"addMcpButton": "Add",
"addMcpHelp": "Runs `claude mcp add`. \"This project\" scopes the server to the current checkout — the CLI's separate `project` scope (a shared `.mcp.json`) isn't offered here because this dashboard can't see servers stored there.",
"mcpAdded": "Added MCP server {{name}}.",
"mcpRemoved": "Removed MCP server {{name}}.",
"hookEvent": "Event name (e.g. PreToolUse)",
"hookAddEvent": "Add event",
"hookRemoveEvent": "Remove event",
@@ -56,6 +56,20 @@
"memory": {
"missing": "No AGENTS.md found."
},
"projectMemory": {
"newButton": "New project memory",
"title": "Create {{file}} for another project",
"dirLabel": "Project directory",
"dirHelp": "Absolute path to a project that already exists on this machine. {{file}} is created inside it; an existing one is never overwritten.",
"dirRequired": "A project directory is required",
"createButton": "Create",
"created": "Created {{path}}",
"template": "# Project memory\n\nWhat this project is, and anything an agent should know before touching it.\n\n## Stack\n\n## Conventions\n\n## Commands\n",
"contentLabel": "Content",
"cancel": "Cancel",
"saving": "Creating…",
"close": "Close"
},
"config": {
"missing": "No config.toml found."
},
+31 -5
View File
@@ -124,6 +124,10 @@
"mcp": {
"userScope": "Máy chủ phạm vi người dùng",
"projectScope": "Máy chủ phạm vi dự án",
"name": "Tên",
"scope": "Phạm vi",
"scopeUser": "Người dùng",
"scopeLocal": "Dự án này",
"command": "Lệnh",
"args": "Tham số",
"env": "Tên env",
@@ -131,7 +135,8 @@
"headers": "Tên Header",
"kind": "Loại",
"source": "Nguồn",
"noServers": "Không tìm thấy máy chủ MCP."
"noServers": "Không tìm thấy máy chủ MCP.",
"notRemovable": "Được cấu hình trong settings.json — hãy xóa ở đó; không có phạm vi CLI nào áp dụng cho mục này."
},
"hooks": {
"noHooks": "Chưa cấu hình hook.",
@@ -164,6 +169,20 @@
"projectLabel": "Dự án",
"projectHelp": "Được tạo trong thư mục bộ nhớ của dự án này."
},
"projectMemory": {
"newButton": "Memory cho project khác",
"title": "Tạo {{file}} cho một project khác",
"dirLabel": "Thư mục project",
"dirHelp": "Đường dẫn tuyệt đối tới một project đã có trên máy. {{file}} sẽ được tạo bên trong; file đã tồn tại thì không bao giờ bị ghi đè.",
"dirRequired": "Cần nhập thư mục project",
"createButton": "Tạo",
"created": "Đã tạo {{path}}",
"template": "# Project memory\n\nProject này là gì, và những gì agent cần biết trước khi đụng vào.\n\n## Stack\n\n## Quy ước\n\n## Lệnh thường dùng\n",
"contentLabel": "Nội dung",
"cancel": "Huỷ",
"saving": "Đang tạo…",
"close": "Đóng"
},
"edit": {
"newButton": "Tạo mới",
"editButton": "Sửa",
@@ -270,10 +289,7 @@
"body": "Cấu hình MCP server nằm trong `~/.claude.json`, đúng tệp mà CLI đang chạy ghi liên tục. Ghi đồng thời từ dashboard sẽ phá hỏng trạng thái dự án, nên flow add/remove đi qua chính Claude Code.",
"list": "Liệt kê server đã cấu hình:",
"listCmd": "claude mcp list",
"add": "Thêm server:",
"addCmd": "claude mcp add <name> <command> [args…]",
"remove": "Gỡ server này:",
"removeButton": "Sao chép lệnh gỡ"
"add": "Thêm server:"
},
"marketplaces": {
"title": "Marketplace là nơi plugin đến từ đó",
@@ -312,6 +328,16 @@
"marketplaceAdded": "Đã thêm marketplace {{name}}.",
"marketplaceUpdated": "Đã cập nhật marketplace {{name}}.",
"marketplaceRemoved": "Đã xóa marketplace {{name}}.",
"addMcpTitle": "Thêm máy chủ MCP",
"addMcpNamePlaceholder": "tên",
"addMcpTransportStdio": "Lệnh (stdio)",
"addMcpTransportHttp": "URL (http)",
"addMcpCommandPlaceholder": "npx -y @modelcontextprotocol/server-github",
"addMcpUrlPlaceholder": "https://mcp.example.com",
"addMcpButton": "Thêm",
"addMcpHelp": "Chạy lệnh `claude mcp add`. \"Dự án này\" giới hạn server trong checkout hiện tại — phạm vi `project` riêng của CLI (một `.mcp.json` dùng chung) không được cung cấp ở đây vì dashboard không thể thấy các server lưu ở đó.",
"mcpAdded": "Đã thêm máy chủ MCP {{name}}.",
"mcpRemoved": "Đã xóa máy chủ MCP {{name}}.",
"hookEvent": "Tên sự kiện (vd PreToolUse)",
"hookAddEvent": "Thêm sự kiện",
"hookRemoveEvent": "Xóa sự kiện",
@@ -56,6 +56,20 @@
"memory": {
"missing": "Không tìm thấy AGENTS.md."
},
"projectMemory": {
"newButton": "Memory cho project khác",
"title": "Tạo {{file}} cho một project khác",
"dirLabel": "Thư mục project",
"dirHelp": "Đường dẫn tuyệt đối tới một project đã có trên máy. {{file}} sẽ được tạo bên trong; file đã tồn tại thì không bao giờ bị ghi đè.",
"dirRequired": "Cần nhập thư mục project",
"createButton": "Tạo",
"created": "Đã tạo {{path}}",
"template": "# Project memory\n\nProject này là gì, và những gì agent cần biết trước khi đụng vào.\n\n## Stack\n\n## Quy ước\n\n## Lệnh thường dùng\n",
"contentLabel": "Nội dung",
"cancel": "Huỷ",
"saving": "Đang tạo…",
"close": "Đóng"
},
"config": {
"missing": "Không tìm thấy config.toml."
},
+91
View File
@@ -97,6 +97,8 @@
* - `CcPluginsResponse` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpServer` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpResponse` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpScope` — exported API; see TSDoc on the symbol for behavior.
* - `CcMcpAddArgs` — exported API; see TSDoc on the symbol for behavior.
* - `CcHookEntry` — exported API; see TSDoc on the symbol for behavior.
* - `CcHookSource` — exported API; see TSDoc on the symbol for behavior.
* - `CcSettingsSource` — exported API; see TSDoc on the symbol for behavior.
@@ -1404,6 +1406,28 @@ export const api = {
* @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`.
*/
mcp: () => request<CcMcpResponse>("/cc-config/mcp"),
/**
* POST /api/cc-config/mcp/add - add an MCP server via `claude mcp add`.
* Either `url` (http/sse) or `command`+`args` (stdio) must be given, not
* both. `scope` is required — `user` or `project` only, never the
* settings union's third `project-local` value.
* @returns {@link CcCliResult} — the CLI's stdout/stderr on success.
*/
mcpAdd: (args: CcMcpAddArgs) =>
request<CcCliResult>("/cc-config/mcp/add", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* POST /api/cc-config/mcp/remove - remove an MCP server via
* `claude mcp remove`. `scope` narrows which config layer to remove
* from; omit it to let the CLI find the server wherever it exists.
*/
mcpRemove: (name: string, scope?: CcMcpScope) =>
request<CcCliResult>("/cc-config/mcp/remove", {
method: "POST",
body: JSON.stringify({ name, scope }),
}),
/**
* GET /api/cc-config/hooks - hook entries from every settings.json layer.
* @returns `{ items }` — one {@link CcHookSource} per settings layer.
@@ -1446,6 +1470,22 @@ export const api = {
method: "PUT",
body: JSON.stringify(args),
}),
/**
* POST /api/cc-config/project-memory - create `CLAUDE.md` in a project the
* dashboard has never run in.
*
* Unlike {@link api.ccConfig.write}, whose "project" scope always means the
* dashboard server's own directory, this one writes into the directory you
* name. It only ever creates: an existing CLAUDE.md comes back as a 409.
*
* @param args `dir` (absolute, must already exist) and the file's `content`.
* @returns {@link ProjectMemoryResult} — the written path and its size.
*/
createProjectMemory: (args: { dir: string; content: string }) =>
request<ProjectMemoryResult>("/cc-config/project-memory", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* DELETE /api/cc-config/file - remove a config artifact (also backed up).
*
@@ -1678,6 +1718,19 @@ export const api = {
method: "PUT",
body: JSON.stringify(args),
}),
/**
* POST /api/codex-config/project-memory - create `AGENTS.md` in a project
* the dashboard has never run in. The Codex twin of
* {@link api.ccConfig.createProjectMemory}, same create-only rule.
*
* @param args `dir` (absolute, must already exist) and the file's `content`.
* @returns {@link ProjectMemoryResult} — the written path and its size.
*/
createProjectMemory: (args: { dir: string; content: string }) =>
request<ProjectMemoryResult>("/codex-config/project-memory", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* DELETE /api/codex-config/file - remove a config artifact (also backed up).
*
@@ -2627,6 +2680,11 @@ export interface CcMcpServer {
name: string;
/** Which config file this entry came from (e.g. a `.mcp.json` path). */
source: string;
/**
* False when no `claude mcp` scope can address this entry, so the dashboard
* cannot remove it. Absent means removable — only the reader sets it.
*/
removable?: boolean;
/** Transport: local subprocess ("stdio"), remote HTTP, or undetermined. */
kind: "stdio" | "http" | "unknown";
/** Launch command, for `kind === "stdio"`. */
@@ -2647,6 +2705,28 @@ export interface CcMcpResponse {
projectScoped: CcMcpServer[];
}
/** Scope the `claude mcp` CLI accepts from this form — `user` and `local`
* ONLY, matching what GET /mcp's `readMcpServers` can actually see:
* `user` is `~/.claude.json` top-level, `local` is its `projects[<root>]`
* block (the panel's `projectScoped` bucket, despite the name). The CLI's
* `project` scope writes `.mcp.json`, which `readMcpServers` never reads —
* offering it here would add a server the dashboard can never show exists. */
export type CcMcpScope = "user" | "local";
/** Body of POST /api/cc-config/mcp/add. Either `url` (http/sse) or
* `command`+`args` (stdio) is required, never both. `env` is stdio-only;
* `headers` is http/sse-only. */
export interface CcMcpAddArgs {
name: string;
scope: CcMcpScope;
transport?: "http" | "sse";
url?: string;
headers?: Record<string, string>;
command?: string;
args?: string[];
env?: Record<string, string>;
}
/** One hook binding within a settings.json `hooks` block. */
export interface CcHookEntry {
/** Tool-name matcher pattern (e.g. "Bash", "Edit|Write", or "*"). */
@@ -2905,6 +2985,17 @@ export interface CodexMutationResult {
backup?: { [key: string]: unknown } | null;
}
/**
* Result of creating a project's memory file (CLAUDE.md / AGENTS.md) via
* POST /api/{cc,codex}-config/project-memory. `file` is the resolved path —
* worth showing back, since the server realpaths the directory it was given.
*/
export interface ProjectMemoryResult {
file: string;
size: number;
created: true;
}
/** One timestamped backup of a config artifact, from GET /api/codex-config/backups. */
export interface CodexBackup {
type: string;
+227 -14
View File
@@ -105,6 +105,7 @@ import {
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { MdItemList } from "../components/config/MdItemList";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import {
Empty,
SkeletonRows,
@@ -122,7 +123,9 @@ import type {
CcKeybindings,
CcKeybindingGroup,
CcMarketplacesResponse,
CcMcpAddArgs,
CcMcpResponse,
CcMcpScope,
CcMcpServer,
CcMdItem,
CcMemoryItem,
@@ -244,6 +247,7 @@ export function CcConfig() {
const [toast, setToast] = useState<Toast>(null);
const [backupsOpen, setBackupsOpen] = useState(false);
const [profilesOpen, setProfilesOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
// Auto-dismiss toasts after 5s
useEffect(() => {
@@ -451,6 +455,20 @@ export function CcConfig() {
[fetchAll, t]
);
// Create CLAUDE.md in ANOTHER project's directory. Deliberately not part of
// `handleSave`: that one writes this machine's user/project layers through
// the artifact writer, while this names its own target directory.
const handleCreateProjectMemory = useCallback(
async (args: { dir: string; content: string }) => {
const result = await api.ccConfig.createProjectMemory(args);
setProjectMemoryOpen(false);
setToast({ kind: "success", message: t("projectMemory.created", { path: result.file }) });
void fetchAll();
return result.file;
},
[fetchAll, t]
);
const handleDelete = useCallback(async () => {
if (!confirmDelete) return;
try {
@@ -525,6 +543,18 @@ export function CcConfig() {
{t("edit.newButton")}
</button>
)}
{/* The memory tab's own "new" is a different thing: the two
CLAUDE.md cards below are this machine's user + current-project
files, while this creates one in ANOTHER project's directory. */}
{tab === "memory" && (
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
)}
</div>
)}
<div className="p-4">
@@ -561,6 +591,15 @@ export function CcConfig() {
onConfirm={handleDelete}
/>
)}
{projectMemoryOpen && (
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template={t("projectMemory.template")}
onClose={() => setProjectMemoryOpen(false)}
onCreate={handleCreateProjectMemory}
/>
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
@@ -958,7 +997,7 @@ function TabPanel({
/>
);
case "mcp":
return <McpPanel data={data.mcp} search={search} />;
return <McpPanel data={data.mcp} search={search} onToast={onToast} onChanged={onChanged} />;
case "hooks":
return (
<HooksPanel
@@ -1822,23 +1861,142 @@ function PluginCard({
// ── MCP servers ───────────────────────────────────────────────────────
function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string }) {
/** Splits a pasted command line on whitespace — good enough for the common
* `npx -y @scope/server --flag value` case. Skipped a full shell-arg
* parser (quoted args with embedded spaces won't survive); add one if a
* real server needs it. */
function splitCommandLine(value: string): [string, string[]] {
const parts = value.trim().split(/\s+/).filter(Boolean);
return [parts[0] || "", parts.slice(1)];
}
function McpPanel({
data,
search,
onToast,
onChanged,
}: {
data: CcMcpResponse | null;
search: string;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [name, setName] = useState("");
const [scope, setScope] = useState<CcMcpScope>("user");
const [transport, setTransport] = useState<"stdio" | "http">("stdio");
const [commandLine, setCommandLine] = useState("");
const [url, setUrl] = useState("");
const canSubmit =
name.trim().length > 0 && (transport === "stdio" ? commandLine.trim() : url.trim());
const submit = useCallback(async () => {
const n = name.trim();
if (!n) return;
const args: CcMcpAddArgs =
transport === "http"
? { name: n, scope, transport: "http", url: url.trim() }
: (() => {
const [command, cmdArgs] = splitCommandLine(commandLine);
return { name: n, scope, command, args: cmdArgs };
})();
const ok = await run("add", () => api.ccConfig.mcpAdd(args), t("manage.mcpAdded", { name: n }));
if (ok) {
setName("");
setCommandLine("");
setUrl("");
}
}, [commandLine, name, run, scope, t, transport, url]);
if (!data) return <SkeletonRows n={3} />;
const all = [...data.user, ...data.projectScoped];
const filter = (arr: CcMcpServer[]) =>
arr.filter((s) => !search || s.name.toLowerCase().includes(search.toLowerCase()));
return (
<div className="space-y-4">
<ExplainerBanner
title={t("explain.mcp.title")}
body={t("explain.mcp.body")}
howTo={t("explain.mcp.add")}
commands={[
{ cmd: t("explain.mcp.listCmd"), note: t("explain.mcp.list") },
{ cmd: t("explain.mcp.addCmd"), note: t("explain.mcp.add") },
]}
commands={[{ cmd: t("explain.mcp.listCmd"), note: t("explain.mcp.list") }]}
/>
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5 space-y-2">
<div className="text-[11px] font-semibold text-fg-secondary">{t("manage.addMcpTitle")}</div>
<div className="flex items-center gap-2 flex-wrap">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("manage.addMcpNamePlaceholder")}
aria-label={t("mcp.name")}
className="h-8 w-40 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"
/>
<select
value={scope}
onChange={(e) => setScope(e.target.value as CcMcpScope)}
aria-label={t("mcp.scope")}
className="h-8 bg-surface-1 border border-border rounded px-2 text-[12px] text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<option value="user">{t("mcp.scopeUser")}</option>
<option value="local">{t("mcp.scopeLocal")}</option>
</select>
<div className="flex rounded-md border border-border overflow-hidden">
{(["stdio", "http"] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setTransport(k)}
className={`h-8 px-2.5 text-[11px] font-medium ${
transport === k
? "bg-accent/20 text-accent"
: "bg-surface-1 text-fg-secondary hover:bg-surface-3"
}`}
>
{k === "stdio" ? t("manage.addMcpTransportStdio") : t("manage.addMcpTransportHttp")}
</button>
))}
</div>
</div>
{transport === "stdio" ? (
<input
value={commandLine}
onChange={(e) => setCommandLine(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
}}
placeholder={t("manage.addMcpCommandPlaceholder")}
aria-label={t("mcp.command")}
className="h-8 w-full 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"
/>
) : (
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
}}
placeholder={t("manage.addMcpUrlPlaceholder")}
aria-label={t("mcp.url")}
className="h-8 w-full 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"
/>
)}
<div className="flex items-center justify-between">
<p className="text-[10px] text-fg-muted leading-relaxed">{t("manage.addMcpHelp")}</p>
<button
type="button"
onClick={() => void submit()}
disabled={!canSubmit || busy === "add"}
className="h-8 shrink-0 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Plus className="w-3 h-3" />
{busy === "add" ? t("manage.working") : t("manage.addMcpButton")}
</button>
</div>
</div>
{all.length === 0 && (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("mcp.noServers")}
@@ -1851,7 +2009,14 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string
</h3>
<div className="space-y-2">
{filter(data.user).map((s) => (
<McpCard key={`u:${s.name}:${s.source}`} server={s} />
<McpCard
key={`u:${s.name}:${s.source}`}
server={s}
scope="user"
run={run}
busy={busy}
t={t}
/>
))}
</div>
</div>
@@ -1863,7 +2028,14 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string
</h3>
<div className="space-y-2">
{filter(data.projectScoped).map((s) => (
<McpCard key={`p:${s.name}:${s.source}`} server={s} />
<McpCard
key={`p:${s.name}:${s.source}`}
server={s}
scope="local"
run={run}
busy={busy}
t={t}
/>
))}
</div>
</div>
@@ -1872,8 +2044,26 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string
);
}
function McpCard({ server }: { server: CcMcpServer }) {
const { t } = useTranslation("ccConfig");
function McpCard({
server,
scope,
run,
busy,
t,
}: {
server: CcMcpServer;
scope: CcMcpScope;
run: (id: string, fn: () => Promise<unknown>, successMessage: string) => Promise<boolean>;
busy: string | null;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
const [confirmRemove, setConfirmRemove] = useState(false);
const busyId = `${scope}:${server.name}`;
// The reader decides this: entries from ~/.claude/settings.json have no CLI
// scope that addresses them, so a Remove button would look live and never
// work. Reading it off a flag rather than matching `source` keeps a reworded
// display string from silently bringing that button back.
const removable = server.removable !== false;
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
@@ -1915,10 +2105,33 @@ function McpCard({ server }: { server: CcMcpServer }) {
)}
</div>
<div className="mt-3">
<CommandSnippet
command={`claude mcp remove ${server.name}`}
label={t("explain.mcp.remove")}
/>
{removable ? (
<ActionButton
icon={Trash2}
danger
disabled={busy === busyId}
onClick={() => {
if (!confirmRemove) {
setConfirmRemove(true);
return;
}
setConfirmRemove(false);
void run(
busyId,
() => api.ccConfig.mcpRemove(server.name, scope),
t("manage.mcpRemoved", { name: server.name })
);
}}
>
{busy === busyId
? t("manage.working")
: confirmRemove
? t("manage.confirmRemove")
: t("manage.remove")}
</ActionButton>
) : (
<p className="text-[10px] text-fg-muted">{t("mcp.notRemovable")}</p>
)}
</div>
</div>
);
+39
View File
@@ -49,6 +49,7 @@ import {
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import {
Empty,
SkeletonRows,
@@ -152,6 +153,7 @@ export function CodexConfig() {
// (mirrors CcConfig.tsx's PluginsPanel: "a failure here is a normal state").
const [cliError, setCliError] = useState<string | null>(null);
const [backupsOpen, setBackupsOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [search, setSearch] = useState("");
const [viewer, setViewer] = useState<{
@@ -322,6 +324,20 @@ export function CodexConfig() {
[fetchAll, t]
);
// Create AGENTS.md in ANOTHER project's directory — the Codex twin of
// CcConfig's handler; `handleSave` above only ever writes this machine's
// user/project layers.
const handleCreateProjectMemory = useCallback(
async (args: { dir: string; content: string }) => {
const result = await api.codexConfig.createProjectMemory(args);
setProjectMemoryOpen(false);
setToast({ kind: "success", message: t("projectMemory.created", { path: result.file }) });
void fetchAll();
return result.file;
},
[fetchAll, t]
);
const handleDelete = useCallback(async () => {
if (!confirmDelete) return;
try {
@@ -393,6 +409,20 @@ export function CodexConfig() {
)}
</div>
)}
{/* The memory tab hides the search bar, but still needs its own
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
the user/project files listed below. */}
{tab === "memory" && (
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
@@ -429,6 +459,15 @@ export function CodexConfig() {
onConfirm={handleDelete}
/>
)}
{projectMemoryOpen && (
<ProjectMemoryModal
ns="codexConfig"
filename="AGENTS.md"
template={t("projectMemory.template")}
onClose={() => setProjectMemoryOpen(false)}
onCreate={handleCreateProjectMemory}
/>
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
</div>
);
+10 -2
View File
@@ -1560,7 +1560,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9
### Claude Config Explorer
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings?tool=claude-code`). 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.
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings?tool=claude-code`). 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/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI.
```http
GET /api/cc-config/overview
@@ -1587,12 +1587,15 @@ POST /api/cc-config/profiles/activate Body: { name }
DELETE /api/cc-config/profiles Body: { name }
PUT /api/cc-config/file Body: { scope, type, name?, content }
DELETE /api/cc-config/file Body: { scope, type, name? }
POST /api/cc-config/project-memory Body: { dir, content }
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 }
POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?, headers?, command?, args?, env? }
POST /api/cc-config/mcp/remove Body: { name, scope? }
```
`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>"`.
@@ -1601,9 +1604,13 @@ POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
`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`).
**`POST /api/{cc,codex}-config/project-memory` (a new project's memory file).** Creates `CLAUDE.md` (Claude) or `AGENTS.md` (Codex) inside the directory named by `dir` — the one write path that honours a caller-supplied directory, because bootstrapping a project the dashboard has never run in is impossible from a writer pinned to the server's own cwd (which is what `PUT /file`'s `"project"` scope means). It stays narrow: `dir` must be absolute and already exist (nothing is created for you), the filename comes from the route and is never taken from the body, and an existing file — including a symlink under that name — returns **409 `EEXISTS`** rather than being overwritten, so this endpoint can only ever create. Bad or missing directories return 400 `EBADDIR`. Unlike the other config routes it sits behind the same loopback-`Origin` guard `/api/run` uses (403 `EBADORIGIN`): a page in the user's browser could otherwise plant agent instructions in a project directory. Returns `{ file, size, created: true }`, where `file` is the realpath'd target, and broadcasts `cc_config_changed`.
**`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 }`.
**Plugins and marketplaces.** These five endpoints shell out to the `claude plugin …` CLI with an explicit argv array (never a shell string); mutating calls are serialized in-process. `CLAUDE_CONFIG_DIR` is pinned to `CLAUDE_HOME` only when the operator explicitly set `CLAUDE_HOME` — left unset (the default), the CLI's own default config dir already matches what `cc-discovery` reads, so the pin is left off rather than pointed at the wrong directory (see `runClaude()`'s comment in `cc-plugin-cli.js` for the incident this fixed). `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 }`.
**MCP servers.** `POST /api/cc-config/mcp/add` and `POST /api/cc-config/mcp/remove` shell out to `claude mcp add`/`claude mcp remove` the same way the plugin routes shell out to `claude plugin …` — an argv array, never a shell string, serialized on the SAME in-process queue `cc-plugin-cli.js` exports (`serialize`), so a plugin install and an MCP add can never spawn `claude` concurrently. `scope` is `user` or `local` only (required on add, optional on remove — omitted, the CLI removes from whichever scope the server exists in). This is the inverse of what it sounds like: `user` writes `~/.claude.json`'s top level, `local` writes its `projects[<root>]` block — the exact source `GET /mcp`'s `projectScoped` bucket reads. The CLI's own `project` scope (a separate, shared `.mcp.json`) is refused with 400: `readMcpServers` never reads that file, so a server added there would report success and never appear in the list. `add` takes either `url` (+ optional `transport: "http"|"sse"`, `headers`) for a remote server, or `command` + `args` (+ optional `env`) for a stdio server — exactly one of the two forms. Validation failures return 400 `EBADARG`; a non-zero CLI exit returns 422 `ECLIEXIT`. `GET /api/cc-config/mcp` is unaffected — it still reads `~/.claude.json` directly; the client refetches it after either call succeeds. `GET /mcp`'s server objects carry `removable: false` for rows sourced from `~/.claude/settings.json` (absent/true means removable) — no CLI scope addresses them, so the panel reads this flag rather than matching the `source` display string to decide whether to offer Remove.
`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).
@@ -1633,6 +1640,7 @@ GET /api/codex-config/config
GET /api/codex-config/file?path=<absolute-path>
PUT /api/codex-config/file Body: { scope, type, name?, content }
DELETE /api/codex-config/file Body: { scope, type, name? }
POST /api/codex-config/project-memory Body: { dir, content }
GET /api/codex-config/backups[?scope=&type=]
GET /api/codex-config/mcp
GET /api/codex-config/plugins
+121
View File
@@ -2039,6 +2039,10 @@ components:
type: string
description: Where the definition was found, e.g. `~/.claude.json (top-level)`, `~/.claude.json (projects[<root>])`, or `~/.claude/settings.json`.
example: ~/.claude.json (top-level)
removable:
type: boolean
description: False when no `claude mcp` scope can address this entry (currently only ~/.claude/settings.json-sourced servers), so POST /mcp/remove has no scope that would find it. Absent/true means removable.
example: true
kind:
type: string
enum:
@@ -2094,6 +2098,21 @@ components:
description: MCP servers scoped to the current project root in ~/.claude.json.
items:
$ref: '#/components/schemas/CcConfigMcpServer'
CcConfigCliResult:
type: object
required:
- ok
- stdout
- stderr
properties:
ok:
type: boolean
enum:
- true
stdout:
type: string
stderr:
type: string
CcConfigHookEntry:
type: object
description: A single flattened hook command bound to a matcher.
@@ -7996,6 +8015,108 @@ paths:
url: https://mcp.example.com/sse
headers:
- Authorization
/api/cc-config/mcp/add:
post:
tags:
- CcConfig
summary: Add an MCP server
description: 'Mutating. Runs `claude mcp add` — never a direct edit of ~/.claude.json, which the running CLI rewrites concurrently. Body carries either `url` (+ optional `transport: http|sse` and `headers`) for a remote server, or `command` + `args` (+ optional `env`) for a stdio server; exactly one of the two forms is required. `scope` is required and is `user`|`local` only: `user` writes ~/.claude.json''s top level, `local` writes its `projects[<root>]` block (GET /mcp''s `projectScoped` bucket). The CLI''s own `project` scope (a shared `.mcp.json`) is deliberately not offered — `readMcpServers` never reads that file, so a server added there would report success and never appear in the list. A non-zero CLI exit is returned as 422, not 500. Emits a `cc_config_changed` websocket event on success.'
operationId: ccConfigPostMcpAdd
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- scope
properties:
name:
type: string
example: vercel
scope:
type: string
enum:
- user
- local
example: user
transport:
type: string
enum:
- http
- sse
url:
type: string
example: https://mcp.vercel.com
headers:
type: object
additionalProperties:
type: string
example:
Authorization: Bearer xyz
command:
type: string
example: npx
args:
type: array
items:
type: string
example:
- '-y'
- server-github
env:
type: object
additionalProperties:
type: string
example:
GITHUB_TOKEN: xxx
responses:
'200':
description: The CLI's stdout/stderr on success.
content:
application/json:
schema:
$ref: '#/components/schemas/CcConfigCliResult'
'400':
description: Bad name/scope/args, or an incompatible combination.
'422':
description: The CLI exited non-zero (e.g. the name is already taken).
/api/cc-config/mcp/remove:
post:
tags:
- CcConfig
summary: Remove an MCP server
description: Mutating. Runs `claude mcp remove`. `scope` (`user`|`local`) narrows which config layer to remove from; omit it to let the CLI find the server wherever it exists. The CLI's own `project` scope is refused with 400 — the panel has no row sourced from `.mcp.json` to remove. A non-zero CLI exit (e.g. the name does not exist) is returned as 422. Emits a `cc_config_changed` websocket event on success.
operationId: ccConfigPostMcpRemove
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
properties:
name:
type: string
example: vercel
scope:
type: string
enum:
- user
- local
responses:
'200':
description: The CLI's stdout/stderr on success.
content:
application/json:
schema:
$ref: '#/components/schemas/CcConfigCliResult'
'400':
description: Bad name/scope.
'422':
description: The CLI exited non-zero (e.g. no such server).
/api/cc-config/hooks:
get:
tags:
+103
View File
@@ -1112,4 +1112,107 @@ describe("/api/cc-config", () => {
process.env.PATH = originalPath;
}
});
// ── MCP add/remove routes (fake `claude` on PATH) ───────────────────────
//
// `printf`, not `echo` — bash's builtin `echo "-e"` swallows a lone `-e`
// argument as its own flag, and `-e KEY=VALUE` is exactly what mcpAdd
// emits for a stdio server's env vars.
const PRINTF_ARGV = 'for a in "$@"; do printf \'%s\\n\' "$a"; done';
it("POST /mcp/add builds an http server with a positional url", async () => {
const restore = stubClaude(PRINTF_ARGV);
try {
const { status, body } = await fetchJson("/api/cc-config/mcp/add", {
method: "POST",
body: { name: "vercel", scope: "user", transport: "http", url: "https://mcp.vercel.com" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), [
"mcp",
"add",
"--scope",
"user",
"--transport",
"http",
"vercel",
"https://mcp.vercel.com",
]);
} finally {
restore();
}
});
it("POST /mcp/add builds a stdio server with args after --", async () => {
const restore = stubClaude(PRINTF_ARGV);
try {
const { status, body } = await fetchJson("/api/cc-config/mcp/add", {
method: "POST",
body: { name: "docs", scope: "user", command: "node", args: ["s.js", "--port", "3000"] },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n").slice(-5), [
"--",
"node",
"s.js",
"--port",
"3000",
]);
} finally {
restore();
}
});
it("POST /mcp/add rejects a name that reads as a flag with 400", async () => {
const { status, body } = await fetchJson("/api/cc-config/mcp/add", {
method: "POST",
body: { name: "--all", scope: "user", command: "node", args: [] },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADARG");
});
it("POST /mcp/remove surfaces a failed CLI run as 422", async () => {
const restore = stubClaude('echo "No MCP server named \\"nope\\"" >&2\nexit 1');
try {
const { status, body } = await fetchJson("/api/cc-config/mcp/remove", {
method: "POST",
body: { name: "nope", scope: "user" },
});
assert.equal(status, 422);
assert.equal(body.error.code, "ECLIEXIT");
assert.match(body.error.message, /No MCP server named/);
} finally {
restore();
}
});
it("POST /mcp/remove runs the CLI with the given scope", async () => {
const restore = stubClaude(PRINTF_ARGV);
try {
const { status, body } = await fetchJson("/api/cc-config/mcp/remove", {
method: "POST",
body: { name: "docs", scope: "local" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), [
"mcp",
"remove",
"docs",
"--scope",
"local",
]);
} finally {
restore();
}
});
it("POST /mcp/remove rejects the CLI's project scope with 400 — .mcp.json isn't addressable here", async () => {
const { status, body } = await fetchJson("/api/cc-config/mcp/remove", {
method: "POST",
body: { name: "docs", scope: "project" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADARG");
});
});
+343
View File
@@ -0,0 +1,343 @@
/**
* @file Tests for server/lib/cc-mcp-cli.js — the `claude mcp add`/`claude mcp
* remove` wrapper. Uses a real fake `claude` script on PATH (the fixture
* style server/__tests__/cc-plugin-cli.test.js already uses) rather than
* mocking child_process, and pins the exact argv each operation builds —
* that argv IS the security boundary.
*
* The argv shapes asserted here were verified against the real
* `claude mcp add/remove --help` (v2.1.266), not assumed: there is no
* `--url` flag (the url is positional), and `--header` must trail the url.
* See cc-mcp-cli.js's file header for the full list of surprises.
*
* @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(), "agenthub-ccmcp-"));
const cli = require("../lib/cc-mcp-cli");
/**
* Put a fake `claude` on PATH; it prints argv, one element per line.
* `printf`, not `echo` — bash's builtin `echo "-e"` treats a lone `-e`
* argument as its own flag and swallows it, which is exactly one of the
* argv elements this wrapper emits (`-e KEY=VALUE`).
*/
function stubClaude(scriptBody = 'for a in "$@"; do printf \'%s\\n\' "$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;
};
}
const argvOf = (result) => result.stdout.trim().split("\n");
after(() => fs.rmSync(ROOT, { recursive: true, force: true, maxRetries: 5 }));
describe("cc-mcp-cli argument validation", () => {
it("accepts letters, numbers, hyphens and underscores as a name", () => {
assert.equal(cli.assertName("my-server_1"), "my-server_1");
});
it("rejects a name with characters the CLI itself rejects", () => {
for (const bad of ["a/b", "a b", "a.b", ""]) {
assert.throws(() => cli.assertName(bad), undefined, `accepted ${bad}`);
}
});
it("refuses a server name that reads as a flag", async () => {
await assert.rejects(() => cli.mcpRemove({ name: "--all" }), /name/i);
});
it("requires an explicit scope on add but not on remove", () => {
assert.throws(() => cli.assertMcpScope(undefined, { required: true }), /scope must be one of/);
assert.equal(cli.assertMcpScope(undefined), undefined);
assert.equal(cli.assertMcpScope("user", { required: true }), "user");
});
it("rejects the CLI's own `project` scope — readMcpServers never reads .mcp.json", () => {
// The Critical this pins: `--scope project` writes .mcp.json, which the
// dashboard's read path never parses, so a successful add would report
// an "added" toast for a server that never appears in the list.
for (const bad of ["project", "project-local", "bogus"]) {
assert.throws(() => cli.assertMcpScope(bad), /scope must be one of/, `accepted ${bad}`);
}
});
it("accepts local — the CLI scope that actually backs the panel's projectScoped bucket", () => {
assert.equal(cli.assertMcpScope("local", { required: true }), "local");
});
});
describe("cc-mcp-cli argv construction", () => {
it("builds an http server with a positional url, not a --url flag", async () => {
const restore = stubClaude();
try {
const result = await cli.mcpAdd({
name: "vercel",
scope: "user",
transport: "http",
url: "https://mcp.vercel.com",
});
assert.deepEqual(argvOf(result), [
"mcp",
"add",
"--scope",
"user",
"--transport",
"http",
"vercel",
"https://mcp.vercel.com",
]);
} finally {
restore();
}
});
it("puts stdio command args after the -- separator", async () => {
const restore = stubClaude();
try {
const result = await cli.mcpAdd({
name: "docs",
scope: "user",
command: "node",
args: ["s.js", "--port", "3000"],
});
assert.deepEqual(argvOf(result).slice(-5), ["--", "node", "s.js", "--port", "3000"]);
} finally {
restore();
}
});
it("puts --header flags after the positional url, not before", async () => {
// Verified against the real CLI: --header is variadic, so placed before
// the url it would swallow the url as one of its own header values.
const restore = stubClaude();
try {
const result = await cli.mcpAdd({
name: "corridor",
scope: "local",
transport: "http",
url: "https://app.corridor.dev/api/mcp",
headers: { Authorization: "Bearer xyz" },
});
assert.deepEqual(argvOf(result), [
"mcp",
"add",
"--scope",
"local",
"--transport",
"http",
"corridor",
"https://app.corridor.dev/api/mcp",
"--header",
"Authorization: Bearer xyz",
]);
} finally {
restore();
}
});
it("passes env vars as -e KEY=VALUE for stdio servers, value may start with -", async () => {
const restore = stubClaude();
try {
const result = await cli.mcpAdd({
name: "envy",
scope: "user",
command: "node",
args: ["s.js"],
env: { API_KEY: "-not-a-flag" },
});
assert.deepEqual(argvOf(result), [
"mcp",
"add",
"--scope",
"user",
"envy",
"-e",
"API_KEY=-not-a-flag",
"--",
"node",
"s.js",
]);
} finally {
restore();
}
});
it("builds the remove argv, scope flag omitted when not given", async () => {
const restore = stubClaude();
try {
assert.deepEqual(argvOf(await cli.mcpRemove({ name: "docs" })), ["mcp", "remove", "docs"]);
assert.deepEqual(argvOf(await cli.mcpRemove({ name: "docs", scope: "user" })), [
"mcp",
"remove",
"docs",
"--scope",
"user",
]);
} finally {
restore();
}
});
it("builds a local-scoped remove — the argv the CLI actually accepts for the panel's projectScoped rows", async () => {
// The other Critical this pins: the panel used to send scope: "project"
// for these rows, which the real CLI never matches (it stores them under
// "local"), so Remove failed every time. `argvOf` is the exact argv
// `execFile` spawns — there is no shell in between to paper over a wrong
// scope string.
const restore = stubClaude();
try {
assert.deepEqual(argvOf(await cli.mcpRemove({ name: "docs", scope: "local" })), [
"mcp",
"remove",
"docs",
"--scope",
"local",
]);
} finally {
restore();
}
});
it("rejects a remove scoped to the CLI's project (.mcp.json is not addressable here)", async () => {
await assert.rejects(
() => cli.mcpRemove({ name: "docs", scope: "project" }),
/scope must be one of/
);
});
it("rejects url and command together, and neither", async () => {
await assert.rejects(
() => cli.mcpAdd({ name: "x", scope: "user", url: "https://a", command: "b" }),
/either url or command, not both/
);
await assert.rejects(
() => cli.mcpAdd({ name: "x", scope: "user" }),
/requires either url or command/
);
});
it("rejects env together with url, and headers together with command", async () => {
await assert.rejects(
() => cli.mcpAdd({ name: "x", scope: "user", url: "https://a", env: { K: "v" } }),
/env is not valid together with url/
);
await assert.rejects(
() =>
cli.mcpAdd({
name: "x",
scope: "user",
command: "node",
headers: { K: "v" },
}),
/headers is not valid together with command/
);
});
it("allows an empty stdio arg — only type/length/control-char bounds are enforced", async () => {
// A trailing empty argv element is invisible to `.trim().split("\n")`
// (it collapses with the final newline), so assert with a marker arg
// after it rather than checking the last line directly.
const restore = stubClaude();
try {
const result = await cli.mcpAdd({
name: "x",
scope: "user",
command: "node",
args: ["", "marker"],
});
assert.deepEqual(argvOf(result).slice(-3), ["node", "", "marker"]);
} finally {
restore();
}
});
it("rejects an object env/header value as a 400 instead of writing [object Object]", async () => {
await assert.rejects(
() =>
cli.mcpAdd({
name: "x",
scope: "user",
command: "node",
env: { KEY: { nope: true } },
}),
/KEY is required/
);
await assert.rejects(
() =>
cli.mcpAdd({
name: "x",
scope: "user",
transport: "http",
url: "https://a",
headers: { Authorization: { nope: true } },
}),
/Authorization is required/
);
});
it("never lets an args element reach a shell", async () => {
// A shell-metacharacter payload is a legal argv element (many real flags
// contain punctuation) — the guarantee is execFile's argv array, not a
// character blocklist. If this were interpolated into a shell string the
// `touch` would run; through execFile it stays one inert literal.
const canary = path.join(ROOT, "pwned");
const restore = stubClaude();
try {
const result = await cli.mcpAdd({
name: "x",
scope: "user",
command: "node",
args: [`s.js; touch ${canary}`],
});
assert.equal(argvOf(result).at(-1), `s.js; touch ${canary}`);
} finally {
restore();
}
assert.equal(fs.existsSync(canary), false);
});
});
describe("cc-mcp-cli run outcomes", () => {
it("reports a non-zero exit as ok:false instead of throwing", async () => {
const restore = stubClaude('echo "✘ Server name already exists" >&2\nexit 1');
try {
const result = await cli.mcpAdd({
name: "dup",
scope: "user",
command: "node",
args: ["s.js"],
});
assert.equal(result.ok, false);
assert.equal(result.code, 1);
assert.match(cli.cliMessage(result), /already exists/);
} finally {
restore();
}
});
it("serializes mutating calls so two clicks cannot interleave", async () => {
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.mcpRemove({ name: "first" }), cli.mcpRemove({ 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();
}
});
});
+83
View File
@@ -190,6 +190,89 @@ describe("cc-plugin-cli argv construction", () => {
});
});
describe("cc-plugin-cli CLAUDE_CONFIG_DIR pinning", () => {
// N1: pinning CLAUDE_CONFIG_DIR unconditionally to getClaudeHome() made the
// CLI write ~/.claude/.claude.json while cc-discovery reads ~/.claude.json
// — a different file, so every default-install add/remove was inert. The
// fix is to leave CLAUDE_CONFIG_DIR unset unless the operator explicitly
// set CLAUDE_HOME; this is the env contract that would have caught it.
const PRINT_ENV = "printf '%s\\n' \"CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-<unset>}\"";
it("leaves CLAUDE_CONFIG_DIR unset when CLAUDE_HOME is not set", async () => {
const originalHome = process.env.CLAUDE_HOME;
delete process.env.CLAUDE_HOME;
const restore = stubClaude(PRINT_ENV);
try {
const result = await cli.marketplaceUpdate({ name: "m" });
assert.equal(result.stdout.trim(), "CLAUDE_CONFIG_DIR=<unset>");
} finally {
restore();
if (originalHome !== undefined) process.env.CLAUDE_HOME = originalHome;
}
});
it("pins CLAUDE_CONFIG_DIR to CLAUDE_HOME when the operator set it", async () => {
const originalHome = process.env.CLAUDE_HOME;
process.env.CLAUDE_HOME = "/tmp/some-other-claude-home";
const restore = stubClaude(PRINT_ENV);
try {
const result = await cli.marketplaceUpdate({ name: "m" });
assert.equal(result.stdout.trim(), "CLAUDE_CONFIG_DIR=/tmp/some-other-claude-home");
} finally {
restore();
if (originalHome === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = originalHome;
}
});
// The two env tests above pin the env var each half of the pair depends
// on. This pins the pair itself end to end: the file getClaudeJsonPath()
// returns must be the file the CLI actually wrote — under BOTH an unset
// and a set CLAUDE_HOME. A fake `claude` that writes `.claude.json`
// exactly where the real binary does (verified against it: flat inside
// CLAUDE_CONFIG_DIR when set, beside — NOT inside — $HOME when unset)
// stands in for the real CLI; no real CLI or real $HOME/CLAUDE_HOME touched.
const WRITE_CLAUDE_JSON = 'echo "{}" > "${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"';
it("writes .claude.json exactly where getClaudeJsonPath() reads it, with CLAUDE_HOME unset", async () => {
const cc = require("../lib/cc-discovery");
const originalHome = process.env.HOME;
const originalClaudeHome = process.env.CLAUDE_HOME;
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-fakehome-"));
delete process.env.CLAUDE_HOME;
process.env.HOME = fakeHome;
const restore = stubClaude(WRITE_CLAUDE_JSON);
try {
await cli.marketplaceUpdate({ name: "m" });
assert.equal(cc.getClaudeJsonPath(), path.join(fakeHome, ".claude.json"));
assert.ok(fs.existsSync(cc.getClaudeJsonPath()), "the CLI's write landed where reads look");
} finally {
restore();
process.env.HOME = originalHome;
if (originalClaudeHome !== undefined) process.env.CLAUDE_HOME = originalClaudeHome;
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
it("writes .claude.json exactly where getClaudeJsonPath() reads it, with CLAUDE_HOME set", async () => {
const cc = require("../lib/cc-discovery");
const originalClaudeHome = process.env.CLAUDE_HOME;
const fakeClaudeHome = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-fakeclaudehome-"));
process.env.CLAUDE_HOME = fakeClaudeHome;
const restore = stubClaude(WRITE_CLAUDE_JSON);
try {
await cli.marketplaceUpdate({ name: "m" });
assert.equal(cc.getClaudeJsonPath(), path.join(fakeClaudeHome, ".claude.json"));
assert.ok(fs.existsSync(cc.getClaudeJsonPath()), "the CLI's write landed where reads look");
} finally {
restore();
if (originalClaudeHome === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = originalClaudeHome;
fs.rmSync(fakeClaudeHome, { recursive: true, force: true });
}
});
});
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');
+179
View File
@@ -0,0 +1,179 @@
/**
* @file project-memory.test.js
* @description Tests for POST /api/{cc,codex}-config/project-memory — the one
* write path that honours a caller-supplied directory, so it is the one that
* has to prove its narrowing rules hold: absolute + existing dir only, a fixed
* filename, never an overwrite, and never reachable from a foreign origin.
*
* Every refusal asserts the file did NOT appear — a 4xx with the write already
* done is not a refusal.
* @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");
const http = require("node:http");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "project-memory-test-"));
const FAKE_HOME = path.join(TMP, "home", ".claude");
const NEW_PROJECT = path.join(TMP, "brand-new-project");
fs.mkdirSync(FAKE_HOME, { recursive: true });
process.env.CLAUDE_HOME = FAKE_HOME;
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard-test.db");
const { createApp } = require("../index");
let server;
let BASE;
function post(p, body, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(p, BASE);
const buf = Buffer.from(JSON.stringify(body));
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": buf.length,
...headers,
},
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
let json;
try {
json = JSON.parse(text);
} catch {
json = text;
}
resolve({ status: res.statusCode, body: json });
});
}
);
req.on("error", reject);
req.write(buf);
req.end();
});
}
// Both routes are the same code with a different filename, so every rule is
// asserted once per tool rather than once overall.
const TOOLS = [
{ route: "/api/cc-config/project-memory", file: "CLAUDE.md" },
{ route: "/api/codex-config/project-memory", file: "AGENTS.md" },
];
describe("POST /project-memory", () => {
before(async () => {
server = http.createServer(createApp());
await new Promise((r) => server.listen(0, r));
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
await new Promise((r) => server.close(r));
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup */
}
});
beforeEach(() => {
fs.rmSync(NEW_PROJECT, { recursive: true, force: true });
fs.mkdirSync(NEW_PROJECT, { recursive: true });
});
for (const { route, file } of TOOLS) {
it(`${file}: creates it in a project the dashboard has never run in`, async () => {
const { status, body } = await post(route, { dir: NEW_PROJECT, content: "# Hello\n" });
assert.equal(status, 200);
assert.equal(body.created, true);
assert.equal(body.file, path.join(fs.realpathSync(NEW_PROJECT), file));
assert.equal(fs.readFileSync(path.join(NEW_PROJECT, file), "utf8"), "# Hello\n");
});
it(`${file}: refuses to overwrite one that already exists`, async () => {
fs.writeFileSync(path.join(NEW_PROJECT, file), "ORIGINAL");
const { status, body } = await post(route, { dir: NEW_PROJECT, content: "REPLACED" });
assert.equal(status, 409);
assert.equal(body.error.code, "EEXISTS");
assert.equal(fs.readFileSync(path.join(NEW_PROJECT, file), "utf8"), "ORIGINAL");
});
it(`${file}: refuses a relative dir`, async () => {
const { status, body } = await post(route, { dir: "some/project", content: "x" });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADDIR");
});
it(`${file}: refuses a dir that does not exist`, async () => {
const missing = path.join(TMP, "not-there");
const { status, body } = await post(route, { dir: missing, content: "x" });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADDIR");
assert.equal(fs.existsSync(missing), false);
});
it(`${file}: refuses a path that is a file, not a directory`, async () => {
const notADir = path.join(NEW_PROJECT, "README.md");
fs.writeFileSync(notADir, "x");
const { status, body } = await post(route, { dir: notADir, content: "y" });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADDIR");
assert.equal(fs.readFileSync(notADir, "utf8"), "x");
});
it(`${file}: refuses a cross-origin request`, async () => {
const { status, body } = await post(
route,
{ dir: NEW_PROJECT, content: "drive-by" },
{ Origin: "https://evil.example" }
);
assert.equal(status, 403);
assert.equal(body.error.code, "EBADORIGIN");
assert.equal(fs.existsSync(path.join(NEW_PROJECT, file)), false);
});
it(`${file}: allows a loopback origin (the dashboard itself)`, async () => {
const { status } = await post(route, { dir: NEW_PROJECT, content: "ok" }, { Origin: BASE });
assert.equal(status, 200);
assert.equal(fs.readFileSync(path.join(NEW_PROJECT, file), "utf8"), "ok");
});
it(`${file}: refuses to follow a symlink planted under that name`, async () => {
// Dangling on purpose: `existsSync`/`statSync` both say "no file here"
// for a broken link, so only the lstat check refuses this — and writing
// through it would create the file at the link's target instead.
const outside = path.join(TMP, `outside-${file}`);
fs.rmSync(outside, { force: true });
fs.symlinkSync(outside, path.join(NEW_PROJECT, file));
const { status } = await post(route, { dir: NEW_PROJECT, content: "PWNED" });
assert.equal(status, 409);
assert.equal(fs.existsSync(outside), false);
});
}
it("writes only the tool's own filename — the caller cannot name the file", async () => {
// `filename` is not part of the request body at all; proving that here
// means a future refactor cannot quietly add it back as a passthrough.
await post("/api/cc-config/project-memory", {
dir: NEW_PROJECT,
content: "x",
filename: "../../escaped.md",
});
assert.equal(fs.existsSync(path.join(NEW_PROJECT, "CLAUDE.md")), true);
assert.equal(fs.existsSync(path.join(TMP, "escaped.md")), false);
});
});
+49
View File
@@ -238,3 +238,52 @@ describe("autoInstallSkillPlugins", () => {
assert.deepEqual(run.calls, []);
});
});
describe("runClaudePlugin: the env it spawns with", () => {
// Every other test in this file injects `run`, so nothing exercises the real
// spawn. That gap is how a wrong CLAUDE_CONFIG_DIR pin lived here: the CLI
// writes `.claude.json` INSIDE that dir, while cc-discovery reads it beside
// ~/.claude by default, so pinning it unconditionally aimed writes at a file
// nothing reads. cc-plugin-cli.js's runClaude has the mirror of this test.
const PRINT_ENV = 'printf "%s\\n" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-<unset>}"';
function stubClaude(body) {
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-skillplug-bin-"));
const file = path.join(bin, "claude");
fs.writeFileSync(file, `#!/usr/bin/env bash\n${body}\n`);
fs.chmodSync(file, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
fs.rmSync(bin, { recursive: true, force: true });
};
}
it("leaves CLAUDE_CONFIG_DIR unset when CLAUDE_HOME is not set", () => {
const original = process.env.CLAUDE_HOME;
delete process.env.CLAUDE_HOME;
const restore = stubClaude(PRINT_ENV);
try {
const res = skillPlugins.runClaudePlugin(["plugin", "list"], 10_000);
assert.equal(res.output.trim(), "CLAUDE_CONFIG_DIR=<unset>");
} finally {
restore();
if (original !== undefined) process.env.CLAUDE_HOME = original;
}
});
it("pins CLAUDE_CONFIG_DIR to CLAUDE_HOME when the operator set it", () => {
const original = process.env.CLAUDE_HOME;
process.env.CLAUDE_HOME = "/tmp/some-other-claude-home";
const restore = stubClaude(PRINT_ENV);
try {
const res = skillPlugins.runClaudePlugin(["plugin", "list"], 10_000);
assert.equal(res.output.trim(), "CLAUDE_CONFIG_DIR=/tmp/some-other-claude-home");
} finally {
restore();
if (original === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = original;
}
});
});
+23 -3
View File
@@ -29,9 +29,21 @@ function getProjectClaudeDir(cwd) {
}
function getClaudeJsonPath() {
// ~/.claude.json sits beside ~/.claude/, NOT inside it. Resolve from $HOME
// so a CLAUDE_HOME override doesn't accidentally relocate it.
return path.join(os.homedir(), ".claude.json");
// NOT path.join(getClaudeHome(), ".claude.json") — verified against the
// real `claude` binary, the two CLAUDE_HOME branches behave differently:
// - CLAUDE_HOME set: the CLI's CLAUDE_CONFIG_DIR (pinned to it by
// cc-plugin-cli.js's runClaude()) becomes a $HOME-substitute for both
// `.claude.json` AND the rest of the tree (projects/, settings.json,
// plugins/) — flat inside it, no nested ".claude". Same value
// getClaudeHome() returns in this branch, so the two formulas agree.
// - CLAUDE_HOME unset: runClaude() deliberately leaves CLAUDE_CONFIG_DIR
// unset too, and the CLI's OWN default writes `.claude.json` BESIDE
// `~/.claude`, not inside it — while getClaudeHome()'s unset branch
// appends "/.claude". Delegating to getClaudeHome() here would read
// `~/.claude/.claude.json`, a file the CLI never writes by default.
return process.env.CLAUDE_HOME
? path.join(process.env.CLAUDE_HOME, ".claude.json")
: path.join(os.homedir(), ".claude.json");
}
/**
@@ -509,6 +521,11 @@ function readMcpServers(opts = {}) {
out.user.push({
name,
source: "~/.claude/settings.json",
// No `claude mcp` scope addresses settings.json, so the dashboard
// cannot remove these. A flag, not a string comparison on `source`:
// the UI used to match that display text, so rewording it silently
// brought back a Remove button that always fails.
removable: false,
...summarizeMcpDef(def),
});
}
@@ -904,6 +921,9 @@ function readOverview(opts = {}) {
}
module.exports = {
// Exported so a test can pin it against the file the CLI writes — the two
// drifted apart once and the mismatch was invisible from either side alone.
getClaudeJsonPath,
// surface readers
readSkills,
readAgents,
+240
View File
@@ -0,0 +1,240 @@
/**
* @file cc-mcp-cli.js
* @description Drives `claude mcp add`/`claude mcp remove` so the dashboard
* can add and remove MCP servers without touching `~/.claude.json` directly.
*
* Why the CLI and not a direct JSON edit: `~/.claude.json` is rewritten
* concurrently by the running Claude Code CLI (health checks, OAuth state,
* project bookkeeping) — the same reason `cc-plugin-cli.js` shells out for
* plugins/marketplaces instead of editing `installed_plugins.json` by hand.
* Reads still go through `cc-discovery.readMcpServers` (parses the JSON
* directly); this file only ever mutates.
*
* The real `claude mcp add --help` (v2.1.266) does NOT match the CLI shape a
* first draft of this file assumed:
* - There is no `--url` flag. `commandOrUrl` is a positional argument:
* `claude mcp add --transport http <name> <url>`.
* - `--header "Key: value"` must come AFTER the positional url — placed
* before it, the variadic `--header <header...>` swallows the url as one
* of its own values and the CLI reports a missing `commandOrUrl`.
* - `list`/`get` have no `--json` output — reads correctly stay on
* `readMcpServers`, which parses `~/.claude.json` itself; this module
* never lists.
* - The CLI's own name validator is "letters, numbers, hyphens, and
* underscores" — `NAME_RE` below matches that.
* Verified by running `claude mcp add/remove/list --help` and exercising
* each form against a throwaway `CLAUDE_CONFIG_DIR`, not assumed from docs.
*
* Hard constraints (do not relax without a follow-up review):
* - `execFile` via `cc-plugin-cli.js`'s `runClaude` — argv array, no shell.
* - Every argument is validated BEFORE the spawn: a name/command/url may
* never start with `-` (it would be parsed as a flag); an `-e`/`--header`
* VALUE may, because it is the right-hand side of one `KEY=VALUE` (or
* `Key: value`) argv element, never its own argv position.
* - Mutating calls are serialized in-process; two dashboard clicks must not
* interleave writes to the same `~/.claude.json`.
* - Only `user`/`local` scope is exposed — matching what `readMcpServers`
* can actually see: `user` is `~/.claude.json` top-level, `local` is
* `~/.claude.json`'s `projects[<root>]` block (the panel's
* "projectScoped" bucket). The CLI's `project` scope writes `.mcp.json`,
* which `readMcpServers` never reads — offering it would add a server
* the CLI accepts and the dashboard can never show existed.
*
* A non-zero CLI exit resolves as `{ok: false}` (data, not an error) so the
* route can map it to HTTP 422. Only a bad argument or a missing binary
* throws — those are programming/environment errors, not CLI outcomes.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// Reused, not reimplemented: runClaude/cliMessage/serialize already pin
// CLAUDE_CONFIG_DIR, turn a non-zero exit into `{ok:false}`, and queue every
// mutating call onto ONE chokepoint — the exact contract this module needs,
// shared with cc-plugin-cli.js's own callers so a plugin install and an MCP
// add can never spawn `claude` concurrently against the same config dir.
const { runClaude, cliMessage, serialize } = require("./cc-plugin-cli");
/** The CLI's own name validator: "letters, numbers, hyphens, and underscores". */
const NAME_RE = /^[A-Za-z0-9_-]+$/;
/** An environment variable NAME — the left-hand side of `-e KEY=VALUE`. */
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
/** Scopes this form exposes — see MCP_SCOPES's own comment for why `project`
* is excluded and `local` is included; the inverse of what "project" sounds
* like it should be. */
const MCP_SCOPES = ["user", "local"];
function makeError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/**
* Baseline check for a value that occupies its OWN argv position (a name, a
* command, a url, an env-var key). Such a value could be parsed as a flag by
* the CLI if it started with `-`, so that 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;
}
/** An MCP server name — the routes' shared 400 guard. */
function assertName(value) {
assertArg(value, "name");
if (!NAME_RE.test(value)) {
throw makeError("EBADARG", "name is not a valid identifier");
}
return value;
}
function assertEnvKey(value, label) {
assertArg(value, label);
if (!ENV_KEY_RE.test(value)) {
throw makeError("EBADARG", `${label} is not a valid identifier`);
}
return value;
}
/**
* A value that is embedded as the right-hand side of a single argv element
* (`KEY=VALUE` for `-e`, `Key: value` for `--header`) rather than being its
* own argv position. It may legitimately start with `-` — the surrounding
* flag already fixes its argv position, so it can never be parsed as one.
* Empty is allowed (a legitimate empty stdio arg or env value); only the
* type, length, and control-character bounds are enforced.
*/
function assertEmbeddedValue(value, label) {
if (typeof value !== "string") {
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`);
}
return value;
}
function assertUrl(value, label) {
assertArg(value, label);
let parsed;
try {
parsed = new URL(value);
} catch {
throw makeError("EBADARG", `${label} must be a valid http(s) URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw makeError("EBADARG", `${label} must be a valid http(s) URL`);
}
return value;
}
/** `required:true` for add (the UI always sends one); optional for remove. */
function assertMcpScope(value, { required = false } = {}) {
if (value === undefined || value === null || value === "") {
if (required) throw makeError("EBADARG", `scope must be one of ${MCP_SCOPES.join(", ")}`);
return undefined;
}
if (!MCP_SCOPES.includes(value)) {
throw makeError("EBADARG", `scope must be one of ${MCP_SCOPES.join(", ")}`);
}
return value;
}
function envFlags(env) {
if (env === undefined) return [];
if (typeof env !== "object" || env === null || Array.isArray(env)) {
throw makeError("EBADARG", "env must be an object");
}
return Object.entries(env).flatMap(([k, v]) => {
const key = assertEnvKey(k, "env key");
assertEmbeddedValue(v, `env.${key}`);
return ["-e", `${key}=${v}`];
});
}
function headerFlags(headers) {
if (headers === undefined) return [];
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) {
throw makeError("EBADARG", "headers must be an object");
}
return Object.entries(headers).flatMap(([k, v]) => {
assertEmbeddedValue(k, "header name");
assertEmbeddedValue(v, `header.${k}`);
return ["--header", `${k}: ${v}`];
});
}
/**
* `claude mcp add` has two mutually exclusive forms, matching the real CLI
* (not a `--url` flag — see the file header): an http/sse server (`name`,
* positional `url`, optional `--header`) or a stdio server (`name`, optional
* `-e`, `-- command args…`).
*/
async function mcpAdd({ name, scope, transport, command, args = [], url, headers, env }) {
const n = assertName(name);
const sc = assertMcpScope(scope, { required: true });
if (url && command) {
throw makeError("EBADARG", "mcpAdd accepts either url or command, not both");
}
if (!url && !command) {
throw makeError("EBADARG", "mcpAdd requires either url or command");
}
if (!Array.isArray(args)) throw makeError("EBADARG", "args must be an array");
if (url) {
if (env !== undefined) {
throw makeError("EBADARG", "env is not valid together with url (stdio-only)");
}
const u = assertUrl(url, "url");
if (transport !== undefined && transport !== "http" && transport !== "sse") {
throw makeError("EBADARG", "transport must be http or sse when url is set");
}
const t = transport === "sse" ? "sse" : "http";
const hFlags = headerFlags(headers);
return serialize(() =>
runClaude(["mcp", "add", "--scope", sc, "--transport", t, n, u, ...hFlags])
);
}
if (headers !== undefined) {
throw makeError("EBADARG", "headers is not valid together with command (http/sse-only)");
}
const cmd = assertArg(command, "command");
// Args land AFTER the `--` separator, so the CLI never parses them as its
// own flags — a leading "-" here is a flag for the spawned MCP server
// (e.g. `--port`), not for `claude`. Same embedded-value reasoning as
// env/header values, applied because of position rather than KEY=VALUE.
const cmdArgs = args.map((a, i) => assertEmbeddedValue(a, `args[${i}]`));
const eFlags = envFlags(env);
return serialize(() =>
runClaude(["mcp", "add", "--scope", sc, n, ...eFlags, "--", cmd, ...cmdArgs])
);
}
async function mcpRemove({ name, scope }) {
const n = assertName(name);
const sc = assertMcpScope(scope);
const scopeFlags = sc ? ["--scope", sc] : [];
return serialize(() => runClaude(["mcp", "remove", n, ...scopeFlags]));
}
module.exports = {
mcpAdd,
mcpRemove,
cliMessage,
// exported for tests
assertName,
assertMcpScope,
NAME_RE,
MCP_SCOPES,
};
+21 -5
View File
@@ -17,8 +17,10 @@
* 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.
* - `CLAUDE_CONFIG_DIR` is pinned ONLY when the operator set `CLAUDE_HOME`.
* Left unset it stays unset, because the CLI's own defaults already write
* where `cc-discovery` looks — pinning it unconditionally is what once
* made every MCP add/remove write a file nothing reads.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -131,15 +133,25 @@ function serialize(fn) {
function runClaude(args, opts = {}) {
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
// Only pin CLAUDE_CONFIG_DIR when the operator explicitly set CLAUDE_HOME
// (a non-default install). Left unset, the CLI's own default config dir
// is `~/.claude` and it writes `.claude.json` NEXT TO it (`~/.claude.json`)
// — the exact file cc-discovery.getClaudeJsonPath() reads (see that
// function's own comment). Pinning CLAUDE_CONFIG_DIR unconditionally to
// getClaudeHome() made the CLI write `~/.claude/.claude.json` instead —
// a file nothing reads — so every default-install add/remove was inert.
// When CLAUDE_HOME IS set, this pins it and the CLI writes
// <CLAUDE_HOME>/.claude.json — which is what getClaudeJsonPath() reads in
// that branch, so both cases line up. Keep the two in step.
const env = { ...process.env };
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
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() },
env,
},
(err, stdout, stderr) => {
const out = String(stdout || "");
@@ -259,6 +271,10 @@ function cliMessage(res) {
module.exports = {
runClaude,
cliMessage,
// Exported so cc-mcp-cli.js queues onto this SAME chokepoint — one CLI
// process against CLAUDE_CONFIG_DIR at a time, whether the click was a
// plugin install or an MCP add.
serialize,
listAvailable,
marketplaceAdd,
marketplaceRemove,
+98
View File
@@ -0,0 +1,98 @@
/**
* @file project-memory.js
* @description Create a project's top-level memory file — `CLAUDE.md` for
* Claude Code, `AGENTS.md` for Codex — in ANY existing directory on this
* machine, so the dashboard can bootstrap a project it has never run in.
*
* Why this is not `cc-mutate`/`codex-mutate`: those writers resolve "project"
* scope against the server's own cwd on purpose, and `?cwd=` is ignored there
* precisely so a request can never redirect a write. This surface is the
* deliberate, narrow exception, and it pays for that with:
* - the directory must be absolute and already exist (we never mkdir -p a
* path someone typed);
* - the filename is not caller-supplied — it comes from {@link FILENAMES};
* - the file must not exist yet, so this can only ever create, never
* clobber an existing memory file;
* - a symlink at `<dir>/CLAUDE.md` pointing elsewhere is refused.
*
* The capability is not new in kind: the Run page already spawns `claude` in
* any existing directory the same way. What it must not become is a general
* "write arbitrary content to an arbitrary path" endpoint — hence the fixed
* filename and the no-overwrite rule.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { atomicWriteFile } = require("./config-write");
/** The only two filenames this module will ever write. Keyed by tool. */
const FILENAMES = { claude: "CLAUDE.md", codex: "AGENTS.md" };
/** Same cap the other config writers use — a memory file is prose, not a blob. */
const MAX_CONTENT_BYTES = 1024 * 1024;
function makeError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/**
* Create `<dir>/<CLAUDE.md|AGENTS.md>`.
*
* @param {{tool: "claude"|"codex", dir: string, content: string}} args
* @returns {{file: string, size: number}}
* @throws EBADTOOL, EBADDIR, EEXISTS, EBADCONTENT, EOUTOFROOT
*/
function createProjectMemory({ tool, dir, content }) {
const filename = FILENAMES[tool];
if (!filename) throw makeError("EBADTOOL", `tool must be one of ${Object.keys(FILENAMES)}`);
if (typeof dir !== "string" || !dir.trim()) {
throw makeError("EBADDIR", "dir is required");
}
if (!path.isAbsolute(dir)) {
throw makeError("EBADDIR", "dir must be an absolute path");
}
// eslint-disable-next-line no-control-regex
if (/\u0000/.test(dir)) throw makeError("EBADDIR", "dir contains a NUL byte");
let realDir;
try {
realDir = fs.realpathSync(path.resolve(dir));
} catch {
throw makeError("EBADDIR", `directory does not exist: ${dir}`);
}
if (!fs.statSync(realDir).isDirectory()) {
throw makeError("EBADDIR", `not a directory: ${dir}`);
}
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
if (Buffer.byteLength(content, "utf8") > MAX_CONTENT_BYTES) {
throw makeError("EBADCONTENT", "content is too large");
}
const file = path.join(realDir, filename);
// lstat, not existsSync: a symlink named CLAUDE.md pointing somewhere else —
// dangling or not — counts as taken, so we refuse instead of writing through
// it. That, plus `realDir` being already realpath'd, is the whole symlink
// story here; the usual `assertNoSymlinkEscape` walk has nothing left to
// catch. And if someone plants the link between this check and the write,
// `atomicWriteFile`'s rename replaces the link itself rather than following
// it.
let taken = true;
try {
fs.lstatSync(file);
} catch (err) {
if (err.code !== "ENOENT") throw err;
taken = false;
}
if (taken) throw makeError("EEXISTS", `${filename} already exists in ${realDir}`);
atomicWriteFile(file, content);
return { file, size: Buffer.byteLength(content, "utf8") };
}
module.exports = { createProjectMemory, FILENAMES, MAX_CONTENT_BYTES };
+50
View File
@@ -0,0 +1,50 @@
/**
* @file same-origin.js
* @description Loopback-Origin guard for the routes that act on the machine
* rather than on the dashboard's own database — spawning a process, or
* writing a file into a directory the request names.
*
* The dashboard binds to localhost, so any page in the user's browser can
* reach it. Without this, a malicious site could POST to it and, say, drop a
* CLAUDE.md full of instructions into a project the user later opens.
*
* Browser requests carry `Origin`; it must be a loopback host. Requests with
* no `Origin` (curl, the CLI) pass — they are not the drive-by case. `Referer`
* is the fallback for older browsers.
*
* Extracted from routes/run.js so the config routes enforce the identical
* rule instead of a second, drifting copy.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
function isLoopback(raw) {
try {
return ALLOWED_ORIGIN_HOSTS.has(new URL(raw).hostname);
} catch {
return false;
}
}
function sameOriginGuard(req, res, next) {
const origin = req.headers.origin;
if (origin) {
if (!isLoopback(origin)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
}
const referer = req.headers.referer;
if (referer && !isLoopback(referer)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
}
module.exports = { sameOriginGuard, ALLOWED_ORIGIN_HOSTS };
+12 -4
View File
@@ -119,20 +119,24 @@ function missingSkillPlugins() {
* because every argv element below comes from {@link AUTO_INSTALLABLE} — a
* constant in this file, never a request or a template.
*
* Still an argv array, never a shell string, and `CLAUDE_CONFIG_DIR` is pinned
* to the same home `cc-discovery` reads so a write here is a write the
* dashboard can see.
* Still an argv array, never a shell string. `CLAUDE_CONFIG_DIR` is pinned
* only when the operator set `CLAUDE_HOME`, matching `cc-plugin-cli.js`'s
* `runClaude()`: left unset, the CLI's own defaults already write where
* `cc-discovery` reads, and pinning it unconditionally sent `.claude.json`
* to `~/.claude/.claude.json`, which nothing reads.
*
* @returns {{ok: boolean, output: string}}
*/
function runClaudePlugin(args, timeoutMs) {
const env = { ...process.env };
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
const result = spawnSync("claude", args, {
timeout: timeoutMs,
encoding: "utf8",
maxBuffer: 1024 * 1024,
// Non-interactive: a would-be prompt must fail fast, not hang out the timeout.
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, CLAUDE_CONFIG_DIR: getClaudeHome() },
env,
});
const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
if (result.error) {
@@ -216,4 +220,8 @@ module.exports = {
requiredSkillPlugins,
missingSkillPlugins,
autoInstallSkillPlugins,
// Exported only so a test can pin the env it spawns with. Every other test
// injects `run` and so never reaches the real spawn — which is how an
// unconditional CLAUDE_CONFIG_DIR pin could be restored here unnoticed.
runClaudePlugin,
};
+110
View File
@@ -327,6 +327,12 @@ const schemas = {
"Where the definition was found, e.g. `~/.claude.json (top-level)`, `~/.claude.json (projects[<root>])`, or `~/.claude/settings.json`.",
example: "~/.claude.json (top-level)",
},
removable: {
type: "boolean",
description:
"False when no `claude mcp` scope can address this entry (currently only ~/.claude/settings.json-sourced servers), so POST /mcp/remove has no scope that would find it. Absent/true means removable.",
example: true,
},
kind: {
type: "string",
enum: ["http", "stdio", "unknown"],
@@ -387,6 +393,18 @@ const schemas = {
},
},
/** Response of a successful mutating `claude mcp …` run. A non-zero CLI
* exit arrives as an HTTP 422 error instead of one of these. */
CcConfigCliResult: {
type: "object",
required: ["ok", "stdout", "stderr"],
properties: {
ok: { type: "boolean", enum: [true] },
stdout: { type: "string" },
stderr: { type: "string" },
},
},
// ── Hooks ────────────────────────────────────────────────────────────
CcConfigHookEntry: {
type: "object",
@@ -1580,6 +1598,98 @@ const paths = {
},
},
"/api/cc-config/mcp/add": {
post: {
tags: ["CcConfig"],
summary: "Add an MCP server",
description:
"Mutating. Runs `claude mcp add` — never a direct edit of ~/.claude.json, which the running CLI rewrites concurrently. Body carries either `url` (+ optional `transport: http|sse` and `headers`) for a remote server, or `command` + `args` (+ optional `env`) for a stdio server; exactly one of the two forms is required. `scope` is required and is `user`|`local` only: `user` writes ~/.claude.json's top level, `local` writes its `projects[<root>]` block (GET /mcp's `projectScoped` bucket). The CLI's own `project` scope (a shared `.mcp.json`) is deliberately not offered — `readMcpServers` never reads that file, so a server added there would report success and never appear in the list. A non-zero CLI exit is returned as 422, not 500. Emits a `cc_config_changed` websocket event on success.",
operationId: "ccConfigPostMcpAdd",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name", "scope"],
properties: {
name: { type: "string", example: "vercel" },
scope: { type: "string", enum: ["user", "local"], example: "user" },
transport: { type: "string", enum: ["http", "sse"] },
url: { type: "string", example: "https://mcp.vercel.com" },
headers: {
type: "object",
additionalProperties: { type: "string" },
example: { Authorization: "Bearer xyz" },
},
command: { type: "string", example: "npx" },
args: {
type: "array",
items: { type: "string" },
example: ["-y", "server-github"],
},
env: {
type: "object",
additionalProperties: { type: "string" },
example: { GITHUB_TOKEN: "xxx" },
},
},
},
},
},
},
responses: {
200: {
description: "The CLI's stdout/stderr on success.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CcConfigCliResult" },
},
},
},
400: { description: "Bad name/scope/args, or an incompatible combination." },
422: { description: "The CLI exited non-zero (e.g. the name is already taken)." },
},
},
},
"/api/cc-config/mcp/remove": {
post: {
tags: ["CcConfig"],
summary: "Remove an MCP server",
description:
"Mutating. Runs `claude mcp remove`. `scope` (`user`|`local`) narrows which config layer to remove from; omit it to let the CLI find the server wherever it exists. The CLI's own `project` scope is refused with 400 — the panel has no row sourced from `.mcp.json` to remove. A non-zero CLI exit (e.g. the name does not exist) is returned as 422. Emits a `cc_config_changed` websocket event on success.",
operationId: "ccConfigPostMcpRemove",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name"],
properties: {
name: { type: "string", example: "vercel" },
scope: { type: "string", enum: ["user", "local"] },
},
},
},
},
},
responses: {
200: {
description: "The CLI's stdout/stderr on success.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CcConfigCliResult" },
},
},
},
400: { description: "Bad name/scope." },
422: { description: "The CLI exited non-zero (e.g. no such server)." },
},
},
},
"/api/cc-config/hooks": {
get: {
tags: ["CcConfig"],
+70 -3
View File
@@ -10,9 +10,11 @@
* artifacts (skills, agents, commands, output styles, memory, per-project
* auto-memory), the structured keybindings.json editor, settings.json layers
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
* and plugin/marketplace management delegated to the `claude plugin …` CLI.
* Every file write is preceded by a timestamped backup. MCP server config
* stays read-only — it is written concurrently by the running CLI.
* plugin/marketplace management delegated to the `claude plugin …` CLI, and
* MCP server add/remove delegated to the `claude mcp …` CLI. Every file
* write is preceded by a timestamped backup; the CLI-delegated writes are
* not files this server touches directly, so there is nothing to back up —
* the CLI owns `~/.claude.json`.
*
* Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic
* account the local CLI runs as; those carry identity metadata only, never
@@ -25,7 +27,10 @@ const { Router } = require("express");
const cc = require("../lib/cc-discovery");
const ccMutate = require("../lib/cc-mutate");
const ccCli = require("../lib/cc-plugin-cli");
const ccMcpCli = require("../lib/cc-mcp-cli");
const ccProfiles = require("../lib/cc-profiles");
const projectMemory = require("../lib/project-memory");
const { sameOriginGuard } = require("../lib/same-origin");
const { broadcast } = require("../websocket");
const router = Router();
@@ -45,6 +50,10 @@ const ERR_TO_STATUS = {
EBADNAME: 400,
EBADPROJECT: 400,
EBADCONTENT: 400,
EBADDIR: 400,
EBADTOOL: 400,
// The project already has a memory file; creating would clobber it.
EEXISTS: 409,
ETOOLARGE: 413,
EOUTOFROOT: 400,
ENOTFOUND: 404,
@@ -213,6 +222,27 @@ router.delete("/file", (req, res) => {
}
});
/**
* POST /api/cc-config/project-memory — create `CLAUDE.md` in a project the
* dashboard has never run in. Body: `{ dir, content }`.
*
* The one write here that honours a caller-supplied directory, which is the
* whole point: you cannot bootstrap a NEW project's memory from a writer
* pinned to the server's own cwd. It stays narrow — see project-memory.js —
* and sits behind the same loopback-origin guard the process-spawning routes
* use, so a random web page cannot plant instructions in a project directory.
*/
router.post("/project-memory", sameOriginGuard, (req, res) => {
const { dir, content } = req.body || {};
try {
const result = projectMemory.createProjectMemory({ tool: "claude", dir, content });
emitChanged({ action: "write", scope: "project", type: "memory", name: null });
res.json({ ...result, created: true });
} catch (err) {
return mutateError(res, err);
}
});
// ── Plugins / marketplaces (via the `claude` CLI) ──────────────────────
//
// These shell out to `claude plugin …` with an argv array — never a shell
@@ -329,6 +359,43 @@ router.post("/marketplaces/action", async (req, res) => {
}
});
// ── MCP servers (via the `claude` CLI) ─────────────────────────────────
//
// Same reasoning as the plugin/marketplace routes above: `~/.claude.json` is
// rewritten concurrently by the running CLI, so add/remove shell out to
// `claude mcp …` rather than editing the file. Reads are unaffected — GET
// /mcp above stays on `readMcpServers`; the client refetches it after either
// call below succeeds.
router.post("/mcp/add", async (req, res) => {
const { name, scope, transport, command, args, url, headers, env } = req.body || {};
try {
const result = await ccMcpCli.mcpAdd({
name,
scope,
transport,
command,
args,
url,
headers,
env,
});
return cliResult(res, result, { action: "add", type: "mcp", name });
} catch (err) {
return cliError(res, err);
}
});
router.post("/mcp/remove", async (req, res) => {
const { name, scope } = req.body || {};
try {
const result = await ccMcpCli.mcpRemove({ name, scope });
return cliResult(res, result, { action: "remove", type: "mcp", 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
+24
View File
@@ -24,6 +24,8 @@ const { Router } = require("express");
const codexDiscovery = require("../lib/codex-discovery");
const codexMutate = require("../lib/codex-mutate");
const codexCli = require("../lib/codex-cli");
const projectMemory = require("../lib/project-memory");
const { sameOriginGuard } = require("../lib/same-origin");
const { broadcast } = require("../websocket");
const router = Router();
@@ -56,9 +58,13 @@ const ERR_TO_STATUS = {
EBADSCOPE: 400,
EBADNAME: 400,
EBADCONTENT: 400,
EBADDIR: 400,
EBADTOOL: 400,
EOUTOFROOT: 400,
ETOOLARGE: 413,
ENOTFOUND: 404,
// The project already has an AGENTS.md; creating would clobber it.
EEXISTS: 409,
};
function mutateError(res, err) {
@@ -158,6 +164,24 @@ router.delete("/file", (req, res) => {
}
});
/**
* POST /api/codex-config/project-memory — create `AGENTS.md` in a project the
* dashboard has never run in. Body: `{ dir, content }`. The Codex twin of
* cc-config.js's route of the same name; both go through project-memory.js,
* which is where the narrowing rules for this one caller-chosen write path
* are documented.
*/
router.post("/project-memory", sameOriginGuard, (req, res) => {
const { dir, content } = req.body || {};
try {
const result = projectMemory.createProjectMemory({ tool: "codex", dir, content });
emitChanged({ action: "write", scope: "project", type: "memory", name: null });
res.json({ ...result, created: true });
} catch (err) {
return mutateError(res, err);
}
});
router.get("/backups", (req, res) => {
const scope =
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
+1 -36
View File
@@ -25,45 +25,10 @@ const path = require("node:path");
const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
const lanesLib = require("../lib/lanes");
const { sameOriginGuard } = require("../lib/same-origin");
const router = Router();
const ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
/**
* Loopback-Origin guard. Browser requests carry Origin; if it's not localhost,
* we reject. Server/CLI requests (curl) typically don't carry Origin and pass.
*
* Referer is checked as a fallback for older browsers / fetch with credentials
* disabled — the same loopback-host rule applies.
*/
function sameOriginGuard(req, res, next) {
const checkHost = (raw) => {
try {
const u = new URL(raw);
return ALLOWED_ORIGIN_HOSTS.has(u.hostname);
} catch {
return false;
}
};
const origin = req.headers.origin;
if (origin) {
if (!checkHost(origin)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
}
const referer = req.headers.referer;
if (referer && !checkHost(referer)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
}
router.use(sameOriginGuard);
function isExistingDir(p) {