Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"eabi",
"eamodio",
"eastasianwidth",
"elicitations",
"émøjî",
"éñüñ",
"encodeurl",
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/features/commands/codexcli-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export type CodexcliCommandParams = {
body: string;
} & Omit<AiFileParams, "fileContent">;

/**
* 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;
Expand Down
25 changes: 19 additions & 6 deletions src/features/hooks/codexcli-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand All @@ -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"),
Expand All @@ -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");
});
Expand Down
19 changes: 11 additions & 8 deletions src/features/hooks/codexcli-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
66 changes: 66 additions & 0 deletions src/features/permissions/codexcli-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

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<string, unknown>).myapp).toEqual({
default_tools_approval_mode: "auto",
});
expect((parsed.mcp_servers as Record<string, unknown>).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,
Expand Down
7 changes: 7 additions & 0 deletions src/features/permissions/codexcli-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.*` / `mcp_servers.<id>.*` 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({
Expand Down
Loading