diff --git a/docs/resources/(resources)/codex-app.mdx b/docs/resources/(resources)/codex-app.mdx new file mode 100644 index 00000000..63ec6fc7 --- /dev/null +++ b/docs/resources/(resources)/codex-app.mdx @@ -0,0 +1,43 @@ +--- +title: codex-app +description: A reference page for the codex-app resource +--- + +The codex-app resource installs the [Codex desktop app](https://developers.openai.com/codex/app) — OpenAI's "Codex command center" for managing coding agent threads, projects, and worktrees. It is a thin wrapper around the Homebrew cask install; the app shares its login, configuration, and MCP servers with the [`codex`](/docs/resources/codex/codex) CLI and IDE extension via `~/.codex/config.toml`. + +This resource is **macOS only**. The Codex desktop app is also available on Windows via the Microsoft Store, but is not yet available on Linux. + +## Parameters + +This resource has no configurable parameters — it manages installation only. + +## Example usage + +```json title="codify.jsonc" +[ + { + "type": "codex-app", + "os": ["macOS"] + } +] +``` + +### Install the CLI and the desktop app together + +```json title="codify.jsonc" +[ + { + "type": "codex", + "os": ["macOS"] + }, + { + "type": "codex-app", + "os": ["macOS"] + } +] +``` + +## Notes + +- Installed via `brew install --cask codex-app` to `/Applications/Codex.app`. +- The desktop app, CLI, and IDE extension all read and write `~/.codex/config.toml` and share the same login session — use the [`codex`](/docs/resources/codex/codex) resource to manage settings and MCP servers declaratively. diff --git a/docs/resources/(resources)/codex-project.mdx b/docs/resources/(resources)/codex-project.mdx new file mode 100644 index 00000000..17c8f1db --- /dev/null +++ b/docs/resources/(resources)/codex-project.mdx @@ -0,0 +1,110 @@ +--- +title: codex-project +description: A reference page for the codex-project resource +--- + +The codex-project resource manages **per-project** Codex configuration. It writes a project-scoped `AGENTS.md`, settings, and MCP servers under a specific directory — leaving global configuration untouched. Use it alongside the [`codex`](/docs/resources/codex/codex) resource, which handles installation. + +## Parameters + +- **directory**: *(string, required)* Path to the project directory. Configuration files are written relative to this path: + - `/AGENTS.md` + - `/.codex/config.toml` + +- **agentsMd**: *(string, optional)* Content for `/AGENTS.md`. Accepts inline text, an `https://` URL, or a `codify://documentId:fileId` cloud URL. Codex walks from the project root down to the current working directory and concatenates any `AGENTS.md` files it finds, using them as project-specific instructions. + +- **config**: *(object, optional)* Key-value pairs to merge into `/.codex/config.toml`. On apply, the declared keys are written; on destroy, only the declared keys are removed. Supports the same keys as the global config, e.g. `model`, `approval_policy`, `sandbox_mode`, `sandbox_workspace_write`. + - Note: Codex ignores `model_provider`, `openai_base_url`, `notify`, `otel`, and `profiles` in project-level config files for security reasons. + +- **mcpServers**: *(array, optional)* MCP servers to register for this project under `[mcp_servers]` in `/.codex/config.toml`. Each entry requires a `name` and `type`, plus transport-specific fields: + - **stdio**: `{ name, type: "stdio", command, args?, env?, envVars?, cwd?, startupTimeoutSec?, toolTimeoutSec? }` — local process server + - **http**: `{ name, type: "http", url, bearerTokenEnvVar?, httpHeaders? }` — remote streamable-HTTP server + +## Example usage + +### Per-project AGENTS.md and sandbox policy + +```json title="codify.jsonc" +[ + { + "type": "codex-project", + "directory": "~/projects/my-api", + "agentsMd": "# Project Instructions\n\nThis is a Node.js API. Always use async/await.\nRun `npm test` before committing.", + "config": { + "sandbox_mode": "workspace-write", + "approval_policy": "on-request" + } + } +] +``` + +### Per-project AGENTS.md with an MCP server + +```json title="codify.jsonc" +[ + { + "type": "codex-project", + "directory": "~/projects/my-api", + "agentsMd": "# Project Instructions\n\nAlways check types with `npm run typecheck` before submitting.", + "mcpServers": [ + { + "name": "project-db", + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"] + } + ] + } +] +``` + +### Per-project AGENTS.md from a remote URL + +```json title="codify.jsonc" +[ + { + "type": "codex-project", + "directory": "~/projects/my-api", + "agentsMd": "codify://my-document-id:my-file-id" + } +] +``` + +Or from a public HTTPS URL: + +```json title="codify.jsonc" +[ + { + "type": "codex-project", + "directory": "~/projects/my-api", + "agentsMd": "https://raw.githubusercontent.com/my-org/dotfiles/main/AGENTS.md" + } +] +``` + +### Global install + per-project config together + +```json title="codify.jsonc" +[ + { + "type": "codex", + "config": { + "model": "gpt-5.1-codex" + } + }, + { + "type": "codex-project", + "directory": "~/projects/my-api", + "agentsMd": "# My API\n\nNode.js + TypeScript. Run `npm test` before any commit." + } +] +``` + +## Notes + +- The `codex` resource must be applied before `codex-project` (it declares a dependency automatically). If Codex is not installed, this resource will report as not present. +- Multiple `codex-project` entries can coexist — each unique `directory` is a separate resource instance. +- Destroying a `codex-project` resource removes only the per-project files (`AGENTS.md` and the `.codex/config.toml` directory). The Codex binary and global configuration are left untouched. +- The `config` parameter merges only the declared top-level keys. Existing project config not in your Codify config is left untouched. +- The `agentsMd` parameter manages the entire `AGENTS.md` file. On destroy, the file is removed. +- MCP servers are stored under `[mcp_servers.]` in `/.codex/config.toml`. Removing an MCP server from your config removes its table; other servers are untouched. diff --git a/docs/resources/(resources)/codex.mdx b/docs/resources/(resources)/codex.mdx new file mode 100644 index 00000000..f1caa76e --- /dev/null +++ b/docs/resources/(resources)/codex.mdx @@ -0,0 +1,87 @@ +--- +title: codex +description: A reference page for the codex resource +--- + +The codex resource installs the [Codex CLI](https://developers.openai.com/codex) — OpenAI's terminal-based coding agent — and manages its global configuration. It handles installation via the official installer script and gives you declarative control over `~/.codex/config.toml` settings and global MCP servers. + +## Parameters + +- **config**: *(object, optional)* Key-value pairs to merge into `~/.codex/config.toml`. On apply, the declared keys are written; on destroy, only the declared keys are removed. Common settings include: + - `model` — the model Codex uses (e.g. `"gpt-5.1-codex"`) + - `model_provider` — the model provider (default: `"openai"`) + - `approval_policy` — `"untrusted"` | `"on-request"` | `"never"` (default: `"on-request"`) + - `sandbox_mode` — `"read-only"` | `"workspace-write"` | `"danger-full-access"` (default: `"workspace-write"`) + - `sandbox_workspace_write` — `{ network_access, writable_roots, ... }` + - `model_reasoning_effort` — `"minimal"` | `"low"` | `"medium"` | `"high"` | `"xhigh"` + - `model_reasoning_summary` — `"auto"` | `"concise"` | `"detailed"` | `"none"` + - `web_search` — `"disabled"` | `"cached"` | `"live"` (default: `"cached"`) + - `file_opener` — `"vscode"` | `"cursor"` | `"windsurf"` | `"none"` + - `history` — `{ persistence, max_bytes }` + - `shell_environment_policy` — `{ inherit, set, include_only, exclude }` + +- **mcpServers**: *(array, optional)* MCP servers to register globally under `[mcp_servers]` in `~/.codex/config.toml`. Each entry requires a `name` and `type`, plus transport-specific fields: + - **stdio**: `{ name, type: "stdio", command, args?, env?, envVars?, cwd?, startupTimeoutSec?, toolTimeoutSec? }` — local process server + - **http**: `{ name, type: "http", url, bearerTokenEnvVar?, httpHeaders? }` — remote streamable-HTTP server + +## Example usage + +### Install Codex with custom settings + +```json title="codify.jsonc" +[ + { + "type": "codex", + "config": { + "model": "gpt-5.1-codex", + "approval_policy": "on-request", + "sandbox_mode": "workspace-write" + } + } +] +``` + +### Codex with an MCP server + +```json title="codify.jsonc" +[ + { + "type": "codex", + "mcpServers": [ + { + "name": "filesystem", + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + ] + } +] +``` + +### Codex with a remote MCP server + +```json title="codify.jsonc" +[ + { + "type": "codex", + "mcpServers": [ + { + "name": "figma", + "type": "http", + "url": "https://mcp.figma.com/mcp", + "bearerTokenEnvVar": "FIGMA_OAUTH_TOKEN" + } + ] + } +] +``` + +## Notes + +- Codex is installed via the official installer (`curl -fsSL https://chatgpt.com/codex/install.sh | sh`) on both macOS and Linux. The binary is placed at `~/.local/bin/codex`. +- The installer adds `~/.local/bin` to your PATH. This entry remains after destroy — remove it manually if you no longer want it. +- The `config` parameter merges only the declared top-level keys of `~/.codex/config.toml`. Existing keys not in your Codify config (including `mcp_servers`) are left untouched. +- MCP servers are stored under `[mcp_servers.]` in `~/.codex/config.toml`. Removing an MCP server from your config removes its table; other servers are untouched. +- For per-project configuration (AGENTS.md, project-scoped settings and MCP servers), see [`codex-project`](/docs/resources/codex/codex-project). For the Codex desktop app, see [`codex-app`](/docs/resources/codex/codex-app). +- Authentication (`codex login`) is interactive and not managed by this resource. diff --git a/package-lock.json b/package-lock.json index 64af52e7..fa362ae1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "default", - "version": "1.6.0-beta.7", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "default", - "version": "1.6.0-beta.7", + "version": "1.6.0", "license": "ISC", "dependencies": { "@codifycli/plugin-core": "1.2.2", @@ -19,6 +19,7 @@ "nanoid": "^5.0.9", "plist": "^3.1.0", "semver": "^7.6.0", + "smol-toml": "^1.6.1", "strip-ansi": "^7.1.0", "trash": "^10.0.0" }, @@ -10747,6 +10748,18 @@ "node": ">=20.0.0" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index deb16045..3a428dc4 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "nanoid": "^5.0.9", "plist": "^3.1.0", "semver": "^7.6.0", + "smol-toml": "^1.6.1", "strip-ansi": "^7.1.0", "trash": "^10.0.0" }, diff --git a/src/index.ts b/src/index.ts index b0145bc8..70fc4ee8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,9 @@ import { MacosSettingsResource } from './resources/macos/macos-settings/macos-se import { MacportsResource } from './resources/macports/macports.js'; import { ClaudeCodeResource } from './resources/claude-code/claude-code.js'; import { ClaudeCodeProjectResource } from './resources/claude-code/claude-code-project.js'; +import { CodexResource } from './resources/codex/codex.js'; +import { CodexProjectResource } from './resources/codex/codex-project.js'; +import { CodexAppResource } from './resources/codex/codex-app.js'; import { OllamaResource } from './resources/ollama/ollama.js'; import { PgcliResource } from './resources/pgcli/pgcli.js'; import { Pip } from './resources/python/pip/pip.js'; @@ -122,6 +125,9 @@ runPlugin(Plugin.create( new TartVmResource(), new ClaudeCodeResource(), new ClaudeCodeProjectResource(), + new CodexResource(), + new CodexProjectResource(), + new CodexAppResource(), new OllamaResource(), new SyncthingResource(), new SyncthingDeviceResource(), diff --git a/src/resources/codex/codex-app.ts b/src/resources/codex/codex-app.ts new file mode 100644 index 00000000..921ef90b --- /dev/null +++ b/src/resources/codex/codex-app.ts @@ -0,0 +1,68 @@ +import { + ExampleConfig, + Resource, + ResourceSettings, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS, StringIndexedObject } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; + +const CODEX_APP_PATH = '/Applications/Codex.app'; + +const schema = z + .object({}) + .meta({ $comment: 'https://codifycli.com/docs/resources/codex/codex-app' }) + .describe('Codex desktop app installation'); + +export type CodexAppConfig = z.infer & StringIndexedObject; + +const exampleBasic: ExampleConfig = { + title: 'Install the Codex desktop app', + description: 'Install the Codex desktop app (the "Codex command center") via Homebrew cask.', + configs: [ + { + type: 'codex-app', + os: ['macOS'], + }, + ], +}; + +export class CodexAppResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'codex-app', + operatingSystems: [OS.Darwin], + schema, + exampleConfigs: { + example1: exampleBasic, + }, + }; + } + + async refresh(): Promise | null> { + try { + await fs.access(CODEX_APP_PATH); + } catch { + return null; + } + + return {}; + } + + async create(): Promise { + const $ = getPty(); + await $.spawn('brew install --cask codex-app', { + interactive: true, + env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, + }); + } + + async destroy(): Promise { + const $ = getPty(); + await $.spawnSafe('brew uninstall --cask codex-app', { + env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, + }); + await $.spawnSafe(`rm -rf "${CODEX_APP_PATH}"`); + } +} diff --git a/src/resources/codex/codex-project.ts b/src/resources/codex/codex-project.ts new file mode 100644 index 00000000..2c67e9d3 --- /dev/null +++ b/src/resources/codex/codex-project.ts @@ -0,0 +1,229 @@ +import { + CodifyCliSender, + CreatePlan, + DestroyPlan, + ExampleConfig, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { untildify } from '../../utils/untildify.js'; +import { CodexConfigParameter } from './config-parameter.js'; +import { codexMcpServerSchema } from './mcp-server-schema.js'; +import { CodexMcpServersParameter } from './mcp-servers-parameter.js'; + +const schema = z + .object({ + directory: z + .string() + .describe( + 'Path to the project directory. AGENTS.md is written at /AGENTS.md and ' + + 'per-project settings/MCP servers are written under /.codex/config.toml.', + ), + agentsMd: z + .string() + .optional() + .describe( + 'Content for /AGENTS.md. Accepts inline text, an https:// URL, or a ' + + 'codify:// cloud URL (e.g. codify://documentId:fileId). Codex reads this file for ' + + 'project-specific instructions.', + ), + config: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Settings to merge into /.codex/config.toml. Supports the same keys as the ' + + 'global config, e.g. model, approval_policy, sandbox_mode, sandbox_workspace_write.', + ), + mcpServers: z + .array(codexMcpServerSchema) + .optional() + .describe('MCP servers to register for this project under [mcp_servers] in /.codex/config.toml.'), + }) + .meta({ $comment: 'https://codifycli.com/docs/resources/codex/codex-project' }) + .describe('Per-project Codex configuration'); + +export type CodexProjectConfig = z.infer; + +const defaultConfig: Partial = { + mcpServers: [], +}; + +const examplePerProject: ExampleConfig = { + title: 'Per-project AGENTS.md and settings', + description: 'Add project-specific AGENTS.md instructions and a sandbox policy for a single repository.', + configs: [ + { + type: 'codex-project', + directory: '~/projects/my-api', + agentsMd: '# Project Instructions\n\nThis is a Node.js API. Always use async/await.\nRun `npm test` before committing.', + config: { + sandbox_mode: 'workspace-write', + approval_policy: 'on-request', + }, + }, + ], +}; + +const exampleWithMcp: ExampleConfig = { + title: 'Per-project AGENTS.md with MCP server', + description: 'Configure per-project AGENTS.md and a project-scoped MCP server for database access.', + configs: [ + { + type: 'codex-project', + directory: '~/projects/my-api', + agentsMd: '# Project Instructions\n\nAlways check types with `npm run typecheck` before submitting.', + mcpServers: [ + { + name: 'project-db', + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-postgres', 'postgresql://localhost/mydb'], + }, + ], + }, + ], +}; + +function resolveCodexDir(directory: string): string { + return path.join(untildify(directory), '.codex'); +} + +function resolveAgentsMdPath(directory: string): string { + return path.join(untildify(directory), 'AGENTS.md'); +} + +export class CodexProjectResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'codex-project', + schema, + defaultConfig, + exampleConfigs: { + example1: examplePerProject, + example2: exampleWithMcp, + }, + operatingSystems: [OS.Darwin, OS.Linux], + dependencies: ['codex'], + parameterSettings: { + directory: { type: 'directory', canModify: false }, + agentsMd: { canModify: true }, + config: { type: 'stateful', definition: new CodexConfigParameter(), order: 1 }, + mcpServers: { type: 'stateful', definition: new CodexMcpServersParameter(), order: 2 }, + }, + allowMultiple: { + identifyingParameters: ['directory'], + }, + removeStatefulParametersBeforeDestroy: true, + }; + } + + async refresh(parameters: Partial): Promise | null> { + if (!parameters.directory) { + return null; + } + + // Use the .codex dir as the existence marker. Return null (not installed) if it doesn't exist. + try { + await fs.access(resolveCodexDir(parameters.directory)); + } catch { + return null; + } + + // Start from parameters so identifying fields (directory) are present in the result, + // preventing the framework from re-planning a CREATE on every validation pass. + const result: Partial = { ...parameters }; + + if (parameters.agentsMd != null) { + if (isRemoteUrl(parameters.agentsMd)) { + // For remote URLs, keep the URL as-is so the framework compares URL vs URL. + // Change detection for remote content is done via hash on apply. + result.agentsMd = parameters.agentsMd; + } else { + try { + result.agentsMd = await fs.readFile(resolveAgentsMdPath(parameters.directory), 'utf8'); + } catch { + result.agentsMd = undefined; + } + } + } + + return result; + } + + async create(plan: CreatePlan): Promise { + const { directory, agentsMd } = plan.desiredConfig; + if (directory) { + await fs.mkdir(resolveCodexDir(directory), { recursive: true }); + } + if (agentsMd && directory) { + await this.writeAgentsMd(agentsMd, directory); + } + } + + async modify( + pc: ParameterChange, + plan: ModifyPlan, + ): Promise { + if (pc.name === 'agentsMd') { + const { directory, agentsMd } = plan.desiredConfig; + if (agentsMd && directory) { + await this.writeAgentsMd(agentsMd, directory); + } else if (directory) { + await fs.rm(resolveAgentsMdPath(directory), { force: true }); + } + } + } + + async destroy(plan: DestroyPlan): Promise { + const { directory } = plan.currentConfig; + if (!directory) return; + + await fs.rm(resolveAgentsMdPath(directory), { force: true }); + await fs.rm(resolveCodexDir(directory), { recursive: true, force: true }); + } + + private async writeAgentsMd(content: string, directory: string): Promise { + const resolved = await resolveAgentsMdContent(content); + await fs.mkdir(untildify(directory), { recursive: true }); + await fs.writeFile(resolveAgentsMdPath(directory), resolved, 'utf8'); + } +} + +function isRemoteUrl(value: string): boolean { + return value.startsWith('https://') || value.startsWith('http://') || value.startsWith('codify://'); +} + +async function resolveAgentsMdContent(content: string): Promise { + if (content.startsWith('codify://')) { + const regex = /codify:\/\/(.*):(.*)/; + const [, documentId, fileId] = regex.exec(content) ?? []; + if (!documentId || !fileId) { + throw new Error(`Invalid codify URL for agentsMd: ${content}`); + } + const credentials = await CodifyCliSender.getCodifyCliCredentials(); + const response = await fetch(`https://api.codifycli.com/v1/documents/${documentId}/file/${fileId}`, { + headers: { Authorization: `Bearer ${credentials}` }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch agentsMd from ${content}: ${response.statusText}`); + } + return response.text(); + } + + if (content.startsWith('https://') || content.startsWith('http://')) { + const response = await fetch(content); + if (!response.ok) { + throw new Error(`Failed to fetch agentsMd from ${content}: ${response.statusText}`); + } + return response.text(); + } + + return content; +} diff --git a/src/resources/codex/codex.ts b/src/resources/codex/codex.ts new file mode 100644 index 00000000..763a99da --- /dev/null +++ b/src/resources/codex/codex.ts @@ -0,0 +1,123 @@ +import { + CreatePlan, + DestroyPlan, + ExampleConfig, + Resource, + ResourceSettings, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { CodexConfigParameter } from './config-parameter.js'; +import { codexMcpServerSchema } from './mcp-server-schema.js'; +import { CodexMcpServersParameter } from './mcp-servers-parameter.js'; + +const schema = z + .object({ + config: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Settings to merge into ~/.codex/config.toml. Supports model, model_provider, approval_policy, ' + + 'sandbox_mode, sandbox_workspace_write, model_reasoning_effort, model_reasoning_summary, web_search, ' + + 'file_opener, history, shell_environment_policy, and all other Codex config keys.', + ), + mcpServers: z + .array(codexMcpServerSchema) + .optional() + .describe('MCP servers to register globally under [mcp_servers] in ~/.codex/config.toml.'), + }) + .meta({ $comment: 'https://codifycli.com/docs/resources/codex/codex' }) + .describe('Codex CLI installation and configuration management'); + +export type CodexConfig = z.infer; + +const defaultConfig: Partial = { + mcpServers: [], +}; + +const exampleSettings: ExampleConfig = { + title: 'Codex with custom settings', + description: 'Install the Codex CLI and configure the model, approval policy, and sandbox mode.', + configs: [ + { + type: 'codex', + config: { + model: 'gpt-5.1-codex', + approval_policy: 'on-request', + sandbox_mode: 'workspace-write', + }, + }, + ], +}; + +const exampleWithMcp: ExampleConfig = { + title: 'Codex with an MCP server', + description: 'Install the Codex CLI and register a filesystem MCP server available to every project.', + configs: [ + { + type: 'codex', + mcpServers: [ + { + name: 'filesystem', + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + ], + }, + ], +}; + +export class CodexResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'codex', + defaultConfig, + exampleConfigs: { + example1: exampleSettings, + example2: exampleWithMcp, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + parameterSettings: { + config: { type: 'stateful', definition: new CodexConfigParameter(), order: 1 }, + mcpServers: { type: 'stateful', definition: new CodexMcpServersParameter(), order: 2 }, + }, + }; + } + + async refresh(): Promise | null> { + const codexBin = path.join(os.homedir(), '.local', 'bin', 'codex'); + try { + await fs.access(codexBin); + } catch { + return null; + } + + return {}; + } + + async create(_plan: CreatePlan): Promise { + const $ = getPty(); + + await $.spawn( + 'bash -c "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh"', + { interactive: true }, + ); + + // Ensure PATH is updated so subsequent lifecycle methods can call `codex` + const localBin = path.join(os.homedir(), '.local', 'bin'); + process.env['PATH'] = `${localBin}:${process.env['PATH'] ?? ''}`; + } + + async destroy(_plan: DestroyPlan): Promise { + // Native uninstall: remove the binary and standalone release artifacts + await fs.rm(path.join(os.homedir(), '.local', 'bin', 'codex'), { force: true }); + await fs.rm(path.join(os.homedir(), '.codex', 'packages', 'standalone'), { recursive: true, force: true }); + } +} diff --git a/src/resources/codex/config-parameter.ts b/src/resources/codex/config-parameter.ts new file mode 100644 index 00000000..09df2259 --- /dev/null +++ b/src/resources/codex/config-parameter.ts @@ -0,0 +1,80 @@ +import { ParameterSetting, Plan, StatefulParameter } from '@codifycli/plugin-core'; +import { StringIndexedObject } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import * as TOML from 'smol-toml'; + +import { untildify } from '../../utils/untildify.js'; + +type Settings = Record; + +export function resolveConfigTomlPath(directory?: string): string { + if (directory) return path.join(untildify(directory), '.codex', 'config.toml'); + return path.join(os.homedir(), '.codex', 'config.toml'); +} + +export async function readConfigToml(filePath: string): Promise> { + try { + const content = await fs.readFile(filePath, 'utf8'); + return TOML.parse(content) as Record; + } catch { + return {}; + } +} + +export async function writeConfigToml(filePath: string, data: Record): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, TOML.stringify(data), 'utf8'); +} + +/** + * Manages arbitrary top-level keys of ~/.codex/config.toml (or /.codex/config.toml), + * leaving the `mcp_servers` table (managed by CodexMcpServersParameter) untouched. + */ +export class CodexConfigParameter extends StatefulParameter { + getSettings(): ParameterSetting { + return { type: 'object' }; + } + + override async refresh(_desired: Settings | null, config: Partial): Promise { + const filePath = resolveConfigTomlPath(config['directory'] as string | undefined); + try { + const data = await readConfigToml(filePath); + const { mcp_servers: _mcpServers, ...rest } = data; + return rest; + } catch { + return null; + } + } + + async add(valueToAdd: Settings, plan: Plan): Promise { + const filePath = resolveConfigTomlPath(plan.desiredConfig?.['directory'] as string | undefined); + const existing = await readConfigToml(filePath); + await writeConfigToml(filePath, { ...existing, ...valueToAdd }); + } + + async modify(newValue: Settings, previousValue: Settings, plan: Plan): Promise { + const filePath = resolveConfigTomlPath(plan.desiredConfig?.['directory'] as string | undefined); + const existing = await readConfigToml(filePath); + + for (const key of Object.keys(previousValue)) { + if (!(key in newValue)) { + delete existing[key]; + } + } + + Object.assign(existing, newValue); + await writeConfigToml(filePath, existing); + } + + async remove(valueToRemove: Settings, plan: Plan): Promise { + const directory = (plan.currentConfig?.['directory'] ?? plan.desiredConfig?.['directory']) as string | undefined; + const filePath = resolveConfigTomlPath(directory); + const existing = await readConfigToml(filePath); + for (const key of Object.keys(valueToRemove)) { + delete existing[key]; + } + await writeConfigToml(filePath, existing); + } +} diff --git a/src/resources/codex/mcp-server-schema.ts b/src/resources/codex/mcp-server-schema.ts new file mode 100644 index 00000000..b8a3dfea --- /dev/null +++ b/src/resources/codex/mcp-server-schema.ts @@ -0,0 +1,79 @@ +import { z } from '@codifycli/plugin-core'; + +const codexMcpStdioServerSchema = z.object({ + name: z.string().describe('Unique name for this MCP server'), + type: z.literal('stdio'), + command: z.string().describe('Executable or command used to launch the MCP server process'), + args: z.array(z.string()).optional().describe('Arguments passed to the command'), + env: z.record(z.string(), z.string()).optional().describe('Static environment variables passed to the server process'), + envVars: z.array(z.string()).optional().describe('Names of environment variables forwarded from the Codex process environment'), + cwd: z.string().optional().describe('Working directory for the server process'), + startupTimeoutSec: z.number().optional().describe('Seconds to wait for the server to start (default: 10)'), + toolTimeoutSec: z.number().optional().describe('Seconds to wait for tool calls to complete (default: 60)'), +}); + +const codexMcpHttpServerSchema = z.object({ + name: z.string().describe('Unique name for this MCP server'), + type: z.literal('http'), + url: z.string().describe('URL of the streamable-HTTP MCP server'), + bearerTokenEnvVar: z.string().optional().describe('Name of the environment variable that holds the bearer token'), + httpHeaders: z.record(z.string(), z.string()).optional().describe('Static HTTP headers sent with every request'), +}); + +export const codexMcpServerSchema = z.discriminatedUnion('type', [ + codexMcpStdioServerSchema, + codexMcpHttpServerSchema, +]); + +export type CodexMcpServer = z.infer; + +/** + * Converts a CodexMcpServer (camelCase) into the TOML table shape expected under + * [mcp_servers.] in ~/.codex/config.toml (snake_case keys). + */ +export function mcpServerToToml(server: CodexMcpServer): Record { + const toml: Record = {}; + + if (server.type === 'stdio') { + toml['command'] = server.command; + if (server.args !== undefined) toml['args'] = server.args; + if (server.env !== undefined) toml['env'] = server.env; + if (server.envVars !== undefined) toml['env_vars'] = server.envVars; + if (server.cwd !== undefined) toml['cwd'] = server.cwd; + if (server.startupTimeoutSec !== undefined) toml['startup_timeout_sec'] = server.startupTimeoutSec; + if (server.toolTimeoutSec !== undefined) toml['tool_timeout_sec'] = server.toolTimeoutSec; + } else { + toml['url'] = server.url; + if (server.bearerTokenEnvVar !== undefined) toml['bearer_token_env_var'] = server.bearerTokenEnvVar; + if (server.httpHeaders !== undefined) toml['http_headers'] = server.httpHeaders; + } + + return toml; +} + +/** + * Converts a [mcp_servers.] TOML table back into a CodexMcpServer (camelCase). + */ +export function tomlToMcpServer(name: string, toml: Record): CodexMcpServer { + if (typeof toml['command'] === 'string') { + return { + name, + type: 'stdio', + command: toml['command'] as string, + ...(toml['args'] !== undefined ? { args: toml['args'] as string[] } : {}), + ...(toml['env'] !== undefined ? { env: toml['env'] as Record } : {}), + ...(toml['env_vars'] !== undefined ? { envVars: toml['env_vars'] as string[] } : {}), + ...(toml['cwd'] !== undefined ? { cwd: toml['cwd'] as string } : {}), + ...(toml['startup_timeout_sec'] !== undefined ? { startupTimeoutSec: toml['startup_timeout_sec'] as number } : {}), + ...(toml['tool_timeout_sec'] !== undefined ? { toolTimeoutSec: toml['tool_timeout_sec'] as number } : {}), + }; + } + + return { + name, + type: 'http', + url: toml['url'] as string, + ...(toml['bearer_token_env_var'] !== undefined ? { bearerTokenEnvVar: toml['bearer_token_env_var'] as string } : {}), + ...(toml['http_headers'] !== undefined ? { httpHeaders: toml['http_headers'] as Record } : {}), + }; +} diff --git a/src/resources/codex/mcp-servers-parameter.ts b/src/resources/codex/mcp-servers-parameter.ts new file mode 100644 index 00000000..3792209b --- /dev/null +++ b/src/resources/codex/mcp-servers-parameter.ts @@ -0,0 +1,56 @@ +import { ArrayStatefulParameter, Plan } from '@codifycli/plugin-core'; +import { StringIndexedObject } from '@codifycli/schemas'; + +import { readConfigToml, resolveConfigTomlPath, writeConfigToml } from './config-parameter.js'; +import { CodexMcpServer, mcpServerToToml, tomlToMcpServer } from './mcp-server-schema.js'; + +export class CodexMcpServersParameter extends ArrayStatefulParameter { + override getSettings() { + return { + type: 'array' as const, + isElementEqual: (a: CodexMcpServer, b: CodexMcpServer) => a.name === b.name, + }; + } + + async refresh(_desired: CodexMcpServer[] | null, config: Partial): Promise { + const filePath = resolveConfigTomlPath(config['directory'] as string | undefined); + const data = await readConfigToml(filePath); + const mcpServers = data['mcp_servers']; + + if (!mcpServers || typeof mcpServers !== 'object') { + return []; + } + + return Object.entries(mcpServers as Record) + .map(([name, serverConfig]) => tomlToMcpServer(name, serverConfig as Record)); + } + + async addItem(item: CodexMcpServer, plan: Plan): Promise { + await this.mutateMcpServers((servers) => { + servers[item.name] = mcpServerToToml(item); + }, plan.desiredConfig?.['directory'] as string | undefined); + } + + async removeItem(item: CodexMcpServer, plan: Plan): Promise { + const directory = (plan.currentConfig?.['directory'] ?? plan.desiredConfig?.['directory']) as string | undefined; + await this.mutateMcpServers((servers) => { + delete servers[item.name]; + }, directory); + } + + private async mutateMcpServers( + mutate: (servers: Record) => void, + directory?: string, + ): Promise { + const filePath = resolveConfigTomlPath(directory); + const data = await readConfigToml(filePath); + + if (!data['mcp_servers'] || typeof data['mcp_servers'] !== 'object') { + data['mcp_servers'] = {}; + } + + mutate(data['mcp_servers'] as Record); + + await writeConfigToml(filePath, data); + } +} diff --git a/test/codex/codex-app.test.ts b/test/codex/codex-app.test.ts new file mode 100644 index 00000000..3be4656c --- /dev/null +++ b/test/codex/codex-app.test.ts @@ -0,0 +1,28 @@ +import { PluginTester } from '@codifycli/plugin-test'; +import { Utils } from '@codifycli/plugin-core'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const CODEX_APP_PATH = '/Applications/Codex.app'; + +describe('codex-app resource integration tests', { skip: !Utils.isMacOS() }, async () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can install the Codex desktop app', { timeout: 600_000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [{ type: 'codex-app' }], + { + validateApply: async () => { + const lstat = await fs.lstat(CODEX_APP_PATH); + expect(lstat.isDirectory()).toBe(true); + }, + validateDestroy: async () => { + const exists = await fs.access(CODEX_APP_PATH).then(() => true).catch(() => false); + expect(exists).toBe(false); + }, + }, + ); + }); +}); diff --git a/test/codex/codex-project.test.ts b/test/codex/codex-project.test.ts new file mode 100644 index 00000000..d53fcf1a --- /dev/null +++ b/test/codex/codex-project.test.ts @@ -0,0 +1,148 @@ +import { PluginTester } from '@codifycli/plugin-test'; +import * as TOML from 'smol-toml'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const TEST_DIR = path.join(os.tmpdir(), 'codify-codex-project-test'); +const AGENTS_MD_PATH = path.join(TEST_DIR, 'AGENTS.md'); +const CONFIG_TOML_PATH = path.join(TEST_DIR, '.codex', 'config.toml'); + +describe('codex-project resource integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + beforeAll(async () => { + await fs.mkdir(TEST_DIR, { recursive: true }); + }); + + afterAll(async () => { + await fs.rm(TEST_DIR, { recursive: true, force: true }); + }); + + it('Can manage agentsMd for a project directory', { timeout: 120_000 }, async () => { + const initialContent = '# Project Instructions\n\nAlways write tests.'; + const modifiedContent = '# Project Instructions\n\nAlways write tests.\nPrefer TypeScript.'; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'codex-project', directory: TEST_DIR, agentsMd: initialContent }], + { + validateApply: async () => { + const content = await fs.readFile(AGENTS_MD_PATH, 'utf8'); + expect(content).toBe(initialContent); + }, + testModify: { + modifiedConfigs: [{ type: 'codex-project', directory: TEST_DIR, agentsMd: modifiedContent }], + validateModify: async () => { + const content = await fs.readFile(AGENTS_MD_PATH, 'utf8'); + expect(content).toBe(modifiedContent); + }, + }, + validateDestroy: async () => { + const exists = await fs.access(AGENTS_MD_PATH).then(() => true).catch(() => false); + expect(exists).toBe(false); + }, + }, + ); + }); + + it('Can manage per-project config', { timeout: 120_000 }, async () => { + const initialConfig = { + approval_policy: 'on-request', + }; + + const modifiedConfig = { + approval_policy: 'never', + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'codex-project', directory: TEST_DIR, config: initialConfig }], + { + validateApply: async () => { + const content = await fs.readFile(CONFIG_TOML_PATH, 'utf8'); + const parsed = TOML.parse(content) as Record; + expect(parsed['approval_policy']).toBe('on-request'); + }, + testModify: { + modifiedConfigs: [{ type: 'codex-project', directory: TEST_DIR, config: modifiedConfig }], + validateModify: async () => { + const content = await fs.readFile(CONFIG_TOML_PATH, 'utf8'); + const parsed = TOML.parse(content) as Record; + expect(parsed['approval_policy']).toBe('never'); + }, + }, + validateDestroy: async () => { + const exists = await fs.access(CONFIG_TOML_PATH).then(() => true).catch(() => false); + expect(exists).toBe(false); + }, + }, + ); + }); + + it('Can manage per-project MCP servers', { timeout: 120_000 }, async () => { + const mcpServer = { + name: 'test-filesystem', + type: 'stdio' as const, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'codex-project', directory: TEST_DIR, mcpServers: [mcpServer] }], + { + validateApply: async () => { + const content = await fs.readFile(CONFIG_TOML_PATH, 'utf8'); + const parsed = TOML.parse(content) as { mcp_servers?: Record }; + expect(parsed.mcp_servers).toBeDefined(); + expect(parsed.mcp_servers?.['test-filesystem']).toBeDefined(); + expect(parsed.mcp_servers?.['test-filesystem']?.command).toBe('npx'); + }, + validateDestroy: async () => { + const exists = await fs.access(CONFIG_TOML_PATH).then(() => true).catch(() => false); + expect(exists).toBe(false); + }, + }, + ); + }); + + it('Does not affect global config when managing per-project config', { timeout: 120_000 }, async () => { + const globalConfigPath = path.join(os.homedir(), '.codex', 'config.toml'); + + let globalConfigBefore: string | null = null; + try { + globalConfigBefore = await fs.readFile(globalConfigPath, 'utf8'); + } catch { /* may not exist */ } + + await PluginTester.fullTest( + pluginPath, + [{ type: 'codex-project', directory: TEST_DIR, config: { approval_policy: 'never' } }], + { + validateApply: async () => { + // Per-project config written + const content = await fs.readFile(CONFIG_TOML_PATH, 'utf8'); + expect((TOML.parse(content) as Record)['approval_policy']).toBe('never'); + + // Global config unchanged + try { + const globalContent = await fs.readFile(globalConfigPath, 'utf8'); + expect(globalContent).toBe(globalConfigBefore); + } catch { + expect(globalConfigBefore).toBeNull(); + } + }, + validateDestroy: async () => { + // Global config still unchanged after destroy + try { + const globalContent = await fs.readFile(globalConfigPath, 'utf8'); + expect(globalContent).toBe(globalConfigBefore); + } catch { + expect(globalConfigBefore).toBeNull(); + } + }, + }, + ); + }); +}); diff --git a/test/codex/codex.test.ts b/test/codex/codex.test.ts new file mode 100644 index 00000000..504d72ef --- /dev/null +++ b/test/codex/codex.test.ts @@ -0,0 +1,108 @@ +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import * as TOML from 'smol-toml'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; + +const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml'); + +describe('codex resource integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can install codex', { timeout: 300_000 }, async () => { + const codexBin = path.join(os.homedir(), '.local', 'bin', 'codex'); + await PluginTester.fullTest( + pluginPath, + [{ type: 'codex' }], + { + skipUninstall: true, + validateApply: async () => { + const exists = await fs.access(codexBin).then(() => true).catch(() => false); + expect(exists).toBe(true); + }, + }, + ); + }); + + it('Can manage config', { timeout: 300_000 }, async () => { + const initialConfig = { + approval_policy: 'on-request', + sandbox_mode: 'workspace-write', + }; + + const modifiedConfig = { + approval_policy: 'never', + sandbox_mode: 'workspace-write', + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'codex', config: initialConfig }], + { + skipUninstall: true, + validateApply: async () => { + const content = await fs.readFile(CODEX_CONFIG_PATH, 'utf8'); + const parsed = TOML.parse(content) as Record; + expect(parsed['approval_policy']).toBe('on-request'); + expect(parsed['sandbox_mode']).toBe('workspace-write'); + }, + testModify: { + modifiedConfigs: [{ type: 'codex', config: modifiedConfig }], + validateModify: async () => { + const content = await fs.readFile(CODEX_CONFIG_PATH, 'utf8'); + const parsed = TOML.parse(content) as Record; + expect(parsed['approval_policy']).toBe('never'); + }, + }, + validateDestroy: async () => { + try { + const content = await fs.readFile(CODEX_CONFIG_PATH, 'utf8'); + const parsed = TOML.parse(content) as Record; + expect(parsed['approval_policy']).toBeUndefined(); + } catch { + // file removed entirely is also acceptable + } + }, + }, + ); + }); + + it('Can manage MCP servers', { timeout: 300_000 }, async () => { + const mcpServer = { + name: 'test-filesystem', + type: 'stdio' as const, + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'codex', mcpServers: [mcpServer] }], + { + validateApply: async () => { + const content = await fs.readFile(CODEX_CONFIG_PATH, 'utf8'); + const parsed = TOML.parse(content) as { mcp_servers?: Record }; + expect(parsed.mcp_servers).toBeDefined(); + expect(parsed.mcp_servers?.['test-filesystem']).toBeDefined(); + expect(parsed.mcp_servers?.['test-filesystem']?.command).toBe('npx'); + }, + validateDestroy: async () => { + try { + const content = await fs.readFile(CODEX_CONFIG_PATH, 'utf8'); + const parsed = TOML.parse(content) as { mcp_servers?: Record }; + expect(parsed.mcp_servers?.['test-filesystem']).toBeUndefined(); + } catch { + // file not existing is also acceptable + } + }, + }, + ); + }); + + afterAll(async () => { + // Best-effort cleanup in case tests left codex installed + await testSpawn('rm -f ~/.local/bin/codex'); + await testSpawn('rm -rf ~/.codex/packages/standalone'); + }, 60_000); +});