diff --git a/client/src/lib/__tests__/codexConfigApi.test.ts b/client/src/lib/__tests__/codexConfigApi.test.ts new file mode 100644 index 00000000..89bdf008 --- /dev/null +++ b/client/src/lib/__tests__/codexConfigApi.test.ts @@ -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ĩ + */ + +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", + }); + }); +}); diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 03b29cc9..ad6c5844 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1609,6 +1609,162 @@ 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("/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(`/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("/codex-config/file", { + method: "PUT", + 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("/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. + * @returns `{ mcp }` — {@link CodexMcpServer} items. + */ + mcp: () => request<{ mcp: CodexMcpServer[] }>("/codex-config/mcp"), + /** + * GET /api/codex-config/plugins - installed plugins from the codex CLI. + * @returns `{ plugins }` — {@link CodexPlugin} items. + */ + plugins: () => request<{ plugins: CodexPlugin[] }>("/codex-config/plugins"), + /** + * GET /api/codex-config/marketplaces - registered marketplaces from the codex CLI. + * @returns `{ marketplaces }` — {@link CodexMarketplace} items. + */ + marketplaces: () => request<{ marketplaces: CodexMarketplace[] }>("/codex-config/marketplaces"), + /** + * POST /api/codex-config/mcp/add - add an MCP server via the `codex` CLI. + * @param args Arguments for `codex mcp add`. + * @returns `{ ok, stdout, stderr }` — success and CLI output. + */ + mcpAdd: (args: unknown) => + 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 Arguments with `spec` and `action` fields. + * @returns `{ ok, stdout, stderr }` — success and CLI output. + */ + pluginAction: (args: unknown) => + 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 Arguments with `name` and `action` fields. + * @returns `{ ok, stdout, stderr }` — success and CLI output. + */ + marketplaceAction: (args: unknown) => + 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 +2386,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, @@ -2675,6 +2848,152 @@ 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; +} + +/** 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//. */ +export interface CodexSkill { + scope: "user" | "project"; + name: string; + path: string; + file: string; + size: number; + mtime: number; + truncated: boolean; + frontmatter: Record; + preview: string; +} + +/** A discovered agent definition (TOML file). */ +export interface CodexAgent { + scope: "user" | "project"; + name: string; + file: string; + size: number; + mtime: number; + meta: Record; + 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; +} + // ───────────────────────────────────────────────────────────────────────────── // Run types — request/response shapes for the Run page's `claude` process // spawning/management. `RunMode`/`RunStatus`/`PermissionMode`/`EffortLevel`