feat(codex): create and customize Codex config from /settings
Adds the Codex config panel (skills/agents/rules/memory/config editing, mcp/plugins/marketplaces add-remove via the codex CLI) as the last piece of parity with the Claude Code settings tab, plus the codex_config_changed WebSocket type the client was missing, and docs for the new /api/codex-config surface.
This commit is contained in:
+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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"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",
|
||||
"empty": "Nothing here yet."
|
||||
},
|
||||
"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": {
|
||||
"userScope": "User memory",
|
||||
"projectScope": "Project memory",
|
||||
"missing": "No AGENTS.md found."
|
||||
},
|
||||
"config": {
|
||||
"userScope": "User config",
|
||||
"projectScope": "Project 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",
|
||||
"noServers": "No MCP servers found.",
|
||||
"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}}.",
|
||||
"noMarketplaces": "No marketplaces registered."
|
||||
},
|
||||
"manage": {
|
||||
"working": "Working…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"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",
|
||||
"empty": "Chưa có gì ở đây."
|
||||
},
|
||||
"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": {
|
||||
"userScope": "Bộ nhớ người dùng",
|
||||
"projectScope": "Bộ nhớ dự án",
|
||||
"missing": "Không tìm thấy AGENTS.md."
|
||||
},
|
||||
"config": {
|
||||
"userScope": "Cấu hình người dùng",
|
||||
"projectScope": "Cấu hình dự án",
|
||||
"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ỏ",
|
||||
"noServers": "Không tìm thấy máy chủ MCP nào.",
|
||||
"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}}.",
|
||||
"noMarketplaces": "Chưa đăng ký marketplace nào."
|
||||
},
|
||||
"manage": {
|
||||
"working": "Đang xử lý…"
|
||||
}
|
||||
}
|
||||
@@ -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,24 @@ 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";
|
||||
action?: "write" | "delete";
|
||||
/** "user" or "project" - Codex has no "project-local" scope. */
|
||||
scope?: "user" | "project";
|
||||
/** Artifact kind, e.g. "skills", "agents", "rules", "memory", "config". */
|
||||
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 +1643,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 +1660,7 @@ export interface WSMessage {
|
||||
| "run_status"
|
||||
| "run_input_ack"
|
||||
| "cc_config_changed"
|
||||
| "codex_config_changed"
|
||||
| "alert_triggered"
|
||||
| "alert_updated"
|
||||
| "workflow_upserted"
|
||||
@@ -1661,6 +1682,7 @@ export interface WSMessage {
|
||||
| UpdateStatusPayload
|
||||
| RunStatusPayload
|
||||
| CcConfigChangedPayload
|
||||
| CodexConfigChangedPayload
|
||||
| AlertEvent
|
||||
| WorkflowRun
|
||||
| RemoteSourceStatusPayload
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @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 per-AI-tool configuration panels (Claude Code and Codex today, Cursor reserved), 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";
|
||||
@@ -10,6 +10,7 @@ import { Boxes, Settings as SettingsIcon, SquareTerminal, MousePointerClick } fr
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
|
||||
interface ToolTab {
|
||||
id: string;
|
||||
@@ -30,7 +31,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: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
|
||||
{ id: "cursor", labelKey: "tools.cursor", Icon: MousePointerClick },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @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: "" }),
|
||||
};
|
||||
});
|
||||
|
||||
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" }));
|
||||
});
|
||||
});
|
||||
+31
@@ -1619,6 +1619,37 @@ 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? }
|
||||
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 marketplace list --json`; a fresh install with none of a kind returns `{ mcp: [] }` etc. rather than a parse error, and the client further coerces a non-array response to `[]` defensively. 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. 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).
|
||||
|
||||
Reference in New Issue
Block a user