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:
2026-09-08 21:09:28 +07:00
parent f6973d24d6
commit 8015730adf
9 changed files with 1856 additions and 6 deletions
+5
View File
@@ -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",
+100
View File
@@ -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…"
}
}
+100
View File
@@ -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ý…"
}
}
+22
View File
@@ -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
+3 -2
View File
@@ -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" }));
});
});