diff --git a/cspell.json b/cspell.json index 55f2404b5..0b223847e 100644 --- a/cspell.json +++ b/cspell.json @@ -104,6 +104,7 @@ "eabi", "eamodio", "eastasianwidth", + "elicitations", "émøjî", "éñüñ", "encodeurl", diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index db464246e..f13671582 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -277,6 +277,8 @@ Execute the following in parallel: The command body itself uses a Claude Code-compatible **universal syntax** (e.g. `$ARGUMENTS`, `` !`cmd` ``). When a target tool expects a different placeholder syntax, rulesync translates it automatically on generation and reverses the translation on import. See [Command Syntax](./command-syntax.md) for the full mapping. +> **Codex CLI deprecation note:** Codex CLI's own docs now state "Custom prompts are deprecated. Use skills for reusable instructions" (see [Custom Prompts](https://developers.openai.com/codex/custom-prompts)). Rulesync's `codexcli` commands still generate the global-only `~/.codex/prompts/*.md` custom-prompt files described above — they remain functional and no removal date has been announced, so this behavior is unchanged for now. For new reusable instructions, prefer rulesync's `codexcli` skills support (see `.rulesync/skills/*/SKILL.md` below) instead. + > **Qwen Code note:** Custom commands are emitted as **Markdown** files (not TOML — TOML is deprecated upstream) under `.qwen/commands/` (project) and `~/.qwen/commands/` (global, via `--global`). The file is an optional YAML frontmatter block (`description`) followed by the prompt body. Subdirectory namespacing is supported: `.qwen/commands/git/commit.md` becomes the `/git:commit` command. Any extra fields are preserved on round-trip under the `qwencode:` block. > **OpenCode import note:** OpenCode lets commands live both as Markdown files under `.opencode/commands/*.md` **and** inline in `opencode.json`/`opencode.jsonc` under the top-level `command` key. On import, rulesync reads both: each inline entry's `template` becomes the command body and its `description`/`agent`/`model`/`subtask` fields become frontmatter. A Markdown file takes precedence over an inline entry with the same name. diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index db464246e..f13671582 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -277,6 +277,8 @@ Execute the following in parallel: The command body itself uses a Claude Code-compatible **universal syntax** (e.g. `$ARGUMENTS`, `` !`cmd` ``). When a target tool expects a different placeholder syntax, rulesync translates it automatically on generation and reverses the translation on import. See [Command Syntax](./command-syntax.md) for the full mapping. +> **Codex CLI deprecation note:** Codex CLI's own docs now state "Custom prompts are deprecated. Use skills for reusable instructions" (see [Custom Prompts](https://developers.openai.com/codex/custom-prompts)). Rulesync's `codexcli` commands still generate the global-only `~/.codex/prompts/*.md` custom-prompt files described above — they remain functional and no removal date has been announced, so this behavior is unchanged for now. For new reusable instructions, prefer rulesync's `codexcli` skills support (see `.rulesync/skills/*/SKILL.md` below) instead. + > **Qwen Code note:** Custom commands are emitted as **Markdown** files (not TOML — TOML is deprecated upstream) under `.qwen/commands/` (project) and `~/.qwen/commands/` (global, via `--global`). The file is an optional YAML frontmatter block (`description`) followed by the prompt body. Subdirectory namespacing is supported: `.qwen/commands/git/commit.md` becomes the `/git:commit` command. Any extra fields are preserved on round-trip under the `qwencode:` block. > **OpenCode import note:** OpenCode lets commands live both as Markdown files under `.opencode/commands/*.md` **and** inline in `opencode.json`/`opencode.jsonc` under the top-level `command` key. On import, rulesync reads both: each inline entry's `template` becomes the command body and its `description`/`agent`/`model`/`subtask` fields become frontmatter. A Markdown file takes precedence over an inline entry with the same name. diff --git a/src/features/commands/codexcli-command.ts b/src/features/commands/codexcli-command.ts index ba0765ebd..a7174ca1e 100644 --- a/src/features/commands/codexcli-command.ts +++ b/src/features/commands/codexcli-command.ts @@ -30,6 +30,16 @@ export type CodexcliCommandParams = { body: string; } & Omit; +/** + * Generates Codex CLI's global-only custom prompts under `~/.codex/prompts/*.md`. + * + * Note: upstream Codex docs now state "Custom prompts are deprecated. Use skills for + * reusable instructions" (https://developers.openai.com/codex/custom-prompts). No removal + * date has been announced and custom prompts remain functional, so this class's + * generation behavior is unchanged. Prefer rulesync's `codexcli` skills support + * (see `src/features/skills/codexcli-skill.ts`) for new reusable instructions going + * forward; this class is kept for users who still rely on custom prompts. + */ export class CodexcliCommand extends ToolCommand { private readonly frontmatter: CodexcliCommandFrontmatter; private readonly body: string; diff --git a/src/features/hooks/codexcli-hooks.test.ts b/src/features/hooks/codexcli-hooks.test.ts index 74f5c4560..96432e316 100644 --- a/src/features/hooks/codexcli-hooks.test.ts +++ b/src/features/hooks/codexcli-hooks.test.ts @@ -532,9 +532,9 @@ describe("CodexcliConfigToml", () => { await cleanup(); }); - it("should generate config.toml with hooks feature flag", async () => { + it("should not force-write [features] hooks = true (hooks are GA/default-on)", async () => { const configToml = await CodexcliConfigToml.fromOutputRoot({ outputRoot: testDir }); - expect(configToml.getFileContent()).toContain("hooks = true"); + expect(configToml.getFileContent()).not.toContain("hooks = true"); expect(configToml.getFileContent()).not.toContain("codex_hooks"); }); @@ -547,23 +547,36 @@ describe("CodexcliConfigToml", () => { const configToml = await CodexcliConfigToml.fromOutputRoot({ outputRoot: testDir }); const content = configToml.getFileContent(); - expect(content).toContain("hooks = true"); + expect(content).not.toContain("hooks = true"); expect(content).not.toContain("codex_hooks"); expect(content).toContain("mcp_servers"); expect(content).toContain("myserver"); }); - it("should preserve existing [features] values when enabling hooks", async () => { + it("should preserve existing [features] values without adding hooks = true", async () => { await ensureDir(join(testDir, ".codex")); await writeFileContent(join(testDir, ".codex", "config.toml"), "[features]\nverbose = true\n"); + const configToml = await CodexcliConfigToml.fromOutputRoot({ outputRoot: testDir }); + const content = configToml.getFileContent(); + expect(content).not.toContain("hooks = true"); + expect(content).toContain("verbose = true"); + }); + + it("should not strip a user-set [features] hooks = true value", async () => { + await ensureDir(join(testDir, ".codex")); + await writeFileContent( + join(testDir, ".codex", "config.toml"), + "[features]\nhooks = true\nverbose = true\n", + ); + const configToml = await CodexcliConfigToml.fromOutputRoot({ outputRoot: testDir }); const content = configToml.getFileContent(); expect(content).toContain("hooks = true"); expect(content).toContain("verbose = true"); }); - it("should remove deprecated codex_hooks when enabling hooks", async () => { + it("should remove deprecated codex_hooks without adding hooks = true", async () => { await ensureDir(join(testDir, ".codex")); await writeFileContent( join(testDir, ".codex", "config.toml"), @@ -572,7 +585,7 @@ describe("CodexcliConfigToml", () => { const configToml = await CodexcliConfigToml.fromOutputRoot({ outputRoot: testDir }); const content = configToml.getFileContent(); - expect(content).toContain("hooks = true"); + expect(content).not.toContain("hooks = true"); expect(content).toContain("verbose = true"); expect(content).not.toContain("codex_hooks"); }); diff --git a/src/features/hooks/codexcli-hooks.ts b/src/features/hooks/codexcli-hooks.ts index 47c0f0710..087b2d68a 100644 --- a/src/features/hooks/codexcli-hooks.ts +++ b/src/features/hooks/codexcli-hooks.ts @@ -37,9 +37,15 @@ const CODEXCLI_CONVERTER_CONFIG: ToolHooksConverterConfig = { }; /** - * Build the content for `.codex/config.toml` with `[features] hooks = true`. - * Reads the existing file (if any), parses TOML, sets the flag, and returns the content - * without writing to disk. The caller is responsible for writing via the normal write phase. + * Build the content for `.codex/config.toml`, cleaning up the deprecated `codex_hooks` key. + * Reads the existing file (if any), parses TOML, and returns the content without writing to + * disk. The caller is responsible for writing via the normal write phase. + * + * Hooks are GA and enabled by default in Codex CLI, so `[features] hooks = true` is no longer + * required and is intentionally NOT force-written here (doing so used to be harmless/idempotent, + * but is now redundant and could mask a user's own `hooks = false` opt-out on a later edit). + * See https://developers.openai.com/codex/hooks. Only the legacy `codex_hooks` alias — superseded + * by `hooks` — is still cleaned up. */ async function buildCodexConfigTomlContent({ outputRoot, @@ -60,12 +66,9 @@ async function buildCodexConfigTomlContent({ ); } - if (typeof configToml.features !== "object" || configToml.features === null) { - configToml.features = {} as smolToml.TomlTable; + if (typeof configToml.features === "object" && configToml.features !== null) { + delete (configToml.features as smolToml.TomlTable).codex_hooks; } - const features = configToml.features as smolToml.TomlTable; - delete features.codex_hooks; - features.hooks = true; return smolToml.stringify(configToml); } diff --git a/src/features/permissions/codexcli-permissions.test.ts b/src/features/permissions/codexcli-permissions.test.ts index 3006145c4..40e0eb1c6 100644 --- a/src/features/permissions/codexcli-permissions.test.ts +++ b/src/features/permissions/codexcli-permissions.test.ts @@ -1080,6 +1080,72 @@ default_permissions = "rulesync" } }); + it("should preserve granular tool-approval keys (default_tools_approval_mode, approval_policy, approvals_reviewer, apps.*, mcp_servers.*) on round-trip", async () => { + const codexDir = join(testDir, ".codex"); + await ensureDir(codexDir); + await writeFileContent( + join(codexDir, "config.toml"), + ` +default_tools_approval_mode = "prompt" +approvals_reviewer = "auto_review" + +[approval_policy] +sandbox_approval = true +rules = true +mcp_elicitations = false +request_permissions = true +skill_approval = false + +[apps.myapp] +default_tools_approval_mode = "auto" + +[mcp_servers.myserver] +default_tools_approval_mode = "approve" +command = "node" +`, + ); + + const rulesyncPermissions = new RulesyncPermissions({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ + permission: { + read: { "src/**": "allow" }, + }, + }), + }); + + const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + + const fileContent = codexPermissions.getFileContent(); + const parsed = smolToml.parse(fileContent) as Record; + + expect(parsed.default_tools_approval_mode).toBe("prompt"); + expect(parsed.approvals_reviewer).toBe("auto_review"); + expect(parsed.approval_policy).toEqual({ + sandbox_approval: true, + rules: true, + mcp_elicitations: false, + request_permissions: true, + skill_approval: false, + }); + expect((parsed.apps as Record).myapp).toEqual({ + default_tools_approval_mode: "auto", + }); + expect((parsed.mcp_servers as Record).myserver).toEqual({ + default_tools_approval_mode: "approve", + command: "node", + }); + + // The rulesync-managed profile is still written alongside the preserved keys. + expect(fileContent).toContain('default_permissions = "rulesync"'); + expect(fileContent).toContain('"src/**" = "read"'); + }); + it("should convert rulesync bash permissions to Codex CLI .rules file", () => { const rulesFile = createCodexcliBashRulesFile({ outputRoot: testDir, diff --git a/src/features/permissions/codexcli-permissions.ts b/src/features/permissions/codexcli-permissions.ts index cd7596648..40f49ed55 100644 --- a/src/features/permissions/codexcli-permissions.ts +++ b/src/features/permissions/codexcli-permissions.ts @@ -110,6 +110,13 @@ export class CodexcliPermissions extends ToolPermissions { const paths = this.getSettablePaths({ global }); const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); const existingContent = (await readFileContentOrNull(filePath)) ?? smolToml.stringify({}); + // `parsed` is a shallow copy of the FULL top-level config.toml table (see toMutableTable), + // and only its `permissions` and `default_permissions` keys are overwritten below. This means + // Codex config keys rulesync does not model — e.g. the granular tool-approval surface + // (`default_tools_approval_mode`, `approval_policy`, `approvals_reviewer`, and their + // `apps..*` / `mcp_servers..*` variants) — survive a read-modify-write round-trip + // untouched, the same way amp/devin permissions preserve sibling settings they don't manage. + // https://developers.openai.com/codex/config-reference const parsed = toMutableTable(smolToml.parse(existingContent)); const newProfile = convertRulesyncToCodexProfile({