Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 876c6de6d2 | |||
| 3549000fa9 | |||
| 2942ced3ec | |||
| 742e23e418 | |||
| d7c1a10724 | |||
| e63bbf825e | |||
| 2ade30f670 | |||
| 5720e8d5f8 | |||
| 85187dfc78 | |||
| 697384145a | |||
| 114823ceef | |||
| c34931c3f5 | |||
| 17e4321761 | |||
| 0ad4b8c920 | |||
| 6d27c115d8 | |||
| fd4edaac98 | |||
| 22fd4797aa | |||
| ce64132771 | |||
| ed418d5696 | |||
| 42db57ee18 | |||
| acff059e92 | |||
| fef3e640d0 | |||
| 4d4449866b | |||
| 56bf5bbf4d | |||
| 8015730adf | |||
| f6973d24d6 | |||
| 865c6c1d1e | |||
| e38dc5555c | |||
| 8021e569f2 | |||
| 2ae9b06652 | |||
| aca8fd02ee | |||
| 29ed86d672 | |||
| 32839332f2 | |||
| 58623f37c0 | |||
| f3a2f41ed7 | |||
| c9541dc509 | |||
| dcd65f891c | |||
| ba79610697 | |||
| b0788eea80 | |||
| 236e94760f | |||
| 98b1921ca6 | |||
| 7618766861 | |||
| 57849d2f75 | |||
| aef7ec130b | |||
| 6f9b9c36b8 | |||
| dab01b7f2b |
+11
-4
@@ -350,6 +350,10 @@ graph TD
|
||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
|
||||
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
||||
| `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store |
|
||||
| `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override |
|
||||
| `lib/codex-cli.js` | Shells out to the `codex` CLI (argv array, never a shell string) for the three surfaces with no on-disk reader: MCP servers (`mcp list\|add\|remove`), plugins (`plugin list\|add\|remove`), and marketplaces (`marketplace list\|add\|remove`). `runCodex()` parses stdout as JSON when possible; empty stdout on a zero-exit is a normal empty list, not a parse failure. A non-zero exit is returned as a structured failure (surfaced by the route as 422, never a 500) with `stdout`/`stderr` preserved |
|
||||
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
|
||||
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
|
||||
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live |
|
||||
@@ -597,10 +601,13 @@ graph LR
|
||||
DETAIL_R["/sessions/:id"] --> DETAIL[SessionDetail]
|
||||
ACT_R["/activity"] --> ACT[ActivityFeed]
|
||||
WF_R["/workflows"] --> WF[Workflows]
|
||||
CC_R["/cc-config"] --> CC[CcConfig]
|
||||
CC_R["/cc-config"] -->|redirect| SET_R
|
||||
LANES_R["/lanes"] -->|redirect| RUN_R["/run"]
|
||||
RUN_R --> WORKSPACE["Workspace<br/>lanes + runs merged"]
|
||||
SET_R["/settings"] --> SET[Settings]
|
||||
SET_R["/settings"] --> SETHUB["SettingsHub<br/>(?tool= tab shell)"]
|
||||
SETHUB --> SETDASH["dashboard tab<br/>Settings"]
|
||||
SETHUB --> SETCC["claude-code tab<br/>CcConfig"]
|
||||
SETHUB --> SETCODEX["codex tab<br/>CodexConfig"]
|
||||
NF_R["/*"] --> NF[NotFound]
|
||||
|
||||
ALL["All routes"] --> LAYOUT["Layout wrapper<br/>(Sidebar + Outlet)"]
|
||||
@@ -613,9 +620,9 @@ graph LR
|
||||
| `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Dashboard) stay tooltip-only |
|
||||
| `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` |
|
||||
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
||||
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
|
||||
| `/cc-config` | *(redirect)* | Redirects to `/settings?tool=claude-code` — the config explorer moved under the Settings hub as one of its per-tool panels (see `/settings` below) |
|
||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title |
|
||||
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
|
||||
| `/settings` | SettingsHub | Tabbed shell (`?tool=` query param picks the active tab, `PageTabs`) hosting the dashboard's own settings plus one configuration panel per AI tool. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs; hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages. **claude-code** tab (`?tool=claude-code`) → `CcConfig`: 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab (within CcConfig) leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. **codex** tab (`?tool=codex`) → `CodexConfig`: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is `"user"` \| `"project"` only — no `"project-local"`, no per-project file-based memory. Reads/mutates via `GET/PUT/DELETE /api/codex-config/*` (skills/agents/rules/memory/config are file-backed with the same backup-first write path as `cc-config`; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to `codex_config_changed` WS messages. Both config panels share a tool-agnostic component set under `client/src/components/config/` (`EditorModal`, `ConfirmDeleteModal`, `FileViewer`, `ToastNotice`, `MdItemList`, `ScopeBadge`, `configPrimitives`) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title |
|
||||
| `/*` | NotFound | None (static 404 page) |
|
||||
|
||||
### Activity Feed Interaction Model
|
||||
|
||||
@@ -2766,22 +2766,6 @@ async function cmdStage(args) {
|
||||
result: flag("result"),
|
||||
});
|
||||
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
|
||||
|
||||
// A stage name matching no node (nor alias) still stores — setStage takes the
|
||||
// string verbatim — but phaseIdx() then returns -1, so nothing renders as
|
||||
// `current` and progress reads 0. Warn, never fail: a typo must not break a
|
||||
// declaration the pipeline can still record, but it must not pass silently.
|
||||
// Skipped for `--result fail`, which paints the node `failed` rather than
|
||||
// `current` and would otherwise look identical to an unknown stage.
|
||||
const nodes = lane.pipeline_nodes || [];
|
||||
if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
|
||||
console.error(
|
||||
c.yellow(
|
||||
`! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
|
||||
`pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command catalog ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,8 +11,6 @@ export interface PageTab {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
/** Small muted suffix, e.g. "soon" for a tool that is not integrated yet. */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface PageTabsProps {
|
||||
@@ -34,7 +32,7 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
|
||||
aria-label={ariaLabel}
|
||||
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
|
||||
>
|
||||
{tabs.map(({ id, label, Icon, badge }) => {
|
||||
{tabs.map(({ id, label, Icon }) => {
|
||||
const isActive = id === activeId;
|
||||
return (
|
||||
<button
|
||||
@@ -51,7 +49,6 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
{badge && <span className="text-[10px] text-fg-muted">{badge}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @file ConfigTabBar.tsx
|
||||
* @description The tab strip both config explorers wear: one horizontally
|
||||
* scrollable row with fade edges and chevrons that appear only when there is
|
||||
* something to scroll to.
|
||||
*
|
||||
* Shared rather than copied because the two pages must look the same. Codex
|
||||
* originally wrapped its tabs onto a second row — fine at eight tabs, but it
|
||||
* read as a different component sitting next to Claude Code's twelve-tab
|
||||
* scroller. One row that scrolls is the same shape at any tab count.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ComponentType } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
export interface ConfigTab<K extends string> {
|
||||
key: K;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
/** Badge number, or null for tabs that count nothing (e.g. Overview). */
|
||||
count: number | null;
|
||||
}
|
||||
|
||||
interface ConfigTabBarProps<K extends string> {
|
||||
tabs: ConfigTab<K>[];
|
||||
current: K;
|
||||
onSelect: (key: K) => void;
|
||||
/** `data-testid` becomes `<prefix>-<key>`, e.g. "cc-tab-plugins". */
|
||||
testIdPrefix: string;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export function ConfigTabBar<K extends string>({
|
||||
tabs,
|
||||
current,
|
||||
onSelect,
|
||||
testIdPrefix,
|
||||
ariaLabel,
|
||||
}: ConfigTabBarProps<K>) {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
// Update scroll affordances when content size or scroll position changes.
|
||||
const updateAffordances = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 1);
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateAffordances();
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("scroll", updateAffordances, { passive: true });
|
||||
const ro = new ResizeObserver(updateAffordances);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", updateAffordances);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [updateAffordances]);
|
||||
|
||||
// Scroll the active tab into view when it changes (e.g. user picks a tab
|
||||
// that's offscreen, or window resize hides the active one).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const active = el.querySelector<HTMLElement>('[data-tab-active="true"]');
|
||||
if (!active) return;
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const activeRect = active.getBoundingClientRect();
|
||||
if (activeRect.left < elRect.left + 8) {
|
||||
el.scrollBy({ left: activeRect.left - elRect.left - 16, behavior: "smooth" });
|
||||
} else if (activeRect.right > elRect.right - 8) {
|
||||
el.scrollBy({ left: activeRect.right - elRect.right + 16, behavior: "smooth" });
|
||||
}
|
||||
}, [current]);
|
||||
|
||||
const scrollByButton = (dir: 1 | -1) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: dir * Math.max(200, el.clientWidth * 0.6), behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative rounded-xl border border-border bg-surface-1">
|
||||
{/* Left edge gradient + chevron */}
|
||||
<div
|
||||
className={`pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 ${
|
||||
canScrollLeft ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
onClick={() => scrollByButton(-1)}
|
||||
aria-label="scroll tabs left"
|
||||
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{tabs.map(({ key, icon: Icon, label, count }) => {
|
||||
const active = current === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
data-testid={`${testIdPrefix}-${key}`}
|
||||
data-tab-active={active ? "true" : undefined}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(key)}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap ${
|
||||
active
|
||||
? "bg-accent/15 text-accent border border-accent/30"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{label}</span>
|
||||
{count !== null && (
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full font-semibold ${
|
||||
active ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right edge gradient + chevron */}
|
||||
<div
|
||||
className={`pointer-events-none absolute right-0 top-0 bottom-0 w-12 rounded-r-xl bg-gradient-to-l from-surface-1 to-transparent transition-opacity z-10 ${
|
||||
canScrollRight ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{canScrollRight && (
|
||||
<button
|
||||
onClick={() => scrollByButton(1)}
|
||||
aria-label="scroll tabs right"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 rounded-md w-7 h-7 flex items-center justify-center bg-surface-2 border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file ConfirmDeleteModal.tsx
|
||||
* @description Confirmation modal for deleting a config artifact. Tool-
|
||||
* agnostic — the caller supplies the async delete action; the modal only
|
||||
* tracks the busy state and Escape-to-cancel behavior.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert, Trash2 } from "lucide-react";
|
||||
import type { CcArtifactType } from "../../lib/api";
|
||||
|
||||
/** See EditorModal.tsx's `DefaultScope` — same reasoning: Codex instantiates
|
||||
* `TScope` as `"user" | "project"` instead of casting into Claude's wider
|
||||
* default. */
|
||||
export type ConfirmDeleteState<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = "user" | "project" | "auto-memory",
|
||||
> = {
|
||||
type: TType;
|
||||
scope: TScope;
|
||||
name?: string;
|
||||
path: string;
|
||||
project?: string; // set for type === "auto-memory"
|
||||
} | null;
|
||||
|
||||
interface ConfirmDeleteModalProps<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = "user" | "project" | "auto-memory",
|
||||
> {
|
||||
state: NonNullable<ConfirmDeleteState<TType, TScope>>;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConfirmDeleteModal<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = "user" | "project" | "auto-memory",
|
||||
>({ state, onCancel, onConfirm }: ConfirmDeleteModalProps<TType, TScope>) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [onConfirm]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 text-status-danger" />
|
||||
<span className="text-sm font-medium text-fg-primary flex-1">
|
||||
{t("edit.confirmDelete")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-xs text-fg-secondary leading-relaxed">{t("edit.confirmDeleteBody")}</p>
|
||||
<div className="rounded-md border border-border bg-surface-2 px-3 py-2 font-mono text-[11px] text-fg-secondary break-all">
|
||||
{t("edit.confirmDeletePath", { path: state.path })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
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 disabled:opacity-60"
|
||||
>
|
||||
{t("edit.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={busy}
|
||||
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-status-danger/50 bg-status-danger/15 hover:bg-status-danger/25 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-60"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{busy ? t("edit.deleting") : t("edit.confirmDeleteAction")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file EditorModal.tsx
|
||||
* @description Create/edit modal for text-file config artifacts (skills,
|
||||
* agents, commands, output styles, memory). Tool-agnostic: it takes a
|
||||
* `fetchContent` callback instead of reaching for any particular tool's API,
|
||||
* so the same modal serves both the Claude Code and Codex config pages.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, X, Save, AlertCircle } from "lucide-react";
|
||||
import type { CcArtifactType } from "../../lib/api";
|
||||
|
||||
/** Claude Code's own scope union, kept as the default so existing callers
|
||||
* (CcConfig.tsx) don't need to name it. Codex has no `auto-memory` scope —
|
||||
* its caller instantiates `TScope` as just `"user" | "project"` instead of
|
||||
* casting its own narrower type into this one. */
|
||||
type DefaultScope = "user" | "project" | "auto-memory";
|
||||
|
||||
export type EditorState<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = DefaultScope,
|
||||
> =
|
||||
| {
|
||||
mode: "create";
|
||||
type: TType;
|
||||
defaultScope: "user" | "project";
|
||||
template: string;
|
||||
project?: string; // set for type === "auto-memory"
|
||||
}
|
||||
| {
|
||||
mode: "edit";
|
||||
type: TType;
|
||||
scope: TScope;
|
||||
name: string;
|
||||
filePath: string;
|
||||
project?: string; // set for type === "auto-memory"
|
||||
}
|
||||
| null;
|
||||
|
||||
/** Types that are a single fixed file per scope (e.g. Claude's CLAUDE.md, Codex's
|
||||
* AGENTS.md / config.toml) - no `name` field, and no artifact name is ever sent. */
|
||||
const DEFAULT_NAMELESS_TYPES = ["memory"];
|
||||
|
||||
interface EditorModalProps<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = DefaultScope,
|
||||
> {
|
||||
state: NonNullable<EditorState<TType, TScope>>;
|
||||
fetchContent: (filePath: string) => Promise<string>;
|
||||
onClose: () => void;
|
||||
onSave: (args: {
|
||||
type: TType;
|
||||
targetScope: TScope;
|
||||
name: string | undefined;
|
||||
content: string;
|
||||
project?: string;
|
||||
}) => Promise<void>;
|
||||
/** Which `state.type` values are nameless singleton files. Defaults to
|
||||
* Claude Code's own `["memory"]` so existing callers are unaffected. */
|
||||
namelessTypes?: readonly string[];
|
||||
}
|
||||
|
||||
export function EditorModal<
|
||||
TType extends string = CcArtifactType,
|
||||
TScope extends string = DefaultScope,
|
||||
>({
|
||||
state,
|
||||
fetchContent,
|
||||
onClose,
|
||||
onSave,
|
||||
namelessTypes = DEFAULT_NAMELESS_TYPES,
|
||||
}: EditorModalProps<TType, TScope>) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const isCreate = state.mode === "create";
|
||||
const isNameless = namelessTypes.includes(state.type);
|
||||
const isAutoMemory = state.type === "auto-memory";
|
||||
const [content, setContent] = useState<string>(isCreate ? state.template : "");
|
||||
const [name, setName] = useState<string>("");
|
||||
const [targetScope, setTargetScope] = useState<"user" | "project">(
|
||||
isCreate
|
||||
? state.defaultScope
|
||||
: state.scope === "auto-memory"
|
||||
? "user"
|
||||
: (state.scope as "user" | "project")
|
||||
);
|
||||
const [loading, setLoading] = useState(!isCreate);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// For edit mode, fetch the actual file content
|
||||
useEffect(() => {
|
||||
if (state.mode === "edit") {
|
||||
setLoading(true);
|
||||
fetchContent(state.filePath)
|
||||
.then((text) => {
|
||||
setContent(text);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "unknown";
|
||||
setError(msg);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [state, fetchContent]);
|
||||
|
||||
// Esc to close
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!isNameless && state.mode === "create" && !name) {
|
||||
setError(t("edit.nameLabel"));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
// For auto-memory creates, append a .md extension when the user omits it.
|
||||
const createName = isAutoMemory && !/\.md$/i.test(name) ? `${name}.md` : name;
|
||||
const effectiveName =
|
||||
state.mode === "edit" ? state.name : isNameless ? undefined : createName;
|
||||
await onSave({
|
||||
type: state.type,
|
||||
// Widening back to TScope: this component is the one place that
|
||||
// legitimately knows about all three scope shapes across both
|
||||
// callers; a Codex caller's TScope ("user" | "project") never
|
||||
// actually receives "auto-memory" since isAutoMemory can only be
|
||||
// true when TType includes "auto-memory" (Claude-only).
|
||||
targetScope: (isAutoMemory ? "auto-memory" : targetScope) as TScope,
|
||||
name: effectiveName,
|
||||
content,
|
||||
project: state.project,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "unknown";
|
||||
setError(t("edit.writeError", { message: msg }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [state, targetScope, name, content, isAutoMemory, onSave, t]);
|
||||
|
||||
const titleText = isCreate
|
||||
? isAutoMemory
|
||||
? t("memory.newFileTitle", { project: state.project ?? "" })
|
||||
: t("edit.newTitle", { type: state.type })
|
||||
: t("edit.editTitle", { name: state.mode === "edit" ? state.name : "" });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl 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">
|
||||
<Pencil className="w-4 h-4 text-fg-muted" />
|
||||
<span className="text-sm font-medium text-fg-primary flex-1 truncate">{titleText}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-4 space-y-3">
|
||||
{isCreate && !isNameless && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
||||
{t("edit.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={
|
||||
isAutoMemory ? t("memory.namePlaceholder") : t("edit.namePlaceholder")
|
||||
}
|
||||
pattern={isAutoMemory ? undefined : "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"}
|
||||
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"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{isAutoMemory ? t("memory.nameHelp") : t("edit.nameHelp")}
|
||||
</p>
|
||||
</div>
|
||||
{isAutoMemory ? (
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
||||
{t("memory.projectLabel")}
|
||||
</label>
|
||||
<div className="rounded-md border border-border bg-surface-2 px-3 py-1.5 font-mono text-[11px] text-fg-secondary truncate">
|
||||
{state.project}
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("memory.projectHelp")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
||||
{t("edit.scopePicker")}
|
||||
</label>
|
||||
<div className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
|
||||
{(["user", "project"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setTargetScope(s)}
|
||||
className={`px-3 py-1 text-[11px] font-medium rounded ${
|
||||
targetScope === s
|
||||
? "bg-accent/20 text-accent border border-accent/30"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`scope.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
|
||||
{t("edit.contentLabel")}
|
||||
</label>
|
||||
{loading ? (
|
||||
<div className="h-72 rounded-md border border-border bg-surface-2 animate-pulse" />
|
||||
) : (
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full h-72 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted 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("edit.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
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("edit.saving") : t("edit.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @file FileViewer.tsx
|
||||
* @description Read-only file-content modal for the config explorer. Takes
|
||||
* already-fetched data as props (path/data/error) rather than fetching
|
||||
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
|
||||
* config page) owns the fetch.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, X, AlertCircle } from "lucide-react";
|
||||
import type { CcFileResponse } from "../../lib/api";
|
||||
import { CopyButton } from "./configPrimitives";
|
||||
|
||||
export function FileViewer({
|
||||
state,
|
||||
onClose,
|
||||
}: {
|
||||
state: { path: string; data: CcFileResponse | null; error: string | null };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl max-h-[85vh] 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">
|
||||
<FileText className="w-4 h-4 text-fg-muted" />
|
||||
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
|
||||
{state.path}
|
||||
</span>
|
||||
<CopyButton value={state.path} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto p-4">
|
||||
{state.error ? (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{state.error}
|
||||
</div>
|
||||
) : !state.data ? (
|
||||
<div className="text-sm text-fg-muted">…</div>
|
||||
) : (
|
||||
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
|
||||
{state.data.text}
|
||||
{state.data.truncated && (
|
||||
<span className="text-fg-muted italic">
|
||||
{"\n\n"}
|
||||
{t("common.truncated")}
|
||||
</span>
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @file MdItemList.tsx
|
||||
* @description Generic list + card for markdown-frontmatter config artifacts
|
||||
* (skills, agents, commands, output styles). Tool-agnostic: callers pass the
|
||||
* items and the open/edit/delete handlers, nothing here reaches for an API.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
|
||||
import type { CcArtifactType, CcMdItem } from "../../lib/api";
|
||||
import { ScopeBadge } from "./ScopeBadge";
|
||||
import { Empty, SkeletonRows } from "./configPrimitives";
|
||||
|
||||
interface MdItemListProps {
|
||||
items: CcMdItem[] | null;
|
||||
search: string;
|
||||
onOpen: (path: string) => void;
|
||||
onEdit: (
|
||||
type: CcArtifactType,
|
||||
item: { scope: "user" | "project"; name: string; filePath: string }
|
||||
) => void;
|
||||
onDelete: (
|
||||
type: CcArtifactType,
|
||||
scope: "user" | "project",
|
||||
name: string | undefined,
|
||||
path: string
|
||||
) => void;
|
||||
kind: "skills" | "agents" | "commands" | "outputStyles";
|
||||
}
|
||||
|
||||
export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
|
||||
const filtered = useMemo(() => {
|
||||
if (!items) return null;
|
||||
const q = search.toLowerCase();
|
||||
return items.filter((it) => {
|
||||
if (!q) return true;
|
||||
const blob = [it.name, it.frontmatter.description, it.frontmatter.name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return blob.includes(q);
|
||||
});
|
||||
}, [items, search]);
|
||||
|
||||
if (!filtered) return <SkeletonRows n={6} />;
|
||||
if (filtered.length === 0) return <Empty />;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((it) => (
|
||||
<MdItemCard
|
||||
key={`${it.scope}:${it.name}`}
|
||||
item={it}
|
||||
onOpen={onOpen}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
kind={kind}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MdItemCardProps {
|
||||
item: CcMdItem;
|
||||
onOpen: (p: string) => void;
|
||||
onEdit: (
|
||||
type: CcArtifactType,
|
||||
item: { scope: "user" | "project"; name: string; filePath: string }
|
||||
) => void;
|
||||
onDelete: (
|
||||
type: CcArtifactType,
|
||||
scope: "user" | "project",
|
||||
name: string | undefined,
|
||||
path: string
|
||||
) => void;
|
||||
kind: "skills" | "agents" | "commands" | "outputStyles";
|
||||
}
|
||||
|
||||
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
|
||||
|
||||
const filePath = item.file || `${item.path}/SKILL.md`;
|
||||
const description =
|
||||
item.frontmatter.description ||
|
||||
item.preview
|
||||
.replace(/^#+\s.*\n/, "")
|
||||
.trim()
|
||||
.slice(0, 200);
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-fg-primary truncate">{item.name}</span>
|
||||
<ScopeBadge scope={item.scope} />
|
||||
{item.frontmatter.model && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/10 text-accent border border-accent/20">
|
||||
{item.frontmatter.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1.5 text-xs text-fg-secondary leading-relaxed line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{kind === "agents" && item.frontmatter.tools && (
|
||||
<div className="mt-2 text-[11px] text-fg-muted">
|
||||
<span className="text-fg-muted">{t("agents.tools")}:</span>{" "}
|
||||
<span className="font-mono text-fg-secondary">{item.frontmatter.tools}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 font-mono text-[10px] text-fg-muted truncate">{filePath}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onOpen(filePath)}
|
||||
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{t("common.viewSource")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(artifactType, { scope: item.scope, name: item.name, filePath })}
|
||||
className="text-[11px] font-medium px-2 py-1 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
{t("edit.editButton")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(artifactType, item.scope, item.name, filePath)}
|
||||
className="text-[11px] font-medium px-2 py-1 rounded-md border border-status-danger/30 bg-status-danger/5 hover:bg-status-danger/15 text-status-danger inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t("edit.deleteButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,32 @@
|
||||
/**
|
||||
* @file ScopeBadge.tsx
|
||||
* @description Small colored badge for a config artifact's scope (user /
|
||||
* project / project-local). Tool-agnostic — takes a plain string so it works
|
||||
* for Claude Code and Codex scopes alike.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function ScopeBadge({ scope }: { scope: string }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const color =
|
||||
scope === "user"
|
||||
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
||||
: scope === "project"
|
||||
? "bg-status-success/10 text-status-success border-status-success/30"
|
||||
: scope === "project-local"
|
||||
? "bg-violet-500/10 text-violet-300 border-violet-500/30"
|
||||
: "bg-surface-3 text-fg-secondary border-border";
|
||||
const label =
|
||||
scope === "project-local"
|
||||
? t("scope.projectLocal")
|
||||
: scope === "user"
|
||||
? t("scope.user")
|
||||
: scope === "project"
|
||||
? t("scope.project")
|
||||
: scope;
|
||||
return (
|
||||
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{label}</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file ToastNotice.tsx
|
||||
* @description Bottom-right success/error toast for the config explorer.
|
||||
* Tool-agnostic — the page owns the 5s auto-dismiss timer and passes the
|
||||
* message down.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { AlertCircle, Check, X } from "lucide-react";
|
||||
|
||||
export type Toast = { kind: "success" | "error"; message: string } | null;
|
||||
|
||||
export function ToastNotice({
|
||||
toast,
|
||||
onDismiss,
|
||||
}: {
|
||||
toast: NonNullable<Toast>;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const isErr = toast.kind === "error";
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 max-w-md">
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${
|
||||
isErr
|
||||
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
|
||||
: "border-status-success/40 bg-status-success/10 text-status-success"
|
||||
}`}
|
||||
>
|
||||
{isErr ? (
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<span className="text-xs leading-relaxed flex-1 break-all">{toast.message}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-current/70 hover:text-current p-0.5"
|
||||
aria-label="dismiss"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file EditorModal.test.tsx
|
||||
* @description Unit tests for the tool-agnostic `EditorModal`: it must load
|
||||
* existing content through the injected `fetchContent` callback rather than
|
||||
* a hardcoded API, and must refuse to save a create with no name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { EditorModal } from "../EditorModal";
|
||||
|
||||
describe("EditorModal", () => {
|
||||
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
|
||||
const fetchContent = vi.fn().mockResolvedValue("old body");
|
||||
render(
|
||||
<EditorModal
|
||||
state={{
|
||||
mode: "edit",
|
||||
type: "rules",
|
||||
scope: "user",
|
||||
name: "default",
|
||||
filePath: "/r/default.rules",
|
||||
}}
|
||||
fetchContent={fetchContent}
|
||||
onClose={() => {}}
|
||||
onSave={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(fetchContent).toHaveBeenCalledWith("/r/default.rules");
|
||||
expect(await screen.findByDisplayValue("old body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses to save a create with no name", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(
|
||||
<EditorModal
|
||||
state={{ mode: "create", type: "agents", defaultScope: "user", template: "" }}
|
||||
fetchContent={async () => ""}
|
||||
onClose={() => {}}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file configPrimitives.tsx
|
||||
* @description Small tool-agnostic atoms shared by the config-explorer UI
|
||||
* (Claude Code today, Codex next): an empty-state card, a skeleton-row list
|
||||
* placeholder, a copy-to-clipboard button, and a byte-size formatter. None
|
||||
* of these know about any particular tool's API.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
export function CopyButton({ value }: { value: string }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
|
||||
title={t("common.copyPath")}
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty() {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("common.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonRows({ n }: { n: number }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<div key={i} className="h-16 rounded-lg border border-border bg-surface-2 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @file LanePipelinePanel.tsx
|
||||
* @description How often each node of this dashboard's OWN pipelines actually
|
||||
* gets passed, aggregated over every lane and every archived feature by
|
||||
* gets passed, aggregated over every recorded run by
|
||||
* `GET /api/lanes/pipeline-stats`. One row per node: a bar split into passed
|
||||
* with evidence / passed without / failed, plus the raw counts, so a stage that
|
||||
* everyone claims but nobody proves is visible at a glance. A lane picker
|
||||
* narrows the same aggregation to one lane and its archived features.
|
||||
* with evidence / passed without / failed / reached but only inferred, plus the
|
||||
* raw counts, so a stage that everyone claims but nobody proves is visible at a
|
||||
* glance. A lane picker narrows the same aggregation to one lane.
|
||||
* Read-only — the live per-lane view and every lane action stay on the
|
||||
* Workspace page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -101,7 +101,9 @@ export function LanePipelinePanel() {
|
||||
);
|
||||
}
|
||||
|
||||
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered), 1);
|
||||
// Detected-only reaches share the scale: a node the hooks saw fifty times and
|
||||
// nobody ever declared should read as busy, not as empty.
|
||||
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered + n.detected), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -127,6 +129,7 @@ export function LanePipelinePanel() {
|
||||
<LegendSwatch className="bg-status-success" label={t("lanes.legendEvidence")} />
|
||||
<LegendSwatch className="bg-status-warning" label={t("lanes.legendClaimed")} />
|
||||
<LegendSwatch className="bg-status-danger" label={t("lanes.legendFailed")} />
|
||||
<LegendSwatch className="bg-fg-muted/60" label={t("lanes.legendDetected")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,11 +181,23 @@ export function LanePipelinePanel() {
|
||||
style={{ width: `${pct(n.failed, maxEntered)}%` }}
|
||||
title={t("lanes.legendFailed")}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-fg-muted/60"
|
||||
style={{ width: `${pct(n.detected, maxEntered)}%` }}
|
||||
title={t("lanes.legendDetected")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="w-28 shrink-0 text-right tabular-nums text-fg-muted">
|
||||
{n.entered === 0 ? (
|
||||
t("lanes.never")
|
||||
// Reached but never declared still beats "never reached" — it
|
||||
// is the difference between "you skip this stage" and "you do
|
||||
// it without saying so".
|
||||
n.detected > 0 ? (
|
||||
t("lanes.detectedOnly", { count: n.detected })
|
||||
) : (
|
||||
t("lanes.never")
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="text-fg-secondary">{n.passed}</span>/{n.entered}
|
||||
|
||||
@@ -29,6 +29,7 @@ const node = (id: string, over: Record<string, number | string> = {}) => ({
|
||||
passed: 0,
|
||||
withEvidence: 0,
|
||||
failed: 0,
|
||||
detected: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -114,6 +115,24 @@ describe("LanePipelinePanel", () => {
|
||||
|
||||
const ship = await screen.findByTestId("pipeline-stat-ship");
|
||||
expect(ship).not.toHaveTextContent("%");
|
||||
expect(ship).toHaveTextContent(/never reached/i);
|
||||
});
|
||||
|
||||
it("separates a node the hooks saw from one that never happened", async () => {
|
||||
// `gate` was reached in two runs but never declared. Reporting that as
|
||||
// "never reached" is what made the panel look broken to someone who works
|
||||
// without running the pipeline commands.
|
||||
statsMock.mockResolvedValue({
|
||||
...STATS,
|
||||
pipelines: STATS.pipelines.map((p, i) =>
|
||||
i === 0 ? { ...p, nodes: [...p.nodes, node("gate", { detected: 2 })] } : p
|
||||
),
|
||||
});
|
||||
render(<LanePipelinePanel />);
|
||||
|
||||
const gate = await screen.findByTestId("pipeline-stat-gate");
|
||||
expect(gate).not.toHaveTextContent(/never reached/i);
|
||||
expect(gate).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
it("switches templates through the picker", async () => {
|
||||
|
||||
@@ -83,6 +83,8 @@ import updates_en from "./locales/en/updates.json";
|
||||
import updates_vi from "./locales/vi/updates.json";
|
||||
import ccConfig_en from "./locales/en/ccConfig.json";
|
||||
import ccConfig_vi from "./locales/vi/ccConfig.json";
|
||||
import codexConfig_en from "./locales/en/codexConfig.json";
|
||||
import codexConfig_vi from "./locales/vi/codexConfig.json";
|
||||
import run_en from "./locales/en/run.json";
|
||||
import run_vi from "./locales/vi/run.json";
|
||||
import alerts_en from "./locales/en/alerts.json";
|
||||
@@ -110,6 +112,7 @@ i18n
|
||||
errors: errors_en,
|
||||
updates: updates_en,
|
||||
ccConfig: ccConfig_en,
|
||||
codexConfig: codexConfig_en,
|
||||
run: run_en,
|
||||
alerts: alerts_en,
|
||||
splash: splash_en,
|
||||
@@ -128,6 +131,7 @@ i18n
|
||||
errors: errors_vi,
|
||||
updates: updates_vi,
|
||||
ccConfig: ccConfig_vi,
|
||||
codexConfig: codexConfig_vi,
|
||||
run: run_vi,
|
||||
alerts: alerts_vi,
|
||||
splash: splash_vi,
|
||||
@@ -150,6 +154,7 @@ i18n
|
||||
"errors",
|
||||
"updates",
|
||||
"ccConfig",
|
||||
"codexConfig",
|
||||
"run",
|
||||
"alerts",
|
||||
"splash",
|
||||
|
||||
@@ -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",
|
||||
@@ -190,7 +209,7 @@
|
||||
"saveSuccessNew": "Created.",
|
||||
"deleteSuccess": "Deleted · backup at {{path}}",
|
||||
"confirmDelete": "Delete this item?",
|
||||
"confirmDeleteBody": "This will move the file (or skill folder, including bundled assets) to a timestamped backup before removing it. The backup lives outside the directories Claude Code scans, so the entry will disappear from your CLI.",
|
||||
"confirmDeleteBody": "This will move the file (or skill folder, including bundled assets) to a timestamped backup before removing it. The backup lives outside the directories the CLI scans, so the entry will disappear from your CLI.",
|
||||
"confirmDeletePath": "Path: {{path}}",
|
||||
"confirmDeleteAction": "Yes, back up and delete",
|
||||
"templates": {
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"title": "Codex Config Explorer",
|
||||
"subtitle": "Your Codex CLI setup at a glance - skills, agents, rules, AGENTS.md memory, config.toml, MCP servers, plugins and marketplaces.",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing…",
|
||||
"lastUpdated": "Last updated {{time}}",
|
||||
"loadError": "Failed to load: {{message}}",
|
||||
"scope": {
|
||||
"all": "All scopes",
|
||||
"user": "User",
|
||||
"project": "Project"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"skills": "Skills",
|
||||
"agents": "Agents",
|
||||
"rules": "Rules",
|
||||
"memory": "Memory",
|
||||
"config": "Config",
|
||||
"mcp": "MCP servers",
|
||||
"plugins": "Plugins"
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Roots",
|
||||
"codexHome": "Codex home",
|
||||
"projectCodexDir": "Project .codex",
|
||||
"projectRoot": "Project root",
|
||||
"summary": "Quick summary",
|
||||
"user": "user",
|
||||
"project": "project"
|
||||
},
|
||||
"common": {
|
||||
"search": "Search by name…",
|
||||
"clearSearch": "Clear search",
|
||||
"close": "Close"
|
||||
},
|
||||
"list": {
|
||||
"newButton": "New",
|
||||
"viewButton": "View source",
|
||||
"editButton": "Edit",
|
||||
"deleteButton": "Delete"
|
||||
},
|
||||
"edit": {
|
||||
"saveSuccess": "Saved · {{path}}",
|
||||
"saveSuccessNew": "Created.",
|
||||
"deleteSuccess": "Deleted · {{path}}",
|
||||
"deleteError": "Delete failed: {{message}}",
|
||||
"templates": {
|
||||
"skills": "---\nname: my-skill\ndescription: One-line description (shown when Codex picks the skill)\n---\n\nBody - what the skill does, when to use it, examples.\n",
|
||||
"agents": "name = \"my-agent\"\ndescription = \"When to invoke this agent\"\n\ninstructions = \"\"\"\nSystem prompt for the agent.\n\"\"\"\n",
|
||||
"rules": "# Rules\n\nCodex follows these rules for this scope.\n",
|
||||
"memory": "# Project memory\n\nCodex reads this on session start.\n",
|
||||
"config": "# config.toml\n"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"mcp": {
|
||||
"addTitle": "Add an MCP server",
|
||||
"nameLabel": "Name",
|
||||
"modeCommand": "Command",
|
||||
"modeUrl": "URL",
|
||||
"commandLabel": "Command",
|
||||
"argsLabel": "Args (space-separated)",
|
||||
"urlLabel": "URL",
|
||||
"bearerLabel": "Bearer token env var (optional)",
|
||||
"addButton": "Add",
|
||||
"removeButton": "Remove",
|
||||
"added": "Added {{name}}.",
|
||||
"removed": "Removed {{name}}."
|
||||
},
|
||||
"plugins": {
|
||||
"installTitle": "Install a plugin",
|
||||
"installPlaceholder": "name or name@marketplace",
|
||||
"installButton": "Add",
|
||||
"removeButton": "Remove",
|
||||
"installed": "Installed {{name}}.",
|
||||
"removed": "Removed {{name}}.",
|
||||
"noPlugins": "No plugins installed.",
|
||||
"marketplacesTitle": "Marketplaces",
|
||||
"addMarketplaceTitle": "Add a marketplace",
|
||||
"addMarketplacePlaceholder": "https://github.com/owner/repo",
|
||||
"addMarketplaceButton": "Add",
|
||||
"marketplaceAdded": "Added marketplace.",
|
||||
"marketplaceRemoved": "Removed marketplace {{name}}."
|
||||
},
|
||||
"manage": {
|
||||
"working": "Working…",
|
||||
"noIdentifier": "No stable identifier for this entry - remove it from the CLI directly."
|
||||
},
|
||||
"cli": {
|
||||
"unavailable": "The codex CLI isn't available on this machine ({{message}}) - install it or add it to PATH to manage this."
|
||||
},
|
||||
"backups": {
|
||||
"openButton": "Backups",
|
||||
"title": "Backups",
|
||||
"subtitle": "Every save / delete writes a copy here first. Backup folders live outside the directories the CLI scans, so they don't reappear as ghosts in your CLI.",
|
||||
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
|
||||
"restoreHint": "Restore by renaming the backup back into place:"
|
||||
},
|
||||
"snippet": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
}
|
||||
}
|
||||
@@ -445,10 +445,6 @@
|
||||
"label": "Tools",
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"cursor": "Cursor",
|
||||
"soonBadge": "soon",
|
||||
"comingSoon": "{{tool}} is not integrated yet",
|
||||
"comingSoonDesc": "Its configuration will show up here once support lands."
|
||||
"codex": "Codex"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +246,9 @@
|
||||
"never": "never reached",
|
||||
"copyCommand": "Copy: {{command}}",
|
||||
"empty": "No lane has recorded a stage yet. Create one with",
|
||||
"emptyLane": "This lane has not recorded a stage yet."
|
||||
"emptyLane": "This lane has not recorded a stage yet.",
|
||||
"legendDetected": "reached, not declared",
|
||||
"detectedOnly": "detected ×{{count}}"
|
||||
},
|
||||
"errorPropagation": {
|
||||
"title": "Error Propagation Map",
|
||||
|
||||
@@ -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",
|
||||
@@ -185,7 +204,7 @@
|
||||
"saveSuccessNew": "Đã tạo.",
|
||||
"deleteSuccess": "Đã xoá · bản sao tại {{path}}",
|
||||
"confirmDelete": "Xoá mục này?",
|
||||
"confirmDeleteBody": "Tệp (hoặc thư mục skill cùng tài nguyên đi kèm) sẽ được sao lưu kèm dấu thời gian trước khi xoá. Bản sao nằm ngoài thư mục Claude Code quét, nên mục này sẽ biến mất khỏi CLI.",
|
||||
"confirmDeleteBody": "Tệp (hoặc thư mục skill cùng tài nguyên đi kèm) sẽ được sao lưu kèm dấu thời gian trước khi xoá. Bản sao nằm ngoài thư mục CLI quét, nên mục này sẽ biến mất khỏi CLI.",
|
||||
"confirmDeletePath": "Đường dẫn: {{path}}",
|
||||
"confirmDeleteAction": "Xác nhận sao lưu và xoá",
|
||||
"templates": {
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"title": "Trình khám phá cấu hình Codex",
|
||||
"subtitle": "Toàn cảnh thiết lập Codex CLI của bạn - skill, agent, rule, bộ nhớ AGENTS.md, config.toml, máy chủ MCP, plugin và marketplace.",
|
||||
"refresh": "Làm mới",
|
||||
"refreshing": "Đang làm mới…",
|
||||
"lastUpdated": "Cập nhật lần cuối {{time}}",
|
||||
"loadError": "Tải thất bại: {{message}}",
|
||||
"scope": {
|
||||
"all": "Tất cả phạm vi",
|
||||
"user": "Người dùng",
|
||||
"project": "Dự án"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Tổng quan",
|
||||
"skills": "Skill",
|
||||
"agents": "Agent",
|
||||
"rules": "Rule",
|
||||
"memory": "Bộ nhớ",
|
||||
"config": "Cấu hình",
|
||||
"mcp": "Máy chủ MCP",
|
||||
"plugins": "Plugin"
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Thư mục gốc",
|
||||
"codexHome": "Thư mục Codex",
|
||||
"projectCodexDir": "Thư mục .codex của dự án",
|
||||
"projectRoot": "Thư mục gốc dự án",
|
||||
"summary": "Tóm tắt nhanh",
|
||||
"user": "người dùng",
|
||||
"project": "dự án"
|
||||
},
|
||||
"common": {
|
||||
"search": "Tìm theo tên…",
|
||||
"clearSearch": "Xóa tìm kiếm",
|
||||
"close": "Đóng"
|
||||
},
|
||||
"list": {
|
||||
"newButton": "Mới",
|
||||
"viewButton": "Xem nguồn",
|
||||
"editButton": "Sửa",
|
||||
"deleteButton": "Xóa"
|
||||
},
|
||||
"edit": {
|
||||
"saveSuccess": "Đã lưu · {{path}}",
|
||||
"saveSuccessNew": "Đã tạo.",
|
||||
"deleteSuccess": "Đã xóa · {{path}}",
|
||||
"deleteError": "Xóa thất bại: {{message}}",
|
||||
"templates": {
|
||||
"skills": "---\nname: my-skill\ndescription: Mô tả một dòng (hiển thị khi Codex chọn skill)\n---\n\nNội dung - skill làm gì, khi nào dùng, ví dụ.\n",
|
||||
"agents": "name = \"my-agent\"\ndescription = \"Khi nào gọi agent này\"\n\ninstructions = \"\"\"\nSystem prompt cho agent.\n\"\"\"\n",
|
||||
"rules": "# Rules\n\nCodex tuân theo các quy tắc này ở phạm vi này.\n",
|
||||
"memory": "# Bộ nhớ dự án\n\nCodex đọc tệp này khi bắt đầu phiên làm việc.\n",
|
||||
"config": "# config.toml\n"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"mcp": {
|
||||
"addTitle": "Thêm máy chủ MCP",
|
||||
"nameLabel": "Tên",
|
||||
"modeCommand": "Lệnh",
|
||||
"modeUrl": "URL",
|
||||
"commandLabel": "Lệnh",
|
||||
"argsLabel": "Tham số (cách nhau bằng dấu cách)",
|
||||
"urlLabel": "URL",
|
||||
"bearerLabel": "Biến môi trường bearer token (tùy chọn)",
|
||||
"addButton": "Thêm",
|
||||
"removeButton": "Gỡ bỏ",
|
||||
"added": "Đã thêm {{name}}.",
|
||||
"removed": "Đã gỡ bỏ {{name}}."
|
||||
},
|
||||
"plugins": {
|
||||
"installTitle": "Cài đặt plugin",
|
||||
"installPlaceholder": "tên hoặc tên@marketplace",
|
||||
"installButton": "Thêm",
|
||||
"removeButton": "Gỡ bỏ",
|
||||
"installed": "Đã cài đặt {{name}}.",
|
||||
"removed": "Đã gỡ bỏ {{name}}.",
|
||||
"noPlugins": "Chưa cài đặt plugin nào.",
|
||||
"marketplacesTitle": "Marketplace",
|
||||
"addMarketplaceTitle": "Thêm marketplace",
|
||||
"addMarketplacePlaceholder": "https://github.com/owner/repo",
|
||||
"addMarketplaceButton": "Thêm",
|
||||
"marketplaceAdded": "Đã thêm marketplace.",
|
||||
"marketplaceRemoved": "Đã gỡ bỏ marketplace {{name}}."
|
||||
},
|
||||
"manage": {
|
||||
"working": "Đang xử lý…",
|
||||
"noIdentifier": "Mục này không có định danh ổn định - hãy gỡ trực tiếp từ CLI."
|
||||
},
|
||||
"cli": {
|
||||
"unavailable": "CLI codex không có sẵn trên máy này ({{message}}) - cài đặt hoặc thêm vào PATH để quản lý mục này."
|
||||
},
|
||||
"backups": {
|
||||
"openButton": "Bản sao lưu",
|
||||
"title": "Bản sao lưu",
|
||||
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí CLI quét, nên không xuất hiện như mục thừa trong CLI.",
|
||||
"empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.",
|
||||
"restoreHint": "Khôi phục bằng cách đổi tên bản sao về vị trí cũ:"
|
||||
},
|
||||
"snippet": {
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép"
|
||||
}
|
||||
}
|
||||
@@ -445,10 +445,6 @@
|
||||
"label": "Công cụ",
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"cursor": "Cursor",
|
||||
"soonBadge": "sắp có",
|
||||
"comingSoon": "{{tool}} chưa được tích hợp",
|
||||
"comingSoonDesc": "Cấu hình của công cụ này sẽ hiện ở đây khi có hỗ trợ."
|
||||
"codex": "Codex"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +246,9 @@
|
||||
"never": "chưa từng tới",
|
||||
"copyCommand": "Sao chép: {{command}}",
|
||||
"empty": "Chưa lane nào ghi nhận giai đoạn. Tạo lane bằng",
|
||||
"emptyLane": "Lane này chưa ghi nhận giai đoạn nào."
|
||||
"emptyLane": "Lane này chưa ghi nhận giai đoạn nào.",
|
||||
"legendDetected": "đã chạm, chưa khai báo",
|
||||
"detectedOnly": "suy đoán ×{{count}}"
|
||||
},
|
||||
"errorPropagation": {
|
||||
"title": "Bản đồ lan truyền lỗi",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file Pins the codexConfig API routes and payloads. Tests that the client
|
||||
* sends the right method, URL, and body shape to the Codex config backend.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { api } from "../api";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubFetch(response: unknown) {
|
||||
const spy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => "application/json" },
|
||||
json: async () => response,
|
||||
text: async () => JSON.stringify(response),
|
||||
});
|
||||
vi.stubGlobal("fetch", spy);
|
||||
return spy;
|
||||
}
|
||||
|
||||
describe("api.codexConfig", () => {
|
||||
it("sends a write as a single PUT with the artifact envelope", async () => {
|
||||
const spy = stubFetch({ ok: true, file: "/x", created: true });
|
||||
|
||||
await api.codexConfig.write({
|
||||
scope: "user",
|
||||
type: "rules",
|
||||
name: "default",
|
||||
content: "allow",
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const [, options] = spy.mock.calls[0] as [string, RequestInit | undefined];
|
||||
expect(options?.method).toBe("PUT");
|
||||
expect(JSON.parse(options?.body as string)).toEqual({
|
||||
scope: "user",
|
||||
type: "rules",
|
||||
name: "default",
|
||||
content: "allow",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
*
|
||||
@@ -1609,6 +1649,191 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
// ─────────────────────────────── Codex-Config API ──────────────────────────────
|
||||
/** Read/write access to on-disk Codex CLI configuration - skills, agents,
|
||||
* rules, memory (AGENTS.md), config.toml, MCP servers, plugins, and
|
||||
* marketplaces - for the dashboard's "Codex Config" explorer/editor pages. */
|
||||
codexConfig: {
|
||||
/**
|
||||
* GET /api/codex-config/overview - counts of every artifact kind, for the
|
||||
* explorer's landing page.
|
||||
* @returns {@link CodexOverview} — filesystem roots plus per-kind counts.
|
||||
*/
|
||||
overview: () => request<CodexOverview>("/codex-config/overview"),
|
||||
/**
|
||||
* GET /api/codex-config/skills - user and/or project skill definitions.
|
||||
*
|
||||
* The optional `scope` is appended as `?scope=` only when provided; omitting
|
||||
* it lets the server apply its default scope.
|
||||
*
|
||||
* @param scope Optional {@link CodexScope} ("user"|"project"|"all") filter.
|
||||
* @returns `{ skills }` — the {@link CodexSkill} items for each skill.
|
||||
*/
|
||||
skills: (scope?: CodexScope) =>
|
||||
request<{ skills: CodexSkill[] }>(`/codex-config/skills${scope ? `?scope=${scope}` : ""}`),
|
||||
/**
|
||||
* GET /api/codex-config/agents - user and/or project agent definitions.
|
||||
* @param scope Optional {@link CodexScope} filter.
|
||||
* @returns `{ agents }` — {@link CodexAgent} items for each agent.
|
||||
*/
|
||||
agents: (scope?: CodexScope) =>
|
||||
request<{ agents: CodexAgent[] }>(`/codex-config/agents${scope ? `?scope=${scope}` : ""}`),
|
||||
/**
|
||||
* GET /api/codex-config/rules - user and/or project rules files.
|
||||
* @param scope Optional {@link CodexScope} filter.
|
||||
* @returns `{ rules }` — {@link CodexRule} items for each rule file.
|
||||
*/
|
||||
rules: (scope?: CodexScope) =>
|
||||
request<{ rules: CodexRule[] }>(`/codex-config/rules${scope ? `?scope=${scope}` : ""}`),
|
||||
/**
|
||||
* GET /api/codex-config/memory - AGENTS.md files at both scopes.
|
||||
* @returns `{ memory }` — {@link CodexMemoryFile} items.
|
||||
*/
|
||||
memory: () => request<{ memory: CodexMemoryFile[] }>("/codex-config/memory"),
|
||||
/**
|
||||
* GET /api/codex-config/config - config.toml files at both scopes.
|
||||
* @returns `{ config }` — {@link CodexConfigFile} items.
|
||||
*/
|
||||
config: () => request<{ config: CodexConfigFile[] }>("/codex-config/config"),
|
||||
/**
|
||||
* GET /api/codex-config/file - raw contents of one config file by absolute path.
|
||||
*
|
||||
* The absolute path is passed as a URL-encoded `path` query param (not a
|
||||
* path segment) so arbitrary filesystem paths survive intact.
|
||||
*
|
||||
* @param path Absolute path of the config file to read.
|
||||
* @returns {@link CodexFileResponse} — file text + metadata.
|
||||
*/
|
||||
file: (path: string) =>
|
||||
request<CodexFileResponse>(`/codex-config/file?path=${encodeURIComponent(path)}`),
|
||||
/**
|
||||
* PUT /api/codex-config/file - create/overwrite a config artifact; the
|
||||
* server writes a backup of any previous content first.
|
||||
*
|
||||
* @param args {@link CodexWriteArgs} — scope/type/name/content.
|
||||
* @returns {@link CodexMutationResult} — the written path and whether a new file was created.
|
||||
*/
|
||||
write: (args: CodexWriteArgs) =>
|
||||
request<CodexMutationResult>("/codex-config/file", {
|
||||
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).
|
||||
*
|
||||
* @param args {@link CodexDeleteArgs} — which artifact to delete.
|
||||
* @returns {@link CodexMutationResult} — the deleted path and its backup path.
|
||||
*/
|
||||
remove: (args: CodexDeleteArgs) =>
|
||||
request<CodexMutationResult>("/codex-config/file", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(args),
|
||||
}),
|
||||
/**
|
||||
* GET /api/codex-config/backups - timestamped backups written by `write`/
|
||||
* `remove`, optionally filtered by scope/artifact type.
|
||||
*
|
||||
* @param params Optional `{ scope, type }` filter for the backup listing.
|
||||
* @returns `{ items }` — the matching {@link CodexBackup} entries.
|
||||
*/
|
||||
backups: (params?: { scope?: "user" | "project"; type?: CodexArtifactType }) =>
|
||||
requestCodexBackupsHelper(params),
|
||||
/**
|
||||
* GET /api/codex-config/mcp - configured MCP servers from the codex CLI.
|
||||
* Coerces the CLI's JSON output (which may be an object) to an array.
|
||||
* @returns `{ mcp }` — {@link CodexMcpServer} items.
|
||||
*/
|
||||
mcp: async (): Promise<{ mcp: CodexMcpServer[] }> => {
|
||||
const res = await request<{ mcp: unknown }>("/codex-config/mcp");
|
||||
return { mcp: Array.isArray(res.mcp) ? (res.mcp as CodexMcpServer[]) : [] };
|
||||
},
|
||||
/**
|
||||
* GET /api/codex-config/plugins - installed plugins from the codex CLI.
|
||||
* Coerces the CLI's JSON output (which may be an object) to an array.
|
||||
* @returns `{ plugins }` — {@link CodexPlugin} items.
|
||||
*/
|
||||
plugins: async (): Promise<{ plugins: CodexPlugin[] }> => {
|
||||
const res = await request<{ plugins: unknown }>("/codex-config/plugins");
|
||||
return { plugins: Array.isArray(res.plugins) ? (res.plugins as CodexPlugin[]) : [] };
|
||||
},
|
||||
/**
|
||||
* GET /api/codex-config/marketplaces - registered marketplaces from the codex CLI.
|
||||
* Coerces the CLI's JSON output (which may be an object) to an array.
|
||||
* @returns `{ marketplaces }` — {@link CodexMarketplace} items.
|
||||
*/
|
||||
marketplaces: async (): Promise<{ marketplaces: CodexMarketplace[] }> => {
|
||||
const res = await request<{ marketplaces: unknown }>("/codex-config/marketplaces");
|
||||
return {
|
||||
marketplaces: Array.isArray(res.marketplaces)
|
||||
? (res.marketplaces as CodexMarketplace[])
|
||||
: [],
|
||||
};
|
||||
},
|
||||
/**
|
||||
* POST /api/codex-config/mcp/add - add an MCP server via the `codex` CLI.
|
||||
* @param args {@link CodexMcpAddArgs} for `codex mcp add`.
|
||||
* @returns `{ ok, stdout, stderr }` — success and CLI output.
|
||||
*/
|
||||
mcpAdd: (args: CodexMcpAddArgs) =>
|
||||
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/mcp/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(args),
|
||||
}),
|
||||
/**
|
||||
* POST /api/codex-config/mcp/remove - remove an MCP server via the `codex` CLI.
|
||||
* @param name The MCP server name to remove.
|
||||
* @returns `{ ok, stdout, stderr }` — success and CLI output.
|
||||
*/
|
||||
mcpRemove: (name: string) =>
|
||||
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/mcp/remove", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
/**
|
||||
* POST /api/codex-config/plugins/action - add or remove a plugin via the `codex` CLI.
|
||||
* @param args {@link CodexPluginActionArgs} with `spec` and `action` fields.
|
||||
* @returns `{ ok, stdout, stderr }` — success and CLI output.
|
||||
*/
|
||||
pluginAction: (args: CodexPluginActionArgs) =>
|
||||
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/plugins/action", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(args),
|
||||
}),
|
||||
/**
|
||||
* POST /api/codex-config/marketplaces/add - register a marketplace via the `codex` CLI.
|
||||
* @param url The marketplace URL to register.
|
||||
* @returns `{ ok, stdout, stderr }` — success and CLI output.
|
||||
*/
|
||||
marketplaceAdd: (url: string) =>
|
||||
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/marketplaces/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
/**
|
||||
* POST /api/codex-config/marketplaces/action - update or remove a marketplace via the `codex` CLI.
|
||||
* @param args {@link CodexMarketplaceActionArgs} with `name` and `action` fields.
|
||||
* @returns `{ ok, stdout, stderr }` — success and CLI output.
|
||||
*/
|
||||
marketplaceAction: (args: CodexMarketplaceActionArgs) =>
|
||||
request<{ ok: true; stdout: string; stderr: string }>("/codex-config/marketplaces/action", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(args),
|
||||
}),
|
||||
},
|
||||
|
||||
// ────────────────────────────────── Run API ─────────────────────────────────
|
||||
/** Spawn/manage headless or conversational `claude` CLI child processes
|
||||
* launched from the dashboard's Run page, and stream their output. */
|
||||
@@ -2230,6 +2455,23 @@ function requestBackupsHelper(params?: { scope?: "user" | "project"; type?: CcAr
|
||||
return request<{ items: CcBackup[] }>(`/cc-config/backups${q ? `?${q}` : ""}`);
|
||||
}
|
||||
|
||||
/** Helper for GET /api/codex-config/backups with optional `scope` and `type` filters.
|
||||
* Builds an optional `?scope=&type=` query string (each part appended only when
|
||||
* present) and calls {@link request} for GET /api/codex-config/backups.
|
||||
*
|
||||
* @param params Optional `{ scope, type }` filter for the backup listing.
|
||||
* @returns `{ items }` — the matching {@link CodexBackup} entries. */
|
||||
function requestCodexBackupsHelper(params?: {
|
||||
scope?: "user" | "project";
|
||||
type?: CodexArtifactType;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.scope) qs.set("scope", params.scope);
|
||||
if (params?.type) qs.set("type", params.type);
|
||||
const q = qs.toString();
|
||||
return request<{ items: CodexBackup[] }>(`/codex-config/backups${q ? `?${q}` : ""}`);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CC-Config types — request/response shapes for the "CC Config" explorer/editor.
|
||||
// These describe on-disk Claude Code configuration artifacts (skills, agents,
|
||||
@@ -2438,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"`. */
|
||||
@@ -2458,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 "*"). */
|
||||
@@ -2675,6 +2944,185 @@ export interface CcHookScripts {
|
||||
items: { name: string; file: string; size: number; mtime: number }[];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Codex-Config types — request/response shapes for the "Codex Config" explorer/editor.
|
||||
// These describe on-disk Codex CLI configuration artifacts (skills, agents, rules,
|
||||
// memory, config, MCP servers, plugins, marketplaces) as surfaced by the
|
||||
// /api/codex-config/* routes. They live in this client because they are specific
|
||||
// to the explorer UI.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind of Codex config artifact manageable via `api.codexConfig.write`/
|
||||
* `remove` - each maps to a distinct on-disk location. */
|
||||
export type CodexArtifactType = "skills" | "agents" | "rules" | "memory" | "config";
|
||||
|
||||
/** Scope filter accepted by most `codexConfig` list endpoints; "all" merges
|
||||
* user + project scope in one response. */
|
||||
export type CodexScope = "user" | "project" | "all";
|
||||
|
||||
/** Body for PUT /api/codex-config/file - create or overwrite one artifact. */
|
||||
export interface CodexWriteArgs {
|
||||
scope: "user" | "project";
|
||||
type: CodexArtifactType;
|
||||
/** Artifact name (e.g. skill/agent/rule name); omitted for singleton
|
||||
* artifacts like memory/config. */
|
||||
name?: string;
|
||||
/** Full file contents to write. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Body for DELETE /api/codex-config/file - remove one artifact. */
|
||||
export interface CodexDeleteArgs {
|
||||
scope: "user" | "project";
|
||||
type: CodexArtifactType;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Response shape of a successful `codexConfig.write`/`remove` call. */
|
||||
export interface CodexMutationResult {
|
||||
file: string;
|
||||
created?: boolean;
|
||||
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;
|
||||
name: string;
|
||||
file: string;
|
||||
mtime: number;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
/** A discovered skill: a directory containing a SKILL.md under skills/<name>/. */
|
||||
export interface CodexSkill {
|
||||
scope: "user" | "project";
|
||||
name: string;
|
||||
path: string;
|
||||
file: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
truncated: boolean;
|
||||
frontmatter: Record<string, string>;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
/** A discovered agent definition (TOML file). */
|
||||
export interface CodexAgent {
|
||||
scope: "user" | "project";
|
||||
name: string;
|
||||
file: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
meta: Record<string, unknown>;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
/** A discovered rules file. */
|
||||
export interface CodexRule {
|
||||
scope: "user" | "project";
|
||||
name: string;
|
||||
file: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
/** AGENTS.md file at either user or project scope. */
|
||||
export interface CodexMemoryFile {
|
||||
scope: "user" | "project";
|
||||
file: string;
|
||||
exists: boolean;
|
||||
size?: number;
|
||||
mtime?: number;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
/** config.toml file at either user or project scope. */
|
||||
export interface CodexConfigFile {
|
||||
scope: "user" | "project";
|
||||
file: string;
|
||||
exists: boolean;
|
||||
size?: number;
|
||||
mtime?: number;
|
||||
content?: string;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/** Response shape of GET /api/codex-config/file - full contents of one config artifact. */
|
||||
export interface CodexFileResponse {
|
||||
ok: true;
|
||||
file: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
truncated: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Response shape of GET /api/codex-config/overview. */
|
||||
export interface CodexOverview {
|
||||
roots: {
|
||||
codexHome: string;
|
||||
projectCodexDir: string;
|
||||
projectRoot: string;
|
||||
};
|
||||
counts: {
|
||||
skills: { user: number; project: number };
|
||||
agents: { user: number; project: number };
|
||||
rules: { user: number; project: number };
|
||||
memory: { user: number; project: number };
|
||||
config: { user: number; project: number };
|
||||
};
|
||||
}
|
||||
|
||||
/** One configured MCP server, from GET /api/codex-config/mcp. */
|
||||
export interface CodexMcpServer {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** One installed plugin, from GET /api/codex-config/plugins. */
|
||||
export interface CodexPlugin {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** One registered marketplace, from GET /api/codex-config/marketplaces. */
|
||||
export interface CodexMarketplace {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arguments for POST /api/codex-config/mcp/add. A union, not one optional-heavy
|
||||
* shape: `codex mcp add` takes either a URL or a command and rejects both or
|
||||
* neither, and `env` is stdio-only. Modelling that here turns three of the
|
||||
* server's `EBADARG` responses into compile errors.
|
||||
*/
|
||||
export type CodexMcpAddArgs =
|
||||
| { name: string; url: string; bearerTokenEnvVar?: string }
|
||||
| { name: string; command: string; args?: string[]; env?: Record<string, string> };
|
||||
|
||||
/** Arguments for POST /api/codex-config/plugins/action. */
|
||||
export interface CodexPluginActionArgs {
|
||||
spec: string;
|
||||
action: "add" | "remove";
|
||||
}
|
||||
|
||||
/** Arguments for POST /api/codex-config/marketplaces/action. */
|
||||
export interface CodexMarketplaceActionArgs {
|
||||
name: string;
|
||||
action: "remove";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Run types — request/response shapes for the Run page's `claude` process
|
||||
// spawning/management. `RunMode`/`RunStatus`/`PermissionMode`/`EffortLevel`
|
||||
|
||||
+34
-4
@@ -545,6 +545,7 @@
|
||||
* - `run_stream` / `run_status` / `run_input_ack` ... {@link RunStreamPayload} /
|
||||
* {@link RunStatusPayload} / {@link RunInputAckPayload}
|
||||
* - `cc_config_changed` ............................ {@link CcConfigChangedPayload}
|
||||
* - `codex_config_changed` ......................... {@link CodexConfigChangedPayload}
|
||||
* - `alert_triggered` / `alert_updated` ............ {@link AlertEvent}
|
||||
* - `workflow_upserted` ............................ {@link WorkflowRun}
|
||||
*
|
||||
@@ -1331,6 +1332,27 @@ export interface CcConfigChangedPayload {
|
||||
paths?: string[];
|
||||
}
|
||||
|
||||
// ───── Codex config change notifications ─────
|
||||
|
||||
/** Payload for the `codex_config_changed` WebSocket message: broadcast
|
||||
* whenever a Codex CLI config artifact (skill, agent, rule, memory, config,
|
||||
* MCP server, plugin, marketplace) is written or removed through the
|
||||
* dashboard's codex-config editor, so other open tabs can refresh their
|
||||
* view. Codex has no filesystem watcher counterpart to `cc-watcher.js`, so
|
||||
* unlike {@link CcConfigChangedPayload} this is always dashboard-sourced. */
|
||||
export interface CodexConfigChangedPayload {
|
||||
source: "dashboard";
|
||||
/** "write"/"delete" for file mutations; "add"/"remove" for the CLI-driven
|
||||
* mcp/plugin/marketplace routes. */
|
||||
action?: "write" | "delete" | "add" | "remove";
|
||||
/** "user" or "project" - Codex has no "project-local" scope. */
|
||||
scope?: "user" | "project";
|
||||
/** Artifact kind, e.g. "skills", "agents", "rules", "memory", "config",
|
||||
* or "mcp"/"plugin"/"marketplace" for the CLI-driven routes. */
|
||||
type?: string;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
// ───── Alerting ─────
|
||||
// User-defined rules that watch the ingest stream (or a periodic sweep) and fire
|
||||
// {@link AlertEvent}s. {@link AlertRuleConfig} is a tagged bag of settings whose
|
||||
@@ -1624,6 +1646,7 @@ export interface WSMessage {
|
||||
* new_event → DashboardEvent; import.progress → ImportProgressMessage;
|
||||
* update_status → UpdateStatusPayload; run_stream/run_status/run_input_ack
|
||||
* → their matching Run*Payload; cc_config_changed → CcConfigChangedPayload;
|
||||
* codex_config_changed → CodexConfigChangedPayload;
|
||||
* alert_triggered/alert_updated → AlertEvent; workflow_upserted → WorkflowRun;
|
||||
* remote_source.status → RemoteSourceStatusPayload;
|
||||
* remote_data.updated → RemoteDataUpdatedPayload;
|
||||
@@ -1640,6 +1663,7 @@ export interface WSMessage {
|
||||
| "run_status"
|
||||
| "run_input_ack"
|
||||
| "cc_config_changed"
|
||||
| "codex_config_changed"
|
||||
| "alert_triggered"
|
||||
| "alert_updated"
|
||||
| "workflow_upserted"
|
||||
@@ -1661,6 +1685,7 @@ export interface WSMessage {
|
||||
| UpdateStatusPayload
|
||||
| RunStatusPayload
|
||||
| CcConfigChangedPayload
|
||||
| CodexConfigChangedPayload
|
||||
| AlertEvent
|
||||
| WorkflowRun
|
||||
| RemoteSourceStatusPayload
|
||||
@@ -2482,9 +2507,9 @@ export interface LaneCounts {
|
||||
}
|
||||
|
||||
/**
|
||||
* How often one pipeline node was actually passed, summed over every lane and
|
||||
* every archived feature. `passed + failed === entered`; `withEvidence` is the
|
||||
* subset of passes that left an artifact, so a claim never reads as a proof.
|
||||
* How often one pipeline node was actually passed, summed over every recorded
|
||||
* run. `passed + failed === entered`; `withEvidence` is the subset of passes
|
||||
* that left an artifact, so a claim never reads as a proof.
|
||||
*/
|
||||
export interface PipelineNodeStat {
|
||||
id: string;
|
||||
@@ -2498,6 +2523,10 @@ export interface PipelineNodeStat {
|
||||
passed: number;
|
||||
withEvidence: number;
|
||||
failed: number;
|
||||
/** Runs that reached this node by inference from the hook stream only, with
|
||||
* no declaration. Deliberately outside `entered`: it says the work happened,
|
||||
* not that the gate was met. */
|
||||
detected: number;
|
||||
}
|
||||
|
||||
/** Per-node pass counts, grouped by pipeline template (busiest first). */
|
||||
@@ -2505,7 +2534,8 @@ export interface PipelineStats {
|
||||
pipelines: {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Lanes + archived features recorded against this template. */
|
||||
/** Passes through this template that recorded at least one stage. A lane
|
||||
* that ships three features contributes three. */
|
||||
runs: number;
|
||||
nodes: PipelineNodeStat[];
|
||||
}[];
|
||||
|
||||
+263
-743
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,22 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus per-AI-tool configuration panels (Claude Code today, Codex/Cursor reserved), with the active tab kept in the `?tool=` query param.
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex), with the active tab kept in the `?tool=` query param.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Boxes, Settings as SettingsIcon, SquareTerminal, MousePointerClick } from "lucide-react";
|
||||
import { Boxes, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
|
||||
interface ToolTab {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
/** Omit for tools that are not integrated yet - the tab renders a placeholder. */
|
||||
Panel?: ComponentType;
|
||||
Panel: ComponentType;
|
||||
}
|
||||
|
||||
const DASHBOARD_TAB: ToolTab = {
|
||||
@@ -30,8 +30,7 @@ const DASHBOARD_TAB: ToolTab = {
|
||||
const TOOL_TABS: ToolTab[] = [
|
||||
DASHBOARD_TAB,
|
||||
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
|
||||
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal },
|
||||
{ id: "cursor", labelKey: "tools.cursor", Icon: MousePointerClick },
|
||||
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -50,24 +49,14 @@ export function SettingsHub() {
|
||||
ariaLabel={t("tools.label", "Tools")}
|
||||
activeId={active.id}
|
||||
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
|
||||
tabs={TOOL_TABS.map(({ id, labelKey, Icon, Panel }) => ({
|
||||
tabs={TOOL_TABS.map(({ id, labelKey, Icon }) => ({
|
||||
id,
|
||||
Icon,
|
||||
label: t(labelKey),
|
||||
...(Panel ? {} : { badge: t("tools.soonBadge", "soon") }),
|
||||
}))}
|
||||
/>
|
||||
|
||||
{active.Panel ? (
|
||||
<active.Panel />
|
||||
) : (
|
||||
<div className="card p-8 text-center space-y-1">
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{t("tools.comingSoon", { tool: t(active.labelKey) })}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted">{t("tools.comingSoonDesc")}</p>
|
||||
</div>
|
||||
)}
|
||||
<active.Panel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @file CodexConfig.test.tsx
|
||||
* @description Covers the two load-bearing behaviors of the Codex config
|
||||
* panel: create/edit/delete is offered on the authored surfaces (skills,
|
||||
* agents, rules, memory, config) and withheld on the CLI-owned ones (mcp,
|
||||
* plugins); and saves go through `api.codexConfig`, not the Claude client.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// vi.mock factories are hoisted above the rest of the module, so the mock
|
||||
// object they reference must be created inside vi.hoisted() too.
|
||||
const codexConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
overview: r({
|
||||
roots: { codexHome: "/home/.codex", projectCodexDir: "/repo/.codex", projectRoot: "/repo" },
|
||||
counts: {
|
||||
skills: { user: 0, project: 0 },
|
||||
agents: { user: 0, project: 0 },
|
||||
rules: { user: 0, project: 0 },
|
||||
memory: { user: 0, project: 0 },
|
||||
config: { user: 0, project: 0 },
|
||||
},
|
||||
}),
|
||||
skills: r({ skills: [] }),
|
||||
agents: r({ agents: [] }),
|
||||
rules: r({ rules: [] }),
|
||||
memory: r({ memory: [] }),
|
||||
config: r({ config: [] }),
|
||||
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
||||
write: r({ file: "/f", created: true }),
|
||||
remove: r({ file: "/f" }),
|
||||
mcp: r({ mcp: [] }),
|
||||
plugins: r({ plugins: [] }),
|
||||
marketplaces: r({ marketplaces: [] }),
|
||||
mcpAdd: r({ ok: true, stdout: "", stderr: "" }),
|
||||
mcpRemove: r({ ok: true, stdout: "", stderr: "" }),
|
||||
pluginAction: r({ ok: true, stdout: "", stderr: "" }),
|
||||
marketplaceAdd: r({ ok: true, stdout: "", stderr: "" }),
|
||||
marketplaceAction: r({ ok: true, stdout: "", stderr: "" }),
|
||||
backups: r({ items: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...(actual.api as Record<string, unknown>),
|
||||
codexConfig: codexConfigMock,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { CodexConfig } from "../CodexConfig";
|
||||
|
||||
/** Reset the mock to its defaults, then apply the given overrides (each
|
||||
* value is wrapped in a resolved-promise mock unless already a mock fn). */
|
||||
function stubCodexApi(overrides: {
|
||||
skills?: unknown[];
|
||||
rules?: { scope: "user" | "project"; name: string; file: string; size: number }[];
|
||||
mcp?: { servers?: unknown[] };
|
||||
write?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
codexConfigMock.skills.mockResolvedValue({ skills: overrides.skills ?? [] });
|
||||
codexConfigMock.rules.mockResolvedValue({ rules: overrides.rules ?? [] });
|
||||
codexConfigMock.mcp.mockResolvedValue({ mcp: overrides.mcp?.servers ?? [] });
|
||||
if (overrides.write) codexConfigMock.write = overrides.write as never;
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
codexConfigMock.write = vi.fn().mockResolvedValue({ file: "/f", created: true }) as never;
|
||||
codexConfigMock.remove = vi.fn().mockResolvedValue({ file: "/f" }) as never;
|
||||
});
|
||||
|
||||
describe("CodexConfig", () => {
|
||||
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
|
||||
stubCodexApi({ skills: [], mcp: { servers: [] } });
|
||||
render(<CodexConfig />);
|
||||
await settle();
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
|
||||
expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
|
||||
expect(screen.queryByRole("button", { name: /new/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes an edited rule back through the codex API, not the Claude one", async () => {
|
||||
const write = vi.fn().mockResolvedValue({ file: "/r/default.rules", created: false });
|
||||
stubCodexApi({
|
||||
rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }],
|
||||
write,
|
||||
});
|
||||
render(<CodexConfig />);
|
||||
await settle();
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await settle();
|
||||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
expect(write).toHaveBeenCalledWith(expect.objectContaining({ type: "rules", name: "default" }));
|
||||
});
|
||||
|
||||
it("keeps the file-backed tabs working when the codex CLI read rejects", async () => {
|
||||
stubCodexApi({ skills: [{ scope: "user", name: "my-skill", file: "/s/my-skill", size: 3 }] });
|
||||
codexConfigMock.mcp.mockRejectedValueOnce(new Error("the codex CLI was not found on PATH"));
|
||||
render(<CodexConfig />);
|
||||
await settle();
|
||||
|
||||
// No page-wide error banner - the CLI-backed rejection is isolated.
|
||||
expect(screen.queryByText(/failed to load/i)).not.toBeInTheDocument();
|
||||
|
||||
// Pure file reads (skills) still rendered their data.
|
||||
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
|
||||
expect(screen.getByText("my-skill")).toBeInTheDocument();
|
||||
|
||||
// The CLI-owned tab degrades gracefully instead of spinning forever.
|
||||
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
|
||||
expect(screen.getByText(/codex cli isn't available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a nameless-type artifact (config.toml) without requiring a name", async () => {
|
||||
const write = vi.fn().mockResolvedValue({ file: "/c/config.toml", created: true });
|
||||
stubCodexApi({ write });
|
||||
render(<CodexConfig />);
|
||||
await settle();
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: /config/i }));
|
||||
const newButtons = screen.getAllByRole("button", { name: /new/i });
|
||||
await userEvent.click(newButtons[0]!);
|
||||
await settle();
|
||||
|
||||
// No name field, no client-side "Name" validation error blocking save.
|
||||
expect(screen.queryByPlaceholderText(/name/i)).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "config", name: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the backups modal and lists what the codex API returns", async () => {
|
||||
codexConfigMock.backups.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
type: "rules",
|
||||
name: "default.2026-09-08.bak",
|
||||
file: "/r/default.bak",
|
||||
mtime: 0,
|
||||
size: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<CodexConfig />);
|
||||
await settle();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /backups/i }));
|
||||
await settle();
|
||||
|
||||
expect(screen.getByText("default.2026-09-08.bak")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -80,11 +80,6 @@ class ObserverStub {
|
||||
}
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
|
||||
@@ -936,13 +936,17 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
class="pointer-events-none absolute left-0 top-0 bottom-0 w-12 rounded-l-xl bg-gradient-to-r from-surface-1 to-transparent transition-opacity z-10 opacity-0"
|
||||
/>
|
||||
<div
|
||||
aria-label="Claude Config Explorer"
|
||||
class="flex gap-1 p-1 overflow-x-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
style="scrollbar-width: none;"
|
||||
>
|
||||
<button
|
||||
aria-selected="true"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap bg-accent/15 text-accent border border-accent/30"
|
||||
data-tab-active="true"
|
||||
data-testid="cc-tab-overview"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-boxes w-3.5 h-3.5"
|
||||
@@ -998,8 +1002,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-skills"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-sparkles w-3.5 h-3.5"
|
||||
@@ -1039,8 +1045,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-agents"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-user-round w-3.5 h-3.5"
|
||||
@@ -1073,8 +1081,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-commands"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-slash w-3.5 h-3.5"
|
||||
@@ -1102,8 +1112,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-memory"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-book-open w-3.5 h-3.5"
|
||||
@@ -1134,8 +1146,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-plugins"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plug-zap w-3.5 h-3.5"
|
||||
@@ -1175,8 +1189,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-marketplaces"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-store w-3.5 h-3.5"
|
||||
@@ -1216,8 +1232,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-mcp"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-server w-3.5 h-3.5"
|
||||
@@ -1270,8 +1288,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-hooks"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-webhook w-3.5 h-3.5"
|
||||
@@ -1305,8 +1325,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-keybindings"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-keyboard w-3.5 h-3.5"
|
||||
@@ -1362,8 +1384,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-settings"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-settings w-3.5 h-3.5"
|
||||
@@ -1396,8 +1420,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex-shrink-0 whitespace-nowrap text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
data-testid="cc-tab-outputStyles"
|
||||
role="tab"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-palette w-3.5 h-3.5"
|
||||
@@ -9511,7 +9537,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.4.6 (rebuild client to match)
|
||||
UI build v1.5.0 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -521,11 +521,6 @@ if (!window.matchMedia) {
|
||||
},
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
|
||||
// Flush pending promises (mocked API resolves) + effects so the loaded state
|
||||
// is rendered before snapshotting.
|
||||
|
||||
@@ -23,6 +23,13 @@ global.ResizeObserver = class {
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
/** jsdom implements no scrolling — stub the methods the tab bars call. */
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Pin locale to English — LanguageDetector may otherwise pick up zh/vi from the host OS. */
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
|
||||
+71
-9
@@ -611,14 +611,23 @@ Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`)
|
||||
GET /api/lanes/pipeline-stats
|
||||
```
|
||||
|
||||
How often each pipeline node was actually passed, aggregated over every lane
|
||||
**and** every archived feature (an archived feature is a finished run of a
|
||||
pipeline, so it carries most of the history once a lane starts its next
|
||||
feature). Grouped by pipeline template, busiest first.
|
||||
How often each pipeline node was actually passed, aggregated over every recorded
|
||||
run and grouped by pipeline template, busiest first.
|
||||
|
||||
Read from `lane_stage_events`, the append-only log every stage declaration
|
||||
writes — **not** from `lanes.stages`. That blob describes the pass in flight: it
|
||||
is overwritten when a node is re-entered and truncated when the lane moves
|
||||
backwards, so reading it counted one run per lane row no matter how many
|
||||
features that lane shipped, and no amount of use ever moved the numbers. A run
|
||||
is one pass through the template: the log starts a new one when the lane moves
|
||||
backwards or leaves the template's last node.
|
||||
|
||||
Templates configured on a lane are always listed, at zero, even before that lane
|
||||
records anything — a pipeline going unused has to be visible as such.
|
||||
|
||||
**Query parameters:**
|
||||
- **`lane`** — lane id; narrows the aggregation to that lane and its own
|
||||
archived features. Omit (or pass `all`) for every lane.
|
||||
- **`lane`** — lane id; narrows the aggregation to that lane's own runs. Omit
|
||||
(or pass `all`) for every lane.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -629,7 +638,7 @@ feature). Grouped by pipeline template, busiest first.
|
||||
"runs": 4,
|
||||
"nodes": [
|
||||
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false,
|
||||
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0 }
|
||||
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0, "detected": 0 }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -637,6 +646,11 @@ feature). Grouped by pipeline template, busiest first.
|
||||
}
|
||||
```
|
||||
|
||||
- **`detected`** — runs that reached the node by inference from the hook stream
|
||||
with no declaration. Counted apart from `entered`: it says the work happened,
|
||||
not that the gate was met. A node the hooks saw is reported as such rather
|
||||
than as "never reached", which is what a session that never runs the pipeline
|
||||
commands used to look like.
|
||||
- **`lanes`** — every lane, for the picker. Never narrowed by `lane`, so the
|
||||
response always offers the way back to the other lanes.
|
||||
- **`runs`** — lanes plus archived features recorded against this template.
|
||||
@@ -1560,7 +1574,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 +1601,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 +1618,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).
|
||||
|
||||
@@ -1619,6 +1640,38 @@ The dashboard **cannot log an account in** — only `claude auth login` mints to
|
||||
|
||||
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
|
||||
|
||||
### Codex Config Explorer
|
||||
|
||||
The `/api/codex-config/*` namespace powers the **Codex** tab of Settings (`/settings?tool=codex`) — the Codex CLI counterpart of the Claude Config Explorer above. Codex has a narrower scope union than Claude Code: `"user"` or `"project"` only, no `"project-local"` and no per-project file-based memory. Reads for skills/agents/rules/memory/config are pure file reads (`server/lib/codex-discovery.js`); MCP servers, plugins, and marketplaces have no on-disk reader and are read *and* mutated entirely through the `codex` CLI (`server/lib/codex-cli.js`), because the CLI owns `config.toml`'s schema.
|
||||
|
||||
```http
|
||||
GET /api/codex-config/overview
|
||||
GET /api/codex-config/skills?scope=user|project|all
|
||||
GET /api/codex-config/agents?scope=user|project|all
|
||||
GET /api/codex-config/rules?scope=user|project|all
|
||||
GET /api/codex-config/memory
|
||||
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
|
||||
GET /api/codex-config/marketplaces
|
||||
POST /api/codex-config/mcp/add Body: { name, url, bearerTokenEnvVar? } | { name, command, args?, env? }
|
||||
POST /api/codex-config/mcp/remove Body: { name }
|
||||
POST /api/codex-config/plugins/action Body: { spec, action: add|remove }
|
||||
POST /api/codex-config/marketplaces/add Body: { url }
|
||||
POST /api/codex-config/marketplaces/action Body: { name, action: remove }
|
||||
```
|
||||
|
||||
`type` is one of `skills`, `agents`, `rules`, `memory`, `config`. `name` is required for `skills`/`agents`/`rules` and forbidden for `memory`/`config` (each is a single fixed file per scope — `AGENTS.md` and `config.toml` respectively). `PUT`/`DELETE` always back the previous content up first (`server/lib/codex-mutate.js`) and always resolve `project` scope against the **dashboard server's own cwd** — unlike the read routes, there is no `?cwd=` override on a write, for the same reason `cc-config.js`'s `PATCH /settings` refuses one. Mutation errors: `EBADTYPE`/`EBADSCOPE`/`EBADNAME`/`EBADCONTENT`/`EOUTOFROOT` (400), `ETOOLARGE` (413), `ENOTFOUND` (404). Every successful write/delete broadcasts `codex_config_changed`.
|
||||
|
||||
`GET /mcp`, `/plugins`, `/marketplaces` proxy `codex mcp list --json` / `codex plugin list --json` / `codex plugin marketplace list --json`; a fresh install with none of a kind returns `{ mcp: [] }` etc. rather than a parse error. Unlike `mcp list --json` (a bare array), `plugin list --json` and `plugin marketplace list --json` return `{installed, available}` / `{marketplaces}` objects — `server/lib/codex-cli.js` unwraps those down to a plain array before the route ever sees them, so every list route's contract stays "the named field is a list"; the client's `Array.isArray` coercion is only a defensive fallback for CLI output that fails to parse at all. The add/remove routes shell out to `codex …` with an explicit argv array (never a shell string); a non-zero CLI exit is reported as **422 `ECLIEXIT`** with `stdout`/`stderr` preserved, never a 500. `POST /mcp/add` takes either `{ name, url, bearerTokenEnvVar? }` (HTTP transport) or `{ name, command, args?, env? }` (stdio transport) — never both, mirroring `codex mcp add`'s own mutually-exclusive flags. `POST /marketplaces/add`'s `url` field is misnamed for history but accepts the same source shapes as `codex plugin marketplace add`: `owner/repo`, an http(s) git URL, or an absolute local path (ssh remotes are out of scope). Other CLI errors: `EBADARG` (400), `ECLINOTFOUND` (503 — the `codex` binary isn't on `PATH`), `ETIMEOUT` (504).
|
||||
|
||||
Because `CodexMcpServer`/`CodexPlugin`/`CodexMarketplace` are opaque JSON blobs from the CLI (`[key: string]: unknown`) rather than a shape the dashboard controls, the Codex tab renders them as generic key/value cards instead of the richly-typed plugin/MCP cards the Claude Code tab shows.
|
||||
|
||||
### Run Claude via tmux+PTY
|
||||
|
||||
The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-backed pseudoterminals, one session per lane. The Workspace page renders an interactive xterm.js terminal attached to the session over `/ws-pty/:runId` (binary PTY frames, not JSON). Every route enforces a same-origin / loopback-Origin guard; browser requests must come from `localhost`, `127.0.0.1`, `::1`, or `0.0.0.0`. CLI / curl requests with no `Origin` header pass through. When `DASHBOARD_TOKEN` is set, a valid token is also required here (like the rest of `/api/*` — see [Authentication](#authentication)). **`tmux` must be installed on the dashboard server** (same operational requirement as better-sqlite3's native module).
|
||||
@@ -1837,6 +1890,15 @@ Broadcast whenever Claude Code configuration changes — either by dashboard mut
|
||||
{ "type": "cc_config_changed", "data": { "source": "fs", "paths": ["/Users/foo/.claude/settings.json"] } }
|
||||
```
|
||||
|
||||
#### codex_config_changed
|
||||
|
||||
The Codex counterpart of `cc_config_changed` — broadcast by `routes/codex-config.js` after every successful mutation: `PUT`/`DELETE /api/codex-config/file`, and the CLI-backed `mcp/add`, `mcp/remove`, `plugins/action`, `marketplaces/add`, `marketplaces/action` routes. Always `source: "dashboard"` — there is no filesystem watcher on `$CODEX_HOME` (unlike the Claude side's `cc-watcher.js`). The Codex Config page subscribes and refetches automatically.
|
||||
|
||||
```json
|
||||
{ "type": "codex_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "rules", "name": "default" } }
|
||||
{ "type": "codex_config_changed", "data": { "source": "dashboard", "action": "add", "type": "mcp", "name": "docs" } }
|
||||
```
|
||||
|
||||
#### remote_data.updated
|
||||
|
||||
Broadcast once per successful remote sync (background poller, manual **Sync now**, or immediate pull after add/re-enable). Clients use this — and the per-session `session_created` / `session_updated` frames emitted in the same pass — to refetch sessions, costs, and analytics without polling.
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
/\ \ /\ \ /\ \ /\ \
|
||||
/::\ \ /::\ \ /::\ \ /::\____\
|
||||
… (AgentHub word-mark) …
|
||||
AgentHub · interactive shell · v1.4.6 ● 127.0.0.1:4820
|
||||
AgentHub · interactive shell · v1.5.0 ● 127.0.0.1:4820
|
||||
Type commands without the 'agenthub' prefix — e.g. sessions --limit 5
|
||||
help all commands · help <cmd> details · Tab completes · ↑/↓ history · exit to quit
|
||||
|
||||
|
||||
+12
-2
@@ -687,8 +687,18 @@ things beyond the ordinary transition:
|
||||
The node the declaration lands on keeps its own record (its evidence and
|
||||
`result` survive), and `stage_since` restarts, so time-on-phase measures this
|
||||
pass. Records stored under a key matching **no** node in the pipeline are left
|
||||
untouched — their position is unknown, so they cannot be proven to be ahead;
|
||||
for the same reason, declaring a stage that matches no node prunes nothing.
|
||||
untouched — their position is unknown, so they cannot be proven to be ahead.
|
||||
Such keys only exist on lanes that predate validation: `POST
|
||||
/api/lanes/:id/stage` now rejects a stage naming no node of the lane's pipeline
|
||||
with **400 `EBADSTAGE`** and lists the ones that exist. Aliases still resolve,
|
||||
so this catches typos and names borrowed from another template — `agenthub
|
||||
stage test` on a `ship-feature` lane, whose CI node is `gates` — which used to
|
||||
be stored, warned about on stderr, and then counted nowhere.
|
||||
|
||||
Every declaration is also appended to `lane_stage_events`, the append-only log
|
||||
the pass-rate report reads. `lanes.stages` describes the pass in flight and is
|
||||
overwritten and truncated by the rules above; the log is what remembers that
|
||||
the previous pass happened at all.
|
||||
|
||||
Going back this way is a *declaration*, not a reset: the lane, its worktree, and
|
||||
its archived features are untouched. To start a genuinely separate piece of work
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Editable settings.json + MCP for the Claude Code Panel — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Turn the Claude Code panel's two read-only surfaces — `settings.json` and MCP servers — into surfaces you can change from `/settings?tool=claude-code`.
|
||||
|
||||
**Architecture:** The backend already does the dangerous half: `PATCH /api/cc-config/settings` writes through `patchSettings`, which validates every dotted key path, refuses prototype-polluting segments, rejects a payload still carrying the read path's `<redacted>` marker, backs the file up and writes atomically. So settings editing is a UI task against an existing, tested API. MCP is the opposite: `cc-mutate.js` excludes it on purpose, because the live `claude` CLI writes `~/.claude.json` concurrently and a dashboard write would race it. MCP therefore gets the same treatment plugins already get — mutation through the vendor CLI (`claude mcp add|remove`), never by editing the file.
|
||||
|
||||
**Tech Stack:** Node 22 + Express + `node:test`, React 18 + Vitest + react-i18next, `claude` CLI.
|
||||
|
||||
**Spec:** none written — scope fixed by the user in-session: "Mở thêm settings.json + MCP". This plan is the spec of record.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Every file created or modified gets the project header** with the exact author line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`.
|
||||
- **User-visible strings live in i18n JSON**, `en` and `vi` together.
|
||||
- **Never write `~/.claude.json` directly.** MCP changes go through the `claude` CLI, argv array, no shell.
|
||||
- **A value the read path redacted must never be written back.** `patchSettings`' `assertNoRedactionMarker` already enforces this; the UI must additionally render redacted fields as non-editable so the user is never invited to try.
|
||||
- **Depends on:** `2026-09-08-codex-config-panel.md` Task 8 (the extracted `components/config/` modals). Run that task first, or inline the modal locally and lift it later.
|
||||
- Backend tests: `npm run test:server`. Client tests: `cd client && npm test`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Editable settings.json
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/CcConfig.tsx` (the `SettingsPanel` / `SettingsBlock` components), `client/src/lib/api.ts` (add `api.ccConfig.patchSettings`), `client/src/i18n/locales/{en,vi}/ccConfig.json`
|
||||
- Test: `client/src/pages/__tests__/CcConfigSettings.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `PATCH /api/cc-config/settings` — body `{scope: "user" | "project" | "project-local", set: Record<string, unknown>, unset?: string[]}`, response `{ok, file, backup}`
|
||||
- Produces: `api.ccConfig.patchSettings(args: {scope, set, unset?}): Promise<{ok: boolean, file: string, backup: string | null}>`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```tsx
|
||||
it("sends only the edited key, as a dotted path", async () => {
|
||||
const patchSettings = vi.fn().mockResolvedValue({ ok: true, file: "/s.json", backup: "/s.bak" });
|
||||
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }], patchSettings });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /edit model/i }));
|
||||
await userEvent.clear(screen.getByRole("textbox"));
|
||||
await userEvent.type(screen.getByRole("textbox"), "sonnet");
|
||||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
expect(patchSettings).toHaveBeenCalledWith({ scope: "user", set: { model: "sonnet" } });
|
||||
});
|
||||
|
||||
it("will not let a redacted value be edited", () => {
|
||||
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { apiKey: "<redacted>" } }] });
|
||||
expect(screen.queryByRole("button", { name: /edit apiKey/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the backup it wrote, so the change is reversible in one glance", async () => {
|
||||
const patchSettings = vi.fn().mockResolvedValue({ ok: true, file: "/s.json", backup: "/s.json.2026.bak" });
|
||||
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }], patchSettings });
|
||||
await saveAnEdit();
|
||||
expect(await screen.findByText(/s\.json\.2026\.bak/)).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd client && npx vitest run src/pages/__tests__/CcConfigSettings.test.tsx`
|
||||
Expected: FAIL — no edit affordance exists
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
`SettingsBlock` renders each leaf with an inline edit control; the dotted path is built from the node's ancestry as it recurses. A value equal to `"<redacted>"` renders as a static chip with a tooltip saying the dashboard never received it.
|
||||
|
||||
- [ ] **Step 4: Run test + typecheck**
|
||||
|
||||
Run: `cd client && npx tsc -b && npx vitest run src/pages/__tests__/CcConfigSettings.test.tsx`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/CcConfig.tsx client/src/lib/api.ts client/src/i18n client/src/pages/__tests__/CcConfigSettings.test.tsx
|
||||
git commit -m "feat(cc-config): edit settings.json values from the dashboard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: MCP servers through the claude CLI
|
||||
|
||||
**Files:**
|
||||
- Create: `server/lib/cc-mcp-cli.js`, `server/__tests__/cc-mcp-cli.test.js`
|
||||
- Modify: `server/routes/cc-config.js` (add `POST /mcp/add`, `POST /mcp/remove`), `server/openapi-extra/cc-config.js`, `client/src/lib/api.ts`, `client/src/pages/CcConfig.tsx` (`McpPanel`), `client/src/i18n/locales/{en,vi}/ccConfig.json`
|
||||
- Test: `server/__tests__/cc-mcp-cli.test.js`, extend `server/__tests__/cc-config.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `mcpAdd({name, scope, transport, command, args, url, headers, env})` → `claude mcp add <name> [--scope user|project] [--transport http --url <url> | -- <command> <args…>]`
|
||||
- `mcpRemove({name, scope})` → `claude mcp remove <name> [--scope …]`
|
||||
- both async, returning `{ok, code, stdout, stderr}`, argv array, no shell — same contract as `cc-plugin-cli.js`
|
||||
- Routes return **422** on a non-zero CLI exit, **400** on a name failing `NAME_RE`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
it("builds an http server as a transport flag, not a shell string", async () => {
|
||||
const spawned = [];
|
||||
const cli = proxy({ spawn: (c, a) => { spawned.push([c, a]); return ok(""); } });
|
||||
await cli.mcpAdd({ name: "vercel", scope: "user", transport: "http", url: "https://mcp.vercel.com" });
|
||||
assert.deepEqual(spawned[0][1],
|
||||
["mcp", "add", "vercel", "--scope", "user", "--transport", "http", "--url", "https://mcp.vercel.com"]);
|
||||
});
|
||||
|
||||
it("puts stdio command args after the -- separator", async () => {
|
||||
const spawned = [];
|
||||
const cli = proxy({ spawn: (c, a) => { spawned.push([c, a]); return ok(""); } });
|
||||
await cli.mcpAdd({ name: "docs", scope: "user", command: "node", args: ["s.js", "--port", "3000"] });
|
||||
assert.deepEqual(spawned[0][1].slice(-5), ["--", "node", "s.js", "--port", "3000"]);
|
||||
});
|
||||
|
||||
it("refuses a server name that reads as a flag", async () => {
|
||||
await assert.rejects(() => require("../lib/cc-mcp-cli").mcpRemove({ name: "--all" }), /name/i);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test server/__tests__/cc-mcp-cli.test.js`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Implement the wrapper, then the two routes, then the UI**
|
||||
|
||||
`McpPanel` gains an "Add server" form (name, scope, stdio-vs-http toggle) and a remove action per row. Each row keeps its existing read-only detail view — the source of truth stays `readMcpServers`, refetched after every CLI call.
|
||||
|
||||
- [ ] **Step 4: Run both suites**
|
||||
|
||||
Run: `npm run test:server && cd client && npx tsc -b && npm test`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Regenerate OpenAPI, update docs, commit**
|
||||
|
||||
```bash
|
||||
npm run openapi:yaml
|
||||
git add server/lib/cc-mcp-cli.js server/routes/cc-config.js server/openapi-extra/cc-config.js server/__tests__ client/src openapi.yaml docs/API.md
|
||||
git commit -m "feat(cc-config): add and remove MCP servers through the claude CLI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:** "Mở thêm settings.json + MCP" → Task 1 (settings.json, using the API that already exists) and Task 2 (MCP, via CLI because direct file writes would race the CLI). Hooks stay read-only — they live inside settings.json, so Task 1 already covers editing them by key path, and a bespoke hook editor was not asked for.
|
||||
|
||||
**Placeholder scan:** none.
|
||||
|
||||
**Type consistency:** `scope` for settings is Claude's three-value union `"user" | "project" | "project-local"` (matching `readSettings`), while MCP's CLI scope flag accepts only `user | project` — the UI must not offer `project-local` on the MCP form.
|
||||
@@ -0,0 +1,612 @@
|
||||
# Codex Config Panel Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Give `/settings?tool=codex` the same create/read/update/delete power the Claude Code tab already has, against Codex's own config surfaces.
|
||||
|
||||
**Architecture:** Mirror the existing `cc-discovery` / `cc-mutate` / `cc-plugin-cli` / `routes/cc-config.js` trio with a Codex-side quartet, sharing the safe-write primitives instead of copying them. Codex's structured surfaces (MCP servers, plugins, marketplaces) are read and mutated through the `codex` CLI, which emits JSON — so no TOML parser enters the tree. File-shaped surfaces (skills, agents, rules, AGENTS.md, config.toml) go through the same containment + backup + atomic-rename path Claude's artifacts use. The client extracts CcConfig's already-generic modals and list components into `components/config/` and adds a Codex orchestrator beside them.
|
||||
|
||||
**Tech Stack:** Node 22 + Express + `node:test` (backend), React 18 + Vite + Vitest + react-i18next (frontend), `codex` CLI 0.149.0.
|
||||
|
||||
**Spec:** none written — the scope was fixed by the user in-session: "parity đầy đủ với tab Claude Code". This plan is the spec of record.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Every file created or modified gets the project header** — `@file`, `@description`, and the exact author line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>` (see `skills/file-headers/`).
|
||||
- **User-visible strings live in i18n JSON**, never as literals in components. Both `en` and `vi` must be updated in the same commit.
|
||||
- **No new npm dependencies.** TOML is never parsed in-process; `codex … --json` or a raw-text editor covers every case.
|
||||
- **Path containment is non-negotiable.** Every write resolves through one chokepoint and is re-checked with `isUnder(root, target)` after regex validation.
|
||||
- **Backup before every mutation of an existing file**, atomic rename on write — the same guarantees `cc-mutate.js` gives today.
|
||||
- **`CODEX_HOME` overrides `~/.codex`** everywhere, exactly as `CLAUDE_HOME` overrides `~/.claude`. Tests inject a temp home through it; nothing in the suite may touch the real one.
|
||||
- Backend tests: `npm run test:server`. Client tests: `cd client && npm test`. Both must be green before a commit.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shared safe-write primitives
|
||||
|
||||
`cc-mutate.js` owns the three guarantees a config writer needs — containment, backup, atomic rename. Codex needs the identical guarantees; copying them would mean two divergent copies of security code. Extract first, then reuse.
|
||||
|
||||
**Files:**
|
||||
- Create: `server/lib/config-write.js`
|
||||
- Modify: `server/lib/cc-mutate.js` (delete the local `createBackup`, `atomicWriteFile`, `timestamp`; import them instead)
|
||||
- Test: `server/__tests__/config-write.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `isUnder` from `server/lib/cc-discovery.js`
|
||||
- Produces:
|
||||
- `atomicWriteFile(filePath: string, content: string): void`
|
||||
- `createBackup(opts: {source: string, backupRoot: string, type: string, name: string}): string | null` — returns the backup path, or `null` when `source` does not exist. Copies a directory recursively when `source` is one.
|
||||
- `assertUnder(root: string, target: string): void` — throws `Object.assign(new Error(...), {code: "EOUTOFROOT"})`
|
||||
- `timestamp(): string` — filesystem-safe ISO stamp
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
const { atomicWriteFile, createBackup, assertUnder } = require("../lib/config-write");
|
||||
|
||||
it("leaves no temp file behind after a successful write", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
|
||||
atomicWriteFile(path.join(dir, "a.md"), "body");
|
||||
assert.equal(fs.readFileSync(path.join(dir, "a.md"), "utf8"), "body");
|
||||
assert.deepEqual(fs.readdirSync(dir), ["a.md"]);
|
||||
});
|
||||
|
||||
it("backs a directory up as a tree, not a single file", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
|
||||
fs.mkdirSync(path.join(dir, "src", "sk"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "src", "sk", "SKILL.md"), "x");
|
||||
const out = createBackup({
|
||||
source: path.join(dir, "src", "sk"),
|
||||
backupRoot: path.join(dir, "bak"),
|
||||
type: "skills",
|
||||
name: "sk",
|
||||
});
|
||||
assert.ok(fs.existsSync(path.join(out, "SKILL.md")));
|
||||
});
|
||||
|
||||
it("rejects a target outside the root", () => {
|
||||
assert.throws(() => assertUnder("/a/b", "/a/c/x"), (e) => e.code === "EOUTOFROOT");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test server/__tests__/config-write.test.js`
|
||||
Expected: FAIL — `Cannot find module '../lib/config-write'`
|
||||
|
||||
- [ ] **Step 3: Move the implementations**
|
||||
|
||||
Cut `timestamp`, `createBackup`, `atomicWriteFile` out of `server/lib/cc-mutate.js` verbatim into `server/lib/config-write.js`; generalize `createBackup`'s hardcoded backup root into the `backupRoot` argument; add `assertUnder` wrapping the existing `isUnder` check. Re-export nothing from `cc-mutate` — it imports what it needs.
|
||||
|
||||
- [ ] **Step 4: Run both suites to verify nothing regressed**
|
||||
|
||||
Run: `node --test server/__tests__/config-write.test.js server/__tests__/cc-mutate-settings.test.js server/__tests__/cc-config.test.js`
|
||||
Expected: PASS — the cc-mutate suites are the proof the extraction was behavior-preserving.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/lib/config-write.js server/lib/cc-mutate.js server/__tests__/config-write.test.js
|
||||
git commit -m "refactor(config): extract the safe-write primitives cc-mutate guards with"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Codex home resolution
|
||||
|
||||
`codexHome()` already exists inside `server/lib/codex-ingest.js:101` but is not exported, so every new module would re-derive it. Give it a home of its own, matching `claude-home.js`'s role.
|
||||
|
||||
**Files:**
|
||||
- Create: `server/lib/codex-home.js`
|
||||
- Modify: `server/lib/codex-ingest.js` (drop the local `codexHome`, import it)
|
||||
- Test: `server/__tests__/codex-home.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `getCodexHome(): string` — `process.env.CODEX_HOME || path.join(os.homedir(), ".codex")`
|
||||
- `getProjectCodexDir(cwd: string): string` — `<cwd>/.codex`
|
||||
- `getProjectRoot(cwd: string): string` — `cwd` (where `AGENTS.md` lives)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
it("honours CODEX_HOME over the home directory", () => {
|
||||
process.env.CODEX_HOME = "/tmp/injected-codex";
|
||||
delete require.cache[require.resolve("../lib/codex-home")];
|
||||
assert.equal(require("../lib/codex-home").getCodexHome(), "/tmp/injected-codex");
|
||||
});
|
||||
|
||||
it("puts the project surfaces under <cwd>/.codex", () => {
|
||||
const { getProjectCodexDir } = require("../lib/codex-home");
|
||||
assert.equal(getProjectCodexDir("/repo"), path.join("/repo", ".codex"));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test server/__tests__/codex-home.test.js`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Write the module and re-point codex-ingest**
|
||||
|
||||
`getCodexHome` must read `process.env` at call time (not at module load) so tests can inject.
|
||||
|
||||
- [ ] **Step 4: Run the codex suites**
|
||||
|
||||
Run: `node --test server/__tests__/codex-home.test.js server/__tests__/codex-ingest.test.js`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/lib/codex-home.js server/lib/codex-ingest.js server/__tests__/codex-home.test.js
|
||||
git commit -m "refactor(codex): give the CODEX_HOME lookup its own module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Codex discovery (read side)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/lib/codex-discovery.js`
|
||||
- Test: `server/__tests__/codex-discovery.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getCodexHome`, `getProjectCodexDir`, `getProjectRoot` (Task 2); `parseFrontmatter`, `isUnder`, `MAX_FILE_BYTES` from `server/lib/cc-discovery.js`
|
||||
- Produces (every reader takes `{scope, cwd}` where `scope` is `"user" | "project" | undefined` meaning both):
|
||||
- `readSkills(opts): Skill[]` — `{scope, name, path, file, size, mtime, truncated, frontmatter, preview}` from `<home>/skills/*/SKILL.md` and `<cwd>/.codex/skills/*/SKILL.md`
|
||||
- `readAgents(opts): Agent[]` — `{scope, name, file, size, mtime, meta, preview}` from `*/agents/*.toml`; `meta` is `{name, description, model, sandbox_mode}` best-effort
|
||||
- `readRules(opts): Rule[]` — `{scope, name, file, size, mtime, preview}` from `*/rules/*.rules`
|
||||
- `readMemory(opts): Memory[]` — `{scope, file, exists, size, mtime, preview}` for `<home>/AGENTS.md` and `<cwd>/AGENTS.md`
|
||||
- `readConfig(opts): ConfigFile[]` — `{scope, file, exists, size, mtime, content, truncated}` for `<home>/config.toml` and `<cwd>/.codex/config.toml`
|
||||
- `readOverview(opts)` — `{counts: {skills, agents, rules, memory, config}, roots: {codexHome, projectCodexDir, projectRoot}}`
|
||||
- `readTomlMeta(text): object` — the best-effort top-level scalar reader used by `readAgents`
|
||||
- Note: MCP servers and plugins are NOT here — they come from the CLI in Task 4.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-"));
|
||||
process.env.CODEX_HOME = TMP;
|
||||
const cx = require("../lib/codex-discovery");
|
||||
|
||||
it("reads a user skill and a project skill into one scoped list", () => {
|
||||
write(path.join(TMP, "skills", "release-guard", "SKILL.md"), "---\nname: release-guard\n---\nbody");
|
||||
write(path.join(REPO, ".codex", "skills", "onboarding", "SKILL.md"), "---\nname: onboarding\n---\nb");
|
||||
|
||||
const rows = cx.readSkills({ cwd: REPO });
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.scope, r.name]).sort(),
|
||||
[["project", "onboarding"], ["user", "release-guard"]]
|
||||
);
|
||||
assert.equal(rows.find((r) => r.name === "release-guard").frontmatter.name, "release-guard");
|
||||
});
|
||||
|
||||
it("pulls an agent's identity out of its TOML without a TOML parser", () => {
|
||||
write(path.join(REPO, ".codex", "agents", "reviewer.toml"),
|
||||
'name = "reviewer"\ndescription = "Read-only review agent."\nmodel = "gpt-5.4"\n' +
|
||||
'developer_instructions = """\nmulti\nline\n"""\n');
|
||||
|
||||
const [agent] = cx.readAgents({ cwd: REPO, scope: "project" });
|
||||
assert.equal(agent.meta.name, "reviewer");
|
||||
assert.equal(agent.meta.description, "Read-only review agent.");
|
||||
assert.equal(agent.meta.model, "gpt-5.4");
|
||||
});
|
||||
|
||||
it("reports a missing AGENTS.md as a row that does not exist, not a gap", () => {
|
||||
const rows = cx.readMemory({ cwd: REPO });
|
||||
const user = rows.find((r) => r.scope === "user");
|
||||
assert.equal(user.exists, false, "the UI offers to create it, so the row must be present");
|
||||
});
|
||||
|
||||
it("never walks outside the codex roots", () => {
|
||||
write(path.join(TMP, "skills", "esc", "SKILL.md"), "x");
|
||||
for (const row of cx.readSkills({ cwd: REPO })) {
|
||||
assert.ok(row.file.startsWith(TMP) || row.file.startsWith(REPO));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test server/__tests__/codex-discovery.test.js`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Implement the readers**
|
||||
|
||||
`readTomlMeta` handles exactly what the UI shows: top-level `key = "value"` / `key = 123` / `key = true` lines before the first `[section]`, skipping `"""` blocks. Anything richer is the raw-text editor's job.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test server/__tests__/codex-discovery.test.js`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/lib/codex-discovery.js server/__tests__/codex-discovery.test.js
|
||||
git commit -m "feat(codex): read the Codex config surfaces"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Codex CLI wrapper
|
||||
|
||||
**Files:**
|
||||
- Create: `server/lib/codex-cli.js`
|
||||
- Test: `server/__tests__/codex-cli.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (all async, all returning `{ok: boolean, code: number, stdout: string, stderr: string, data?: object}`; `data` is the parsed JSON when the command emits it):
|
||||
- `mcpList()` → `codex mcp list --json`
|
||||
- `mcpAdd({name, command, args, env})` → `codex mcp add <name> -- <command> <args…>`
|
||||
- `mcpRemove(name)` → `codex mcp remove <name>`
|
||||
- `pluginList()` → `codex plugin list --json`
|
||||
- `pluginAdd(spec)` / `pluginRemove(name)`
|
||||
- `marketplaceList()` → `codex plugin marketplace list --json`
|
||||
- `marketplaceAdd(url)` / `marketplaceRemove(name)`
|
||||
- `NAME_RE` — the identifier guard shared with the routes
|
||||
- Every call builds an **argv array** and spawns without a shell, exactly like `cc-plugin-cli.js`. A name failing `NAME_RE` throws before spawn.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
it("passes the server name as one argv entry, never through a shell", async () => {
|
||||
const spawned = [];
|
||||
const cli = proxy({ spawn: (cmd, args) => { spawned.push([cmd, args]); return ok('{"servers":[]}'); } });
|
||||
await cli.mcpAdd({ name: "docs", command: "node", args: ["server.js"] });
|
||||
assert.deepEqual(spawned[0][0], "codex");
|
||||
assert.deepEqual(spawned[0][1], ["mcp", "add", "docs", "--", "node", "server.js"]);
|
||||
});
|
||||
|
||||
it("refuses a name that could be read as a flag or a path", async () => {
|
||||
const cli = require("../lib/codex-cli");
|
||||
await assert.rejects(() => cli.mcpRemove("--force"), /name/i);
|
||||
await assert.rejects(() => cli.mcpRemove("../../etc"), /name/i);
|
||||
});
|
||||
|
||||
it("surfaces a non-zero exit as ok:false with stderr kept", async () => {
|
||||
const cli = proxy({ spawn: () => fail(2, "no such marketplace") });
|
||||
const res = await cli.marketplaceRemove("nope");
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.stderr, /no such marketplace/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test server/__tests__/codex-cli.test.js`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Implement, copying cc-plugin-cli.js's spawn/injection shape**
|
||||
|
||||
Read `server/lib/cc-plugin-cli.js` first and follow its structure — same injectable spawn, same result object, same argv discipline.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test server/__tests__/codex-cli.test.js`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/lib/codex-cli.js server/__tests__/codex-cli.test.js
|
||||
git commit -m "feat(codex): wrap the codex mcp/plugin CLI in argv-safe calls"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Codex mutation (write side)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/lib/codex-mutate.js`
|
||||
- Test: `server/__tests__/codex-mutate.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `atomicWriteFile`, `createBackup`, `assertUnder`, `timestamp` (Task 1); `getCodexHome`, `getProjectCodexDir`, `getProjectRoot` (Task 2)
|
||||
- Produces:
|
||||
- `TYPES` — `{skills: {kind: "dir", entry: "SKILL.md"}, agents: {kind: "file", ext: ".toml"}, rules: {kind: "file", ext: ".rules"}, memory: {kind: "fixed", file: "AGENTS.md"}, config: {kind: "fixed", file: "config.toml"}}`
|
||||
- `resolveTarget(scope, type, name, opts): {kind, target, filePath, containmentRoot}` — the single chokepoint
|
||||
- `writeArtifact({scope, type, name, content, cwd}): {file, created, backup}`
|
||||
- `deleteArtifact({scope, type, name, cwd}): {file, backup}`
|
||||
- `listBackups({scope, type}): {type, name, file, mtime, size}[]`
|
||||
- `NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/`
|
||||
- Error codes match cc-mutate's so the route error map is shared in shape: `EBADTYPE`, `EBADSCOPE`, `EBADNAME`, `EBADCONTENT`, `ETOOLARGE`, `ENOTFOUND`, `EOUTOFROOT`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
it("creates a project skill as a directory with SKILL.md inside", () => {
|
||||
const { file, created } = cm.writeArtifact({
|
||||
scope: "project", type: "skills", name: "release-guard", content: "---\nname: x\n---\n", cwd: REPO,
|
||||
});
|
||||
assert.equal(created, true);
|
||||
assert.equal(file, path.join(REPO, ".codex", "skills", "release-guard", "SKILL.md"));
|
||||
});
|
||||
|
||||
it("backs the old body up before overwriting it", () => {
|
||||
cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "old", cwd: REPO });
|
||||
const { backup } = cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "new", cwd: REPO });
|
||||
assert.equal(fs.readFileSync(backup, "utf8"), "old");
|
||||
assert.equal(fs.readFileSync(path.join(TMP, "rules", "default.rules"), "utf8"), "new");
|
||||
});
|
||||
|
||||
it("refuses a name that climbs out of the root", () => {
|
||||
assert.throws(
|
||||
() => cm.writeArtifact({ scope: "user", type: "agents", name: "../../evil", content: "x", cwd: REPO }),
|
||||
(e) => e.code === "EBADNAME"
|
||||
);
|
||||
});
|
||||
|
||||
it("writes AGENTS.md at the repo root, not inside .codex", () => {
|
||||
const { file } = cm.writeArtifact({ scope: "project", type: "memory", content: "# rules", cwd: REPO });
|
||||
assert.equal(file, path.join(REPO, "AGENTS.md"));
|
||||
});
|
||||
|
||||
it("keeps a deleted skill's backup out of the skills tree", () => {
|
||||
cm.writeArtifact({ scope: "user", type: "skills", name: "gone", content: "x", cwd: REPO });
|
||||
const { backup } = cm.deleteArtifact({ scope: "user", type: "skills", name: "gone", cwd: REPO });
|
||||
assert.ok(!backup.includes(path.join(TMP, "skills", "gone")), "a backup must never be discoverable as a skill");
|
||||
assert.equal(fs.existsSync(path.join(TMP, "skills", "gone")), false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test server/__tests__/codex-mutate.test.js`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Implement against `server/lib/cc-mutate.js`'s structure**
|
||||
|
||||
Backups land in `<root>/codex-config-backups/<type>/<name>.<ts>.bak`, mirroring Claude's dotted-away location so Codex never rediscovers a backup as a skill.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test server/__tests__/codex-mutate.test.js`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/lib/codex-mutate.js server/__tests__/codex-mutate.test.js
|
||||
git commit -m "feat(codex): write Codex artifacts with backup and atomic rename"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Codex config routes
|
||||
|
||||
**Files:**
|
||||
- Create: `server/routes/codex-config.js`, `server/openapi-extra/codex-config.js`
|
||||
- Modify: `server/index.js` (mount `/api/codex-config`), `server/openapi-extra.js` (merge the fragment)
|
||||
- Test: `server/__tests__/codex-config.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 3, 4, 5
|
||||
- Produces: `GET /overview|/skills|/agents|/rules|/memory|/config|/mcp|/plugins|/marketplaces`, `GET /file?path=`, `PUT /file`, `DELETE /file`, `GET /backups`, `POST /mcp/add`, `POST /mcp/remove`, `POST /plugins/action`, `POST /marketplaces/add`, `POST /marketplaces/action`
|
||||
- Mirrors `server/routes/cc-config.js`: same `ERR_TO_STATUS` mapping, same `emitChanged` broadcast (event name `codex_config_changed`), CLI non-zero exit → **422**.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
it("lists skills for both scopes", async () => {
|
||||
const res = await request(app).get("/api/codex-config/skills");
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(Array.isArray(res.body.skills));
|
||||
});
|
||||
|
||||
it("rejects a write whose name escapes the root with 400, not 500", async () => {
|
||||
const res = await request(app)
|
||||
.put("/api/codex-config/file")
|
||||
.send({ scope: "user", type: "agents", name: "../evil", content: "x" });
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it("reports a failed CLI call as 422 and keeps the stderr", async () => {
|
||||
const res = await request(app).post("/api/codex-config/marketplaces/action")
|
||||
.send({ name: "nope", action: "remove" });
|
||||
assert.equal(res.status, 422);
|
||||
});
|
||||
|
||||
it("broadcasts codex_config_changed after a successful write", async () => {
|
||||
const seen = [];
|
||||
onBroadcast((msg) => seen.push(msg.type));
|
||||
await request(app).put("/api/codex-config/file")
|
||||
.send({ scope: "user", type: "rules", name: "default", content: "allow" });
|
||||
assert.ok(seen.includes("codex_config_changed"));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test server/__tests__/codex-config.test.js`
|
||||
Expected: FAIL — 404 on every route
|
||||
|
||||
- [ ] **Step 3: Implement the router following `server/routes/cc-config.js`**
|
||||
|
||||
- [ ] **Step 4: Run the full backend suite**
|
||||
|
||||
Run: `npm run test:server`
|
||||
Expected: PASS, no regressions
|
||||
|
||||
- [ ] **Step 5: Regenerate the OpenAPI YAML and commit**
|
||||
|
||||
```bash
|
||||
npm run openapi:yaml
|
||||
git add server/routes/codex-config.js server/openapi-extra/codex-config.js server/openapi-extra.js server/index.js server/__tests__/codex-config.test.js openapi.yaml
|
||||
git commit -m "feat(codex): expose the Codex config API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Client API namespace
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/lib/api.ts`
|
||||
- Test: `client/src/lib/__tests__/codexConfigApi.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `api.codexConfig` with `overview()`, `skills(scope?)`, `agents(scope?)`, `rules(scope?)`, `memory()`, `config()`, `mcp()`, `plugins()`, `marketplaces()`, `file(path)`, `write(args)`, `remove(args)`, `backups()`, `mcpAdd(args)`, `mcpRemove(name)`, `pluginAction(args)`, `marketplaceAdd(url)`, `marketplaceAction(args)`; plus exported types `CodexScope = "user" | "project"`, `CodexArtifactType = "skills" | "agents" | "rules" | "memory" | "config"`, `CodexSkill`, `CodexAgent`, `CodexRule`, `CodexMemoryFile`, `CodexConfigFile`, `CodexMcpServer`, `CodexPlugin`, `CodexMarketplace`, `CodexOverview`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```ts
|
||||
it("sends a write as a single PUT with the artifact envelope", async () => {
|
||||
const calls: RequestInit[] = [];
|
||||
stubFetch(calls, { ok: true, file: "/x", created: true });
|
||||
await api.codexConfig.write({ scope: "user", type: "rules", name: "default", content: "allow" });
|
||||
expect(calls[0].method).toBe("PUT");
|
||||
expect(JSON.parse(calls[0].body as string)).toEqual({
|
||||
scope: "user", type: "rules", name: "default", content: "allow",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd client && npx vitest run src/lib/__tests__/codexConfigApi.test.ts`
|
||||
Expected: FAIL — `api.codexConfig` is undefined
|
||||
|
||||
- [ ] **Step 3: Add the namespace beside `api.ccConfig`, following its exact shape**
|
||||
|
||||
- [ ] **Step 4: Run test + typecheck**
|
||||
|
||||
Run: `cd client && npx vitest run src/lib/__tests__/codexConfigApi.test.ts && npx tsc -b`
|
||||
Expected: PASS, no type errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/lib/api.ts client/src/lib/__tests__/codexConfigApi.test.ts
|
||||
git commit -m "feat(codex): add the codexConfig API namespace"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Extract the tool-agnostic config UI
|
||||
|
||||
`CcConfig.tsx` is 4902 lines and its modals are already generic over `type: string` / `scope: string`. Lift them before a second tool copies them.
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/components/config/FileViewer.tsx`, `EditorModal.tsx`, `ConfirmDeleteModal.tsx`, `ToastNotice.tsx`, `MdItemList.tsx`, `ScopeBadge.tsx`, `configPrimitives.tsx` (`Empty`, `SkeletonRows`, `CopyButton`, `formatBytes`)
|
||||
- Modify: `client/src/pages/CcConfig.tsx` (delete the moved definitions, import them)
|
||||
- Test: `client/src/components/config/__tests__/EditorModal.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: the components above, with `EditorModal` gaining one new prop so it stops reaching for Claude's API directly:
|
||||
- `fetchContent: (filePath: string) => Promise<string>` — supplied by whichever orchestrator opens it
|
||||
- existing props keep their names: `state`, `onClose`, `onSave`
|
||||
- The moved components must not import `api` at all.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```tsx
|
||||
it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
|
||||
const fetchContent = vi.fn().mockResolvedValue("old body");
|
||||
render(<EditorModal
|
||||
state={{ mode: "edit", type: "rules", scope: "user", name: "default", filePath: "/r/default.rules" }}
|
||||
fetchContent={fetchContent}
|
||||
onClose={() => {}}
|
||||
onSave={async () => {}}
|
||||
/>);
|
||||
expect(fetchContent).toHaveBeenCalledWith("/r/default.rules");
|
||||
expect(await screen.findByDisplayValue("old body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses to save a create with no name", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<EditorModal state={{ mode: "create", type: "agents", defaultScope: "user" }}
|
||||
fetchContent={async () => ""} onClose={() => {}} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd client && npx vitest run src/components/config/__tests__/EditorModal.test.tsx`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Move the components; pass `fetchContent={(p) => api.ccConfig.file(p)}` from CcConfig**
|
||||
|
||||
- [ ] **Step 4: Verify the Claude tab is unchanged**
|
||||
|
||||
Run: `cd client && npm test`
|
||||
Expected: PASS — `screens.snapshot.test.tsx`'s Claude Config snapshot must NOT change. If it does, the move was not behavior-preserving; fix rather than update the snapshot.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/components/config client/src/pages/CcConfig.tsx
|
||||
git commit -m "refactor(config): lift the tool-agnostic config modals out of CcConfig"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: The Codex panel
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/pages/CodexConfig.tsx`, `client/src/i18n/locales/en/codexConfig.json`, `client/src/i18n/locales/vi/codexConfig.json`, `client/src/pages/__tests__/CodexConfig.test.tsx`
|
||||
- Modify: `client/src/pages/SettingsHub.tsx:33` (add `Panel: CodexConfig` to the existing `codex` entry), `client/src/i18n/index.ts` (register the namespace)
|
||||
- Test: `client/src/pages/__tests__/CodexConfig.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.codexConfig` (Task 7), the extracted components (Task 8)
|
||||
- Produces: `export function CodexConfig()` — tabs `overview | skills | agents | rules | memory | config | mcp | plugins`, with create/edit/delete on `skills | agents | rules | memory | config`, and CLI-driven actions on `mcp | plugins`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```tsx
|
||||
it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
|
||||
stubCodexApi({ skills: [], mcp: { servers: [] } });
|
||||
render(<CodexConfig />);
|
||||
await userEvent.click(screen.getByRole("tab", { name: /skills/i }));
|
||||
expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: /mcp/i }));
|
||||
expect(screen.queryByRole("button", { name: /new/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes an edited rule back through the codex API, not the Claude one", async () => {
|
||||
const write = vi.fn().mockResolvedValue({ ok: true });
|
||||
stubCodexApi({ rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }], write });
|
||||
render(<CodexConfig />);
|
||||
await userEvent.click(screen.getByRole("tab", { name: /rules/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(write).toHaveBeenCalledWith(expect.objectContaining({ type: "rules", name: "default" }));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd client && npx vitest run src/pages/__tests__/CodexConfig.test.tsx`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Implement the panel and both locale files**
|
||||
|
||||
Every string goes through `useTranslation("codexConfig")`. The `vi` file is a real translation, not an English copy.
|
||||
|
||||
- [ ] **Step 4: Run the whole client suite + typecheck**
|
||||
|
||||
Run: `cd client && npx tsc -b && npm test`
|
||||
Expected: PASS. The `screens.snapshot.test.tsx` Settings snapshot changes here (the Codex tab loses its "soon" badge) — that one is expected; review the diff before updating.
|
||||
|
||||
- [ ] **Step 5: Update the docs and commit**
|
||||
|
||||
Update `docs/API.md` (new `/api/codex-config` surface) and `ARCHITECTURE.md` (the Settings hub now hosts two tool panels), per `skills/update-project-docs/`.
|
||||
|
||||
```bash
|
||||
git add client/src/pages/CodexConfig.tsx client/src/pages/SettingsHub.tsx client/src/i18n client/src/pages/__tests__ docs/API.md ARCHITECTURE.md
|
||||
git commit -m "feat(codex): create and customize Codex config from /settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:** skills, agents, rules, AGENTS.md, config.toml → Tasks 3/5/9. MCP + plugins + marketplaces → Tasks 4/6/9. "Create and customize in settings" → Task 9's create/edit/delete wiring. The Claude-side settings.json/MCP editing the user also asked for is deliberately **not** here — it is a separate subsystem with its own plan (`2026-09-08-claude-settings-editable.md`).
|
||||
|
||||
**Placeholder scan:** none — every step names its file, its command, and its expected output.
|
||||
|
||||
**Type consistency:** `CodexArtifactType` (Task 7) matches `TYPES` (Task 5) matches the tab keys (Task 9): `skills | agents | rules | memory | config`. `scope` is `"user" | "project"` everywhere; Codex has no `project-local` or `auto-memory` analogue, which is why its scope union is narrower than Claude's.
|
||||
+1421
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.4.6",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.4.6",
|
||||
"version": "1.5.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.4.6",
|
||||
"version": "1.5.0",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Diagnose a plugin-installed AgentHub — Node, bootstrap, server, hooks, CLI
|
||||
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, UI build
|
||||
---
|
||||
|
||||
Run the diagnostic and report what it found. Do not fix anything unless the user
|
||||
@@ -17,8 +17,13 @@ Print the output verbatim, then add at most three lines of guidance based on the
|
||||
- `FAIL Hooks` → duplicate hook entries double every token and cost figure.
|
||||
Starting a new Claude Code session removes them automatically (the bootstrap
|
||||
backs the file up as `settings.json.agenthub-bak` first).
|
||||
- `FAIL Server` or `FAIL Runtime deps` → run `/agenthub-update`, then check
|
||||
- `FAIL Server` → run `/agenthub-update`, then check
|
||||
`~/.claude/agenthub/runtime/bootstrap.log` and `server.log`.
|
||||
- `FAIL Runtime deps` → the install is incomplete, not merely absent. Delete
|
||||
`~/.claude/agenthub/runtime/node_modules` before running `/agenthub-update`;
|
||||
reinstalling over the broken tree is what let it survive.
|
||||
- `FAIL npm` or `FAIL Disk` → nothing else in the report can be trusted until
|
||||
these are fixed; both silently truncate installs.
|
||||
- `WARN agenthub CLI` → print the exact `export PATH=...` line from the output.
|
||||
- `WARN Skill plugins` → the pipelines name slash commands no installed plugin
|
||||
provides. The dashboard is unaffected; `/agenthub:ship-feature(-lane)` is what
|
||||
@@ -26,6 +31,7 @@ Print the output verbatim, then add at most three lines of guidance based on the
|
||||
means that attempt has not run or did not succeed — say `/agenthub-update` retries
|
||||
it, and print the `claude plugin install …` line from the output as the manual
|
||||
route.
|
||||
- `WARN Dashboard UI` → run `/agenthub-open` to build the bundle.
|
||||
- `WARN Dashboard UI` → run `/agenthub-open` to build the bundle. When the row
|
||||
quotes a build error, report that error — it is the actual problem.
|
||||
|
||||
No preamble, no summary of things that passed.
|
||||
|
||||
+110
-16
@@ -2,10 +2,13 @@
|
||||
/**
|
||||
* @file plugin-doctor.js
|
||||
* @description Reports the health of a plugin-installed AgentHub: Node version,
|
||||
* bootstrap state, runtime dependencies, server liveness, duplicate hook
|
||||
* entries (the one failure that silently doubles every token and cost figure),
|
||||
* the `agenthub` CLI launcher and its PATH, and whether the slash commands the pipeline templates name
|
||||
* as node drivers are actually resolvable (the `superpowers` plugin, today).
|
||||
* npm, free disk on the data dir, bootstrap state, runtime dependencies (every
|
||||
* declared package, not just that node_modules exists), server liveness,
|
||||
* duplicate hook entries (the one failure that silently doubles every token and
|
||||
* cost figure), the `agenthub` CLI launcher and its PATH, whether the slash
|
||||
* commands the pipeline templates name as node drivers are actually resolvable
|
||||
* (the `superpowers` plugin, today), and the dashboard bundle — quoting the last
|
||||
* build error when it is missing.
|
||||
* Read-only — it diagnoses, it never repairs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -13,6 +16,7 @@
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const boot = require("./plugin-bootstrap");
|
||||
const { getSettingsPath, getDataDir } = require("../server/lib/claude-home");
|
||||
@@ -54,12 +58,9 @@ function diagnose() {
|
||||
);
|
||||
}
|
||||
|
||||
const deps = fs.existsSync(path.join(rt, "node_modules"));
|
||||
add(
|
||||
deps ? "ok" : "fail",
|
||||
"Runtime deps",
|
||||
deps ? path.join(rt, "node_modules") : "missing — run /agenthub-update"
|
||||
);
|
||||
add(...runtimeDepsStatus(rt));
|
||||
add(...npmStatus());
|
||||
add(...diskStatus());
|
||||
|
||||
const live = boot.liveServers();
|
||||
add(
|
||||
@@ -83,16 +84,109 @@ function diagnose() {
|
||||
add(...cliStatus());
|
||||
add(...skillPluginStatus());
|
||||
|
||||
const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist");
|
||||
add(
|
||||
fs.existsSync(path.join(dist, "index.html")) ? "ok" : "warn",
|
||||
"Dashboard UI",
|
||||
fs.existsSync(path.join(dist, "index.html")) ? dist : "not built yet — run /agenthub-open"
|
||||
);
|
||||
add(...dashboardUiStatus(rt));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `node_modules` existing proves nothing: an interrupted install or a truncated
|
||||
* cache entry leaves a directory that npm reports as installed and that is
|
||||
* missing packages — the failure mode that took the client build down with
|
||||
* TS2318. Check every declared runtime dependency actually landed.
|
||||
*
|
||||
* Only `dependencies` count. `optionalDependencies` (better-sqlite3) are allowed
|
||||
* to be absent by design — the server falls back to `node:sqlite`.
|
||||
*/
|
||||
function runtimeDepsStatus(rt) {
|
||||
const nm = path.join(rt, "node_modules");
|
||||
if (!fs.existsSync(nm)) return ["fail", "Runtime deps", "missing — run /agenthub-update"];
|
||||
|
||||
let declared;
|
||||
try {
|
||||
declared = Object.keys(
|
||||
JSON.parse(fs.readFileSync(path.join(rt, "package.json"), "utf8")).dependencies || {}
|
||||
);
|
||||
} catch {
|
||||
return ["warn", "Runtime deps", `${nm} — no manifest beside it, cannot verify the tree`];
|
||||
}
|
||||
|
||||
const missing = declared.filter((d) => !fs.existsSync(path.join(nm, d, "package.json")));
|
||||
if (missing.length) {
|
||||
return [
|
||||
"fail",
|
||||
"Runtime deps",
|
||||
`${missing.join(", ")} declared but not installed — the tree is incomplete. ` +
|
||||
`Delete ${nm} and run /agenthub-update`,
|
||||
];
|
||||
}
|
||||
return ["ok", "Runtime deps", `${declared.length} packages in ${nm}`];
|
||||
}
|
||||
|
||||
/** No npm, no dashboard bundle and no runtime install — both go through it. */
|
||||
function npmStatus() {
|
||||
const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["-v"], {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
if (res.status !== 0) {
|
||||
return ["fail", "npm", "not runnable — the runtime install and the UI build both need it"];
|
||||
}
|
||||
return ["ok", "npm", `v${res.stdout.trim()}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* A full disk is the quiet cause of half-written installs and a SQLite file that
|
||||
* refuses writes, and it reads as unrelated breakage everywhere else.
|
||||
*/
|
||||
function diskStatus() {
|
||||
const dir = getDataDir();
|
||||
let free;
|
||||
try {
|
||||
const s = fs.statfsSync(dir);
|
||||
free = s.bavail * s.bsize;
|
||||
} catch {
|
||||
return ["ok", "Disk", "not measurable on this platform"];
|
||||
}
|
||||
const mb = Math.round(free / 1024 / 1024);
|
||||
if (free < 200 * 1024 * 1024) {
|
||||
return ["fail", "Disk", `${mb} MB free on ${dir} — installs and the database will truncate`];
|
||||
}
|
||||
if (free < 1024 * 1024 * 1024) return ["warn", "Disk", `${mb} MB free on ${dir}`];
|
||||
return ["ok", "Disk", `${Math.round(mb / 1024)} GB free`];
|
||||
}
|
||||
|
||||
/**
|
||||
* When the bundle is missing the useful thing is not "run /agenthub-open" — it
|
||||
* is why the last build failed, which sits in `client-build.log` and which
|
||||
* nobody thinks to open.
|
||||
*/
|
||||
function dashboardUiStatus(rt) {
|
||||
const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist");
|
||||
if (fs.existsSync(path.join(dist, "index.html"))) return ["ok", "Dashboard UI", dist];
|
||||
|
||||
const err = lastBuildError(path.join(rt, "client-build.log"));
|
||||
return [
|
||||
"warn",
|
||||
"Dashboard UI",
|
||||
err
|
||||
? `not built — the last build failed with: ${err} (full log: ${path.join(rt, "client-build.log")})`
|
||||
: "not built yet — run /agenthub-open",
|
||||
];
|
||||
}
|
||||
|
||||
/** First compiler/npm error line of the log's last build, or null. */
|
||||
function lastBuildError(logPath) {
|
||||
let lines;
|
||||
try {
|
||||
lines = fs.readFileSync(logPath, "utf8").split("\n");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const hit = lines.filter((l) => /\berror\b|npm ERR!/i.test(l)).pop();
|
||||
return hit ? hit.trim().slice(0, 200) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WARN (never FAIL) when a plugin the pipelines depend on cannot be resolved:
|
||||
* the dashboard, hooks and CLI all work without it — only the pipeline
|
||||
|
||||
+25
-3
@@ -41,9 +41,17 @@ function run(cmd, args, cwd, stdio) {
|
||||
* caller's stdio — used when this runs inside the detached bootstrap worker,
|
||||
* whose own stdout is discarded, so a build failure is still debuggable.
|
||||
*
|
||||
* `exec` is the command runner, swappable for tests.
|
||||
*
|
||||
* @returns {"ready"|"built"|"failed"}
|
||||
*/
|
||||
function buildClient({ rt = boot.runtimeDir(), root = PLUGIN_ROOT, force = false, logPath } = {}) {
|
||||
function buildClient({
|
||||
rt = boot.runtimeDir(),
|
||||
root = PLUGIN_ROOT,
|
||||
force = false,
|
||||
logPath,
|
||||
exec = run,
|
||||
} = {}) {
|
||||
const dist = path.join(rt, "client-dist");
|
||||
if (!force && fs.existsSync(path.join(dist, "index.html"))) return "ready";
|
||||
|
||||
@@ -57,8 +65,22 @@ function buildClient({ rt = boot.runtimeDir(), root = PLUGIN_ROOT, force = false
|
||||
filter: (p) => !/[\\/](node_modules|dist)$/.test(p),
|
||||
});
|
||||
|
||||
if (!run(NPM, ["install", "--no-audit", "--no-fund"], src, stdio)) return "failed";
|
||||
if (!run(NPM, ["run", "build"], src, stdio)) return "failed";
|
||||
// `npm install` reuses whatever the cache and a previous run left behind and
|
||||
// still reports success, so a truncated cache entry or an interrupted install
|
||||
// yields a tree that is missing files nobody notices until `tsc` dies with
|
||||
// TS2318 (every global type gone, because typescript's own lib/*.d.ts never
|
||||
// landed). `npm ci` starts from an empty node_modules and checks every
|
||||
// tarball against the lockfile's integrity hash, so a bad cache entry is
|
||||
// refetched instead of silently reused. It needs a lockfile that matches
|
||||
// package.json — fall back to `install` when the copy has none or has drifted.
|
||||
const npmFlags = ["--no-audit", "--no-fund"];
|
||||
if (
|
||||
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
|
||||
!exec(NPM, ["install", ...npmFlags], src, stdio)
|
||||
) {
|
||||
return "failed";
|
||||
}
|
||||
if (!exec(NPM, ["run", "build"], src, stdio)) return "failed";
|
||||
|
||||
fs.rmSync(dist, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(src, "dist"), dist, { recursive: true });
|
||||
|
||||
+1
-1
@@ -699,7 +699,7 @@ HTTP surface for the tmux-backed terminal run: one session per lane, named `agen
|
||||
|
||||
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the lane action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
|
||||
|
||||
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`).
|
||||
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher).
|
||||
|
||||
### Import History
|
||||
|
||||
|
||||
@@ -551,16 +551,70 @@ describe("/api/cc-config", () => {
|
||||
});
|
||||
|
||||
it("PUT /file edits memory CLAUDE.md without a name", async () => {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/cc-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
||||
{
|
||||
// chdir, not `?cwd=`: project-scope writes resolve against the SERVER's
|
||||
// cwd on purpose, so a request cannot pick the destination. Passing
|
||||
// `?cwd=` here would have written into the repo's own CLAUDE.md.
|
||||
const prev = process.cwd();
|
||||
process.chdir(FAKE_PROJECT);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/cc-config/file", {
|
||||
method: "PUT",
|
||||
body: { scope: "project", type: "memory", content: "# new project memory" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.ok(body.backupPath, "previous CLAUDE.md should be backed up");
|
||||
assert.equal(fs.readFileSync(body.file, "utf8"), "# new project memory");
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.ok(body.backupPath, "previous CLAUDE.md should be backed up");
|
||||
assert.equal(fs.readFileSync(body.file, "utf8"), "# new project memory");
|
||||
assert.equal(body.file, path.join(FAKE_PROJECT, "CLAUDE.md"));
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
|
||||
it("PUT /file ignores ?cwd= — a caller cannot choose where a write lands", async () => {
|
||||
// The hole this closes: `?cwd=<anywhere>` with scope "project" dropped a
|
||||
// file into any directory on disk. PATCH /settings already refused it;
|
||||
// PUT/DELETE did not.
|
||||
const victim = path.join(TMP, "victim-project");
|
||||
fs.mkdirSync(victim, { recursive: true });
|
||||
fs.writeFileSync(path.join(victim, "CLAUDE.md"), "UNTOUCHED");
|
||||
|
||||
const prev = process.cwd();
|
||||
process.chdir(FAKE_PROJECT);
|
||||
try {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/cc-config/file?cwd=${encodeURIComponent(victim)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: { scope: "project", type: "memory", content: "PWNED" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.file, path.join(FAKE_PROJECT, "CLAUDE.md"), "must ignore the query cwd");
|
||||
assert.equal(fs.readFileSync(path.join(victim, "CLAUDE.md"), "utf8"), "UNTOUCHED");
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
|
||||
it("DELETE /file ignores ?cwd= too", async () => {
|
||||
const victim = path.join(TMP, "victim-delete");
|
||||
fs.mkdirSync(victim, { recursive: true });
|
||||
fs.writeFileSync(path.join(victim, "CLAUDE.md"), "UNTOUCHED");
|
||||
fs.writeFileSync(path.join(FAKE_PROJECT, "CLAUDE.md"), "# server-cwd memory");
|
||||
|
||||
const prev = process.cwd();
|
||||
process.chdir(FAKE_PROJECT);
|
||||
try {
|
||||
const { status } = await fetchJson(`/api/cc-config/file?cwd=${encodeURIComponent(victim)}`, {
|
||||
method: "DELETE",
|
||||
body: { scope: "project", type: "memory" },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.equal(fs.existsSync(path.join(victim, "CLAUDE.md")), true, "victim must survive");
|
||||
assert.equal(fs.existsSync(path.join(FAKE_PROJECT, "CLAUDE.md")), false);
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
|
||||
it("DELETE /file backs up and removes a single-file agent", async () => {
|
||||
@@ -1058,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @file cc-mutate-containment.test.js
|
||||
* @description Path-containment tests for cc-mutate.js's artifact writer.
|
||||
*
|
||||
* `isUnder` compares strings, which a symlink defeats: a link planted at
|
||||
* `~/.claude/skills/<name>` — or at any ancestor of it — is lexically inside
|
||||
* the root while the write lands wherever the link points. The Codex writer
|
||||
* hit both variants (leaf first, then ancestor on a fresh create, where
|
||||
* realpath fails with ENOENT and a naive check waves it through), and the
|
||||
* backup DESTINATION is a third path with the same exposure.
|
||||
*
|
||||
* Every case here asserts the refusal AND that nothing appeared outside the
|
||||
* root — a thrown error with the write already done is not a fix.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, after } = 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 TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-mutate-containment-"));
|
||||
const FAKE_HOME = path.join(TMP, "home", ".claude");
|
||||
const OUTSIDE = path.join(TMP, "outside");
|
||||
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
fs.mkdirSync(OUTSIDE, { recursive: true });
|
||||
|
||||
process.env.CLAUDE_HOME = FAKE_HOME;
|
||||
const ccMutate = require("../lib/cc-mutate");
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 }));
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(FAKE_HOME, { recursive: true, force: true });
|
||||
fs.rmSync(OUTSIDE, { recursive: true, force: true });
|
||||
fs.mkdirSync(FAKE_HOME, { recursive: true });
|
||||
fs.mkdirSync(OUTSIDE, { recursive: true });
|
||||
});
|
||||
|
||||
const write = (over = {}) =>
|
||||
ccMutate.writeArtifact({
|
||||
scope: "user",
|
||||
type: "skills",
|
||||
name: "victim",
|
||||
content: "NEW",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("cc-mutate: symlink containment on the write path", () => {
|
||||
it("refuses a leaf symlink pointing out of the root, and writes nothing there", () => {
|
||||
const outsideDir = path.join(OUTSIDE, "target");
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outsideDir, "SKILL.md"), "ORIGINAL");
|
||||
fs.mkdirSync(path.join(FAKE_HOME, "skills"), { recursive: true });
|
||||
fs.symlinkSync(outsideDir, path.join(FAKE_HOME, "skills", "victim"));
|
||||
|
||||
assert.throws(
|
||||
() => write(),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(outsideDir, "SKILL.md"), "utf8"), "ORIGINAL");
|
||||
});
|
||||
|
||||
it("refuses a symlinked ANCESTOR even when the artifact itself is new", () => {
|
||||
// The case a leaf-only check misses: realpath on a not-yet-existing leaf
|
||||
// throws ENOENT, so the walk has to climb to the symlinked parent.
|
||||
fs.symlinkSync(OUTSIDE, path.join(FAKE_HOME, "skills"));
|
||||
|
||||
assert.throws(
|
||||
() => write({ name: "fresh" }),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(OUTSIDE, "fresh")), false);
|
||||
});
|
||||
|
||||
it("refuses to back up through a symlinked backup root", () => {
|
||||
// rmSync/unlink only remove the link, but the backup runs first and
|
||||
// copies THROUGH it — so the destination needs the guard too.
|
||||
fs.mkdirSync(path.join(FAKE_HOME, "skills", "victim"), { recursive: true });
|
||||
fs.writeFileSync(path.join(FAKE_HOME, "skills", "victim", "SKILL.md"), "OLD");
|
||||
fs.symlinkSync(OUTSIDE, path.join(FAKE_HOME, "cc-config-backups"));
|
||||
|
||||
assert.throws(
|
||||
() => write(),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(OUTSIDE), []);
|
||||
// the original is untouched — the backup failing must not have written
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(FAKE_HOME, "skills", "victim", "SKILL.md"), "utf8"),
|
||||
"OLD"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a delete whose target is a symlink out of the root", () => {
|
||||
const outsideFile = path.join(OUTSIDE, "SKILL.md");
|
||||
fs.writeFileSync(outsideFile, "ORIGINAL");
|
||||
fs.mkdirSync(path.join(FAKE_HOME, "skills"), { recursive: true });
|
||||
fs.symlinkSync(OUTSIDE, path.join(FAKE_HOME, "skills", "victim"));
|
||||
|
||||
assert.throws(
|
||||
() => ccMutate.deleteArtifact({ scope: "user", type: "skills", name: "victim" }),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.readFileSync(outsideFile, "utf8"), "ORIGINAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cc-mutate: symlinks that stay inside the root still work", () => {
|
||||
it("writes through a symlink whose target is inside the root", () => {
|
||||
// Reorganising within ~/.claude is legitimate; the rule is where the path
|
||||
// lands, not whether a link was involved.
|
||||
const real = path.join(FAKE_HOME, "real-skills");
|
||||
fs.mkdirSync(real, { recursive: true });
|
||||
fs.symlinkSync(real, path.join(FAKE_HOME, "skills"));
|
||||
|
||||
const res = write({ name: "ok" });
|
||||
assert.equal(res.created, true);
|
||||
assert.equal(fs.readFileSync(path.join(real, "ok", "SKILL.md"), "utf8"), "NEW");
|
||||
});
|
||||
|
||||
it("works when CLAUDE_HOME itself is a symlink — the dotfiles case", () => {
|
||||
// A dotfiles-managed ~/.claude is ordinary. Comparing an unresolved root
|
||||
// against a resolved target would reject every write on such a machine.
|
||||
const realHome = path.join(TMP, "real-home");
|
||||
fs.rmSync(realHome, { recursive: true, force: true });
|
||||
fs.mkdirSync(realHome, { recursive: true });
|
||||
const linkedHome = path.join(TMP, "linked-home");
|
||||
fs.rmSync(linkedHome, { recursive: true, force: true });
|
||||
fs.symlinkSync(realHome, linkedHome);
|
||||
|
||||
process.env.CLAUDE_HOME = linkedHome;
|
||||
try {
|
||||
const res = write({ name: "ok" });
|
||||
assert.equal(res.created, true);
|
||||
assert.equal(fs.readFileSync(path.join(realHome, "skills", "ok", "SKILL.md"), "utf8"), "NEW");
|
||||
} finally {
|
||||
process.env.CLAUDE_HOME = FAKE_HOME;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @file Tests for server/lib/codex-cli.js — the `codex mcp`/`codex plugin`
|
||||
* wrapper that backs the Codex config panel's MCP-server and plugin
|
||||
* management. Uses a fake `codex` script on PATH (the fixture style
|
||||
* server/__tests__/cc-plugin-cli.test.js uses) rather than mocking
|
||||
* child_process, and pins the exact argv each operation builds — that argv
|
||||
* IS the security boundary.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-codexcli-"));
|
||||
const cli = require("../lib/codex-cli");
|
||||
|
||||
/**
|
||||
* Put a fake `codex` on PATH. The default body echoes the argv, one element
|
||||
* per line, so a test can assert the exact array that was spawned.
|
||||
*/
|
||||
function stubCodex(scriptBody = 'for a in "$@"; do echo "$a"; done') {
|
||||
const bin = path.join(ROOT, `bin-${process.hrtime.bigint()}`);
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const file = path.join(bin, "codex");
|
||||
fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`);
|
||||
fs.chmodSync(file, 0o755);
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
|
||||
return () => {
|
||||
process.env.PATH = originalPath;
|
||||
};
|
||||
}
|
||||
|
||||
/** PATH with no `codex` on it at all. */
|
||||
function stubMissingCodex() {
|
||||
const empty = path.join(ROOT, `empty-${process.hrtime.bigint()}`);
|
||||
fs.mkdirSync(empty, { recursive: true });
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = empty;
|
||||
return () => {
|
||||
process.env.PATH = originalPath;
|
||||
};
|
||||
}
|
||||
|
||||
const argvOf = (result) => result.stdout.trim().split("\n");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true, maxRetries: 5 }));
|
||||
|
||||
describe("codex-cli argument validation", () => {
|
||||
it("refuses a name that could be read as a flag or a path", async () => {
|
||||
await assert.rejects(() => cli.mcpRemove("--force"), /name/i);
|
||||
await assert.rejects(() => cli.mcpRemove("../../etc"), /name/i);
|
||||
await assert.rejects(() => cli.pluginRemove("--force"), /spec/i);
|
||||
await assert.rejects(() => cli.pluginRemove("../../etc"), /spec/i);
|
||||
await assert.rejects(() => cli.marketplaceRemove("--force"), /name/i);
|
||||
await assert.rejects(() => cli.mcpAdd({ name: "--force", command: "node" }), /name/i);
|
||||
});
|
||||
|
||||
it("accepts a plain identifier as a name", () => {
|
||||
assert.match("docs", cli.NAME_RE);
|
||||
assert.doesNotMatch("--force", cli.NAME_RE);
|
||||
assert.doesNotMatch("../../etc", cli.NAME_RE);
|
||||
});
|
||||
|
||||
it("accepts <name> and <name>@<marketplace> plugin specs only", () => {
|
||||
assert.equal(cli.assertPluginSpec("agenthub"), "agenthub");
|
||||
assert.equal(cli.assertPluginSpec("agenthub@sg"), "agenthub@sg");
|
||||
for (const bad of ["../agenthub", "agenthub agenthub", "@sg", "-agenthub", "a/b"]) {
|
||||
assert.throws(() => cli.assertPluginSpec(bad), undefined, `accepted ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts only http(s) URLs (mcpAdd's --url form)", () => {
|
||||
assert.equal(cli.assertUrl("https://example.com/mp.git", "url"), "https://example.com/mp.git");
|
||||
for (const bad of ["ssh://example.com/mp.git", "not a url", "file:///tmp/x"]) {
|
||||
assert.throws(() => cli.assertUrl(bad, "url"), /http\(s\)/, `accepted ${bad}`);
|
||||
}
|
||||
// rejected earlier, by the shared leading-dash guard, not the URL check
|
||||
assert.throws(() => cli.assertUrl("--force", "url"), /may not start with/);
|
||||
});
|
||||
|
||||
it("marketplaceAdd accepts the source forms `codex plugin marketplace add` actually takes", async () => {
|
||||
// Not assertUrl: `codex plugin marketplace add <SOURCE>` also takes
|
||||
// owner/repo and a local path, which a bare http(s)-only check rejected.
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
assert.deepEqual(await cli.marketplaceAdd("openai/codex").then(argvOf), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"openai/codex",
|
||||
]);
|
||||
assert.deepEqual(await cli.marketplaceAdd("/abs/local/path").then(argvOf), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"/abs/local/path",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
// ssh remotes are deliberately out of scope, same as cc-plugin-cli's
|
||||
// marketplace source guard — no credential/host-key story for them yet.
|
||||
await assert.rejects(() => cli.marketplaceAdd("git@github.com:owner/repo.git"), /source/);
|
||||
await assert.rejects(() => cli.marketplaceAdd("../escape"), /source/);
|
||||
});
|
||||
|
||||
it("rejects mcpAdd with neither or both of url/command", async () => {
|
||||
await assert.rejects(() => cli.mcpAdd({ name: "docs" }), /requires either/);
|
||||
await assert.rejects(
|
||||
() => cli.mcpAdd({ name: "docs", url: "https://x", command: "node" }),
|
||||
/either url or command, not both/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects env together with url — --env is invalid on the url form", async () => {
|
||||
await assert.rejects(
|
||||
() => cli.mcpAdd({ name: "docs", url: "https://x", env: { A: "1" } }),
|
||||
/stdio-only/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an env key that is not a valid identifier", async () => {
|
||||
await assert.rejects(
|
||||
() => cli.mcpAdd({ name: "docs", command: "node", env: { "1bad": "1" } }),
|
||||
/identifier/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex-cli argv construction", () => {
|
||||
it("passes the server name as one argv entry, never through a shell", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
const result = await cli.mcpAdd({ name: "docs", command: "node", args: ["server.js"] });
|
||||
assert.deepEqual(argvOf(result), ["mcp", "add", "docs", "--", "node", "server.js"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("puts --env flags between NAME and -- for a stdio server", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
const result = await cli.mcpAdd({
|
||||
name: "docs",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
env: { API_KEY: "secret", MODE: "prod" },
|
||||
});
|
||||
assert.deepEqual(argvOf(result), [
|
||||
"mcp",
|
||||
"add",
|
||||
"docs",
|
||||
"--env",
|
||||
"API_KEY=secret",
|
||||
"--env",
|
||||
"MODE=prod",
|
||||
"--",
|
||||
"node",
|
||||
"server.js",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds the --url form with an optional --bearer-token-env-var", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
const result = await cli.mcpAdd({
|
||||
name: "docs",
|
||||
url: "https://example.com/mcp",
|
||||
bearerTokenEnvVar: "DOCS_TOKEN",
|
||||
});
|
||||
assert.deepEqual(argvOf(result), [
|
||||
"mcp",
|
||||
"add",
|
||||
"docs",
|
||||
"--bearer-token-env-var",
|
||||
"DOCS_TOKEN",
|
||||
"--url",
|
||||
"https://example.com/mcp",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an env value starting with '-' — it's the RHS of one KEY=VALUE argv element, never a flag", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
const result = await cli.mcpAdd({
|
||||
name: "docs",
|
||||
command: "node",
|
||||
env: { OFFSET: "-1" },
|
||||
});
|
||||
assert.deepEqual(argvOf(result), ["mcp", "add", "docs", "--env", "OFFSET=-1", "--", "node"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds mcp list / remove argvs", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
assert.deepEqual(argvOf(await cli.mcpList()), ["mcp", "list", "--json"]);
|
||||
assert.deepEqual(argvOf(await cli.mcpRemove("docs")), ["mcp", "remove", "docs"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds plugin list / add / remove argvs", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
assert.deepEqual(argvOf(await cli.pluginList()), ["plugin", "list", "--json"]);
|
||||
assert.deepEqual(argvOf(await cli.pluginAdd("demo@sg")), ["plugin", "add", "demo@sg"]);
|
||||
assert.deepEqual(argvOf(await cli.pluginRemove("demo")), ["plugin", "remove", "demo"]);
|
||||
// `plugin list` reports marketplace-scoped plugins as `<name>@<marketplace>`,
|
||||
// and `plugin add` takes that form — so remove has to accept it back.
|
||||
assert.deepEqual(argvOf(await cli.pluginRemove("demo@sg")), ["plugin", "remove", "demo@sg"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds marketplace list / add / remove argvs, nested under plugin", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
assert.deepEqual(argvOf(await cli.marketplaceList()), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"list",
|
||||
"--json",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.marketplaceAdd("https://example.com/mp.git")), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://example.com/mp.git",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.marketplaceRemove("sg")), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"remove",
|
||||
"sg",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("never lets a rejected name reach a shell", async () => {
|
||||
// If the value were interpolated into a shell string this would create
|
||||
// the file; the NAME_RE guard rejects it before any spawn happens.
|
||||
const canary = path.join(ROOT, "pwned");
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
await assert.rejects(() => cli.mcpRemove("docs; touch " + canary), /name/i);
|
||||
assert.equal(fs.existsSync(canary), false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex-cli run outcomes", () => {
|
||||
it("surfaces a non-zero exit as ok:false with stderr kept", async () => {
|
||||
const restore = stubCodex('echo "no such marketplace" >&2\nexit 2');
|
||||
try {
|
||||
const res = await cli.marketplaceRemove("nope");
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, 2);
|
||||
assert.match(res.stderr, /no such marketplace/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws a clear error when the codex CLI is not on PATH", async () => {
|
||||
const restore = stubMissingCodex();
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => cli.mcpList(),
|
||||
(err) => {
|
||||
assert.equal(err.code, "ECLINOTFOUND");
|
||||
assert.match(err.message, /not found on PATH/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses JSON output into `data`", async () => {
|
||||
const restore = stubCodex(`cat <<'JSON'\n{"servers":[{"name":"docs"}]}\nJSON`);
|
||||
try {
|
||||
const res = await cli.mcpList();
|
||||
assert.equal(res.ok, true);
|
||||
assert.deepEqual(res.data, { servers: [{ name: "docs" }] });
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps `codex plugin list --json`'s {installed, available} object into a bare array", async () => {
|
||||
// Real codex-cli 0.149.0 shape — an object, unlike `mcp list`'s array.
|
||||
const restore = stubCodex(
|
||||
`cat <<'JSON'\n{"installed":[{"name":"demo"}],"available":[{"name":"other"}]}\nJSON`
|
||||
);
|
||||
try {
|
||||
const res = await cli.pluginList();
|
||||
assert.equal(res.ok, true);
|
||||
assert.deepEqual(res.data, [{ name: "demo" }]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a literal `null` on stdout as an empty list, not a crash", async () => {
|
||||
// `null` is valid JSON, so it parses and slips past the unparseable-output
|
||||
// guard. Reading `.installed` off it would surface as a 500 from a route
|
||||
// whose whole contract is that CLI output never produces one.
|
||||
const restore = stubCodex("echo null");
|
||||
try {
|
||||
assert.deepEqual((await cli.pluginList()).data, []);
|
||||
assert.deepEqual((await cli.marketplaceList()).data, []);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps `codex plugin marketplace list --json`'s {marketplaces} object into a bare array", async () => {
|
||||
const restore = stubCodex(`cat <<'JSON'\n{"marketplaces":[{"name":"sg"}]}\nJSON`);
|
||||
try {
|
||||
const res = await cli.marketplaceList();
|
||||
assert.equal(res.ok, true);
|
||||
assert.deepEqual(res.data, [{ name: "sg" }]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves `data` undefined when stdout is not JSON", async () => {
|
||||
const restore = stubCodex('echo "removed docs"');
|
||||
try {
|
||||
const res = await cli.mcpRemove("docs");
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.data, undefined);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes mutating calls so two clicks cannot interleave", async () => {
|
||||
const log = path.join(ROOT, `order-${process.hrtime.bigint()}.txt`);
|
||||
const restore = stubCodex(`echo "start $2" >> ${log}\nsleep 0.2\necho "end $2" >> ${log}`);
|
||||
try {
|
||||
await Promise.all([cli.mcpRemove("first"), cli.mcpRemove("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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* @file codex-config.test.js
|
||||
* @description Tests for /api/codex-config — the Codex CLI configuration
|
||||
* explorer. Builds a fake CODEX_HOME and project .codex/ in tmpdir, points
|
||||
* the server at it, and exercises every read/write surface plus
|
||||
* path-containment guards, backups, and the CLI-backed MCP/plugin/
|
||||
* marketplace routes (via a stubbed `codex` binary on PATH).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = 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 { WebSocket } = require("ws");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-test-"));
|
||||
const FAKE_HOME = path.join(TMP, "home", ".codex");
|
||||
const FAKE_PROJECT = path.join(TMP, "project");
|
||||
const FAKE_PROJECT_CODEX = path.join(FAKE_PROJECT, ".codex");
|
||||
|
||||
fs.mkdirSync(path.join(FAKE_HOME, "skills", "demo-skill"), { recursive: true });
|
||||
fs.mkdirSync(path.join(FAKE_HOME, "agents"), { recursive: true });
|
||||
fs.mkdirSync(path.join(FAKE_HOME, "rules"), { recursive: true });
|
||||
fs.mkdirSync(path.join(FAKE_PROJECT_CODEX, "skills", "proj-skill"), { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(FAKE_HOME, "skills", "demo-skill", "SKILL.md"),
|
||||
`---\nname: demo-skill\ndescription: A demo skill for tests\n---\n\nBody text here.`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(FAKE_PROJECT_CODEX, "skills", "proj-skill", "SKILL.md"),
|
||||
`---\nname: proj-skill\n---\n\nProject skill.`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(FAKE_HOME, "agents", "demo-agent.toml"),
|
||||
`name = "demo-agent"\nmodel = "gpt-5"\n`
|
||||
);
|
||||
fs.writeFileSync(path.join(FAKE_HOME, "rules", "default.rules"), "allow");
|
||||
fs.writeFileSync(path.join(FAKE_HOME, "config.toml"), 'model = "gpt-5"\n');
|
||||
fs.writeFileSync(path.join(FAKE_PROJECT, "AGENTS.md"), "# Project memory\nHello.");
|
||||
|
||||
process.env.CODEX_HOME = FAKE_HOME;
|
||||
const TEST_DB = path.join(TMP, "dashboard-test.db");
|
||||
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
function fetchJson(p, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(p, BASE);
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
let bodyBuf;
|
||||
if (opts.body !== undefined) {
|
||||
bodyBuf = Buffer.from(JSON.stringify(opts.body));
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Content-Length"] = bodyBuf.length;
|
||||
}
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname + url.search,
|
||||
method: opts.method || "GET",
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
json = body;
|
||||
}
|
||||
resolve({ status: res.statusCode, body: json });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
if (bodyBuf) req.write(bodyBuf);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function stubCodex(scriptBody) {
|
||||
const bin = path.join(TMP, `bin-${process.hrtime.bigint()}`);
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const file = path.join(bin, "codex");
|
||||
fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`);
|
||||
fs.chmodSync(file, 0o755);
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
|
||||
return () => {
|
||||
process.env.PATH = originalPath;
|
||||
};
|
||||
}
|
||||
|
||||
const ECHO_ARGV = 'for a in "$@"; do echo "$a"; done';
|
||||
|
||||
describe("/api/codex-config", () => {
|
||||
before(async () => {
|
||||
const app = createApp();
|
||||
server = await startServer(app, 0);
|
||||
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 */
|
||||
}
|
||||
});
|
||||
|
||||
it("overview reports counts and roots", async () => {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/overview?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.roots.codexHome, FAKE_HOME);
|
||||
assert.equal(body.roots.projectCodexDir, FAKE_PROJECT_CODEX);
|
||||
assert.equal(body.roots.projectRoot, FAKE_PROJECT);
|
||||
assert.equal(body.counts.skills.user, 1);
|
||||
assert.equal(body.counts.skills.project, 1);
|
||||
assert.equal(body.counts.agents.user, 1);
|
||||
assert.equal(body.counts.rules.user, 1);
|
||||
});
|
||||
|
||||
it("lists skills for both scopes", async () => {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/skills?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.ok(Array.isArray(body.skills));
|
||||
const demo = body.skills.find((s) => s.name === "demo-skill");
|
||||
assert.equal(demo.scope, "user");
|
||||
assert.equal(demo.frontmatter.name, "demo-skill");
|
||||
const proj = body.skills.find((s) => s.name === "proj-skill");
|
||||
assert.equal(proj.scope, "project");
|
||||
});
|
||||
|
||||
it("scope=user filters out project items", async () => {
|
||||
const { body } = await fetchJson(
|
||||
`/api/codex-config/skills?scope=user&cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
||||
);
|
||||
assert.ok(body.skills.every((s) => s.scope === "user"));
|
||||
});
|
||||
|
||||
it("agents parses top-level TOML scalars", async () => {
|
||||
const { body } = await fetchJson(
|
||||
`/api/codex-config/agents?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
||||
);
|
||||
const a = body.agents.find((x) => x.name === "demo-agent");
|
||||
assert.equal(a.meta.model, "gpt-5");
|
||||
});
|
||||
|
||||
it("rules returns preview text", async () => {
|
||||
const { body } = await fetchJson(
|
||||
`/api/codex-config/rules?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
||||
);
|
||||
const r = body.rules.find((x) => x.name === "default");
|
||||
assert.match(r.preview, /allow/);
|
||||
});
|
||||
|
||||
it("memory returns project AGENTS.md", async () => {
|
||||
const { body } = await fetchJson(
|
||||
`/api/codex-config/memory?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
||||
);
|
||||
const proj = body.memory.find((x) => x.scope === "project");
|
||||
assert.equal(proj.exists, true);
|
||||
assert.match(proj.preview, /Project memory/);
|
||||
});
|
||||
|
||||
it("config returns user config.toml content", async () => {
|
||||
const { body } = await fetchJson(
|
||||
`/api/codex-config/config?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
||||
);
|
||||
const user = body.config.find((x) => x.scope === "user");
|
||||
assert.equal(user.exists, true);
|
||||
assert.match(user.content, /gpt-5/);
|
||||
});
|
||||
|
||||
it("file endpoint reads inside CODEX_HOME", async () => {
|
||||
const target = path.join(FAKE_HOME, "rules", "default.rules");
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.text, "allow");
|
||||
});
|
||||
|
||||
it("file endpoint blocks paths outside allowed roots", async () => {
|
||||
const outside = path.join(TMP, "evil.rules");
|
||||
fs.writeFileSync(outside, "secret");
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(outside)}`
|
||||
);
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "READ_DENIED");
|
||||
});
|
||||
|
||||
it("file endpoint blocks a ?cwd=-driven escape (path outside the roots ?cwd= computes)", async () => {
|
||||
const otherCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-othercwd-"));
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(otherCwd)}&path=${encodeURIComponent(
|
||||
path.join(FAKE_HOME, "..", "..", "etc", "passwd")
|
||||
)}`
|
||||
);
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "READ_DENIED");
|
||||
fs.rmSync(otherCwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("file endpoint follows a leaf symlink whose LOCATION is inside CODEX_HOME (dotfiles setup)", async () => {
|
||||
// Ruling: containment applies to the link's location, not where it
|
||||
// points — the same thing the other four discovery readers already do.
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-outside-"));
|
||||
fs.writeFileSync(path.join(outside, "secret.rules"), "MY DOTFILES RULE");
|
||||
const linkPath = path.join(FAKE_HOME, "rules", "leaf-link.rules");
|
||||
fs.symlinkSync(path.join(outside, "secret.rules"), linkPath, "file");
|
||||
try {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(linkPath)}`
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.text, "MY DOTFILES RULE");
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("file endpoint follows an ancestor symlink whose LOCATION is inside CODEX_HOME", async () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-outside-"));
|
||||
fs.writeFileSync(path.join(outside, "linked.txt"), "MY DOTFILES ANCESTOR");
|
||||
const linkDir = path.join(FAKE_HOME, "linked-dir");
|
||||
fs.symlinkSync(outside, linkDir, "dir");
|
||||
try {
|
||||
const target = path.join(linkDir, "linked.txt");
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.text, "MY DOTFILES ANCESTOR");
|
||||
} finally {
|
||||
fs.rmSync(linkDir, { force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("file endpoint 404s on a missing file inside an existing root", async () => {
|
||||
const target = path.join(FAKE_HOME, "rules", "does-not-exist.rules");
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
|
||||
);
|
||||
assert.equal(status, 404);
|
||||
assert.equal(body.error.code, "NOT_FOUND");
|
||||
});
|
||||
|
||||
it("file endpoint 404s on a ?cwd= that doesn't exist yet, and creates nothing", async () => {
|
||||
const missingCwd = path.join(TMP, `never-existed-${process.hrtime.bigint()}`);
|
||||
const target = path.join(missingCwd, "AGENTS.md");
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(missingCwd)}&path=${encodeURIComponent(target)}`
|
||||
);
|
||||
assert.equal(status, 404);
|
||||
assert.equal(body.error.code, "NOT_FOUND");
|
||||
assert.equal(fs.existsSync(missingCwd), false, "GET /file must not create the missing root");
|
||||
});
|
||||
|
||||
it("file endpoint requires a path", async () => {
|
||||
const { status, body } = await fetchJson("/api/codex-config/file");
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "BAD_PATH");
|
||||
});
|
||||
|
||||
it("PUT /file creates a new rule (no backup, file did not exist)", async () => {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: { scope: "user", type: "rules", name: "fresh", content: "deny" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.created, true);
|
||||
assert.equal(body.backup, null);
|
||||
assert.equal(fs.readFileSync(body.file, "utf8"), "deny");
|
||||
});
|
||||
|
||||
it("PUT /file overwrites an existing rule and creates a backup, and broadcasts codex_config_changed", async () => {
|
||||
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const messages = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on("open", resolve);
|
||||
ws.on("error", reject);
|
||||
});
|
||||
ws.on("message", (msg) => {
|
||||
try {
|
||||
messages.push(JSON.parse(msg));
|
||||
} catch {
|
||||
/* parse error */
|
||||
}
|
||||
});
|
||||
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: { scope: "user", type: "rules", name: "default", content: "deny-all" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.created, false);
|
||||
assert.ok(body.backup, "backup metadata returned");
|
||||
assert.match(fs.readFileSync(body.file, "utf8"), /deny-all/);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
ws.close();
|
||||
assert.ok(
|
||||
messages.some((m) => m.type === "codex_config_changed"),
|
||||
"PUT /file should broadcast codex_config_changed"
|
||||
);
|
||||
});
|
||||
|
||||
it("PUT /file ignores ?cwd= for project scope — writes to the server's own cwd, not a caller-chosen dir", async () => {
|
||||
const victim = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-victim-"));
|
||||
const originalCwd = process.cwd();
|
||||
const serverCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-servercwd-"));
|
||||
process.chdir(serverCwd);
|
||||
try {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(victim)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: { scope: "project", type: "memory", content: "PWNED" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(fs.existsSync(path.join(victim, "AGENTS.md")), false, "must not write to ?cwd=");
|
||||
assert.equal(body.file, path.join(serverCwd, "AGENTS.md"));
|
||||
assert.equal(fs.readFileSync(body.file, "utf8"), "PWNED");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(victim, { recursive: true, force: true });
|
||||
fs.rmSync(serverCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("DELETE /file ignores ?cwd= for project scope", async () => {
|
||||
const victim = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-victim-"));
|
||||
fs.writeFileSync(path.join(victim, ".codex-marker"), "x"); // proves this dir wasn't touched
|
||||
const originalCwd = process.cwd();
|
||||
const serverCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-servercwd-"));
|
||||
fs.mkdirSync(path.join(serverCwd, ".codex", "rules"), { recursive: true });
|
||||
fs.writeFileSync(path.join(serverCwd, ".codex", "rules", "victim.rules"), "real");
|
||||
process.chdir(serverCwd);
|
||||
try {
|
||||
const { status } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(victim)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: { scope: "project", type: "rules", name: "victim" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(fs.existsSync(path.join(victim, ".codex-marker")), true, "?cwd= dir left alone");
|
||||
assert.equal(fs.existsSync(path.join(serverCwd, ".codex", "rules", "victim.rules")), false);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(victim, { recursive: true, force: true });
|
||||
fs.rmSync(serverCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("PUT /file rejects a write whose name escapes the root with 400, not 500", async () => {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: { scope: "user", type: "agents", name: "../evil", content: "x" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADNAME");
|
||||
});
|
||||
|
||||
it("PUT /file rejects unknown type", async () => {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: { scope: "user", type: "plugins", name: "x", content: "y" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADTYPE");
|
||||
});
|
||||
|
||||
it("PUT /file rejects a name on a fixed-filename type (memory)", async () => {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: { scope: "project", type: "memory", name: "nope", content: "x" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADNAME");
|
||||
});
|
||||
|
||||
it("PUT /file rejects oversize content", async () => {
|
||||
const huge = "x".repeat(256 * 1024 + 1);
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: { scope: "user", type: "rules", name: "huge", content: huge },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 413);
|
||||
assert.equal(body.error.code, "ETOOLARGE");
|
||||
});
|
||||
|
||||
it("DELETE /file backs up and removes a rule", async () => {
|
||||
fs.writeFileSync(path.join(FAKE_HOME, "rules", "to-delete.rules"), "bye");
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: { scope: "user", type: "rules", name: "to-delete" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.ok(body.backup);
|
||||
assert.equal(fs.existsSync(path.join(FAKE_HOME, "rules", "to-delete.rules")), false);
|
||||
});
|
||||
|
||||
it("DELETE /file 404s on a missing item", async () => {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: { scope: "user", type: "rules", name: "never-existed" },
|
||||
}
|
||||
);
|
||||
assert.equal(status, 404);
|
||||
assert.equal(body.error.code, "ENOTFOUND");
|
||||
});
|
||||
|
||||
it("backups endpoint lists everything we just created", async () => {
|
||||
const { status, body } = await fetchJson(
|
||||
`/api/codex-config/backups?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
||||
);
|
||||
assert.equal(status, 200);
|
||||
assert.ok(Array.isArray(body.items));
|
||||
assert.ok(body.items.length >= 2, `expected >=2 backups, got ${body.items.length}`);
|
||||
});
|
||||
|
||||
// ── CLI-backed routes (fake `codex` on PATH) ────────────────────────
|
||||
|
||||
it("GET /mcp returns the CLI's JSON output", async () => {
|
||||
const restore = stubCodex(`cat <<'JSON'\n{"servers":[{"name":"docs"}]}\nJSON`);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/mcp");
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.mcp, { servers: [{ name: "docs" }] });
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /plugins unwraps the CLI's {installed, available} object into a bare array", async () => {
|
||||
// Real codex-cli 0.149.0 shape: `codex plugin list --json` returns an
|
||||
// object, not an array like `mcp list` — without the unwrap the Plugins
|
||||
// tab silently showed nothing (Array.isArray(res.plugins) was false).
|
||||
const restore = stubCodex(
|
||||
`cat <<'JSON'\n{"installed":[{"name":"demo"}],"available":[{"name":"other"}]}\nJSON`
|
||||
);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/plugins");
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.plugins, [{ name: "demo" }]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /marketplaces unwraps the CLI's {marketplaces} object into a bare array", async () => {
|
||||
const restore = stubCodex(`cat <<'JSON'\n{"marketplaces":[{"name":"sg"}]}\nJSON`);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/marketplaces");
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.marketplaces, [{ name: "sg" }]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /mcp|/plugins|/marketplaces treat empty stdout as an empty list (fresh-install case), not a 502", async () => {
|
||||
const restore = stubCodex(""); // exits 0, prints nothing — the normal fresh-install state
|
||||
try {
|
||||
for (const [route, key] of [
|
||||
["/api/codex-config/mcp", "mcp"],
|
||||
["/api/codex-config/plugins", "plugins"],
|
||||
["/api/codex-config/marketplaces", "marketplaces"],
|
||||
]) {
|
||||
const { status, body } = await fetchJson(route);
|
||||
assert.equal(status, 200, route);
|
||||
assert.deepEqual(body[key], [], route);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /mcp reports unparseable CLI JSON as 502, not a type-confused 200", async () => {
|
||||
const restore = stubCodex('echo "not json"');
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/mcp");
|
||||
assert.equal(status, 502);
|
||||
assert.equal(body.error.code, "ECLIBADOUTPUT");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /mcp/add rejects a non-array args with 400, not a 500", async () => {
|
||||
const restore = stubCodex(ECHO_ARGV);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/mcp/add", {
|
||||
method: "POST",
|
||||
body: { name: "docs", command: "node", args: "oops" },
|
||||
});
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADARG");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /mcp/add runs the CLI with the given argv", async () => {
|
||||
const restore = stubCodex(ECHO_ARGV);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/mcp/add", {
|
||||
method: "POST",
|
||||
body: { name: "docs", url: "https://example.com/mcp" },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.ok, true);
|
||||
assert.deepEqual(body.stdout.trim().split("\n"), [
|
||||
"mcp",
|
||||
"add",
|
||||
"docs",
|
||||
"--url",
|
||||
"https://example.com/mcp",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /mcp/remove runs the CLI", async () => {
|
||||
const restore = stubCodex(ECHO_ARGV);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/mcp/remove", {
|
||||
method: "POST",
|
||||
body: { name: "docs" },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.stdout.trim().split("\n"), ["mcp", "remove", "docs"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /plugins/action validates the action name", async () => {
|
||||
const { status, body } = await fetchJson("/api/codex-config/plugins/action", {
|
||||
method: "POST",
|
||||
body: { spec: "demo", action: "purge" },
|
||||
});
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADACTION");
|
||||
});
|
||||
|
||||
it("POST /plugins/action adds through the CLI", async () => {
|
||||
const restore = stubCodex(ECHO_ARGV);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/plugins/action", {
|
||||
method: "POST",
|
||||
body: { spec: "demo@market", action: "add" },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.stdout.trim().split("\n"), ["plugin", "add", "demo@market"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /marketplaces/add runs the CLI with the pasted url", async () => {
|
||||
const restore = stubCodex(ECHO_ARGV);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
|
||||
method: "POST",
|
||||
body: { url: "https://example.com/mp.git" },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.stdout.trim().split("\n"), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://example.com/mp.git",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /marketplaces/add accepts an owner/repo source (not just http(s) urls)", async () => {
|
||||
const restore = stubCodex(ECHO_ARGV);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
|
||||
method: "POST",
|
||||
body: { url: "openai/codex" },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.stdout.trim().split("\n"), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"openai/codex",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /marketplaces/add rejects a source that is none of path/owner-repo/http(s) url", async () => {
|
||||
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
|
||||
method: "POST",
|
||||
body: { url: "not a url" },
|
||||
});
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADARG");
|
||||
});
|
||||
|
||||
it("reports a failed CLI call as 422 and keeps the stderr", async () => {
|
||||
const restore = stubCodex('echo "✘ marketplace \\"nope\\" is not registered" >&2\nexit 1');
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
|
||||
method: "POST",
|
||||
body: { name: "nope", action: "remove" },
|
||||
});
|
||||
assert.equal(status, 422);
|
||||
assert.equal(body.error.code, "ECLIEXIT");
|
||||
assert.match(body.error.message, /not registered/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /marketplaces/action validates the action name", async () => {
|
||||
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
|
||||
method: "POST",
|
||||
body: { name: "demo-mp", action: "update" },
|
||||
});
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADACTION");
|
||||
});
|
||||
|
||||
it("reports a missing `codex` binary as 503 rather than a crash", async () => {
|
||||
const empty = path.join(TMP, `empty-${process.hrtime.bigint()}`);
|
||||
fs.mkdirSync(empty, { recursive: true });
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = empty;
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
|
||||
method: "POST",
|
||||
body: { name: "demo-mp", action: "remove" },
|
||||
});
|
||||
assert.equal(status, 503);
|
||||
assert.equal(body.error.code, "ECLINOTFOUND");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @file Tests for codex-discovery.js: read-only discovery of Codex config
|
||||
* surfaces (skills, agents, rules, memory, config) across the user (CODEX_HOME)
|
||||
* and project (<cwd>/.codex) scopes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
function write(file, content) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
|
||||
describe("codex-discovery", () => {
|
||||
let TMP;
|
||||
let REPO;
|
||||
let prevCodexHome;
|
||||
let cx;
|
||||
|
||||
before(() => {
|
||||
prevCodexHome = process.env.CODEX_HOME;
|
||||
TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-"));
|
||||
REPO = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-repo-"));
|
||||
process.env.CODEX_HOME = TMP;
|
||||
delete require.cache[require.resolve("../lib/codex-home")];
|
||||
delete require.cache[require.resolve("../lib/codex-discovery")];
|
||||
cx = require("../lib/codex-discovery");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodexHome;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("reads a user skill and a project skill into one scoped list", () => {
|
||||
write(
|
||||
path.join(TMP, "skills", "release-guard", "SKILL.md"),
|
||||
"---\nname: release-guard\n---\nbody"
|
||||
);
|
||||
write(
|
||||
path.join(REPO, ".codex", "skills", "onboarding", "SKILL.md"),
|
||||
"---\nname: onboarding\n---\nb"
|
||||
);
|
||||
|
||||
const rows = cx.readSkills({ cwd: REPO });
|
||||
assert.deepEqual(rows.map((r) => [r.scope, r.name]).sort(), [
|
||||
["project", "onboarding"],
|
||||
["user", "release-guard"],
|
||||
]);
|
||||
assert.equal(rows.find((r) => r.name === "release-guard").frontmatter.name, "release-guard");
|
||||
});
|
||||
|
||||
it("pulls an agent's identity out of its TOML without a TOML parser", () => {
|
||||
write(
|
||||
path.join(REPO, ".codex", "agents", "reviewer.toml"),
|
||||
'name = "reviewer"\ndescription = "Read-only review agent."\nmodel = "gpt-5.4"\n' +
|
||||
'developer_instructions = """\nmulti\nline\n"""\n'
|
||||
);
|
||||
|
||||
const [agent] = cx.readAgents({ cwd: REPO, scope: "project" });
|
||||
assert.equal(agent.meta.name, "reviewer");
|
||||
assert.equal(agent.meta.description, "Read-only review agent.");
|
||||
assert.equal(agent.meta.model, "gpt-5.4");
|
||||
});
|
||||
|
||||
it("does not mistake a key inside a triple-quoted block for a top-level field", () => {
|
||||
const meta = cx.readTomlMeta(
|
||||
'name = "implementer"\n' +
|
||||
'sandbox_mode = "workspace-write"\n' +
|
||||
'developer_instructions = """\n' +
|
||||
'model = "not-a-real-field"\n' +
|
||||
'"""\n' +
|
||||
'model = "gpt-5.3-codex-spark"\n'
|
||||
);
|
||||
assert.equal(meta.name, "implementer");
|
||||
assert.equal(meta.sandbox_mode, "workspace-write");
|
||||
assert.equal(meta.model, "gpt-5.3-codex-spark");
|
||||
});
|
||||
|
||||
it("keeps a field with a trailing inline comment, and keeps a '#' that is inside the string", () => {
|
||||
const meta = cx.readTomlMeta(
|
||||
'model = "gpt-5.4" # default\n' + 'description = "uses # for headings"\n'
|
||||
);
|
||||
assert.equal(meta.model, "gpt-5.4");
|
||||
assert.equal(meta.description, "uses # for headings");
|
||||
});
|
||||
|
||||
it("does not let an escaped quote before a real trailing comment truncate the value", () => {
|
||||
const meta = cx.readTomlMeta('description = "say \\"hi\\"" # not a comment\n');
|
||||
assert.equal(meta.description, 'say \\"hi\\"');
|
||||
});
|
||||
|
||||
it("recognises a quote preceded by an escaped backslash as a real terminator", () => {
|
||||
const meta = cx.readTomlMeta('path = "path\\\\" # c\n');
|
||||
assert.equal(meta.path, "path\\");
|
||||
});
|
||||
|
||||
it("keeps counting backslash pairs correctly with four of them before the terminator", () => {
|
||||
const meta = cx.readTomlMeta('key = "a\\\\\\\\" # c\n');
|
||||
assert.equal(meta.key, "a\\\\");
|
||||
});
|
||||
|
||||
it("reads a rule file's preview", () => {
|
||||
write(
|
||||
path.join(TMP, "rules", "no-force-push.rules"),
|
||||
"never force-push main\nask before deleting"
|
||||
);
|
||||
const [rule] = cx.readRules({ cwd: REPO, scope: "user" });
|
||||
assert.equal(rule.name, "no-force-push");
|
||||
assert.match(rule.preview, /never force-push main/);
|
||||
});
|
||||
|
||||
it("reports a missing AGENTS.md as a row that does not exist, not a gap", () => {
|
||||
const rows = cx.readMemory({ cwd: REPO });
|
||||
const user = rows.find((r) => r.scope === "user");
|
||||
assert.equal(user.exists, false, "the UI offers to create it, so the row must be present");
|
||||
});
|
||||
|
||||
it("reads project AGENTS.md from the repo root, not under .codex", () => {
|
||||
write(path.join(REPO, "AGENTS.md"), "# Project instructions");
|
||||
const rows = cx.readMemory({ cwd: REPO });
|
||||
const project = rows.find((r) => r.scope === "project");
|
||||
assert.equal(project.exists, true);
|
||||
assert.equal(project.file, path.join(REPO, "AGENTS.md"));
|
||||
});
|
||||
|
||||
it("reads config.toml content for both scopes", () => {
|
||||
write(path.join(TMP, "config.toml"), 'model = "gpt-5.3-codex-spark"\n');
|
||||
const rows = cx.readConfig({ cwd: REPO });
|
||||
const user = rows.find((r) => r.scope === "user");
|
||||
assert.equal(user.exists, true);
|
||||
assert.match(user.content, /gpt-5\.3-codex-spark/);
|
||||
const project = rows.find((r) => r.scope === "project");
|
||||
assert.equal(project.exists, false);
|
||||
});
|
||||
|
||||
it("follows a skill directory symlinked in from outside CODEX_HOME — a supported install pattern", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-outside-"));
|
||||
write(path.join(outside, "SKILL.md"), "---\nname: linked\n---\nbody");
|
||||
const linkPath = path.join(TMP, "skills", "linked");
|
||||
fs.symlinkSync(outside, linkPath, "dir");
|
||||
|
||||
const [linked] = cx.readSkills({ cwd: REPO, scope: "user" });
|
||||
assert.equal(linked.scope, "user");
|
||||
assert.equal(linked.name, "linked");
|
||||
assert.equal(linked.frontmatter.name, "linked", "frontmatter must be read through the link");
|
||||
assert.equal(linked.file, path.join(linkPath, "SKILL.md"));
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("follows an agent .toml symlinked in from outside CODEX_HOME", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-outside-"));
|
||||
const realFile = path.join(outside, "reviewer.toml");
|
||||
write(realFile, 'name = "reviewer"\n');
|
||||
const linkPath = path.join(TMP, "agents", "reviewer.toml");
|
||||
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
||||
fs.symlinkSync(realFile, linkPath, "file");
|
||||
|
||||
const [agent] = cx.readAgents({ cwd: REPO, scope: "user" });
|
||||
assert.equal(agent.scope, "user");
|
||||
assert.equal(agent.name, "reviewer");
|
||||
assert.equal(agent.meta.name, "reviewer", "meta must be read through the link");
|
||||
assert.equal(agent.file, linkPath);
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("summarizes counts and roots in the overview", () => {
|
||||
const overview = cx.readOverview({ cwd: REPO });
|
||||
assert.equal(overview.roots.projectRoot, REPO);
|
||||
assert.ok(overview.counts.skills.user >= 1);
|
||||
assert.ok(overview.counts.agents.project >= 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file Tests for codex-home.js: CODEX_HOME resolution and related path
|
||||
* resolutions for Codex project and surface directories.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
describe("codex-home", () => {
|
||||
beforeEach(() => {
|
||||
// Clear the require cache so getCodexHome() reads the fresh env var
|
||||
delete require.cache[require.resolve("../lib/codex-home")];
|
||||
});
|
||||
|
||||
it("honours CODEX_HOME over the home directory", () => {
|
||||
process.env.CODEX_HOME = "/tmp/injected-codex";
|
||||
delete require.cache[require.resolve("../lib/codex-home")];
|
||||
assert.equal(require("../lib/codex-home").getCodexHome(), "/tmp/injected-codex");
|
||||
});
|
||||
|
||||
it("puts the project surfaces under <cwd>/.codex", () => {
|
||||
const { getProjectCodexDir } = require("../lib/codex-home");
|
||||
assert.equal(getProjectCodexDir("/repo"), path.join("/repo", ".codex"));
|
||||
});
|
||||
|
||||
it("returns the cwd as the project root", () => {
|
||||
const { getProjectRoot } = require("../lib/codex-home");
|
||||
assert.equal(getProjectRoot("/repo"), "/repo");
|
||||
});
|
||||
|
||||
it("defaults to ~/.codex when CODEX_HOME is not set", () => {
|
||||
delete process.env.CODEX_HOME;
|
||||
delete require.cache[require.resolve("../lib/codex-home")];
|
||||
const { getCodexHome } = require("../lib/codex-home");
|
||||
assert.equal(getCodexHome(), path.join(os.homedir(), ".codex"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* @file Tests for codex-mutate.js: create/edit/delete on Codex config
|
||||
* surfaces (skills, agents, rules, memory, config) across the user
|
||||
* (CODEX_HOME) and project (<cwd>/.codex or <cwd> for AGENTS.md) scopes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
describe("codex-mutate", () => {
|
||||
let TMP;
|
||||
let REPO;
|
||||
let prevCodexHome;
|
||||
let cm;
|
||||
|
||||
before(() => {
|
||||
prevCodexHome = process.env.CODEX_HOME;
|
||||
TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-"));
|
||||
REPO = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-repo-"));
|
||||
process.env.CODEX_HOME = TMP;
|
||||
delete require.cache[require.resolve("../lib/codex-home")];
|
||||
delete require.cache[require.resolve("../lib/codex-mutate")];
|
||||
cm = require("../lib/codex-mutate");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodexHome;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates a project skill as a directory with SKILL.md inside", () => {
|
||||
const { file, created } = cm.writeArtifact({
|
||||
scope: "project",
|
||||
type: "skills",
|
||||
name: "release-guard",
|
||||
content: "---\nname: x\n---\n",
|
||||
cwd: REPO,
|
||||
});
|
||||
assert.equal(created, true);
|
||||
assert.equal(file, path.join(REPO, ".codex", "skills", "release-guard", "SKILL.md"));
|
||||
});
|
||||
|
||||
it("backs the old body up before overwriting it", () => {
|
||||
cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "old", cwd: REPO });
|
||||
const { backup } = cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "rules",
|
||||
name: "default",
|
||||
content: "new",
|
||||
cwd: REPO,
|
||||
});
|
||||
assert.equal(fs.readFileSync(backup, "utf8"), "old");
|
||||
assert.equal(fs.readFileSync(path.join(TMP, "rules", "default.rules"), "utf8"), "new");
|
||||
});
|
||||
|
||||
it("refuses a name that climbs out of the root", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "agents",
|
||||
name: "../../evil",
|
||||
content: "x",
|
||||
cwd: REPO,
|
||||
}),
|
||||
(e) => e.code === "EBADNAME"
|
||||
);
|
||||
});
|
||||
|
||||
it("writes AGENTS.md at the repo root, not inside .codex", () => {
|
||||
const { file } = cm.writeArtifact({
|
||||
scope: "project",
|
||||
type: "memory",
|
||||
content: "# rules",
|
||||
cwd: REPO,
|
||||
});
|
||||
assert.equal(file, path.join(REPO, "AGENTS.md"));
|
||||
});
|
||||
|
||||
it("keeps a deleted skill's backup out of the skills tree", () => {
|
||||
cm.writeArtifact({ scope: "user", type: "skills", name: "gone", content: "x", cwd: REPO });
|
||||
const { backup } = cm.deleteArtifact({
|
||||
scope: "user",
|
||||
type: "skills",
|
||||
name: "gone",
|
||||
cwd: REPO,
|
||||
});
|
||||
assert.ok(
|
||||
!backup.includes(path.join(TMP, "skills", "gone")),
|
||||
"a backup must never be discoverable as a skill"
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(TMP, "skills", "gone")), false);
|
||||
});
|
||||
|
||||
it("writes and reads back a project agent under .codex/agents/<name>.toml", () => {
|
||||
const { file } = cm.writeArtifact({
|
||||
scope: "project",
|
||||
type: "agents",
|
||||
name: "reviewer",
|
||||
content: 'name = "reviewer"\n',
|
||||
cwd: REPO,
|
||||
});
|
||||
assert.equal(file, path.join(REPO, ".codex", "agents", "reviewer.toml"));
|
||||
});
|
||||
|
||||
it("writes user config.toml under CODEX_HOME and project config.toml under <cwd>/.codex", () => {
|
||||
const u = cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "config",
|
||||
content: 'model = "x"\n',
|
||||
cwd: REPO,
|
||||
});
|
||||
assert.equal(u.file, path.join(TMP, "config.toml"));
|
||||
const p = cm.writeArtifact({
|
||||
scope: "project",
|
||||
type: "config",
|
||||
content: 'model = "y"\n',
|
||||
cwd: REPO,
|
||||
});
|
||||
assert.equal(p.file, path.join(REPO, ".codex", "config.toml"));
|
||||
});
|
||||
|
||||
it("rejects a scope that is neither user nor project, and writes nothing", () => {
|
||||
const target = path.join(TMP, "rules", "x.rules");
|
||||
assert.throws(
|
||||
() =>
|
||||
cm.writeArtifact({
|
||||
scope: "project-local",
|
||||
type: "rules",
|
||||
name: "x",
|
||||
content: "x",
|
||||
cwd: REPO,
|
||||
}),
|
||||
(e) => e.code === "EBADSCOPE"
|
||||
);
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
});
|
||||
|
||||
it("rejects a type outside TYPES, and writes nothing", () => {
|
||||
const target = path.join(TMP, "commands", "x.md");
|
||||
assert.throws(
|
||||
() =>
|
||||
cm.writeArtifact({ scope: "user", type: "commands", name: "x", content: "x", cwd: REPO }),
|
||||
(e) => e.code === "EBADTYPE"
|
||||
);
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
});
|
||||
|
||||
it("refuses a symlinked skill dir that escapes both Codex roots, and writes nothing outside", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
|
||||
const linkPath = path.join(TMP, "skills", "escape-link");
|
||||
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
||||
fs.symlinkSync(outside, linkPath, "dir");
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "skills",
|
||||
name: "escape-link",
|
||||
content: "pwned",
|
||||
cwd: REPO,
|
||||
}),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(outside, "SKILL.md")), false);
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("follows a symlinked skill dir that points back inside the same root", () => {
|
||||
const realDir = path.join(TMP, "skills", "real-dir");
|
||||
fs.mkdirSync(realDir, { recursive: true });
|
||||
const linkPath = path.join(TMP, "skills", "inside-link");
|
||||
fs.symlinkSync(realDir, linkPath, "dir");
|
||||
|
||||
const { file, created } = cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "skills",
|
||||
name: "inside-link",
|
||||
content: "fine",
|
||||
cwd: REPO,
|
||||
});
|
||||
assert.equal(created, true);
|
||||
assert.equal(fs.readFileSync(file, "utf8"), "fine");
|
||||
assert.equal(fs.readFileSync(path.join(realDir, "SKILL.md"), "utf8"), "fine");
|
||||
});
|
||||
|
||||
it("refuses to back up a symlinked artifact that escapes the roots when deleting it", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
|
||||
fs.writeFileSync(path.join(outside, "secret.rules"), "top secret");
|
||||
const linkPath = path.join(TMP, "rules", "escape-del.rules");
|
||||
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
||||
fs.symlinkSync(path.join(outside, "secret.rules"), linkPath, "file");
|
||||
|
||||
assert.throws(
|
||||
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "escape-del", cwd: REPO }),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(outside, "secret.rules")), true);
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("refuses a brand-new artifact whose subdir (not the leaf) is a symlink escaping the root", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
|
||||
const linkPath = path.join(TMP, "agents");
|
||||
// "agents" doesn't exist yet in this fresh suite of tests for this scope —
|
||||
// remove it first if an earlier test already created it as a real dir.
|
||||
fs.rmSync(linkPath, { recursive: true, force: true });
|
||||
fs.symlinkSync(outside, linkPath, "dir");
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "agents",
|
||||
name: "brand-new",
|
||||
content: "pwned",
|
||||
cwd: REPO,
|
||||
}),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(outside, "brand-new.toml")), false);
|
||||
fs.rmSync(linkPath, { force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("still writes correctly when $CODEX_HOME itself is a symlink (a dotfiles checkout)", () => {
|
||||
const realHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-realhome-"));
|
||||
const linkHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-linkhome-"));
|
||||
fs.rmdirSync(linkHome); // symlinkSync needs the link path itself to not exist
|
||||
fs.symlinkSync(realHome, linkHome, "dir");
|
||||
|
||||
const prev = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = linkHome;
|
||||
delete require.cache[require.resolve("../lib/codex-home")];
|
||||
delete require.cache[require.resolve("../lib/codex-mutate")];
|
||||
const linkedCm = require("../lib/codex-mutate");
|
||||
try {
|
||||
const { file, created } = linkedCm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "rules",
|
||||
name: "via-link",
|
||||
content: "ok",
|
||||
cwd: REPO,
|
||||
});
|
||||
assert.equal(created, true);
|
||||
assert.equal(fs.readFileSync(file, "utf8"), "ok");
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(realHome, "rules", "via-link.rules"), "utf8"),
|
||||
"ok",
|
||||
"the write must land in the real directory the symlinked CODEX_HOME points to"
|
||||
);
|
||||
} finally {
|
||||
process.env.CODEX_HOME = prev;
|
||||
delete require.cache[require.resolve("../lib/codex-home")];
|
||||
delete require.cache[require.resolve("../lib/codex-mutate")];
|
||||
fs.rmSync(linkHome, { force: true });
|
||||
fs.rmSync(realHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to write (and back up) when <root>/codex-config-backups is a symlink escaping the root", () => {
|
||||
cm.writeArtifact({ scope: "user", type: "rules", name: "toclobber", content: "v1", cwd: REPO });
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
|
||||
const backupsLink = path.join(TMP, "codex-config-backups");
|
||||
fs.rmSync(backupsLink, { recursive: true, force: true });
|
||||
fs.symlinkSync(outside, backupsLink, "dir");
|
||||
try {
|
||||
assert.throws(
|
||||
() =>
|
||||
cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "rules",
|
||||
name: "toclobber",
|
||||
content: "v2 — attacker wants this in `outside`",
|
||||
cwd: REPO,
|
||||
}),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.readdirSync(outside).length, 0, "backup must not land outside the root");
|
||||
} finally {
|
||||
fs.rmSync(backupsLink, { force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to delete (and back up) when <root>/codex-config-backups is a symlink escaping the root", () => {
|
||||
cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "rules",
|
||||
name: "todelete2",
|
||||
content: "keep-me",
|
||||
cwd: REPO,
|
||||
});
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
|
||||
const backupsLink = path.join(TMP, "codex-config-backups");
|
||||
fs.rmSync(backupsLink, { recursive: true, force: true });
|
||||
fs.symlinkSync(outside, backupsLink, "dir");
|
||||
try {
|
||||
assert.throws(
|
||||
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "todelete2", cwd: REPO }),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
assert.equal(fs.readdirSync(outside).length, 0, "backup must not land outside the root");
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(TMP, "rules", "todelete2.rules"), "utf8"),
|
||||
"keep-me",
|
||||
"the original file must survive a refused backup"
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(backupsLink, { force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
for (const badName of ["..", ".", "/etc/passwd", "a/b", "a\0b"]) {
|
||||
it(`rejects the bad name ${JSON.stringify(badName)} with EBADNAME and writes nothing`, () => {
|
||||
const target = path.join(TMP, "agents", `${badName.replace(/[/\0]/g, "_")}.toml`);
|
||||
assert.throws(
|
||||
() =>
|
||||
cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "agents",
|
||||
name: badName,
|
||||
content: "x",
|
||||
cwd: REPO,
|
||||
}),
|
||||
(e) => e.code === "EBADNAME"
|
||||
);
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects a name passed to memory, and writes nothing", () => {
|
||||
const target = path.join(TMP, "AGENTS.md");
|
||||
assert.throws(
|
||||
() =>
|
||||
cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "memory",
|
||||
name: "AGENTS",
|
||||
content: "x",
|
||||
cwd: REPO,
|
||||
}),
|
||||
(e) => e.code === "EBADNAME"
|
||||
);
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
});
|
||||
|
||||
it("rejects a name passed to config, and leaves any existing config.toml untouched", () => {
|
||||
const target = path.join(TMP, "config.toml");
|
||||
const before = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
|
||||
assert.throws(
|
||||
() =>
|
||||
cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "config",
|
||||
name: "config",
|
||||
content: "x",
|
||||
cwd: REPO,
|
||||
}),
|
||||
(e) => e.code === "EBADNAME"
|
||||
);
|
||||
const after = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
|
||||
assert.equal(after, before);
|
||||
});
|
||||
|
||||
it("rejects content over the size limit and leaves the existing file untouched", () => {
|
||||
cm.writeArtifact({ scope: "user", type: "rules", name: "toobig", content: "small", cwd: REPO });
|
||||
const huge = "x".repeat(256 * 1024 + 1);
|
||||
assert.throws(
|
||||
() =>
|
||||
cm.writeArtifact({
|
||||
scope: "user",
|
||||
type: "rules",
|
||||
name: "toobig",
|
||||
content: huge,
|
||||
cwd: REPO,
|
||||
}),
|
||||
(e) => e.code === "ETOOLARGE"
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(TMP, "rules", "toobig.rules"), "utf8"), "small");
|
||||
});
|
||||
|
||||
it("refuses to delete something that does not exist, and creates no backup", () => {
|
||||
const before = fs.existsSync(path.join(TMP, "codex-config-backups", "rules"))
|
||||
? fs.readdirSync(path.join(TMP, "codex-config-backups", "rules")).length
|
||||
: 0;
|
||||
assert.throws(
|
||||
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "never-existed", cwd: REPO }),
|
||||
(e) => e.code === "ENOTFOUND"
|
||||
);
|
||||
const after = fs.existsSync(path.join(TMP, "codex-config-backups", "rules"))
|
||||
? fs.readdirSync(path.join(TMP, "codex-config-backups", "rules")).length
|
||||
: 0;
|
||||
assert.equal(after, before, "no backup should be created for a missing target");
|
||||
});
|
||||
|
||||
it("lists backups for a given scope and type", () => {
|
||||
cm.writeArtifact({ scope: "user", type: "rules", name: "listed", content: "v1", cwd: REPO });
|
||||
cm.writeArtifact({ scope: "user", type: "rules", name: "listed", content: "v2", cwd: REPO });
|
||||
const rows = cm.listBackups({ scope: "user", type: "rules" });
|
||||
assert.ok(rows.length >= 1);
|
||||
assert.equal(rows[0].type, "rules");
|
||||
assert.equal(rows[0].name.startsWith("listed.rules."), true);
|
||||
});
|
||||
|
||||
it("refuses a listBackups scope that is neither user nor project", () => {
|
||||
assert.throws(
|
||||
() => cm.listBackups({ scope: "../../../../tmp" }),
|
||||
(e) => e.code === "EBADSCOPE"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a listBackups type that tries to traverse outside the backups root", () => {
|
||||
assert.throws(
|
||||
() => cm.listBackups({ scope: "user", type: "../../../../tmp" }),
|
||||
(e) => e.code === "EBADTYPE"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @file config-write.test.js
|
||||
* @description Tests for the shared safe-write primitives (atomicWriteFile,
|
||||
* createBackup, assertUnder) that cc-mutate.js and the Codex config writer
|
||||
* both depend on.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = 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 { atomicWriteFile, createBackup, assertUnder } = require("../lib/config-write");
|
||||
|
||||
describe("atomicWriteFile", () => {
|
||||
it("leaves no temp file behind after a successful write", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
|
||||
atomicWriteFile(path.join(dir, "a.md"), "body");
|
||||
assert.equal(fs.readFileSync(path.join(dir, "a.md"), "utf8"), "body");
|
||||
assert.deepEqual(fs.readdirSync(dir), ["a.md"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBackup", () => {
|
||||
it("backs a directory up as a tree, not a single file", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
|
||||
fs.mkdirSync(path.join(dir, "src", "sk"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "src", "sk", "SKILL.md"), "x");
|
||||
const out = createBackup({
|
||||
source: path.join(dir, "src", "sk"),
|
||||
backupRoot: path.join(dir, "bak"),
|
||||
type: "skills",
|
||||
name: "sk",
|
||||
});
|
||||
assert.ok(fs.existsSync(path.join(out, "SKILL.md")));
|
||||
});
|
||||
|
||||
it("returns null when the source does not exist", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-"));
|
||||
const out = createBackup({
|
||||
source: path.join(dir, "missing"),
|
||||
backupRoot: path.join(dir, "bak"),
|
||||
type: "agents",
|
||||
name: "missing",
|
||||
});
|
||||
assert.equal(out, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertUnder", () => {
|
||||
it("rejects a target outside the root", () => {
|
||||
assert.throws(
|
||||
() => assertUnder("/a/b", "/a/c/x"),
|
||||
(e) => e.code === "EOUTOFROOT"
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a target under the root", () => {
|
||||
assert.doesNotThrow(() => assertUnder("/a/b", "/a/b/c"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file lane-stage-events.test.js
|
||||
* @description Tests for the append-only stage log behind Lane Pipeline Pass
|
||||
* Rates.
|
||||
*
|
||||
* The panel used to read `lanes.stages`, which is a snapshot of the current
|
||||
* pass: re-entering a node overwrote its record and moving backwards deleted
|
||||
* every record ahead. Ten features shipped through one lane therefore counted
|
||||
* as one run, and no amount of use ever moved the numbers. These tests pin the
|
||||
* two properties that fixes it — a pass is never overwritten, and passes are
|
||||
* separated into runs — plus the detected/declared split the panel draws.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, beforeEach, after } = 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 TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lane-stage-events-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const { db } = require("../db");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
|
||||
let laneId;
|
||||
|
||||
after(() => {
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
|
||||
} catch {
|
||||
/* best-effort temp cleanup */
|
||||
}
|
||||
});
|
||||
|
||||
before(() => {
|
||||
db.prepare(
|
||||
"INSERT INTO lanes (title, cwd, pipeline, tool) VALUES ('t', ?, 'default', 'claude')"
|
||||
).run(path.join(TMP, "work"));
|
||||
laneId = db.prepare("SELECT id FROM lanes ORDER BY id DESC LIMIT 1").get().id;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.prepare("DELETE FROM lane_stage_events WHERE lane_id = ?").run(laneId);
|
||||
db.prepare("UPDATE lanes SET stage = 'idle', stages = '{}' WHERE id = ?").run(laneId);
|
||||
});
|
||||
|
||||
const events = () =>
|
||||
db
|
||||
.prepare("SELECT node_id, run_seq, source, result, evidence FROM lane_stage_events ORDER BY id")
|
||||
.all();
|
||||
|
||||
describe("lane_stage_events", () => {
|
||||
it("records one event per declaration, including a node entered twice", () => {
|
||||
lanesLib.setStage(laneId, { stage: "plan" });
|
||||
lanesLib.setStage(laneId, { stage: "implement" });
|
||||
lanesLib.setStage(laneId, { stage: "implement", evidence: "second pass" });
|
||||
|
||||
const rows = events();
|
||||
assert.equal(rows.length, 3);
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.node_id),
|
||||
["plan", "implement", "implement"]
|
||||
);
|
||||
// The snapshot keeps one record for `implement`; the log keeps both.
|
||||
assert.equal(Object.keys(lanesLib.getLane(laneId).stages).length, 2);
|
||||
});
|
||||
|
||||
it("keeps the records of a finished pass when the lane moves backwards", () => {
|
||||
for (const stage of ["plan", "implement", "review"]) lanesLib.setStage(laneId, { stage });
|
||||
lanesLib.setStage(laneId, { stage: "plan" });
|
||||
|
||||
// The snapshot truncates ahead of `plan` — that is deliberate, it describes
|
||||
// the pass in flight. The log must not lose what already happened.
|
||||
const snapshot = Object.keys(lanesLib.getLane(laneId).stages);
|
||||
assert.deepEqual(snapshot.sort(), ["plan"]);
|
||||
assert.deepEqual(
|
||||
events().map((r) => r.node_id),
|
||||
["plan", "implement", "review", "plan"]
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a new run when the lane moves backwards", () => {
|
||||
for (const stage of ["plan", "implement", "review"]) lanesLib.setStage(laneId, { stage });
|
||||
lanesLib.setStage(laneId, { stage: "plan" });
|
||||
lanesLib.setStage(laneId, { stage: "implement" });
|
||||
|
||||
assert.deepEqual(
|
||||
events().map((r) => `${r.node_id}#${r.run_seq}`),
|
||||
["plan#1", "implement#1", "review#1", "plan#2", "implement#2"]
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a new run when the pipeline's last node is left behind", () => {
|
||||
lanesLib.setStage(laneId, { stage: "done" });
|
||||
lanesLib.setStage(laneId, { stage: "intake" });
|
||||
|
||||
assert.deepEqual(
|
||||
events().map((r) => `${r.node_id}#${r.run_seq}`),
|
||||
["done#1", "intake#2"]
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the result and whether evidence was left", () => {
|
||||
lanesLib.setStage(laneId, { stage: "review", result: "fail" });
|
||||
lanesLib.setStage(laneId, { stage: "gate", evidence: "GO — 2 reviewers" });
|
||||
|
||||
const [review, gate] = events();
|
||||
assert.equal(review.result, "fail");
|
||||
assert.equal(review.evidence, 0);
|
||||
assert.equal(gate.result, null);
|
||||
assert.equal(gate.evidence, 1);
|
||||
});
|
||||
|
||||
it("records a detection as its own source, never as a declaration", () => {
|
||||
const wrote = lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" });
|
||||
assert.equal(wrote.written, true);
|
||||
|
||||
const rows = events();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].source, "detected");
|
||||
assert.equal(rows[0].node_id, "implement");
|
||||
// A detection must not move the declared stage — same rule as before.
|
||||
assert.equal(lanesLib.getLane(laneId).stage, "idle");
|
||||
});
|
||||
|
||||
it("records nothing when a detection is rejected", () => {
|
||||
lanesLib.setStage(laneId, { stage: "review" });
|
||||
db.prepare("DELETE FROM lane_stage_events WHERE lane_id = ?").run(laneId);
|
||||
|
||||
const wrote = lanesLib.recordDetection(laneId, { nodeId: "plan", signal: "`read`" });
|
||||
assert.equal(wrote.written, false);
|
||||
assert.deepEqual(events(), []);
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,21 @@ describe("/api/lanes", () => {
|
||||
assert.equal(r.status, 409);
|
||||
});
|
||||
|
||||
it("rejects a stage that names no node of the lane's pipeline", async () => {
|
||||
// Silently storing it is what made "CI gates" read never-reached on a lane
|
||||
// where the agent had been declaring `test` (the default pipeline's name)
|
||||
// all along.
|
||||
const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "implemnt" });
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "EBADSTAGE");
|
||||
assert.ok(r.body.error.stages.includes("implement"));
|
||||
});
|
||||
|
||||
it("accepts a stage declared by one of the node's aliases", async () => {
|
||||
const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "coding" });
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
|
||||
it("reports a stage and returns node states", async () => {
|
||||
const r = await request("POST", `/api/lanes/${laneId}/stage`, {
|
||||
stage: "review",
|
||||
|
||||
@@ -203,29 +203,29 @@ describe("agenthub stage", () => {
|
||||
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
|
||||
});
|
||||
|
||||
it("warns, but still records, a stage name matching no pipeline node", async () => {
|
||||
// setStage stores the string verbatim, so a typo'd stage is accepted and
|
||||
// then renders nowhere (phaseIdx -1, progress 0). Silent acceptance is how
|
||||
// a lane ends up looking unstarted for an entire pipeline run.
|
||||
it("refuses a stage name matching no pipeline node", async () => {
|
||||
// It used to be recorded with a warning, which renders nowhere (phaseIdx
|
||||
// -1, progress 0) and counts nowhere. A warning on stderr proved easy to
|
||||
// miss — a real lane accumulated five such names — so the server rejects
|
||||
// it and the agent is told which names exist.
|
||||
const r = await cli(["stage", "revieww"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /revieww/);
|
||||
assert.match(r.stderr, /matches no node/i);
|
||||
assert.match(r.stderr, /revieww/);
|
||||
assert.notEqual(r.status, 0);
|
||||
const out = `${r.stdout}${r.stderr}`;
|
||||
assert.match(out, /revieww/);
|
||||
assert.match(out, /not a stage/i);
|
||||
assert.match(out, /review/);
|
||||
});
|
||||
|
||||
it("does not warn for a stage declared by alias", async () => {
|
||||
it("accepts a stage declared by alias", async () => {
|
||||
const r = await cli(["stage", "planning"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /matches no node/i);
|
||||
});
|
||||
|
||||
it("does not warn for a valid stage reported as failed", async () => {
|
||||
// --result fail paints the node `failed`, not `current`; the warning must
|
||||
// not read that as an unknown stage.
|
||||
it("accepts a valid stage reported as failed", async () => {
|
||||
// --result fail paints the node `failed`, not `current`; the validation
|
||||
// must not read that as an unknown stage.
|
||||
const r = await cli(["stage", "review", "--result", "fail"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /matches no node/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -339,3 +339,72 @@ describe("plugin-open / plugin-bootstrap require cycle", () => {
|
||||
assert.equal(typeof require("../../scripts/plugin-open").buildClient, "function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClient dependency install", () => {
|
||||
const { buildClient } = require("../../scripts/plugin-open");
|
||||
|
||||
// A fake plugin root holding just enough of a client/ to copy.
|
||||
function fakeRoot() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-root-"));
|
||||
fs.mkdirSync(path.join(root, "client"));
|
||||
fs.writeFileSync(path.join(root, "client", "package.json"), "{}");
|
||||
return root;
|
||||
}
|
||||
|
||||
// Records every npm invocation; `fails` names the subcommands that exit non-zero.
|
||||
// A successful "run build" writes the dist/ the real Vite build would produce.
|
||||
function fakeExec(calls, fails = []) {
|
||||
return (_cmd, args, cwd) => {
|
||||
calls.push(args.join(" "));
|
||||
if (fails.includes(args[0])) return false;
|
||||
if (args[0] === "run") fs.mkdirSync(path.join(cwd, "dist"), { recursive: true });
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
it("installs with `npm ci`, which cannot reuse a half-populated node_modules", () => {
|
||||
// Regression: `npm install` reported success against a truncated cache
|
||||
// entry, and the build then died with TS2318 — typescript's lib/*.d.ts
|
||||
// missing, so every global type (Array, Boolean, …) was undefined.
|
||||
resetRuntime();
|
||||
const calls = [];
|
||||
const result = buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
|
||||
|
||||
assert.equal(result, "built");
|
||||
assert.equal(calls[0].startsWith("ci"), true, `expected npm ci first, got ${calls[0]}`);
|
||||
assert.equal(
|
||||
calls.some((c) => c.startsWith("install")),
|
||||
false,
|
||||
"npm install must not run once ci succeeded"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to `npm install` when ci refuses the lockfile", () => {
|
||||
resetRuntime();
|
||||
const calls = [];
|
||||
const result = buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls, ["ci"]) });
|
||||
|
||||
assert.equal(result, "built");
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c.split(" ")[0]),
|
||||
["ci", "install", "run"]
|
||||
);
|
||||
});
|
||||
|
||||
it("fails without building when neither install path works", () => {
|
||||
resetRuntime();
|
||||
const calls = [];
|
||||
const result = buildClient({
|
||||
rt: RT,
|
||||
root: fakeRoot(),
|
||||
exec: fakeExec(calls, ["ci", "install"]),
|
||||
});
|
||||
|
||||
assert.equal(result, "failed");
|
||||
assert.equal(
|
||||
calls.some((c) => c.startsWith("run")),
|
||||
false,
|
||||
"the build must not run on a broken dependency tree"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @file Tests the `Runtime deps` and `Dashboard UI` rows of
|
||||
* scripts/plugin-doctor.js — the two checks that have to survive a half-written
|
||||
* install. A `node_modules` directory that exists but is missing packages is
|
||||
* exactly what took a client build down with TS2318 (typescript's lib/*.d.ts
|
||||
* never landed, so every global type was undefined), and when the bundle is
|
||||
* missing the reader needs the build error, not another "run /agenthub-open".
|
||||
* Every case runs against an injected temp CLAUDE_HOME.
|
||||
* @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 TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-doctor-runtime-"));
|
||||
process.env.CLAUDE_HOME = TMP_HOME;
|
||||
delete process.env.DASHBOARD_CLIENT_DIST;
|
||||
|
||||
const { diagnose } = require("../../scripts/plugin-doctor");
|
||||
|
||||
const RT = path.join(TMP_HOME, "agenthub", "runtime");
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
||||
delete process.env.CLAUDE_HOME;
|
||||
});
|
||||
|
||||
/**
|
||||
* Rebuild the runtime dir from scratch, then read back one row.
|
||||
* `installed` are the packages that actually made it into node_modules.
|
||||
*/
|
||||
function rowFor(label, { manifest, installed = [], buildLog, dist = false } = {}) {
|
||||
fs.rmSync(RT, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
||||
|
||||
if (manifest) fs.writeFileSync(path.join(RT, "package.json"), JSON.stringify(manifest));
|
||||
for (const dep of installed) {
|
||||
fs.mkdirSync(path.join(RT, "node_modules", dep), { recursive: true });
|
||||
fs.writeFileSync(path.join(RT, "node_modules", dep, "package.json"), "{}");
|
||||
}
|
||||
if (buildLog) fs.writeFileSync(path.join(RT, "client-build.log"), buildLog);
|
||||
if (dist) {
|
||||
fs.mkdirSync(path.join(RT, "client-dist"), { recursive: true });
|
||||
fs.writeFileSync(path.join(RT, "client-dist", "index.html"), "<html></html>");
|
||||
}
|
||||
|
||||
const row = diagnose().find((r) => r.label === label);
|
||||
assert.ok(row, `the doctor must always emit a ${label} row`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("doctor: Runtime deps", () => {
|
||||
it("fails on a tree that npm reported as installed but that is missing packages", () => {
|
||||
const row = rowFor("Runtime deps", {
|
||||
manifest: { dependencies: { express: "^4", ws: "^8", uuid: "^11" } },
|
||||
installed: ["express"],
|
||||
});
|
||||
|
||||
assert.equal(row.level, "fail", row.detail);
|
||||
assert.match(row.detail, /ws/);
|
||||
assert.match(row.detail, /uuid/);
|
||||
assert.ok(!/express/.test(row.detail), `express is installed and must not be listed`);
|
||||
});
|
||||
|
||||
it("passes when every declared dependency landed", () => {
|
||||
const row = rowFor("Runtime deps", {
|
||||
manifest: { dependencies: { express: "^4", ws: "^8" } },
|
||||
installed: ["express", "ws"],
|
||||
});
|
||||
|
||||
assert.equal(row.level, "ok", row.detail);
|
||||
assert.match(row.detail, /2 packages/);
|
||||
});
|
||||
|
||||
it("does not fail over an absent optional dependency", () => {
|
||||
// better-sqlite3 is optional by design: the server falls back to
|
||||
// node:sqlite. Failing here would send readers reinstalling for nothing.
|
||||
const row = rowFor("Runtime deps", {
|
||||
manifest: { dependencies: { ws: "^8" }, optionalDependencies: { "better-sqlite3": "^12" } },
|
||||
installed: ["ws"],
|
||||
});
|
||||
|
||||
assert.equal(row.level, "ok", row.detail);
|
||||
});
|
||||
|
||||
it("warns rather than fails when there is no manifest to verify against", () => {
|
||||
const row = rowFor("Runtime deps", { installed: ["ws"] });
|
||||
|
||||
assert.equal(row.level, "warn", row.detail);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor: Dashboard UI", () => {
|
||||
it("quotes the last build error instead of repeating the build command", () => {
|
||||
const row = rowFor("Dashboard UI", {
|
||||
manifest: { dependencies: {} },
|
||||
buildLog: [
|
||||
"added 321 packages in 7s",
|
||||
"> tsc -b && vite build",
|
||||
"error TS2318: Cannot find global type 'Array'.",
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
assert.equal(row.level, "warn", row.detail);
|
||||
assert.match(row.detail, /TS2318/);
|
||||
assert.match(row.detail, /client-build\.log/);
|
||||
});
|
||||
|
||||
it("says the bundle was never built when no build has run", () => {
|
||||
const row = rowFor("Dashboard UI", { manifest: { dependencies: {} } });
|
||||
|
||||
assert.equal(row.level, "warn", row.detail);
|
||||
assert.match(row.detail, /not built yet/);
|
||||
});
|
||||
|
||||
it("is ok once the bundle exists", () => {
|
||||
const row = rowFor("Dashboard UI", { manifest: { dependencies: {} }, dist: true });
|
||||
|
||||
assert.equal(row.level, "ok", row.detail);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const assert = require("node:assert/strict");
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const laneFeatures = require("../lib/lane-features");
|
||||
const { db, stmts } = require("../db");
|
||||
|
||||
let server;
|
||||
@@ -61,8 +62,14 @@ function makeLane() {
|
||||
return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" });
|
||||
}
|
||||
|
||||
function setStages(laneId, stages) {
|
||||
db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), laneId);
|
||||
// Declare stages the way an agent does. Poking `lanes.stages` directly would
|
||||
// test the snapshot the pass-rate report no longer reads: that blob describes
|
||||
// the pass in flight, while the report counts the append-only stage log every
|
||||
// declaration writes.
|
||||
function declare(laneId, stages) {
|
||||
for (const [stage, rec] of Object.entries(stages)) {
|
||||
lanesLib.setStage(laneId, { stage, evidence: rec.evidence, result: rec.result });
|
||||
}
|
||||
}
|
||||
|
||||
function node(pipelineStats, pipelineId, nodeId) {
|
||||
@@ -73,10 +80,10 @@ function node(pipelineStats, pipelineId, nodeId) {
|
||||
describe("GET /api/lanes/pipeline-stats", () => {
|
||||
it("counts a pass with evidence apart from a pass without one, and a fail as neither", async () => {
|
||||
const lane = makeLane();
|
||||
setStages(lane.id, {
|
||||
plan: { enteredAt: "2026-01-01T00:00:00Z", evidence: "plan.md", result: null },
|
||||
implement: { enteredAt: "2026-01-01T01:00:00Z", evidence: null, result: null },
|
||||
tests: { enteredAt: "2026-01-01T02:00:00Z", evidence: null, result: "fail" },
|
||||
declare(lane.id, {
|
||||
plan: { evidence: "plan.md", result: null },
|
||||
implement: { evidence: null, result: null },
|
||||
tests: { evidence: null, result: "fail" },
|
||||
});
|
||||
|
||||
const res = await get("/api/lanes/pipeline-stats");
|
||||
@@ -106,33 +113,35 @@ describe("GET /api/lanes/pipeline-stats", () => {
|
||||
assert.equal(node(res.body, "default", "ship").entered, 0);
|
||||
});
|
||||
|
||||
it("keeps counting an archived feature's stages after the lane moves on", async () => {
|
||||
it("keeps a finished feature's run after the lane starts the next one", async () => {
|
||||
// The regression this guards: the report used to read the lane's live
|
||||
// `stages` blob, which the next feature overwrites. Ship two features
|
||||
// through one lane and the first one's gate had never happened.
|
||||
const lane = makeLane();
|
||||
db.prepare(
|
||||
`INSERT INTO lane_features (lane_id, slug, pipeline, stage, stages, archived_at)
|
||||
VALUES (?, 'old', 'default', 'done', ?, '2026-01-02T00:00:00Z')`
|
||||
).run(lane.id, JSON.stringify({ gate: { enteredAt: "x", evidence: "GO", result: null } }));
|
||||
declare(lane.id, { gate: { evidence: "GO" } });
|
||||
laneFeatures.activateFeature(lane.id, "next-feature");
|
||||
declare(lane.id, { plan: { evidence: null } });
|
||||
|
||||
const res = await get("/api/lanes/pipeline-stats");
|
||||
const res = await get(`/api/lanes/pipeline-stats?lane=${lane.id}`);
|
||||
const gate = node(res.body, "default", "gate");
|
||||
assert.equal(gate.passed, 1);
|
||||
assert.equal(gate.withEvidence, 1);
|
||||
assert.equal(node(res.body, "default", "plan").passed, 1);
|
||||
// Two passes through the pipeline, counted as two runs rather than one lane.
|
||||
assert.equal(res.body.pipelines.find((p) => p.id === "default").runs, 2);
|
||||
});
|
||||
|
||||
it("narrows to one lane and its own archived features with ?lane=", async () => {
|
||||
it("narrows to one lane and all of its own runs with ?lane=", async () => {
|
||||
const mine = makeLane();
|
||||
const other = makeLane();
|
||||
setStages(mine.id, { plan: { enteredAt: "x", evidence: "plan.md", result: null } });
|
||||
setStages(other.id, { plan: { enteredAt: "x", evidence: null, result: null } });
|
||||
db.prepare(
|
||||
`INSERT INTO lane_features (lane_id, slug, pipeline, stage, stages, archived_at)
|
||||
VALUES (?, 'mine-old', 'default', 'done', ?, '2026-01-02T00:00:00Z')`
|
||||
).run(mine.id, JSON.stringify({ plan: { enteredAt: "x", evidence: "old.md", result: null } }));
|
||||
declare(mine.id, { plan: { evidence: "plan.md" }, done: {} });
|
||||
declare(mine.id, { plan: { evidence: "again.md" } });
|
||||
declare(other.id, { plan: { evidence: null } });
|
||||
|
||||
const res = await get(`/api/lanes/pipeline-stats?lane=${mine.id}`);
|
||||
assert.equal(res.status, 200);
|
||||
const plan = node(res.body, "default", "plan");
|
||||
// The lane itself plus its one archived feature — never the other lane's.
|
||||
// Both of this lane's passes — never the other lane's.
|
||||
assert.deepEqual(
|
||||
{ entered: plan.entered, withEvidence: plan.withEvidence },
|
||||
{ entered: 2, withEvidence: 2 }
|
||||
|
||||
@@ -457,6 +457,38 @@ db.exec(`
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lane_features_lane ON lane_features(lane_id);
|
||||
|
||||
-- Append-only log of every stage a lane has ever entered.
|
||||
--
|
||||
-- lanes.stages cannot answer "how often is this stage passed": it is a
|
||||
-- snapshot of the CURRENT pass, overwritten when a node is re-entered and
|
||||
-- truncated when the lane moves backwards. Pass rates read from it counted
|
||||
-- one run per lane row no matter how many features that lane shipped.
|
||||
--
|
||||
-- run_seq groups events into passes: it increments when the lane moves
|
||||
-- backwards or leaves the pipeline's last node, which is what starting the
|
||||
-- next feature looks like from here.
|
||||
--
|
||||
-- source separates 'declared' (an agent ran the stage command) from
|
||||
-- 'detected' (inferred from the hook stream). Both are recorded; only the
|
||||
-- reader decides what a claim is worth.
|
||||
CREATE TABLE IF NOT EXISTS lane_stage_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lane_id INTEGER NOT NULL,
|
||||
pipeline TEXT NOT NULL,
|
||||
run_seq INTEGER NOT NULL DEFAULT 1,
|
||||
node_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'declared',
|
||||
result TEXT,
|
||||
evidence INTEGER NOT NULL DEFAULT 0,
|
||||
at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
FOREIGN KEY (lane_id) REFERENCES lanes(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lane_stage_events_lane
|
||||
ON lane_stage_events(lane_id, run_seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_lane_stage_events_pipeline
|
||||
ON lane_stage_events(pipeline, node_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lanes_session ON lanes(session_id);
|
||||
`);
|
||||
|
||||
@@ -653,6 +685,52 @@ try {
|
||||
}
|
||||
db.prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_cwd_tool ON lanes(cwd, tool)").run();
|
||||
|
||||
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
|
||||
// not read as "every pipeline has never run". Each lane's live `stages` blob
|
||||
// becomes one run; each archived feature becomes one more. That is all the
|
||||
// history those blobs ever held — the events table is what starts keeping more.
|
||||
//
|
||||
// Guarded on the table being empty rather than a migration flag: re-seeding a
|
||||
// table someone truncated re-adds at most one run per lane, and a fresh install
|
||||
// has nothing to copy.
|
||||
if (db.prepare("SELECT count(*) AS c FROM lane_stage_events").get().c === 0) {
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO lane_stage_events (lane_id, pipeline, run_seq, node_id, source, result, evidence, at)
|
||||
VALUES (?, ?, ?, ?, 'declared', ?, ?, ?)`
|
||||
);
|
||||
const seed = db.transaction(() => {
|
||||
const rows = [
|
||||
...db.prepare("SELECT id AS lane_id, pipeline, stages FROM lanes").all(),
|
||||
...db.prepare("SELECT lane_id, pipeline, stages FROM lane_features").all(),
|
||||
];
|
||||
const runByLane = new Map();
|
||||
for (const row of rows) {
|
||||
let stages;
|
||||
try {
|
||||
stages = JSON.parse(row.stages || "{}");
|
||||
} catch {
|
||||
continue; // a corrupt blob costs one row, never the migration
|
||||
}
|
||||
const entries = Object.entries(stages);
|
||||
if (!entries.length) continue;
|
||||
const runSeq = (runByLane.get(row.lane_id) || 0) + 1;
|
||||
runByLane.set(row.lane_id, runSeq);
|
||||
for (const [nodeId, rec] of entries) {
|
||||
insert.run(
|
||||
row.lane_id,
|
||||
row.pipeline || "default",
|
||||
runSeq,
|
||||
nodeId,
|
||||
rec && rec.result ? rec.result : null,
|
||||
rec && rec.evidence ? 1 : 0,
|
||||
(rec && rec.enteredAt) || new Date().toISOString()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
seed();
|
||||
}
|
||||
|
||||
// Migrate: link agent rows to a workflow run. Workflow inner-agents are already
|
||||
// ingested as subagents (same subagents/ dir); these columns add the grouping +
|
||||
// phase that the run journal provides. Additive, safe on existing DBs.
|
||||
|
||||
@@ -72,6 +72,7 @@ const pushRouter = require("./routes/push");
|
||||
const importRouter = require("./routes/import");
|
||||
const updatesRouter = require("./routes/updates");
|
||||
const ccConfigRouter = require("./routes/cc-config");
|
||||
const codexConfigRouter = require("./routes/codex-config");
|
||||
const runRouter = require("./routes/run");
|
||||
const alertsRouter = require("./routes/alerts");
|
||||
const webhooksRouter = require("./routes/webhooks");
|
||||
@@ -113,6 +114,7 @@ function createApp() {
|
||||
app.use("/api/import", importRouter);
|
||||
app.use("/api/updates", updatesRouter);
|
||||
app.use("/api/cc-config", ccConfigRouter);
|
||||
app.use("/api/codex-config", codexConfigRouter);
|
||||
app.use("/api/run", runRouter);
|
||||
app.use("/api/lanes", lanesRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
|
||||
@@ -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,
|
||||
@@ -925,6 +945,7 @@ module.exports = {
|
||||
parseFrontmatter,
|
||||
redactSettings,
|
||||
isUnder,
|
||||
isDirLike,
|
||||
isFileLike,
|
||||
MAX_FILE_BYTES,
|
||||
HOOK_EVENT_TYPES,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+37
-73
@@ -31,6 +31,12 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
const { isUnder, MAX_FILE_BYTES } = require("./cc-discovery");
|
||||
const {
|
||||
timestamp,
|
||||
createBackup,
|
||||
atomicWriteFile,
|
||||
assertNoSymlinkEscape,
|
||||
} = require("./config-write");
|
||||
|
||||
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
// Auto-memory files are arbitrary flat *.md filenames inside a project's
|
||||
@@ -117,6 +123,7 @@ function resolveTarget(scope, type, name, opts = {}) {
|
||||
target: filePath,
|
||||
filePath,
|
||||
containmentRoot: path.dirname(filePath),
|
||||
trustedRoot: path.dirname(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,7 +133,15 @@ function resolveTarget(scope, type, name, opts = {}) {
|
||||
throw makeError("EBADNAME", `auto-memory name must be a flat *.md filename`);
|
||||
}
|
||||
const target = path.join(memDir, name);
|
||||
return { kind: "file", target, filePath: target, containmentRoot: memDir };
|
||||
// The projects root, not memDir: `<projects>/<slug>/memory` is itself
|
||||
// replaceable with a link, so it cannot be its own comparison base.
|
||||
return {
|
||||
kind: "file",
|
||||
target,
|
||||
filePath: target,
|
||||
containmentRoot: memDir,
|
||||
trustedRoot: path.join(getClaudeHome(), "projects"),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
@@ -143,6 +158,7 @@ function resolveTarget(scope, type, name, opts = {}) {
|
||||
target,
|
||||
filePath: path.join(target, spec.filename),
|
||||
containmentRoot: subdirAbs,
|
||||
trustedRoot: root,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,6 +169,7 @@ function resolveTarget(scope, type, name, opts = {}) {
|
||||
target,
|
||||
filePath: target,
|
||||
containmentRoot: subdirAbs,
|
||||
trustedRoot: root,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,84 +190,25 @@ function autoMemoryBackupRoot(memDir) {
|
||||
return path.join(memDir, ".cc-config-backups", "auto-memory");
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[:]/g, "-");
|
||||
}
|
||||
|
||||
function copyDirSync(src, dst) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const s = path.join(src, ent.name);
|
||||
const d = path.join(dst, ent.name);
|
||||
if (ent.isDirectory()) copyDirSync(s, d);
|
||||
else if (ent.isFile()) fs.copyFileSync(s, d);
|
||||
// symlinks/sockets/etc skipped intentionally — these surfaces are
|
||||
// text-file-only by spec
|
||||
}
|
||||
}
|
||||
|
||||
function rmTreeSync(p) {
|
||||
fs.rmSync(p, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-on backup. For files, copies to <backupRoot>/<name>.<ts>.bak. For
|
||||
* dirs (skills), copies the whole tree. Returns the backup path (or null
|
||||
* if there was nothing to back up — e.g. brand-new file).
|
||||
* Always-on backup, routed to the bucket the (scope, type) pair backs up
|
||||
* into. For files, copies to <bucket>/<name>.<ts>.bak. For dirs (skills),
|
||||
* copies the whole tree. Returns the backup path (or null if there was
|
||||
* nothing to back up — e.g. brand-new file).
|
||||
*/
|
||||
function createBackup({ scope, type, target, kind, opts }) {
|
||||
if (!fs.existsSync(target)) return null;
|
||||
function backupTarget({ scope, type, target, opts, trustedRoot }) {
|
||||
let root;
|
||||
if (type === "memory") root = memoryBackupRoot(scope, opts);
|
||||
else if (type === "auto-memory") root = autoMemoryBackupRoot(path.dirname(target));
|
||||
else root = backupRoot(scope, type, opts);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const base = path.basename(target);
|
||||
const stamp = timestamp();
|
||||
if (kind === "dir") {
|
||||
const dst = path.join(root, `${base}.${stamp}.bak`);
|
||||
copyDirSync(target, dst);
|
||||
return dst;
|
||||
}
|
||||
// file
|
||||
const dst = path.join(root, `${base}.${stamp}.bak`);
|
||||
fs.copyFileSync(target, dst);
|
||||
return dst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write: tmp file → fsync (best-effort) → rename. Tmp is unlinked
|
||||
* on any failure path. Caller is responsible for ensuring parent dir exists.
|
||||
*/
|
||||
function atomicWriteFile(filePath, content) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(tmp, "wx");
|
||||
fs.writeSync(fd, content);
|
||||
try {
|
||||
fs.fsyncSync(fd);
|
||||
} catch {
|
||||
// fsync may fail on some filesystems / tmpfs — non-fatal
|
||||
}
|
||||
fs.closeSync(fd);
|
||||
fd = null;
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch (err) {
|
||||
try {
|
||||
if (fd != null) fs.closeSync(fd);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// The backup DESTINATION needs the same guard as the artifact: a link at
|
||||
// `<root>/cc-config-backups` sends the copied contents outside the root.
|
||||
if (trustedRoot) assertNoSymlinkEscape(trustedRoot, root, { label: "Claude" });
|
||||
return createBackup({ source: target, backupRoot: root, type, name: path.basename(target) });
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────
|
||||
@@ -275,15 +233,18 @@ function writeArtifact(args) {
|
||||
if (!isUnder(r.containmentRoot, r.target)) {
|
||||
throw makeError("EOUTOFROOT", "resolved path is outside containment root");
|
||||
}
|
||||
// The lexical check above is defeated by a symlink at the target or at any
|
||||
// ancestor of it — see config-write.js. Resolve before writing.
|
||||
assertNoSymlinkEscape(r.trustedRoot, r.target, { label: "Claude" });
|
||||
|
||||
const existedBefore = fs.existsSync(r.filePath);
|
||||
const backupPath = existedBefore
|
||||
? createBackup({
|
||||
? backupTarget({
|
||||
scope,
|
||||
type,
|
||||
target: r.kind === "dir" ? r.target : r.filePath,
|
||||
kind: r.kind,
|
||||
opts: { cwd },
|
||||
trustedRoot: r.trustedRoot,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -312,17 +273,20 @@ function deleteArtifact(args) {
|
||||
if (!isUnder(r.containmentRoot, r.target)) {
|
||||
throw makeError("EOUTOFROOT", "resolved path is outside containment root");
|
||||
}
|
||||
// rmSync/unlinkSync only unlink the link itself, but the backup that runs
|
||||
// first copies THROUGH it — so the same guard has to cover delete.
|
||||
assertNoSymlinkEscape(r.trustedRoot, r.target, { label: "Claude" });
|
||||
|
||||
if (!fs.existsSync(r.target)) {
|
||||
throw makeError("ENOTFOUND", `${type}/${name || "CLAUDE.md"} does not exist`);
|
||||
}
|
||||
|
||||
const backupPath = createBackup({
|
||||
const backupPath = backupTarget({
|
||||
scope,
|
||||
type,
|
||||
target: r.target,
|
||||
kind: r.kind === "memoryFile" ? "file" : r.kind,
|
||||
opts: { cwd },
|
||||
trustedRoot: r.trustedRoot,
|
||||
});
|
||||
|
||||
if (r.kind === "dir") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* @file codex-cli.js
|
||||
* @description Drives the official `codex mcp …` / `codex plugin …` CLI so
|
||||
* the Codex config panel can add/remove MCP servers, marketplaces and
|
||||
* plugins without the dashboard touching Codex's TOML config directly.
|
||||
*
|
||||
* Why the CLI and not direct TOML edits: the CLI owns config.toml's schema
|
||||
* (MCP server table, plugin/marketplace registration) and rewrites it in
|
||||
* place. Reproducing that here would drift the moment the CLI's format
|
||||
* moves — the same reasoning `cc-plugin-cli.js` follows for `claude plugin`.
|
||||
*
|
||||
* Hard constraints (do not relax without a follow-up review):
|
||||
* - `execFile` with an explicit argv array. NEVER a shell string — a
|
||||
* server/plugin/marketplace name comes straight from a user-typed field.
|
||||
* - A name is validated against `NAME_RE` BEFORE spawning: no leading `-`
|
||||
* (never a flag), no `/` (never a path). This is the same guard the
|
||||
* HTTP routes re-check. Plugin specs and URLs get their own purpose-built
|
||||
* guards (`assertPluginSpec`, `assertUrl`) — same idea, looser shape.
|
||||
* - Mutating calls are serialized in-process; two dashboard clicks must
|
||||
* not interleave writes to the same config.toml.
|
||||
* - `CODEX_HOME` is pinned to the same home `codex-discovery` reads, so
|
||||
* the CLI writes where the dashboard looks.
|
||||
*
|
||||
* A non-zero CLI exit resolves as `{ok: false}` (data, not an error) so a
|
||||
* route can map it to HTTP 422. Only a rejected name or a missing binary
|
||||
* throws — those are programming/environment errors, not CLI outcomes.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFile } = require("node:child_process");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
// Reused, not reimplemented — see marketplaceAdd's doc comment.
|
||||
const { assertSource } = require("./cc-plugin-cli");
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
|
||||
/** An MCP server / plugin / marketplace name. No slashes: never a path. */
|
||||
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
/** `name` or `name@marketplace` — same shape as cc-plugin-cli's PLUGIN_SPEC_RE. */
|
||||
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
||||
/** An environment variable name — used for both `--env K=V` and `--bearer-token-env-var`. */
|
||||
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function makeError(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline check applied to every argv element we interpolate. A value that
|
||||
* starts with `-` would be parsed as a flag by the CLI even inside an argv
|
||||
* array, so it is rejected rather than escaped.
|
||||
*/
|
||||
function assertArg(value, label) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw makeError("EBADARG", `${label} is required`);
|
||||
}
|
||||
if (value.length > 512) throw makeError("EBADARG", `${label} is too long`);
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\u0000-\u001f\u007f]/.test(value)) {
|
||||
throw makeError("EBADARG", `${label} contains control characters`);
|
||||
}
|
||||
if (value.startsWith("-")) throw makeError("EBADARG", `${label} may not start with "-"`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** An MCP server / plugin / marketplace identifier — the routes' shared guard. */
|
||||
function assertName(value, label = "name") {
|
||||
assertArg(value, label);
|
||||
if (!NAME_RE.test(value)) {
|
||||
throw makeError("EBADARG", `${label} is not a valid identifier`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `<name>` or `<name>@<marketplace>`, as `codex plugin add` accepts. */
|
||||
function assertPluginSpec(value) {
|
||||
assertArg(value, "plugin spec");
|
||||
if (!PLUGIN_SPEC_RE.test(value)) {
|
||||
throw makeError("EBADARG", "plugin spec must be <name> or <name>@<marketplace>");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** An environment variable NAME (never a literal secret). */
|
||||
function assertEnvKey(value, label) {
|
||||
assertArg(value, label);
|
||||
if (!ENV_KEY_RE.test(value)) {
|
||||
throw makeError("EBADARG", `${label} is not a valid identifier`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* An environment variable VALUE. Unlike `assertArg`, a leading "-" is fine:
|
||||
* the value is the right-hand side of one `KEY=VALUE` argv element (built
|
||||
* by envFlags below), never its own argv position, so it can never be
|
||||
* parsed as a flag. Rejecting it anyway (as `assertArg` would) refused
|
||||
* ordinary values like "-1" or a base64 string that happens to start with
|
||||
* "-" for no real reason.
|
||||
*/
|
||||
function assertEnvValue(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;
|
||||
}
|
||||
|
||||
/** An http(s) URL — `new URL()` both validates and normalizes the parse. */
|
||||
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;
|
||||
}
|
||||
|
||||
// One in-process queue for every mutating CLI call — the same reasoning as
|
||||
// cc-plugin-cli.js's `mutationQueue`: this dashboard is the only Node writer,
|
||||
// so a promise chain is the whole locking story. It does NOT protect against
|
||||
// a human running `codex …` in a terminal at the same moment.
|
||||
let mutationQueue = Promise.resolve();
|
||||
|
||||
function serialize(fn) {
|
||||
const run = mutationQueue.then(fn, fn);
|
||||
mutationQueue = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `codex <args…>` and resolve with the outcome. A non-zero exit is a
|
||||
* normal result (`ok:false`), not a rejection. When stdout parses as JSON
|
||||
* the parsed value is attached as `data`.
|
||||
*
|
||||
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
|
||||
* @param {{timeoutMs?:number}} [opts]
|
||||
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, data?:object}>}
|
||||
*/
|
||||
function runCodex(args, opts = {}) {
|
||||
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
"codex",
|
||||
args,
|
||||
{
|
||||
timeout,
|
||||
maxBuffer: MAX_OUTPUT_BYTES,
|
||||
// Pin the CLI to the same home codex-discovery reads, so a write
|
||||
// made here is a write the dashboard can see.
|
||||
env: { ...process.env, CODEX_HOME: getCodexHome() },
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
const out = String(stdout || "");
|
||||
const errOut = String(stderr || "");
|
||||
if (err && (err.code === "ENOENT" || err.code === "EACCES")) {
|
||||
return reject(
|
||||
makeError(
|
||||
"ECLINOTFOUND",
|
||||
"the `codex` CLI was not found on PATH — install Codex or add it to the dashboard's PATH"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (err && err.killed) {
|
||||
return reject(makeError("ETIMEOUT", `codex ${args[0]} timed out after ${timeout}ms`));
|
||||
}
|
||||
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
|
||||
const result = { ok: !err, code, stdout: out, stderr: errOut };
|
||||
try {
|
||||
result.data = JSON.parse(out.trim());
|
||||
} catch {
|
||||
/* not every command emits JSON */
|
||||
}
|
||||
resolve(result);
|
||||
}
|
||||
);
|
||||
// Every command here is non-interactive. Closing stdin turns a would-be
|
||||
// prompt into an immediate CLI error instead of a hang until `timeout`.
|
||||
try {
|
||||
child.stdin?.end();
|
||||
} catch {
|
||||
/* the child may already have exited */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── MCP servers ────────────────────────────────────────────────────────
|
||||
|
||||
async function mcpList() {
|
||||
return runCodex(["mcp", "list", "--json"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `codex mcp add` has two mutually exclusive forms: a streamable HTTP server
|
||||
* (`--url`, optionally `--bearer-token-env-var`) or a stdio server
|
||||
* (`-- <command> <args…>`, optionally `--env K=V`). `--env` is invalid on
|
||||
* the `--url` form per the CLI's own `--help`, so `{url, env}` together is
|
||||
* rejected rather than silently dropping one of them.
|
||||
*/
|
||||
async function mcpAdd({ name, url, bearerTokenEnvVar, command, args = [], env }) {
|
||||
const n = assertName(name);
|
||||
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 (env !== undefined && (typeof env !== "object" || env === null || Array.isArray(env))) {
|
||||
throw makeError("EBADARG", "env must be an object");
|
||||
}
|
||||
|
||||
if (url) {
|
||||
if (env) throw makeError("EBADARG", "env is not valid together with url (stdio-only)");
|
||||
const u = assertUrl(url, "url");
|
||||
const flags = bearerTokenEnvVar
|
||||
? ["--bearer-token-env-var", assertEnvKey(bearerTokenEnvVar, "bearerTokenEnvVar")]
|
||||
: [];
|
||||
return serialize(() => runCodex(["mcp", "add", n, ...flags, "--url", u]));
|
||||
}
|
||||
|
||||
const cmd = assertArg(command, "command");
|
||||
const cmdArgs = args.map((a, i) => assertArg(a, `args[${i}]`));
|
||||
const envFlags = Object.entries(env || {}).flatMap(([k, v]) => {
|
||||
const key = assertEnvKey(k, "env key");
|
||||
assertEnvValue(String(v), `env.${k}`);
|
||||
return ["--env", `${key}=${v}`];
|
||||
});
|
||||
return serialize(() => runCodex(["mcp", "add", n, ...envFlags, "--", cmd, ...cmdArgs]));
|
||||
}
|
||||
|
||||
async function mcpRemove(name) {
|
||||
const n = assertName(name);
|
||||
return serialize(() => runCodex(["mcp", "remove", n]));
|
||||
}
|
||||
|
||||
// ── Plugins ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `codex plugin list --json` returns `{installed: [...], available: [...]}`,
|
||||
* not a bare array — unlike `mcp list`. The installed list is what the
|
||||
* Plugins tab shows; unwrap it here so `cliRead` (codex-config.js) can keep
|
||||
* treating every list route the same way: `result.data` is the list.
|
||||
*/
|
||||
async function pluginList() {
|
||||
const result = await runCodex(["plugin", "list", "--json"]);
|
||||
// `data` is null when stdout was the literal `null` — JSON that parses to
|
||||
// nothing. Guard it: a CLI's output must never reach the route as a 500.
|
||||
if (result.data !== undefined && !Array.isArray(result.data)) {
|
||||
result.data = Array.isArray(result.data?.installed) ? result.data.installed : [];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function pluginAdd(spec) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runCodex(["plugin", "add", s]));
|
||||
}
|
||||
|
||||
async function pluginRemove(spec) {
|
||||
// A spec, not a bare name: `plugin add` accepts `<name>@<marketplace>`, so
|
||||
// `plugin remove` has to accept back what `plugin list` hands out.
|
||||
// cc-plugin-cli.js validates every plugin verb the same way.
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runCodex(["plugin", "remove", s]));
|
||||
}
|
||||
|
||||
// ── Marketplaces ───────────────────────────────────────────────────────
|
||||
|
||||
/** Same object-not-array shape as `pluginList` — unwrap `marketplaces`. */
|
||||
async function marketplaceList() {
|
||||
const result = await runCodex(["plugin", "marketplace", "list", "--json"]);
|
||||
if (result.data !== undefined && !Array.isArray(result.data)) {
|
||||
result.data = Array.isArray(result.data?.marketplaces) ? result.data.marketplaces : [];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* `codex plugin marketplace add <SOURCE>` accepts a local path, `owner/repo`,
|
||||
* an https git URL, or an ssh git URL — not just http(s), so `assertUrl`
|
||||
* rejected the common `owner/repo` form. Reuse cc-plugin-cli's `assertSource`
|
||||
* (same shape `claude plugin marketplace add` accepts) instead of writing a
|
||||
* third validator. That guard also excludes ssh remotes and `..` — kept as
|
||||
* is here too: ssh needs its own credential/host-key handling this dashboard
|
||||
* has no story for yet, and every other CLI-facing source guard in this repo
|
||||
* already draws that same line.
|
||||
*/
|
||||
async function marketplaceAdd(source) {
|
||||
const s = assertSource(source);
|
||||
return serialize(() => runCodex(["plugin", "marketplace", "add", s]));
|
||||
}
|
||||
|
||||
async function marketplaceRemove(name) {
|
||||
const n = assertName(name);
|
||||
return serialize(() => runCodex(["plugin", "marketplace", "remove", n]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort human message from a failed run. The CLI prints its errors with
|
||||
* a `✘` marker on either stream; fall back to the last non-empty line.
|
||||
* Unchanged copy of cc-plugin-cli.js's cliMessage — same CLI-output shape,
|
||||
* same heuristic.
|
||||
*/
|
||||
function cliMessage(res) {
|
||||
const text = `${res.stderr || ""}\n${res.stdout || ""}`;
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const marked = lines.find((l) => l.includes("✘"));
|
||||
const picked = marked || lines[lines.length - 1] || "";
|
||||
return picked.replace(/^✘\s*/, "").slice(0, 1000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runCodex,
|
||||
cliMessage,
|
||||
mcpList,
|
||||
mcpAdd,
|
||||
mcpRemove,
|
||||
pluginList,
|
||||
pluginAdd,
|
||||
pluginRemove,
|
||||
marketplaceList,
|
||||
marketplaceAdd,
|
||||
marketplaceRemove,
|
||||
NAME_RE,
|
||||
PLUGIN_SPEC_RE,
|
||||
assertPluginSpec,
|
||||
assertUrl,
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* @file codex-discovery.js
|
||||
* @description Read-only discovery of Codex CLI configuration surfaces
|
||||
* (skills, agents, rules, memory/AGENTS.md, config.toml). Powers the Codex
|
||||
* Config panel. All operations are pure file reads — never writes. MCP
|
||||
* servers and plugins are not covered here — they come from the `codex` CLI
|
||||
* itself, read elsewhere.
|
||||
*
|
||||
* Path containment: every read resolves under getCodexHome(),
|
||||
* getProjectCodexDir(), or getProjectRoot() (for AGENTS.md). Mirrors
|
||||
* cc-discovery.js's scope handling and record shapes so the two panels feel
|
||||
* like one system.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("./codex-home");
|
||||
const {
|
||||
parseFrontmatter,
|
||||
isUnder,
|
||||
isDirLike,
|
||||
isFileLike,
|
||||
MAX_FILE_BYTES,
|
||||
} = require("./cc-discovery");
|
||||
|
||||
function safeReadText(absPath) {
|
||||
try {
|
||||
const stat = fs.statSync(absPath);
|
||||
if (!stat.isFile()) return null;
|
||||
if (stat.size > MAX_FILE_BYTES) {
|
||||
return {
|
||||
truncated: true,
|
||||
size: stat.size,
|
||||
text: fs.readFileSync(absPath, "utf8").slice(0, MAX_FILE_BYTES),
|
||||
mtime: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
truncated: false,
|
||||
size: stat.size,
|
||||
text: fs.readFileSync(absPath, "utf8"),
|
||||
mtime: stat.mtimeMs,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listDir(absPath) {
|
||||
try {
|
||||
return fs.readdirSync(absPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skills ──────────────────────────────────────────────────────────────
|
||||
|
||||
function readSkillsAt(scope, codexDir) {
|
||||
const dir = path.join(codexDir, "skills");
|
||||
const skills = [];
|
||||
for (const ent of listDir(dir)) {
|
||||
const skillDir = path.join(dir, ent.name);
|
||||
// isUnder here is defensive only — ent.name comes from readdirSync and
|
||||
// can't carry a traversal segment, so this can never actually trip.
|
||||
if (!isDirLike(ent, skillDir) || !isUnder(codexDir, skillDir)) continue;
|
||||
const skillFile = path.join(skillDir, "SKILL.md");
|
||||
const read = safeReadText(skillFile);
|
||||
if (!read) continue;
|
||||
const { frontmatter, body } = parseFrontmatter(read.text);
|
||||
skills.push({
|
||||
scope,
|
||||
name: ent.name,
|
||||
path: skillDir,
|
||||
file: skillFile,
|
||||
size: read.size,
|
||||
mtime: read.mtime,
|
||||
truncated: read.truncated,
|
||||
frontmatter: frontmatter || {},
|
||||
preview: body.slice(0, 320),
|
||||
});
|
||||
}
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function readSkills(opts = {}) {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") out.push(...readSkillsAt("user", getCodexHome()));
|
||||
if (opts.scope !== "user") out.push(...readSkillsAt("project", getProjectCodexDir(opts.cwd)));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Agents (TOML) ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Best-effort reader for the handful of top-level scalar fields the UI
|
||||
* shows (`name`, `description`, `model`, `sandbox_mode`). Matches
|
||||
* `key = "value"` / `key = 123` / `key = true` lines appearing before the
|
||||
* first `[section]`, skipping over `"""..."""` blocks so a field name
|
||||
* mentioned inside one (e.g. in `developer_instructions`) is never mistaken
|
||||
* for a real top-level key. Anything richer than these scalars is the
|
||||
* raw-text editor's job, not this reader's — no TOML parser here.
|
||||
*/
|
||||
function readTomlMeta(text) {
|
||||
const meta = {};
|
||||
const lines = text.split("\n");
|
||||
let inTripleQuote = false;
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (inTripleQuote) {
|
||||
if (line.includes('"""')) inTripleQuote = false;
|
||||
continue;
|
||||
}
|
||||
if (/^\[/.test(line)) break; // first section ends the top-level scalars
|
||||
const m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
let value = m[2];
|
||||
if (value.startsWith('"""')) {
|
||||
if (!value.slice(3).includes('"""')) inTripleQuote = true;
|
||||
continue;
|
||||
}
|
||||
if (value.startsWith('"') || value.startsWith("'")) {
|
||||
// Find the real closing quote — one not preceded by an ODD run of
|
||||
// backslashes (an even run, including zero, means it isn't escaped) —
|
||||
// so a trailing `# comment` outside the string is stripped without
|
||||
// being fooled by an escaped quote or an escaped backslash inside it.
|
||||
const quote = value[0];
|
||||
let closeIdx = -1;
|
||||
for (let i = 1; i < value.length; i++) {
|
||||
if (value[i] !== quote) continue;
|
||||
let backslashes = 0;
|
||||
while (value[i - 1 - backslashes] === "\\") backslashes++;
|
||||
if (backslashes % 2 === 0) {
|
||||
closeIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (closeIdx < 0) continue;
|
||||
// \\ decodes to one backslash; other escapes (e.g. \") are left
|
||||
// literal — this is a best-effort reader, not a full TOML unescaper.
|
||||
value = value.slice(1, closeIdx).replace(/\\\\/g, "\\");
|
||||
} else if (/^(true|false|-?\d+(\.\d+)?)\s*(#.*)?$/.test(value)) {
|
||||
value = value.replace(/\s*#.*$/, "");
|
||||
value = value === "true" ? true : value === "false" ? false : Number(value);
|
||||
} else {
|
||||
continue; // arrays, inline tables, etc. — not one of the shown fields
|
||||
}
|
||||
meta[m[1]] = value;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
function readAgentsAt(scope, codexDir) {
|
||||
const dir = path.join(codexDir, "agents");
|
||||
const agents = [];
|
||||
for (const ent of listDir(dir)) {
|
||||
if (!ent.name.endsWith(".toml")) continue;
|
||||
const file = path.join(dir, ent.name);
|
||||
// isUnder here is defensive only — ent.name comes from readdirSync and
|
||||
// can't carry a traversal segment, so this can never actually trip.
|
||||
if (!isFileLike(ent, file) || !isUnder(codexDir, file)) continue;
|
||||
const read = safeReadText(file);
|
||||
if (!read) continue;
|
||||
agents.push({
|
||||
scope,
|
||||
name: ent.name.replace(/\.toml$/, ""),
|
||||
file,
|
||||
size: read.size,
|
||||
mtime: read.mtime,
|
||||
meta: readTomlMeta(read.text),
|
||||
preview: read.text.slice(0, 320),
|
||||
});
|
||||
}
|
||||
return agents.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function readAgents(opts = {}) {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") out.push(...readAgentsAt("user", getCodexHome()));
|
||||
if (opts.scope !== "user") out.push(...readAgentsAt("project", getProjectCodexDir(opts.cwd)));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Rules (plain text) ─────────────────────────────────────────────────
|
||||
|
||||
function readRulesAt(scope, codexDir) {
|
||||
const dir = path.join(codexDir, "rules");
|
||||
const rules = [];
|
||||
for (const ent of listDir(dir)) {
|
||||
if (!ent.name.endsWith(".rules")) continue;
|
||||
const file = path.join(dir, ent.name);
|
||||
// isUnder here is defensive only — ent.name comes from readdirSync and
|
||||
// can't carry a traversal segment, so this can never actually trip.
|
||||
if (!isFileLike(ent, file) || !isUnder(codexDir, file)) continue;
|
||||
const read = safeReadText(file);
|
||||
if (!read) continue;
|
||||
rules.push({
|
||||
scope,
|
||||
name: ent.name.replace(/\.rules$/, ""),
|
||||
file,
|
||||
size: read.size,
|
||||
mtime: read.mtime,
|
||||
preview: read.text.slice(0, 320),
|
||||
});
|
||||
}
|
||||
return rules.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function readRules(opts = {}) {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") out.push(...readRulesAt("user", getCodexHome()));
|
||||
if (opts.scope !== "user") out.push(...readRulesAt("project", getProjectCodexDir(opts.cwd)));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Memory (AGENTS.md) ─────────────────────────────────────────────────
|
||||
|
||||
function readMemory(opts = {}) {
|
||||
const sources = [
|
||||
{ scope: "user", file: path.join(getCodexHome(), "AGENTS.md") },
|
||||
{ scope: "project", file: path.join(getProjectRoot(opts.cwd), "AGENTS.md") },
|
||||
];
|
||||
return sources.map(({ scope, file }) => {
|
||||
const r = safeReadText(file);
|
||||
if (!r) return { scope, file, exists: false };
|
||||
return {
|
||||
scope,
|
||||
file,
|
||||
exists: true,
|
||||
size: r.size,
|
||||
mtime: r.mtime,
|
||||
preview: r.text.slice(0, 480),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Config (config.toml) ───────────────────────────────────────────────
|
||||
|
||||
function readConfig(opts = {}) {
|
||||
const sources = [
|
||||
{ scope: "user", file: path.join(getCodexHome(), "config.toml") },
|
||||
{ scope: "project", file: path.join(getProjectCodexDir(opts.cwd), "config.toml") },
|
||||
];
|
||||
return sources.map(({ scope, file }) => {
|
||||
const r = safeReadText(file);
|
||||
if (!r) return { scope, file, exists: false };
|
||||
return {
|
||||
scope,
|
||||
file,
|
||||
exists: true,
|
||||
size: r.size,
|
||||
mtime: r.mtime,
|
||||
content: r.text,
|
||||
truncated: r.truncated,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Overview (counts + roots) ──────────────────────────────────────────
|
||||
|
||||
function readOverview(opts = {}) {
|
||||
const skills = readSkills(opts);
|
||||
const agents = readAgents(opts);
|
||||
const rules = readRules(opts);
|
||||
const memory = readMemory(opts);
|
||||
const config = readConfig(opts);
|
||||
|
||||
const countByScope = (arr) => ({
|
||||
user: arr.filter((x) => x.scope === "user").length,
|
||||
project: arr.filter((x) => x.scope === "project").length,
|
||||
});
|
||||
|
||||
return {
|
||||
roots: {
|
||||
codexHome: getCodexHome(),
|
||||
projectCodexDir: getProjectCodexDir(opts.cwd),
|
||||
projectRoot: getProjectRoot(opts.cwd),
|
||||
},
|
||||
counts: {
|
||||
skills: countByScope(skills),
|
||||
agents: countByScope(agents),
|
||||
rules: countByScope(rules),
|
||||
memory: countByScope(memory.filter((m) => m.exists)),
|
||||
config: countByScope(config.filter((c) => c.exists)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Single-file body endpoint (with strict path containment) ───────────
|
||||
|
||||
/**
|
||||
* Read the body of one file living under CODEX_HOME, the project .codex
|
||||
* dir, or (only for a file literally named AGENTS.md) the project root.
|
||||
* Mirrors cc-discovery's readFileSafe.
|
||||
*
|
||||
* Containment here is lexical — `isUnder` after `path.resolve`, never a
|
||||
* realpath check — and deliberately so: a path whose LOCATION lies inside a
|
||||
* root is readable even when it is a symlink resolving somewhere else. The
|
||||
* other four Codex readers (readSkills/readAgents/readRules/readMemory/
|
||||
* readConfig) already follow such symlinks — a symlinked dotfiles skill or
|
||||
* config is the normal setup — so this endpoint refusing the same path
|
||||
* would show a file in the list, preview its body, and then bare-`READ_DENIED`
|
||||
* it on click. The write side (codex-mutate.js) keeps its stricter
|
||||
* realpath-based `assertNotSymlinkEscape`: reading through a link shows you
|
||||
* your own file, writing through one clobbers something unrelated — that
|
||||
* asymmetry is intentional, not an inconsistency to "fix" here.
|
||||
*
|
||||
* A `path` that resolves outside the roots (e.g. `../../etc/passwd`) or a
|
||||
* `?cwd=`-driven escape are still refused by the lexical `isUnder` checks
|
||||
* below — only the symlink-target check is relaxed. Failure messages never
|
||||
* include the resolved path — a `GET /file` response is not the place to
|
||||
* reveal what does or does not exist outside the root.
|
||||
*
|
||||
* @returns {{ok:true,file:string,size:number,mtime:number,truncated:boolean,text:string}
|
||||
* | {notFound:true} | {error:string}}
|
||||
*/
|
||||
function readFileSafe(absPath, opts = {}) {
|
||||
const codexHome = getCodexHome();
|
||||
const projectCodexDir = getProjectCodexDir(opts.cwd);
|
||||
const projectRoot = getProjectRoot(opts.cwd);
|
||||
const resolved = path.resolve(absPath);
|
||||
|
||||
const inside = [codexHome, projectCodexDir, projectRoot].some((root) => isUnder(root, resolved));
|
||||
if (!inside) return { error: "path is outside allowed roots" };
|
||||
if (
|
||||
isUnder(projectRoot, resolved) &&
|
||||
!isUnder(projectCodexDir, resolved) &&
|
||||
path.basename(resolved) !== "AGENTS.md"
|
||||
) {
|
||||
return { error: "only AGENTS.md is readable from project root" };
|
||||
}
|
||||
|
||||
const r = safeReadText(resolved);
|
||||
if (!r) return { notFound: true };
|
||||
return { ok: true, file: resolved, ...r };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readSkills,
|
||||
readAgents,
|
||||
readRules,
|
||||
readMemory,
|
||||
readConfig,
|
||||
readOverview,
|
||||
readFileSafe,
|
||||
readTomlMeta,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @file Centralized Codex home directory path management. Resolves the Codex
|
||||
* home directory, project-level Codex surfaces, and project roots. Supports a
|
||||
* custom root via the CODEX_HOME environment variable so the dashboard can
|
||||
* track non-standard Codex installations.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
/**
|
||||
* The Codex home directory. Resolves to $CODEX_HOME if set, else
|
||||
* ~/.codex. Always reads process.env at call time so tests can inject.
|
||||
*
|
||||
* @returns {string} Absolute path to the Codex home directory.
|
||||
*/
|
||||
function getCodexHome() {
|
||||
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory where Codex stores per-project surfaces (rollout journals,
|
||||
* SQLite mirrors, etc.) for a project at the given working directory.
|
||||
*
|
||||
* @param {string} cwd Absolute path to a project root.
|
||||
* @returns {string} Absolute path to <cwd>/.codex.
|
||||
*/
|
||||
function getProjectCodexDir(cwd) {
|
||||
return path.join(cwd, ".codex");
|
||||
}
|
||||
|
||||
/**
|
||||
* The project root. For now, this is just an identity function — cwd is
|
||||
* the project root. Exists as a contract for consistency with claude-home.js
|
||||
* and to anchor the path-resolution hierarchy.
|
||||
*
|
||||
* @param {string} cwd Absolute path to a project root (where AGENTS.md lives).
|
||||
* @returns {string} The same cwd.
|
||||
*/
|
||||
function getProjectRoot(cwd) {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCodexHome,
|
||||
getProjectCodexDir,
|
||||
getProjectRoot,
|
||||
};
|
||||
@@ -33,6 +33,7 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
|
||||
// `*** Update File: <path>` lines inside an apply_patch heredoc.
|
||||
const PATCH_FILE_RE = /^\*\*\* (Add|Update|Delete) File: (.+)$/gm;
|
||||
@@ -98,10 +99,6 @@ function eventsFromPayload(payload) {
|
||||
return [{ tool_name: "Bash", tool_input: { command: commandFromPayload(payload, text) } }];
|
||||
}
|
||||
|
||||
function codexHome() {
|
||||
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
/** Every rollout journal under sessions/, newest mtime first, capped at MAX_FILES. */
|
||||
function rolloutFiles() {
|
||||
const out = [];
|
||||
@@ -126,7 +123,7 @@ function rolloutFiles() {
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(path.join(codexHome(), "sessions"), 0);
|
||||
walk(path.join(getCodexHome(), "sessions"), 0);
|
||||
return out.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @file codex-mutate.js
|
||||
* @description Mutation helpers for the Codex Config panel. Handles
|
||||
* create / overwrite / delete on Codex's text-file surfaces: skills, agents,
|
||||
* rules, memory (AGENTS.md), and config (config.toml). Mirrors cc-mutate.js's
|
||||
* structure (single `resolveTarget` chokepoint, regex-validated names,
|
||||
* containment re-check, backup-before-mutation, atomic write) so the two
|
||||
* panels share the same safety shape and error-code vocabulary.
|
||||
*
|
||||
* Hard constraints (do not relax without a follow-up review):
|
||||
* - `memory` and `config` are fixed filenames (AGENTS.md / config.toml) —
|
||||
* they take NO `name`. Passing one is a caller error (EBADNAME).
|
||||
* - Every write/delete of an existing file creates a timestamped backup
|
||||
* BEFORE the mutation. Backups land under
|
||||
* <root>/codex-config-backups/<type>/, a directory Codex never scans, so
|
||||
* a deleted skill can never come back as a discoverable skill named
|
||||
* after its backup.
|
||||
* - Writes are atomic via temp file + fs.renameSync (see config-write.js).
|
||||
* - Names are validated against a strict allowlist regex; resolved paths
|
||||
* are double-checked to live under the expected containment root before
|
||||
* any I/O. That string-only check does not see through a symlink placed
|
||||
* anywhere along the path — including the type's own subdir (e.g.
|
||||
* `<root>/skills`) — so the nearest existing ancestor of the target is
|
||||
* additionally realpath-resolved and required to land inside the
|
||||
* realpath'd scope root (see `assertNotSymlinkEscape`). A symlink that
|
||||
* merely reorganises files within the root (a supported install
|
||||
* pattern, and a symlinked CODEX_HOME itself) keeps working; one
|
||||
* pointing outside is refused.
|
||||
* - Codex has no `project-local` scope and no auto-memory: only "user" and
|
||||
* "project" are valid scopes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("./codex-home");
|
||||
const { MAX_FILE_BYTES, isUnder } = require("./cc-discovery");
|
||||
const {
|
||||
createBackup,
|
||||
atomicWriteFile,
|
||||
assertUnder,
|
||||
assertNoSymlinkEscape,
|
||||
} = require("./config-write");
|
||||
|
||||
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
const TYPES = {
|
||||
skills: { kind: "dir", subdir: "skills", filename: "SKILL.md" },
|
||||
agents: { kind: "file", subdir: "agents", ext: ".toml" },
|
||||
rules: { kind: "file", subdir: "rules", ext: ".rules" },
|
||||
memory: { kind: "fixed", file: "AGENTS.md" }, // AGENTS.md, no `name`
|
||||
config: { kind: "fixed", file: "config.toml" }, // config.toml, no `name`
|
||||
};
|
||||
|
||||
function makeError(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
function assertScope(scope) {
|
||||
if (scope !== "user" && scope !== "project") {
|
||||
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The <root> a (scope) resolves under for skills/agents/rules subdirs. */
|
||||
function rootForScope(scope, opts = {}) {
|
||||
return scope === "user" ? getCodexHome() : getProjectCodexDir(opts.cwd);
|
||||
}
|
||||
|
||||
/** Fixed-filename path for memory/config, per scope's own layout rule. */
|
||||
function fixedPathFor(type, scope, opts = {}) {
|
||||
if (type === "memory") {
|
||||
return scope === "user"
|
||||
? path.join(getCodexHome(), "AGENTS.md")
|
||||
: path.join(getProjectRoot(opts.cwd), "AGENTS.md");
|
||||
}
|
||||
// config
|
||||
return scope === "user"
|
||||
? path.join(getCodexHome(), "config.toml")
|
||||
: path.join(getProjectCodexDir(opts.cwd), "config.toml");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the on-disk target for a (scope, type, name) tuple, the
|
||||
* containment root used for string-only path-traversal checks, and the
|
||||
* trusted root used for the realpath-based symlink-escape check.
|
||||
*
|
||||
* `trustedRoot` is deliberately the OUTER root (CODEX_HOME / project .codex
|
||||
* dir / project root), not `containmentRoot`: for skills/agents/rules,
|
||||
* `containmentRoot` is `<root>/<subdir>` (e.g. `<root>/skills`), and that
|
||||
* subdir itself is exactly what an attacker could replace with a symlink —
|
||||
* checking a target against its own compromised parent would always pass.
|
||||
*
|
||||
* Returns: { kind: "file" | "dir", target, filePath, containmentRoot, trustedRoot }
|
||||
*/
|
||||
function resolveTarget(scope, type, name, opts = {}) {
|
||||
const spec = TYPES[type];
|
||||
if (!spec) throw makeError("EBADTYPE", `unknown type: ${type}`);
|
||||
assertScope(scope);
|
||||
|
||||
if (spec.kind === "fixed") {
|
||||
if (name !== undefined && name !== null) {
|
||||
throw makeError("EBADNAME", `${type} is a fixed file and takes no name`);
|
||||
}
|
||||
const filePath = fixedPathFor(type, scope, opts);
|
||||
// No attacker-placeable segment between the trusted root and the fixed
|
||||
// filename, so the containment root doubles as the trusted root.
|
||||
const root = path.dirname(filePath);
|
||||
return { kind: "file", target: filePath, filePath, containmentRoot: root, trustedRoot: root };
|
||||
}
|
||||
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
throw makeError("EBADNAME", `name must match ${NAME_RE}`);
|
||||
}
|
||||
|
||||
const root = rootForScope(scope, opts);
|
||||
const subdirAbs = path.join(root, spec.subdir);
|
||||
|
||||
if (spec.kind === "dir") {
|
||||
const target = path.join(subdirAbs, name);
|
||||
return {
|
||||
kind: "dir",
|
||||
target,
|
||||
filePath: path.join(target, spec.filename),
|
||||
containmentRoot: subdirAbs,
|
||||
trustedRoot: root,
|
||||
};
|
||||
}
|
||||
|
||||
const target = path.join(subdirAbs, name + spec.ext);
|
||||
return { kind: "file", target, filePath: target, containmentRoot: subdirAbs, trustedRoot: root };
|
||||
}
|
||||
|
||||
/** <root>/codex-config-backups/<type>/, always rooted the same way as the read side's <root>. */
|
||||
function backupRootFor(scope, type, opts = {}) {
|
||||
return path.join(rootForScope(scope, opts), "codex-config-backups", type);
|
||||
}
|
||||
|
||||
function rmTreeSync(p) {
|
||||
fs.rmSync(p, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlinks are a supported install pattern (Task 3's reader follows them),
|
||||
* so a symlink under `trustedRoot` is not rejected outright — only one that
|
||||
* dereferences outside it. `trustedRoot` itself may be a symlink (a
|
||||
* dotfiles-managed CODEX_HOME is a normal setup) — that's fine, it just
|
||||
* means everything is compared against wherever it really points.
|
||||
*
|
||||
* `target` may not exist yet (a fresh create). Realpath can't resolve a
|
||||
* path that isn't there, so this walks up to the nearest ancestor that DOES
|
||||
* exist, realpath's that, and requires it to land inside the realpath'd
|
||||
* root. Everything below that existing ancestor is plain path segments this
|
||||
* module built itself from a NAME_RE-validated name or a fixed literal, so
|
||||
* they can't smuggle in a symlink — only an EXISTING ancestor can.
|
||||
*
|
||||
* `trustedRoot` is created first (mkdirSync recursive — safe, it is never
|
||||
* attacker-controlled) so the walk is guaranteed to bottom out at or below
|
||||
* it, never wander past it into whatever happens to contain it. Pass
|
||||
* `{create: false}` for a read-only caller (codex-discovery.js's
|
||||
* readFileSafe): a GET must have no filesystem side effect, so it skips the
|
||||
* mkdir and instead lets a missing `trustedRoot` surface as the native
|
||||
* `ENOENT` from `realpathSync` — the caller treats that as "not found".
|
||||
*
|
||||
* ponytail: this check and the write/delete that follows are not atomic —
|
||||
* a symlink swapped in between them could still redirect one write. Closing
|
||||
* that needs O_NOFOLLOW-style fd work with real platform variance; out of
|
||||
* scope for this task (see task-5 fix round 2).
|
||||
*/
|
||||
function assertNotSymlinkEscape(trustedRoot, target, opts = {}) {
|
||||
// Lives in config-write.js so the Claude writer runs the identical check —
|
||||
// this guard cost four fix rounds to get right and must not exist twice.
|
||||
assertNoSymlinkEscape(trustedRoot, target, { ...opts, label: "Codex" });
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create or overwrite a single text artifact. `name` is required for
|
||||
* skills/agents/rules and forbidden for memory/config.
|
||||
*
|
||||
* @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args
|
||||
*/
|
||||
function writeArtifact(args) {
|
||||
const { scope, type, name, content, cwd } = args;
|
||||
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
|
||||
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
|
||||
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
|
||||
}
|
||||
|
||||
const r = resolveTarget(scope, type, name, { cwd });
|
||||
assertUnder(r.containmentRoot, r.target);
|
||||
assertNotSymlinkEscape(r.trustedRoot, r.target);
|
||||
|
||||
const existedBefore = fs.existsSync(r.filePath);
|
||||
let backup = null;
|
||||
if (existedBefore) {
|
||||
const backupRoot = backupRootFor(scope, type, { cwd });
|
||||
// The backup write never went through the symlink-escape guard: a
|
||||
// `<root>/codex-config-backups` symlink would send the previous file's
|
||||
// body wherever it points. Same check as the target above, same reason.
|
||||
assertNotSymlinkEscape(r.trustedRoot, backupRoot);
|
||||
backup = createBackup({
|
||||
source: r.kind === "dir" ? r.target : r.filePath,
|
||||
backupRoot,
|
||||
type,
|
||||
name: path.basename(r.target),
|
||||
});
|
||||
}
|
||||
|
||||
if (r.kind === "dir") {
|
||||
fs.mkdirSync(r.target, { recursive: true });
|
||||
}
|
||||
atomicWriteFile(r.filePath, content);
|
||||
|
||||
return { file: r.filePath, created: !existedBefore, backup };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single text artifact. Backup is mandatory and runs first; if
|
||||
* the target does not exist, no backup is attempted.
|
||||
*/
|
||||
function deleteArtifact(args) {
|
||||
const { scope, type, name, cwd } = args;
|
||||
const r = resolveTarget(scope, type, name, { cwd });
|
||||
assertUnder(r.containmentRoot, r.target);
|
||||
|
||||
if (!fs.existsSync(r.target)) {
|
||||
throw makeError("ENOTFOUND", `${type}/${name || TYPES[type].file} does not exist`);
|
||||
}
|
||||
assertNotSymlinkEscape(r.trustedRoot, r.target);
|
||||
|
||||
const backupRoot = backupRootFor(scope, type, { cwd });
|
||||
assertNotSymlinkEscape(r.trustedRoot, backupRoot);
|
||||
const backup = createBackup({
|
||||
source: r.target,
|
||||
backupRoot,
|
||||
type,
|
||||
name: path.basename(r.target),
|
||||
});
|
||||
|
||||
if (r.kind === "dir") {
|
||||
rmTreeSync(r.target);
|
||||
} else {
|
||||
fs.unlinkSync(r.target);
|
||||
}
|
||||
|
||||
return { file: r.filePath, backup };
|
||||
}
|
||||
|
||||
/**
|
||||
* List backups for either all types or a specific (scope, type) bucket.
|
||||
* Returns [{ type, name, file, mtime, size }].
|
||||
*/
|
||||
function listBackups(opts = {}) {
|
||||
const scopes = opts.scope ? [opts.scope] : ["user", "project"];
|
||||
const types = opts.type ? [opts.type] : Object.keys(TYPES);
|
||||
for (const scope of scopes) assertScope(scope);
|
||||
for (const type of types) {
|
||||
if (!TYPES[type]) throw makeError("EBADTYPE", `unknown type: ${type}`);
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const scope of scopes) {
|
||||
for (const type of types) {
|
||||
const root = backupRootFor(scope, type, opts);
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const full = path.join(root, ent.name);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
type,
|
||||
name: ent.name,
|
||||
file: full,
|
||||
mtime: stat.mtimeMs,
|
||||
size: ent.isDirectory() ? null : stat.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => b.mtime - a.mtime);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
writeArtifact,
|
||||
deleteArtifact,
|
||||
listBackups,
|
||||
resolveTarget, // exported for tests
|
||||
assertNotSymlinkEscape, // shared with codex-discovery.js's readFileSafe — one check, both verbs
|
||||
TYPES,
|
||||
NAME_RE,
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @file config-write.js
|
||||
* @description Shared safe-write primitives for config writers (cc-mutate.js
|
||||
* for Claude Code, and any Codex equivalent): path containment, timestamped
|
||||
* backup-before-mutate, and atomic file write. Extracted out of cc-mutate.js
|
||||
* so both writers share one copy of this security-sensitive code instead of
|
||||
* diverging copies.
|
||||
*
|
||||
* Hard constraints (do not relax without a follow-up review):
|
||||
* - Backups happen BEFORE the mutation and land outside the tree being
|
||||
* backed up (caller passes `backupRoot`).
|
||||
* - Writes are atomic via temp file + fs.renameSync. Tmp is removed on
|
||||
* any failure path.
|
||||
* - `assertUnder` is the single containment check callers must run on any
|
||||
* resolved path before touching disk.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { isUnder } = require("./cc-discovery");
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[:]/g, "-");
|
||||
}
|
||||
|
||||
function copyDirSync(src, dst) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const s = path.join(src, ent.name);
|
||||
const d = path.join(dst, ent.name);
|
||||
if (ent.isDirectory()) copyDirSync(s, d);
|
||||
else if (ent.isFile()) fs.copyFileSync(s, d);
|
||||
// symlinks/sockets/etc skipped intentionally — these surfaces are
|
||||
// text-file-only by spec
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-on backup. Copies `source` (file or directory tree) into
|
||||
* `backupRoot` as `<name>.<ts>.bak`. Returns the backup path, or null if
|
||||
* `source` does not exist (e.g. brand-new file/skill).
|
||||
*/
|
||||
function createBackup({ source, backupRoot, type, name }) {
|
||||
if (!fs.existsSync(source)) return null;
|
||||
fs.mkdirSync(backupRoot, { recursive: true });
|
||||
const stamp = timestamp();
|
||||
const dst = path.join(backupRoot, `${name}.${stamp}.bak`);
|
||||
if (fs.statSync(source).isDirectory()) {
|
||||
copyDirSync(source, dst);
|
||||
} else {
|
||||
fs.copyFileSync(source, dst);
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write: tmp file → fsync (best-effort) → rename. Tmp is unlinked
|
||||
* on any failure path. Caller is responsible for ensuring parent dir exists.
|
||||
*/
|
||||
function atomicWriteFile(filePath, content) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(tmp, "wx");
|
||||
fs.writeSync(fd, content);
|
||||
try {
|
||||
fs.fsyncSync(fd);
|
||||
} catch {
|
||||
// fsync may fail on some filesystems / tmpfs — non-fatal
|
||||
}
|
||||
fs.closeSync(fd);
|
||||
fd = null;
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch (err) {
|
||||
try {
|
||||
if (fd != null) fs.closeSync(fd);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Throw EOUTOFROOT unless `target` resolves to `root` or a descendant. */
|
||||
function assertUnder(root, target) {
|
||||
if (!isUnder(root, target)) {
|
||||
const err = new Error("resolved path is outside containment root");
|
||||
err.code = "EOUTOFROOT";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The containment check for WRITE paths. `assertUnder` compares strings, which
|
||||
* a symlink defeats: a link planted at `<root>/skills/<name>` — or at any
|
||||
* ancestor of it — is lexically inside the root while the write lands wherever
|
||||
* the link points.
|
||||
*
|
||||
* So resolve instead of compare. Walk up from `target` to the first component
|
||||
* that actually exists, realpath THAT, and require the result inside the
|
||||
* realpath'd root. Checking only `target` is not enough: on a create the leaf
|
||||
* does not exist yet, `realpathSync` fails with ENOENT, and a symlinked
|
||||
* ancestor sails through.
|
||||
*
|
||||
* A symlink that stays inside the root still works — the rule is where the
|
||||
* path lands, not whether a link was involved.
|
||||
*
|
||||
* Read paths deliberately do NOT use this. Reading through a link shows you a
|
||||
* file your own config directory points at, which is what the CLIs do;
|
||||
* writing through one clobbers something unrelated.
|
||||
*
|
||||
* ponytail: check-then-use, so a local attacker who swaps a directory for a
|
||||
* symlink between this call and the write still wins. Closing that needs
|
||||
* O_NOFOLLOW-style fd work with real platform variance.
|
||||
*
|
||||
* @param {string} trustedRoot the outermost root the write must stay inside —
|
||||
* never a subdirectory an attacker could replace with a link.
|
||||
* @param {string} target the path about to be written, created or removed.
|
||||
* @param {{create?: boolean, label?: string}} [opts] `create: false` skips
|
||||
* materialising the root (a read-side caller must not have side effects).
|
||||
*/
|
||||
function assertNoSymlinkEscape(trustedRoot, target, opts = {}) {
|
||||
if (opts.create !== false) fs.mkdirSync(trustedRoot, { recursive: true });
|
||||
const realRoot = fs.realpathSync(trustedRoot);
|
||||
|
||||
let dir = target;
|
||||
let real;
|
||||
for (;;) {
|
||||
try {
|
||||
real = fs.realpathSync(dir);
|
||||
break;
|
||||
} catch {
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
real = dir; // hit the filesystem root without finding anything real
|
||||
break;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUnder(realRoot, real)) {
|
||||
const err = new Error(`resolved path escapes the ${opts.label || "config"} root: ${real}`);
|
||||
err.code = "EOUTOFROOT";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
timestamp,
|
||||
createBackup,
|
||||
atomicWriteFile,
|
||||
assertUnder,
|
||||
assertNoSymlinkEscape,
|
||||
};
|
||||
@@ -305,6 +305,51 @@ function reservedPorts(excludeLaneId = null) {
|
||||
* has to keep saying so, or an inferred stage becomes indistinguishable from
|
||||
* one the agent actually claimed.
|
||||
*/
|
||||
/**
|
||||
* Append one row to the stage log — the history `lanes.stages` cannot keep,
|
||||
* because that blob is the current pass and gets overwritten and truncated.
|
||||
*
|
||||
* The run a row belongs to is decided here rather than stored on the lane, so
|
||||
* there is one rule and no column to fall out of sync. A new run starts when
|
||||
* the lane moves BACKWARDS (the next feature begins at plan) or when the last
|
||||
* event sat on the pipeline's final node (the previous feature finished). Any
|
||||
* other move continues the run in flight.
|
||||
*
|
||||
* Never throws: a lane's stage must still advance if this log cannot be
|
||||
* written — the dashboard losing a statistic beats an agent's pipeline dying.
|
||||
*/
|
||||
function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) {
|
||||
try {
|
||||
const pipeline = getPipeline(lane.pipeline);
|
||||
const last = db
|
||||
.prepare("SELECT node_id, run_seq FROM lane_stage_events WHERE lane_id = ? ORDER BY id DESC")
|
||||
.get(lane.id);
|
||||
let runSeq = 1;
|
||||
if (last) {
|
||||
const lastIdx = phaseIdx(pipeline, last.node_id);
|
||||
const nextIdx = phaseIdx(pipeline, nodeId);
|
||||
const wentBack = lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx;
|
||||
const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx;
|
||||
runSeq = last.run_seq + (wentBack || finished ? 1 : 0);
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT INTO lane_stage_events (lane_id, pipeline, run_seq, node_id, source, result, evidence, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
lane.id,
|
||||
lane.pipeline,
|
||||
runSeq,
|
||||
nodeId,
|
||||
source,
|
||||
result || null,
|
||||
evidence ? 1 : 0,
|
||||
nowIso()
|
||||
);
|
||||
} catch {
|
||||
/* the log is a report, never a gate on the lane's own state */
|
||||
}
|
||||
}
|
||||
|
||||
function setStage(id, { stage, status, evidence, note, result, keepDetection } = {}) {
|
||||
const lane = getLane(id);
|
||||
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
|
||||
@@ -342,6 +387,11 @@ function setStage(id, { stage, status, evidence, note, result, keepDetection } =
|
||||
nowIso(),
|
||||
id
|
||||
);
|
||||
appendStageEvent(lane, next, {
|
||||
source: "declared",
|
||||
result: stages[next].result,
|
||||
evidence: stages[next].evidence,
|
||||
});
|
||||
return getLane(id);
|
||||
}
|
||||
|
||||
@@ -396,6 +446,7 @@ function recordDetection(id, { nodeId, signal } = {}) {
|
||||
db.prepare(
|
||||
"UPDATE lanes SET detected_stage = ?, detected_signal = ?, detected_at = ? WHERE id = ?"
|
||||
).run(nodeId, signal || null, nowIso(), id);
|
||||
appendStageEvent(lane, nodeId, { source: "detected" });
|
||||
return { written: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
// New endpoint groups (previously-undocumented gaps in the base spec).
|
||||
const ccConfig = require("./openapi-extra/cc-config");
|
||||
const codexConfig = require("./openapi-extra/codex-config");
|
||||
const run = require("./openapi-extra/run");
|
||||
const lanes = require("./openapi-extra/lanes");
|
||||
const push = require("./openapi-extra/push");
|
||||
@@ -44,6 +45,7 @@ function combine(...fragments) {
|
||||
|
||||
module.exports = combine(
|
||||
ccConfig,
|
||||
codexConfig,
|
||||
run,
|
||||
lanes,
|
||||
push,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
/**
|
||||
* @file OpenAPI 3.0 fragment for the Codex Config router
|
||||
* (`server/routes/codex-config.js`, mounted at `/api/codex-config`).
|
||||
* Documents the read surfaces (skills, agents, rules, memory, config, plus
|
||||
* MCP servers/plugins/marketplaces read live from the `codex` CLI), the
|
||||
* low-risk mutation surface (PUT/DELETE /file), the backups listing, and the
|
||||
* CLI-backed MCP/plugin/marketplace mutation endpoints.
|
||||
*
|
||||
* Mirrors `server/openapi-extra/cc-config.js`'s structure and level of
|
||||
* detail is intentionally lighter — one shared response shape per surface
|
||||
* kind rather than a bespoke schema per field — since this panel has far
|
||||
* fewer fields per record. All schema names are prefixed with `CodexConfig`.
|
||||
* The shared `{ error: { code, message } }` envelope reuses the base
|
||||
* `ErrorResponse` schema (defined in server/openapi.js).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const tags = [
|
||||
{
|
||||
name: "CodexConfig",
|
||||
description:
|
||||
"Codex CLI configuration explorer: discover and (for low-risk artifacts) edit skills, agents, rules, memory (AGENTS.md), config.toml, MCP servers, plugins, and marketplaces.",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Shared sub-schemas ─────────────────────────────────────────────────
|
||||
|
||||
const schemas = {
|
||||
CodexConfigScope: {
|
||||
type: "string",
|
||||
enum: ["user", "project", "all"],
|
||||
description:
|
||||
"Discovery scope. `user` = $CODEX_HOME (or ~/.codex); `project` = <cwd>/.codex; `all` = both, merged. Defaults to `all` when omitted or unrecognized.",
|
||||
example: "all",
|
||||
},
|
||||
|
||||
CodexConfigSkill: {
|
||||
type: "object",
|
||||
description:
|
||||
"A discovered skill: a directory containing a SKILL.md under <root>/skills/<name>/.",
|
||||
required: [
|
||||
"scope",
|
||||
"name",
|
||||
"path",
|
||||
"file",
|
||||
"size",
|
||||
"mtime",
|
||||
"truncated",
|
||||
"frontmatter",
|
||||
"preview",
|
||||
],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"], example: "user" },
|
||||
name: { type: "string", example: "code-reviewer" },
|
||||
path: { type: "string", example: "/home/user/.codex/skills/code-reviewer" },
|
||||
file: { type: "string", example: "/home/user/.codex/skills/code-reviewer/SKILL.md" },
|
||||
size: { type: "integer", example: 2048 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
truncated: { type: "boolean", example: false },
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
example: { name: "code-reviewer", description: "Reviews diffs" },
|
||||
},
|
||||
preview: { type: "string", example: "Use this skill to review pull requests..." },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigAgent: {
|
||||
type: "object",
|
||||
description: "A discovered TOML agent definition (<root>/agents/<name>.toml).",
|
||||
required: ["scope", "name", "file", "size", "mtime", "meta", "preview"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"], example: "user" },
|
||||
name: { type: "string", example: "reviewer" },
|
||||
file: { type: "string", example: "/home/user/.codex/agents/reviewer.toml" },
|
||||
size: { type: "integer", example: 512 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
meta: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
"Best-effort top-level scalar fields (name, description, model, sandbox_mode).",
|
||||
example: { name: "reviewer", model: "gpt-5" },
|
||||
},
|
||||
preview: { type: "string", example: 'name = "reviewer"\nmodel = "gpt-5"' },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigRule: {
|
||||
type: "object",
|
||||
description: "A discovered rules file (<root>/rules/<name>.rules).",
|
||||
required: ["scope", "name", "file", "size", "mtime", "preview"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"], example: "user" },
|
||||
name: { type: "string", example: "default" },
|
||||
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
|
||||
size: { type: "integer", example: 128 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
preview: { type: "string", example: "allow" },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigMemoryEntry: {
|
||||
type: "object",
|
||||
description: "AGENTS.md at either the Codex home or the project root.",
|
||||
required: ["scope", "file", "exists"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"], example: "project" },
|
||||
file: { type: "string", example: "/repo/AGENTS.md" },
|
||||
exists: { type: "boolean", example: true },
|
||||
size: { type: "integer", example: 256 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
preview: { type: "string", example: "# Project memory\nHello." },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigConfigEntry: {
|
||||
type: "object",
|
||||
description: "config.toml at either the Codex home or the project .codex dir.",
|
||||
required: ["scope", "file", "exists"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"], example: "user" },
|
||||
file: { type: "string", example: "/home/user/.codex/config.toml" },
|
||||
exists: { type: "boolean", example: true },
|
||||
size: { type: "integer", example: 640 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
content: { type: "string", example: 'model = "gpt-5"\n' },
|
||||
truncated: { type: "boolean", example: false },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigOverviewResponse: {
|
||||
type: "object",
|
||||
required: ["roots", "counts"],
|
||||
properties: {
|
||||
roots: {
|
||||
type: "object",
|
||||
properties: {
|
||||
codexHome: { type: "string", example: "/home/user/.codex" },
|
||||
projectCodexDir: { type: "string", example: "/repo/.codex" },
|
||||
projectRoot: { type: "string", example: "/repo" },
|
||||
},
|
||||
},
|
||||
counts: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: { user: { type: "integer" }, project: { type: "integer" } },
|
||||
},
|
||||
example: {
|
||||
skills: { user: 3, project: 1 },
|
||||
agents: { user: 2, project: 0 },
|
||||
rules: { user: 1, project: 0 },
|
||||
memory: { user: 1, project: 1 },
|
||||
config: { user: 1, project: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigFileReadResponse: {
|
||||
type: "object",
|
||||
required: ["ok", "file", "size", "mtime", "truncated", "text"],
|
||||
properties: {
|
||||
ok: { type: "boolean", example: true },
|
||||
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
|
||||
size: { type: "integer", example: 128 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
truncated: { type: "boolean", example: false },
|
||||
text: { type: "string", example: "allow" },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigFileWriteResponse: {
|
||||
type: "object",
|
||||
required: ["file", "created", "backup"],
|
||||
properties: {
|
||||
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
|
||||
created: { type: "boolean", example: true },
|
||||
backup: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
additionalProperties: true,
|
||||
description: "Backup metadata when an existing file was overwritten; null on first create.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigFileDeleteResponse: {
|
||||
type: "object",
|
||||
required: ["file", "backup"],
|
||||
properties: {
|
||||
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
|
||||
backup: { type: "object", additionalProperties: true },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigBackupsResponse: {
|
||||
type: "object",
|
||||
required: ["items"],
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", example: "rules" },
|
||||
name: { type: "string", example: "default.rules.2026-06-25T12-00-00.000Z.bak" },
|
||||
file: { type: "string" },
|
||||
mtime: { type: "number" },
|
||||
size: { type: "integer", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigCliActionResponse: {
|
||||
type: "object",
|
||||
description: "Result of a successful mutating `codex` CLI call.",
|
||||
required: ["ok", "stdout", "stderr"],
|
||||
properties: {
|
||||
ok: { type: "boolean", example: true },
|
||||
stdout: { type: "string" },
|
||||
stderr: { type: "string" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Reusable parameters ──────────────────────────────────────────────────
|
||||
|
||||
const scopeParam = {
|
||||
name: "scope",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["user", "project", "all"], default: "all" },
|
||||
description: "Discovery scope: `user`, `project`, or `all` (default).",
|
||||
example: "all",
|
||||
};
|
||||
|
||||
const cwdParam = {
|
||||
name: "cwd",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Override the working directory used to resolve the project root and project .codex dir. Defaults to the dashboard server's own cwd.",
|
||||
example: "/repo",
|
||||
};
|
||||
|
||||
const errorResponse = (code, message) => ({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: { error: { code, message } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cliExitResponse = {
|
||||
description: "`ECLIEXIT` — the `codex` CLI exited non-zero. stdout/stderr are preserved.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: {
|
||||
type: "object",
|
||||
properties: { code: { type: "string" }, message: { type: "string" } },
|
||||
},
|
||||
code: { type: "integer", nullable: true },
|
||||
stdout: { type: "string" },
|
||||
stderr: { type: "string" },
|
||||
},
|
||||
},
|
||||
example: {
|
||||
error: { code: "ECLIEXIT", message: 'marketplace "nope" is not registered' },
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: '✘ marketplace "nope" is not registered\n',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function readSurface(name, summary, schemaRef, exampleItems) {
|
||||
return {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary,
|
||||
operationId: `codexConfigGet${name[0].toUpperCase()}${name.slice(1)}`,
|
||||
parameters: name === "memory" || name === "config" ? [cwdParam] : [scopeParam, cwdParam],
|
||||
responses: {
|
||||
200: {
|
||||
description: `${summary}.`,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { [name]: { type: "array", items: { $ref: schemaRef } } },
|
||||
},
|
||||
example: { [name]: exampleItems },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Paths ─────────────────────────────────────────────────────────────
|
||||
|
||||
const paths = {
|
||||
"/api/codex-config/overview": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Codex config explorer overview",
|
||||
description:
|
||||
"Read-only. Returns the three resolved filesystem roots plus per-scope counts across skills, agents, rules, memory, and config.",
|
||||
operationId: "codexConfigGetOverview",
|
||||
parameters: [cwdParam],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Overview of roots and counts.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigOverviewResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/skills": readSurface(
|
||||
"skills",
|
||||
"List discovered skills",
|
||||
"#/components/schemas/CodexConfigSkill",
|
||||
[{ scope: "user", name: "code-reviewer", frontmatter: {}, preview: "..." }]
|
||||
),
|
||||
"/api/codex-config/agents": readSurface(
|
||||
"agents",
|
||||
"List discovered agent definitions",
|
||||
"#/components/schemas/CodexConfigAgent",
|
||||
[{ scope: "user", name: "reviewer", meta: { model: "gpt-5" } }]
|
||||
),
|
||||
"/api/codex-config/rules": readSurface(
|
||||
"rules",
|
||||
"List discovered rules files",
|
||||
"#/components/schemas/CodexConfigRule",
|
||||
[{ scope: "user", name: "default", preview: "allow" }]
|
||||
),
|
||||
"/api/codex-config/memory": readSurface(
|
||||
"memory",
|
||||
"Read AGENTS.md at both scopes",
|
||||
"#/components/schemas/CodexConfigMemoryEntry",
|
||||
[{ scope: "project", file: "/repo/AGENTS.md", exists: true }]
|
||||
),
|
||||
"/api/codex-config/config": readSurface(
|
||||
"config",
|
||||
"Read config.toml at both scopes",
|
||||
"#/components/schemas/CodexConfigConfigEntry",
|
||||
[{ scope: "user", file: "/home/user/.codex/config.toml", exists: true }]
|
||||
),
|
||||
|
||||
"/api/codex-config/file": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Read a single file body",
|
||||
description:
|
||||
"Read-only, no filesystem side effect. Returns the body of one file. Strict path containment: the resolved absolute path must live under CODEX_HOME, the project .codex dir, or (only for a file literally named AGENTS.md) the project root, and must not escape that root through a symlink. Error messages never reveal what does or does not exist outside the root.",
|
||||
operationId: "codexConfigGetFile",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
example: "/home/user/.codex/rules/default.rules",
|
||||
},
|
||||
cwdParam,
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "File body with metadata.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigFileReadResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("READ_DENIED", "path escapes the allowed root"),
|
||||
404: errorResponse("NOT_FOUND", "file not found"),
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Create or overwrite a text artifact",
|
||||
description:
|
||||
"Writes skills/agents/rules/memory/config. `name` is required for skills/agents/rules and forbidden for memory/config (fixed filenames). `project` scope always resolves against the dashboard server's own cwd — there is no `?cwd=` override on writes, unlike the read routes, so a caller cannot point a write at an arbitrary directory. Backs up any existing file first; broadcasts `codex_config_changed` on success.",
|
||||
operationId: "codexConfigPutFile",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["scope", "type", "content"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"] },
|
||||
type: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
|
||||
name: { type: "string", nullable: true, example: "default" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
},
|
||||
example: { scope: "user", type: "rules", name: "default", content: "allow" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "File written.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigFileWriteResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("EBADNAME", "name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"),
|
||||
413: errorResponse("ETOOLARGE", "content exceeds 262144 bytes"),
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Delete a text artifact",
|
||||
description:
|
||||
"Deletes skills/agents/rules/memory/config. `project` scope always resolves against the dashboard server's own cwd — no `?cwd=` override, same as PUT. Backup is mandatory and runs first. Broadcasts `codex_config_changed` on success.",
|
||||
operationId: "codexConfigDeleteFile",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["scope", "type"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"] },
|
||||
type: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
|
||||
name: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
example: { scope: "user", type: "agents", name: "reviewer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "File deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigFileDeleteResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: errorResponse("ENOTFOUND", "agents/reviewer does not exist"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/backups": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "List backups",
|
||||
description:
|
||||
"Read-only. Lists timestamped backups, optionally filtered by scope and/or type.",
|
||||
operationId: "codexConfigGetBackups",
|
||||
parameters: [
|
||||
{
|
||||
name: "scope",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["user", "project"] },
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
|
||||
},
|
||||
cwdParam,
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Backups list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigBackupsResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/mcp": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "List configured MCP servers",
|
||||
description:
|
||||
"Reads live from `codex mcp list --json` — there is no on-disk reader for this surface.",
|
||||
operationId: "codexConfigGetMcp",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP servers as reported by the CLI.",
|
||||
content: { "application/json": { schema: { type: "object", properties: { mcp: {} } } } },
|
||||
},
|
||||
422: cliExitResponse,
|
||||
502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"),
|
||||
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/plugins": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "List installed plugins",
|
||||
description:
|
||||
"Reads live from `codex plugin list --json` and unwraps its `{installed, available}` " +
|
||||
"object down to the `installed` array — the route's `plugins` field is always a list.",
|
||||
operationId: "codexConfigGetPlugins",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Installed plugins as reported by the CLI.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { plugins: { type: "array", items: {} } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
422: cliExitResponse,
|
||||
502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"),
|
||||
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/marketplaces": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "List registered marketplaces",
|
||||
description:
|
||||
"Reads live from `codex plugin marketplace list --json` and unwraps its " +
|
||||
"`{marketplaces}` object down to a bare array.",
|
||||
operationId: "codexConfigGetMarketplaces",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Marketplaces as reported by the CLI.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { marketplaces: { type: "array", items: {} } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
422: cliExitResponse,
|
||||
502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"),
|
||||
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/mcp/add": {
|
||||
post: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Add an MCP server",
|
||||
description:
|
||||
"Runs `codex mcp add`. Accepts either a streamable-HTTP server (`url`, optional `bearerTokenEnvVar`) or a stdio server (`command`, `args`, optional `env`) — never both.",
|
||||
operationId: "codexConfigPostMcpAdd",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
url: { type: "string" },
|
||||
bearerTokenEnvVar: { type: "string" },
|
||||
command: { type: "string" },
|
||||
args: { type: "array", items: { type: "string" } },
|
||||
env: { type: "object", additionalProperties: { type: "string" } },
|
||||
},
|
||||
},
|
||||
example: { name: "github", url: "https://mcp.example.com/github" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server added.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("EBADARG", "url must be a valid http(s) URL"),
|
||||
422: cliExitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/mcp/remove": {
|
||||
post: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Remove an MCP server",
|
||||
operationId: "codexConfigPostMcpRemove",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
example: { name: "github" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server removed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
422: cliExitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/plugins/action": {
|
||||
post: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Add or remove a plugin",
|
||||
description: "Runs `codex plugin add <spec>` or `codex plugin remove <spec>`.",
|
||||
operationId: "codexConfigPostPluginsAction",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["spec", "action"],
|
||||
properties: {
|
||||
spec: { type: "string" },
|
||||
action: { type: "string", enum: ["add", "remove"] },
|
||||
},
|
||||
},
|
||||
example: { spec: "demo@market", action: "add" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Plugin action applied.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("EBADACTION", "action must be one of add, remove"),
|
||||
422: cliExitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/marketplaces/add": {
|
||||
post: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Register a marketplace",
|
||||
description:
|
||||
"Runs `codex plugin marketplace add <source>`. `source` is an owner/repo shorthand, " +
|
||||
"an http(s) git URL, or an absolute local path — the same set `claude plugin " +
|
||||
"marketplace add` accepts; ssh remotes are not.",
|
||||
operationId: "codexConfigPostMarketplacesAdd",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", required: ["url"], properties: { url: { type: "string" } } },
|
||||
example: { url: "openai/codex" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Marketplace added.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse(
|
||||
"EBADARG",
|
||||
"source must be owner/repo, an http(s) URL, or an absolute path"
|
||||
),
|
||||
422: cliExitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/marketplaces/action": {
|
||||
post: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Remove a marketplace",
|
||||
description: "Runs `codex plugin marketplace remove <name>`.",
|
||||
operationId: "codexConfigPostMarketplacesAction",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name", "action"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
action: { type: "string", enum: ["remove"] },
|
||||
},
|
||||
},
|
||||
example: { name: "demo-mp", action: "remove" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Marketplace action applied.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("EBADACTION", "action must be one of remove"),
|
||||
422: cliExitResponse,
|
||||
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
+1
-1
@@ -326,7 +326,7 @@ function createOpenApiSpec() {
|
||||
version: {
|
||||
type: "string",
|
||||
description: "Dashboard release version from package.json",
|
||||
example: "1.4.6",
|
||||
example: "1.5.0",
|
||||
},
|
||||
timestamp: { type: "string", format: "date-time" },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -178,7 +187,17 @@ router.put("/file", (req, res) => {
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
try {
|
||||
const result = ccMutate.writeArtifact({ scope, type, name, content, project, cwd: cwdOf(req) });
|
||||
// process.cwd(), never ?cwd= — the same rule PATCH /settings already
|
||||
// follows: a caller-chosen write target lets any request drop a file
|
||||
// anywhere on disk. Reads keep the override; writes do not.
|
||||
const result = ccMutate.writeArtifact({
|
||||
scope,
|
||||
type,
|
||||
name,
|
||||
content,
|
||||
project,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
emitChanged({ action: "write", scope, type, name: name || null, project: project || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
@@ -194,7 +213,8 @@ router.delete("/file", (req, res) => {
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
try {
|
||||
const result = ccMutate.deleteArtifact({ scope, type, name, project, cwd: cwdOf(req) });
|
||||
// Pinned for the same reason as PUT above.
|
||||
const result = ccMutate.deleteArtifact({ scope, type, name, project, cwd: process.cwd() });
|
||||
emitChanged({ action: "delete", scope, type, name: name || null, project: project || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
@@ -202,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
|
||||
@@ -318,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
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* @file codex-config.js
|
||||
* @description HTTP surface for inspecting and (carefully) mutating Codex
|
||||
* CLI configuration: skills, agents, rules, memory (AGENTS.md),
|
||||
* config.toml, MCP servers, plugins, and marketplaces. Powers the Codex
|
||||
* Config panel — the Codex counterpart of cc-config.js.
|
||||
*
|
||||
* Read paths (skills/agents/rules/memory/config) come from codex-discovery.js
|
||||
* (pure file reads). MCP servers, plugins, and marketplaces have no on-disk
|
||||
* reader — codex-discovery.js deliberately leaves them to the `codex` CLI —
|
||||
* so those three GET routes, plus every mutation, shell out through
|
||||
* codex-cli.js. A non-zero CLI exit is reported as 422 with stdout/stderr
|
||||
* preserved, never a 500.
|
||||
*
|
||||
* Write paths (skills/agents/rules/memory/config) go through codex-mutate.js:
|
||||
* every write/delete is preceded by a timestamped backup, and every mutation
|
||||
* error code is mapped explicitly below so an unmapped one can never fall
|
||||
* through to a bare 500.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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();
|
||||
|
||||
function emitChanged(payload) {
|
||||
try {
|
||||
broadcast("codex_config_changed", { source: "dashboard", ...payload });
|
||||
} catch {
|
||||
/* websocket may not be initialised in unit tests */
|
||||
}
|
||||
}
|
||||
|
||||
function scopeOf(req) {
|
||||
const s = String(req.query.scope || "all");
|
||||
return s === "user" || s === "project" ? s : "all";
|
||||
}
|
||||
|
||||
function cwdOf(req) {
|
||||
// Mirrors cc-config's cwdOf: the server's own cwd is the natural
|
||||
// "project", but ?cwd= lets the caller inspect another working dir.
|
||||
const c = typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
|
||||
return c || process.cwd();
|
||||
}
|
||||
|
||||
// ── Mutation errors (codex-mutate.js) → HTTP status ────────────────────
|
||||
// Anything unmapped is a 500 — this map must cover every code codex-mutate
|
||||
// can throw.
|
||||
const ERR_TO_STATUS = {
|
||||
EBADTYPE: 400,
|
||||
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) {
|
||||
const status = ERR_TO_STATUS[err.code] || 500;
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
||||
}
|
||||
|
||||
// ── Discovery reads ─────────────────────────────────────────────────────
|
||||
|
||||
router.get("/overview", (req, res) => {
|
||||
res.json(codexDiscovery.readOverview({ cwd: cwdOf(req) }));
|
||||
});
|
||||
|
||||
router.get("/skills", (req, res) => {
|
||||
res.json({ skills: codexDiscovery.readSkills({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/agents", (req, res) => {
|
||||
res.json({ agents: codexDiscovery.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/rules", (req, res) => {
|
||||
res.json({ rules: codexDiscovery.readRules({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/memory", (req, res) => {
|
||||
res.json({ memory: codexDiscovery.readMemory({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/config", (req, res) => {
|
||||
res.json({ config: codexDiscovery.readConfig({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
// GET /api/codex-config/file?path=/abs/path — return body of a single file.
|
||||
// Path must resolve under CODEX_HOME, the project .codex dir, or (only for a
|
||||
// file literally named AGENTS.md) the project root. codexDiscovery.readFileSafe
|
||||
// checks that containment lexically — a symlink whose LOCATION is inside a
|
||||
// root is readable even when it resolves outside, matching the other four
|
||||
// discovery readers. The write side (codex-mutate.js) keeps the stricter
|
||||
// realpath-based assertNotSymlinkEscape — reads and writes intentionally
|
||||
// disagree on symlinks; see readFileSafe's doc comment for why.
|
||||
router.get("/file", (req, res) => {
|
||||
const p = req.query.path;
|
||||
if (typeof p !== "string" || !p) {
|
||||
return res.status(400).json({ error: { code: "BAD_PATH", message: "path is required" } });
|
||||
}
|
||||
const result = codexDiscovery.readFileSafe(p, { cwd: cwdOf(req) });
|
||||
if (result.notFound)
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "file not found" } });
|
||||
if (result.error)
|
||||
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Mutation endpoints (codex-mutate.js) ────────────────────────────────
|
||||
//
|
||||
// PUT /api/codex-config/file — create or overwrite. Body: { scope, type, name?, content }
|
||||
// DELETE /api/codex-config/file — delete. Body: { scope, type, name? }
|
||||
// GET /api/codex-config/backups[?scope=&type=] — list backups
|
||||
//
|
||||
// Writes resolve "project" scope against the SERVER's cwd, never `?cwd=`: a
|
||||
// write target chosen by the caller would let any request create a file
|
||||
// anywhere on disk (same reasoning cc-config.js's PATCH /settings gives for
|
||||
// refusing `?cwd=` there). Reads keep the override.
|
||||
|
||||
router.put("/file", (req, res) => {
|
||||
const { scope, type, name, content } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
try {
|
||||
const result = codexMutate.writeArtifact({ scope, type, name, content, cwd: process.cwd() });
|
||||
emitChanged({ action: "write", scope, type, name: name || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/file", (req, res) => {
|
||||
const { scope, type, name } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
try {
|
||||
const result = codexMutate.deleteArtifact({ scope, type, name, cwd: process.cwd() });
|
||||
emitChanged({ action: "delete", scope, type, name: name || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const type = typeof req.query.type === "string" ? req.query.type : undefined;
|
||||
try {
|
||||
res.json({ items: codexMutate.listBackups({ scope, type, cwd: cwdOf(req) }) });
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── MCP servers / plugins / marketplaces (via the `codex` CLI) ─────────
|
||||
//
|
||||
// Same reasoning as cc-config.js's plugin/marketplace routes: these shell
|
||||
// out to `codex …` with an argv array — never a shell string — because the
|
||||
// CLI owns config.toml's schema. Identifiers travel in the body, not the
|
||||
// URL, for the same reason DELETE /file does.
|
||||
|
||||
const CLI_ERR_TO_STATUS = {
|
||||
EBADARG: 400,
|
||||
ECLINOTFOUND: 503,
|
||||
ETIMEOUT: 504,
|
||||
};
|
||||
|
||||
function cliError(res, err) {
|
||||
const status = CLI_ERR_TO_STATUS[err.code] || 500;
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
||||
}
|
||||
|
||||
/** A non-zero CLI exit is a 422, not a 500 — the caller can inspect stderr. */
|
||||
function cliFailure(res, result) {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
code: "ECLIEXIT",
|
||||
message: codexCli.cliMessage(result) || `codex exited with code ${result.code}`,
|
||||
},
|
||||
code: result.code,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-through result for a GET route backed by `codex … --json`. `result.data`
|
||||
* is only set when stdout parsed as JSON (see runCodex). A fresh install with
|
||||
* zero MCP servers/plugins/marketplaces is the normal case and prints nothing
|
||||
* — that's an empty list, not a parse failure. Only stdout that has content
|
||||
* and still doesn't parse (the CLI's output shape changed under us) is a 502,
|
||||
* never a 200 with a string where the OpenAPI fragment promises an array/object.
|
||||
*/
|
||||
function cliRead(res, result, key) {
|
||||
if (!result.ok) return cliFailure(res, result);
|
||||
if (result.data === undefined) {
|
||||
if (!result.stdout.trim()) return res.json({ [key]: [] });
|
||||
return res.status(502).json({
|
||||
error: {
|
||||
code: "ECLIBADOUTPUT",
|
||||
message: `could not parse \`codex\` JSON output for ${key}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
res.json({ [key]: result.data });
|
||||
}
|
||||
|
||||
/** Result for a mutating route: broadcast on success, 422 on CLI failure. */
|
||||
function cliResult(res, result, changed) {
|
||||
if (!result.ok) return cliFailure(res, result);
|
||||
emitChanged(changed);
|
||||
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
|
||||
}
|
||||
|
||||
router.get("/mcp", async (_req, res) => {
|
||||
try {
|
||||
return cliRead(res, await codexCli.mcpList(), "mcp");
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/plugins", async (_req, res) => {
|
||||
try {
|
||||
return cliRead(res, await codexCli.pluginList(), "plugins");
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/marketplaces", async (_req, res) => {
|
||||
try {
|
||||
return cliRead(res, await codexCli.marketplaceList(), "marketplaces");
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mcp/add", async (req, res) => {
|
||||
try {
|
||||
const result = await codexCli.mcpAdd(req.body || {});
|
||||
return cliResult(res, result, { action: "add", type: "mcp", name: (req.body || {}).name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mcp/remove", async (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
try {
|
||||
const result = await codexCli.mcpRemove(name);
|
||||
return cliResult(res, result, { action: "remove", type: "mcp", name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
const PLUGIN_ACTIONS = {
|
||||
add: codexCli.pluginAdd,
|
||||
remove: codexCli.pluginRemove,
|
||||
};
|
||||
|
||||
router.post("/plugins/action", async (req, res) => {
|
||||
const { spec, action } = req.body || {};
|
||||
const run = PLUGIN_ACTIONS[action];
|
||||
if (!run) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EBADACTION",
|
||||
message: `action must be one of ${Object.keys(PLUGIN_ACTIONS).join(", ")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await run(spec);
|
||||
return cliResult(res, result, { action, type: "plugin", name: spec });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/marketplaces/add", async (req, res) => {
|
||||
const { url } = req.body || {};
|
||||
try {
|
||||
const result = await codexCli.marketplaceAdd(url);
|
||||
return cliResult(res, result, { action: "add", type: "marketplace", name: url });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
const MARKETPLACE_ACTIONS = {
|
||||
remove: codexCli.marketplaceRemove,
|
||||
};
|
||||
|
||||
router.post("/marketplaces/action", async (req, res) => {
|
||||
const { name, action } = req.body || {};
|
||||
const run = MARKETPLACE_ACTIONS[action];
|
||||
if (!run) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EBADACTION",
|
||||
message: `action must be one of ${Object.keys(MARKETPLACE_ACTIONS).join(", ")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await run(name);
|
||||
return cliResult(res, result, { action, type: "marketplace", name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+108
-50
@@ -19,7 +19,7 @@ const {
|
||||
getPipeline,
|
||||
nodeStates,
|
||||
progressPct,
|
||||
stageRecords,
|
||||
phaseIdx,
|
||||
} = require("../lib/pipelines");
|
||||
const laneFeatures = require("../lib/lane-features");
|
||||
const proofLib = require("../lib/proof");
|
||||
@@ -161,63 +161,100 @@ router.get("/pipeline-stats", (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const rows =
|
||||
const events =
|
||||
laneId === null
|
||||
? [
|
||||
...db.prepare("SELECT pipeline, stages FROM lanes").all(),
|
||||
...db.prepare("SELECT pipeline, stages FROM lane_features").all(),
|
||||
]
|
||||
: [
|
||||
...db.prepare("SELECT pipeline, stages FROM lanes WHERE id = ?").all(laneId),
|
||||
...db.prepare("SELECT pipeline, stages FROM lane_features WHERE lane_id = ?").all(laneId),
|
||||
];
|
||||
? db
|
||||
.prepare(
|
||||
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events ORDER BY id"
|
||||
)
|
||||
.all()
|
||||
: db
|
||||
.prepare(
|
||||
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE lane_id = ? ORDER BY id"
|
||||
)
|
||||
.all(laneId);
|
||||
|
||||
const acc = new Map();
|
||||
for (const row of rows) {
|
||||
let stages;
|
||||
try {
|
||||
stages = JSON.parse(row.stages);
|
||||
} catch {
|
||||
continue; // a corrupt blob costs one row, never the whole report
|
||||
}
|
||||
const pipeline = getPipeline(row.pipeline);
|
||||
const entryFor = (pipelineId) => {
|
||||
const pipeline = getPipeline(pipelineId);
|
||||
let entry = acc.get(pipeline.id);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
id: pipeline.id,
|
||||
name: pipeline.name || pipeline.id,
|
||||
runs: 0,
|
||||
nodes: pipeline.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
icon: n.icon,
|
||||
gate: n.gate,
|
||||
command: n.command,
|
||||
entered: 0,
|
||||
passed: 0,
|
||||
withEvidence: 0,
|
||||
failed: 0,
|
||||
})),
|
||||
};
|
||||
entry = { id: pipeline.id, runs: new Set(), nodes: new Map(), template: pipeline };
|
||||
acc.set(pipeline.id, entry);
|
||||
}
|
||||
entry.runs += 1;
|
||||
const records = stageRecords(pipeline, stages);
|
||||
for (const [idx, rec] of records) {
|
||||
const node = entry.nodes[idx];
|
||||
if (!node) continue;
|
||||
node.entered += 1;
|
||||
if (rec && rec.result === "fail") node.failed += 1;
|
||||
else {
|
||||
node.passed += 1;
|
||||
if (rec && rec.evidence) node.withEvidence += 1;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
// Seed the templates the lanes in scope are configured with, before folding
|
||||
// any events in. A lane that has recorded nothing still has to render its map
|
||||
// — the picker is how you find out a pipeline is going unused, so a pipeline
|
||||
// with zero runs must appear, at zero, rather than vanish.
|
||||
const scopedLanes =
|
||||
laneId === null
|
||||
? db.prepare("SELECT pipeline FROM lanes").all()
|
||||
: db.prepare("SELECT pipeline FROM lanes WHERE id = ?").all(laneId);
|
||||
for (const row of scopedLanes) entryFor(row.pipeline);
|
||||
|
||||
// Fold the event stream down to one verdict per (run, node): a run that
|
||||
// entered a node twice is still one pass of it, and a declaration always
|
||||
// beats a detection of the same node — the agent's own claim is the stronger
|
||||
// statement, so a node it declared must never be reported as merely inferred.
|
||||
for (const ev of events) {
|
||||
const entry = entryFor(ev.pipeline);
|
||||
const pipeline = entry.template;
|
||||
entry.runs.add(`${ev.lane_id}#${ev.run_seq}`);
|
||||
|
||||
const idx = phaseIdx(pipeline, ev.node_id);
|
||||
if (idx === -1) continue; // a stage nothing in this pipeline is named after
|
||||
const key = `${ev.lane_id}#${ev.run_seq}#${idx}`;
|
||||
const prev = entry.nodes.get(key);
|
||||
const declared = ev.source !== "detected";
|
||||
if (prev && prev.declared && !declared) continue;
|
||||
entry.nodes.set(key, {
|
||||
idx,
|
||||
declared: declared || (prev ? prev.declared : false),
|
||||
failed: declared ? ev.result === "fail" : prev ? prev.failed : false,
|
||||
evidence: (declared && !!ev.evidence) || (prev ? prev.evidence : false),
|
||||
});
|
||||
}
|
||||
|
||||
// Busiest pipeline first: the one with the most recorded runs is the one the
|
||||
// reader most likely came to look at.
|
||||
const pipelines = [...acc.values()].sort((a, b) => b.runs - a.runs);
|
||||
const pipelines = [...acc.values()]
|
||||
.map((entry) => {
|
||||
const nodes = entry.template.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
icon: n.icon,
|
||||
gate: n.gate,
|
||||
command: n.command,
|
||||
entered: 0,
|
||||
passed: 0,
|
||||
withEvidence: 0,
|
||||
failed: 0,
|
||||
detected: 0,
|
||||
}));
|
||||
for (const v of entry.nodes.values()) {
|
||||
const node = nodes[v.idx];
|
||||
if (!node) continue;
|
||||
if (!v.declared) {
|
||||
// Reached, but only inferred from the hook stream. Counted apart from
|
||||
// the pass rate: it says the work happened, not that a gate was met.
|
||||
node.detected += 1;
|
||||
continue;
|
||||
}
|
||||
node.entered += 1;
|
||||
if (v.failed) node.failed += 1;
|
||||
else {
|
||||
node.passed += 1;
|
||||
if (v.evidence) node.withEvidence += 1;
|
||||
}
|
||||
}
|
||||
const name = entry.template.name || entry.id;
|
||||
return { id: entry.id, name, runs: entry.runs.size, nodes };
|
||||
})
|
||||
// Busiest pipeline first: the one with the most recorded runs is the one
|
||||
// the reader most likely came to look at.
|
||||
.sort((a, b) => b.runs - a.runs);
|
||||
|
||||
const lanes = db.prepare("SELECT id, title, cwd FROM lanes ORDER BY id").all();
|
||||
res.json({ pipelines, lanes });
|
||||
});
|
||||
@@ -407,9 +444,30 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
});
|
||||
|
||||
router.post("/:id/stage", sameOriginGuard, (req, res) => {
|
||||
if (!lanesLib.getLane(req.params.id)) {
|
||||
const current = lanesLib.getLane(req.params.id);
|
||||
if (!current) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
}
|
||||
// A stage naming no node of this lane's pipeline used to be stored and then
|
||||
// silently ignored by every reader — `agenthub stage test` on a ship-feature
|
||||
// lane (whose node is `gates`) left the CI row reading "never reached" with
|
||||
// no hint anything was wrong. Aliases still resolve, so this rejects typos
|
||||
// and wrong-pipeline names, not shorthand.
|
||||
const requested = req.body && req.body.stage;
|
||||
if (requested !== undefined && requested !== null && requested !== "") {
|
||||
const pipeline = getPipeline(current.pipeline);
|
||||
if (phaseIdx(pipeline, requested) === -1) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EBADSTAGE",
|
||||
message:
|
||||
`"${requested}" is not a stage of the "${pipeline.id}" pipeline. ` +
|
||||
`Nodes: ${pipeline.nodes.map((n) => n.id).join(", ")}`,
|
||||
stages: pipeline.nodes.map((n) => n.id),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const lane = lanesLib.setStage(req.params.id, req.body || {});
|
||||
broadcastLane(lane.id);
|
||||
res.json({ lane: payload(lane) });
|
||||
|
||||
+1
-36
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user