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:
@@ -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" }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user