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:
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