Skip to content

File Formats ​

Rulesync follows symbolic links when it discovers source files, whether you use a plain .rulesync/ directory or separate --input-roots. Glob-based discovery (rules, commands, subagents, skills) follows symlinked files and directories; single fixed-path files such as .rulesyncignore, .rulesync/mcp.jsonc, and .rulesync/permissions.jsonc are likewise resolved transparently by the OS when read. A symlink inside the input tree that points elsewhere is followed transparently, and the resolved file content is copied into the generated output. This is intentional: it lets you centralize shared skills or rules in one place and reference them via symlinks without duplication (see issue #1707). A directory link that leads back to a directory the walk is already inside (for example loop -> . or up -> .. reaching the source tree again) is not descended a second time, so a link loop ends the walk instead of repeating it until the OS reports ELOOP; the files below that point are still found once, under the directory the walk is already inside.

The trust boundary is the directory you point Rulesync at. There is no realpath-based containment check on individual symlinks, so a link may resolve to a target outside the input root — enforcing containment would break the shared-file use case above. Only run Rulesync against trees you control. Directory symlink cycles are handled safely: a glob walk does not descend a link back into a directory it is already inside, and glob-based results are deduplicated by the real file they resolve to, so a cycle does not produce duplicated output, and two names for one file (a docs/reference.md link pointing at a top-level reference.md, say) yield a single entry. Skill directories are not discovered by globs at all — they are walked directly, and that walk keeps every entry it reaches, so a supporting file remains available under each name the skill gives it. Note that the remote-fetch path (rulesync fetch from a Git repository) is a separate, hardened code path that never follows a symlink. A symlink in the remote repository is skipped rather than downloaded, so untrusted remote content never has its links resolved. Locally, the stale-file prune described in CLI Commands removes a stale link itself without reading through it, and refuses to prune a skill directory that is a symlink at all, so a shared skill you linked in stays untouched.

Three exceptions narrow this within skill directories. First, the entries a skill directory never carries (listed in the supporting-file note below) are excluded by the name they really resolve to, not just the name they have inside the skill — a link called vendor pointing at ~/.aws is refused exactly as a directory called .aws is. Second, a link whose real path lands in a system pseudo-filesystem (/proc, /sys, /dev) is refused: /proc/self/environ looks like an ordinary file but reads back the environment of the running process. Third, a skill directory's companion files include hidden (dot-prefixed) entries, so an ordinary-looking link to a home directory would otherwise pull in every dotfile beneath it: a hidden entry whose real path resolves outside the skill directory is not carried, and the skipped paths are named in a warning. Copy such a file into the directory if the skill really needs it.

What decides that third rule is the name inside the skill directory, not the path the link resolves through: a named file keeps the behavior above even when its target sits under a dot-directory such as ~/.dotfiles/skills/, because somebody chose that name. Reaching outside the directory is reported either way — carried or not — since the content is about to be copied into every enabled tool root.

On the output side, generation does not write through a link that leads out of the output root. A checked-out repository can carry a symbolic link at an output path — an AGENTS.md pointing at ~/.bashrc, or a tool directory pointing at /etc — and following it would land the generated content wherever the link points. Such a file or directory is skipped with a warning naming the path — a skill directory as a whole when any one file in it (its SKILL.md or a supporting file) is such a link — and a dangling link is judged by the target it would create, resolved link by link the way the OS would resolve it. A link that stays inside the output root is written through as before, so a dotfiles checkout linked from inside the home directory keeps working with --global. Under --delete, a skills or subagents directory that is itself such a link (for example ~/.cursor/skills or ~/.claude/agents) is written through but not swept: nothing under it is deleted, including stale files inside the skill directories this run writes, and the run warns that its orphan sweep was skipped and still succeeds, since sweeping through the link could delete files rulesync does not manage. Also under --delete, two tools whose output directories are one directory through such a link, such as .cursor/commands linked to .claude/commands, keep each other's files: a file only one of them generates is not swept as the other's orphan.

One discovery pass is deliberately excluded from the follow-symlinks rule: the scan for nested AGENTS.md files (see the agentsmd note below). Unlike every other glob above, it walks the whole project rather than a rulesync-owned directory, so a symlink committed to a repository you cloned could otherwise pull a file from outside the project into version-controlled .rulesync/. That scan does not follow symlinks.

rulesync/rules/*.md ​

Example:

md
---
root: true # true for root-level rules, false for details such as `.agents/memories/*.md`
localRoot: false # (optional, default: false) true for project-specific local rules. Claude Code: CLAUDE.local.md; Rovodev (Rovo Dev CLI), Roo Code, Zoo Code and Devin: AGENTS.local.md; CodeBuddy Code: CODEBUDDY.local.md; Crush: CRUSH.local.md; Reasonix: REASONIX.local.md; Qwen Code: .qwen/QWEN.local.md; Others: append to root file. See the localRoot note below for import behavior
targets: ["*"] # * = all, or specific tools
description: "Rulesync project overview and development guidelines for unified AI rules management CLI tool"
globs: ["**/*"] # file patterns to match (e.g., ["*.md", "*.txt"])
agentsmd: # agentsmd and other nested-AGENTS.md readers (codexcli, warp, ...) specific parameters
  # Support for using nested AGENTS.md files for subprojects in a large monorepo.
  # This option is available only if root is false.
  # If subprojectPath is provided, the file is located in `${subprojectPath}/AGENTS.md`.
  # If subprojectPath is not provided and root is false, the file is located in `.agents/memories/*.md`.
  # Set it to "auto" to derive the directory from `globs` (`["packages/api/**/*"]` -> `packages/api`)
  # for this rule alone; the `deriveSubprojectPathFromGlobs` config option does the same for every rule.
  # Set it to "" to keep this rule at its default placement even when that option is on.
  subprojectPath: "path/to/subproject"
cursor: # cursor specific parameters
  alwaysApply: true
  description: "Rulesync project overview and development guidelines for unified AI rules management CLI tool"
  # Omit this key to fall back to the canonical `globs` above; set it to an
  # explicit `[]`, as here, to write the `.mdc` with no `globs` line at all.
  # `[]` is the right value beside `alwaysApply: true`: the flag already applies
  # the rule everywhere, and Cursor treats a glob next to it as a conflict. On a
  # rule that is *not* Always Apply, put the Cursor-specific patterns here
  # instead (e.g. `["src/**/*.ts"]`). See the Cursor note below.
  globs: []
copilot: # copilot specific parameters (non-root `*.instructions.md` files only)
  name: "TypeScript Style" # (optional) display name shown in the VS Code UI; defaults to the file name
  excludeAgent: "code-review" # (optional) "code-review" or "cloud-agent": skip this file for that agent
  # Any other frontmatter key found in a hand-written `*.instructions.md` is imported into this
  # section and written back out, so a field Rulesync does not model is not lost on regeneration.
  # `description` and `applyTo` are the exception: they have canonical homes (`description` and
  # `globs`), so a value written for them in this section is overwritten by the canonical one.
antigravity: # antigravity specific parameters
  trigger: "always_on" # always_on, glob, manual, or model_decision
  globs: ["**/*"] # (optional) file patterns to match when trigger is "glob"
  description: "When to apply this rule" # (optional) used with "model_decision" trigger
devin: # devin (Devin Desktop, formerly Windsurf) specific parameters
  trigger: "always_on" # always_on, glob, manual, or model_decision
  globs: ["**/*"] # (optional) file patterns to match when trigger is "glob"
  description: "When to apply this rule" # (optional) used with "model_decision" trigger
aiassistant: # aiassistant (JetBrains AI Assistant) specific parameters
  # (optional) Rule type written to the `apply:` line of `.aiassistant/rules/*.md`:
  # always, manually, by model decision, by file patterns, or off.
  # Omit it to derive the type from `globs` and `description`; see the AI Assistant note below.
  apply: "always"
trae: # trae specific parameters
  # (optional) Overrides the `alwaysApply` value derived from `globs` and `description`,
  # e.g. `false` on a rule with neither makes it apply only via `#Rule`. See the Trae note below.
  alwaysApply: false
  scene: "git_message" # (optional) Trae's `scene` field: also apply this rule to AI-generated Git commit messages
augmentcode: # augmentcode specific parameters
  type: "always_apply" # always_apply, manual, or agent_requested
  description: "When to apply this rule" # (optional) used with "agent_requested" type
kiro: # kiro specific parameters (steering inclusion)
  inclusion: "fileMatch" # always, fileMatch, manual, or auto
  fileMatchPattern: ["src/components/**/*.tsx"] # (optional) glob string or array of globs, used when inclusion is "fileMatch"
  name: "api-design" # (optional) required when inclusion is "auto"; the steering entry key
  description: "REST API design patterns. Use when creating or modifying API endpoints." # (optional) required when inclusion is "auto"; Kiro auto-includes the file when a request matches this
takt: # takt specific parameters (optional; emitted under .takt/facets/policies/ — frontmatter is dropped on emit)
  name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..")
  extends: "base" # (optional) emit a leading `{extends:<parent>}` facet-inheritance directive (Takt 0.39.0+)
  facet: "output-contracts" # (optional) "policies" (default) or "output-contracts": redirect this rule to Takt's output-structure/report-template facet
factorydroid: # factorydroid specific parameters
  # Route this non-root rule's body to one of Factory Droid's fixed files instead
  # of folding it into AGENTS.md / .factory/rules/*.md: "design" → the
  # design-guidelines DESIGN.md, "threat-model" → the Security Review attack-surface
  # map .factory/threat-model.md (both project scope only); "output-style" → a
  # custom output style of its own, .factory/output-styles/<rule file name>.md
  # (project and global scope). This option is available only if root is false.
  channel: "design"
  # (optional) With channel: "output-style", the style's picker name; Droid
  # defaults it to the file name without .md. Output-style rules should set
  # targets: ["factorydroid"] so other tools do not receive them as rules.
  name: "Review Notes"
---

# Rulesync Project Overview

This is Rulesync, a Node.js CLI tool that automatically generates configuration files for various AI development tools from unified AI rule files. The project enables teams to maintain consistent AI coding assistant rules across multiple tools.

...

Multiple files can set root: true for the same target in project and global modes. Rulesync renders each file through the target adapter, then combines compatible root or plain-Markdown single-file outputs in deterministic source-discovery order with one blank line between fragments. Local rules are ordered lexicographically by source file path and composed before non-overridden .curated/ rules, which are also ordered lexicographically; filename prefixes such as 10- and 20- control composition order within each set. Targets that map source rules to distinct native paths keep those files separate. Explicitly supported plain-Markdown modular rules that normalize to the same output path are combined; a fragment whose generated output carries its own frontmatter block (such as Amp's globs: gate) is never composed and stays a separate file instead. Unsafe collisions involving a source root: true rule fail. Other exact or case-insensitive modular collisions remain separate and produce a warning that the last write wins wherever the filesystem treats their paths as the same.

localRoot import note: For the tools that emit a separate personal local file (Claude Code and its legacy layout: CLAUDE.local.md; Rovodev, Roo Code, Zoo Code and Devin: AGENTS.local.md; CodeBuddy Code: CODEBUDDY.local.md; Crush: CRUSH.local.md; Reasonix: REASONIX.local.md; Qwen Code: .qwen/QWEN.local.md), rulesync import also reads that file back as a localRoot: true rule under .rulesync/rules/, keeping the tool-side basename. The imported rule's targets is scoped to the tool it was imported from, not "*" — a wildcard would spread the personal content into other tools' committed root files on the next generate (tools without a separate local file append localRoot bodies to their root file), and importing from several tools would otherwise produce conflicting wildcard localRoot rules. Widen targets by hand if you do want the content shared. The same scoping applies to rulesync convert: converting to a different tool drops the source tool's personal local file rather than folding it into the destination's root file. The derived .gitignore covers the imported copy via .rulesync/rules/*.local.md; run rulesync gitignore after a first import if the project's .gitignore has not been generated yet, so the personal content stays untracked. Project scope only, like localRoot generation itself.

AGENTS.md standard note (agentsmd): Nested AGENTS.md files are the standard's only scoping mechanism — agents read the nearest file in the directory tree, so the closest one wins. Rulesync writes them from agentsmd.subprojectPath and, on import, discovers them by scanning the project for **/AGENTS.md. The path can also be derived from the rule's globs: set agentsmd.subprojectPath: "auto" on one rule, or deriveSubprojectPathFromGlobs: true in rulesync.jsonc for every non-root rule. The derived directory is the longest leading run of wildcard-free path segments that every glob shares, minus a trailing file name (["packages/api/**/*.ts"] → packages/api, ["path/to/*"] → path/to), and it must be the same for all of the rule's globs. A wildcard-free final segment is read as a file name, not a directory (["packages/api"] → packages), so write a directory as packages/api/**. Globs with no such prefix (["**/*.ts"], ["*.md"], none at all), globs that disagree (["packages/api/**", "packages/web/**"]), negations (!…), absolute paths, .. segments, and brace expansion in the first segment ({a,b}/**) derive nothing: the rule keeps its default modular placement. Only a rule that asked with "auto" is warned about, once, naming the file; with deriveSubprojectPathFromGlobs the fallback is silent, since most rules carry activation globs such as ["src/**/*.ts", "test/**/*.ts"] that were never meant to name a directory. An explicit agentsmd.subprojectPath always wins over the derived value, an explicit "" opts a single rule out of the config option (default placement, nothing derived), and root: true rules never nest. Two rules that resolve to the same directory — derived, explicit, or one of each — collide exactly as two explicit subprojectPath rules do today (composed in source order for agentsmd; see each target's collision policy). Note that globs keeps its existing meaning for the targets that read it as a conditional-activation hint (Cursor, Cline, Kiro fileMatch, and others): derivation only adds where the file is placed, it does not change what the field means. Derived nested files are written through the same path as explicit ones, so generate --delete treats the two identically: it sweeps the .agents/memories/*.md file the rule used to produce (run it once after turning the option on), and it leaves the nested file on disk if the rule is later removed, exactly as described above. Hidden directories (other tools' generated output, including rulesync's own) are skipped at any depth, as are node_modules/ and __pycache__/. Build, vendoring and scratch directories (vendor/, third_party/, dist/, build/, out/, target/, coverage/, tmp/, temp/, venv/) are skipped at the project root only, because a top-level build/ is a build directory while packages/build/ is a real subproject. Beyond those names, the scan honors your .gitignore: a file git does not track is not your project's source, and copying a vendored dependency's rule file into version-controlled .rulesync/rules/ would hand third-party instructions to every tool — including the ones that concatenate non-root rules into a single always-loaded file. (Ignore rules come from the .gitignore files at and below the output root — a parent repository's rules are not consulted, so running against a subdirectory only sees that subdirectory's own. The test is applied to the directories above each file, not the file itself, so the **/AGENTS.md entry that rulesync gitignore writes for its own output does not disable the scan; the flip side is that ignoring one individual AGENTS.md no longer keeps it out of the import.) Symbolic links are not followed, so a link committed to a repository cannot pull a file from outside the project into version-controlled .rulesync/. The scan is import-only: a nested file rulesync did not write is never removed by --delete. That means deleting the rulesync rule stops the reference from being listed in the root AGENTS.md, but leaves the subproject file itself on disk — where agents still read it, since the nearest file wins. Remove it by hand. Each discovered file is imported to .rulesync/rules/<directory-with-hyphens>.md (e.g. packages/api/AGENTS.md → packages-api.md) carrying agentsmd.subprojectPath, so the next generate puts it back where it came from. A subproject that would claim the reserved overview.md name gets an -agents suffix instead, so the root rule is never overwritten; any other pair of sources deriving the same name is reported at import time, since only the last one survives. Import always rewrites .rulesync/rules/, so a subproject whose derived name matches a rule file you wrote by hand replaces it — pick distinct names, or keep hand-written rules out of the derived namespace. See https://agents.md/.

Kiro note: Kiro reads steering files from .kiro/steering/*.md and uses an inclusion frontmatter block to decide when each is loaded (always, fileMatch with a fileMatchPattern, manual, or auto — which auto-includes the file when a request matches its companion description, keyed by name). Rulesync derives this for non-root steering files: an explicit kiro.inclusion block round-trips as-is (carrying name/description through for auto); otherwise specific (non-wildcard) globs map to inclusion: fileMatch (a single glob is written as a string and multiple as a YAML array, both of which Kiro accepts), so the rule applies only to matching files instead of always; otherwise the file stays always-on and is written without a frontmatter block (Kiro's no-frontmatter default). The root overview index is always written plain so Kiro always loads it. A rule carrying the shared directory-scoping carrier agentsmd.subprojectPath is written to <dir>/AGENTS.md instead of .kiro/steering/: Kiro CLI 2.18.0 and IDE 1.0.309 load AGENTS.md as steering context from anywhere in the workspace tree, so a directory-scoped rule reaches only the subtree it applies to rather than being flattened into an always-loaded steering file. A nested file is written plain (no inclusion block — that frontmatter belongs to .kiro/steering/*.md), and imports back as a kiro-targeted rule named after its directory (services/api → services-api-kiro.md). Like every other nested scan, discovery is import-only: the matches are hand-authored files outside a rulesync-owned directory, so generate --delete never sweeps them. Nesting is project scope only — under ~/.kiro/steering/ there is no workspace tree to scope against, so subprojectPath is ignored there. In global mode (--global), steering is written to ~/.kiro/steering/ with the root rule as ~/.kiro/steering/product.md (Kiro does not read ~/AGENTS.md, so the project-scope root AGENTS.md is not used at the home level), and global MCP is written to ~/.kiro/settings/mcp.json.

Grok CLI note: Grok Build writes the root rule to the auto-loaded AGENTS.md (project) / ~/.grok/AGENTS.md (global, via --global), and non-root rules to .grok/rules/*.md (project) / ~/.grok/rules/*.md (global). Grok scans that directory flat and in name order, alongside the AGENTS.md family — earlier Rulesync versions folded every topic rule into the single root file, which matched Grok 0.2.54 but not the current release, so regenerate to split them back out. Non-root files carry no frontmatter. Because this is a directory Grok defines rather than one Rulesync invented, a project may already have hand-written files there: Rulesync owns it from now on, so --delete removes anything in it — ~/.grok/rules/ included, in global mode — that .rulesync/rules/ does not produce. Move those files into .rulesync/rules/ first.

Kilo Code note: Kilo writes the root rule to the auto-loaded AGENTS.md and non-root rules to .kilo/rules/*.md. Because Kilo v7 does not auto-load files under .kilo/rules/, Rulesync also registers each generated non-root rule file in the instructions array of the shared kilo.jsonc (the root AGENTS.md is auto-loaded and is therefore not registered). This merge is non-destructive: existing keys such as mcp, tools, and permission are preserved. Within the instructions list rulesync owns the entries under .kilo/rules/ — that subset is rebuilt from the current generate, so deleting a rule also drops its registration — while entries outside it pass through verbatim; the result is deduped and sorted.

In global mode (--global), Kilo's own layout is asymmetric: the root rule goes to ~/.config/kilo/AGENTS.md, while non-root rules go to ~/.kilo/rules/*.md — the same .kilo-relative path the skills adapter uses in both scopes. Global rules need no instructions registration, because Kilo auto-discovers every ~/.kilo/rules/*.md on config load; writing the files is enough, and no global kilo.jsonc is touched by the rules feature.

Kimi Code note: Kimi Code reads .kimi-code/AGENTS.md at project scope and ~/.kimi-code/AGENTS.md at user scope. When KIMI_CODE_HOME is set, Rulesync follows Kimi and resolves every global Kimi-specific file (AGENTS.md, mcp.json, config.toml, skills/, and agents/) under that custom data root; the shared ~/.agents/skills/ and ~/.agents/agents/ discovery roots remain under the user's real home directory. Because Kimi has no dedicated directory for topic-based instruction files, Rulesync folds every non-root rule body into that single file. See the Kimi Code agents and instruction-files docs and environment-variable docs. The kimi-code-plugin target instead folds every rule into a plugin bundle's SYSTEM.md (project scope only; localRoot rules are not packaged); see Plugin Packaging.

deepagents note: deepagents-cli (dcode) reads .deepagents/AGENTS.md at project scope and ~/.deepagents/agent/AGENTS.md at user scope. When DEEPAGENTS_HOME is set, Rulesync follows dcode and treats that directory as the profile root itself, so every global deepagents file moves under it with the .deepagents segment dropped: $DEEPAGENTS_HOME/agent/AGENTS.md, $DEEPAGENTS_HOME/agent/skills/, $DEEPAGENTS_HOME/agent/agents/, $DEEPAGENTS_HOME/.mcp.json, $DEEPAGENTS_HOME/hooks.json, and $DEEPAGENTS_HOME/config.toml. The agent segment is dcode's default agent profile; a different [agents].default in config.toml is not followed. See Tool home overrides for how the variable is validated.

OpenCode note: OpenCode writes the root rule to the auto-loaded AGENTS.md and non-root rules to .opencode/memories/*.md. Because OpenCode auto-loads only the root AGENTS.md plus files explicitly listed in the instructions array of opencode.json (it does not auto-discover a rules directory), Rulesync also registers each generated non-root rule file in the instructions array of the shared opencode.json/opencode.jsonc (the root AGENTS.md is auto-loaded and is therefore not registered). The same applies in global mode (via --global): OpenCode reads instructions from the global ~/.config/opencode/opencode.json too, so global non-root rules are written to ~/.config/opencode/memories/*.md and registered there (entries relative to the config file's directory, e.g. memories/style.md) instead of being dropped. This merge is non-destructive: existing keys such as mcp, tools, and permission are preserved. Within the instructions list rulesync owns the entries under its managed rules directory (.opencode/memories/, or memories/ in the global config) — that subset is rebuilt from the current generate, so deleting a rule also drops its registration — while entries outside it pass through verbatim; the result is deduped and sorted.

MiMo Code note: MiMo Code (XiaomiMiMo/MiMo-Code) is an OpenCode distribution, so the mimocode target reuses the OpenCode behavior above with MiMo's paths: the root rule goes to AGENTS.md (global: ~/.config/mimocode/AGENTS.md), non-root rules to .mimocode/memories/*.md (global: ~/.config/mimocode/memories/*.md), and they are registered in the instructions array of .mimocode/mimocode.json/.mimocode/mimocode.jsonc (global: ~/.config/mimocode/mimocode.json(c)). MiMo Code resolves a relative instructions entry against the project directory rather than the config file's directory, so global entries are written home-rooted (e.g. ~/.config/mimocode/memories/style.md), which MiMo Code expands.

GitLab Duo CLI note: GitLab Duo reads project custom rules from .gitlab/duo/chat-rules.md and user rules from ~/.gitlab/duo/chat-rules.md (global, via --global). The file is plain Markdown with no frontmatter and there is no rules directory, so the root rule and every non-root rule are folded into that one file. GitLab Duo also reads AGENTS.md, but that file belongs to the agentsmd target, so the gitlabduo target does not write it.

Qwen Code note: Qwen Code writes the root rule to the auto-loaded QWEN.md (project) / ~/.qwen/QWEN.md (global, via --global) as plain Markdown, and non-root rules to its path-based context-rule directory .qwen/rules/ (project) / ~/.qwen/rules/ (global). Each non-root rule is a Markdown file with optional YAML frontmatter: Rulesync maps globs ⇄ Qwen's paths (a picomatch glob array) and description ⇄ description. A rule with specific paths is conditional — Qwen lazily injects it only when the model touches a matching file — while a rule without paths (empty or wildcard **/*/* globs) is a baseline rule loaded at session start and is written as plain Markdown with no frontmatter block. The .qwen/rules/ directory supersedes the legacy .qwen/memories/ import surface, so each rule is emitted to exactly one location; the root QWEN.md is unchanged. A localRoot: true rule is emitted to .qwen/QWEN.local.md (project scope only) — Qwen Code v0.16.2's personal project context file, loaded after the shared QWEN.md so it can override team instructions; the file is covered by the derived .gitignore since Qwen Code does not gitignore it for you. See the Qwen Code memory/context docs.

Cline note: Cline writes the root rule to the auto-loaded AGENTS.md (project) as plain Markdown, and non-root rules to its flat .clinerules/ directory. Each non-root rule is a Markdown file with optional YAML frontmatter for conditional activation: Rulesync maps globs ⇄ Cline's paths (a glob array; the rule loads only when a matching file is in context) and description ⇄ description. A rule with specific globs emits paths; a rule with universal globs (**/* or *) emits alwaysApply: true (always load); a rule without globs is written as plain Markdown with no frontmatter block (always active). In global mode (via --global), the root rule is written to the cross-tool ~/.agents/AGENTS.md (Cline CLI v3.0.15+) as plain Markdown, and non-root rules go to ~/Documents/Cline/Rules/*.md — the global modular-rules directory both the VS Code extension and the SDK/CLI read — with the same conditional-frontmatter conversion project rules get. See the Cline rules docs.

Roo / Zoo Code note (rules): At project scope both tools write the root rule to the auto-loaded workspace-root AGENTS.md and non-root rules to .roo/rules/*.md. In global mode (via --global) the root rule goes to ~/.roo/rules/AGENTS.md, not ~/AGENTS.md: the agent-rules loader discovers AGENTS.md / AGENT.md / AGENTS.local.md in the workspace only (the current directory, plus subdirectories holding a .roo folder when enableSubfolderRules is on), so a home-directory AGENTS.md is never read, while ~/.roo/rules/ is loaded before the workspace rules. The root rule therefore joins the non-root files in the directory the extension does load, and keeps the AGENTS.md basename so it still reads as the root overview — which is also what identifies it as the root rule on import --global. A localRoot rule stays project-only: AGENTS.local.md is a workspace file, so global generation skips it with a warning. See the Roo Code custom instructions docs.

Response language note: When rulesync.jsonc sets the root language key, the generated root rule file (the one built from the root: true rule — AGENTS.md, GEMINI.md, .github/copilot-instructions.md, .cursor/rules/overview.mdc, ...) ends with a thematic break followed by You must always answer in <language>. On the other hand, reasoning (thinking) should be in English to improve token efficiency. The block is body content, never frontmatter, so it works for every format; nested rules do not get it. Claude Code is the exception: it has a native language setting, so Rulesync writes "language": "<name>" into .claude/settings.local.json (project) or ~/.claude/settings.json (global) through the shared settings gateway instead of touching CLAUDE.md. On import the block is recognized for every supported language and stripped from the rulesync rule, so the language lives in rulesync.jsonc alone. See Response Language.

Cursor note (rules): Cursor's Always Apply template is alwaysApply: true with globs omitted, and the Cursor rules docs say globs are ignored once that flag is set. The two together are a semantic conflict Cursor's own staff advise against — some versions classify such a rule by its glob and treat it as Agent Requested instead of Always — so Rulesync keeps them apart on both sides of the round trip. Import keeps the canonical globs: ["**/*"], so the tools that have no alwaysApply equivalent still apply the rule everywhere, but records cursor.globs: []. Generate drops a universal glob (**/* or *) from an Always Apply rule wherever it came from, so the .mdc goes back out with no globs line. A specific glob is kept even alongside the flag: it says something the flag cannot, so it stays the author's to keep. An earlier Rulesync version invented globs: **/* on import, so an import-then-generate round trip rewrote hand-written Always Apply rules; just regenerate after upgrading and the stray glob comes back out, whether it sits in cursor.globs or in the canonical globs. Rules that are not Always Apply are untouched: an empty globs stays empty for both fields.

The two ways cursor.globs can be absent mean different things, here and everywhere else a tool-specific globs appears. Omitting the key means "no Cursor-specific opinion", so the canonical globs is used; an explicit [] means "this rule has no Cursor globs" and overrides the canonical value. That override is one-way and silent — a rule with canonical globs: ["docs/**"] and cursor: { alwaysApply: true, globs: [] } applies to the whole project in Cursor while the canonical file reads as if it were scoped to docs/. Deleting cursor.globs therefore does not by itself remove a glob — the canonical value takes over instead. On an Always Apply rule that is invisible when the canonical value is universal, since generate drops it either way, but a specific canonical glob comes back out. Note also that cursor.globs: [] on a rule that has no alwaysApply leaves description as the only frontmatter key, which makes the rule Agent Requested — Cursor reads the description and decides whether to pull the rule in. Only a rule with no description either ends up with empty frontmatter, which is Apply Manually: it applies solely when it is @-mentioned.

Cursor rules are project scope only. The Cursor rules docs list four kinds of rules — Project Rules in .cursor/rules, User Rules, Team Rules and AGENTS.md (written by the agentsmd target, not cursor) — and none of them is a file in the home directory: User Rules are entered in Cursor's Customize → Rules settings and Team Rules are managed from the Cursor dashboard, with no documented on-disk format or API that Rulesync could write. The Cursor CLI docs likewise document only .cursor/rules plus the project-root AGENTS.md and CLAUDE.md. There is therefore no ~/.cursor/rules equivalent to generate, and rulesync generate --global --targets cursor --features rules skips the feature with a warning instead of writing a directory Cursor never reads. For rules that should apply in every project, paste them into User Rules by hand, or generate global rules for a tool that has a home-directory rules file (see the supported tools matrix).

Warp note (rules): Warp reads project rules from the root AGENTS.md (or the back-compat WARP.md) and does not scan a modular rules directory, so non-root rule bodies are folded into the single root ./AGENTS.md. Directory-scoped rules are the exception: Warp also reads AGENTS.md files placed in subdirectories "for more targeted guidance", applying the root file plus the current directory's one, with the subdirectory's rules taking precedence over the root's — so a non-root rule carrying agentsmd.subprojectPath (explicit, or derived from globs via "auto" / deriveSubprojectPathFromGlobs) is emitted as a nested <subprojectPath>/AGENTS.md (project scope only) instead of being folded. Note the narrower reach this buys: Warp reliably applies a nested file only while the current directory is that subdirectory (other subdirectories are included on a best-effort basis when you edit files there), whereas a folded rule is read from anywhere in the project — keep a rule unscoped if it must always apply. Because Warp loads those files itself, the root AGENTS.md carries no reference list pointing at them. On import, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by --delete) and land in .rulesync/rules/<directory-with-hyphens>.md with targets: ["*"], exactly as described in the Vibe Code note below — and, likewise, a nested file emitted from a rule scoped with targets: ["warp"] is still read by every other AGENTS.md reader working in that directory. In global mode (via --global), the root rule is written to the cross-tool ~/.agents/AGENTS.md — Warp's third rule source alongside project and Warp Drive rules, also used from remote hosts in SSH sessions — with the same folding (a directory-scoped rule folds there too, since the global root has no workspace to nest under). Warp gives a legacy WARP.md priority over AGENTS.md in the same directory, so when a WARP.md exists next to the generated root or nested AGENTS.md, Warp never reads that AGENTS.md; rulesync warns on generate but neither writes nor deletes WARP.md — move its content into .rulesync/rules/ and remove or rename it. A WARP.md is not imported. Other targets (e.g. Cline) own the same global path; as with the shared project-root AGENTS.md, each target regenerates the file per its own semantics. See the Warp rules docs and file locations.

Crush note (rules): Crush reads project context from the root CRUSH.md (it also opportunistically reads crush.md/Crush.md, their .local variants, and the cross-tool AGENTS.md/CLAUDE.md/GEMINI.md/.cursorrules/.cursor/rules//.github/copilot-instructions.md, all owned by other targets) and does not scan a modular non-root instructions directory, so non-root rule bodies are folded into the single root ./CRUSH.md. A localRoot: true rule is the exception: it is written to ./CRUSH.local.md, one of Crush's default context paths, rather than folded into the committed root file — project scope only, since Crush's global context paths have no .local variant. Crush does not gitignore that file for you, so the derived .gitignore covers it. In global mode (via --global), the root rule is written to ~/.config/crush/CRUSH.md with the same folding. See the Crush config source and context-file loading.

Pi note: Pi writes the root rule to the auto-loaded AGENTS.md (project) / ~/.pi/agent/AGENTS.md (global, via --global) as plain Markdown, and folds non-root rules into that single file (Pi has no modular rules directory). Pi additionally loads two system-prompt instruction files. .pi/APPEND_SYSTEM.md (project) / ~/.pi/agent/APPEND_SYSTEM.md (global) appends to the default system prompt, and Rulesync emits it from any rule that opts in via a pi.systemPrompt: append frontmatter block — those rule bodies are routed to APPEND_SYSTEM.md instead of AGENTS.md, multiple opted-in rules concatenate in source order, and the file is managed by generate/import/delete like the root file (note: if you hand-authored .pi/APPEND_SYSTEM.md before this feature existed, generate --delete for the pi target now treats it as a managed path and removes it unless a rule opts in — import it first to convert it into a canonical rule). The opt-in is ignored on the root: true rule, which always stays on AGENTS.md (routing the root away would leave the context file without a merge target). .pi/SYSTEM.md (project) / ~/.pi/agent/SYSTEM.md (global) replaces the default system prompt entirely — which silently disables Pi's built-in tool instructions — so Rulesync deliberately never emits it and leaves it to be authored by hand. Example:

yaml
---
targets: ["pi"]
description: "House style for the system prompt"
pi:
  systemPrompt: append # routes this rule's body to .pi/APPEND_SYSTEM.md / ~/.pi/agent/APPEND_SYSTEM.md
---

Pi tries AGENTS.override.md before AGENTS.md, AGENTS.MD, CLAUDE.md and CLAUDE.MD in every directory it scans (including the global ~/.pi/agent/ one), loading it instead of the others from that directory. Set pi.contextFile: override on the root: true rule to emit the root context file under that name — useful when another target owns the shared AGENTS.md, or when a CLAUDE.md sits next to it and Pi should deterministically prefer Rulesync's output. Because Pi folds every rule body into the root context file, one opted-in root rule decides for the whole Pi output: the flag is applied to every other Pi rule (root ones included), and setting it only on a non-root rule is ignored with a warning — emitting both files would hide everything left in AGENTS.md. AGENTS.override.md is Pi-exclusive, so it is imported and deleted like the root file, and toggling the flag off cleans it up. The project-root AGENTS.md is never deleted on Pi's behalf, with or without the flag: agentsmd, codexcli, warp and others write that same path, so the pi target leaves a stale one behind rather than removing another target's output (the global ~/.pi/agent/AGENTS.md is Pi-exclusive and is still cleaned up). Example:

yaml
---
root: true
targets: ["pi"]
pi:
  contextFile: override # emits AGENTS.override.md instead of AGENTS.md
---

See the Pi usage docs and the context-file discovery in the Pi source.

oh-my-pi note: oh-my-pi (omp, can1357/oh-my-pi) is a Pi fork with its own .omp/ configuration tree. Rulesync writes the root rule as plain Markdown to .omp/AGENTS.md (project) / ~/.omp/agent/AGENTS.md (global, via --global), the native context file, which oh-my-pi reads in place of a root AGENTS.md at the same depth, and each non-root rule to .omp/rules/<name>.md / ~/.omp/agent/rules/<name>.md with description, globs and alwaysApply frontmatter. oh-my-pi drops a rule that is neither alwaysApply: true nor described, so a rule whose globs are empty or match everything (**/*) is written as alwaysApply: true, and a glob-scoped rule without a description gets one generated from its globs. oh-my-pi reads only the top level of its rules directory, so a nested rule (.rulesync/rules/frontend/style.md) is flattened to .omp/rules/frontend-style.md. Import reads the top-level *.md and *.mdc files oh-my-pi reads, and an imported .mdc rule becomes a .md rulesync rule; generation always writes .md, so delete the original .mdc file after importing it (oh-my-pi treats foo.md and foo.mdc as the same rule). Flattening does not round-trip: importing .omp/rules/frontend-style.md creates .rulesync/rules/frontend-style.md beside the nested source rule, so keep only one of them. Nested files under .omp/rules/ are not imported (oh-my-pi does not read them), but generate with --delete still removes nested .md files there, which earlier Rulesync versions wrote. .omp/RULES.md (sticky rule) and .omp/SYSTEM.md (system prompt replacement) are never emitted. Global paths assume the default profile (~/.omp/agent); a named profile (OMP_PROFILE) or a PI_CODING_AGENT_DIR override is not followed. See the oh-my-pi context-file docs.

Factory Droid note (rules): Beyond the root AGENTS.md (project) / ~/.factory/AGENTS.md (global, via --global) and non-root rules in .factory/rules/*.md, Factory Droid also loads two further fixed files on its own, and Rulesync emits each from any non-root rule that opts in via the factorydroid.channel frontmatter key. Opted-in rule bodies are routed to the channel's file instead of AGENTS.md/.factory/rules/*.md, multiple opted-in rules concatenate in source order, and the file is managed by generate/import/delete like the root file:

  • channel: design → DESIGN.md at the project root: "Always-on design-system, UX, visual, and interaction guidance", loaded independently of AGENTS.md's coding guidelines.
  • channel: threat-model → .factory/threat-model.md: the attack-surface map Factory's Security Review reads ("if .factory/threat-model.md exists, Droid uses it as the attack-surface map"). On import it lands in .rulesync/rules/threat-model.md.

Both files are excluded from the TOON reference list Factory Droid's root file otherwise embeds for non-root rules, since Factory Droid already loads them itself and listing them there would double them up. The opt-in is ignored on the root: true rule and in global mode: Factory's docs describe root and nested DESIGN.md files like AGENTS.md, and the threat model only as a repository file, but document no personal/global home-directory equivalent for either. Example:

yaml
---
targets: ["factorydroid"]
description: "Color palette and spacing scale"
factorydroid:
  channel: design # routes this rule's body to DESIGN.md
---
yaml
---
targets: ["factorydroid"]
description: "Trust boundaries and sensitive data flows"
factorydroid:
  channel: threat-model # routes this rule's body to .factory/threat-model.md
---

A third value, channel: output-style, writes the rule as one of Factory Droid's custom output styles — response-presentation instructions a user picks in /settings — instead of a concatenated file: each opted-in rule becomes its own .factory/output-styles/<rule file name>.md (~/.factory/output-styles/ with --global, the one channel that also works in global mode, since Factory documents the user scope too). The rule's description becomes the style's description frontmatter (shown below its name in the picker), and the optional factorydroid.name its name (the picker name, which Droid otherwise takes from the file name; generate warns when name is set without channel: output-style, where it is ignored). The file is excluded from the TOON reference list like the other channels, and import brings each style back as .rulesync/rules/<file name>.md with the same frontmatter. Because Droid loads only direct .md children of output-styles/ and reserves its built-in Default and Concise styles, generate fails for an output-style rule nested in a subdirectory of .rulesync/rules/ or whose style name is default / concise (case-insensitive). Unlike .factory/rules/, the directory is not swept by generate --delete: it also holds styles written by hand (including personal ones in ~/.factory/output-styles/) that Rulesync cannot tell apart from its own, so a style no rule produces anymore — after renaming or removing the rule — has to be deleted by hand (or the next import brings it back as a rule). For the same reason rulesync gitignore adds no entry for it, and generate overwrites a hand-written style of the same file name without a warning — with --global that includes a personal style in ~/.factory/output-styles/, so pick a rule file name no style of your own uses. Import brings back every .md file in the directory, hand-written styles included, but skips any style reached through a symbolic link — a linked file or a linked output-styles directory, at either scope — so a link's target is never copied into .rulesync/rules/; a style whose file name matches a .factory/rules/*.md rule imports to the same .rulesync/rules/ path, and only one of the two is kept (a warning names both). Give an output-style rule targets: ["factorydroid"], as in the example below: with any other target it also reaches those tools as an ordinary rule, putting presentation instructions in their project rules, and generate warns about it. Selecting the style (the outputStyle setting) stays with the user. Example:

yaml
---
targets: ["factorydroid"]
description: "Put findings before the summary"
factorydroid:
  channel: output-style # writes .factory/output-styles/<this file name>.md
  name: "Review Notes"
---

See the Factory Droid AGENTS.md configuration docs, the Security Review docs and the output styles docs.

Devin note: The root rule is emitted to the project-root AGENTS.md — the file Devin CLI / Devin Local actually reads (its rules page does not list .devin/rules/ among its sources) — as plain markdown, while non-root rules keep going to .devin/rules/*.md, the Devin Desktop Cascade directory whose trigger activation modes (always_on, glob, manual, model_decision) are driven by the devin frontmatter block. Global mode mirrors that layout: the root rule is a plain ~/.config/devin/AGENTS.md, and non-root rules are emitted one file per rule into ~/.devin/rules/*.md with the same trigger/globs frontmatter. Note the directory split — the per-rule global directory is the home ~/.devin/, not the ~/.config/devin/ tree the global root and Devin's other global surfaces use; that is what the rules page documents (~/.devin/rules/*.md, ~/.devin/global_rules.md). The devin-plugin target writes the root rule to a plugin bundle's AGENTS.md and other rules to its rules/<name>.md (project scope only; localRoot rules are not packaged). See the plugin packaging guide.

Amp note: Amp gates an @-mentioned guidance file on globs: YAML frontmatter — the file is loaded only after Amp has read a file matching one of the globs, and without the frontmatter it is always loaded. Rulesync therefore emits each non-root rule's globs as that frontmatter on the generated .agents/memories/*.md file (in addition to the advisory applyTo value in the root file's TOON table, which Amp does not enforce), and restores it into the canonical globs on import. Amp implicitly prefixes each glob with **/ unless it starts with ./ or ../, so canonical globs pass through verbatim. See Globs in AGENTS.md.

JetBrains AI Assistant note: AI Assistant reads every .aiassistant/rules/*.md file (project scope only; there is no root file, so the root rule is written there as its own file too) and stores each rule's type — the "Rule type" selector in the IDE — in the file itself, as a leading metadata block: --- / apply: <type> / ---, where <type> is one of always, manually, by model decision (with an instructions: <condition> line), by file patterns (with a patterns: <glob>, <glob> line — comma-separated, not a YAML list) or off. The block is line-based (<field>: <value>, split on the first colon) rather than YAML, and the values are matched case-sensitively. A file with no apply: line is typed Off when the plugin collects rules for a chat, i.e. it is never attached automatically (the Settings table still shows it as "Always"), which is why Rulesync always writes one. The type is derived from the canonical frontmatter unless aiassistant.apply sets it explicitly: globs with no universal entry become apply: by file patterns with the globs as patterns; a description on a rule without any globs becomes apply: by model decision with the description (flattened to one line) as instructions; everything else — the root rule, any list containing a universal glob (**/*, *) and rules with neither — is apply: always. Because the plugin splits patterns on every comma, a brace alternation such as src/**/*.{ts,tsx} is expanded into one pattern per branch (src/**/*.ts, src/**/*.tsx) before it is written, and a glob that still contains a comma — or any value containing ---, which the plugin reads as the end of the block — is written with a warning. An explicit aiassistant.apply is written verbatim even when it is not one of the five types (with a warning — the current plugin treats such a rule as Off), and an explicit by file patterns writes every glob, universal ones included, as patterns. On import the block is stripped from the body and mapped back: by file patterns → globs (split on the commas outside brace groups), by model decision → description, always → globs: ["**/*"]. aiassistant.apply is kept whenever the next generate would not derive the same type on its own — manually / off and unrecognized values, which have no canonical counterpart, and a by file patterns / by model decision block whose patterns / instructions line is missing or universal — so a regenerate reproduces the file. This format comes from the plugin itself (AiRulesService and its message bundle, verified on build 262.10968.75) — the project rules docs describe the rule type only as an IDE setting.

Trae note: Trae (ByteDance's AI IDE, called "TraeCode" in its docs) reads every .trae/rules/*.md file (project scope only; there is no root rule file, so the root rule is written there as its own file too, the same layout as Cursor). Each file carries Cursor's frontmatter triple, which selects one of Trae's four application modes: alwaysApply: true (always), alwaysApply: false with globs (specific files), alwaysApply: false with description (apply intelligently), and alwaysApply: false alone (manual, via #Rule in chat). The mode is derived from the canonical frontmatter unless trae.alwaysApply sets it: globs with no universal entry give alwaysApply: false plus the globs, written unquoted and comma-separated as Trae's editor writes them (globs: *.ts,src/**/*.tsx), with brace alternations expanded first because Trae splits the value on every comma (src/**/*.{ts,tsx} is written as src/**/*.ts,src/**/*.tsx; a glob that still holds a comma, such as one past the expansion cap, is written as is with a warning); a description on a rule without any globs gives alwaysApply: false plus the description; everything else — the root rule, any list containing a universal glob (**/*, *) and rules with neither — is alwaysApply: true, written without globs (an explicit trae.alwaysApply: true keeps the specific globs, and trae.alwaysApply: false keeps every glob, universal ones included, so a file-scoped **/* rule does not turn into a manual one). On import alwaysApply: true without globs maps to globs: ["**/*"], an unquoted globs value is read literally (quoted before YAML parsing, so {src,lib}/** or !**/test/** do not break the import; YAML null and boolean keywords and block scalars are left to YAML) and split on the commas outside brace groups (a YAML flow list is accepted too; a value that only starts with [, such as the character-class glob [abc]*.ts, is read as a glob), and trae.alwaysApply is kept only when the next generate would not derive the same value (a manual rule, or alwaysApply: true beside specific globs). Trae's scene field (scene: git_message also applies the rule when Trae generates a Git commit message, as in the git-commit-message.md its Source Control panel creates) round-trips through trae.scene. As for any rules directory, generate --delete removes .trae/rules/*.md files that no Rulesync rule produces, so import a hand-made rule such as git-commit-message.md first if you want to keep it. Global rules are not generated: Trae documents ~/.trae/user_rules only as a path, without saying whether it is a file or a directory or what goes in it. Trae also reads a .trae/rules/ folder in any project subdirectory and applies it when files in that directory are read or mentioned, so a non-root rule carrying agentsmd.subprojectPath (explicit or derived from globs) is written to <subprojectPath>/.trae/rules/<name>.md instead (project scope only), with the same frontmatter mapping and its globs left as written. On import, those folders are found by a project scan (hidden directories, node_modules/, __pycache__/ and root build/vendoring directories are skipped, symbolic links are not followed, and git-ignored directories are skipped — tested down to <dir>/.trae/ only, so the **/.trae/rules/ entry rulesync gitignore writes does not disable the scan) and each file lands in .rulesync/rules/ under its own name (a subfolder inside the nested rules directory is kept, as for the root one) with targets: ["trae"] and the subprojectPath carried, so the next generate puts it back without other tools picking it up; two files with the same name in different rules directories collide, which import reports. Like every other nested scan this is import-only: generate --delete never removes a nested .trae/rules/ file, so delete one by hand after dropping its rule. MCP servers go to the project .trae/mcp.json as a plain mcpServers object (user-level servers live in the IDE settings UI; other top-level keys already in the file are kept). Trae expands only ${workspaceFolder}, so any other ${...} reference in env or headers (such as ${API_KEY}) reaches Trae as literal text and Rulesync warns about it. Skills go to .trae/skills/<name>/SKILL.md (project) / ~/.trae/skills/<name>/SKILL.md (global) in the Agent Skills format. See the Trae rules, skills and MCP docs.

Junie note: Junie CLI resolves project guidelines in order — .junie/AGENTS.md → root AGENTS.md combined with .junie/playbook.md and every .junie/rules/*.md → the legacy .junie/guidelines.md / .junie/guidelines/. The multi-file branch is unreachable whenever .junie/AGENTS.md exists, because that file "is used exclusively and no other guidelines files are combined with it". Rulesync therefore writes the root rule to .junie/AGENTS.md (project) / ~/.junie/AGENTS.md (global, via --global) and folds non-root rules into that single file — lossless, since Junie loads it in full. Emitting .junie/rules/*.md beside it would produce files Junie never reads, and moving the root output to the project-root AGENTS.md would change every existing output path and collide with the agentsmd target, so the fold stays. The multi-file branch is read on import, but only while .junie/AGENTS.md is absent — exactly when Junie itself reads it. Hand-authored .junie/rules/*.md (that directory itself, not a tree below it) and .junie/playbook.md are then imported as non-root rules. They are import-only read roots — Rulesync never generates them, never deletes them as orphans, and never adds them to .gitignore — so the generate that follows folds their content into .junie/AGENTS.md and leaves the originals untouched. Later imports skip them with a single warning naming up to ten skipped files (the rest are folded into an and N more count): re-reading rules the root file already contains would fold the same content in again on every import/generate cycle. Only .junie/AGENTS.md closes that gate — the legacy .junie/guidelines.md does not, since Junie ranks it below the multi-file branch and Rulesync never writes it, so nothing has been folded into it. In that legacy layout both branches are imported at once, so a .junie/rules/overview.md and the legacy root claim the same .rulesync/rules/overview.md; the root file wins, and the usual collision warning names the file that lost. Delete them once you have checked the fold, since Junie stops reading them the moment .junie/AGENTS.md exists. In that pre-.junie/AGENTS.md layout Junie's root file is the project-root AGENTS.md, which belongs to the agentsmd target. rulesync import takes one target at a time, so run rulesync import --targets agentsmd before rulesync import --targets junie if you want a root rule as well: a junie-only import of that layout yields non-root rules alone, and the next generate writes .junie/AGENTS.md — at which point Junie stops reading the project-root AGENTS.md whose content was never imported. The legacy .junie/guidelines.md is still accepted as an import fallback. Earlier Rulesync versions emitted non-root rules to .junie/memories/*.md, which is not a documented Junie read path; those files are no longer generated (stale outputs stay gitignored but are not cleaned up automatically). See the Junie guidelines docs and the Junie IDE plugin docs — the IDE plugin page is where the exclusivity sentence quoted above appears.

Reasonix note: Reasonix auto-injects a hierarchical instruction document, reading its vendor-specific REASONIX.md (alongside the cross-tool AGENTS.md/CLAUDE.md) by walking user-home → ancestors → project root/local. Rulesync writes the vendor REASONIX.md at the project root (project) / ~/.reasonix/REASONIX.md (global, via --global) and folds non-root rules into that single file, since Reasonix has no modular rules directory. A localRoot: true rule goes to ./REASONIX.local.md instead (project scope only): Reasonix loads that personal variant beside each REASONIX.md in the workspace chain, ahead of it (LocalDocumentNames in internal/instruction/resolver.go), so the personal body stays out of the committed file; the derived .gitignore lists it, and rulesync import reads it back as a reasonix-scoped localRoot rule. The Reasonix home loads no local variant, so global scope has none. Directory-scoped rules are the exception: Context Engine v2 (v1.18.0) also walks from the workspace root to the target path loading per-directory instruction files (“Deeper directories beat broader directories”), so a non-root rule carrying agentsmd.subprojectPath is emitted as a nested <subprojectPath>/REASONIX.md (project scope only) instead of being folded — its paragraphs load only under that path rather than being carried on every turn. On import, nested REASONIX.md files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by --delete) and land in .rulesync/rules/<directory-with-hyphens>-reasonix.md with targets: ["reasonix"] and the subprojectPath carried, so the next generate puts them back. The -reasonix suffix and the reasonix-only targeting keep them from clobbering the AGENTS.md standard's derived names or surprising other tools with new nested files; note that a rule targeting both agentsmd and reasonix with a subprojectPath produces a nested AGENTS.md and a nested REASONIX.md in the same directory, both of which Reasonix loads — scope such rules to one target. See the Reasonix GUIDE and Context Engine v2 docs.

Vibe Code note: Vibe reads the project-root AGENTS.md (project) / ~/.vibe/AGENTS.md (global, via --global) and folds non-root rules into that single file, since it has no modular rules directory. Directory-scoped rules are the exception: Vibe's harness manager walks the directories between the workspace root and the file being read and loads every AGENTS.md it finds along the way (find_subdirectory_agents_md), injecting the result into the read_file tool's output since v2.19.1 — so a non-root rule carrying agentsmd.subprojectPath is emitted as a nested <subprojectPath>/AGENTS.md (project scope only) instead of being folded. On import, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by --delete). Unlike the Reasonix note above, the imported rule is not suffixed or target-scoped: Vibe's nested file is literally the AGENTS.md standard's own per-directory file at the same path, so it lands in .rulesync/rules/<directory-with-hyphens>.md with targets: ["*"] — importing the same file through agentsmd and vibe therefore yields one rulesync rule, not two copies that would fold duplicated content back into the same AGENTS.md. Because the emitted file is the plain per-directory AGENTS.md, a rule scoped with targets: ["vibe"] is still picked up by every other AGENTS.md reader working in that directory — Vibe's nested surface is the shared standard's file, so target-scoping it is structurally impossible. See mistral-vibe (vibe/core/config/harness_files/_harness_manager.py).

Meta Muse Code note: Muse Code walks up from the working directory to the .git boundary and loads one instruction file per directory level, preferring AGENTS.md over CLAUDE.md when both exist. The musecode target writes the root rule to the shared project-root AGENTS.md (the same file agentsmd, codexcli and others write) and folds non-root rules into it, since Muse Code has no modular rules directory. Muse Code has user/global rules, but their path is not documented, so the musecode rules target is project-scope only. See the Muse Code configuration docs.

ZCode note: ZCode (Z.ai's agentic development environment for the GLM family) reads exactly two instruction files — the user-global ~/.zcode/AGENTS.md and the workspace AGENTS.md at the project root — and appends them in that order. Its docs are explicit that it "does not merge multiple AGENTS.md files across directory levels" and "does not scan child directories", so there is no nested rules surface to emit: rulesync writes the project-root AGENTS.md (project) / ~/.zcode/AGENTS.md (global, via --global) and folds non-root rules into that single file. CLAUDE.md is deliberately not written for zcode: ZCode reads it only once, during onboarding, as a migration source. See the ZCode AGENTS.md docs.

OpenClaw note: OpenClaw (the always-on assistant gateway) injects its agent workspace's AGENTS.md into every session — it is also the only bootstrap file sub-agent sessions receive — and, when a session runs from another folder or a managed worktree, appends that folder's AGENTS.md as project context. Rulesync writes the project-root AGENTS.md (project) / ~/.openclaw/workspace/AGENTS.md (global, via --global — only the default workspace; OPENCLAW_WORKSPACE_DIR, OPENCLAW_PROFILE, OPENCLAW_STATE_DIR, agents.defaults.workspace and per-agent agents.entries.*.workspace settings that relocate it are not followed) and folds non-root rules into that single file, since OpenClaw documents no per-directory walk (when OpenClaw runs on the native Codex harness, Codex's own project-doc discovery reads the execution folder's AGENTS.md instead). OpenClaw seeds the workspace AGENTS.md with its own template on onboarding, and rulesync generate --global replaces that file wholesale — run rulesync import --targets openclaw --global first if you want to keep its content. The workspace's other prompt files (SOUL.md, IDENTITY.md, USER.md, MEMORY.md) are never touched. OpenClaw truncates each bootstrap file past agents.defaults.bootstrapMaxChars (20,000 characters by default) and all bootstrap files together past agents.defaults.bootstrapTotalMaxChars (60,000 by default), and injects a short missing-file marker when AGENTS.md is absent — so a --delete --global run that no longer produces any rule for openclaw removes the file and leaves the agent without its operating instructions. For skills, the execution folder's .agents/skills/ and the personal ~/.agents/skills/ are served by the agentsskills target; the workspace-local ~/.openclaw/workspace/skills/ and ~/.openclaw/workspace/.agents/skills/ roots are not written. See the OpenClaw system prompt, agent workspace and skills docs.

Codex CLI note: Codex CLI reads the project-root AGENTS.md (project) / ~/.codex/AGENTS.md (global, via --global) and folds non-root rules into that single file, since it has no modular rules directory (.codex/memories/ belongs to its separate auto-memory system). Directory-scoped rules are the exception: Codex loads one AGENTS.md per directory from the project root down to the working directory, deeper files later in the prompt — so a non-root rule carrying agentsmd.subprojectPath is emitted as a nested <subprojectPath>/AGENTS.md (project scope only) instead of being folded. A rule scoped only by globs (e.g. ["services/svc1/**"]) is still folded unless you opt in with agentsmd.subprojectPath: "auto" on the rule or deriveSubprojectPathFromGlobs: true in rulesync.jsonc (see the AGENTS.md standard note above for how the directory is derived). Nesting matters for size: Codex stops reading project AGENTS.md files once their combined size on the root-to-working-directory chain reaches project_doc_max_bytes (32 KiB by default) and drops the rest without telling you, so rulesync generate warns in project scope when the generated root AGENTS.md — or the root plus the nested files on one directory chain — exceeds 32,768 bytes (measured per output root, so files Codex would also load from directories above an output root are not counted). Rulesync cannot author project_doc_max_bytes yet; if you raised it in .codex/config.toml yourself, the warning still uses the default and can be ignored. The personal ~/.codex/AGENTS.md is loaded outside that budget and is not checked. On import, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by --delete, so a nested file stays on disk after you drop the rule's subprojectPath — remove it by hand) and land in .rulesync/rules/<directory-with-hyphens>.md with targets: ["*"], exactly as described in the Vibe Code note above — and, likewise, a nested file emitted from a rule scoped with targets: ["codexcli"] is still read by every other AGENTS.md reader working in that directory. See the Codex AGENTS.md guide.

Pool note: Pool (Poolside's coding agent CLI) reads the project-root AGENTS.md (project) / ~/.config/poolside/AGENTS.md (global, via --global — only the XDG-default path; Pool itself honours XDG_CONFIG_HOME upstream) and folds non-root rules into that single file, since it has no modular rules directory. Directory-scoped rules are the exception: Pool loads every AGENTS.md from the repository root down through the working directory, deeper files taking precedence — so a non-root rule carrying agentsmd.subprojectPath is emitted as a nested <subprojectPath>/AGENTS.md (project scope only) instead of being folded. On import, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by --delete). Like the Vibe Code note above, the imported rule is not suffixed or target-scoped: Pool's nested file is literally the AGENTS.md standard's own per-directory file at the same path, so it lands in .rulesync/rules/<directory-with-hyphens>.md with targets: ["*"]. Because the emitted file is the plain per-directory AGENTS.md, a rule scoped with targets: ["pool"] is still picked up by every other AGENTS.md reader working in that directory — Pool's nested surface is the shared standard's file, so target-scoping it is structurally impossible. See the Pool AGENTS.md instructions.

DeepSeek Harness note: DeepSeek Harness (dsh, DeepSeek's open-source agent harness) loads the user-global $DSH_HOME/AGENTS.md and then the project chain of instruction files from the project root (the nearest ancestor holding .git) down to the session cwd, broad to specific. Rulesync writes the project-root AGENTS.md (project) / ~/.dsh/AGENTS.md (global, via --global — only the $DSH_HOME default path) and folds non-root rules into that single file, since the harness has no modular rules directory; the user-global file has no .local.md overlay upstream, so global scope is root-only. Directory-scoped rules are the exception: a non-root rule carrying agentsmd.subprojectPath is emitted as a nested <subprojectPath>/AGENTS.md (project scope only) instead of being folded, because the harness loads it only while working under that directory. On import, nested files are discovered by the same project scan the AGENTS.md standard uses (same dependency/build-directory exclusions; import-only, never removed by --delete), and like the Pool note above the imported rule is not suffixed or target-scoped — it is literally the AGENTS.md standard's per-directory file, so it lands in .rulesync/rules/<directory-with-hyphens>.md with targets: ["*"]. The harness also reads CLAUDE.md and the AGENTS.local.md / CLAUDE.local.md overlays at every level of the project chain, collapsing a sibling that duplicates its AGENTS.md; those are the claudecode target's files, and rulesync's dsh target emits AGENTS.md only. DeepSeek Harness is a pre-1.0 developer preview, so these paths may move before its first stable release. See the agent-instructions README.

Claude Code root file note: Claude Code reads AGENTS.md instead of CLAUDE.md when the working directory (or any directory above it) has no CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md, while .claude/rules/*.md keeps loading alongside AGENTS.md (Claude Code memory docs). When another target such as agentsmd or codexcli already writes the project-root AGENTS.md, set "includeRoot": false on the claudecode target's rules options to skip the root CLAUDE.md and the personal CLAUDE.local.md while still generating .claude/rules/*.md. With delete enabled, a CLAUDE.md or CLAUDE.local.md left over from an earlier run at the project root is removed; a hand-authored .claude/CLAUDE.md is never deleted, but a warning points out that it still shadows AGENTS.md. Without delete, nothing is removed: a warning lists every project-root CLAUDE.md / CLAUDE.local.md and .claude/CLAUDE.md that still exists, and you must remove them by hand for Claude Code to read AGENTS.md. A root: true or localRoot: true rule whose targets name only claudecode is dropped with a warning naming the file, since no other target generates it; add the target that writes AGENTS.md to its targets or turn it into a non-root rule. ruleDiscoveryMode has no effect with this option, since the references it adds are written into CLAUDE.md. The option applies to project scope only: in global mode ~/.claude/CLAUDE.md does not shadow AGENTS.md, so it is still generated (with a warning). Keep it true (the default) when some of your Claude Code sessions do not read AGENTS.md — AGENTS.md support arrived in v2.1.277 and is unavailable in some setups; see when AGENTS.md support is unavailable. Example:

jsonc
// rulesync.jsonc
{
  "targets": {
    "agentsmd": ["rules"],
    "claudecode": { "rules": { "includeRoot": false } },
  },
}

CodeBuddy Code note: CodeBuddy Code (Tencent Cloud's terminal coding agent, @tencent-ai/codebuddy-code) mirrors Claude Code's configuration surface closely. Rulesync writes the root rule to CODEBUDDY.md at the project root (also read from .codebuddy/CODEBUDDY.md as an alternative root) / ~/.codebuddy/CODEBUDDY.md (global, via --global), and non-root rules to .codebuddy/rules/*.md (project) / ~/.codebuddy/rules/*.md (global), auto-loaded without needing a reference section. A personal, gitignored CODEBUDDY.local.md is materialized next to the root file, mirroring Claude Code's CLAUDE.local.md. Non-root frontmatter supports description, paths (glob patterns gating when the rule is loaded, accepted as a single string or a list; a string is split on the commas between patterns, leaving {a,b} brace groups intact, so the documented paths: {src,lib}/**/*.ts, tests/**/*.test.ts form imports as two globs), alwaysApply and enabled, closer to the Cursor rule model than Claude Code's paths-only schema; put tool-specific overrides in the codebuddy: block of the canonical rule. Unlike Cursor, CodeBuddy's documented alwaysApply default is true: paths only scopes a rule when alwaysApply: false sits alongside it, and alwaysApply: false with no paths makes CodeBuddy drop the rule entirely. Rulesync applies those rules for you — a rule with non-universal globs is emitted with an explicit alwaysApply: false, and an alwaysApply: false with nothing to trigger on is left implicit rather than written out. The shipped CLI is looser than its docs table and already treats paths without alwaysApply as a path-triggered rule, so import follows the CLI: such a file becomes a rule scoped to those globs, and alwaysApply is carried over only when the file spells it out. enabled: false stops CodeBuddy loading the rule at all and survives an import/generate round trip. See the CodeBuddy Code memory docs and the .codebuddy directory docs.

IBM Bob note: IBM Bob (IBM's agentic coding assistant, available as an IDE extension and the Bob Shell CLI) reads the project-root AGENTS.md as its primary instruction file and every Markdown file under .bob/rules/ as additional always-on rules; the user-level counterparts are ~/.bob/AGENTS.md and ~/.bob/rules/. Rulesync writes the root rule to AGENTS.md (project) / ~/.bob/AGENTS.md (global, via --global) and non-root rules to .bob/rules/*.md (project) / ~/.bob/rules/*.md (global). Bob rule files are plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and no metadata beyond the file name survives an import. See the rules docs.

JetBrains Junie note: Junie runs a custom slash command only once every $name argument in its template is provided, so the universal $ARGUMENTS is translated to Junie's free-form $prompt argument and allowPromptArgument: true is added to the generated .junie/commands/*.md frontmatter (and reversed on import — see Command Syntax for the exact conditions). Set junie.allowPromptArgument in the rulesync frontmatter to override the derived value; false keeps $ARGUMENTS verbatim. Other keys in the junie block are passed through to the Junie frontmatter. See the custom slash commands docs.

Tabnine CLI note: The file-based Tabnine CLI is in maintenance mode since Tabnine 6.6 (2026-09-15; "Tabnine CLI (Legacy)", tabnine-legacy, critical updates only through 2026-12-31), and the new tabnine command is an OpenCode distribution covered by --targets opencode — see Supported tools > Deprecation notes. The legacy Tabnine CLI loads the project-root TABNINE.md as its memory file on every session (refreshed with /memory reload), so Rulesync writes the root rule there. Non-root rules are written to .tabnine/guidelines/*.md, the guideline directory the Tabnine agent reads; the CLI does not load that directory on its own, so TABNINE.md also lists the guideline files as a table of contents (the same table-of-contents mode used for tools whose non-root rules are not auto-loaded). In global mode (--global) the root rule goes to ~/.tabnine/agent/TABNINE.md — the user-level memory file the migration guide names as "the global ~/.tabnine/agent/TABNINE.md file", next to the other user-scoped agent assets under ~/.tabnine/agent/ — and non-root rules to ~/.tabnine/guidelines/*.md. Rule files are plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and no metadata beyond the file name survives an import. Because .tabnine/guidelines/ is a directory Tabnine defines rather than one Rulesync invented, a project may already have hand-written guidelines there: Rulesync owns it from now on, so --delete removes anything in it — ~/.tabnine/guidelines/ included, in global mode — that .rulesync/rules/ does not produce. Move those files into .rulesync/rules/ first. See the settings reference (context.fileName) and the guidelines docs.

Snowflake Cortex Code note: Snowflake Cortex Code (the cortex CLI, also called "CoCo") reads the project-root AGENTS.md as its instruction file. It documents no modular rules directory and no user-level instruction file, so Rulesync folds every non-root rule into the single AGENTS.md (the same fold mode Codex CLI uses for rules that are not directory-scoped) and the rules feature is project-only (--global is not supported). Rule files are plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and no metadata beyond the file name survives an import. See the Cortex Code overview.

Codewhale note: Codewhale reads the workspace-root AGENTS.md as its project instruction file and then every .codewhale/rules/*.md file in filename order, so Rulesync writes the root rule to AGENTS.md and each non-root rule to .codewhale/rules/<name>.md. Codewhale does not walk into nested subproject AGENTS.md files, so agentsmd.subprojectPath is not honored. In global mode (--global), Codewhale prepends ~/.codewhale/AGENTS.md to the project instructions and has no user-level rules directory, so every global rule is folded into that single file.

Continue note: Continue is end of life (it joined Cursor on 2026-06-18; the repository is read-only after the final v2.0.0 release), so the continue target is frozen at that final source tree — see Supported tools > Deprecation notes. Continue (the open-source IDE extension and cn CLI) reads the project-root AGENTS.md as an always-applied instruction file and every Markdown file under .continue/rules/ (project) / ~/.continue/rules/ (global, via --global) as a rule with optional YAML frontmatter, so Rulesync writes the root rule to AGENTS.md and non-root rules to .continue/rules/*.md. Continue documents no user-level AGENTS.md, so in global mode the root rule is written as ~/.continue/rules/AGENTS.md and told apart from the other rules by its basename (the convention the Roo Code target also uses). A non-root rule carries description, globs and the Continue-specific regex / alwaysApply keys: a rule with no globs/regex is always applied, one with globs is applied when a file in context matches, alwaysApply: true forces it on and alwaysApply: false makes it depend on globs/regex only. The universal globs **/* and * are therefore dropped on generate (a globless rule is already always-on), as is any glob next to alwaysApply: true. The continue frontmatter block (name, alwaysApply, regex, plus globs and description overrides of the canonical keys) authors those fields; on import a globless always-on rule maps to globs: ["**/*"], alwaysApply: false without globs maps to globs: [], and name / alwaysApply / regex are lifted into the continue block. See the rules docs.

Command Code note: Command Code (the open-source terminal coding agent published as command-code on npm) reads the project-root AGENTS.md as its instruction file and the user-level ~/.commandcode/AGENTS.md (global, via --global). An AGENTS.md in a subdirectory is only loaded lazily once the agent touches a file beneath it, so there is no deterministic nested rules surface and Rulesync folds every non-root rule into the single AGENTS.md (the same fold mode Codex CLI uses for rules that are not directory-scoped). The file is plain Markdown with no frontmatter, so rulesync frontmatter is stripped on generate and only the file name survives an import. See the memory docs.

Qoder note: Qoder (Alibaba's agentic coding platform: the Qoder IDE, the qodercli terminal agent and the JetBrains plugin, which share one .qoder/ layout) reads the project-root AGENTS.md plus the per-machine AGENTS.local.md overlay (written for a localRoot: true rule), and auto-loads the topic rules under .qoder/rules/*.md; the user scope is ~/.qoder/AGENTS.md and ~/.qoder/rules/*.md (global, via --global). Rulesync writes the root rule as plain Markdown to AGENTS.md and each non-root rule to .qoder/rules/<name>.md with a trigger frontmatter: specific globs become trigger: glob with a glob list, and a rule with no globs or a match-everything glob (**/*) becomes trigger: always_on. A description is carried along. Set qoder.trigger (always_on, glob, manual or model_decision, where Qoder needs a non-empty description) and optionally qoder.glob in the rulesync frontmatter to pick the mode yourself; a glob trigger without specific globs is written as always_on with a warning, and a model_decision trigger without a description is written with a warning. On import the compatibility spellings are read too (alwaysApply: true / false as always_on / manual, as Qoder documents them, a bare paths or glob list as glob); a manual or model_decision rule keeps its mode in the qoder block, and so does any frontmatter key rulesync does not map. A glob rule with an empty glob list, which Qoder never applies, is imported with qoder.trigger: glob and a warning; the next generate writes it as always_on, so add globs or set qoder.trigger: manual to keep it inactive. See the memory docs.

.rulesync/hooks.jsonc ​

.rulesync/hooks.jsonc is the recommended source path and accepts comments and trailing commas. The legacy .rulesync/hooks.json path remains readable for existing projects. When both files exist, the JSONC file takes precedence; write flows update the existing source instead of creating a second variant.

A key named __proto__, constructor, or prototype is rejected with an error naming its path, rather than being dropped in silence. The "Rejected keys" paragraph in the .rulesync/permissions.jsonc section below describes this handling, which is the same for all three source files.

Hermes Agent accepts native snake-case events under hermesagent.hooks: pre_tool_call, post_tool_call, transform_terminal_output, transform_tool_result, transform_llm_output, pre_llm_call, post_llm_call, on_stream_start, on_stream_delta, on_stream_end, on_interim_message, pre_verify, pre_api_request, post_api_request, api_request_error, pre_auxiliary_call, post_auxiliary_call, on_session_start, on_session_end, on_session_finalize, on_session_reset, on_skill_lifecycle, subagent_start, subagent_stop, pre_gateway_dispatch, post_gateway_admission, agent_loop_stopped, pre_approval_request, post_approval_response, on_human_input_request, on_human_input_resolved, on_room_member_activity, pre_transcription, kanban_task_claimed, kanban_task_completed, kanban_task_blocked, on_kanban_worker_spawned, on_kanban_worker_exited, on_kanban_worker_stale_claim, on_kanban_task_updated, on_kanban_dispatch_tick, gateway_platform_event, and pre_command. That is 43 of the 44 VALID_HOOKS entries at v0.21.6 (agent_loop_stopped and on_room_member_activity arrived in v0.21, pre_auxiliary_call and post_auxiliary_call in v0.21.5, post_gateway_admission, on_human_input_request and on_human_input_resolved in v0.21.6): transform_api_error_classification is subtracted by SHELL_UNSUPPORTED_HOOKS, because a shell hook cannot return its directive and Hermes refuses the registration — authoring it still emits the entry, with a warning saying it will never run. Rulesync maps shared canonical events first, applies canonical keys from hermesagent.hooks next, then applies exact native keys last. An exact native key therefore wins when both forms resolve to the same Hermes event. Native-only events remain under hermesagent.hooks on import instead of leaking into other targets. Rulesync owns the event keys inside the hooks: mapping of config.yaml, but not the mapping itself: Hermes v0.20.0 nests the outbound webhook registry under the same key as hooks.outbound, so any key there that is not a Hermes hook event is carried over from the existing file untouched. Rulesync neither authors nor imports outbound, since it is a list of webhook targets rather than a hook event; it only makes sure a regenerate leaves it alone. An event key Rulesync did write, including one under an undocumented event name supplied through hermesagent.hooks, is still retracted when it disappears from the source.

Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use canonical camelCase in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Qwen Code, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (.opencode/plugins/rulesync-hooks.js, .kilo/plugins/rulesync-hooks.js) — both share one event surface apart from notification (see below), in which preToolUse/postToolUse become named tool.execute.before/tool.execute.after hooks, preCompact becomes the named experimental.session.compacting hook, beforeSubmitPrompt the named chat.message hook and preModelInvocation the named chat.params hook (fired before every LLM request, for subagents too) — all three receive (input, output) and expose nothing to match on, so a matcher on any of them is dropped — while userPromptExpansion becomes the named command.execute.before hook, which fires only for slash commands (on the expanded prompt, right before it is submitted) and tests a matcher against the command name (input.command), beforeShellExecution/afterShellExecution also land in those named tool.execute.* hooks with an implicit input.tool === "bash" gate — OpenCode has no shell-execution lifecycle event (command.executed, which earlier Rulesync versions mapped afterShellExecution to, is a slash-command event, so the hook never fired on shell commands; regenerate to fix), and matchers on the shell events are dropped with a warning since the named hooks expose no command text, and the rest are event.type dispatches — sessionStart → session.created, stop → session.idle, afterFileEdit → file.edited, permissionRequest → permission.asked, permissionDenied → permission.replied (which fires for every reply, so the generated handler is gated on event.properties.reply === "reject"), notification → tui.toast.show (OpenCode only — Kilo's plugin docs document no TUI events, so notification is not part of its surface; note too that OpenCode's toast channel is broader than the canonical event, since every info/success/warning/error toast fires the hook rather than only the ones asking for your attention, and most toasts originate in the TUI client, so a headless opencode run rarely fires it at all), postCompact → session.compacted, afterError → session.error, fileChanged → file.watcher.updated, sessionDelete → session.deleted; Amp hooks are emitted as a TypeScript plugin (.amp/plugins/rulesync-hooks.ts, or ~/.config/amp/plugins/rulesync-hooks.ts in global mode) using session.start, tool.call, tool.result, agent.start, and agent.end; Pi Coding Agent hooks are emitted as a Rulesync-owned TypeScript extension (.pi/extensions/rulesync-hooks.ts, or ~/.pi/agent/extensions/rulesync-hooks.ts in global mode) that subscribes to Pi's snake_case extension events (sessionStart → session_start, stop → agent_before_settle (added in Pi v0.87.0, so it needs Pi v0.87.0 or later — older Pi never fires it and stop hooks silently do not run there), preToolUse → tool_call with the matcher tested as a regex against the tool name, preCompact → session_before_compact, postCompact → session_compact, postModelInvocation → message_end gated on assistant messages so it runs once per finalized model response, beforeSubmitPrompt → input, postToolUse → tool_result, postToolUseFailure → the same tool_result gated on event.isError — Pi fires one event for every finished tool call and flags the failed ones, so the two canonical events share one subscription and only the failure handlers check the flag; postToolUse keeps firing for failed calls too, as it always has on Pi — and notification → ui_prompt_start (added in Pi v0.84.4), Pi's "waiting for user" signal: it fires when an extension opens a blocking ctx.ui.select / confirm / input / editor / custom prompt, so it covers prompts raised by extensions — Rulesync's own or third-party ones — and never core Pi, which has no built-in permission popup; it is notification-only (handlers are best-effort and not awaited, so the hook command can neither delay nor cancel the prompt), nested prompts coalesce into one span so it fires once per span, and a matcher on it is dropped because the handler tests nothing against it) — tool_call is Pi's only tool gate, so a preToolUse command that exits non-zero denies the call with { block: true, reason } (the reason is the command's stderr, falling back to its stdout and then to its exit code, with terminal escape sequences, control and C1 characters, and format characters such as bidirectional overrides and zero-width joiners stripped, carriage returns folded into newlines so the text cannot overwrite an already printed line, the sanitized result capped at 2000 characters (and only the first 128000 characters of the command's output scanned at all) — sanitizing first so a reason that opens with a progress banner is still reported by its content — and a generic Hook command failed. used when nothing survives), and input is its prompt-submission gate, so a beforeSubmitPrompt command that exits non-zero cancels the prompt with { action: "handled" } — that result carries no reason field, so the same text is reported through ctx.ui.notify, falling back to stderr in print (-p) and JSON modes where that channel is a no-op; three limits are worth knowing before relying on the prompt gate: Pi checks extension slash commands before the input event, so input consumed as a /cmd never reaches the gate, messages another extension injects via sendUserMessage (event.source === "extension") are deliberately passed through because the canonical event covers prompts the user submits, and the hook command receives no prompt text (Pi's input event exposes it, but the generated handler runs the command with no stdin or arguments, unlike Claude Code's UserPromptSubmit), so the gate can only decide from ambient state; a matcher on beforeSubmitPrompt is dropped, since input exposes no tool name to test it against; agent_before_settle is Pi's last actionable boundary before a run settles (it fires after automatic retries and compaction, so a stop hook runs once per settle, unlike the notify-only agent_end earlier Rulesync versions mapped stop to), so a stop command that exits with code 2 asks the agent to keep going like a blocking Claude Code Stop hook (any other non-zero exit, including a command that cannot be run, never continues the agent, as Claude Code treats it as a non-blocking error; the handler rethrows it after the remaining stop commands run, so Pi reports it as an extension error): every configured stop command runs, and the same sanitized text from each command that exited with 2 is combined into one displayed custom_message (Stop hook feedback: …) appended after any entries other extensions proposed, with continue: true for one more model request — note that this sends the command's output to the model, so do not print secrets from a stop hook; to avoid the loop Pi warns about, this happens at most once per run (the guard resets on agent_settled, so the next prompt may continue again; later failures in the same run are only reported), never for a run that was aborted or ended in an error, and the hook command receives no event payload, so there is no equivalent of Claude Code's stop_hook_active to check; every other event, postToolUse/tool_result included, observes only and cannot block or mutate Pi events; a denied call deliberately leaves Pi's terminate flag unset so control returns to the model instead of ending the turn; Copilot and Copilot CLI map event names to their own camelCase (e.g. beforeSubmitPrompt → userPromptSubmitted, stop → agentStop, afterError → errorOccurred) and write the command into the bash/powershell field named by the canonical shell selector, or into the portable command field when none is set — Copilot CLI additionally covers a wider event set and supports prompt and http hook types beyond command; deepagents-cli gets the Hooks v2 PascalCase HookEvent names (e.g. SessionStart, PostToolUseFailure) in a { "hooks": { "<Event>": [{ "matcher": …, "hooks": [{ "type": "command", … }] }] } } document — this requires deepagents-code 0.1.52+, the release where Hooks v2 became generally available (the legacy flat list is removed upstream on 2026-09-01; Rulesync still imports the legacy format but no longer writes it); kiro-cli and kiro-ide emit hooks into the standalone .kiro/hooks/rulesync.json with PascalCase triggers, while the deprecated kiro alias still writes them into .kiro/agents/default.json using the older event names (agentSpawn, userPromptSubmit, preToolUse, postToolUse, stop); Qwen Code emits PascalCase events into the hooks key of .qwen/settings.json (its supported event set differs from Gemini CLI's); ZCode emits PascalCase events into the events map nested under the hooks key of .zcode/config.json (project, the same file that holds its MCP servers, whose mcp key is preserved) or ~/.zcode/cli/config.json (global, via --global) — ZCode runs workspace hooks only after each user trusts them (new or changed workspace hooks wait for approval in ZCode's review prompt or via zcode hooks trust review / grant, and headless runs skip untrusted ones with workspace_hooks_pending_trust), so any change to the generated hooks asks every teammate to trust them again; ZCode's hooks docs page still says workspace hooks are ignored, but the v3.8.1 changelog announces workspace-level hooks and the v3.14.3 runtime executes trusted ones — seven events in all, matcher-capable except on UserPromptSubmit and Stop (which expose no value to match against), with commands emitted verbatim — note that ZCode reads a matcher of only letters, digits, underscores and | as an exact name list (Write|Edit matches those two tool names) and anything else as a case-sensitive JavaScript regex, and Rulesync passes the value through unchanged; it states hooks.enabled: true when it writes events and the file states no enabled preference, because ZCode runs no configuration hooks while that switch is off (any authored enabled value, including a deliberate false, is left alone as the user's switch) — note that ZCode's master switch is on when any config source states enabled: true, so a committed project .zcode/config.json with enabled: true can also activate a teammate's user-level hooks whose ~/.zcode/cli/config.json leaves enabled unset inside that project (a source that states enabled: false keeps its own hooks off); ZCode's native process hook type has no canonical equivalent, so those entries are skipped with a warning on import.

oh-my-pi note: oh-my-pi (omp) hooks are emitted as the same kind of Rulesync-owned TypeScript extension as Pi's, written to .omp/extensions/rulesync-hooks.ts (project) / ~/.omp/agent/extensions/rulesync-hooks.ts (global, via --global; default profile only, so a named profile or a PI_CODING_AGENT_DIR override is not followed). oh-my-pi loads extension modules from those directories itself; Rulesync does not use the legacy .omp/hooks/pre|post/ hook directories, since one module subscribes to both pre- and post-tool events. The generated module imports its types from @oh-my-pi/pi-coding-agent and follows the Pi mapping above for sessionStart, sessionEnd, preToolUse, postToolUse, postToolUseFailure, preModelInvocation, postModelInvocation, beforeSubmitPrompt, preCompact and postCompact, with the same matcher, blocking and reason-sanitizing behavior, except that a failing beforeSubmitPrompt command cancels the prompt with oh-my-pi's { handled: true } result instead of Pi's { action: "handled" }. stop and notification are not supported yet: oh-my-pi has neither agent_before_settle nor ui_prompt_start, and mapping stop to oh-my-pi's own session_stop event is not implemented. oh-my-pi fails a tool_call closed when its handlers exceed extensionHandlers.toolCallTimeoutMs (30 seconds by default), so a slow preToolUse command blocks the tool call, and it gives session_shutdown handlers only 2 seconds. The prompt gate is narrower than the tool gate: oh-my-pi fires input only for prompts submitted interactively or over RPC (print mode and ACP input bypass it), and an input handler that exceeds the 30-second handler budget lets the prompt through. Tool-scoped hooks go under an omp.hooks block. See the oh-my-pi extension docs and extension loading docs.

Example:

json
{
  "version": 1,
  "hooks": {
    "sessionStart": [{ "type": "command", "command": ".rulesync/hooks/session-start.sh" }],
    "preToolUse": [{ "matcher": "Bash", "command": ".rulesync/hooks/confirm.sh" }],
    "postToolUse": [{ "matcher": "Write|Edit", "command": ".rulesync/hooks/format.sh" }],
    "stop": [{ "command": ".rulesync/hooks/audit.sh" }]
  },
  "cursor": {
    "hooks": {
      "afterFileEdit": [{ "command": ".cursor/hooks/format.sh" }]
    }
  },
  "claudecode": {
    "hooks": {
      "notification": [
        {
          "matcher": "permission_prompt",
          "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh"
        }
      ]
    }
  },
  "opencode": {
    "hooks": {
      "afterShellExecution": [{ "command": ".rulesync/hooks/post-shell.sh" }]
    }
  },
  "copilot": {
    "hooks": {
      "afterError": [{ "command": ".rulesync/hooks/report-error.sh" }]
    }
  }
}

Top-level keys:

  • version: Schema version (currently 1).
  • hooks: Map of canonical event names to an array of hook entries. These are dispatched to every tool that supports the given event.
  • amp.hooks, cursor.hooks, claudecode.hooks, opencode.hooks, mimocode.hooks, kilo.hooks, copilot.hooks, copilotcli.hooks, factorydroid.hooks, codexcli.hooks, goose.hooks, deepagents.hooks, kiro.hooks, qwencode.hooks, grokcli.hooks, zcode.hooks, bob.hooks, tabnine.hooks, cortexcode.hooks, codewhale.hooks, continue.hooks, commandcode.hooks, lettacode.hooks, qoder.hooks, gitlabduo.hooks, crush.hooks, pool.hooks: Tool-specific override keys. Entries under these keys are emitted only for the corresponding tool, so tool-only events (e.g. afterFileEdit for Cursor/OpenCode/Kilo, worktreeCreate for Claude Code, afterError for Copilot/Copilot CLI, PostFileSave/PreTaskExec for Kiro) can coexist with shared ones without leaking to other tools. copilotcli.hooks falls back to copilot.hooks, which in turn falls back to the shared hooks block.

By default the generated hooks replace the destination's hook list wholesale, so a handler another tool or a person added by hand there is lost on the next generate. Setting "preserveUnownedHooks": true in rulesync.jsonc keeps those handlers and appends the generated ones instead; see Configuration. It covers Claude Code, Codex CLI and Cursor (the Claude Code plugin bundle always replaces) and records what Rulesync generated in a .rulesync-hooks-lock.json next to each hooks file, so a hook Rulesync wrote and the sources no longer define is still retracted.

Hook entry keys:

  • command (required): Shell command to execute when the event fires.

    Write paths to project files as ./-relative paths. For targets that anchor hook commands to a project-directory variable (Claude Code's $CLAUDE_PROJECT_DIR, the Claude Code plugin's $CLAUDE_PLUGIN_ROOT, the AugmentCode plugin's $AUGMENT_PLUGIN_ROOT, Command Code, Continue, Cortex Code and Factory Droid), Rulesync prefixes a command whose first word starts with . (but not the . builtin, as in . ./env.sh) with the variable. It also anchors later ./ words that name a file the command runs:

    • Anchored positions: the command word of each later command in a chain or pipeline, or inside $( … ), backticks or a single-quoted -c script of sh, bash and similar shells (combined flags such as bash -lc '…' included; the script's own quotes are respected); and the first non-option argument of a script runner — ., source, sh, bash (and similar shells), node, bun, tsx, ts-node, python (also python3 or python3.12), ruby, perl or php, as in node ./x.js or python3 "./my x.py". The same applies behind a package runner or run subcommand (npx tsx ./x.ts, pnpm exec tsx ./x.ts, uv run ./x.py, uv run python ./x.py, bun run ./x.ts, deno run -A ./x.ts), and to the value of a runner option that loads a file (node -r ./register.js ./x.js, perl -I ./lib ./x.pl). Prefix words and their options before the command word are skipped (time -p node ./x.js, exec -a name ./x.sh), as are env and the assignments after it (env FOO=1 ./x.sh), although nothing is anchored after an env option (env -u NAME ./x.sh, env -C dir ./x.sh); and an escaped or quoted command name (\node, \bash -c '…', "bash" -c '…') counts as the command itself; an option that takes a value, such as python3 -W ignore ./x.py, is skipped together with its value, and after inline code, a module, standard input or a package runner's shell mode (python3 -c, python3 -m, node -e, bash -s, npx -c, pnpm exec --shell-mode) nothing more is anchored; yarn exec, which runs its arguments through Yarn's own shell, is not followed either.
    • Written forms: an unquoted ./x becomes "$CLAUDE_PROJECT_DIR"/x, a double-quoted "./x" becomes "$CLAUDE_PROJECT_DIR/x", and a single-quoted './x' becomes "$CLAUDE_PROJECT_DIR"/'x'.
    • Left as written: every other argument, whose meaning depends on the command (npx prettier --write ./src, a redirection target, --config=./c.json, the arguments of a command run through npx that is not a script runner, such as npx prettier), and anything handed to a container or another host (docker, podman, nerdctl, kubectl, ssh, vagrant). Text inside a quoted string, comments, $'…' strings and ../ or .name words are never anchored, and neither is an unquoted or double-quoted sh -c script, which the inner shell would split again once expanded, nor a -c script handed to another command (sudo sh -c '…', also written \sudo, or find . -execdir sh -c '…' \;), which runs elsewhere if at all. A quote on the line after sh -c starts a new command rather than the script, unless a line continuation (\ at the end of the line) joins the lines.

    These later words are scanned as POSIX shell syntax, so a hook with shell: "powershell" only has its first word prefixed, on generate and on import alike. This applies only to targets whose hooks have a shell field (Claude Code, including its plugin hooks); every other target runs the hook in its own shell, so shell does not change what is anchored there.

    Import converts the variable back to ./ wherever it starts a path, so a hook such as python3 "$CLAUDE_PROJECT_DIR/hooks/check.py" becomes python3 "./hooks/check.py" and the variable does not leak into targets that never set it. It keeps the variable after the first word in a command that changes directory (cd, pushd, popd), uses eval, a heredoc or here-string (<<), or one of the container and remote commands above, since ./ would no longer mean the same place there; a command that starts with another variable (such as $HOME/…) is converted in its first word only as well, a -c script handed to another command (sudo sh -c '…') is left as it is, and so is the value of an assignment word or option (PATH=$CLAUDE_PROJECT_DIR/bin:$PATH, --config=$CLAUDE_PROJECT_DIR/c.json), which generate never anchors; import prints a warning naming a command that keeps the variable this way. A variable in an argument that generate does not anchor (npx prettier --write "$CLAUDE_PROJECT_DIR"/src) comes back as ./src, which resolves against the directory the hook runs in, and import prints a warning naming the command. Claude Code runs hooks in its current directory, which is a worktree or a directory Claude changed into rather than always the project root, so to keep the variable there, write the command in the claudecode.hooks override. A working directory changed by other means (for example git -C dir, env -C dir, uv run --directory dir, pnpm -r exec, pnpm --filter=pkg exec or bun --cwd=dir run) or by an obfuscated cd (such as c\d) is not detected, and neither is a path in a -c script that follows a single quote embedded as '\''.

  • type (optional): One of "command" (default), "prompt", "http", "agent", "mcp_tool", or "function" — the union of the hook types accepted across supported tools. Each tool supports a subset (most support only command); hooks with a type a tool does not support are skipped for that tool with a warning. See notes below.

  • matcher (optional): Regex used by tools that scope hooks to specific tool names (e.g. preToolUse, postToolUse, notification). Ignored by events that do not take a matcher (e.g. sessionStart, worktreeCreate, worktreeRemove).

  • timeout (optional): Per-hook timeout in seconds, forwarded to tools that support it.

  • cacheTtl (optional): Number of seconds to cache a successful hook result. Forwarded to the deprecated kiro alias's agent-config format as cache_ttl_seconds; 0 disables caching and Kiro never caches AgentSpawn hooks.

  • failClosed (optional): Boolean. When true, a hook failure (crash, timeout, invalid JSON) blocks the action instead of allowing it through. Passed through to Cursor's .cursor/hooks.json, to JetBrains Junie's ~/.junie/config.json (as Junie's equivalently-named blockOnError flag), to Hermes Agent's ~/.hermes/config.yaml (as fail_closed), and to Goose's plugin hooks.json (as on_failure: "block", Goose v1.48.0+). Hermes only honours it on pre_tool_call (its one blocking-capable event) and Goose only reads on_failure on PreToolUse, so a failClosed set on any other canonical event is dropped with a warning for those targets.

  • commandRegex (optional): Regex applied to the shell command string, narrowing an Execute matcher group further (e.g. "^git "). Forwarded to Factory Droid, which skips invalid regex values. Like matcher, it belongs to the whole matcher group, so every hook sharing that matcher receives it.

  • async (optional): Boolean. When true, the hook command runs in the background without blocking. Forwarded to Qwen Code (.qwen/settings.json), JetBrains Junie (~/.junie/config.json, same field name), Claude Code and Codex CLI (.codex/hooks.json, same field name — Codex runs up to eight background hooks concurrently per session and queues the rest).

  • env (optional, command hooks): a map of extra environment variables merged into the hook process's environment. Forwarded to Qwen Code (.qwen/settings.json), Copilot CLI, and Grok CLI (.grok/hooks/rulesync.json, upstream HookConfig.env, merged into the spawned command's extra_env). Documented on command hooks only, so it is neither emitted on a hook of another type nor imported from one (a value found there is dropped with a warning). For Grok CLI, an entry whose key is empty or contains =, or whose key or value contains a newline, carriage return or NUL, is refused in both directions — the tool rebuilds each entry into a KEY=VALUE string, so such a key would name a different variable than it appears to.

  • shell (optional): Either "bash" or "powershell" — the only two interpreter values any tool accepts. Forwarded to Qwen Code, Claude Code, Copilot and Copilot CLI command hooks; for the two Copilot targets it names the bash/powershell field the command is written into, and leaving it unset selects their portable command field. Like args, async and asyncRewake, it is documented on command hooks only, so it is neither emitted on a hook of another type nor imported from one (a value found there is dropped with a warning).

  • url / headers / allowedEnvVars (optional, http hooks): the POST target URL, request headers (values support $VAR interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks.

  • server / tool / input (optional, mcp_tool hooks): the configured MCP server name, the tool to call on it, and the (arbitrary JSON) arguments, whose string values support ${path} substitution from the hook input. Forwarded to Claude Code mcp_tool hooks.

  • model (optional, prompt / agent hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks, to Qwen Code prompt hooks, and to Cursor prompt hooks (the Cursor hooks docs document it as an optional prompt-hook override; it is dropped from Cursor command hooks).

  • args (optional, command hooks): an argument list. When present — an empty list counts, and is the form the Claude Code docs use — the tool spawns command directly as an executable with these arguments. There is no shell, so Rulesync writes the project-directory prefix as the braced placeholder ${CLAUDE_PROJECT_DIR}/… that Claude Code substitutes itself, rather than the quoted shell form. Forwarded to Claude Code, AugmentCode and Copilot CLI (where it is written as the CLI-only exec + args pair). Only command is prefixed; entries of args are passed through exactly as written.

  • asyncRewake (optional): boolean. Like async, but wakes Claude when the hook exits with code 2. Forwarded to Claude Code command hooks.

  • once (optional): boolean. Run the hook once per session, then remove it. Forwarded to Claude Code (honored in skill frontmatter; accepted but ignored in settings files) and Qwen Code http hooks.

  • continueOnBlock (optional): boolean. Feed a blocking hook's rejection reason back to the model and continue the turn instead of ending it. Forwarded to Claude Code.

  • commandWindows (optional): a Windows-only override for command, so one hook set can be cross-platform. Forwarded to Codex CLI command hooks (.codex/hooks.json), which is the only tool that accepts it.

  • additionalContextLimit (optional): a non-negative integer. The token threshold above which the tool writes the hook's additional context to a file and passes that path instead of the text itself (upstream default 2500). Forwarded to Codex CLI command hooks (.codex/hooks.json), which is the only tool that accepts it.

  • statusMessage (optional): the progress text shown while the hook runs. Forwarded to Qwen Code (command and http hooks) and to Codex CLI command hooks.

  • enabled (optional): boolean, default true. Whether the hook is active. Forwarded to Kiro's standalone hooks file (.kiro/hooks/rulesync.json, written by the kiro-cli and kiro-ide targets) and to ZCode's per-hook enabled in .zcode/config.json / ~/.zcode/cli/config.json and in a ZCode plugin's hooks/hooks.json — the places with a per-hook on-disk enable flag Rulesync writes; an imported enabled: false round-trips, so a deliberately disabled hook is not silently switched back on by the next generate. Every other target has no way to express it, so the hook is emitted there as an ordinary active hook and a warning is logged at generate time — to turn a hook off everywhere, remove it rather than setting enabled: false. (Antigravity has an enabled flag of its own, but on the named hook group rather than the individual definition, so it is not driven by this field.)

  • confirm (optional): { "question": "…", "options": [{ "id": "…", "label": "…", "run": true | false }], "confirmCommand": "…" } — a confirmation prompt shown before the hook runs, where the chosen option's run decides whether the command executes. The optional confirmCommand inside the block names a command whose JSON stdout drives that prompt dynamically ({ "skip": true } suppresses it and skips the hook; a replacement question/options object overrides the static prompt; on failure or timeout the static prompt is used). Forwarded verbatim to Kiro's standalone hooks file (kiro-cli and kiro-ide), which honors it on Stop-trigger command hooks, and round-tripped on import so a hand-written prompt survives regeneration. No other target has an equivalent: they drop the block with a warning and run the hook unconditionally.

  • if (optional): a single permission rule (same syntax as settings.json permission rules, e.g. "Bash(rm *)") that filters a hook by tool arguments in addition to the tool name. Forwarded to Claude Code, where it is evaluated only on tool events (preToolUse, postToolUse, postToolUseFailure, permissionRequest, permissionDenied); it round-trips as an opaque string.

  • source (optional, command hooks): an object { "source": "github:<org>/<repo>/<path>", "ref"?: "<branch or commit>" } naming a script fetched from a Git repository (a Snowflake Cortex Code "remote hook"); command then names the interpreter the fetched script runs under, e.g. "bash". Forwarded to Snowflake Cortex Code, which is the only tool that accepts it; on import an object without a source key is dropped with a warning.

A field a tool documents on command hooks only (args, env, shell, async, asyncRewake, source) is dropped with a warning in both directions when it appears on a hook of another type — on generate for a value authored in .rulesync/hooks.*, and on import for one found in an existing tool config. Import additionally checks each value against the constraint the canonical field declares (e.g. shell must be "bash" or "powershell", additionalContextLimit must be a non-negative integer, and no string field may carry a newline, carriage return or NUL); a value that fails is skipped with a warning naming the constraint, rather than imported into a file the next generate would refuse to read. When the offending value is a command, prompt or matcher, the whole hook is skipped instead of just that field, since a hook without its command runs nothing and a hook without its matcher fires on everything.

Top-level hooks keys must be canonical event names; unknown event names are rejected at parse time. Tool-specific override blocks (e.g. kiro.hooks) additionally accept tool-native event keys, which pass through verbatim.

Events present in the shared hooks block but unsupported by a given tool are skipped for that tool (a warning is logged at generate time). The canonical notification event maps to deepagents-cli's Hooks v2 Notification event, whose matcher selects the notification kind (e.g. agent_needs_input); canonical contextOffload is skipped for deepagents-cli, since its legacy context.offload event has no Hooks v2 counterpart.

Hook event × tool matrix ​

EventAmpIBM BobClaude CodeClaude Code pluginCodeBuddy CodeCodewhaleCodex CLICommand CodeGitHub CopilotGitHub Copilot CLICrushGooseHermes AgentGrok CLICursordeepagents-cliFactory DroidOpenCodeMiMo CodeGitLab Duo CLIClineKilo CodeKimi CodeTabnine CLI ⚠️Continue ⚠️Snowflake Cortex CodeVibe CodeVibe Code pluginQoderQwen CodeReasonixKiro ⚠️Kiro CLIKiro IDELetta CodeGoogle Antigravity IDEGoogle Antigravity CLIGoogle Antigravity pluginJetBrains JunieAugmentCodeAugmentCode pluginDevin DesktopDevin pluginPi Coding Agentoh-my-piZCode (Z.ai)ZCode pluginPool (Poolside)
sessionStart✅✅✅✅✅✅✅✅✅✅—✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅——✅✅✅✅✅✅✅———✅✅✅✅✅✅✅✅✅✅
sessionEnd——✅✅✅✅✅—✅✅—✅✅✅✅✅✅———✅—✅✅✅✅——✅✅✅✅——✅———✅✅✅✅✅✅✅———
preToolUse✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅—✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅
postToolUse✅✅✅✅✅✅✅✅✅✅—✅✅✅✅✅✅✅✅—✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅—✅✅✅✅✅✅✅✅✅
preModelInvocation————————————✅————✅✅——✅—✅———————————✅✅✅—————✅✅———
postModelInvocation————————————✅——————————✅——————✅————✅✅✅—————✅✅———
beforeSubmitPrompt✅✅✅✅✅✅✅—✅✅—✅—✅✅✅✅✅✅—✅✅✅✅✅✅——✅✅✅✅✅✅✅———✅✅✅✅✅✅✅✅✅✅
stop✅✅✅✅✅✅✅✅✅✅—✅—✅✅✅✅✅✅——✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅✅—✅✅✅
subagentStop——✅✅✅✅✅—✅✅——✅✅✅✅✅—————✅—✅✅——✅✅✅———✅—————————————
preCompact—✅✅✅✅—✅—✅✅———✅✅✅✅✅✅—✅✅✅✅✅✅——✅✅✅———✅————————✅✅——✅
postCompact—✅✅✅✅—✅——————✅———✅✅——✅✅—————✅✅———————————✅✅✅✅———
postToolUseFailure——✅✅✅———✅✅—✅—✅✅✅——————✅—✅———✅✅✅———✅————————✅✅✅✅—
subagentStart——✅✅✅✅✅—✅✅——✅✅✅✅——————✅—✅———✅✅——————————————————
beforeShellExecution———————————✅——✅——✅✅——✅——————————————————————————
afterShellExecution———————————✅——✅——✅✅——✅——————————————————————————
beforeMCPExecution—————————✅————✅—————————————————————————————————
afterMCPExecution——————————————✅—————————————————————————————————
beforeReadFile———————————✅——✅—————————————————————————————————
afterFileEdit———————————✅——✅——✅✅——✅——————————————————————————
afterAgentResponse——————————————✅—————————————————————————————————
afterAgentThought——————————————✅—————————————————————————————————
beforeTabFileRead——————————————✅—————————————————————————————————
afterTabFileEdit——————————————✅—————————————————————————————————
permissionRequest——✅✅✅—✅——✅—————✅—✅✅——✅✅—✅✅——✅✅✅———✅———✅——✅✅——✅✅—
notification——✅✅✅————✅———✅—✅✅✅✅—✅—✅✅✅✅——✅✅✅———✅————✅✅——✅————
setup——✅✅—————————————————————✅——✅———————————————————
afterError—————✅——✅✅———————✅✅—✅✅——————————————————————————
beforeToolSelection———————————————————————✅————————————————————————
worktreeCreate——✅✅✅———————————————————✅———✅———————————————————
worktreeRemove——✅✅✅———————————————————✅———✅———————————————————
workspaceOpen——————————————✅—————————————————————————————————
messageDisplay——✅✅—————————————————————————✅——————————————————
todoCreated—————————————————————————————✅——————————————————
todoCompleted—————————————————————————————✅——————————————————
stopFailure——✅✅✅✅———————✅————————✅—————✅✅✅———————✅—————————
stopCancelled——————✅——————✅——————✅———————————————————————————
instructionsLoaded——✅✅✅———————————————————————✅✅——————————————————
userPromptExpansion——✅✅————✅✅———————✅✅——✅———————✅——————————————————
postToolBatch——✅✅—————————————————————————✅——————————————————
permissionDenied——✅✅✅————————✅———✅✅——✅——————✅✅——————————————————
taskCreated——✅✅✅———————————————————————✅———————————————————
taskCompleted——✅✅✅———————————————✅———✅———✅———————————————————
teammateIdle——✅✅✅———————————————————✅———✅———————————————————
configChange——✅✅✅———————————————————✅———✅———————————————————
cwdChanged——✅✅✅———————————————————————✅———————————————————
fileChanged——✅✅✅————————————✅✅——✅——————✅———————————————————
directoryAdded——✅✅————————————————————————————————————————————
elicitation——✅✅✅———————————————————————✅———————————————————
elicitationResult——✅✅✅———————————————————————✅———————————————————
sessionDelete—————————————————✅✅——✅———————✅——————————————————
preModelSwitch——✅✅————————————————————————————————————————————
postModelSwitch——✅✅————————————————————————————————————————————

Note: beforeSubmitPrompt, stop, worktreeCreate, worktreeRemove, messageDisplay, postToolBatch, taskCreated, taskCompleted, teammateIdle, and cwdChanged are the Claude Code events the matcher table lists as not supporting the matcher field (they fire on every occurrence). A matcher authored on one of them is dropped with a warning rather than written into settings.json to be ignored. directoryAdded is not one of them: the matcher table documents it as filtering on how the directory was added (slash_command, register_repo_root), so a matcher written on it is emitted as-is. The same holds for preModelSwitch and postModelSwitch (Claude Code v2.1.251), whose matcher is tested against the canonical name of the model the session switches to (e.g. .*opus.*); preModelSwitch can block the switch.

Note: Rulesync implements OpenCode hooks as a plugin at .opencode/plugins/rulesync-hooks.js and Kilo hooks as a plugin at .kilo/plugins/rulesync-hooks.js, so importing from OpenCode/Kilo to rulesync is not supported. Both only support command-type hooks (not prompt-type).

MiMo Code note: MiMo Code loads the same OpenCode plugin API from .mimocode/plugins/ (global: ~/.config/mimocode/plugins/), so Rulesync writes the same plugin to .mimocode/plugins/rulesync-hooks.js with the OpenCode event mapping. Entries under mimocode.hooks are emitted only for MiMo Code. Import is not supported, as for OpenCode.

Note: Rulesync implements Amp hooks as a generated TypeScript plugin at .amp/plugins/rulesync-hooks.ts (project) or ~/.config/amp/plugins/rulesync-hooks.ts (global), so importing arbitrary Amp plugin code is not supported. Amp supports command hooks for sessionStart → session.start, preToolUse → tool.call, postToolUse → tool.result, beforeSubmitPrompt → agent.start, and stop → agent.end. Tool-event matchers are regular expressions against the Amp tool name; definitions with a matcher on any lifecycle event are skipped with a warning. A failing preToolUse command rejects the tool call and lets the agent continue; other mapped events observe the command result.

Amp command syntax: Amp executes plugin commands with Bun Shell, whose syntax differs slightly from POSIX shells. Use $VAR for environment expansion (${VAR} remains literal) and $(command) for command substitution (backticks remain literal). Rulesync passes the authored command through unchanged so quoting and escaped operators retain their Bun Shell meaning.

Note: GitHub Copilot's format uses separate powershell and bash fields for hooks, plus a portable command field that upstream copies into both when neither is present. Rulesync picks between them with the canonical shell selector, and writes the portable command field when a hook does not set one. Earlier versions chose the field from the platform Rulesync happened to run on; regenerate to get a machine-independent file. VS Code reads the same file and maps bash to its osx / linux and powershell to its windows overrides; its own per-OS keys windows, linux and osx (see the VS Code hooks reference) can be authored on a canonical hook and are passed through verbatim on generate and preserved on import, including for an entry that carries only those and no command — such an entry lands in the shared hooks block without a canonical command, so every other target would generate it command-less; the import warns, and adding a command fixes it. VS Code also documents that it ignores matcher values, so a hook with a matcher is dropped for this target rather than emitted unscoped.

Note: Hook file paths per tool:

  • Copilot (cloud agent / VS Code) — project: <project>/.github/hooks/copilot-hooks.json; global: ~/.copilot/hooks/copilot-ide-hooks.json. Command hooks carry bash/powershell with optional timeoutSec, plus the canonical env map and a pass-through cwd. On import, timeout is honored as an alias for timeoutSec when timeoutSec is absent. Which command field is written is chosen by the canonical shell selector; without it the portable command field is written, which upstream copies to both. It is deliberately not chosen from the platform Rulesync runs on: the cloud agent runs hooks in a Linux sandbox where only bash and command are honored, so a powershell entry generated on a Windows machine would simply never run. It also keeps the output identical everywhere, which matters because the cloud agent reads this file from the repository. For the same reason, an imported entry carrying both fields resolves to bash (with a warning) on every platform. VS Code and the coding agent both document ~/.copilot/hooks as the user scope and load every *.json in that folder; the Copilot CLI's global file already occupies copilot-hooks.json there, so the VS Code target uses a distinct filename and the two never overwrite each other. Note the flip side of "every *.json is loaded": generating both copilot and copilotcli in global mode leaves two files in that one folder, and a reader of the folder runs the hooks from both — so a command present in your canonical config fires twice per event. Generate only one of the two globally unless you want that.
  • Copilot CLI — project: <project>/.github/hooks/copilotcli-hooks.json; global: ~/.copilot/hooks/copilot-hooks.json. The Copilot CLI docs let you choose any filename inside .github/hooks/, so Rulesync uses the CLI-specific name to avoid colliding with the cloud-agent file when both targets are enabled. The global path is a Rulesync convention; the official Copilot CLI documentation does not currently enumerate a global hooks location, so this placement may change if the spec later mandates an alternate layout. Copilot CLI uses a wider event surface than the shared cloud-agent set (sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse, postToolUseFailure, agentStop ← stop, subagentStart, subagentStop, errorOccurred ← afterError, preCompact, permissionRequest, notification, userPromptTransformed ← userPromptExpansion, preMcpToolCall ← beforeMCPExecution) and supports three hook types: command (bash/powershell with optional timeoutSec, plus pass-through cwd/env; on import the portable command field is read as the cross-platform fallback when neither shell field is present, and timeout is honored as an alias for timeoutSec when timeoutSec is absent. On generate the canonical shell selector chooses bash or powershell; without it the portable command field is written, so the generated file does not depend on the machine Rulesync ran on. An imported entry carrying both shell fields resolves to bash (with a warning) on every platform, so importing the same file yields the same canonical config everywhere. The CLI-only shell-free form — exec naming an executable plus an args list, spawned without a shell — is the canonical command + args exec form: a canonical args list (an empty one counts) makes Rulesync write exec/args instead of a shell field, dropping any shell selector with a warning, and an imported exec entry lands as command + args (an empty list when upstream omitted args) rather than as a hook with no command; an exec combined with bash/powershell/command, which the hooks reference says not to do, imports as the exec with a warning), prompt (a prompt string — Copilot CLI only honors prompt hooks on sessionStart, so prompt hooks on other events are dropped), and http (url/headers/allowedEnvVars with optional timeoutSec). An entry's optional matcher field is emitted and round-tripped on the six events the hooks reference documents as matcher-aware — preToolUse and postToolUse (regex on the tool name), permissionRequest (tool name), notification (notification type), preCompact (the trigger, manual or auto) and subagentStart (agent name); on any other event a matcher is dropped with a warning because the CLI does not honor it there. See the hooks reference.
  • Antigravity IDE / Antigravity CLI — project: <project>/.agents/hooks.json; global: ~/.gemini/config/hooks.json. Both targets share the same dedicated hooks.json (a Claude-Code-style matcher map nested under a generated rulesync hook name), so enabling both writes the same file.
  • Devin Desktop (formerly Windsurf) — project: <project>/.windsurf/hooks.json; global: ~/.codeium/windsurf/hooks.json. The Cascade Hooks file location is unchanged by the Devin Desktop rebrand.
  • Factory Droid — project: <project>/.factory/hooks.json; global: ~/.factory/hooks.json. A standalone hooks.json is keyed directly by event name ({"PreToolUse": [...]}); the hooks wrapper Droid documents belongs to settings.json only, so Rulesync writes the bare event map. The file is Rulesync-owned and rewritten wholesale, which also repairs a hooks.json an earlier version left in the wrapped shape — Droid found no known event key at the top level of that file, so none of those hooks ever fired; regenerate to fix. Import accepts both shapes: the top level when it names an event, and the hooks key otherwise, which is also how the legacy .factory/settings.json read-time fallback is understood. Import falls back in order: .factory/hooks.json, then the .factory/settings.json hooks key (with .factory/settings.local.json overlaid, since Droid reads the pair as one) when those settings actually carry a hooks key — an unrelated settings.local.json therefore does not shadow the next step — then the pre-1.0 .factory/hooks/hooks.json — last because Droid renames that file to hooks.migrated.json once it has migrated it, so a copy still sitting there is the least likely to be live. Only .factory/hooks.json is ever written. As with permissions below, a hook that came from the machine-local file is imported into .rulesync/hooks.json like any other, so the next generate writes it into the committed hooks files of every targeted tool — and a hook carries a command someone else's machine would then run. Drop a personal hook from .rulesync/hooks.json after importing if it should stay personal. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's.
  • AugmentCode — project: <project>/.augment/settings.json; global: ~/.augment/settings.json. Hooks are merged under the top-level hooks key of the shared settings file (which also holds toolPermissions).
  • IBM Bob — project: <project>/.bob/settings.json; global: ~/.bob/settings/settings.json. Hooks are merged under the top-level hooks key of the shared settings file; the sibling keys Bob keeps there are left untouched.
  • Tabnine CLI — project: <project>/.tabnine/agent/settings.json; global: ~/.tabnine/agent/settings.json. Hooks are merged under the top-level hooks key of the shared settings file; the sibling keys Tabnine keeps there (mcpServers, tools, hooksConfig, ...) are left untouched.
  • Snowflake Cortex Code — project: <project>/.cortex/settings.json; global: ~/.snowflake/cortex/hooks.json. Hooks are merged under the top-level hooks key of either file; the sibling keys Cortex Code keeps there are left untouched, and neither file is removed by --delete. The per-hook enabled flag and the Remote Hooks source object ({ "source": "github:org/repo/path", "ref": "main" }, command hooks only) round-trip as-is.
  • Codewhale — project: <project>/.codewhale/hooks.toml; global: the [hooks] table of ~/.codewhale/config.toml. The project file holds top-level [[hooks]] entries and is owned by Rulesync; in the global config only the [[hooks.hooks]] entry list is replaced, while the table's own settings (enabled, default_timeout_secs, working_dir) and every other Codewhale setting are left untouched, and the file is never deleted.
  • Continue — project: <project>/.continue/settings.json; global: ~/.continue/settings.json. Hooks are merged under the top-level hooks key of the Continue CLI settings file; the sibling keys Continue keeps there are left untouched, and the file is never removed by --delete.
  • CodeBuddy Code — project: <project>/.codebuddy/settings.json; global: ~/.codebuddy/settings.json. Hooks are merged under the top-level hooks key of the settings file, next to permissions; the sibling keys CodeBuddy keeps there are left untouched, and the file is never removed by --delete. The gitignored .codebuddy/settings.local.json layer is left to CodeBuddy.
  • Command Code — project: <project>/.commandcode/settings.json; global: ~/.commandcode/settings.json. Hooks are merged under the top-level hooks key of the settings file, next to permissions; the sibling keys Command Code keeps there are left untouched, and the file is never removed by --delete.
  • Letta Code — project: <project>/.letta/settings.json; global: ~/.letta/settings.json. Hooks are merged under the top-level hooks key of the settings file, next to permissions; the sibling keys Letta Code keeps there (including the hooks.disabled switch) are left untouched, and the file is never removed by --delete.
  • Qoder — project: <project>/.qoder/settings.json; global: ~/.qoder/settings.json. Hooks are merged under the top-level hooks key of the settings file, next to permissions and (in the user file) mcpServers; the sibling keys Qoder keeps there are left untouched, and the file is never removed by --delete.
  • GitLab Duo CLI — project: <project>/.gitlab/duo/hooks.json; global: ~/.gitlab/duo/hooks.json. The file holds only { "hooks": { ... } }, so Rulesync owns and rewrites it as a whole (and --delete removes it). Hooks are an experiment in GitLab Duo CLI 8.95.0 and later, and project-level hooks are disabled by default: start the CLI with --enable-project-hooks or set GITLAB_ENABLE_PROJECT_HOOKS=true to run them. Only sessionStart ⇄ SessionStart is documented, so every other event is skipped with a warning. Only command hooks are emitted; matcher (a regex on the session source, startup or resume) and timeout (seconds) round-trip. The CLI exports DUO_PROJECT_DIR to hook processes, so a ./-relative or dot-relative command is anchored to "$DUO_PROJECT_DIR"/. See the hooks docs.
  • Crush — project: <project>/crush.json (or .crush.json when that file already exists); global: ~/.config/crush/crush.json. Hooks are written as the flat hooks.PreToolUse list of Crush's shared JSON config; the sibling keys Crush keeps there (providers, options, mcp, permissions, ...) are left untouched, and the file is never removed by --delete.
  • Pool — project: <project>/.poolside/settings.yaml; global: ~/.config/poolside/settings.yaml. Hooks are written as the per-event lists of the hooks block of Pool's shared YAML settings file; the sibling keys Pool keeps there (pool, mcp_servers, tools, paths, ...) and the non-event keys of the hooks block itself (stop_hook_max_continuations) are left untouched, and the file is never removed by --delete.
  • Kimi Code — global only: ~/.kimi-code/config.toml. Hooks are merged into the shared [[hooks]] array without replacing unrelated model, provider, or permission settings.
  • Vibe Code — project: <project>/.vibe/hooks.toml; global: ~/.vibe/hooks.toml. Stable since v2.21.0, which removed the enable_experimental_hooks flag: declaring a hook is enough, so Rulesync writes nothing into .vibe/config.toml for hooks.

Note: Claude Code hooks are merged under the hooks key of the shared .claude/settings.json (project) / ~/.claude/settings.json (global). Whichever feature writes that file (hooks, permissions or ignore), Rulesync adds a "$schema": "https://json.schemastore.org/claude-code-settings.json" key as the first entry when the file does not have one yet, so editors validate and autocomplete the settings. A $schema the file already states is left untouched, whatever its value.

Note: Because each AI tool evolves its own hook surface at its own pace, the matrix above reflects the events Rulesync currently translates. When a tool ships a new event that Rulesync does not yet support, the most reliable path is to open an issue — the matrix is the intended baseline to compare against.

Note: Kiro has two hook formats, and the target you pick decides which one you get. The kiro-cli and kiro-ide targets write the standalone { "version": "v1", "hooks": [ … ] } file that both products read today — .kiro/hooks/rulesync.json in project scope and ~/.kiro/hooks/rulesync.json in user scope — with one array entry per hook carrying name, trigger, an optional matcher, an action ({ "type": "command", "command": … } for a canonical command hook, { "type": "agent", "prompt": … } for a prompt hook), an optional timeout in seconds, enabled, and — on Stop-trigger command hooks — the optional confirm prompt (with its nested confirmCommand). Triggers are PascalCase (sessionStart ⇄ SessionStart, stop ⇄ Stop, …); triggers with no canonical event, such as PostFileSave or PreTaskExec, are reachable through the shared kiro.hooks override block and pass through verbatim. Both targets write the same filename, so they read that one block rather than per-target kiro-cli.hooks / kiro-ide.hooks blocks — otherwise a divergent override would make the file's content depend on which target was generated last. Generating either or both targets therefore always yields the same file. A block authored under kiro-cli.hooks or kiro-ide.hooks is read by nothing and reported with a warning; move it to kiro.hooks (the same key the deprecated kiro alias, and the Kiro MCP and permissions wiring, already use). A second filename would not help either, since Kiro runs every *.json in the directory and both products read the same one. The deprecated kiro alias reads the same block but writes a different format, so the two writers each keep to their own vocabulary: a standalone-only trigger (PostFileSave, PreTaskExec, …) in kiro.hooks is dropped from the alias's .kiro/agents/default.json output with a warning, and an agent-config spelling (agentSpawn, fileEdited, …) is translated to its v1 equivalent (SessionStart, PostFileSave) for the standalone targets rather than written as a trigger Kiro does not define. Keys neither writer recognizes still pass through unchanged.

The deprecated kiro alias still writes the older embedded format: .kiro/agents/default.json under the hooks field, merged with any existing agent configuration (tools, allowedTools, etc.). There, both sessionEnd and stop map to Kiro's stop event, only command-type hooks are supported (prompt-type hooks are silently skipped), per-hook timeouts are timeout_ms (milliseconds), and cache_ttl_seconds maps to the canonical cacheTtl field in both directions. Kiro's hooks migration guide states this format "does not work in 3.0", so prefer kiro-cli.

If you generated kiro-cli hooks with an earlier Rulesync version, the hooks block it left in .kiro/agents/default.json is not removed for you — that file is shared with the permissions and subagents features, so Rulesync never deletes it. On Kiro CLI 2.x, which reads both formats, leaving it in place means every hook fires twice; delete the block by hand (or run Kiro's own agent migration) after regenerating. For the same reason, rulesync import --targets kiro-cli --features hooks now reads only the standalone file: to pull hooks out of an existing agent config, import with --targets kiro. Two event-surface differences come with the switch as well: Kiro's standalone triggers have no SessionEnd, so a canonical sessionEnd hook is dropped with a warning — use stop instead — and cacheTtl has no counterpart outside the agent-config format.

Note: Antigravity (IDE and CLI) writes a dedicated hooks.json keyed by a named hook whose value holds the event map, e.g. { "rulesync": { "PreToolUse": [ { "matcher": "...", "hooks": [...] } ], "Stop": [ { "hooks": [...] } ] } }. Rulesync emits a single generated hook under the stable name rulesync. It supports five lifecycle events — preToolUse ⇄ PreToolUse, postToolUse ⇄ PostToolUse, preModelInvocation ⇄ PreInvocation, postModelInvocation ⇄ PostInvocation, and stop ⇄ Stop — where PreInvocation/PostInvocation/Stop are matcher-less handler lists. On import, both the named-hook wrapper and a legacy flat top-level event map are accepted, and the optional per-hook enabled flag is ignored.

Note: Devin Desktop (formerly Windsurf) Cascade Hooks (GA) are written to a dedicated hooks.json whose top-level hooks key maps each Cascade event name to a flat array of hook objects (no matcher, no type, no inner hooks wrapper, and no timeout). Each object carries command and/or powershell, plus optional show_output and working_directory. Rulesync splits the generic tool lifecycle into Devin's file/command/MCP-specific events, so the canonical events map bijectively: beforeReadFile ⇄ pre_read_code, beforeTabFileRead ⇄ post_read_code, afterTabFileEdit ⇄ pre_write_code, afterFileEdit ⇄ post_write_code, beforeShellExecution ⇄ pre_run_command, afterShellExecution ⇄ post_run_command, beforeMCPExecution ⇄ pre_mcp_tool_use, afterMCPExecution ⇄ post_mcp_tool_use, beforeSubmitPrompt ⇄ pre_user_prompt, afterAgentResponse ⇄ post_cascade_response, beforeAgentResponse ⇄ post_cascade_response_with_transcript, and worktreeCreate ⇄ post_setup_worktree. Canonical events with no Devin equivalent (e.g. sessionStart, stop) are dropped with a logged warning. The Cascade Hooks file location (.windsurf/hooks.json / ~/.codeium/windsurf/hooks.json) is retained from the Windsurf era and is unaffected by the rebrand.

Note: AugmentCode (Auggie CLI) hooks are merged under the top-level hooks key of the shared .augment/settings.json (project) / ~/.augment/settings.json (global), mirroring Claude Code's per-event matcher arrays ({ "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }). The hooks block is merged in place so it coexists with the toolPermissions block from the permissions feature. Auggie documents that its settings files accept JSON with Comments (comments and trailing commas), so Rulesync reads settings.json / settings.local.json as JSONC for every feature that shares the file (mcp, hooks, permissions) and edits only the keys it owns, leaving hand-written comments in place; a file that fails to parse is an error rather than a partial read. Seven lifecycle events are supported — preToolUse ⇄ PreToolUse, postToolUse ⇄ PostToolUse, sessionStart ⇄ SessionStart, sessionEnd ⇄ SessionEnd, stop ⇄ Stop, notification ⇄ Notification, and beforeSubmitPrompt ⇄ PromptSubmit (added in Auggie 0.27.0). The matcher field (a case-sensitive regex, default .*, with mcp:* support) applies only to the tool events PreToolUse/PostToolUse; any matcher on the session events (including Notification and PromptSubmit) is dropped with a logged warning. Two Auggie-specific fields round-trip as well: a command hook's args (extra argv the runner appends, authored as args on the canonical hook) and the matcher group's metadata (includeConversationData / includeMCPMetadata / includeUserContext, which select what the runner puts in the JSON payload the script receives). metadata belongs to the group upstream, so it is authored on any hook of the group and re-applied to every hook of that group on import. Both matter because the hooks key is owned in the shared settings file: a value not written here is erased from a hand-written settings.json on the next generate. Commands are emitted verbatim — Auggie exposes AUGMENT_PROJECT_DIR as a runtime environment variable, not as an inline command substitution, so no directory prefix is added. Only command-type hooks are supported. Auggie expresses timeout in milliseconds (default 60000), so the canonical seconds value is multiplied by 1000 on generate and divided on import. On import (project scope), Rulesync also reads the layered overrides file <workspace>/.augment/settings.local.json — a gitignored, machine-specific file that Auggie merges on top of settings.json — and combines it over the base settings before importing, following Auggie's documented layering (simple values take the local override, mcpServers/plugins replace wholesale, and other objects/lists — including the hooks events — are combined across tiers), so personal hook overrides are picked up without dropping base events. This overlay is import-only and project-only: Rulesync never writes settings.local.json, AugmentCode documents no global ~/.augment/settings.local.json, so the overlay is skipped in global mode. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's.

AugmentCode plugin note: The augmentcode-plugin target writes the same hooks format into a plugin bundle's hooks/hooks.json (project scope only) as a { "hooks": { ... } } document that Rulesync owns as a whole, reading the augmentcode override section. Because plugin hook scripts ship inside the plugin, ./-relative commands are anchored to $AUGMENT_PLUGIN_ROOT (the braced ${AUGMENT_PLUGIN_ROOT} in the exec form) instead of being emitted verbatim. See the plugin packaging guide.

Note: IBM Bob hooks are merged under the top-level hooks key of the shared .bob/settings.json (project) / ~/.bob/settings/settings.json (global), mirroring Claude Code's per-event matcher arrays ({ "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }). Only the hooks key is rewritten, so the other settings Bob keeps in that file survive a regenerate; a file that fails to parse is an error rather than a partial read. Seven lifecycle events are supported — sessionStart ⇄ SessionStart, beforeSubmitPrompt ⇄ UserPromptSubmit, preToolUse ⇄ PreToolUse, postToolUse ⇄ PostToolUse, preCompact ⇄ PreCompact, postCompact ⇄ PostCompact (the two compaction events are Bob Shell 2.0.3+; the IDE ignores them), and stop ⇄ Stop. The matcher field applies only to the tool events PreToolUse/PostToolUse; a matcher on any other event is dropped with a logged warning. Bob compiles the matcher as a regex over the tool name and treats an omitted matcher as match-all, so the canonical catch-all "*" (not a valid regex) is emitted as no matcher instead of verbatim. Commands are emitted verbatim (no directory prefix is added). command and http hooks are supported: Bob Shell spells the webhook handler https ({ "type": "https", "url": "...", "timeout": <seconds> }), so a canonical http hook is written with type: "https" and read back as http on import; prompt, agent and mcp_tool hooks are skipped. Bob-only entries can be authored under the bob.hooks override key. See the Bob Shell lifecycle hooks docs and the Bob IDE lifecycle hooks docs.

Note: Tabnine CLI hooks are merged under the top-level hooks key of the shared .tabnine/agent/settings.json (project) / ~/.tabnine/agent/settings.json (global), as per-event arrays of matcher groups ({ "EventName": [ { "matcher": "...", "sequential": true, "hooks": [ { "type": "command", "command": "...", "name": "...", "description": "...", "timeout": 60000, "env": { ... } } ] } ] }). Only the hooks key is rewritten, so the other settings Tabnine keeps in that file survive a regenerate — including the sibling hooksConfig block (enabled, disabled, notifications), which Rulesync never manages; a file that fails to parse is an error rather than a partial read. Eleven events are supported — sessionStart ⇄ SessionStart, sessionEnd ⇄ SessionEnd, beforeSubmitPrompt ⇄ BeforeAgent, stop ⇄ AfterAgent, preModelInvocation ⇄ BeforeModel, postModelInvocation ⇄ AfterModel, beforeToolSelection ⇄ BeforeToolSelection, preToolUse ⇄ BeforeTool, postToolUse ⇄ AfterTool, preCompact ⇄ PreCompress, and notification ⇄ Notification. Tabnine accepts a matcher on every event: a regex over the tool name on BeforeTool/AfterTool, an exact string on the other events (for example the Notification kind), with an omitted matcher or .* meaning match-all — so the canonical catch-all "*" is emitted as no matcher. Tabnine expresses timeout in milliseconds (default 60000), so the canonical seconds value is multiplied by 1000 on generate and divided on import; name, description and sequential (which makes the hooks of one matcher group run in order) round-trip through the canonical entry, and env is written only when every entry is a safe KEY=VALUE pair (an unsafe entry is dropped with a warning). Commands are emitted verbatim — Tabnine expands $TABNINE_PROJECT_DIR at runtime, so keep it in the command rather than a resolved path — and only command-type hooks are supported; a hook with no command is skipped with a warning because Tabnine would discard it. Tabnine-only entries can be authored under the tabnine.hooks override key. See the hooks docs and the hook configuration reference.

Note: Snowflake Cortex Code hooks are merged under the top-level hooks key of .cortex/settings.json (project) / ~/.snowflake/cortex/hooks.json (global), mirroring Claude Code's per-event matcher arrays ({ "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": 60, "enabled": true } ] } ] }). Only the hooks key is rewritten, so the other settings Cortex Code keeps in the project file survive a regenerate; a file that fails to parse is an error rather than a partial read. Eleven events are supported — sessionStart ⇄ SessionStart, sessionEnd ⇄ SessionEnd, beforeSubmitPrompt ⇄ UserPromptSubmit, stop ⇄ Stop, subagentStop ⇄ SubagentStop, preToolUse ⇄ PreToolUse, postToolUse ⇄ PostToolUse, permissionRequest ⇄ PermissionRequest, preCompact ⇄ PreCompact, notification ⇄ Notification, and setup ⇄ Setup. Both command and prompt hook types are written (http hooks are skipped with a warning). Cortex Code compiles matcher as a case-sensitive regex over the runtime tool name and documents * as "all tools", so the canonical catch-all is emitted verbatim; as in Claude Code, a matcher on UserPromptSubmit or Stop is dropped with a logged warning. timeout is in seconds (default 60) and the per-hook enabled flag round-trips. A dot-relative command (./script.sh, .rulesync/hooks/x.sh) is anchored to the project as "$CORTEX_PROJECT_DIR"/… (Cortex Code exports that variable to hook scripts) and the prefix is stripped again on import; other commands are emitted verbatim. Cortex-Code-only entries can be authored under the cortexcode.hooks override key. See the extensibility docs.

Note: Codewhale hooks are { event, command } entries with snake_case event names: sessionStart → session_start, sessionEnd → session_end, stop → turn_end, beforeSubmitPrompt → message_submit, preToolUse → tool_call_before, postToolUse → tool_call_after, afterError → on_error, subagentStart → subagent_spawn, subagentStop → subagent_complete, and stopFailure → session_error. Only command hooks are emitted; Codewhale runs them through the shell. On the three tool events (preToolUse, postToolUse, afterError) a matcher becomes a tool_name condition (a |-separated list becomes an any condition, and * globs are kept); a matcher that is a regular expression cannot be expressed and the hook is skipped with a warning rather than widened to every tool. Matchers are compared against Codewhale's own tool names (for example exec_shell, write_file, edit_file), which Rulesync does not translate. timeout becomes timeout_secs (a positive whole number of seconds; note that a global default_timeout_secs overrides every per-hook value), and name, background, continue_on_error and a raw Codewhale condition object pass through. Codewhale-only events (mode_change, shell_env, session_idle, waiting_for_user, session_busy) can be written by their native name under codewhale.hooks, where import also files them, and are emitted verbatim. Codewhale runs a project .codewhale/hooks.toml only in a trusted workspace after its exact bytes are approved with /hooks approve, so every regeneration that changes the file needs approving again.

Note: Continue hooks are merged under the top-level hooks key of .continue/settings.json (project) / ~/.continue/settings.json (global), the settings file of the Continue CLI (cn), in Claude Code's per-event matcher-array shape ({ "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": 60 } ] } ] }). Only the hooks key is rewritten, so the other settings Continue keeps in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. Seventeen events are supported, all under their Claude Code names — sessionStart, sessionEnd, beforeSubmitPrompt ⇄ UserPromptSubmit, stop, subagentStart, subagentStop, preToolUse, postToolUse, postToolUseFailure, permissionRequest, preCompact, notification, configChange, teammateIdle, taskCompleted, worktreeCreate and worktreeRemove. Continue accepts command, prompt, http and agent hooks (the mcp_tool and function types are skipped with a warning); a prompt/agent hook carries its model, and the statusMessage, once and async (command hooks only) fields pass through. As in Claude Code, a matcher on an event that takes none (UserPromptSubmit, Stop, TeammateIdle, TaskCompleted, WorktreeCreate, WorktreeRemove) is dropped with a logged warning. A dot-relative command (./script.sh, .rulesync/hooks/x.sh) is anchored to the project as "$CONTINUE_PROJECT_DIR"/… (Continue exports that variable to hook processes) and the prefix is stripped again on import; other commands are emitted verbatim. An http hook's headers are written as authored into the committable .continue/settings.json — Continue expands $VAR / ${VAR} references in hook headers for the variables named in the hook's allowedEnvVars, so write Authorization: Bearer $TOKEN with allowedEnvVars: ["TOKEN"] rather than a literal secret. Continue-only entries can be authored under the continue.hooks override key. See the Continue CLI hooks source.

Note: CodeBuddy Code hooks are merged under the top-level hooks key of .codebuddy/settings.json (project) / ~/.codebuddy/settings.json (global), in Claude Code's per-event matcher-array shape ({ "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": 30 } ] } ] }). Only the hooks key is rewritten, so the permissions block and the other settings CodeBuddy keeps in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. The supported events are the lifecycle events CodeBuddy's plugins reference lists for user-defined and plugin hooks alike, under Claude Code's PascalCase names (beforeSubmitPrompt ⇄ UserPromptSubmit, the others by name: SessionStart, PreToolUse, PermissionRequest, PermissionDenied, PostToolUse, PostToolUseFailure, Notification, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, Stop, StopFailure, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, FileChanged, WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, Elicitation, ElicitationResult, SessionEnd); other canonical events are skipped with a warning. command, http (url, headers), prompt and agent hooks are emitted (mcp_tool is skipped with a warning), and a prompt hook's continueOnBlock flag is carried both ways. CodeBuddy documents prompt hooks for Stop, UserPromptSubmit and PreToolUse only; rulesync does not filter prompt hooks per event, so keep them on those events. matcher is passed through verbatim; UserPromptSubmit, Stop, SubagentStop and PostCompact (and the plugin-only events that fire on every occurrence) take none, so a matcher there is dropped with a logged warning. timeout is in seconds. A dot-relative command (./script.sh, .rulesync/hooks/x.sh) is anchored to the project as "$CODEBUDDY_PROJECT_DIR"/… and the prefix is stripped again on import; other commands are emitted verbatim. CodeBuddy-only entries can be authored under the codebuddy.hooks override key. See the hooks docs and the plugins reference.

Note: Command Code hooks are merged under the top-level hooks key of .commandcode/settings.json (project) / ~/.commandcode/settings.json (global), in Claude Code's per-event matcher-array shape ({ "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": 30 } ] } ] }). Only the hooks key is rewritten, so the permissions block and the other settings Command Code keeps in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. Four events are supported — preToolUse ⇄ PreToolUse, postToolUse ⇄ PostToolUse, stop ⇄ Stop and sessionStart ⇄ SessionStart. Only command hooks exist (prompt, http and the other types are skipped with a warning). matcher is a regex over the tool name on the two tool events, passed through verbatim; Command Code tests it case-insensitively against its display names (SHELL, READ, EDIT, WRITE, SEARCH for grep, GLOB, LIST, WEB_FETCH, WEB_SEARCH, ...; * matches every tool), so a Claude-Code-style Bash matcher never fires there — write Command-Code-specific matchers under the commandcode.hooks override key. Stop and SessionStart take none, so a matcher there is dropped with a logged warning. timeout is in seconds. A dot-relative command (./script.sh, .rulesync/hooks/x.sh) is anchored to the project as "$COMMANDCODE_PROJECT_DIR"/… (Command Code exports that variable to hook processes) and the prefix is stripped again on import; other commands are emitted verbatim. Command-Code-only entries can be authored under the commandcode.hooks override key. See the hooks docs.

Note: Letta Code hooks are merged under the top-level hooks key of .letta/settings.json (project) / ~/.letta/settings.json (global), in Claude Code's per-event matcher-array shape. The event keys of hooks are rewritten as a whole, so existing Letta Code prompt hooks there are replaced (with a warning); the hooks.disabled switch, the permissions block and the other settings in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. Eleven events are supported — sessionStart, sessionEnd, preToolUse, postToolUse, postToolUseFailure, permissionRequest, notification, stop, subagentStop, preCompact, and beforeSubmitPrompt ⇄ UserPromptSubmit. Only command hooks are emitted (Letta Code also runs prompt hooks, but only on some events, so prompt and the other types are skipped with a warning). matcher is kept on the four tool events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest) and dropped with a warning elsewhere. timeout is written in milliseconds (the canonical value is in seconds) and converted back on import. Letta Code runs hooks with the project directory as their working directory (also exported as LETTA_WORKING_DIR), so commands are written verbatim. Letta-Code-only entries can be authored under the lettacode.hooks override key. Letta Code's hooks page is not published yet, so this surface follows the source.

Note: Qoder hooks are merged under the top-level hooks key of .qoder/settings.json (project) / ~/.qoder/settings.json (global), in Claude Code's per-event matcher-array shape; the per-machine .qoder/settings.local.json is left to you. The event keys of hooks are rewritten as a whole, while the permissions block, the mcpServers map and the other settings in that file survive a regenerate, and a file that fails to parse is an error rather than a partial read. All 27 documented Qoder events are supported, under Claude Code's PascalCase names (beforeSubmitPrompt ⇄ UserPromptSubmit). The command, http, prompt and agent handler types are emitted (mcp_tool is skipped with a warning), with timeout in seconds, model on prompt / agent hooks, url / headers / allowedEnvVars on http hooks, if, statusMessage and once on any hook, and shell, env, args, async and asyncRewake on command hooks. matcher is dropped with a warning on UserPromptSubmit, Stop, CwdChanged, WorktreeCreate, WorktreeRemove, TaskCreated, TaskCompleted and TeammateIdle, and kept elsewhere. Dot-relative commands (./x.sh, .rulesync/hooks/x.sh) are anchored with "$QODER_PROJECT_DIR", which Qoder exports into every hook process; other commands are written verbatim. Qoder-only entries can be authored under the qoder.hooks override key. See the hooks docs. Note: Crush hooks are written under the top-level hooks key of Crush's shared JSON config — <project>/crush.json (project) / ~/.config/crush/crush.json (global, via --global) — as a flat per-event array of hook objects with no matcher-group wrapper: { "hooks": { "PreToolUse": [ { "name": "...", "matcher": "...", "command": "...", "timeout": 30 } ] } }. PreToolUse is the only event Crush fires (the only one its hooks docs list), so preToolUse ⇄ PreToolUse is the whole mapping and every other canonical event is skipped with a warning; only command hooks are supported. matcher is a Go regular expression matched case-sensitively against the lower-case tool name (bash, view, edit, write, mcp_<server>_<tool>, ...), so it should be spelled in lower case; a canonical * or empty matcher is omitted so the hook applies to every tool. timeout is an integer number of seconds (Crush defaults to 30), so a fractional canonical value is rounded up. The hook receives a Claude-Code-style JSON payload on stdin (event, tool_name, tool_input, session_id, cwd) and answers through the JSON output envelope on stdout; exit code 2 blocks the call and exit code 49 halts the turn. At project scope Crush reads both .crush.json and crush.json and merges them (objects recursively, lists concatenated, .crush.json on top), so neither file hides the other. When a .crush.json already exists it is the file written (otherwise crush.json); if the crush.json next to it still carries the keys rulesync owns — for example from a generate run before .crush.json was added — those entries stay in effect and are named in a warning, since they can only be removed by hand. Import reads the merged pair, the way Crush does; at global scope only ~/.config/crush/crush.json is read. Only the hooks key is rewritten, so the providers, options, mcp and permissions keys Crush keeps in the same file survive a regenerate, a file that fails to parse is an error rather than a partial read, and the file is never removed by --delete. Crush's newer Bash-based crushrc compiles its hook add builtin into the same hooks.PreToolUse list and overrides the JSON key by key, so a crushrc next to the generated file takes precedence. Import reads hooks.PreToolUse back as preToolUse (Crush accepts the key in any case and with underscores, so pre_tool_use folds into the same event); any other event key in the file is filed under the crush.hooks override so it round-trips verbatim. See the hooks docs.

Pool note: Pool (Poolside's coding agent CLI) hooks are written under the top-level hooks key of Pool's shared YAML settings file — .poolside/settings.yaml (project; the committed, team-shared file — the untracked .poolside/settings.local.yaml overlay is left to you) / ~/.config/poolside/settings.yaml (global, via --global) — as a flat per-event list of hook objects with no matcher-group wrapper: hooks: { PreToolUse: [ { name: ..., matcher: ..., command: ..., timeout: 60 } ] }. Six canonical events map onto Pool's: preToolUse ⇄ PreToolUse, postToolUse ⇄ PostToolUse, beforeSubmitPrompt ⇄ UserPromptSubmit, preCompact ⇄ PreCompact, sessionStart ⇄ SessionStart and stop ⇄ Stop; every other canonical event is skipped with a warning, and only command hooks are supported (a command hook without a command is skipped with a warning). Pool's schema requires matcher on every entry but only tests it on PreToolUse and PostToolUse, against the tool name ("" or * matches any tool, a bare name is an exact match, a|b a list, anything else a regular expression), so the canonical matcher is written on those two events (* when the hook has none) and a matcher authored on any other event is replaced by the * placeholder with a warning, since Pool would ignore it. timeout is an integer number of seconds (Pool defaults to 60), so a fractional canonical value is rounded up. Pool runs the hooks of an event sequentially, feeds each a JSON envelope on stdin (hook_api_version, hook_event_name, session_id, cwd, plus the event's fields such as tool_name and tool_input) and reads a decision JSON from stdout; exit code 2 blocks the action with the stderr text as the reason. Pool merges its settings levels user → project → settings.local.yaml → --settings, where a hook re-declared under the same name at a more specific level replaces the earlier one in place and unnamed hooks accumulate, so name a hook when a project should be able to override it. Only the event lists of the hooks block are rewritten: stop_hook_max_continuations (the cap Pool puts on Stop continuations) and the sibling keys Pool keeps in the same file (pool, mcp_servers, tools, paths, sandbox, ...) survive a regenerate, a file that fails to parse is an error rather than a partial read, and the file is never removed by --delete. Import reads the six events back into their canonical names — a * or empty matcher is dropped, as is any matcher on an event Pool never tests it on — and files any other event key under the pool.hooks override so it round-trips verbatim. See the hooks docs and the settings file reference.

Note: Vibe Code (mistral-vibe) hooks are written to a dedicated .vibe/hooks.toml (project) / ~/.vibe/hooks.toml (global) as a flat [[hooks]] TOML array. Each entry carries its own event type, a command, and optional name, timeout (seconds, default 60), and description. Tool-hook entries (pre_tool / post_tool) additionally carry a tool-name match (an fnmatch glob like bash/mcp_* or a re:-prefixed regex, case-insensitive — the canonical matcher field; * means "any tool") and an optional strict flag; post_agent carries neither. Three events are supported — preToolUse ⇄ pre_tool, postToolUse ⇄ post_tool, and stop ⇄ post_agent (fires after every assistant turn that ends without pending tool calls). Only command-type hooks are emitted. The command runs through a shell, so pipes, &&, redirects and $VAR work. Vibe v2.25.1–v2.25.4 briefly skipped any hook whose command contained a backslash; v2.25.5 removed that skip, so Rulesync writes such commands verbatim without a warning. Rulesync cannot detect the installed Vibe version, so if your hook commands contain backslashes and you are on v2.25.1–v2.25.4, upgrade to v2.25.5 or later. Vibe v2.21.0 graduated hooks from experimental: it renamed all three types (before_tool → pre_tool, after_tool → post_tool, post_agent_turn → post_agent) and removed the enable_experimental_hooks flag, so declaring a hook is enough and Rulesync no longer writes an auxiliary .vibe/config.toml. HookType is a strict enum upstream, so an entry using an old name is rejected outright. Import still reads the three old spellings, so a hooks.toml left behind from before the rename is repaired into the current names rather than being lost. The vibe-plugin target writes the same format into a plugin bundle's ai.mistral.vibe/hooks.toml (project scope only). See the plugin packaging guide.

Cline note (hooks): Cline's file-based hooks are executables, not a config file: it resolves one script per lifecycle event from <project>/.clinerules/hooks/ (project) or ~/Documents/Cline/Hooks/ (global, via --global), named exactly after the event — the extensionless name on Unix, <Event>.ps1 on Windows. Rulesync emits a wrapper script per configured event in both spellings (the POSIX one with mode 0755, since Cline spawns the file itself), plus a rulesync-hooks.json manifest listing the scripts it owns. The wrapper feeds the event payload it receives on stdin to each configured command in order and answers on stdout with {"cancel": …, "contextModification": "", "errorMessage": …}: a command exiting 2 cancels the task, any other non-zero exit is surfaced through errorMessage without cancelling. Ten canonical events map onto Cline's fixed script names — sessionStart → TaskStart, sessionEnd → SessionShutdown, beforeSubmitPrompt → UserPromptSubmit, preToolUse → PreToolUse, postToolUse → PostToolUse, preCompact → PreCompact, notification → Notification, taskCompleted → TaskComplete, afterError → TaskError, stopCancelled → TaskCancel. That set is the union of the two runtimes reading the same directory: the VS Code extension's VALID_HOOK_TYPES and the SDK/CLI's HookConfigFileName, which drops Notification but adds TaskError and SessionShutdown. A script named for an event the running runtime does not know is simply never spawned. That applies to unknown names only: for an event it does know, the SDK/CLI runtime spawns both spellings, because it lists hook files per path rather than per event. Each generated script therefore opens with a guard that stands down on the platform the other one owns — the .ps1 is a no-op off Windows, and the extensionless script is a no-op on Windows. Both are needed: off Windows the runtime runs the .ps1 through pwsh, and on Windows it infers the extensionless file's interpreter from its #!/bin/bash shebang and normalizes it to a bare bash, so with Git Bash on PATH the two spellings both execute your commands — a genuine double fire. (The Unix side was noise rather than duplication: the .ps1 body shells out through cmd /c, which Unix does not have, so it failed on every fire instead.) The PowerShell guard tests that $IsWindows is both defined and false, since it does not exist at all in the Windows PowerShell 5.1 that powershell -File starts; the POSIX guard matches $OSTYPE/uname against the msys/cygwin/mingw family. TaskCancel fires instead of TaskComplete when a task is aborted (the VS Code extension runs it from afterRun when the result status is aborted, the SDK/CLI as agent_abort), so it is the same shape as canonical stopCancelled on Grok CLI and Codex CLI; it is observation-only and cannot cancel anything. Cline's TaskResume has no canonical counterpart and is left unmapped; only command-type hooks are supported, and matcher is ignored because the wrapper is a plain shell script with no payload parser. Each command is passed to bash -c as a single quoted argument, so its own quotes and operators cannot break the wrapper; a command that is not valid shell syntax is reported through errorMessage instead of cancelling (an unparseable command would otherwise exit 2). Note that the same command string runs under bash on Unix and cmd /c on Windows, so shell-specific syntax is not portable across the two generated spellings. Three caveats on ownership: the hooks directory is also where you hand-author your own hooks and the filenames are fixed by Cline, so every generated script carries a rulesync-owned: cline-hooks marker line and a script without that marker is never overwritten (that event is then not managed by Rulesync, and generate warns about it); a script whose event you remove is rewritten as a no-op rather than deleted, while dropping the cline target with --delete removes the marked scripts outright; and rulesync gitignore lists the generated script names explicitly rather than the whole directory, so a hand-authored hook sharing one of those names needs a negation in your own .gitignore if you want to commit it. Generated scripts cannot be imported back into canonical hooks, so this target is generate-only. Cline's in-process hook surface (AgentHooks from @cline/core) is a separate mechanism that Rulesync does not target. See VALID_HOOK_TYPES and HookConfigFileName in the Cline source.

Note: Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory hooks/hooks.json that Goose auto-discovers at startup. Locations are <project>/.agents/plugins/rulesync/hooks/hooks.json (project) and ~/.agents/plugins/rulesync/hooks/hooks.json (global). The JSON shape matches Claude Code's ({ "hooks": { "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "..." } ] } ] } }). Eleven lifecycle events are supported — sessionStart ⇄ SessionStart, sessionEnd ⇄ SessionEnd, stop ⇄ Stop, beforeSubmitPrompt ⇄ UserPromptSubmit, preToolUse ⇄ PreToolUse, postToolUse ⇄ PostToolUse, postToolUseFailure ⇄ PostToolUseFailure, beforeReadFile ⇄ BeforeReadFile, afterFileEdit ⇄ AfterFileEdit, beforeShellExecution ⇄ BeforeShellExecution, and afterShellExecution ⇄ AfterShellExecution — matching Goose's HookEvent enum (it has no SubagentStart/SubagentStop; its twelfth value, PreToolUseResult added in v1.48.0, has no canonical counterpart yet and is not emitted). The matcher regex is preserved, commands are emitted verbatim (Goose exposes PLUGIN_ROOT as a runtime environment variable), and only command-type hooks are supported. A canonical failClosed: true on a preToolUse command hook is written as Goose's on_failure: "block" (v1.48.0+), which makes a hook that crashes, times out or returns unparseable output deny the tool call instead of allowing it; false is omitted as Goose's default, and on import on_failure: "block" / "allow" read back as failClosed: true / false. Goose only reads on_failure on PreToolUse, so a failClosed: true on any other event is dropped with a warning rather than emitted. One exception applies to the matcher: Goose compiles it with Regex::new and silently drops the whole rule when compilation fails, and the canonical catch-all "*" is not a valid regex, so it is emitted as no matcher (which Goose treats as match-all) instead of verbatim.

Note: Qwen Code hooks are written under the top-level hooks key of .qwen/settings.json (project) / ~/.qwen/settings.json (global), using Claude-style PascalCase per-matcher arrays ({ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }). Qwen's supported event set differs from Gemini CLI's, so rulesync defines a Qwen-specific mapping. Twenty-two lifecycle events are supported — sessionStart ⇄ SessionStart, sessionEnd ⇄ SessionEnd, preToolUse ⇄ PreToolUse, postToolUse ⇄ PostToolUse, postToolUseFailure ⇄ PostToolUseFailure, postToolBatch ⇄ PostToolBatch, beforeSubmitPrompt ⇄ UserPromptSubmit, userPromptExpansion ⇄ UserPromptExpansion, stop ⇄ Stop, stopFailure ⇄ StopFailure, subagentStart ⇄ SubagentStart, subagentStop ⇄ SubagentStop, preCompact ⇄ PreCompact, postCompact ⇄ PostCompact, permissionRequest ⇄ PermissionRequest, permissionDenied ⇄ PermissionDenied, notification ⇄ Notification, instructionsLoaded ⇄ InstructionsLoaded, todoCreated ⇄ TodoCreated, todoCompleted ⇄ TodoCompleted, messageDisplay ⇄ MessageDisplay (fires repeatedly as the reply streams; added in Qwen Code v0.19.10), and sessionDelete ⇄ SessionDelete (fires after an explicitly selected session is deleted, via the interactive /delete command or the ACP deleteSession request; matcher-less, added in Qwen Code v0.21.3). Commands are emitted verbatim (no $GEMINI_PROJECT_DIR rewriting). Qwen's four hook types are supported: command, prompt (which carries the required prompt body — with $ARGUMENTS interpolation — and an optional model override, both round-tripped; a prompt hook without a prompt is warned about at generate time since Qwen Code loads it and fails it at runtime), http (which carries a url and POSTs JSON to it; the type and URL round-trip), and function. Per-hook fields added in Qwen Code PR #2827 round-trip as well: command hooks carry async (run in the background), env (extra subprocess environment variables), and shell (bash/powershell); http hooks carry headers (with ${VAR} interpolation), allowedEnvVars (the env-var allowlist), and once (single execution per event per session); statusMessage (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The canonical timeout (seconds) is converted for command hooks: since Qwen Code v0.23.4 (PR #11615) a command hook timeout is read in seconds, but a value of 1000 or more keeps its legacy millisecond meaning, so a canonical timeout below 1000 is written as is and one of 1000 or more is written in milliseconds (1800 becomes 1800000). On import, a command hook timeout of 1000 or more is read as milliseconds and divided by 1000 (10000 becomes 10). http and prompt hook timeouts are always seconds upstream and pass through unchanged, as do function hook timeouts (milliseconds upstream, SDK-registered). The group-level sequential flag (parallel by default) and the top-level disableAllHooks switch are both round-tripped, and other top-level keys in settings.json are preserved. See the Qwen Code hooks docs.

Note: Reasonix hooks are written to a dedicated .reasonix/settings.json (project) / ~/.reasonix/settings.json (global) — a Claude-Code-style but standalone JSON file, separate from the [permissions]/[[plugins]] TOML config. Unlike Claude Code, each event key maps directly to a flat array of hook objects (no matcher/hooks wrapper): { "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ..., "cwd": "...", "env": { ... } } ] }. All thirteen of Reasonix's events (the Events list in internal/hook/hook.go) are mapped — preToolUse ⇄ PreToolUse, postToolUse ⇄ PostToolUse, postToolUseFailure ⇄ PostToolUseFailure, permissionRequest ⇄ PermissionRequest, beforeSubmitPrompt ⇄ UserPromptSubmit, stop ⇄ Stop, stopFailure ⇄ StopFailure, sessionStart ⇄ SessionStart, sessionEnd ⇄ SessionEnd, subagentStop ⇄ SubagentStop, postModelInvocation ⇄ PostLLMCall, notification ⇄ Notification, and preCompact ⇄ PreCompact. match (Reasonix's matcher field name) is honored only on the tool-scoped events PreToolUse/PostToolUse/PostToolUseFailure/PermissionRequest; a matcher on any other event is dropped with a warning. The canonical timeout field is documented in seconds, while Reasonix's timeout is milliseconds, so rulesync converts (× 1000 on generate, ÷ 1000 on import). Each entry also carries the canonical env map and a pass-through cwd (the hook's working directory; Reasonix defaults to the session's), the same two fields Copilot's hooks take, so both are written on generate and lifted back on import instead of being wiped when rulesync rewrites the entry. Only command-type hooks are supported. The settings.json file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the Reasonix Hooks guide.

Note: Grok CLI (xAI Grok Build) hooks are written to a dedicated, standalone rulesync.json that Grok auto-discovers from .grok/hooks/*.json (project) / ~/.grok/hooks/*.json (global). The JSON shape is Claude-Code-compatible: each event nests under the top-level hooks key as a per-matcher array ({ "hooks": { "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] } }). All fifteen documented events map 1:1 onto canonical arms — sessionStart ⇄ SessionStart, sessionEnd ⇄ SessionEnd, beforeSubmitPrompt ⇄ UserPromptSubmit, preToolUse ⇄ PreToolUse, postToolUse ⇄ PostToolUse, postToolUseFailure ⇄ PostToolUseFailure, permissionDenied ⇄ PermissionDenied, stop ⇄ Stop, stopFailure ⇄ StopFailure, stopCancelled ⇄ StopCancelled, notification ⇄ Notification, subagentStart ⇄ SubagentStart, subagentStop ⇄ SubagentStop, preCompact ⇄ PreCompact, and postCompact ⇄ PostCompact. StopCancelled runs instead of Stop when a turn ends without completing — a user interrupt, a declined permission prompt, the --max-turns limit, or a no-progress bail-out — so a stop hook alone does not cover interrupted turns; it is observation-only and cannot block. A matcher (a regex) is honored on every event except Stop and UserPromptSubmit, which always fire; a matcher on either of those two is dropped with a warning. What the regex tests depends on the event: the tool name on PreToolUse / PostToolUse / PostToolUseFailure / PermissionDenied, the notification type on Notification (e.g. idle_prompt), the subagent type on SubagentStart / SubagentStop (e.g. explore), the start source on SessionStart, the end reason on SessionEnd, the compaction trigger (manual or auto) on PreCompact / PostCompact, the error type on StopFailure (rate_limit, authentication_failed, …), and the cancellation reason on StopCancelled (user_interrupt, permission_rejected, permission_cancelled, max_turns, no_progress, or unknown). Earlier Rulesync versions inferred a much narrower set from Claude Code compatibility and dropped the other matchers, so a hook authored that way fired on everything; regenerate to get them back. Commands are emitted verbatim (Grok documents no project-directory variable). See the Grok hooks docs. Both handler types Grok defines round-trip: a command hook runs a command, and an http hook POSTs the payload to its url. A command hook's env map (upstream HookConfig.env, merged into the spawned command's extra_env) round-trips as well. Note that a .rulesync/hooks.* obtained with rulesync fetch can therefore point a Grok hook at any URL — read it before generating.

Note: Kimi Code hooks are global-only and written as flat [[hooks]] entries in ~/.kimi-code/config.toml, with event, command, and optional matcher/timeout. Rulesync maps fourteen canonical lifecycle events to Kimi's PascalCase names: sessionStart, sessionEnd, beforeSubmitPrompt, preToolUse, postToolUse, postToolUseFailure, permissionRequest, stop, stopFailure, notification, subagentStart, subagentStop, preCompact, and postCompact. Kimi's native PermissionResult, Interrupt, TurnStarted, UserPromptQueued, TaskStarted, and SessionHeartbeat events are not mapped from canonical events for Kimi (Interrupt is the same shape as canonical stopCancelled, which Grok CLI, Codex CLI and Cline map, but it stays native-only on Kimi for now), but they can be written and preserved through the kimi-code.hooks override under their native names. (TaskStarted is deliberately not folded into the canonical taskCreated: it fires when a background task starts and matches on task kind, while taskCreated models Claude Code's blocking, matcher-less TaskCreated fired during task creation.) Only command hooks are emitted. A matcher is dropped with a warning on Stop, SessionHeartbeat, and Interrupt, the three events whose Event Reference row documents the matcher as an empty string. Kimi treats matcher as a regular expression tested against the event target, so on these events it is tested against "" and any non-trivial matcher never matches — such a hook silently never ran. Dropping the matcher is therefore a behavior change for existing configs: the hook now fires, which is what authoring it meant. Every other event matches a real value (UserPromptSubmit the submitted prompt text, PermissionRequest and PermissionResult the tool name, PreCompact the trigger, and so on), so matchers there are kept. Kimi normally runs these user-level hooks with each current session project as the working directory, which would let an unrelated repository substitute a relative script or influence commands such as npm test. Rulesync therefore wraps every generated command so it first changes to the trusted absolute directory containing the source .rulesync/hooks.jsonc; relative paths and project-aware commands consistently resolve against that source rather than whichever repository Kimi later opens. This also means a hook that inspects the session project through $PWD or git rev-parse sees the source directory instead; such a hook can read the session project from the cwd field Kimi passes on stdin, or you can set "kimi-code": { "runFromSourceDirectory": false } in .rulesync/hooks.jsonc to emit every command verbatim so it runs in the session project directory, as Claude Code and Codex CLI global hooks do. Opting out restores the exposure described above, so use absolute script paths (e.g. $HOME/hooks/guard.py) rather than relative ones. Import does not recover the flag: a re-imported config regenerates with the wrapper unless you set it again. Kimi requires timeout to be an integer from 1 to 600 seconds; invalid canonical values are omitted with a warning so Kimi can still load the config. The shared TOML file is merged in place and never deleted. See the Kimi Code hooks docs. Note: Codex CLI hooks are written to .codex/hooks.json (project) / ~/.codex/hooks.json (global) with PascalCase event names. The canonical stopCancelled event maps to Codex's Interrupt (shipped in rust-v0.150.0), which runs when the user interrupts an active turn on the main thread; it never fires for idle threads or subagents, Codex ignores any configured matcher on it, its output cannot prevent the interruption (only an optional systemMessage JSON is honoured), and command timeouts are capped at 3s (1s by default). Rulesync forwards the hook as authored, so keep timeout an integer between 1 and 3 and do not rely on a matcher. See the Codex hooks docs.

.github/mcp.json and .copilot/mcp-config.json ​

Example:

json
{
  "mcpServers": {
    "serena": {
      "type": "stdio",
      "command": "uvx",
      "args": ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"]
    },
    "github": {
      "type": "http",
      "url": "http://localhost:3000/mcp"
    },
    "local-dev": {
      "type": "local",
      "command": "node",
      "args": ["scripts/start-local-mcp.js"]
    }
  }
}

This file is used by the GitHub Copilot CLI for MCP server configuration. Rulesync manages it by converting from the unified .rulesync/mcp.jsonc format. Both scopes use the same { "mcpServers": {...} } shape but write to different paths:

  • Project mode: .github/mcp.json (relative to project root) — the Copilot CLI auto-loads MCP servers from this workspace config file (changelog v1.0.61, 2026-06-09).
  • Global mode: ~/.copilot/mcp-config.json (relative to home directory) — the personal/global MCP configuration.

Migration note: earlier Rulesync versions wrote the project-mode Copilot CLI MCP config to .copilot/mcp-config.json (the same path used for global mode). Project mode now writes the dedicated workspace file .github/mcp.json instead, so a previously generated project-scope .copilot/mcp-config.json is no longer managed and can be removed by hand.

Rulesync preserves explicit type values for http, sse, and local servers. For command-based servers that omit a transport type, Rulesync emits the mandatory "type": "stdio" field required by the Copilot CLI. streamable-http is written as http, the transport it names, and the canonical httpUrl alias is normalized to the url Copilot CLI reads. A server the Copilot CLI config cannot express is skipped with a warning rather than failing the run: one that declares no transport at all (the shape a Kilo {"enabled": …} toggle imports as, which switches off a server some other config layer defines — every entry here defines a server), one that names a remote transport but no url/httpUrl, one that names a local transport but no command, and a ws server, since Copilot CLI has no WebSocket transport.

The canonical per-server enabledTools is written as Copilot CLI's own tools allowlist — ["*"] (the default) exposes every tool, a list exposes only those names — and imports back as enabledTools. A server that already carries a native tools value keeps it, and a colliding enabledTools is dropped with a warning. disabledTools has no counterpart upstream (expressing it would need the server's full tool list), so it is not emitted.

rulesync/commands/*.md ​

Example:

md
---
description: "Review a pull request" # command description
targets: ["*"] # * = all, or specific tools
copilot: # copilot specific parameters (optional)
  description: "Review a pull request"
  agent: "agent" # (optional) VS Code prompt-file agent: "ask", "agent", "plan", or a custom agent name (replaces the deprecated "mode")
antigravity: # antigravity specific parameters
  trigger: "/review" # Slash command name; the command is emitted as .agents/skills/review/SKILL.md
takt: # takt specific parameters (optional; emitted under .takt/facets/instructions/)
  name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..")
  extends: "base" # (optional) emit a leading `{extends:<parent>}` facet-inheritance directive (Takt 0.39.0+)
pi: # pi coding agent specific parameters (optional)
  argument-hint: "[message]" # Hint shown in Pi's command palette
codexcli: # Codex CLI custom-prompt specific parameters (optional)
  argument-hint: "[message]" # Hint shown for the custom prompt's arguments
roo: # Roo Code specific parameters (optional)
  mode: "architect" # (optional) mode slug to switch to before running the command body (e.g. "code", "architect")
---

target_pr = $ARGUMENTS

If target_pr is not provided, use the PR of the current branch.

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 for the full mapping.

Antigravity migration note: Antigravity retires workflows on October 19, 2026 in favor of skills, so antigravity-ide and antigravity-cli commands are emitted as skills (.agents/skills/<name>/SKILL.md, invoked as /<name>) instead of .agents/workflows/<name>.md. The workflow-only turbo option and the # Workflow: header are no longer written. Legacy workflows are still imported by rulesync import. Running rulesync generate --delete removes every *.md file left in the workflow directories, including hand-written workflows, so import those first if you want to keep them. If a rulesync skill with the same name as a command is written to the same skills directory, the skill wins and the command is skipped with a warning, even in a run that does not generate that skill. The skill of a deleted command is removed by rulesync generate --delete with the skills feature enabled, because the commands feature does not sweep the shared skills tree.

Codex CLI deprecation note: Codex CLI's own docs now state "Custom prompts are deprecated. Use skills for reusable instructions" (see 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.

GitHub Copilot prompt-files note: the copilot target writes commands as VS Code prompt files (.github/prompts/*.prompt.md). VS Code's own docs now state that prompt files "are deprecated for Agent Host sessions and aren't loaded by Agent Host. They continue to work with the Local agent for now, but the Local agent will be removed in a future release" (see Prompt files), and VS Code ships a default-on migration that turns them into agent skills. Rulesync's copilot commands still generate the prompt files described above — they remain functional on the Local agent and no removal date has been announced, so this behavior is unchanged for now. For new reusable instructions, prefer rulesync's copilot skills support (see .rulesync/skills/*/SKILL.md below), which is not affected by this deprecation.

Warp note: Warp documents skills as its custom slash-command surface — any skill is invocable as /{skill-name} with $ARGUMENTS / $ARGUMENTS[N] / $N argument substitution — so rulesync emits each command onto the native skills surface as .warp/skills/<name>/SKILL.md (project) / ~/.warp/skills/<name>/SKILL.md (global, via --global), with name/description frontmatter derived from the command file. Warp's .warp/workflows/ YAML files are parameterized shell-command templates, not agent prompts, and are deliberately not used. Commands import and --delete are no-ops for warp because the skills feature owns the .warp/skills/ tree (importing it as commands would double-import every skill) — mirrors the Devin note below. Keep command and skill names distinct for this target, since a command and a skill sharing a name write the same SKILL.md path. See the Warp skills docs.

Devin note: Devin's extensibility docs no longer document a standalone workflows/commands component — reusable prompts invoked as slash commands are Skills (/name). Rulesync therefore emits each command onto the native skills surface as .devin/skills/<name>/SKILL.md (project) / ~/.config/devin/skills/<name>/SKILL.md (global, via --global), with name/description frontmatter derived from the command file. The legacy Windsurf/Cascade-era .devin/workflows/ and ~/.codeium/windsurf/global_workflows/ locations are no longer emitted (stale outputs there stay gitignored but are not cleaned up automatically). Commands import and --delete are no-ops for devin because the skills feature owns the .devin/skills/ tree (importing it as commands would double-import every skill). Note that a command and a skill sharing the same name write the same SKILL.md path, so keep command and skill names distinct for this target. The devin-plugin target writes skills into a plugin bundle's skills/<name>/SKILL.md in the same format (project scope only). See the plugin packaging guide.

Agent Skills standard note (agentsskills): Rulesync accepts the legacy rulesync spellings on input but always emits the shapes the specification requires: allowed-tools becomes a space-separated scalar (a YAML list is joined), compatibility becomes a string (an object is flattened to key: value pairs), and metadata values are stringified so the block stays a string→string map. Generation also checks the normative constraints and warns — without failing the run — when name is empty, longer than 64 characters, contains anything but lowercase letters, digits and single hyphens, or does not match its parent directory name; when description is empty or longer than 1024 characters; when compatibility exceeds 500 characters; or when an allowed-tools list entry contains whitespace, which the space-separated form cannot represent. These are warnings rather than errors because a conformant client only skips such a skill, and because import stays lenient as the client-implementation guide advises. Import reports the same empty-description violation, for the one reason it matters on that side too: a conformant client would skip the skill, so a user who is never told has no reason to fix it. Rulesync converts rather than loads, so the skill is imported all the same — dropping the directory would lose content that is still repairable. hermesagent reads the same shape and reports it identically. A value that normalizes to the empty string (compatibility: {}, allowed-tools: []) is dropped rather than written, since the spec requires compatibility to be 1–500 characters when present. On import, allowed-tools is normalized back to the canonical rulesync list, so a generate → import round trip leaves .rulesync/skills/** in the shape it started in (the compatibility and metadata coercions are one-way, because the legacy object/number forms have no conformant equivalent). hermesagent reads the same agentsskills block and applies the same normalization in both directions, so one rulesync source never produces two different on-disk spellings — except for metadata, which stays structured there because Hermes reads metadata.hermes.* as YAML. A hermesagent: override still wins over the shared block (as for every tool-specific section), so a list or mapping written there is emitted as-is and reported as a spec violation rather than rewritten. Validate the result with the spec's own skills-ref validate. Import leniency is root-based as well as tool-based: any tool scanning an Agent Skills interop root (project .agents/skills/, global ~/.agents/skills/, or Amp's ~/.config/agents/skills/) skips-and-warns on a skill (directory-form or flat-file) that fails to load there — the cross-vendor directory is where foreign-authored, potentially non-conformant skills live — while each tool's own native root (e.g. Rovo Dev's .rovodev/skills/) stays fail-fast.

Malformed frontmatter note: a SKILL.md whose YAML fails to parse is retried once with its top-level unquoted values quoted, which recovers the case the Agent Skills client-implementation guide names — description: Use this skill when: the user asks about PDFs, where the second colon ends the scalar. The retry rewrites only top-level key: value lines whose value contains a colon followed by whitespace, so a URL (homepage: https://example.com) and anything already quoted, a flow collection or a block scalar are left alone. An inline comment is cut off before the value is examined, so allowed-tools: Read # TODO: add Bash later is neither rewritten nor turned into a value that grants what the comment had disabled; a file the retry cannot fix still fails with the error it actually has. Recovery is reported with a warning: fix the file itself, since other tools read it with their own parsers.

Replit note: Replit's skills page states conformance to the Agent Skills specification, so replit.allowed-tools accepts either the spec's space-separated string or a canonical rulesync list and is always emitted as the string; replit.compatibility likewise accepts the spec's string alongside the legacy object form. On import, allowed-tools is normalized back to the list, mirroring deepagents — so keep list entries free of whitespace, since the space-separated form cannot represent an entry such as Bash(git commit:*) and a client would read it back as two. An object compatibility is emitted unchanged rather than flattened: unlike the join, that conversion would be one-way, so the legacy form stays as-is and is simply not spec-conformant on disk.

Junie skills note: Junie treats description as optional in a skill's SKILL.md — "If description is not provided in the frontmatter, Junie CLI extracts the first paragraph of the body content as the description." Rulesync's canonical frontmatter requires one, so on import a missing description is filled in the same way, from the first body paragraph (wrapped lines joined into a single line, since it becomes a YAML scalar); a SKILL.md Junie loads fine therefore no longer aborts the import. Markdown headings are skipped, matching upstream's "If the body is also empty or contains only headings, the skill will fail to load" — so a body opening with # Skill Name yields the prose beneath it, not the title. That matters beyond the import: an imported description is written back out explicitly on the next generate, so importing the title would replace Junie's own correct fallback with it, for every tool. A fenced code block is not special-cased — it is ordinary content, so a body whose first non-heading paragraph is a fence yields the fence text, which is a good reason to author a description explicitly. When nothing remains (an empty or headings-only body), Junie could not load the skill either, so Rulesync skips that one skill with a warning and keeps importing the rest. Generation always writes an explicit description, which Junie's own docs recommend. Junie also loads skills from the shared Agent Skills root — <projectRoot>/.agents/skills/ and ~/.agents/skills/ — so Rulesync registers it as an import fallback at either scope; it is import-only (the agentsskills target owns writing there) and is never removed by Junie-target orphan deletion. When the same skill name exists in both roots, the Junie-specific .junie/skills/ root takes precedence — Junie's own docs are silent on that ordering, so this matches how Rulesync already resolves the shared root for kimi-code. Names are compared case-insensitively there too, so .agents/skills/My-Skill does not shadow .junie/skills/my-skill; see the cross-root duplicate note under .rulesync/skills/*/SKILL.md. Junie's third skill source — the custom folders added with --skill-location or the skill-locations key of its config.json — is out of scope for the junie target: Rulesync neither reads nor writes those paths, so a skill kept only there stays invisible to rulesync import. See the agent skills docs.

Vibe skills note: Vibe discovers skills under .vibe/skills/ (project) and ~/.vibe/skills/ (global), plus the shared .agents/skills/ root at both scopes — Vibe's user_skills_dirs returns ~/.vibe/skills and ~/.agents/skills alike. Rulesync registers the shared root as an import fallback at either scope; it is import-only and is never removed by Vibe-target orphan deletion. When the same skill name exists in both roots the Vibe-specific root takes precedence, compared case-insensitively; see the cross-root duplicate note under .rulesync/skills/*/SKILL.md. The names vibe and skill-creator are reserved for Vibe's built-in skills: its SkillManager skips a project or user skill by either name with only a debug log (legacy backend, still the default; the unified harness loads the built-ins from the shipped vibe plugin under namespaced names and does not reserve them), so Rulesync warns at generate time when a Vibe-targeted skill uses one of them (the skill is still written; rename it to make it available in Vibe). disable-model-invocation requires Vibe v2.25.5 or later. Since v2.25.5 Vibe honors disable-model-invocation: true in SKILL.md, making the skill explicit-only (the user can still invoke it with a slash command, but the model cannot load it on its own); Rulesync writes the key from the vibe: block, falling back to the root-level disable-model-invocation, and round-trips it into the vibe: block on import. Rulesync emits the key regardless of the installed Vibe version, and older releases ignore it, so on those versions the model can still load the skill on its own — upgrade to v2.25.5 or later to rely on it. The vibe-plugin target writes the same format into a plugin bundle's skills/<name>/SKILL.md (project scope only). See the plugin packaging guide.

Pi skills note: Pi implements the Agent Skills specification, so pi.allowed-tools accepts either the spec's space-delimited string or a canonical rulesync list and is always emitted as the string; pi.compatibility likewise accepts the spec's string alongside the legacy object form. Importing a spec-conformant SKILL.md used to fail outright. On import, allowed-tools is normalized back to the list, mirroring deepagents; keep list entries free of whitespace, since the space-delimited form cannot represent an entry such as Bash(git commit:*). An allowed-tools value that normalizes to the empty string (an empty list) is dropped rather than written. An object compatibility is emitted unchanged rather than flattened, because that conversion would be one-way.

Hermes Agent note: Commands are global-only and remain distinct from skills. Rulesync writes JSON command specs to ~/.hermes/rulesync/commands/<name>.json, installs the rulesync-commands plugin under ~/.hermes/plugins/, and enables it in ~/.hermes/config.yaml. The plugin registers each spec with Hermes's ctx.register_command() plugin API and dispatches the prompt through delegate_task; invocation arguments are appended to the prompt. .rulesync/skills/<name>/SKILL.md still generates a full Hermes Agent Skill under ~/.hermes/skills/<name>/SKILL.md, which Hermes also exposes as a dynamic slash command. Rulesync rejects command/skill names that would collide in Hermes's slash-command namespace, and rejects nested command paths that flatten to the same name. Generate commands with rulesync generate --targets hermesagent --features commands --global.

Releases before this native plugin transport emitted Hermes commands as ~/.hermes/skills/<name>/SKILL.md. Rulesync cannot distinguish those files from real user-authored skills safely, so remove an obsolete legacy file manually after confirming that .rulesync/skills/<name>/SKILL.md does not own it.

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 followed by the prompt body; besides description, Qwen Code's command loader reads when_to_use (invocation guidance), argument-hint (completion hint), and disable-model-invocation, all typed and round-tripped. 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/variant/subtask fields become frontmatter. A Markdown file takes precedence over an inline entry with the same name. Entries under the OpenCode V2 spelling commands are read too, since OpenCode V1 also loads them (from v1.18.24): an entry without a template is skipped, a provider/model#variant model (or a { providerID, model, variant } object) is split into model and variant, and a command entry wins over a commands entry of the same name, as in OpenCode. A subagent in a commands entry is read as subtask like everywhere else, although OpenCode V1 itself ignores it there. OpenCode V2 renamed subtask to subagent (keeping subtask as a deprecated alias), while OpenCode V1 accepts only subtask, so rulesync reads subagent as subtask — in Markdown files, inline entries, and the opencode (or MiMo Code's mimocode) key of .rulesync/commands/*.md alike, with subagent winning when both are set, as in V2 — and always writes subtask, which both versions understand.

Kilo Code note: Custom commands are Markdown files under .kilo/commands/ (project) / ~/.config/kilo/commands/ (global, via --global), invoked as /<name> where the name is the file stem. Kilo reserves the /goal name for its session-goal command and throws when a custom command by that name is found in either directory (The /goal command is reserved for session goals. Rename the custom command.), which aborts its whole command scan rather than only the one file. Rulesync therefore skips a rulesync command whose emitted stem is goal for the kilo target with a warning, at both scopes, and generates the rest; rename the command or exclude it from kilo via targets. The check runs on the name as written — with the default basename flattening nested/goal.md is also skipped, while with the flattenedCommandNaming: "path" config option it becomes nested-goal.md and is written. Import is unaffected: a goal.md already in .kilo/commands/ is read as it is. See packages/opencode/src/command/index.ts.

MiMo Code note: MiMo Code (an OpenCode distribution) reads the OpenCode command format from .mimocode/commands/*.md (project) / ~/.config/mimocode/commands/*.md (global, via --global). Its tool-specific frontmatter goes under a mimocode key, which takes the same fields as the opencode key; on import, inline command entries in .mimocode/mimocode.json(c) are read as for OpenCode, except the V2 commands spelling, which MiMo Code does not load.

oh-my-pi note: oh-my-pi (omp) reads Pi-style Markdown commands from .omp/commands/*.md (project) / ~/.omp/agent/commands/*.md (global, via --global), not from Pi's prompts/ directory. The format is Pi's (description and argument-hint, with $1 / $@ / $ARGUMENTS placeholders); tool-specific frontmatter goes under an omp key, which takes the same fields as the pi key.

GitLab Duo CLI note: Custom slash commands are Markdown files under the cross-tool .agents/commands/ (project, the same directory the agentsmd target writes) and ~/.gitlab/duo/commands/ (global, via --global), invoked as /<name> where the name is the file stem. Only the optional description frontmatter field is written; GitLab documents no argument placeholders, so the body is passed through as authored. Subdirectories are not supported, and the CLI discovers commands when it starts.

AugmentCode note: Commands are written to .augment/commands/<name>.md (project) / ~/.augment/commands/<name>.md (global, via --global). Subdirectories are namespaces — .augment/commands/git/commit.md is /git:commit — so nested rulesync commands keep their nesting rather than being flattened to a basename. If you generated AugmentCode commands with an earlier Rulesync, the flattened files it wrote are still on disk under their old names; --delete removes them. Auggie also discovers commands under the cross-tool .agents/commands/ root, so import reads that root too and treats a command found there as if it lived under .augment/commands/ — the command's name is its path under whichever root it came from. Generation stays on .augment/commands/, and .agents/commands/ is never written to or swept for orphans, since the files there may belong to another tool — Rulesync itself writes that root for the agentsmd target, so a command already imported from .augment/commands/ is not imported again from there under a flattened name. Auggie's other shared root, .claude/commands/, is deliberately not read: it is Claude Code's own output, which Rulesync already imports as that target. Importing from a shared root is announced, because the result is a Rulesync command written for every target on the next generate. See the custom commands docs. The augmentcode-plugin target writes the same format into a plugin bundle's commands/ directory (project scope only). Auggie reads only description and model from a plugin command, so every other field in the augmentcode section (such as argument-hint) is dropped with a warning; import reads only the bundle's own commands/, never .agents/commands/. See the plugin packaging guide.

Reasonix note: Custom slash commands are Markdown files under .reasonix/commands/ (project) / ~/.reasonix/commands/ (global, via --global) — directly analogous to Claude Code's .claude/commands/, since Reasonix explicitly mirrors Claude Code's conventions. Frontmatter supports description and argument-hint, and the body uses the same $ARGUMENTS / $1…$N placeholder syntax. Subdirectory namespacing is supported (git/commit.md → /git:commit). Any extra fields are preserved on round-trip under the reasonix: block. See the Reasonix GUIDE.

Grok CLI note: Custom slash commands are Markdown files under .grok/commands/ (project) / ~/.grok/commands/ (global, via --global), read by the same Claude-Code-compatible frontmatter parser Grok uses for skills. Rulesync emits description plus, from the grokcli: block, argument-hint, user-invocable (default true) and disable-model-invocation (default false) — the same invocation-control pair Grok skills honor. Two upstream constraints are worth knowing. Grok's command scan is flat and non-recursive, so subdirectory namespacing is not supported: a nested git/commit.md is flattened onto commit.md, and two nested commands with the same basename collide (rulesync warns and the last one wins). And Grok collects skills before commands, letting skills win name collisions — a .grok/skills/<name>/ shadows .grok/commands/<name>.md, so avoid giving a rulesync skill and a rulesync command the same name when targeting Grok. Any extra frontmatter keys are preserved on round-trip under the grokcli: block. See the skills, plugins and marketplaces docs.

Rovo Dev CLI note: Rovo Dev's "saved prompts" are a file-based custom-command surface made of a prompts.yml manifest plus per-prompt Markdown content files, invoked via /prompts [title] [extra]. Rulesync writes the content (no frontmatter) to .rovodev/prompts/<name>.md (project) / ~/.rovodev/prompts/<name>.md (global, via --global), and rebuilds the sibling .rovodev/prompts.yml / ~/.rovodev/prompts.yml manifest with one { name, description, content_file } entry per prompt, content_file pointing at prompts/<name>.md (resolved relative to prompts.yml, matching Rovo Dev's own resolution order). The prompts array is fully replaced from the current rulesync commands on each generate (mirrors the Rovodev MCP adapter fully replacing mcpServers); any other top-level key in an existing manifest is preserved, and the manifest is never deleted. See the saved prompts and CLI commands docs.

ZCode note: Custom commands are Markdown files under .zcode/commands/ (project) / ~/.zcode/commands/ (global, via --global), invoked from the input box with /. Only the global path is spelled out in ZCode's docs, which describe the workspace scope as "workspace-level commands live in the project directory"; the .zcode/commands/ project path is rulesync's inference from that sentence and from the layout ZCode uses for its other workspace assets, not a documented path. The file name is the command's identifier, and the frontmatter carries the description shown in the command picker plus an argument-hint. ZCode's command scan is flat, so subdirectory namespacing is not modeled. Any extra frontmatter fields are preserved on round-trip under the zcode: block. See the ZCode commands docs. The zcode-plugin target writes the same format into a plugin bundle's commands/ directory (project scope only), since ZCode parses plugin commands like .zcode/commands/ ones. See the plugin packaging guide.

Kimi Code plugin note: The kimi-code-plugin target writes commands into a plugin bundle's commands/ directory (project scope only), keeping subdirectories. Kimi registers each one as /<plugin>:<name>, taking the name from the relative path unless frontmatter name overrides it, and substitutes $ARGUMENTS in the body. Rulesync writes description plus any keys of an optional kimi-code-plugin section (such as name) as frontmatter, and import moves every key other than description back into that section. See Plugin Packaging.

CodeBuddy Code note: Custom slash commands are Markdown files under .codebuddy/commands/ (project) / ~/.codebuddy/commands/ (global, via --global). Subdirectory namespacing is supported: .codebuddy/commands/frontend/component.md becomes the /frontend:component command. Rulesync emits the canonical description (a same-named key in the codebuddy: block is ignored); the other documented keys (argument-hint, model, allowed-tools, disable-model-invocation) and any extra ones round-trip through the codebuddy: block. See the CodeBuddy Code slash commands docs.

Codewhale note: User commands are Markdown files under .codewhale/commands/ (project) / ~/.codewhale/commands/ (global, via --global). Codewhale loads the project directory only once the workspace is trusted, scans each directory flat (subdirectories are not supported), and names the command after the lowercased file stem unless frontmatter name replaces it. A project command cannot take the name of a protected built-in such as /trust, /undo, /config or /mcp; Codewhale skips it (or ignores such an alias) with a load error, and Rulesync warns when a generated project command would hit one, or when a frontmatter name is not a single slash-command token (Codewhale then falls back to the file stem). description is written from the canonical field, and the other keys of the codewhale section (name, usage, arguments, argument-hint, allowed-tools, pausable, alias / aliases, hidden) are written beside it. Codewhale reads the frontmatter line by line as key: value pairs rather than as YAML (no YAML quoting or escapes), so Rulesync writes and reads that format directly: every value is written verbatim on one line, a list of strings is joined with , (the separator Codewhale splits allowed-tools and aliases on), and a nested value or a key other than letters, digits, - and _ is dropped with a warning. On import every value comes back as a string, as Codewhale sees it. $ARGUMENTS and $1, $2, ... are passed through unchanged.

IBM Bob note: Custom slash commands are Markdown files under .bob/commands/ (project) / ~/.bob/commands/ (global, via --global), one <name>.md per command, invoked as /<name>. The optional YAML frontmatter carries description and argument-hint; unknown keys are preserved through the bob frontmatter block on import. Bob's docs recommend grouping related commands in subdirectories, so nested paths under .rulesync/commands/ are kept as-is (like Roo). See the IDE slash commands docs and the Bob Shell slash commands docs.

Tabnine CLI note: Custom slash commands are TOML files under .tabnine/agent/commands/ (project) / ~/.tabnine/agent/commands/ (global, via --global), one <name>.toml per command with a description string and the prompt body, invoked as /<name>. Subdirectory namespacing is supported (.tabnine/agent/commands/git/commit.toml becomes /git:commit), so nested paths under .rulesync/commands/ are kept as-is. Tabnine CLI documents the Gemini CLI placeholders, so the universal $ARGUMENTS is translated to and !`cmd` to !{cmd} (and back on import — see Command Syntax); Tabnine's @{path} file-injection form has no universal counterpart and passes through untouched in both directions. Extra TOML keys round-trip through the tabnine frontmatter block: an import puts them there and a generate writes them back after description and prompt. See the custom commands docs.

Continue note: Custom slash commands are Continue prompt files — Markdown under .continue/prompts/ (project) / ~/.continue/prompts/ (global, via --global), one <name>.md per command, invoked as /<name> once the frontmatter declares invokable: true. Rulesync always writes name (the file stem), description and invokable: true, so a file that lacked the flag by hand becomes a slash command after a generate. Placeholders are passed through untouched. Extra frontmatter keys round-trip through the continue frontmatter block; an import drops invokable (re-emitted on generate) and keeps name there only when it differs from the file stem. See the prompts docs.

Command Code note: Custom slash commands are Markdown files under .commandcode/commands/ (project) / ~/.commandcode/commands/ (global, via --global), one <name>.md per command invoked as /<name>; subdirectories are allowed and only group the files, the command name stays the file's basename. Command Code runs the file's whole trimmed body as the prompt — YAML frontmatter is not stripped, it is only skipped when the slash menu picks a summary line — so Rulesync writes the bare body without frontmatter; on import a hand-written file's frontmatter block (which Command Code would send as part of the prompt) is dropped and only the body becomes the rulesync command. See the custom slash commands docs.

Qoder note: Custom commands are Markdown files with YAML frontmatter under .qoder/commands/ (project) / ~/.qoder/commands/ (global, via --global). The command's invocation name comes from its path, and subdirectories namespace it with : (.rulesync/commands/git/commit.md becomes /git:commit), so nested rulesync commands keep their subdirectories. Rulesync writes the rulesync description, which Qoder requires (a command without one is written without it), and the fields of the qoder frontmatter block on top (for example name, which Qoder only uses as the display label in its command list); on import every key other than description goes back into the qoder block. See the commands docs.

JetBrains AI Assistant note: the aiassistant target does not generate commands. The plugin can read custom chat commands from .aiassistant/commands/, but that support is switched off by default behind the registry key llm.chat.input.language.user.commands.enabled (default false in the plugin's plugin.xml as of build 263.5701.47), and the AI Assistant documentation does not describe it, so generated files would be ignored by a default installation. Rulesync will add commands support to the aiassistant target once JetBrains enables or documents the directory.

rulesync/subagents/*.md ​

Nested sources such as .rulesync/subagents/review/security.md retain their directory structure only for targets that support nested agents (Claude Code and Kimi Code). Other targets are skipped with a warning; a flat agent with the same name can be authored separately for them. Before running generate --delete, import any hand-written nested Claude Code agents you want to keep: nested .claude/agents/** files are now included in the orphan sweep, at both project and global scope.

Upgrade note: earlier versions only read Markdown files directly under .rulesync/subagents/ and ignored its subdirectories. Since #3187, every .rulesync/subagents/**/*.md is loaded, so an existing file such as _drafts/old.md starts generating for its targets, and Markdown without valid subagent frontmatter (for example docs/README.md) is skipped with a warning on every run. Move such files out of .rulesync/subagents/ after upgrading. For Claude Code and Kimi Code, which identify agents by frontmatter name, two sources that declare the same name are both generated, with a warning that the tool may load only one of them.

Example:

md
---
name: planner # subagent name
targets: ["*"] # * = all, or specific tools
description: >- # subagent description
  This is the general-purpose planner. The user asks the agent to plan to
  suggest a specification, implement a new feature, refactor the codebase, or
  fix a bug. This agent can be called by the user explicitly only.
claudecode: # for claudecode-specific parameters
  model: inherit # opus, sonnet, haiku, fable, a full model id, or inherit (default)
  tools: ["Read", "Write"] # (optional) allowed tools (string or list)
  disallowedTools: ["Bash"] # (optional) tools to remove (string or list)
  permissionMode: default # (optional) default | acceptEdits | bypassPermissions | plan
  maxTurns: 20 # (optional) maximum agentic turns
  skills: ["skill-creator"] # (optional) Agent Skills to utilize (string or list)
  color: cyan # (optional) UI color (e.g. red, blue, green, cyan, ...)
  memory: project # (optional) user | project | local (`rulesync gitignore` ignores the `local` tree, `.claude/agent-memory-local/`)
  effort: high # (optional) low | medium | high | xhigh | max
  isolation: worktree # (optional) run the subagent in an isolated git worktree
  background: false # (optional) run the subagent in the background
  initialPrompt: "Start by reading the spec." # (optional) seed prompt for the subagent
  mcpServers: {} # (optional) MCP server config (passed through verbatim)
  hooks: {} # (optional) hook config (passed through verbatim)
copilot: # for GitHub Copilot specific parameters
  tools:
    # Listed tools are emitted verbatim; omit `tools` entirely to grant the agent
    # all tools. `agent/runSubagent` is opt-in — add it explicitly only when this
    # subagent needs to orchestrate other subagents.
    - web/fetch
    - agent/runSubagent
copilotcli: # for GitHub Copilot CLI specific parameters (generated to .github/agents/*.agent.md)
  model: claude-sonnet-4.6 # (optional) model id; inherits the session model when unset
  models: ["claude-opus-5", "claude-sonnet-4.6"] # (optional, CLI v1.0.81+) models in priority order; overrides `model`
  modelPolicy: preferred # (optional) preferred (default, overridable) | required (locks dispatch to the authored list)
  tools: ["read", "edit"] # (optional) tools available to the agent (string or list)
opencode: # for OpenCode-specific parameters
  mode: subagent # (optional, defaults to "subagent") OpenCode agent mode
  model: anthropic/claude-sonnet-4-20250514
  variant: high # (optional) default model variant
  temperature: 0.1
  steps: 20 # (optional) max agentic iterations (supersedes the deprecated maxSteps)
  hidden: true # (optional) hide from the @ autocomplete menu (subagents only)
  color: "#FF5733" # (optional) hex color or theme color name
  tools:
    write: false
    edit: false
    bash: false
  permission:
    bash:
      "git diff": allow
kilo: # for Kilo-specific parameters
  mode: all # (optional, defaults to "all") use "subagent" for hidden/subagent-only agents
cursor: # for Cursor-specific parameters (generated to .cursor/agents/*.md)
  model: inherit # (optional, defaults to "inherit") model id, or "inherit" to use the parent's model
  readonly: false # (optional, defaults to false) restrict the subagent to read-only tools
  is_background: false # (optional, defaults to false) run the subagent as a background agent
junie: # for JetBrains Junie CLI specific parameters (generated to .junie/agents/*.md; also imported from .agents/*.md)
  tools: ["Read", "Grep", "Edit"] # allowed tools
  disallowedTools: ["Bash", "WebSearch"] # disallowed tools
  mcpServers: ["github"] # MCP servers the subagent may use
  model: sonnet # model id
  permissionMode: acceptEdits # (optional, defaults to "default") default | acceptEdits | dontAsk | bypassPermissions | plan
  reasoningLevel: high # low | medium | high
  maxTurns: 20 # max agentic turns
  skills: ["kotlin", "writerside"] # Agent Skills to utilize
  allowPromptArgument: true # whether the subagent accepts a prompt argument
takt: # takt specific parameters (optional; emitted under .takt/facets/personas/)
  name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..")
roo: # for Roo Code specific parameters (optional; aggregated into the root .roomodes file)
  slug: planner # (optional) custom mode slug (^[a-zA-Z0-9-]+$); defaults to the sanitized file name
  whenToUse: "When planning a task" # (optional) guidance for automated mode selection
  customInstructions: "Be concise." # (optional) extra behavioral guidelines
  roleDefinition: "You are the planner." # (optional) overrides the body as the mode's roleDefinition
  groups: # (optional, defaults to ["read", "edit", "command", "mcp"]) tool access
    - read
    - ["edit", { fileRegex: "\\.md$", description: "Markdown files" }]
bob: # for IBM Bob specific parameters (optional; aggregated into .bob/custom_modes.yaml)
  slug: planner # (optional) custom mode slug (^[a-zA-Z0-9-]+$); defaults to the sanitized file name
  whenToUse: "When planning a task" # (optional) guidance for automated mode selection
  customInstructions: "Be concise." # (optional) extra behavioral guidelines
  roleDefinition: "You are the planner." # (optional) overrides the body as the mode's roleDefinition
  groups: # (optional, defaults to ["read", "edit", "execute", "mcp"]) Bob tool groups
    - read
    - skill
  allowedSubagents: ["explore"] # (optional) subagents the mode may spawn
pool: # for Pool specific parameters (optional; aggregated into the `subagents` block of .poolside/settings.yaml)
  type: in_process # (optional, defaults to "in_process") in_process | command | agent_server
  description: "Plans a task" # (optional) overrides the top-level description for Pool
  instructions: "Plan carefully." # (optional) overrides the body as the agent's instructions
  command: my-agent # (command type) executable that speaks ACP over stdio
  args: ["--acp"] # (command type) arguments passed to the executable
  env: { LOG_LEVEL: debug } # (command type) environment variables for the process
  agent_server: my-agent # (agent_server type) name of a configured ACP agent server
  inherit_agent_config: true # (optional) inherit the parent's model and settings
  disabled: false # (optional) keep the definition but hide the agent from Pool
---

You are the planner for any tasks.

Based on the user's instruction, create a plan while analyzing the related files. Then, report the plan in detail. You can output files to @tmp/ if needed.

Attention, again, you are just the planner, so though you can read any files and run any commands for analysis, please don't write any code.

Claude Code nested subagents: Rulesync recursively imports Markdown agents from .claude/agents/ (and the equivalent global directory), preserves their relative paths under .rulesync/subagents/, and generates them back to the same nested paths. This makes an import followed by generate --delete round-trip safe for grouped agents such as review/security-reviewer.md. Markdown without valid agent frontmatter (for example, a nested README.md) is skipped with a warning during import and is never an orphan-deletion candidate; it stays in place next to valid agents. Imported nested agents are targeted at claudecode instead of *, because other tools may only support flat agent directories; add other targets explicitly only when they support that layout. When multiple files declare the same frontmatter name, the shallower path wins; ties are resolved lexicographically. File paths are also checked separately for case-insensitive collisions, so differently named agents at Review/Agent.md and review/agent.md cannot overwrite each other on macOS or Windows. Discovery follows symlinks consistently with the other tool import roots described above.

Antigravity note: Antigravity custom agents (CLI v1.1.6+, shared by the IDE and the CLI) are emitted as Markdown + YAML frontmatter to .agents/agents/<name>.md (project) and ~/.gemini/config/agents/<name>.md (global, via --global); the body after the frontmatter is the agent's system prompt. Both antigravity-ide and antigravity-cli read the same two locations, so enabling both writes the same file — the same way they already share .agents/hooks.json. Antigravity also accepts a directory form (<name>/agent.md); Rulesync emits and imports the flat file form only. name and description are required upstream, so a canonical subagent without a description gets a minimal generated fallback rather than a file Antigravity refuses to load. Because the two share that file, every Antigravity target reads the antigravity-ide and antigravity-cli blocks merged in a fixed order (the CLI block wins) — the same rule the MCP feature uses for the same shared-output reason — so generation order never changes the file's content; the antigravity-plugin block is layered on top for the plugin bundle only. Besides the shared name/description, those blocks accept these optional fields (all preserved on round-trip): tools (string list), mainAgent (boolean, default true), subagent (boolean, default true), model (inherit | flash | pro), commandExecutionPolicy (off | auto | eager | sandbox), mcpServers, skills, and plugins. hidden and inheritMcp appear in the v1.1.6 release notes but not in the documented frontmatter table, so they pass through verbatim with no behavior modeled around them; the schema is loose, so any extra keys survive the round-trip too. The antigravity-plugin target writes the same file format into a plugin bundle's agents/ directory (project scope only). See the Antigravity subagents docs and the plugin bundle layout.

AugmentCode plugin note: The augmentcode-plugin target writes AugmentCode subagents into a plugin bundle's agents/<name>.md (project scope only). Auggie names a plugin agent after its file and reads only description, model and hidden from its frontmatter, so Rulesync keeps those (plus the name Rulesync's format requires) and drops every other field in the augmentcode section with a warning. That includes tools and disabled_tools: an agent whose tools were restricted runs with the full tool set when shipped in a plugin, so keep such an agent on the augmentcode target if the restriction matters. A subagent whose name differs from its file name is warned about, since Auggie shows the file name. See the plugin packaging guide.

.agents/agents/ ownership note (agentsmd): .agents/agents/ is not an AGENTS.md convention — the AGENTS.md standard defines only AGENTS.md itself. It is the cross-vendor location AGENTS.md-era clients actually scan for agent definitions: both Antigravity targets generate into it, and Kimi Code reads it alongside its own agents directory. The simulated agentsmd subagent writer therefore emits there too, and because it has no frontmatter model of its own it emits exactly what the Antigravity targets emit — same merged antigravity-ide → antigravity-cli blocks, same serialization — so a simulated writer can never degrade the file a native target owns. Earlier Rulesync versions wrote these files to .agents/subagents/, a directory no documented client reads; they are no longer generated there (stale outputs stay gitignored but are not cleaned up automatically), so move or delete anything you hand-authored under the old path. Note that, unlike the shared root Kimi Code reads (which is import-only for that target), .agents/agents/ is a generated path for agentsmd and the Antigravity targets, so generate --delete sweeps orphans there: a hand-authored agent in that directory that no .rulesync/subagents/*.md produces is removed. Import it first if you want to keep it. A subagent pinned to one writer of this directory (targets: ["antigravity-cli"], say) survives a run that includes the other writers: generate claims every path the run writes, across all the targets it runs, and holds the orphan sweep back until the last of them has written, so no writer of a shared directory treats a sibling's output as a leftover. Narrowing --targets narrows that claim set, though — a later generate --targets antigravity-ide --delete knows nothing about the antigravity-cli-pinned file and sweeps it — so generate the writers of a shared directory together. Because the file is shared, an agentsmd-only generate still emits the Antigravity blocks (tools, model, commandExecutionPolicy, mcpServers, …) verbatim: writing a reduced file would be exactly the silent degradation this shared-path arrangement exists to prevent. That is a deliberate trade-off — a subagent pulled in from someone else's repository with rulesync fetch becomes a live, executable agent definition as soon as any target that writes this path runs, so review .rulesync/subagents/*.md after fetching, exactly as you would review any other fetched configuration before generating.

Qwen Code note: Subagents are emitted as Markdown + YAML frontmatter under .qwen/agents/ (project) and ~/.qwen/agents/ (user/global, via --global); the body is the subagent's system prompt. Besides the shared name/description, the qwencode: block accepts these optional fields (all preserved on round-trip): model, approvalMode (default | plan | auto-edit | yolo | bubble), tools (allowlist), disallowedTools (denylist), maxTurns, color, mcpServers (per-agent MCP overrides — accepts both a record of server specs, matching Qwen's documented shape, and a plain array of server names), and hooks (per-agent hook registrations). See the Qwen Code sub-agents docs.

Tabnine CLI note: Subagents are emitted as Markdown + YAML frontmatter under .tabnine/agent/agents/ (project) and ~/.tabnine/agent/agents/ (user/global, via --global); the body is the subagent's system prompt. Tabnine requires name and description, so a rulesync subagent without a description is written with the placeholder <name> subagent and a warning; the tabnine: block accepts the optional kind (local | remote), tools (allowlist of tool names), model, temperature, max_turns and timeout_mins fields, all preserved on round-trip together with any key Tabnine adds later. Subagents are an experimental Tabnine CLI feature that must be switched on with experimental.enableAgents: true in settings.json — Rulesync writes the agent files only and leaves that flag to you. See the subagents docs.

Snowflake Cortex Code note: Subagents are emitted as Markdown + YAML frontmatter under .cortex/agents/ (project) and ~/.snowflake/cortex/agents/ (user/global, via --global); the body is the subagent's system prompt. Cortex Code requires name; the cortexcode: block accepts the optional description, tools (allowlist of tool names) and model fields, all preserved on round-trip together with any key Cortex Code adds later. (Cortex Code also falls back to .claude/agents/, which the claudecode target already covers.) See the extensibility docs.

Codewhale note: Subagents are emitted as TOML agent profiles under .codewhale/agents/<id>.toml (project) and ~/.codewhale/agents/ (global, via --global). Rulesync writes id (the file name, with characters other than letters, digits, ., _ and - replaced by _), display_name (the subagent name), description, and the body as [instructions] text. Codewhale rejects profiles with unknown fields, so only the role_hint, base_role, loadout, model, provider and reasoning_effort keys of the codewhale section are copied, and only when their value is a string; any other key or value is dropped with a warning. [tools] and [permissions] are not generated. Codewhale also accepts model_hint / model_id for model and thinking / reasoning for reasoning_effort; those spellings are written under the canonical key (the canonical spelling wins when both are set). Import reads display_name (falling back to name, then id), [instructions] text (falling back to the legacy persona), and the alias spellings into model / reasoning_effort.

Command Code note: Subagents are emitted as Markdown + YAML frontmatter under .commandcode/agents/ (project) and ~/.commandcode/agents/ (user/global, via --global); the body is the agent's system prompt. Command Code requires name; name and description come from the rulesync subagent's top-level fields, and the commandcode: block accepts the documented optional fields — tools and disallowedTools (a comma/space-separated string or a YAML array, "*" grants every tool), model, reasoningEffort, maxTurns, permissionMode, background and showOutput — all preserved on round-trip together with any key Command Code adds later. Command Code reserves the names explore, plan, review and general for its built-in agents and silently ignores a custom agent whose name — sanitized to [a-zA-Z0-9_-] and lowercased, so Review counts — is one of them; rulesync still writes such a subagent but warns, since the name is yours to change. See the custom agents docs.

Qoder note: Subagents are Markdown + YAML frontmatter under .qoder/agents/ (project) and ~/.qoder/agents/ (global, via --global); the body is the subagent's system prompt, and its identity is the frontmatter name, not the file name. name and description come from the rulesync subagent's top-level fields, and the qoder: block accepts Qoder's optional fields — tools and disallowedTools (a comma-separated string or a YAML list), model, permissionMode, maxTurns, timeoutMins, color, skills, isolation, mcpServers, hooks and any key Qoder adds later — all preserved on round-trip. See the subagent docs.

Letta Code note: Subagents are emitted as Markdown + YAML frontmatter under .letta/agents/ (project) and ~/.letta/agents/ (user/global, via --global); the body is the subagent's system prompt, and a project subagent overrides a user one of the same name. Letta Code requires name and description, both taken from the rulesync subagent's top-level fields; the lettacode: block carries the optional fields Letta Code reads — tools (a comma-separated string such as Glob, Grep, Read, or all), model, skills, fork and launchProfile — and any other key, all preserved on round-trip. Letta Code reads tools and skills only as comma-separated strings (a YAML list would be ignored, which for tools means every tool), so a list is written joined with , . Letta Code reads frontmatter with its own line-based parser, which does not unquote values, so a string that YAML would have to quote (a description containing : , say) is written as a |- block scalar instead. Letta Code skips a subagent whose name is not lowercase letters, digits and hyphens starting with a letter, one named claude-code or codex (reserved), and one without a description (unless its body is empty, which overlays the built-in subagent of that name); Rulesync still writes such a subagent but warns. See the subagents docs.

Kimi Code note: Subagents are emitted as Markdown files under .kimi-code/agents/ (project) and ~/.kimi-code/agents/ (global). The shared name and required description fields are written to YAML frontmatter; Kimi-specific whenToUse, override, tools, disallowedTools, and subagents fields can be authored under the kimi-code: block and round-trip unchanged. Kimi recursively scans both its Kimi-specific agents directory and the shared .agents/agents/ directory, so Rulesync imports nested Markdown files from both locations and flattens them into .rulesync/subagents/<name>.md using the validated kebab-case agent name. The Kimi-specific root has precedence over .agents/agents/; if multiple source files resolve to the same logical agent name, the first one wins and Rulesync warns about the duplicate. The shared root is import-only and is never removed by Kimi-target orphan deletion. See the Kimi Code custom-agents docs. The kimi-code-plugin target writes the same format into a plugin bundle's agents/<name>.md (project scope only); see Plugin Packaging.

Kiro CLI note: Subagents are emitted as JSON agent configurations under .kiro/agents/ (project) and ~/.kiro/agents/ (global). Kiro allows the JSON name field to be omitted, in which case the filename stem is the agent name; Rulesync accepts that form on import and writes the derived name into the Rulesync frontmatter. Imports through the kiro-cli target retain targets: ["kiro-cli"], so they can be generated back to the same target without changing the target metadata.

Cline note: Cline file-based agents are emitted as YAML files (<name>.yaml) into .cline/agents/ (project) and ~/.cline/agents/ (global, via --global). The file is a YAML frontmatter block followed by the system prompt body, matching Cline's agent config loader: name and description are required (Cline cli-v3.0.23+ refuses to load an agent whose description is missing or empty — a canonical subagent without one gets a minimal generated fallback rather than a file Cline cannot load), and the typed optional fields tools, skills, providerId, modelId, and maxIterations round-trip through the cline: section. Import reads .yml alongside .yaml, matching Cline's isYamlFile().

Devin note: Devin Local custom subagent profiles are emitted as AGENT.md files in a directory-per-agent layout: .devin/agents/<name>/AGENT.md (project) and ~/.config/devin/agents/<name>/AGENT.md (global, via --global). The directory name <name> is the profile id (derived from the rulesync subagent file name). The AGENT.md is a YAML frontmatter block followed by the subagent's system prompt. Besides the shared name/description, the devin subagent block accepts these optional fields (all preserved on round-trip): model (string, override the subagent LLM), allowed-tools (list of strings, restrict available tools), permissions (object with allow/deny/ask string lists, override tool permissions), and max-nesting (integer, enable nested subagent spawning up to the given depth). See the Devin subagents docs. The devin-plugin target writes the same format into a plugin bundle's agents/<name>/AGENT.md (project scope only). See the plugin packaging guide.

Reasonix note: Reasonix native subagents are Skill profiles emitted as SKILL.md files in a directory-per-agent layout: .reasonix/skills/<name>/SKILL.md (project) and ~/.reasonix/skills/<name>/SKILL.md (global, via --global). The directory name <name> is the profile id (derived from the rulesync subagent file name). A subagent is a Skill whose YAML frontmatter declares invocation: manual and runAs: subagent — Rulesync always injects both markers so the SKILL.md is recognized as a manually invoked subagent rather than an auto-discovered skill. Besides the shared name/description, the reasonix subagent block accepts these optional fields (all preserved on round-trip): model (string, subagent LLM), effort (string, reasoning effort), allowed-tools (list of strings, restrict available tools), and color (string, display color). The schema is loose, so any extra keys survive the round-trip. See the Reasonix subagent profiles docs.

Roo / Zoo Code mode-specific rules note: Both tools load .roo/rules-{mode}/ (global ~/.roo/rules-{mode}/) instead of the mode-agnostic .roo/rules/ while that custom mode is active. Set a roo: frontmatter section with mode: architect on a rule to route it there; the section is shared by the roo and zoocode targets, which write the same .roo/ tree. The slug must match ^[a-zA-Z0-9-]+$ (the alphabet the tools themselves accept) — anything else is ignored with a warning and the rule lands in .roo/rules/, rather than interpolating an arbitrary string into a directory name. The key is ignored on the root: true rule, which has no mode-specific counterpart. On import, a file under a mode directory comes back as .rulesync/rules/{name}-{mode}.md carrying roo.mode, suffixed so it cannot collide with a same-named generic rule (an already-suffixed name is left alone, so repeated generate/import cycles converge) and targeted at the importing tool alone, since a wildcard would make every other target emit a mode-scoped rule as an always-on one; mode-directory import is project scope only. Note that mode directories are not swept by orphan deletion — generate --delete only clears .roo/rules/ — because a rules-* glob would also match mode rules you wrote by hand, so a file left behind by a removed rule has to be deleted yourself. Mode-specific skills need no directory support: Zoo Code reads modeSlugs from a skill's own frontmatter at higher priority than the directory it sits in, so the existing roo: modeSlugs frontmatter already scopes a skill in .roo/skills/ to a mode.

Roo skills/commands note (final v3.54.0 state — Roo Code is EOL and its repository archived): Commands are generated to .roo/commands/ (project) and ~/.roo/commands/ (global, via --global; project wins on a name collision). Skill frontmatter beyond name/description — most usefully modeSlugs: string[] for mode targeting — is authored via the roo: section of .rulesync/skills/*/SKILL.md and lifted back into it on import, so it survives the round-trip. A localRoot rule is emitted as AGENTS.local.md, the personal, gitignored override file Roo loads alongside AGENTS.md.

Zoo Code note: Zoo Code (Zoo-Code-Org/Zoo-Code) is the community continuation of the archived Roo Code, named by the Roo shutdown notice and continuing Roo's release numbering (v3.54.0 → v3.72.0 as of 2026-07-25). It still resolves ~/.roo and the project .roo/ layout — the .zoo renaming is confined to provider/auth code — so the zoocode target reuses the roo adapters' path model verbatim across rules (including AGENTS.local.md local-root handling), ignore (.rooignore), MCP (.roo/mcp.json), commands (.roo/commands/), skills (.roo/skills/, roo: frontmatter section), and subagents (the aggregated .roomodes file). Shared mode/skill fields keep riding the roo: frontmatter sections, so one rulesync source never produces two spellings; targeting both roo and zoocode writes the same files, so pick one target per project — and note the fail-open hazard the shared .roomodes creates: a --targets roo generate rewrites it without allowedMcpServers, so opening that workspace in Zoo Code makes every MCP server available to the mode. The post-fork divergence is carried by the zoocode: subagent section: allowedMcpServers (Zoo Code v3.60.0+), a per-mode MCP server allowlist ("when omitted, all servers are available; when set, only the listed servers are injected"), emitted into the mode and lifted back into zoocode: on import. See the Zoo Code docs.

Vibe note: Vibe agent profiles are emitted as TOML to .vibe/agents/<name>.toml (project) and ~/.vibe/agents/<name>.toml (global, via --global). The subagent body is not written into the profile: Vibe's settable field is system_prompt_id, while system_prompt is a read-only property on its config schema and unknown TOML keys are ignored rather than rejected — so a profile carrying system_prompt loads fine and silently runs with the default system prompt. Rulesync therefore writes the body to .vibe/prompts/<name>.md and sets system_prompt_id = "<name>", the same mechanism Vibe's own builtin profiles use (EXPLORE sets "system_prompt_id": "explore"). The two files are always written together, because VibeConfigSchema._check_system_prompt evaluates the id during validation and an unresolvable one makes AgentRegistry._try_load drop the agent with a warning. On import, system_prompt_id is resolved against .vibe/prompts/ and becomes the canonical body; a legacy system_prompt is still read, and an id that resolves to nothing is preserved in the vibe: block so a hand-maintained prompt file keeps working. A subagent with an empty body writes no prompt file and leaves any system_prompt_id you authored alone. Note that .vibe/prompts/ is not swept by orphan deletion — generate --delete only clears .vibe/agents/ — so a prompt file left behind by a removed subagent has to be deleted by hand. The vibe-plugin target writes subagents into a plugin bundle's ai.mistral.vibe/agents/<name>.toml (project scope only), in Vibe's plugin agent document with the prompt inline as instructions. See the plugin packaging guide.

Roo note (as of 2026-06-16): Roo Code reads project custom modes from a single aggregated .roomodes file at the workspace root (YAML; JSON also accepted). Rulesync therefore collapses every Roo-targeted subagent into that file's customModes array — each subagent becomes one mode whose slug is derived from the file name (sanitized to ^[a-zA-Z0-9-]+$), name/description come from the shared frontmatter, and roleDefinition is the subagent body. The optional roo: block supplies groups (defaults to ["read", "edit", "command", "mcp"]), whenToUse, customInstructions, an explicit slug, and a roleDefinition override. (Roo's previous .roo/subagents/ output was inert — Roo Code never read it.) See the Roo custom-modes docs.

IBM Bob note: IBM Bob's native subagents are two fixed presets (explore and general), so Rulesync maps subagents onto Bob's custom modes instead, the same way the roo target fills .roomodes. Every Bob-targeted subagent becomes one entry in the customModes array of the project file .bob/custom_modes.yaml (project scope only: Bob IDE documents ~/.bob/settings/custom_modes.yaml as the global file while Bob Shell documents ~/.bob/custom_modes.yaml, so no global file is written). The slug is derived from the file name (sanitized to ^[a-zA-Z0-9-]+$), name/description come from the shared frontmatter, and roleDefinition is the subagent body. The optional bob: block supplies groups, whenToUse, customInstructions, allowedSubagents, an explicit slug, and a roleDefinition override. It is separate from the roo: block because Bob spells its tool groups differently (read, edit, execute, mcp, skill, workflow, todo, subtask, subagent, mode, as Bob IDE documents them, where Roo has command instead of execute); without bob.groups a mode gets ["read", "edit", "execute", "mcp"]. The Bob Shell custom-modes page still lists Roo's read, edit, browser, command, mcp instead, so set bob.groups explicitly for a mode you use in Bob Shell. On import every mode comes back as its own .rulesync/subagents/<slug>.md targeted at bob, with the other mode fields in the bob: block. Rulesync owns the whole file: generating rewrites it from .rulesync/subagents/, and generate --delete removes it when no subagent targets bob, so import any hand-written modes first. See the Bob custom modes docs.

OpenCode import note: OpenCode lets agents live both as Markdown files under .opencode/agents/*.md and inline in opencode.json/opencode.jsonc under the top-level agent key. On import, rulesync reads both: each inline entry's prompt becomes the subagent body (a "{file:./path}" reference is resolved relative to the config file's location, as OpenCode does), and the remaining fields (description/mode/model/tools/permission/...) become frontmatter under the opencode: block. Entries under the OpenCode V2 spelling agents are read too, lowered the way OpenCode V1 (from v1.18.24) lowers them: system becomes the body, disabled becomes disable, request.body becomes options, a provider/model#variant model (or a { providerID, model, variant } object) is split into model and variant, other V2-only fields (such as permissions) are not read, and an agent entry wins over an agents entry of the same name. A Markdown file takes precedence over an inline entry with the same name, compared case-insensitively — .opencode/agents/planner.md and an inline Planner are one .rulesync/subagents/ file on macOS and Windows, so the inline copy is dropped with a warning rather than silently overwriting the file (two inline entries differing only in case resolve the same way, keeping the earlier one).

MiMo Code note: MiMo Code (an OpenCode distribution) reads the OpenCode agent format from .mimocode/agents/*.md (project) / ~/.config/mimocode/agents/*.md (global, via --global). Put its tool-specific frontmatter under a mimocode key, which takes the same fields as the opencode key; on import, inline agent entries in .mimocode/mimocode.json(c) are read as for OpenCode, except the V2 agents spelling, which MiMo Code does not load.

oh-my-pi note: oh-my-pi (omp) reads Markdown agent definitions from .omp/agents/*.md (project) / ~/.omp/agent/agents/*.md (global, via --global). It ignores an agent without both name and description, so Rulesync falls back to the name when a subagent has no description. Put oh-my-pi's own frontmatter (tools, model, spawns, thinkingLevel, blocking, …) under an omp key; it is copied verbatim. The names main and sub are reserved by oh-my-pi.

Kilo note (as of 2026-05-13): Kilo's documented default for user-defined agents is mode: all, which makes the agent available both as a top-level pick and as a subagent. Set kilo.mode: subagent to opt into hidden/subagent-only behavior.

Besides mode, the kilo subagent block accepts these optional fields (all preserved on round-trip):

FieldTypeNotes
displayNamestringHuman-friendly name shown in pickers
modelstringModel id
variantstringModel variant
temperaturenumberSampling temperature
top_pnumberNucleus-sampling parameter
permissionstring | objectPermission profile name, or a per-tool { <tool>: { allow, deny, ask } } object
promptstringInline system prompt
colorstringUI color
nativebooleanNative (built-in) agent flag
hiddenbooleanHide from top-level picker
disablebooleanDisable the agent
deprecatedbooleanMark as deprecated
stepspositive integerMaximum agentic iterations before a text-only response is forced (an explicit null is accepted and round-trips as-is, so a file that already carries one still imports; earlier Rulesync versions took a list of step objects here, which Kilo never accepted)
optionsobjectFree-form key/value options

Migration note (steps): earlier Rulesync versions typed steps as a list of step objects, which Kilo never accepted — a subagent authored that way produced a file Kilo ignored. It is now the iteration count Kilo documents, so a kilo block (or a .kilo/agents/*.md file) still carrying the list form fails validation with the offending file named, and the run stops rather than writing a file that would not work. Replace the list with the number of iterations you want, or drop the field.

Hermes Agent note: Project generation writes subagent JSON specs under .hermes/rulesync/subagents/ and installs .hermes/plugins/rulesync-subagents/. The plugin resolves specs relative to its own installation, so the same code works in project and global scope. For project scope, Rulesync also enables rulesync-subagents in $HERMES_HOME/config.yaml. Run Hermes from the trusted project root with HERMES_ENABLE_PROJECT_PLUGINS=true; Rulesync deliberately does not persist that global trust gate.

Pool note: Pool has no per-agent files: every subagent is an entry of the subagents.agents map in its settings file, .poolside/settings.yaml for the project and ~/.config/poolside/settings.yaml for --global. Rulesync writes one entry per subagent, keyed by the frontmatter name, with type (defaulting to in_process), description and instructions (the Markdown body, unless pool.instructions overrides it) plus whatever else the pool: block carries, such as command/args/env for a command agent or agent_server for an agent_server one. Rulesync owns the whole subagents block: on every run it recomputes subagents.agents from the Rulesync subagents targeting Pool, dropping agents a previous run generated but keeps subagents.default and Pool's built-in general entry (a Rulesync subagent named general is skipped with a warning) and leaves the file's other keys untouched; a run with no Pool-targeted subagent therefore removes the generated agents instead of leaving stale ones behind, as long as a .rulesync/subagents/ directory exists (a project without one has not adopted the feature and its agents are left alone, and rulesync convert --to pool likewise never retracts, since its sources come from another tool). Every agent Rulesync removes is named in a warning. Hand-written agents that should survive generation belong in .poolside/settings.local.yaml, which Rulesync never touches; at global scope, where Pool has no such overlay, keep them in .rulesync/subagents/ instead. rulesync import --targets pool fans subagents.agents (except general) back out to one .rulesync/subagents/<name>.md per agent, moving instructions into the body and the remaining keys into the pool: block. See the Pool subagents docs and settings file reference.

ZCode note: ZCode subagents are written to .zcode/agents/<name>.md (project) and ~/.zcode/agents/<name>.md (global, via --global). ZCode's docs describe only the user directory — the Settings UI manages user-level subagents alone — but its agent runtime also loads <project>/.zcode/agents/ (observed in the v3.14.3 runtime; not yet documented). Project-scope subagents are parsed like user ones except that permissionMode is discarded, so a permissionMode in the zcode: block is written for --global only and dropped with a warning in project mode. Each subagent is one Markdown file named after the agent, with YAML frontmatter whose keys are camelCase and case-sensitive: name and description are required, and model, thoughtLevel, color, tools, disallowedTools, maxTurns (a positive integer), injectAgentsMd and mcpServers are optional. Put those optional fields in the zcode: block of the canonical subagent; any extra keys are preserved on round-trip through the same block. See the ZCode subagents docs. The zcode-plugin target writes the same format into a plugin bundle's agents/<name>.md (project scope only); ZCode reads plugin agents like ~/.zcode/agents/ ones, so permissionMode is kept there. See the plugin packaging guide.

CodeBuddy Code note: Subagents are written to .codebuddy/agents/<name>.md (project) and ~/.codebuddy/agents/<name>.md (global, via --global) with the required name and description, both taken from the canonical subagent (same-named keys in the codebuddy: block are ignored, so name always matches the file name). The optional keys CodeBuddy documents (tools, model, permissionMode, skills, mcpServers, disallowedTools, effort, maxTurns, background, initialPrompt, memory, hooks) go in the codebuddy: block and are written verbatim, so tools can stay the comma-separated string CodeBuddy's docs use. See the CodeBuddy Code sub-agents docs.

.rulesync/checks/*.md ​

Code review checks are per-check instructions an agent runs during code review. Each check is a single Markdown file with YAML frontmatter (the source of the check identity is the file name — e.g. .rulesync/checks/security.md defines the security check).

Example:

md
---
targets: ["*"] # * = all, or specific tools
description: Flags common security issues # (optional) short summary of the check
severity: high # (optional) low | medium | high | critical
tools: ["Read", "Grep"] # (optional) tool names the check may use
---

Review the diff for injection vulnerabilities, hardcoded secrets, and unsafe
deserialization. Report each finding with a file and line reference.

Amp, AugmentCode, Cursor, Factory Droid, GitLab Duo CLI, Hermes Agent, Kilo Code, Qwen Code, Rovo Dev CLI and Takt consume checks. Amp receives one Markdown file per check:

  • Project scope: .agents/checks/<name>.md
  • Global scope (--global): ~/.config/amp/checks/<name>.md

For Cursor, checks are Bugbot code review instructions, and Bugbot reads one aggregated instruction file per directory rather than a file per check — so every check targeting Cursor collapses into the repository-root .cursor/BUGBOT.md. Each check becomes one section: an HTML-comment marker carrying the check name, an ## <name> heading, and the check body as the instruction text (the description is used when the body is empty). Bugbot reads the file as free prose, so a check's severity and tools have no equivalent there — they are not written and do not come back on import, and neither is description whenever the check also has a body. Project scope only: Bugbot reads repository files and there is no user-level instruction file. Because Bugbot only sees the file when it is committed, the derived .gitignore deliberately does not ignore .cursor/BUGBOT.md (Rovo Dev's .rovodev/.review-agent.md and Factory Droid's .factory/skills/review-guidelines/SKILL.md get the same treatment) — commit the generated file for the reviewer to pick it up. Example output:

md
<!-- rulesync:check:security -->

## security

Review the diff for injection vulnerabilities.

On import the markers split the file back into one check per section, each with targets: ["*"] because Bugbot instructions are plain prose that applies anywhere. Content sitting ahead of the first marker — and a hand-written BUGBOT.md with no markers at all — is imported as a single bugbot check, so nothing in the file is dropped. A check body that contains a marker line of its own (a quoted rulesync doc fragment, say) is written as <!-- rulesync:literal-check:… --> and restored on import, so it cannot split the check it belongs to. Bugbot also merges nested <dir>/.cursor/BUGBOT.md files found while traversing upward from changed files, but rulesync check sources carry no directory-placement semantics, so only the root file is generated.

Generating checks for Cursor replaces .cursor/BUGBOT.md, so run rulesync import --targets cursor --features checks first if the repository already has a hand-written one — generation warns when it is about to replace instructions rulesync did not write. Deletion is guarded: a BUGBOT.md holding anything rulesync did not write — no marker at all, or hand-written text ahead of the first marker — is never removed, so dropping the last check that targets Cursor takes rulesync's own output with it and nothing else.

For Rovo Dev CLI, checks are code-review custom instructions, and Rovo Dev reads one plain-Markdown file rather than a file per check — so every check targeting Rovo Dev collapses into .rovodev/.review-agent.md (note the leading dot in the file name). The file takes no frontmatter. Everything else works exactly as it does for Cursor Bugbot above, because the two surfaces are the same shape: one marked-up section per check, severity/tools dropped, description used only when the body is empty, markers splitting the file back on import (with a hand-written file importing as a single review-agent check), the same <!-- rulesync:literal-check:… --> escaping, the same replace-and-warn on generate, and the same deletion guard for a file holding anything rulesync did not write. Project scope only — these are per-repository review instructions and Rovo Dev documents no user-level equivalent, which is the opposite of the Rovo Dev permissions surface (global only).

For Kilo Code, checks are the repository guidance file that Kilo Code Reviews reads, so every check targeting Kilo collapses into the repository-root REVIEW.md. It works like Rovo Dev's file above — one marked-up section per check, no frontmatter, severity/tools dropped, description used only when the body is empty, markers splitting the file back on import (a hand-written file imports as a single review check), and the same deletion guard — with one difference: REVIEW.md is a generic root-level name other reviewers may read too, so a hand-written REVIEW.md is left untouched with a warning instead of being replaced (import it first, then delete it, to let rulesync own it). Kilo reads the file from the PR/MR base branch, so the derived .gitignore does not ignore it — commit it — and the reviewer only uses it once Use REVIEW.md is enabled in the Code Reviews settings of the Kilo web app, which rulesync cannot toggle. Project scope only.

For Qwen Code, checks are the project review rules that /review injects into its review agents as additional criteria, and Qwen Code reads one plain-Markdown file rather than a file per check — so every check targeting Qwen Code collapses into .qwen/review-rules.md. The file takes no frontmatter, and everything else works exactly as it does for Cursor Bugbot above (one marked-up section per check, severity/tools dropped, description used only when the body is empty, a hand-written file importing as a single review-rules check, the same escaping, replace-and-warn on generate, and the same deletion guard). /review reads .qwen/review-rules.md ahead of its fallbacks (.github/copilot-instructions.md, then a ## Code Review section in AGENTS.md / QWEN.md), and for PR reviews it reads the rules from the base branch, so the derived .gitignore deliberately does not ignore the file — commit it, and the rules take effect for pull requests once they reach the base branch. Project scope only: there is no user-level rules file. (Qwen Code's review.* settings are reviewer policy honored only in user/system settings, not review criteria, so global checks are not written there.)

For Factory Droid, checks are its repository-specific review guidelines. Factory's automated code review has no dedicated instruction file: it reads a skill named review-guidelines and injects it into every review run, so every check targeting Factory Droid collapses into .factory/skills/review-guidelines/SKILL.md. The file is plain Markdown with no frontmatter, matching Factory's documented example — and frontmatter would be self-defeating anyway, since anything ahead of the first marker counts as hand-written text. Everything else works exactly as it does for Cursor Bugbot above (one marked-up section per check, severity/tools dropped, the same escaping, the same deletion guard), with a hand-written file importing as a single review-guidelines check (its YAML frontmatter, if it has any, is skill metadata rather than review prose and is dropped on the way in). Project scope only: the reviewer runs against a repository and reads the file out of it, so there is no user-level equivalent to write. Because the output lives inside the .factory/skills/ tree the skills feature also writes, the derived .gitignore ignores that tree as **/.factory/skills/** and re-includes this one file — git cannot un-ignore a path inside an ignored directory, so the directory pattern has to be the broader one. Re-run rulesync gitignore after upgrading: an earlier rulesync wrote the directory itself as **/.factory/skills/, and git never descends into an ignored directory, so that one line left in place would keep the re-include from working and the reviewer would never see the generated file. The command takes the old spelling out for you, wherever in the file it sits. A review-guidelines skill you authored yourself collides with the generated file. Generation does not replace it: a file holding anything ahead of the first generated marker is left exactly as it is, no Factory Droid checks are written at all for that run, and a warning names the file — the path has a single owner rather than a merge rule, and a file here may be an ordinary skill written for Droid's skill loader rather than review prose, so rulesync does not rewrite it (this is stricter than Cursor Bugbot's replace-and-warn, whose path only the reviewer reads). Import it with rulesync import --targets factorydroid --features checks and then delete the file: importing copies the text into .rulesync/checks/ but leaves the file itself alone, so generation stays blocked until it is gone, and the next generate writes the path back from .rulesync/checks/. Delete it outright if you no longer want it, or rename the directory if what it holds is an ordinary skill rather than review guidelines. The skills feature leaves this path alone in every direction, whatever the file holds: a project-scoped review-guidelines skill is never imported as a skill, never swept as an orphan skill directory by generate --delete, and never generated into .factory/skills/review-guidelines/ in the first place — a rulesync skill of that name is skipped for Factory Droid with a warning, so rename it if you want it generated. Global scope is unaffected, since checks has no user-level output: ~/.factory/skills/review-guidelines/ is an ordinary skill. Deleting the file when the last check targeting Factory Droid goes away is still guarded: it is removed only when it holds nothing but generated sections. Note that this file is committed, unlike the rest of .factory/skills/: Factory's reviewer reads it out of the checked-out repository, so whatever it contains is injected into every review run — review it as you would any other committed instruction file, in particular when it came from rulesync fetch.

For AugmentCode, checks are Augment Code Review guidelines, which live in one YAML file at .augment/code_review_guidelines.yaml rather than in a file per check. Augment groups rules into named areas, each with a description, the globs it applies to, and a list of rules — every rule an id / description / severity triple, all of them required. Rulesync maps one check onto one rule: the body becomes the rule's description (the check's description is used when the body is empty, and the file stem when neither is set), and the rule lands in an area of its own, keyed by the check's file stem. An augmentcode frontmatter block moves it: area groups several checks under one key, with areaDescription and globs taken from the first check to name that area (globs defaults to ["**"], matching Augment's own example; an authored empty list is kept as written, since an area matching nothing is a narrower statement than the catch-all, not an absent value), and id overrides the rule id. An authored area is used verbatim — Augment's own documented example keys an area memory_safety, and rewriting the underscores would leave the original area behind while a second one appeared beside it. Only the file-stem default is slugified, since that has to become a legal key from an arbitrary file name. Rule ids are kept distinct because Augment reports findings by id: two same-named checks in different subdirectories become security and security-2, and a generated id also steps aside for one a preserved hand-written area already uses. Example:

md
---
targets: ["augmentcode"]
severity: high
augmentcode:
  area: databases
  areaDescription: "Data and Database related rules"
  globs: ["db/**"]
  id: "no_pii_in_bigquery"
---

Never store PII data in BigQuery tables.

Severity is lossy in one direction. Augment's scale is high / medium / low with no band above high, so canonical critical is written as high and imports back as high — the canonical value is not recoverable from Augment's file alone. A check with no severity emits medium, since the field cannot be omitted: high would push every unannotated check past the ones deliberately marked medium, and low would bury them.

Generation merges rather than replaces, because Augment's documentation tells users to hand-write this file. Only the areas the current check set claims are rewritten — and a claimed area is replaced as a whole, so a field you hand-added inside one Rulesync regenerates does not survive. Every other area, the file_paths_to_ignore list, and any key Augment adds later are left untouched. file_paths_to_ignore is recognized and preserved but never authored or imported — the canonical check model has no ignore surface, and adding one is a separate question. The cost of merging is that rulesync cannot tell its own leftovers from a hand-written area: renaming a check strands the area under the old key, and when checks remain but none target AugmentCode the existing areas are left in place with a warning rather than guessed at. For the same reason the file is never deleted once it exists — unlike the Markdown surfaces, YAML carries no marker saying which text is rulesync's, since a rewrite drops comments and an unknown top-level key risks Augment's own parser.

On import, each rule becomes its own check (an area of three rules is three checks, not one), carrying the area key, description and globs back in its augmentcode block so the next generate regroups them exactly where they were. A rule missing id or description is left in the YAML rather than imported, and a rule id repeated across two areas is suffixed so the second check does not overwrite the first. Project scope only — the reviewer reads the file from the committed repository, and Augment documents no user-level equivalent.

For GitLab Duo CLI, checks are custom review instructions for the Code Review Flow, which live in one YAML file at .gitlab/duo/mr-review-instructions.yaml (project scope only; the review reads it from the repository, so the file is committed rather than gitignored). The file is a list of instruction groups, each a name, optional fileFilters globs, and the instructions text, and every check targeting GitLab Duo becomes one group: name is the check's file name (without .md) unless gitlabduo.name sets it — GitLab quotes it in review comments as According to custom instructions in '<name>' — fileFilters comes from gitlabduo.fileFilters (omitted means every file), and the body is the instructions (falling back to description). GitLab has no severity or tool fields, so severity and tools are not written.

md
---
targets: ["gitlabduo"]
gitlabduo:
  name: "Ruby Style Guide"
  fileFilters: ["*.rb", "lib/**/*.rb", "!spec/**/*.rb"]
---

Follow the Ruby style guide and flag N+1 queries.

As for AugmentCode, generation merges rather than replaces, because GitLab documents the file as hand-written: a group whose name a check claims is replaced in place, other groups and top-level keys are left untouched, and new groups are appended. The file is re-serialized on write, so YAML comments and quoting or block-scalar styles in it are not preserved. Renaming a check therefore leaves the group under the old name behind, and with no GitLab-targeting check at all the file is left alone with a warning; an existing file is never deleted. On import, each group with a name and instructions becomes one check carrying its name and fileFilters in the gitlabduo block.

For Hermes Agent, Rulesync writes project-local JSON specs under .hermes/plugins/rulesync-checks/checks/ and a rulesync-checks plugin beside them. Its one-shot pre_verify hook fires only for coding turns with changed paths and attempt == 0, then asks Hermes to run all configured checks before finishing. tools is preserved as advisory guidance because Hermes does not enforce an Amp-style per-check tool allowlist. Run Hermes with the project plugin explicitly trusted for that invocation:

sh
HERMES_ENABLE_PROJECT_PLUGINS=1 hermes

Rulesync adds rulesync-checks to plugins.enabled in $HERMES_HOME/config.yaml but deliberately leaves $HERMES_HOME/.env unchanged, preserving Hermes's global trust boundary. Existing plugin configuration is preserved; an explicit plugins.disabled conflict fails generation.

For Takt, checks are quality gates, and they live in the workflow_overrides block of the shared .takt/config.yaml (project) / ~/.takt/config.yaml (global) rather than in files of their own — so every check targeting Takt collapses into that one file. A check becomes one gate: by default a string gate, the body text, which Takt injects into the agent step prompt as a completion directive (the description is used when the body is empty, and the file stem when neither is set); with command in the check's takt frontmatter block, a command gate ({type: command, name, command, cwd, timeout_ms}), which Takt runs after the step and fails on a non-zero exit code. name defaults to the file stem so Takt's logs identify the gate. name, cwd and timeout_ms belong to a command gate, so they are ignored on a check that states no command. steps and personas in that block scope a gate to named workflow steps or personas (workflow_overrides.steps.<step>.quality_gates); an unscoped gate applies everywhere, and a gate naming both is written to both. quality_gates_edit_only is a property of the block as a whole, so one check setting it turns it on for all of them. It reaches only the unscoped gates — Takt runs a steps/personas-scoped gate whether or not the step may edit files — so the reach it narrows is the other checks' unscoped gates, which is warned about when there are any. Takt gates carry no severity or tool allowlist, so a check's severity and tools are not written and do not come back on import. Takt merges quality gates additively and dedupes them (project over global over the workflow YAML's own gates). Example:

md
---
targets: ["takt"]
takt:
  command: ./.takt/quality-gates/check.sh # omit for a string gate
  timeout_ms: 300000
  steps: ["review"] # (optional) scope to named workflow steps
  personas: ["coder"] # (optional) scope to named personas
---

A command gate's command is run by Takt without any policy switch — Takt's default-deny workflow_command_gates.custom_scripts policy applies to gates declared in workflow YAML, not to these — so read the frontmatter of any check you obtain with rulesync fetch before generating. It is not unconditional in the other direction either: since Takt 0.62.0 command gates run after rule resolution and before the transition is applied, and a workflow rule with command_gates: skip applies its transition without running them. The body of a check that carries a command is not used. workflow_overrides is owned by the checks feature: it is rewritten from .rulesync/checks/ on every generate, so a gate deleted there disappears from config.yaml too, while every other key of the file is preserved and the file is never deleted. When checks remain but none of them target Takt — every one names other tools — the block is retracted with a warning, whether an earlier generate or a hand edit put it there; that is what owning the key means, so author gates as checks rather than in config.yaml. A project with no config.yaml does not get one. Emptying .rulesync/checks/ altogether is different: the feature has no source to generate from, so nothing runs and the gates already in config.yaml stay. Delete the block by hand in that case — a command gate left behind keeps running on every step it applies to (unless a rule skips it). On import, each gate becomes its own check file, named from the gate text or the command gate's name. A string gate is prose that applies anywhere, so it imports with targets: ["*"] like an Amp check; a command gate imports as targets: ["takt"], since its body is empty and would generate an empty check for every other tool. A gate scoped to both a step and a persona becomes two checks, and a command gate carrying a field of the wrong type is left in config.yaml rather than imported. The default-deny workflow_command_gates.custom_scripts policy is not written here — Takt validates it against gates declared in workflow YAML, not against these, and it is authorable through the takt block of .rulesync/permissions.*, which owns the security policies. See the Takt workflows docs.

The emitted Amp frontmatter is derived from the source as follows:

Amp fieldSource
namethe source file basename without .md (required by Amp)
descriptiondescription
severity-defaultseverity
toolstools

The frontmatter schema is loose, so extra Amp-specific keys survive a generate/import round-trip (except keys that collide with a rulesync tool-target name such as cursor — those are treated as tool-scoped sections and are not re-emitted). A tool-scoped section (e.g. amp: { "severity-default": "critical" }) overrides the canonical values for that tool — the tool-specific value takes precedence, and the section itself is not emitted (except name, which always comes from the file name). On import, severity-default maps back to the generic severity field, and the name field is dropped because it is re-derived from the file name on the next generate.

Amp runner: The supported way to run checks is Amp's official @amp/review-checks plugin (amp plugins add @amp/review-checks); the amp review command is deprecated, and existing checks in this format continue to apply. Amp also discovers subtree-scoped checks (e.g. api/.agents/checks/, applied to changed files in that directory and its children), but rulesync sources carry no directory-placement semantics, so those subtree-scoped checks are not generated. See the Amp review docs.

JetBrains AI Assistant note: the aiassistant target does not generate checks. Self-Review with AI reads a Markdown guidelines file only from the path entered in Settings | Tools | AI Assistant | Project Settings | Path to rules for AI Self-Review (project settings reference). The setting has no default path, so AI Assistant would not pick up any file Rulesync wrote until each user configured it in the IDE. To reuse your review guidance, point that setting at a plain Markdown file you keep in the repository (a .rulesync/checks/ source would also pass its YAML frontmatter to the reviewer as text).

.rulesync/skills/*/SKILL.md ​

Example:

md
---
name: example-skill # skill name
description: >- # skill description
  A sample skill that demonstrates the skill format
targets: ["*"] # * = all, or specific tools
# (optional) shared default for tools that support the flag — claudecode, codebuddy, copilot,
# copilotcli, crush, cursor, zed, pi, omp, qwencode, vibe, grokcli, factorydroid, dsh, reasonix,
# commandcode, and lettacode. Any of those tool sections can override it by setting their own `disable-model-invocation`
# value below. devin also reads this root value (true maps onto a user-only
# `triggers` list); it has no section key of the same name, but devin.triggers
# overrides it. codexcli also reads this root value (true maps onto
# `policy.allow_implicit_invocation: false` in the agents/openai.yaml sidecar);
# codexcli.policy.allow_implicit_invocation overrides it.
disable-model-invocation: true
# (optional) shared default for tools that support the flag — claudecode, codebuddy, copilot,
# copilotcli, crush, cursor, qwencode, vibe, grokcli, factorydroid, dsh, reasonix, commandcode,
# and lettacode.
# Any of those tool sections can override it by setting their own `user-invocable` value below.
# devin also reads this root value (false maps onto a model-only `triggers`
# list); it has no section key of the same name, but devin.triggers overrides it.
# antigravity-ide, antigravity-cli and antigravity-plugin also read this root value
# (false maps onto `disable-slash-command: true`); their section's
# `disable-slash-command` overrides it.
user-invocable: false
# (optional) shared defaults for the three Agent Skills standard packaging fields.
# Each applies to every tool that models the field, and any of those tool sections
# can override it by setting the same key below (a section value replaces the root
# value outright; the two are never merged). Targets that normalize the field —
# the Agent Skills writers (agentsskills, hermesagent, agentsmd) flatten an object
# `compatibility` to a string and stringify `metadata` values — apply the same
# normalization to a root value as to a section value.
# `license`: claudecode, opencode, mimocode, kilo, kiro, deepagents, copilot, copilotcli, crush,
# pi, omp, replit, rovodev, factorydroid, agentsskills (plus hermesagent and agentsmd), vibe,
# zcode, commandcode.
license: MIT
# `compatibility`: the same tools minus copilot, copilotcli and zcode. A free-form string
# per the Agent Skills spec (1–500 chars); the object form is also accepted.
compatibility: "Requires git and jq"
# `metadata`: the same tools as `compatibility`, plus cursor, zcode, dsh, antigravity-ide,
# antigravity-cli and antigravity-plugin (Antigravity CLI renders `metadata.icon`).
metadata:
  author: example-org
claudecode: # for claudecode-specific parameters
  model: sonnet # opus, sonnet, haiku, or any string
  when_to_use: When the user asks to review a PR # (optional) extra trigger context appended to description
  allowed-tools: # (optional) tools usable without asking; accepts a string or a list
    - "Bash"
    - "Read"
    - "Write"
    - "Grep"
  disallowed-tools: # (optional) removes these tools while the skill is active (string or list)
    - "WebFetch"
  effort: high # (optional) effort while active: low | medium | high | xhigh | max
  argument-hint: "[pr-number]" # (optional) autocomplete hint for expected arguments
  arguments: # (optional) named positional arguments for $name substitution (string or list)
    - "pr_number"
  context: fork # (optional) set to "fork" to run the skill in a forked subagent context
  agent: code-reviewer # (optional) subagent type to use when context: fork
  background: false # (optional, context: fork only) wait for the forked subagent in the invoking turn instead of backgrounding it (default true)
  shell: bash # (optional) shell for ! command blocks: bash (default) or powershell
  hooks: # (optional) hooks scoped to the skill's lifecycle (free-form per the Claude Code docs)
    PreToolUse:
      - matcher: "Bash"
  disable-model-invocation: true # (optional) disable model invocation for this skill
  user-invocable: false # (optional) hide from the / menu while keeping model access
  scheduled-task: true # (optional) emit to ~/.claude/scheduled-tasks/<name>/SKILL.md (global scope only) instead of .claude/skills/<name>/SKILL.md
  # paths (optional) limits auto-activation to matching globs. Accepts a
  # comma-separated string, e.g. paths: "src/**/*.ts,test/**/*.ts", or a list:
  paths:
    - "src/**/*.ts"
    - "test/**/*.ts"
  # Claude Code accepts the three Agent Skills standard fields below but acts on
  # none of them; they matter for claude.ai skill uploads, the Skills API, and
  # packaging with package_skill.py.
  license: Apache-2.0 # (optional) license covering the skill
  compatibility: Requires Node.js 22 or later # (optional) environment requirements, up to 500 characters
  metadata: # (optional) free-form map for your own tooling; a non-map value is dropped by Claude Code
    catalog: internal
codexcli: # for codexcli-specific parameters
  short-description: A brief user-facing description
  # The following sections are emitted to the agents/openai.yaml sidecar next to SKILL.md.
  # See https://developers.openai.com/codex/skills.md
  interface: # (optional) UI metadata
    display_name: Example Skill
    short_description: A brief user-facing description
    default_prompt: Do the thing
  policy: # (optional) invocation policy
    allow_implicit_invocation: false # only invoke explicitly via $skill
  dependencies: # (optional) tool dependencies
    tools:
      - type: mcp
        value: example
        description: Example MCP tool
pi: # for Pi Coding Agent-specific parameters (optional; Agent Skills standard)
  # Authored either as a canonical list or as the spec's space-delimited string;
  # emitted to SKILL.md as the string, and imported back as the list.
  allowed-tools:
    - "Bash"
    - "Read"
  disable-model-invocation: true # (optional) disable model invocation for this skill
  license: MIT # (optional)
  compatibility: "Requires git and jq" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat)
  metadata: # (optional) free-form metadata
    author: rulesync
replit: # for Replit Agent-specific parameters (optional; Agent Skills standard)
  # Authored either as a canonical list or as the spec's space-separated string;
  # emitted to SKILL.md as the string, and imported back as the list.
  allowed-tools:
    - "Bash"
    - "Read"
  license: MIT # (optional)
  compatibility: "Requires git and docker" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat)
  metadata: # (optional) free-form metadata
    author: rulesync
deepagents: # for deepagents-cli (dcode)-specific parameters (optional; Agent Skills standard)
  # Authored as a canonical list; emitted to SKILL.md as a space-delimited string
  # (e.g. "Bash Read") because dcode rejects a YAML list at runtime.
  allowed-tools:
    - "Bash"
    - "Read"
  license: MIT # (optional)
  compatibility: # (optional) free-form compatibility metadata
    deepagents-version: ">=0.1.0"
  metadata: # (optional) free-form metadata
    author: rulesync
opencode: # for OpenCode-specific parameters (optional)
  license: MIT # (optional)
  compatibility: # (optional) free-form compatibility metadata
    opencode-version: ">=1.16.0"
  metadata: # (optional) free-form metadata
    author: rulesync
  allowed-tools: # (optional) Anthropic-spec passthrough; OpenCode ignores unknown fields
    - "Bash"
    - "Read"
kilo: # for Kilo Code-specific parameters (optional)
  license: MIT # (optional)
  compatibility: "Requires git" # (optional) free-form string (an object is also accepted for back-compat)
  metadata: # (optional) free-form metadata
    author: rulesync
  allowed-tools: # (optional) backward-compat passthrough; not part of Kilo's official SKILL.md frontmatter
    - "Bash"
    - "Read"
kiro: # for Kiro-specific parameters (optional; project .kiro/skills/, global ~/.kiro/skills/)
  license: MIT # (optional)
  compatibility: "Requires network access" # (optional) free-form string (an object is also accepted for back-compat)
  metadata: # (optional) free-form metadata
    author: rulesync
  # Any other frontmatter key found in a hand-written SKILL.md is imported into this section and
  # written back out, so a field Rulesync does not model is not lost on regeneration. `name` and
  # `description` are the exception: they have canonical homes at the top level.
kimi-code: # for Kimi Code-specific parameters (optional; project/global .kimi-code/skills/)
  type: inline # (optional) prompt, inline, or flow
  whenToUse: "When reviewing pull requests" # (optional) model invocation hint
  disableModelInvocation: false # (optional) prevent automatic model invocation
  arguments: ["pull_request"] # (optional) named arguments, also accepts a whitespace-separated string
agentsskills: # for the Agent Skills standard target (optional; supports project + global ~/.agents/skills/)
  # `license`, `compatibility` and `metadata` fall back to the root-level values above
  # when omitted here; a value set here wins for this target only.
  license: MIT # (optional)
  compatibility: "Requires Python 3.14+ and uv" # (optional) free-form string, 1–500 chars (an object is also accepted for back-compat)
  metadata: # (optional) free-form metadata (spec-recommended place for skill versioning)
    version: "1.0.0"
  allowed-tools: "shell" # (optional, experimental) space-separated string or list
amp: # for Amp-specific parameters (optional; project .agents/skills/, global ~/.config/agents/skills/)
  # Amp reads the open Agent Skills standard and documents no frontmatter field beyond
  # `name`/`description`, so this section exists only to carry keys a hand-written SKILL.md adds:
  # they are imported into it and written back to the top level of the generated file instead of
  # being erased on regeneration. `name` and `description` are the exception — they have canonical
  # homes at the top level and a section value of either is ignored.
copilot: # for GitHub Copilot-specific parameters (optional; project .github/skills/, global ~/.copilot/skills/)
  license: MIT # (optional)
  allowed-tools: "shell" # (optional) tools pre-approved without per-use confirmation
  argument-hint: "[message]" # (optional) hint shown for the skill's expected arguments
  user-invocable: true # (optional, default true) whether users can run it with /SKILL-NAME
  disable-model-invocation: false # (optional, default false) stop the agent from invoking it on its own
  context: fork # (optional, experimental) run the skill in a forked session (VS Code 1.118+)
  # Any other frontmatter key found in a hand-written SKILL.md is imported into this section and
  # written back out, so a field Rulesync does not model is not lost on regeneration. `name` and
  # `description` are the exception: they have canonical homes at the top level. Like the modeled
  # fields below, such a key rides one section only, so the shared-path caveat that follows applies
  # to it too.
  # `copilot` and `copilotcli` write the same SKILL.md path at both scopes, so with both targets
  # enabled the one generated last wins — and that is the order the targets are listed in, so which
  # section decides the file is not fixed. Set the value in both sections (or, for the two invocation
  # gates, in the shared top-level fields) whenever you generate for both. `context` has no
  # `copilotcli` counterpart, so it survives only when `copilot` is generated last.
copilotcli: # for GitHub Copilot CLI-specific parameters (optional; project .github/skills/, global ~/.copilot/skills/)
  license: MIT # (optional)
  allowed-tools: "shell" # (optional) tools pre-approved without per-use confirmation
  argument-hint: "[message]" # (optional) hint shown for the skill's expected arguments
  user-invocable: true # (optional, default true) whether users can run it with /SKILL-NAME
  disable-model-invocation: false # (optional, default false) stop the agent from invoking it on its own
  # As in the `copilot` section, any other frontmatter key found in a hand-written SKILL.md is
  # imported here and written back out.
rovodev: # for Rovo Dev CLI-specific parameters (optional; Agent Skills standard)
  allowed-tools: "grep bash" # (optional) space-separated string (a YAML list is also accepted)
  license: MIT # (optional)
  compatibility: "Requires Python 3.14+ and uv" # (optional) free-form string (object form also accepted)
  metadata: # (optional) free-form metadata
    author: rulesync
zed: # for Zed-specific parameters (optional)
  disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill
cursor: # for Cursor-specific parameters (optional)
  paths: # (optional) glob patterns (string or list) scoping the skill to matching files
    - "src/**/*.ts"
  disable-model-invocation: true # (optional) only include the skill when invoked via /skill-name
  user-invocable: false # (optional) hide from / autocomplete and typed /skill-name, keep model access
  icon: rocket # (optional) Custom Modes badge icon (code, terminal, bug, git-branch, book-open, beaker, shield, rocket); unknown values fall back to Cursor's default badge
  color: purple # (optional) Custom Modes badge color (default, green, cyan, blue, purple, magenta, orange, yellow, red, brand)
  # On import, the legacy Cursor `globs` field is read as a fallback for `paths` (and dropped when `paths` is also set); generate always writes `paths`.
  metadata: # (optional) free-form metadata
    author: rulesync
factorydroid: # for Factory Droid-specific parameters (optional)
  disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill
  user-invocable: false # (optional) hide from the slash-command menu, keep model access
  enabled: false # (optional, default true) keep the skill on disk but stop Droid loading it
  allowed-tools: "Read Execute" # (optional) tools the skill is designed to use (string or list)
  # Droid documents the four packaging fields below without a type and never validates
  # them, so rulesync carries whatever value they hold through in both directions.
  license: MIT # (optional) license metadata for shared skills
  compatibility: droid # (optional) compatibility metadata for catalogs, plugins, or team tooling
  metadata: # (optional) structured metadata for your own tooling
    owner: platform-team
  version: "1.0.0" # (optional) version string for shared or packaged skills — quote it, since
  # an unquoted 1.0 is a YAML number and is emitted back as `version: 1`
  # `name` and `description` are the exception: the top-level values always win over a
  # value of the same key inside this section.
  # As in the `kiro` section, any other frontmatter key found in a hand-written SKILL.md is
  # imported here and written back out.
takt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit)
  name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..")
  extends: "base" # (optional) emit a leading `{extends:<parent>}` facet-inheritance directive (Takt 0.39.0+)
devin: # for Devin-specific parameters (optional; project .devin/skills/, global ~/.config/devin/skills/)
  argument-hint: "[environment]" # (optional) hint shown after the slash-command name
  model: "fast" # (optional) model override while the skill runs
  subagent: true # (optional) run the skill in a subagent (string or boolean per Devin's docs)
  agent: "deployer" # (optional) named agent profile to run the skill with
  allowed-tools: # (optional) tools available while the skill runs (string or list)
    - "Bash(git status:*)"
  permissions: {} # (optional) auto-approval rules applied while the skill runs (load-bearing since Devin CLI v3000.1.23)
  triggers: ["user"] # (optional) invocation gating; omitted = user + model. The shared disable-model-invocation / user-invocable flags map onto this when unset.
qwencode: # for Qwen Code-specific parameters (optional; project .qwen/skills/, global ~/.qwen/skills/)
  priority: 10 # (optional) higher values appear earlier in /skills listings
  paths: # (optional) glob patterns gating model discovery to matching files (a scalar is coerced to the array Qwen Code requires)
    - "src/**/*.ts"
  user-invocable: false # (optional) hide from slash-command invocation, keep model access
  disable-model-invocation: true # (optional) hide from the model but allow direct user invocation
  allowedTools: # (optional) permissions.allow-syntax rules auto-approved while the skill is active
    - "Shell(git status:*)"
  model: "fast" # (optional) model override while the skill runs (model id, fast, authType:modelId, inherit)
  hooks: {} # (optional) session-scoped hooks registered while the skill runs (settings.json shape)
  when_to_use: "Use when deploying" # (optional) invocation guidance surfaced in the SkillTool description
  argument-hint: "[environment]" # (optional) hint shown after the slash-command name in completion
grokcli: # for Grok CLI-specific parameters (optional)
  user-invocable: false # (optional) hide from the skill tool, keep model access
  disable-model-invocation: true # (optional) block auto-invocation, keep the slash command
  paths: ["src/api/**", "*.sql"] # (optional) gitignore globs; the skill stays hidden until a tool touches a matching file (a comma-separated string is also accepted)
dsh: # for DeepSeek Harness-specific parameters (optional; project .dsh/skills/, global ~/.dsh/skills/)
  whenToUse: "When the user asks to review a PR" # (optional) extra trigger-timing context
  metadata: # (optional) free-form metadata; overrides the root value outright
    author: example-org
  user-invocable: false # (optional) hide from user invocation, keep model access
  disable-model-invocation: true # (optional) block auto-invocation
reasonix: # for Reasonix-specific parameters (optional; project .reasonix/skills/, global ~/.reasonix/skills/)
  user-invocable: false # (optional, v2) hide from the slash surface, keep model access
  disable-model-invocation: true # (optional) hide from the model; also written as `invocation: manual` for the v1 line
  invocation: manual # (optional) Reasonix's catalog switch (auto | manual); always manual when model invocation is disabled
vibe: # for Vibe Code-specific parameters (optional)
  user-invocable: false # (optional) hide from slash-command invocation, keep model access
  disable-model-invocation: true # (optional, Vibe v2.25.5+) explicit-only: keep the slash command, block model-initiated loading
  allowed-tools: "Bash Read" # (optional) space-delimited or list of allowed tool names
antigravity-cli: # for Antigravity CLI-specific parameters (optional; `antigravity-ide` takes the same keys. Both targets write the shared project `.agents/skills/`, so there each reads `antigravity-ide` then `antigravity-cli` merged, the CLI winning; globally each reads its own. `antigravity-plugin` reads the `antigravity-ide` section)
  disable-slash-command: true # (optional, CLI v1.1.12+) hide from the / menu, keep model access; overrides the root user-invocable
  metadata: # (optional) free-form metadata; overrides the root value outright
    icon: "📦" # (optional, CLI v1.1.20+) emoji shown in the /skills catalog and slash-command autocompletion
---

This is the skill body content.

You can provide instructions, context, or any information that helps the AI agent understand and execute this skill effectively.

The skill can include:

- Step-by-step instructions
- Code examples
- Best practices
- Any relevant context

Skills are directory-based and can include additional files alongside SKILL.md.

When `claudecode.scheduled-task: true` is set, that skill is emitted only as a Claude Code scheduled task and is not emitted to other tools even if `targets` contains `"*"`. Claude Code reads scheduled tasks only from the user config directory, so the skill is written only by `rulesync generate --global`, to `~/.claude/scheduled-tasks/<name>/SKILL.md` (Rulesync always writes under `~/.claude/`; it does not follow `CLAUDE_CONFIG_DIR`). A project-scope generate warns and skips it instead of writing a copy under the project's `.claude/scheduled-tasks/` that Claude Code would never run; a project-scope generate with `--delete` removes a copy an earlier version left there. Import still reads both locations.

Supporting-file note: every file beside SKILL.md in a skill directory is copied byte for byte, to whichever tool root the skill is generated into. Most of them are user assets — images, archives, fixtures whose CRLF line endings or missing trailing newline are deliberate — so unlike SKILL.md, whose body and frontmatter Rulesync composes, they get no UTF-8 round-trip, no line-ending normalization and no trailing newline appended. Change detection compares them byte for byte too, so a supporting file written by an older Rulesync (which normalized text files) or edited in place by a formatter is rewritten from the source on the next generate. A supporting file that is executable in .rulesync/skills/<name>/ (any of the 0o111 bits set) is written executable too, with its permission bits masked to 0755 (group and world write bits are dropped, as are setuid, setgid and sticky), so a scripts/run.sh an agent is told to run stays runnable; a generate that finds the directory unchanged still restores the bit if something stripped it. Files without an executable bit keep the mode they already have, or the process default when newly created. A symbolic link standing where the copy should be is never chmod-ed. On Windows there is no mode to carry, and none is applied. The one exception is a supporting file Rulesync composes itself rather than carries through — Codex CLI's agents/openai.yaml — which is compared by parsed content so that re-indenting it does not report a change on every generate. Dot-prefixed entries count as supporting files too — the specification says a skill directory "may contain any files and directories beyond the required SKILL.md", and a hidden .env.example or .config/ is content the skill needs. Some entries are never carried, whether they are reached by their own name or through a symbolic link that renames them:

  • .git, .hg and .svn — a nested repository, whose tracked files are copied but whose history is not. Rulesync warns when it skips a top-level .git.
  • .DS_Store — the macOS Finder's index.
  • Credential stores and credential-shaped files: anything under .ssh/, .aws/ or .gnupg/, plus .npmrc, .netrc, .git-credentials, .pgpass, .pypirc, .htpasswd, .dockercfg, .envrc, .docker/config.json, .kube/config, .config/gh/hosts.yml, .config/gcloud/credentials.db, .gem/credentials, gcloud/application_default_credentials.json, .codex/auth.json and .gemini/oauth_creds.json. A credential name counts wherever it appears in the path, not only as the last segment: .env/production and .netrc/machine hold what the files of those names hold, so a directory given a credential file's name is refused with everything under it. Carrying a secret would copy it into every enabled tool root, multiplying the places it can be committed from. A link that leaves the skill directory to reach a .config/, .local/, .azure/, .m2/, .terraform.d/, .docker/, .kube/ or Keychains/ tree is refused entirely — those are the per-application directories of a home directory, where naming each credential file is a list always one release behind — while a .config/ the skill ships itself is carried as ordinary content. The exemption is for a sibling: a link that goes no higher than the skill directory's own parent, so a global skill under ~/.config/agents/skills/ still shares ../_shared there while the same skill reaching ~/.config/gcloud/ is refused like any other; a tool home such as ~/.claude/ is deliberately not on the list, because that is where the global skills this feature exists to share are kept.
  • .env and every .env.<suffix> spelling except the template ones — the last piece of the name decides, so .env.example, .env.sample, .env.template, .env.dist, .env.defaults and compound spellings such as .env.local.example are carried, being the case this support exists for, while .env.local and .env.production hold real values.
  • Build, cache and virtual-environment trees: .cache, .venv, .tox, .mypy_cache, .pytest_cache, .ruff_cache, .gradle, .next, .nuxt, .turbo, .parcel-cache, .nyc_output and .terraform. These are regenerated from the skill's own sources, and copying a .venv into every enabled tool root would multiply thousands of files that are not skill content.

Names are compared case-insensitively, so .SSH/ is excluded on the platforms that treat it as .ssh/, and trailing dots and spaces are trimmed first, because Windows drops them when the file lands (.env becomes .env there). Leaving out a credential-shaped entry is reported in a warning, and so is an entry whose own name says nothing about it — a link named assets pointing into a .cache/ tree; the ordinary exclusions, where the name in the skill directory is the excluded one — .DS_Store, build and cache trees — stay quiet.

A skill directory is also walked within bounds, because a symbolic link in it may point anywhere: at most 12 directories deep, at most 10,000 files, at most 10,000 directories, at most 200,000 entries looked at, and at most 100MB in total per directory, and each real directory is visited once so that a link pointing back at an ancestor collapses instead of multiplying the walk. Because only one route to a directory is walked, the route is chosen rather than left to the walk order: everything reachable without crossing a symbolic link first, then everything one link away, and so on — so a file keeps the path its SKILL.md refers to. When a route that the hidden-entry rule below then refuses is the one that reached a tree, the directory is walked once more with the hidden routes left out, so a tree that a fully named path also reaches is still carried under that name. Reaching any of those limits leaves files out, so it is always reported in a warning rather than silently truncating the skill; so is an entry that cannot be read at all. A route that passes through /proc, /sys or /dev is refused at every hop rather than only at its destination: /proc/<pid>/fd/N and exe resolve to whatever a running process holds open, so a check of the destination alone would carry another program's private key as though it were an ordinary file. Each hop's parent directory is resolved as well, since a link named fds pointing at /proc/self/fd leaves no /proc in the path fds/3 to check; the hops a chain costs count against the entry limit above, so a directory of long chains cannot buy more work than a directory of files.

.agents/skills/ ownership note: .agents/skills/ is not an AGENTS.md convention — the AGENTS.md standard defines only AGENTS.md itself. It is the Agent Skills project location, which the native agentsskills target writes. Several targets write there — agentsskills, agentsmd, aiassistant, codexcli, amp, zed, replit and both Antigravity targets — because they all implement the same convention. Each native target writes its own documented frontmatter, so enabling more than one and reordering --targets can change which optional keys end up in the file; that is inherent to several tools sharing one path and is not specific to any of them.

The simulated agentsmd writer is the exception that is fixed: it has no frontmatter model of its own (the AGENTS.md standard defines no skills at all), so it used to overwrite the native output with a bare name/description pair and silently drop license, compatibility, metadata and allowed-tools. It now emits exactly what agentsskills emits, so a simulated writer can never degrade the file a native target owns.

AugmentCode plugin note: The augmentcode-plugin target writes skills into a plugin bundle's skills/<name>/SKILL.md in the same format as .augment/skills/ (project scope only), since Auggie reads plugin skills the same way; import reads only the bundle's own skills/, not the shared .agents/skills/ root. See the plugin packaging guide.

Cross-root duplicate note: Several targets discover skills in more than one root — a shared Agent Skills root beside the tool-specific one (junie, vibe, kimi-code, rovodev, augmentcode, warp), nested .claude/skills/ directories (claudecode), or roots the tool's own config points at (opencode). The roots are scanned in precedence order and the first one to claim a skill name keeps it; the multi-root subagent targets follow the same rule, and for targets whose native format aggregates many subagents into one file (Roo's .roomodes) the same de-duplication is applied to the paths the entries fan out to. The comparison is case-insensitive (and Unicode normalizing), because every imported skill is written back into a single .rulesync/skills/ tree, where .junie/skills/my-skill and .agents/skills/My-Skill are one directory on macOS and Windows — comparing the names exactly would let both through, and the shared copy, written last, would overwrite the tool-specific one, inverting the precedence the roots were ordered by. A collision that differs only in case is reported with a warning naming the ignored copy; an exact repeat stays quiet, being the ordinary overlay. Kimi Code is the exception on both counts: it identifies a skill or subagent by its frontmatter name folded to lower case, so two spellings are already one name upstream and the duplicate is resolved silently. Trade-off: the rule applies on every platform, so on a case-sensitive filesystem — where the two really are separate skills — rulesync import and rulesync convert drop the lower-precedence one instead of importing it under its own name. Rename one of them if you need both — and if the dropped copy lives in a shared .agents/skills/ root, import agentsskills in the same run, so the skill still reaches .rulesync/ and a later agentsskills generate does not prune it as an orphan. Slash commands are not covered by this rule yet: augmentcode reads both .augment/commands/ and the shared .agents/commands/, but their names are still compared exactly, so deploy.md and Deploy.md are both imported and collide in .rulesync/commands/ on a case-insensitive filesystem (issue #2741).

Claude Code nested skills note: Claude Code v2.1.178+ also loads skills from nested .claude/skills/ directories below the working directory (a skill in apps/web/.claude/skills/ becomes available when working on files there, and a name clash with a root skill keeps both under a directory-qualified name like apps/web:deploy). rulesync import --targets claudecode --features skills discovers those nested directories (import-only, lenient, same dependency/build-directory exclusions as the nested AGENTS.md scan; symlinks not followed) so an existing nested skill is no longer invisible. A nested directory whose reported path does not resolve inside the project is skipped with a warning rather than followed: a glob rewrites a backslash in a directory name into a path separator, so the path it reports may belong to nothing on disk, or lead out of the project — through a .. the name also carries, or through a symlink the scan itself never follows. A reported path that stays inside the project but resolves onto something that is not a .claude/skills directory of its own — a dependency or build tree, or the project root itself — is skipped the same way, since reading it as a skills directory would offer every directory under it as a skill. The project-root .claude/skills/ itself is never read as a nested root, even when it is a symlink to a directory elsewhere in the project: the directory it resolves to belongs to the root import, and the nested scan leaves it alone. On a name clash the root skill wins the import — rulesync's flat skill namespace cannot express the qualified variant. Because generation stays targeted at the project-root .claude/skills/, a nested skill's location-based scoping would otherwise be lost, so the import derives it: a skill found in apps/web/.claude/skills/ gets claudecode.paths: ["apps/web/**"] written for it. Glob metacharacters in the directory names are escaped, so a Next.js-style app/[slug]/.claude/skills/ still derives a literal match. A paths value the skill already declares is kept as-is — Claude Code does not document whether a nested skill's paths resolves against the project root or its own directory, so rulesync does not rewrite the author's glob, which means a declared value narrower than the subtree (src/**) is re-anchored at the project root once the skill moves there; write it subtree-qualified (apps/web/src/**) if that matters. Root-discovered skills get nothing added, and the derived value lands in the claudecode: block only — other targets with their own paths field (cursor:, qwencode:) are untouched. To scope a skill's activation to a subtree yourself, write the paths frontmatter, or run a separate generate with --output-roots <subdir> for physical co-location.

Claude Code argument-hint and import leniency note: Claude Code's docs write argument-hint: [issue-number] unquoted, which YAML reads as a one-element list. On import (and rulesync convert --from claudecode), a list argument-hint in a .claude/skills/*/SKILL.md or .claude/commands/*.md is accepted and joined back into the bracketed string, one placeholder per entry ([issue-number]; [filename, format] becomes [filename] [format]; an empty list is dropped), so claudecode.argument-hint in .rulesync/ is always a string and is emitted quoted on generate. The unquoted multi-placeholder form argument-hint: [filename] [format] is not valid YAML at all; quote it ("[filename] [format]"). Because Claude Code keeps loading the other skills when one SKILL.md is broken, a Claude Code skill that fails to load on import or convert (unparseable YAML, invalid frontmatter) is skipped with a warning, and the remaining skills and every later feature (hooks, permissions, …) still import. Commands are not covered by this leniency: an invalid .claude/commands/*.md still aborts the import.

Note: claudecode.disallowed-tools (a space/comma-separated string or a YAML list) removes the listed tools from the model while the skill is active. The same field is available on Claude Code slash commands. Both round-trip through the claudecode frontmatter section.

Note: Codex CLI reads UI metadata, invocation policy, and tool dependencies from an agents/openai.yaml sidecar next to SKILL.md (Codex's SKILL.md frontmatter only carries name and description). When codexcli.interface, codexcli.policy, or codexcli.dependencies is present, or the root disable-model-invocation is true, Rulesync emits .agents/skills/<name>/agents/openai.yaml and reads it back on import. The root disable-model-invocation: true maps onto policy.allow_implicit_invocation: false (Codex then only runs the skill when invoked explicitly via $skill); an explicit codexcli.policy.allow_implicit_invocation takes precedence over the root flag, and a root false or absent flag adds nothing (Codex already defaults to true). On import, policy.allow_implicit_invocation is kept under codexcli.policy rather than promoted to the root disable-model-invocation, like every other tool's importer keeps its invocation flags in its own section; this regenerates the same sidecar without changing the output of other targets. If the sidecar is emitted and interface.short_description is absent, the legacy codexcli.short-description is routed there. See the Codex skills docs.

Takt-driven Codex note: Rulesync's codexcli skills land in .agents/skills/ (project) and ~/.agents/skills/ (global), but a Takt workflow driving Codex does not inherit repository or user skills from there by default — upstream's wording is "TAKT workflows do not inherit repository or user Codex Skills by default" — so skills you generated will not reach a Takt-driven Codex run unless you turn inheritance on. (takt exec is the documented exception: each scope defaults to inheritance when it is not explicitly configured.) The setting is provider_options.codex.skills.repo for the project tree and .user for the global one, added in Takt 0.53.0. Where it goes depends on your Takt version. Up to 0.55.x, and on any later version whenever runtime.yaml is inactive (a file carrying only version: 1 counts as inactive and leaves the legacy resolution in place), it belongs in provider_options in .takt/config.yaml — which is also where a takt block in .rulesync/permissions.* writes it. From 0.56.0, runtime.yaml owns provider configuration, and while it is active any legacy provider setting in config.yaml — provider_options included — stops Takt with Mixed provider configuration detected before it runs an agent. Takt generates ~/.takt/runtime.yaml active on first launch in a fresh environment, so a new install is in runtime mode by default; there, set the flag in runtime.yaml under the options of a profile whose provider is Codex, and keep provider_options out of config.yaml. Mind the shape when you move it: a profile's options is a flat bag applying to that profile's own provider, so the codex segment is dropped — options: { skills: { repo: true } }, not options: { codex: { skills: { repo: true } } }. The nested spelling is not a schema error; it is simply never read, so inheritance stays off while the config looks right. Takt 0.57.0 adds a workflow-side alternative to writing provider_options inline: a workflow, step, or parallel sub-step can declare capabilities: enable-skills, a bundled preset covering the Codex repo and user skills. Takt 0.55.0 made the same default change for Claude providers (provider_options.claude.skills.enabled, plus --disable-slash-commands on CLI-backed ones), so Rulesync-generated Claude Code skills and slash commands are off in Takt-driven sessions unless re-enabled the same way. See the Takt configuration docs and CHANGELOG.

Reasonix note: Reasonix discovers Anthropic-style directory-layout skills (<name>/SKILL.md) under .reasonix/skills/ (project) / ~/.reasonix/skills/ (global, via --global). Rulesync emits the portable name/description frontmatter; the schema is loose, so an imported SKILL.md carrying extra keys still parses, but only the modeled keys are carried into the canonical skill (others, such as argument-hint, are dropped on import). The invocation flags are modeled too: the root-level disable-model-invocation / user-invocable defaults (overridable per tool under a reasonix: section) are written as Reasonix's own disable-model-invocation / user-invocable keys, which the v2 line reads (internal/ext/skill/invocation_flags.go, since v2.28.0). The legacy v1 line only knows invocation: manual, which hides the skill from the model's catalog while it stays callable by name — weaker than the v2 flag, which also refuses model calls. A skill whose model invocation is disabled therefore also gets invocation: manual, the closest v1 equivalent. reasonix.invocation (auto / manual) authors that catalog switch directly and is written verbatim when set, unless model invocation is disabled, which always writes manual. On import, the native flags land in the reasonix: section (Reasonix's yes/no, on/off and 1/0 spellings are read as booleans; like Reasonix, an unreadable disable-model-invocation fails closed and is read as true, while an unreadable user-invocable is ignored), and an invocation is kept as reasonix.invocation — except the manual that accompanies disable-model-invocation: true, which follows from the flag. See invocation_flags.go and the v1 skill.go. See the Reasonix GUIDE.

Meta Muse Code note: Muse Code discovers Agent Skills (<skill-id>/SKILL.md) under .agents/skills/ (project) and under $XDG_CONFIG_HOME/muse/skills plus ~/.agents/skills (user). Rulesync emits the shared .agents/skills/ directory in project mode and only the XDG-default ~/.config/muse/skills in global mode (via --global), so a skill is written exactly once. Muse Code's compatibility scans of repo-local .codex/skills and .claude/skills belong to other tools and are not emitted for musecode. Only the portable name/description frontmatter pair is modeled; the schema is loose, so extra keys on an imported SKILL.md survive the round-trip. See the Muse Code extending docs.

Zed note: Zed discovers skills under .agents/skills/ (project) and ~/.agents/skills/ (global) and applies rules of its own when it loads one, which rulesync reports as warnings at generate time rather than discovering them in Zed's UI: a name must be 1–64 characters of lowercase letters, digits and hyphens, with no leading, trailing or consecutive hyphens — a skill whose name breaks that rule "fail[s] to load" in Zed — and a description should be at most 1024 characters, past which Zed still loads the skill but warns. The skill is written either way, since the canonical skill is shared with every other target; fix the frontmatter in .rulesync/skills/<name>/SKILL.md to clear the warning. Zed also caps the combined size of every installed skill's name and description at 50KB and drops what does not fit from its catalog; that budget spans skills rulesync did not write, so it is not checked. Zed's own disable-model-invocation is authored in the zed: block. See the Zed skills docs.

Hermes Agent note: Hermes skills are global-only under ~/.hermes/skills/<name>/SKILL.md. Standard Agent Skills fields (license, compatibility, and allowed-tools) round-trip through agentsskills and are normalized to the Agent Skills spec shapes described above (so allowed-tools is written and imported the same way as for agentsskills); Hermes-native fields such as version, author, platforms, environments, required_environment_variables, required_credential_files, and metadata.hermes round-trip through hermesagent. Canonical name and description always own those two frontmatter keys.

Warp skills note: Warp discovers skills under .warp/skills/ (project) and ~/.warp/skills/ (global), plus the cross-tool .agents/skills/ root at either scope — the location Warp's docs recommend. Rulesync generates to .warp/skills/ only; the .agents/skills/ root is import-only (declared under importOnlySkillRoots, like junie / vibe / kimi-code), scanned after .warp/skills/ under the cross-root duplicate rule above, and never touched by warp-target orphan deletion — in project or global mode. Warp also lists other tools' skill roots (.claude/skills/, .codex/skills/, …) and discovers skills in subdirectories of a repository; those are not imported as warp skills — import the owning target instead. See the Warp skills docs.

Kimi Code note: Kimi Code discovers skills under .kimi-code/skills/ (project) and ~/.kimi-code/skills/ (global), plus the shared .agents/skills/ root at either scope. Rulesync generates the recommended directory layout (<name>/SKILL.md) and imports both that layout and flat <name>.md skills; for flat files, a missing name comes from the filename and a missing description falls back to the first non-empty body line (up to 240 characters), matching Kimi. Imported skills are written to .rulesync/skills/<logical-name>/SKILL.md, using the normalized logical frontmatter name rather than the source directory or filename. Duplicate precedence follows Kimi's case-insensitive logical frontmatter name: the Kimi-specific root takes precedence over .agents/skills/, and a directory skill takes precedence over a same-named flat file within one root. Shared roots are import-only and are never removed by Kimi-target orphan deletion. Besides name/description, Rulesync maps Kimi's type, whenToUse, disableModelInvocation, and arguments frontmatter through the kimi-code: block and preserves supporting files beside directory-layout SKILL.md. The shared top-level disable-model-invocation value supplies the Kimi flag unless the tool-specific block overrides it. See the Kimi Code Agent Skills docs. The kimi-code-plugin target writes the same format into a plugin bundle's skills/<name>/SKILL.md (project scope only); see Plugin Packaging.

ZCode note: ZCode discovers Anthropic-style directory-layout skills (<name>/SKILL.md) and invokes them with $. The documented location is the user one, ~/.zcode/skills/; the workspace scope its import dialog offers is taken to be served from the project's own .zcode/skills/, so rulesync writes .zcode/skills/<name>/SKILL.md in project mode and ~/.zcode/skills/<name>/SKILL.md with --global. That project path is inferred from the import dialog and from ZCode's other workspace assets, not documented. ZCode's Skill SKILL.md field reference allowlists exactly five frontmatter keys — required name/description plus optional when_to_use (extra trigger-timing context), license, and metadata (an object that may carry extras such as author/version) — and states that fields outside that list "are ignored and do not affect loading". Rulesync models all five: license and metadata fall back to the root-level Agent Skills packaging defaults, and when_to_use is authored in the zcode: block. There is no documented compatibility field, so the canonical compatibility is not written for zcode. The schema is loose, so an imported SKILL.md carrying extra keys still parses, but only those five are carried into the canonical skill. ZCode rejects a description longer than 1024 characters and truncates a body past 100KB when it loads the skill; rulesync does not enforce either limit, since the canonical skill is shared with every other target. See the ZCode skills docs. The zcode-plugin target writes the same format into a plugin bundle's skills/<name>/SKILL.md (project scope only). See the plugin packaging guide.

IBM Bob note: IBM Bob discovers Anthropic-style directory-layout skills (<name>/SKILL.md) under .bob/skills/ (project) and ~/.bob/skills/ (global, via --global). The SKILL.md frontmatter carries name and description; any additional keys round-trip through the bob frontmatter block. See the skills docs.

Tabnine CLI note: Tabnine CLI discovers Anthropic-style directory-layout skills (<name>/SKILL.md) under .tabnine/agent/skills/ (project) and ~/.tabnine/agent/skills/ (global, via --global). The SKILL.md frontmatter carries name and description; any additional keys round-trip through the tabnine frontmatter block. See the agent skills docs.

Snowflake Cortex Code note: Snowflake Cortex Code discovers Anthropic-style directory-layout skills (<name>/SKILL.md) under .cortex/skills/ (project) and ~/.snowflake/cortex/skills/ (global, via --global). The SKILL.md frontmatter carries name and description, plus an optional tools allowlist; any additional keys round-trip through the cortexcode frontmatter block. (Cortex Code also falls back to .claude/skills/, which the claudecode target already covers.) See the extensibility docs.

Codewhale note: Codewhale discovers directory-layout skills (<name>/SKILL.md) under .codewhale/skills/ (project) and ~/.codewhale/skills/ (global, via --global). name and description are written from the canonical fields, and any Codewhale-specific frontmatter (for example invocation) is carried through the codewhale section of the rulesync skill.

CodeBuddy Code note: CodeBuddy Code discovers directory-layout skills (<name>/SKILL.md) under .codebuddy/skills/ (project) and ~/.codebuddy/skills/ (global, via --global). Rulesync writes name and description from the canonical skill (same-named keys in the codebuddy: block are ignored), plus the shared disable-model-invocation / user-invocable defaults; the other documented keys (allowed-tools, context, agent, model, hooks) and any extra ones go in the codebuddy: block. CodeBuddy treats every SKILL.md key as optional, so on import a missing name falls back to the directory name and a missing description to an empty string. See the CodeBuddy Code skills docs.

Continue note: Continue discovers Anthropic-style directory-layout skills (<name>/SKILL.md) under .continue/skills/ (project) and ~/.continue/skills/ (global, via --global). The SKILL.md frontmatter requires name (which must match the directory name) and description; any additional keys round-trip through the continue frontmatter block, and supporting files next to SKILL.md are carried along. (Continue also falls back to .claude/skills/, which the claudecode target already covers.) Continue has no doc page for skills yet; the skill loader source is the reference.

Command Code note: Command Code discovers Anthropic-style directory-layout skills (<name>/SKILL.md) under .commandcode/skills/ (project) and ~/.commandcode/skills/ (global, via --global). The SKILL.md frontmatter requires name (matching the directory name) and description; the documented optional keys (license, compatibility, metadata, allowed-tools, disallowed-tools, argument-hint, when_to_use, disable-model-invocation, user-invocable, arguments, model, effort) and any newer key round-trip through the commandcode frontmatter block, and supporting files next to SKILL.md are carried along. The Agent Skills packaging trio (license, compatibility, metadata) and the two invocation gates (disable-model-invocation, user-invocable) also fall back to the root-level rulesync value when the commandcode block omits them; a value in the block wins. See the skills docs.

Qoder note: Qoder reads Agent Skills directories (<name>/SKILL.md, with the required name and description frontmatter) from .qoder/skills/ (project) and ~/.qoder/skills/ (global, via --global); the Qoder IDE and the CLI read the same directories, and on a name clash the user-level skill wins. Rulesync writes them with the same Agent Skills frontmatter as the agentsskills target, and supporting files next to SKILL.md are carried along. See the skills docs.

Letta Code note: Letta Code discovers directory-layout skills (<name>/SKILL.md) under the shared .agents/skills/ root (project, the same directory the agentsskills target writes) and ~/.letta/skills/ (global, via --global). The SKILL.md frontmatter takes name and description; the optional keys Letta Code reads (when_to_use, argument-hint, category, tags, disable-model-invocation, user-invocable) round-trip through the lettacode frontmatter block, and supporting files next to SKILL.md are carried along. The two invocation gates fall back to the root-level rulesync value when the lettacode block omits them. See the skills docs.

DeepSeek Harness note: DeepSeek Harness scans <projectRoot>/.dsh/skills (project) and ~/.dsh/skills (global, the $DSH_HOME default) for skills, alongside the .agents/skills roots the agentsskills target already writes, so rulesync emits .dsh/skills/<name>/SKILL.md in project mode and ~/.dsh/skills/<name>/SKILL.md with --global and leaves the .agents/ roots to agentsskills. The harness discovers only the top level of each root — a <name>/SKILL.md bundle or a flat <name>.md file — and deliberately ignores nested **/SKILL.md, which matches rulesync's output: one top-level bundle per skill, with supporting files carried inside it as plain files (rulesync never writes the flat form). Its frontmatter reads required name/description plus optional whenToUse (extra trigger-timing context), metadata (a free-form object), disable-model-invocation and user-invocable. Rulesync models all six: metadata and the two invocation flags fall back to the root-level shared defaults, and whenToUse is authored in the dsh: block. The harness also accepts yes/no, on/off and 1/0 spellings for the flags and drops the whole skill with a warning on any other value; rulesync writes and imports the canonical boolean form only, so an imported SKILL.md with user-invocable: maybe is rejected rather than silently dropped. There is no documented license or compatibility field, so those canonical fields are not written for dsh; the schema is loose, so an imported file carrying extra keys still parses. The harness also requires name to be kebab-case; rulesync does not enforce that for dsh, since the canonical skill is shared with every other target. See the skill-filesystem README.

Pool note: Pool (Poolside's coding agent CLI) discovers Agent Skills (<name>/SKILL.md) under .poolside/skills/ (project) and ~/.config/poolside/skills/ (global, via --global — only the XDG-default location is written). It also scans the shared .agents/skills/ / ~/.agents/skills/ roots and other tools' skill directories, which belong to their own targets (agentsskills and friends), so each skill is still written exactly once. Only the required name/description pair is modeled — Pool does not enforce allowed-tools or compatibility — and the schema is loose, so extra keys survive an import. Pool only loads a skill whose directory name equals its name, so rulesync warns at generate time when the two differ. User-level skills are not mounted inside Pool's local sandboxes. See Pool skills.

Crush note: Crush auto-discovers standard Agent Skills (<name>/SKILL.md) under .crush/skills/ in project mode and ~/.config/crush/skills/ in global mode (via --global) — the same ~/.config/crush/ root Crush's global CRUSH.md and MCP config live under, distinct from the project-only .crush/ directory name. Unless $CRUSH_SKILLS_DIR is set, Crush also scans several shared directories it does not own (globally ~/.config/agents/skills/, ~/.agents/skills/, ~/.claude/skills/; per-project .agents/skills/, .claude/skills/, .cursor/skills/, also checked at a git worktree's common root) — rulesync writes only the Crush-specific path above, leaving those shared roots to their own targets. Besides name/description, the crush: block round-trips user-invocable, disable-model-invocation, and the shared license/compatibility/metadata packaging trio, all falling back to their root-level defaults when the section omits them; since Crush's UserInvocable field is a non-pointer Go bool, an omitted user-invocable resolves to false, keeping the skill reachable by the model but hidden from Crush's command palette. compatibility and metadata are normalized to the bare string / map[string]string shapes Crush's parser requires before being written, even if the rulesync root default holds the legacy object form. See Crush's skill loader.

.rulesync/mcp.jsonc ​

.rulesync/mcp.jsonc is the recommended source path and accepts comments and trailing commas. The legacy .rulesync/mcp.json path remains readable for existing projects. When both files exist, the JSONC file takes precedence; write flows update the existing source instead of creating a second variant.

A key named __proto__, constructor, or prototype is rejected with an error naming its path, rather than being dropped in silence. The "Rejected keys" paragraph in the .rulesync/permissions.jsonc section below describes this handling, which is the same for all three source files.

Example:

json
{
  "mcpServers": {
    "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/mcp-schema.json",
    "serena": {
      "description": "Code analysis and semantic search MCP server",
      "type": "stdio",
      "command": "uvx",
      "args": [
        "--from",
        "git+https://github.com/oraios/serena",
        "serena",
        "start-mcp-server",
        "--context",
        "ide-assistant",
        "--enable-web-dashboard",
        "false",
        "--project",
        "."
      ],
      "env": {}
    },
    "context7": {
      "description": "Library documentation search server",
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@upstash/context7-mcp"],
      "env": {}
    }
  }
}

Tool-scoped server blocks ({toolname}.mcpServers) ​

Servers under the shared mcpServers key are emitted to every targeted tool. To scope a server to a single tool, add a tool-scoped {toolname} block alongside it — mirroring {toolname}.hooks in .rulesync/hooks.jsonc and {toolname}.permission in .rulesync/permissions.jsonc:

jsonc
{
  "mcpServers": {
    "shared-server": { "type": "stdio", "command": "echo" },
  },
  "claudecode": {
    "mcpServers": {
      // Added only to Claude Code's MCP config.
      "claude-only-server": { "type": "http", "url": "https://example.com/mcp" },
      // `null` removes a shared server for Claude Code only.
      "shared-server": null,
    },
  },
}
  • A tool-scoped entry with the same name as a shared server replaces it wholesale for that tool (no field-level merge).
  • A tool-scoped entry set to null removes the shared server for that tool.
  • Any MCP-capable --targets name is accepted as a block key (claudecode, cursor, codexcli, ...). Targets that share one output file resolve identically so the shared file never depends on generation order: the deprecated claudecode-legacy target reads the claudecode block; the kiro-cli / kiro-ide targets read the kiro block (all three write the same .kiro/settings/mcp.json); the antigravity-ide / antigravity-cli targets both apply both antigravity-* blocks in a fixed order (antigravity-ide first, then antigravity-cli — the CLI block wins per server) because they share their output file at both scopes (.agents/mcp_config.json in project mode, ~/.gemini/config/mcp_config.json in global mode); and in project mode the claudecode (or claudecode-legacy), codebuddy, commandcode and qoder targets all apply the claudecode, codebuddy, commandcode and qoder blocks in that order (a later block wins per server) because they share the project-root .mcp.json — in global mode their files differ (~/.claude.json, ~/.codebuddy/.mcp.json, ~/.commandcode/mcp.json and ~/.qoder/settings.json), so there each reads only its own block.

Generation filter: per-server enabled. Set "enabled": false on a server (in the shared map or a tool-scoped block) to keep the definition in the source file while emitting it to no tool config at all — a temporary off switch that does not lose the entry. Omitted means enabled, so existing configs keep generating everything; writing "enabled": true is opt-in clarity. This is distinct from the canonical disabled, which is a pass-through field the tools read (written as disabled: true, or translated to each tool's own spelling): enabled: false wins and drops the server entirely, while disabled only matters for servers still emitted. The field is rulesync-source-only and never reaches generated output — several tools (OpenCode, Kilo, Grok CLI, Goose) have a native enabled field with different semantics — and import never invents it: a tool's native enabled/disabled state keeps mapping to the canonical disabled (though a stray hand-written enabled in a passthrough-imported tool file does come back as the canonical filter). Two edges to know: a tool-scoped entry replaces the shared entry wholesale, so a same-named tool-scoped entry without enabled: false re-emits the server for that tool (per-tool re-enabling); and on merge-style shared configs (e.g. Hermes Agent's config.yaml), disabling a previously generated server stops writing it but does not remove the already-written entry — same as deleting the definition.

Deprecated: per-server targets. The older per-server "targets": ["tool", ...] array is still honored as a filter (a missing value or ["*"] means every tool), but it is deprecated and logs a warning at generate time. Migrate by moving the server into the matching {toolname}.mcpServers block(s).

JetBrains AI Assistant note: Rulesync writes the native { "mcpServers": { ... } } configuration to .ai/mcp/mcp.json in project mode and ~/.ai/mcp/mcp.json in global mode. Both scopes support STDIO and remote server entries using the shape documented in JetBrains AI Assistant's MCP guide.

Antigravity note: The Antigravity IDE and CLI share .agents/mcp_config.json (project) and ~/.gemini/config/mcp_config.json (global). Antigravity CLI 1.1.24+ accepts // and /* */ comments and trailing commas in mcp_config.json (see the Antigravity CLI changelog), so Rulesync reads the existing file as JSONC; the rewrite is plain JSON, so comments are dropped but the run no longer fails. HTTP/SSE servers use serverUrl in place of the canonical url, translated in both directions.

JSON Schema Support ​

Rulesync provides a JSON Schema for editor validation and autocompletion. Add the $schema property to your .rulesync/mcp.jsonc:

json
{
  "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/mcp-schema.json",
  "mcpServers": {}
}

Transport types (type / transport) ​

The type (and the equivalent transport) field accepts local, stdio, sse, http, ws, and streamable-http. streamable-http is the MCP specification's name for the HTTP transport and is accepted as an alias of http, so configurations copied from a server's documentation work unchanged. ws is the WebSocket transport (a persistent bidirectional connection) and accepts the same url/headers/headersHelper/timeout fields as http. Tools that do not recognize a given transport keep it on round-trip but may ignore it at runtime.

OpenCode skills note: on import, Rulesync also reads the skills.paths array of opencode.json / opencode.jsonc ("Additional paths to skill folders") and scans each entry as an extra skill root, so skills a project keeps outside .opencode/skills/ are no longer invisible to rulesync import. These roots are import-only — generation keeps writing to Rulesync's own managed root — and a skill of the same name found in a managed root still wins. Each entry is resolved against the directory the config was read from — the project root in project mode, ~/.config/opencode/ in global mode — which is what OpenCode itself does. An absolute path is ignored, and so is one that escapes that directory — judged by where the entry resolves rather than by how it is spelled, so a relative entry that is a symbolic link pointing outside the directory is ignored too — and a directory under a configured root that is not a skill is skipped with a warning rather than failing the run, since a configured root is arbitrary user territory. skills.urls is a remote-fetch surface and is out of scope for a file-based generator. The OpenCode V2 flat skills array, which OpenCode V1 also accepts (from v1.18.24), is read the same way: its entries that are not http(s):// URLs are taken as skills.paths.

MiMo Code skills note: MiMo Code (an OpenCode distribution) reads skills from .mimocode/skills/<name>/SKILL.md (project) / ~/.config/mimocode/skills/<name>/SKILL.md (global, via --global), also accepting the singular skill/ directory on import. Tool-specific frontmatter goes under a mimocode key, which takes the same fields as the opencode key so a skill round-trips (MiMo Code itself ignores them). On import, the skills.paths array of .mimocode/mimocode.json(c) is read too, with relative entries resolved against the project root as MiMo Code does; a global config's relative entries depend on the project and are not read.

oh-my-pi skills note: oh-my-pi (omp) reads skills from .omp/skills/<name>/SKILL.md (project) / ~/.omp/agent/skills/<name>/SKILL.md (global, via --global) in Pi's format. Tool-specific frontmatter goes under an omp key, which takes the same fields as the pi key and is handled the same way (see the Pi skills note).

GitLab Duo CLI skills note: GitLab Duo reads Agent Skills from the repository-root skills/<name>/SKILL.md (project) and ~/.gitlab/duo/skills/<name>/SKILL.md (global, via --global). The project skills/ directory is read by GitLab Duo from the committed repository, so rulesync gitignore does not ignore it. Every skill targeting gitlabduo — including a targets: ["*"] one — is written there, so if your repository already uses a top-level skills/ directory for something else (a skill collection, skills distributed to other tools), limit such skills' targets or leave gitlabduo out of the skills feature. User-level skills are an experiment and are loaded only when the CLI is started with --enable-global-skills true or GITLAB_ENABLE_GLOBAL_SKILLS=true. Rulesync always uses the default ~/.gitlab/duo/ location and does not follow GLAB_CONFIG_DIR or XDG_CONFIG_HOME. Because the project root skills/ directory may hold unrelated content, only a subdirectory that contains a SKILL.md is imported. For the same reason generate --delete never removes a skill directory from the project skills/ directory: hand-authored skills there are indistinguishable from ones Rulesync generated earlier, so a generated skill whose .rulesync/skills/ source you removed has to be deleted by hand. (Stale files inside a skill directory that the run does generate are still cleaned up, as for every other target — so a hand-authored skills/<name>/ sharing its name with a Rulesync skill is overwritten.) The global ~/.gitlab/duo/skills/ directory is swept as usual.

OpenCode MCP note: besides the mapped transport fields, Rulesync passes the OpenCode per-server keys timeout, oauth, protocol (legacy | auto | 2026-07-28, OpenCode V2) and codemode (OpenCode V2) through verbatim in both directions. Other tools' MCP keys are never copied into opencode.json. Rulesync reads and writes OpenCode's V1 file shape — a flat mcp map keyed by server name — so protocol and codemode reach the adapter only when a canonical server carries them: authored by hand in .rulesync/mcp.jsonc, or imported from a flat-mcp opencode.json entry that already has them. They are written only in that case and never added on their own, so a V1-only install is unaffected unless you author them (OpenCode V1's published schema does not list them). A V2-shaped file (mcp.servers, disabled, the timeout object) is not read as such; keep those servers in the flat V1 shape, which OpenCode V2 still accepts, for Rulesync to import them. See the OpenCode MCP docs.

Kilo Code note: Kilo's MCP config uses its own native shape in kilo.jsonc (type: "local" | "remote", environment, enabled, command as an array). Rulesync maps stdio/local ⇄ Kilo local and http/sse ⇄ Kilo remote; on import, Kilo remote is normalized to the canonical http transport (the deprecated sse is no longer emitted). The Kilo-specific timeout (local + remote, a positive integer in milliseconds) and oauth (remote only — either an OAuth-config object or false to disable auto-detection) fields are preserved on round-trip. The kilo.jsonc skills config key (skills.paths for extra skill locations and skills.urls for remote skill manifests) is likewise preserved when Rulesync writes the file. A bare {"enabled": false} entry — Kilo's way of switching off a server another config layer defines, such as the global config or a marketplace — round-trips as itself: it imports as a canonical server carrying only disabled: true/disabled: false and no transport, and a server in that shape is written back as {"enabled": …} rather than as a local server with an empty command it cannot start. The enabled state has to be stated outright in both directions: for a transport-less server that says nothing about disabled, a toggle already in kilo.jsonc is left exactly as it is, and if there is none the server is skipped with a warning — a toggle overrides the layer that defines the server, so writing enabled: true for it would switch back on what you turned off there. Kilo's per-tool enabledTools/disabledTools reach the generated file at all now — they used to be stripped before this adapter saw them, so a filter read out of kilo.jsonc was deleted from it on the next generate. The filters are written as Kilo MCP tool permission keys in the permission block — enabledTools as "{server}_{tool}": "allow", disabledTools as "{server}_{tool}": "deny", with both names sanitized the way Kilo names MCP tools (every character outside a-z A-Z 0-9 _ - becomes _, except that a * or ? in a tool name is kept, so disabledTools: ["*"] becomes the wildcard key "{server}_*") — rather than the legacy top-level tools map, which Kilo only folds into permission through a compatibility shim. The permission block is shared with the permissions feature: for every server the canonical config or the file's mcp block names, the MCP feature owns the plain "allow"/"deny" keys of that server's tools (including wildcard ones such as "{server}_*") and rebuilds them on each generate (removing a deny, or a legacy tools entry set to false, that no disabledTools lists anymore warns), while other wildcard keys (such as the catch-all "*"), "ask", the {"*": …} maps Kilo's "Approve Always" saves, Kilo's built-in permissions (such as external_directory for a server named external, or repo_clone for one named repo), and every other key are left alone; the same servers' entries are removed from a legacy tools map. Keys are matched to servers by name prefix, with the longest matching server name winning, so a key of a server defined only in another Kilo config layer whose name extends a listed one (github_enterprise_… next to a listed github) is treated as the listed server's. Note that in Kilo "allow" means the tool runs without asking, so an enabledTools entry is auto-approved even when a catch-all "*": "ask" comes before it. When two servers sanitize to the same key, a deny wins over an allow. Because Kilo evaluates permission last-match-wins in key order, the permissions feature moves exact "deny" keys to the end of the block, so a broad allow it appends can never lift an MCP disabledTools deny. A wildcard disabledTools entry ("{server}_*": "deny") is not moved, because moving a wildcard deny would also override the narrower allows written after it on purpose; a catch-all "*": "allow" placed after it still wins, as it already did over the legacy tools map. A skipped server's filters are written either way, since a permission key is keyed by server name and reaches servers mcp does not list. On import, permission keys (a plain "allow"/"deny" or a {"*": …} map) of a listed server are read back as filters, and so is the legacy tools map (permission wins a key both state, as in Kilo's own shim); a legacy tools entry naming no listed server comes back as a server carrying nothing but the filters. A permission key naming no listed server is not imported as an MCP filter — it cannot be told apart from a built-in permission — and is imported by the permissions feature into its kilo override instead. A server with no transport — a toggle, or one of those filter-only entries — is imported into the tool-scoped kilo.mcpServers block rather than the shared mcpServers map, because an entry with no command and no url is a server the other tools' configs cannot start. All of this applies equally to OpenCode: its published schema carries the same bare-toggle union member, it round-trips a toggle as itself under the same explicit-state rule, its filters still use the top-level tools map (keyed by server name in the same way), and its transport-less servers land in opencode.mcpServers. The entry must carry no field of a local or remote server (type, command, url, headers, environment, cwd, timeout, oauth); an entry that is malformed in some other way still fails loudly rather than being quietly read as a toggle and written back with its command, headers, or OAuth secrets gone, while an unrelated key Kilo adds later is accepted rather than failing the run (it is not carried across the round-trip, though — a toggle imports as its enabled state and nothing else). Since a toggle keeps nothing but its enabled state, a canonical server that declares no transport but still carries fields such as args or env is written as a toggle with those fields dropped and a warning naming them. A server that names a transport it cannot reach — a type with no command, an http with no url — is skipped with a warning instead, because {"type": "local", "command": []} is a server Kilo cannot start. An existing kilo.jsonc carrying that shape (earlier Rulesync versions wrote it) imports as a server with no transport rather than failing the run. The same applies to OpenCode, whose config uses the same shape. Rejecting it used to fail the whole --targets kilo run rather than the MCP feature alone, because kilo.jsonc is the file the rules feature writes too. Environment variable references: Kilo expands only its own {env:VAR} syntax, and only in a trusted config. In global mode (~/.config/kilo/kilo.jsonc) the canonical ${VAR} in env and headers values is written as {env:VAR}. A project kilo.jsonc is untrusted — any {env: in it makes Kilo drop the whole file (or, inside MCP headers, the server) — so in project mode the references are written literally as ${VAR}, which Kilo does not expand, and a warning names the servers that carry them; a server that already carries {env: anywhere (for example Cursor's ${env:VAR}) is skipped with a warning in project mode so it cannot cost the rest of the shared kilo.jsonc. On import, {env:VAR} maps back to ${VAR} (Cursor's ${env:VAR} is left untouched).

MiMo Code note: MiMo Code (an OpenCode distribution) uses OpenCode's MCP shape, written to the mcp (and tools) keys of .mimocode/mimocode.jsonc (project; an existing .mimocode/mimocode.json is updated instead) or ~/.config/mimocode/mimocode.jsonc (global). Everything described for OpenCode applies, and a tool-scoped block goes under mimocode.mcpServers.

oh-my-pi note: oh-my-pi (omp) reads the mcpServers map of .omp/mcp.json (project) / ~/.omp/agent/mcp.json (global). Rulesync writes each server with an explicit type (stdio, http — also for streamable-http — or sse), a string command plus args for stdio servers, and enabled: false for a disabled server; other top-level keys of an existing file ($schema, disabledServers, …) are preserved, and the global file is never deleted. WebSocket servers are skipped with a warning. oh-my-pi expands ${VAR} and ${VAR:-default} itself, so values are written unchanged. enabledTools / disabledTools have no oh-my-pi counterpart and are not written. A tool-scoped block goes under omp.mcpServers.

Pi note: Pi (1.0+) has built-in MCP support and reads the mcpServers map of .pi/mcp.json (project — read only after project trust is granted) / ~/.pi/agent/mcp.json (global). Rulesync writes each server with an explicit type (stdio, or http — also for streamable-http), a string command plus args for stdio servers, enabled: false for a disabled server, and timeout converted from Rulesync's milliseconds to Pi's seconds (a non-positive timeout is dropped with a warning, since Pi rejects the server for it); import reverses these mappings. Pi rejects the legacy SSE transport, so sse servers — like WebSocket servers and any remote server whose url is not http(s):// — are skipped with a warning. Pi-specific keys such as exposure, toolExposure, description, oauth and auth pass through; put them under a pi.mcpServers block to keep them out of other tools' configs. Pi accepts auth only in the global file, so it is dropped with a warning from the project file. Pi rejects server names other than letters, digits, _ and -, and treats names that differ only in - and _ as the same server, so such a name (or the second of two such names) is skipped with a warning. Other top-level keys of an existing file (autoEnableCodemode, …) are preserved, and the global file, which pi mcp add and /mcp also edit, is never deleted. Pi expands ${VAR} and $VAR in env and headers values, so they are written unchanged (note that Pi runs a value starting with ! there as a shell command); it does not expand ${VAR:-default}, and in command, args and cwd it expands only a leading ~/, so a server carrying such a reference gets a warning (write ~/... rather than ${HOME}/... there). enabledTools / disabledTools have no Pi counterpart and are not written. A project entry without command, url or type, which Pi treats as an override of a same-named user-level server, is not modeled: Rulesync skips a server without a transport. See the Pi MCP docs.

GitLab Duo CLI note: GitLab Duo reads MCP servers from .gitlab/duo/mcp.json (project) and ~/.gitlab/duo/mcp.json (global) in the { "mcpServers": { ... } } shape. Every server is written with an explicit type (stdio, http or sse), inferred from command / url when the source leaves it out; streamable-http becomes http, and servers with another transport (such as ws) are skipped with a warning. The GitLab-specific approvedTools (true or a list of tool names that run without a prompt) is passed through — put it under a gitlabduo.mcpServers block to keep it out of other tools' configs.

Zed note: Zed configures MCP servers under context_servers in its shared settings file (.zed/settings.json project, ~/.config/zed/settings.json global — %APPDATA%\Zed\settings.json on Windows), whose value is an untagged shape with no type field: a stdio server is {"command": <string>, "args", "env", "timeout"}, a remote one {"url", "headers", "timeout"}, and an extension-provided one neither. Rulesync translates the canonical fields into those shapes instead of forwarding them verbatim (which used to hand Zed keys it silently ignores — most seriously disabled: true, which left the server enabled): disabled: true becomes enabled: false (and imports back as disabled: true), the httpUrl alias is normalized to url, an array command is flattened to Zed's single command string with the rest prepended to args, and canonical-only fields (type/transport, alwaysAllow, trust, cwd, networkTimeout, the Kiro lists) are dropped. Fields rulesync does not model — a remote server's oauth block, an extension server's settings — pass through untouched, so they are best authored in the tool-scoped zed.mcpServers block. A server Zed cannot start is skipped with a warning rather than written broken: an sse or ws server (Zed has neither transport), a remote server with no url, a local one with no command. A server with no transport at all is written as Zed's extension-provided variant, and on import such an entry lands in the tool-scoped zed.mcpServers block rather than the shared mcpServers map, since other tools cannot start it.

Kimi Code note: MCP servers are written to .kimi-code/mcp.json (project) and ~/.kimi-code/mcp.json (global). Kimi Code supports stdio, HTTP, and SSE plus env, cwd, headers, bearerTokenEnvVar, enabled, deferred (Kimi Code 0.43.0: load the server's tools on demand instead of inline), startupTimeoutMs, toolTimeoutMs, enabledTools, and disabledTools; Rulesync preserves the canonical fields that Kimi accepts. Canonical local maps to stdio and streamable-http maps to HTTP. WebSocket servers are skipped with a warning because Kimi has no WebSocket transport. A kimi-code block may also carry startupTimeoutMs / toolTimeoutMs, which are not per-server: they become Kimi's [mcp] startup_timeout_ms / tool_timeout_ms defaults in the shared global ~/.kimi-code/config.toml, applying to every MCP server including ones Rulesync did not write (a per-server value in mcp.json still wins). Global scope only, since config.toml has no project counterpart, and merged in place so the hooks and permission sections of the same file survive. The merge is per key: authoring only one of the two timeouts leaves a hand-written sibling alone, and dropping the override entirely leaves the section as it stands rather than deleting it — remove the keys from config.toml by hand if you want them gone. See the Kimi Code MCP docs and config-files reference.

Hermes Agent note: Hermes MCP servers live under mcp_servers in the shared ~/.hermes/config.yaml. Rulesync preserves the oauth keys Hermes reads (redirect_uri, redirect_host, redirect_port, client_id, client_secret, client_name, client_metadata_url, cimd, token_endpoint_auth_method, application_type, user_agent, flow, timeout, and scope) — scope is the space-separated string Hermes sends on registration and device login; a scopes list, which earlier Rulesync versions wrote but Hermes never read, is folded into scope in both directions — plus protocol (auto / stateless / legacy, v0.21), lazy (defer the connection to first tool use, v0.21), idle_timeout_seconds, max_lifetime_seconds (also under a lifecycle: mapping, carried as an opaque object), ssl_verify (true/false or a PEM CA-bundle path), skip_preflight, keepalive_interval (liveness ping cadence in seconds), trust (full or untrusted, where every write-capable tool call needs approval — copied verbatim, since Hermes reads any unrecognized value as untrusted), and the sampling, elicitation, and identity_header mappings (carried as opaque objects so new sub-keys keep working). A canonical sse server is written with Hermes's own transport: sse (v0.20.0) and imports back as type: "sse"; without it Hermes connects to a url server over Streamable HTTP, so the transport would silently change. Streamable HTTP is Hermes's default and stays implicit. On import, portable server fields remain in shared mcpServers; Hermes-only fields are isolated in the full hermesagent.mcpServers.<name> replacement block so they cannot leak to other targets.

Devin note: Since Devin v3000.3 (the Local 3.6 release), MCP servers live in a dedicated mcpServers-keyed file: .devin/mcp_config.json (project) and ~/.config/devin/mcp_config.json (global, via --global). The file is MCP-only and rulesync-owned (rewritten whole, deletable), unlike the shared .devin/config.json that permissions and hooks keep patching in place. Rulesync no longer writes the legacy config.json mcpServers key — Devin auto-migrates it away on startup, so re-seeding it would fight the migration — but import still falls back to that key when no mcp_config.json exists, so pre-v3000.3 repos migrate cleanly. The gitignored personal override .devin/mcp_config.local.json is never read or written (it is covered by the derived .gitignore). See the Devin MCP configuration docs. The devin-plugin target instead writes a plugin bundle's .mcp.json under mcpServers (project scope only). See the plugin packaging guide.

Warp note: Warp reads file-based MCP servers from .warp/.mcp.json (project) and ~/.warp/.mcp.json (global). Warp spells the working directory working_directory (used for resolving relative paths), so the canonical cwd is translated to it on generate and back on import; a tool-native working_directory already on the server wins over cwd. See the Warp MCP docs.

Warp Agent CLI note: The standalone Warp Agent CLI (the warp binary, target warpcli) keeps its own config root, separate from the Warp app's: ~/.warp_cli/ on macOS, ~/.config/warp-terminal/cli/ on Linux, and %LOCALAPPDATA%\warp\Warp\config\cli\ on Windows. It reads MCP servers from .mcp.json in that directory only — "Project-scoped MCP config files in repositories are not detected" — so warpcli is global-only (--global). The file format is the Warp app's (including the cwd ⇄ working_directory translation), and a warpcli tool-scoped block in .rulesync/mcp.jsonc applies to it alone. Rules and skills are shared with the app (AGENTS.md, .agents/skills/), so keep generating them with the warp target. See the Warp Agent CLI configuration docs.

Pool note: Pool reads MCP servers from the mcp_servers block of its YAML settings file: .poolside/settings.yaml (project; the committed, team-shared file — the untracked .poolside/settings.local.yaml overlay is left to you) and ~/.config/poolside/settings.yaml (global, via --global). The merge is in place — every other top-level key (pool, tools, sandbox, hooks, …) is preserved and the file is never deleted. Stdio servers are written as command/args/cwd/env; remote servers as a transport block (type: http or type: sse, url, and headers as Pool's list of "Name: value" strings, which imports back to the canonical map). streamable-http is written as http; ws servers are skipped with a warning because Pool offers no WebSocket transport. Tool filters map onto Pool's own switches: enabledTools becomes enabled_tools (the tools Pool exposes at all) and disabledTools becomes deny (patterns agents may never use); Pool's approval allowlist is authored on the canonical server as the namespaced poolAllow (written as allow, imported back as poolAllow, and stripped from every other tool's output). Note that Pool's deny/enabled_tools accept glob patterns while the canonical lists are literal tool names, so a pattern imported from Pool is carried to other tools' disabledTools/enabledTools as a literal name — the import warns when it sees one (allow is exempt, since poolAllow never reaches another tool). Because .poolside/settings.yaml is committed, prefer env entries that reference environment variables (or Pool's own secrets key) over literal credentials in headers. See the Pool MCP docs and the settings file reference.

DeepSeek Harness note: DeepSeek Harness (dsh) has no MCP-only file: an MCP server is an insert row for the bundled @deepseek-ai/dsh-mcp-client plugin in a Cordis patch layer. Rulesync writes those rows into the user-owned home layer ~/.dsh/cordis.patch.yml (the $DSH_HOME default), so dsh is global-only (--global); the profile layer is written by the app itself and there is no project-scoped MCP file. The file is a YAML list of patch entries, and the merge is in place: rulesync owns every @deepseek-ai/dsh-mcp-client row. A server that already has a row is rewritten where that row stood, in the same entry (so a row inserted into a group stays in that group), reusing its id so id-targeted patches keep applying; rows of servers no longer defined are removed; new servers are appended to the first plain top-level insert entry (or a new one), with the id mcp-<name> (suffixed if that id is already taken). Every other patch entry and insert row — including their !!js expressions — is preserved, and the file is never deleted. A file whose root is not a list is refused rather than overwritten. YAML comments are not preserved on rewrite and anchors/aliases are expanded, and a !!js expression inside an MCP row rulesync rewrites is replaced by the canonical value. On import, a !!js value in env or headers is dropped, and a row whose serverName, transport, command, url, cwd, args, disabled or one of the dsh-only fields below is (or, for reconnect, contains) a !!js expression is skipped with a warning — define that server in .rulesync/mcp.jsonc yourself, since the next generate removes the row otherwise. Stdio servers become transport: stdio with command/args/env/cwd; http/streamable-http servers become transport: streamable-http with url/headers; sse and ws servers are skipped with a warning because the client only speaks stdio and Streamable HTTP, and so is a server whose name does not match the harness's [A-Za-z0-9_-]{1,32} pattern. Canonical disabled: true becomes the entry-level disabled switch. The client-only toolCallTimeoutMs, failOnStartupError, maxInstructionBytes and reconnect fields are carried when authored on a server; import puts them in a dsh.mcpServers replacement block so they never leak to other tools; canonical timeout is not mapped. enabledTools/disabledTools are not supported. See the dsh-mcp-client README.

Takt note (partial / transport-allowlist only): Takt does not have a project- or global-level registry of MCP server definitions. The concrete mcp_servers map (command/args/env or type/url/headers) is declared per workflow step inside individual workflow YAML files; there is no top-level mcp_servers key in config.yaml, and Takt's config loader hard-rejects unknown top-level keys (introduced with MCP support in Takt v0.21.0). What config.yaml does hold is the default-deny transport allowlist workflow_mcp_servers: { stdio, sse, http } — without it, workflow-defined MCP servers are refused regardless of how they are declared. So Rulesync emits only this allowlist into the shared .takt/config.yaml (project) / ~/.takt/config.yaml (global), enabling exactly the transports your .rulesync/mcp.jsonc servers use (local/stdio ⇒ stdio; sse ⇒ sse; http/streamable-http/ws ⇒ http). The merge is in place — every other top-level key (provider, provider_profiles, …) is preserved and the file is never deleted. Documented lossiness: per-server names, commands, env, URLs, and headers are not representable in config.yaml and are intentionally not written; you still declare the concrete servers in your workflow YAML steps, and Rulesync only opens the transport gate that permits them. As a corollary, import cannot reconstruct server definitions from a transport allowlist and yields an empty mcpServers map. See the Takt configuration docs.

MCP Tool Config (enabledTools / disabledTools) ​

You can control which individual tools from an MCP server are enabled or disabled using enabledTools and disabledTools arrays per server.

json
{
  "mcpServers": {
    "serena": {
      "type": "stdio",
      "command": "uvx",
      "args": ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"],
      "enabledTools": ["search_symbols", "find_references"],
      "disabledTools": ["rename_symbol"]
    }
  }
}
  • enabledTools: An array of tool names that should be explicitly enabled for this server.
  • disabledTools: An array of tool names that should be explicitly disabled for this server.

Kiro note: Kiro MCP servers are written under mcpServers in .kiro/settings/mcp.json (project) and ~/.kiro/settings/mcp.json (global). Kiro supports disabledTools natively and Rulesync preserves it on generate and import. Kiro does not expose a corresponding per-server enabledTools allowlist, so that field is omitted for Kiro targets. The two Rulesync-only authoring keys are translated onto the field names Kiro actually reads: kiroAutoApprove becomes autoApprove (tools run without a confirmation prompt) and kiroAutoBlock becomes disabledTools (tools hidden from the agent). A server that already spells autoApprove or disabledTools natively keeps working — the two lists are merged rather than one overwriting the other. On import, autoApprove is lifted back into kiroAutoApprove; disabledTools stays as-is because it is already a canonical Rulesync field with the same meaning, so kiroAutoBlock has no import counterpart. That makes kiroAutoBlock a redundant spelling of disabledTools: a Kiro config imported after a generate comes back as canonical disabledTools, which then also reaches the other targets that support it. Prefer authoring disabledTools directly, which makes that scope explicit from the start.

Roo Code / Zoo Code note: Both targets write .roo/mcp.json through the same adapter, and their per-server MCP schema is denylist-only: disabledTools clears each named tool's enabledForPrompt (the tool stops being offered to the model), and there is no corresponding enabledTools allowlist, so that field is omitted for these targets. The server config is emitted verbatim, so disabledTools round-trips as itself. Earlier Rulesync versions stripped it before the adapter saw it, which meant a filter you had written into .roo/mcp.json by hand — or through Zoo Code's own tool toggles, which write the same key — was deleted on the next generate.

deepagents note: MCP servers are written to .deepagents/.mcp.json (project) / ~/.deepagents/.mcp.json (global). Several translations apply, because dcode drops an individual server it cannot validate — the rest of the file still loads, so a mistake here is silent rather than loud. Transports: dcode accepts only stdio, sse and http (plus the aliases streamable_http / streamable-http → http), so canonical local is written as stdio and streamable-http as http; canonical ws has no counterpart and the server is skipped with a warning at generate time, where you can still see it. Whether the value lands under type or transport follows whichever key you authored — dcode reads the two interchangeably, and with neither set it infers http from a url and stdio otherwise. On import, both spellings of the streamable_http alias come back as canonical http. Tool filters: canonical enabledTools becomes allowedTools (dcode never reads enabledTools, and it ignores unknown keys silently, so forwarding the canonical name would be a no-op), while disabledTools carries its own name; each entry is a tool name or an fnmatch glob, and import lifts allowedTools back. Upstream rejects a server that sets both filters and rejects an empty list, and each case is resolved the way that does not hand the model more tools than your canonical config allows. Setting both is valid canonically — other targets apply the two lists independently — but has no form here, so the server is skipped with a warning rather than written without filters, which would leave it running with every tool including the denied ones. An empty enabledTools likewise skips the server, since an allowlist of nothing means no tools at all and dropping the key would publish all of them. An empty disabledTools is the one genuine no-op, so only that key is dropped (with a warning) and the server is still written. Server shapes: dcode validates each entry into FastMCP 4's typed server models, so three canonical shapes are normalized as well. An httpUrl is written as url (dcode never reads the httpUrl alias and would take such a server for stdio) with the transport pinned to http when none is set, since httpUrl names a streamable HTTP endpoint (an authored type or transport is kept); when both are set, url wins and httpUrl is dropped. An array command is split into its first element and an args list that the rest is prepended to, because FastMCP accepts only a string command; an empty array leaves nothing to spawn, so that server is skipped with a warning (on a remote server the empty command is simply dropped). A server with disabled: true is skipped with a warning, because dcode has no per-server disabled key — its only off switch is the server viewer's [mcp].disabled_servers toggle in config.toml — and writing the key would leave the server running; disabled: false is simply dropped. See the MCP tools docs.

Qwen Code note: MCP servers are written to the mcpServers key of .qwen/settings.json (project) / ~/.qwen/settings.json (global, via --global). Qwen supports stdio (command/args), SSE (url), and HTTP (httpUrl) transports. Rulesync maps the canonical per-server enabledTools ⇄ Qwen's includeTools (allowlist) and disabledTools ⇄ Qwen's excludeTools (denylist). Other top-level keys in settings.json are preserved on round-trip. In project mode, import also reads the Claude-parity .mcp.json at the project root (loaded by Qwen Code since v0.18.1) and merges its servers beneath those of .qwen/settings.json, so a server defined in both keeps its settings.json definition, matching Qwen Code's precedence (user settings < project .mcp.json < workspace settings); generation still writes only .qwen/settings.json and never touches .mcp.json, so after an import and a regenerate the imported servers live in both files and the settings.json copy wins — edit them through .rulesync/mcp.jsonc from then on. A malformed .mcp.json is skipped with a warning. Qwen's server-level allow/deny lists — mcp.allowed and mcp.excluded in settings.json, MCP server names or * / ? globs over the mcpServers keys, with excluded winning when a server matches both — are authored as allowed / excluded in the qwencode block of .rulesync/mcp.jsonc (for example "qwencode": { "allowed": ["my-trusted-server", "*-internal"], "excluded": ["experimental-server"] }) and imported back into that block. They gate every server Qwen Code loads, including ones rulesync did not write. Each list is written only when authored, so a list you leave out of the block — and the other keys of the mcp object, such as mcp.serverCommand — keep whatever value settings.json already holds; removing a list from the block therefore does not remove it from settings.json. To clear a list, author it as an empty array ("excluded": []) for one generate, or delete it from settings.json by hand. See the Qwen Code MCP docs.

Codex CLI server-name note: Codex requires MCP server names matching [a-zA-Z0-9_-]+, so Rulesync auto-normalizes non-conforming names on generate (lowercase, runs of other characters become _, leading/trailing _ trimmed) — e.g. Postgres MCP - Production - Read Only becomes postgres_mcp_production_read_only. If two names normalize to the same Codex name, the last processed server overwrites the earlier one (with a warning). A name with no representable characters at all (e.g. a fully Japanese name) falls back to a stable hash-derived name like mcp_1a2b3c4d instead of being dropped; rename the server in .rulesync/mcp.jsonc to pick a readable Codex name. This normalization is one-way: importing back from the generated config.toml yields the normalized name, not the original.

Codex CLI key-translation note: Codex's [mcp_servers.<name>] table reads its own field names, so the canonical fields are translated rather than forwarded. headers is written as http_headers (and imports back as headers), which Codex accepts only on a url-based server — on a stdio server it is a load error upstream, so the headers are dropped with a warning instead. The canonical millisecond timeouts become Codex's second-based ones: timeout ⇄ tool_timeout_sec (the default timeout for tool calls) and networkTimeout ⇄ startup_timeout_sec (initialize + list-tools), dividing by 1000 on generate and multiplying on import, so a sub-second remainder is emitted as a fraction. Codex also accepts startup_timeout_ms, which imports verbatim into networkTimeout unless the config sets startup_timeout_sec too — Codex prefers the seconds spelling when both are present, and so does Rulesync. The canonical tools array is not written: Codex declares tools as a table of per-tool approval settings (tools.<tool>.approval_mode), and an array where it expects a table is a hard deserialization error that takes the whole server entry down, so it is dropped with a warning — use enabledTools / disabledTools, which map onto Codex's enabled_tools / disabled_tools. Codex's table itself is authored through the namespaced codexcliTools key and imports back into it (see below). Both timeouts must be non-negative: Codex builds a duration out of them and errors on a negative value, which fails the whole file, so such a value is dropped with a warning. Canonical fields Codex has no counterpart for (type/transport — Codex infers the transport from command versus url — plus alwaysAllow, trust, and the Kiro lists) are dropped silently; on import a server carrying a url and no command gets type: "http" restated, so a config read out of Codex still reaches the tools that branch on the transport. That restatement is one-way, like the server-name normalization: Codex's only remote transport is streamable_http, so a canonical sse (or ws, or streamable-http) server comes back from a round-trip as http. Fields Rulesync does not model, such as env_http_headers and bearer_token_env_var, pass through under their own names.

Codex CLI environment-variable note: Codex does not expand ${VAR} references in url or http_headers — the values are sent literally. On generate, Rulesync therefore rewrites the two header shapes Codex can source from the environment: Authorization: Bearer ${VAR} becomes bearer_token_env_var = "VAR", and a header whose whole value is ${VAR} becomes an env_http_headers entry ("X-Api-Key" = "VAR"). The header name and the Bearer scheme are matched case-insensitively. A bearer_token_env_var or env_http_headers entry you set explicitly on the server wins: the header that would have been converted into it is dropped instead of being written a second time. Anything else still containing ${…} — a reference inside a longer value, a ${VAR:-default} fallback, or any reference in url (for which Codex has no environment-sourced counterpart) — is written verbatim with a warning naming the server and field, so replace it with a literal value for Codex. The rewrite is one-way: importing from Codex keeps bearer_token_env_var and env_http_headers as pass-through keys rather than restoring the canonical headers entries.

Codex-specific: pass shell env vars to MCP servers (envVars) ​

Codex CLI supports a per-server array of shell env var names to inherit when launching the MCP server process. The source schema uses envVars (camelCase, matching the project convention used by sibling fields like enabledTools/disabledTools); the codex generator renames it to env_vars (snake_case) for codex's native config.toml format.

This is distinct from env (which is a literal {name: value} map) — envVars is a list of names whose values come from the user's environment at runtime. Both fields may coexist on the same server.

json
{
  "mcpServers": {
    "pal": {
      "type": "stdio",
      "command": "uvx",
      "args": [
        "--from",
        "git+https://github.com/BeehiveInnovations/pal-mcp-server.git",
        "pal-mcp-server"
      ],
      "envVars": ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]
    }
  }
}

Generated ~/.codex/config.toml:

toml
[mcp_servers.pal]
type = "stdio"
command = "uvx"
args = ["--from", "git+https://github.com/BeehiveInnovations/pal-mcp-server.git", "pal-mcp-server"]
env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]

An entry may also be an object naming the environment to read the variable from: { "name": "REMOTE_TOKEN", "source": "remote" } reads it from the remote executor environment (and requires remote MCP stdio support), while a bare name and "source": "local" read from Codex's own environment. The object form is written to config.toml as an inline table, matching Codex's documented shape. Only name and source are accepted in that object — Codex rejects an unknown key there, and rejecting one server's entry would take the whole config.toml down with it, so Rulesync fails on the canonical file instead. For the same reason an entry that a config.toml already holds in some other shape is dropped with a warning on import rather than written into a .rulesync/mcp.jsonc the next generate would refuse.

  • Emitted only into the codex CLI output. Stripped from RulesyncMcp.getMcpServers() so it does not appear in other tools' generated configs (Claude Code, Kilo, OpenCode, Gemini CLI, Cursor, Cline, Junie, Factorydroid, Rovodev, etc.).
  • Use this for secrets and API keys you do not want literal-encoded into a committed mcp.json.
  • Precedence: codex CLI resolves these names from the user's runtime shell environment. If a name is also set in env (literal value), the codex CLI behavior is upstream-defined; see the Codex configuration reference (last checked 2026-05-13) for the exact resolution rule.

Codex-specific: run a stdio server remotely (experimentalEnvironment) ​

For stdio servers, experimentalEnvironment: "remote" starts the server through a remote executor environment when one is available. It is written as experimental_environment in config.toml. Like envVars, it is stripped before every other tool's MCP config is written, so it cannot leak into a config that would not understand it — and for the same reason, a server config copied straight out of a config.toml may spell it experimental_environment, which is accepted and normalized on the way to Codex.

See the Codex MCP reference for both fields.

Codex-specific: OAuth keys (oauth.clientId → client_id) ​

A server's oauth block is preserved in the canonical Claude Code shape (camelCase clientId, callbackPort), but Codex CLI reads only the snake_case keys of its own oauth table: client_id, client_secret, callback_url, callback_port and authorization_server_issuer. Without client_id, codex mcp login <server> falls back to dynamic client registration and fails for providers that do not support it (e.g. Slack). The codex generator therefore renames clientId to client_id and callbackPort to callback_port, passes the Codex-native keys through (an explicit native key wins over its canonical spelling), and drops every other key, such as Claude Code's authServerMetadataUrl, with a warning — since Codex 0.155.0 it reports such a key as an ignored setting at startup:

toml
[mcp_servers.slack.oauth]
client_id = "1601185624273.8899143856786"
callback_port = 3118

A value of the wrong type (a non-string client id, a callbackPort that is not an integer port) is dropped with a warning too, because Codex would fail to load the whole config.toml over it — as is a client_secret that is blank or comes without a non-blank client id, which Codex rejects the same way. On import, client_id and callback_port are renamed back to clientId and callbackPort; when a file carries both spellings (earlier Rulesync versions wrote clientId next to client_id), the canonical key wins so the round-trip stays stable.

Codex-specific: transport normalization ​

Codex reads command as a single string and has no httpUrl key, and either mismatch stops the whole config.toml from loading — disabling the permissions profile and approval policy along with MCP. The codex generator therefore splits an array command into its program plus leading args (canonical args follow), and writes httpUrl as url (an explicit url wins). A server whose command array is empty and that has no url is skipped with a warning.

Codex-specific: per-tool approval modes (codexcliTools) ​

Codex reads per-tool settings from [mcp_servers.<name>.tools.<tool>] tables: approval_mode (auto, prompt, writes or approve) overrides the server's default_tools_approval_mode for one tool, and output_token_limit (a positive integer) sets that tool's output token budget. Author them as codexcliTools, a map keyed by tool name — usually inside the codexcli block, since no other tool reads it. The canonical tools key cannot be used for this: it is a string array, and Codex reads its own tools key as this table.

jsonc
{
  "mcpServers": {},
  "codexcli": {
    "mcpServers": {
      "example": {
        "type": "http",
        "url": "https://example.com/mcp",
        "default_tools_approval_mode": "writes",
        "codexcliTools": {
          "example_tool": { "approval_mode": "approve" },
        },
      },
    },
  },
}

Generated .codex/config.toml:

toml
[mcp_servers.example]
url = "https://example.com/mcp"
default_tools_approval_mode = "writes"

[mcp_servers.example.tools.example_tool]
approval_mode = "approve"
  • codexcliTools may also sit on a shared server; it is stripped before every other tool's MCP config is written, like envVars.
  • The two known fields are validated strictly, because Codex rejects the whole config.toml on an unknown approval_mode or a zero output_token_limit; any other key in an entry is passed through.
  • Approvals Codex itself saved into config.toml (for example after "always allow") are kept on regenerate. The authored table is merged over them field by field: a field set in codexcliTools wins, and every saved tool or field it does not name is kept (authoring only output_token_limit for a tool keeps its saved approval_mode). Removing a tool from codexcliTools therefore does not remove its entry from config.toml; delete it there by hand.
  • On import, the tools table is lifted into codexcliTools on the imported server. An entry Rulesync would refuse on the next generate is dropped with a warning. The table includes approvals Codex saved for you locally, so review them before committing the imported .rulesync/mcp.jsonc: once committed, they apply to everyone who generates from it.

See the Codex configuration reference (mcp_servers.<id>.tools.<tool>.approval_mode / output_token_limit).

Grok CLI note: MCP servers are written to a [mcp_servers.<name>] table in .grok/config.toml (project) / ~/.grok/config.toml (global, via --global). The file is treated as shared Grok config: Rulesync only replaces the mcp_servers key and preserves every other table on round-trip, and it is never deleted. Unlike Codex CLI, Grok uses a literal env table (it does not support the env_vars runtime-passthrough list) and has no per-server tool allow/deny lists, so the only field rename is disabled (rulesync) ⇄ enabled = false (grok); an active server simply omits enabled. Servers with no environment variables are emitted without a dangling [mcp_servers.<name>.env] table (empty nested tables are stripped), and a server whose entire configuration would be empty is dropped with a warning.

Goose-specific: MCP servers as extensions (global) and open-plugin manifest (project) ​

Goose configures MCP servers in two locations depending on scope:

  • Global (--global): MCP servers are written as extensions in the shared user config ~/.config/goose/config.yaml. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: command → cmd (an array command folds its tail into args), env → envs, url/httpUrl → uri, and disabled: true → enabled: false. The type is derived — command ⇒ stdio, a remote url ⇒ streamable_http (or sse when the canonical type is sse). Each extension also carries its own name. A canonical server with no command and no url is skipped with a warning rather than written as a stdio extension with no cmd, which Goose cannot start. Generation merges the extensions: block into the existing config.yaml, preserving other Goose settings (model, provider, ...), and the file is never deleted. The extensions: map itself is co-owned: Goose's own builtin/platform/frontend/inline_python extensions (developer, memory, ...) live there alongside MCP servers and are carried over untouched, as is any entry Rulesync cannot read as an MCP server, while every entry it positively identifies as one (stdio/streamable_http/sse) is Rulesync-owned, so a server deleted from .rulesync/.mcp.json is retracted with a warning naming it. Import mirrors this: a non-MCP extension type is skipped with a warning instead of being imported as a server (importing a builtin used to strip the type that makes it work). This location supports both stdio and remote (http/sse) servers.
  • Project: Goose v1.39.0+ discovers MCP extensions in open plugins at <project>/.agents/plugins/<name>/.mcp.json (and ~/.agents/plugins/<name>/.mcp.json at user scope). Rulesync emits .agents/plugins/rulesync/.mcp.json, reusing the same .agents/plugins/rulesync/ tree already used for Goose hooks. The manifest uses the Claude-style { "mcpServers": { "<name>": { "command", "args", "env", "cwd" } } } shape. This manifest is stdio-only — it cannot express url/headers, so remote (http/sse) servers are skipped with a warning in project mode; sync them with --global to ~/.config/goose/config.yaml instead. The .mcp.json manifest is owned by Rulesync and is deleted when no servers remain.

See the Goose extensions docs and open-plugins MCP PR #9471.

Goose-specific: commands as recipes, subagents as custom agents ​

Goose recipes are reusable YAML workflow files. Commands map to top-level recipes at .goose/recipes/<name>.yaml (project) and ~/.config/goose/recipes/<name>.yaml (global); the command body becomes the recipe prompt, title defaults to the file name and description to the rulesync description (falling back to title), version defaults to 1.0.0, and any other recipe field round-trips through the rulesync goose section of a command.

A recipe on disk is not invocable as /name on its own: Goose resolves slash commands from the slash_commands list in the user config (~/.config/goose/config.yaml), whose entries are { command, recipe_path } pairs. In global mode Rulesync therefore registers every generated recipe there. recipe_path is written as an absolute path, because Goose resolves it with a bare PathBuf::from(...) on this code path (the tilde expansion used by goose run --recipe does not apply, so a ~/… registration would never resolve), and the command name is lowercased, because Goose lowercases the typed command and compares it against the stored value verbatim. There is no project-level registration surface upstream, so project-scope recipes must still be run with goose run --recipe.

The list is co-owned: entries whose recipe_path points outside ~/.config/goose/recipes/ — and sub-recipes under recipes/subagents/ — are carried over untouched, while every entry pointing directly into that directory is Rulesync-owned and recomputed on each --global generate. That retracts a deleted command's registration and drops the key once nothing is registered, but it also means a slash command you registered yourself (via Goose's own UI or goose recipe) for a recipe living in that directory is removed on the next generate — keep such recipes elsewhere, or author them in .rulesync/commands/. Command names must be unique, contain no spaces, and must not shadow a built-in command such as /recipe, /compact, or /help; Rulesync does not check the built-in names for you. See the slash-command mapping in the Goose source.

Subagents map to Goose's custom agents (v1.34.0+): Markdown files with name (required) / description / model frontmatter whose body is the agent instructions, invocable via @name or delegation. They are emitted to the goose-specific discovery dirs .goose/agents/<name>.md (project) and ~/.config/goose/agents/<name>.md (global), so the output cannot collide with a future shared .agents/agents/ target; model and unknown future fields round-trip through the rulesync goose subagent section. Earlier rulesync versions emitted subagents as sub-recipe YAML under .goose/recipes/subagents/ — a location Goose's agent discovery never scans, so those files were inert; they are no longer generated (stale outputs stay gitignored but are not cleaned up automatically).

Vibe-specific: stdio cwd and MCP [auth] block ​

Vibe (mistral-vibe) MCP servers live in [[mcp_servers]] arrays of the shared .vibe/config.toml. In addition to the flat fields, Rulesync passes through the stdio cwd (working directory), a structured per-server auth block (Vibe v2.15.0+), and the four keys Vibe's /mcp panel writes back when you toggle a server or one of its tools — prompt, sampling_enabled, disabled and disabled_tools. Because mcp_servers is replaced as a whole array on each generate, a server Rulesync writes is seeded from the on-disk entry of the same name for exactly those keys, so a toggle you made in the TUI survives — unless your .rulesync/mcp.json states the value itself, which wins. disabled_tools is the canonical disabledTools under Vibe's spelling; prompt and sampling_enabled have no canonical equivalent and pass through as-is. The auth table is discriminated on type: static (headers, api_key_env, api_key_header, api_key_format) and oauth (scopes, client_id / client_metadata_url, redirect_port). Because Vibe rejects mixing legacy top-level static-auth keys with an explicit [auth] block, Rulesync suppresses the legacy keys (headers/api_key_env/api_key_header/api_key_format) whenever a server carries an auth block. Servers added outside Rulesync — through vibe mcp add (v2.23.0) or the /mcp add panel, both of which persist straight into this TOML — are preserved after the managed entries instead of being deleted by the array replace. The flip side: removing a server from .rulesync/mcp.jsonc no longer removes it from config.toml; delete it there too (or run vibe mcp remove). Deleting it from one scope may not be enough either: since v2.24.0 Vibe stacks the user and project layers and union-merges mcp_servers by name, so a server of the same name left in ~/.vibe/config.toml still resolves after you remove it from the project file. See mistral-vibe (vibe/core/config/models.py). The vibe-plugin target instead writes a plugin bundle's mcp.json in the Agent Plugins shape (project scope only), with stdio and streamable-http servers only. See the plugin packaging guide.

GitHub Copilot (VS Code) MCP note: the copilot target writes .vscode/mcp.json, which has three documented top-level sections: servers, inputs (secret prompts referenced as ${input:id}) and sandbox (filesystem/network rules for sandboxed servers, added in VS Code v1.112). Rulesync owns and replaces only servers; the rest of the document — including any future top-level section — is read back and preserved on each generate. VS Code recommends committing this file, so dropping an inputs entry would leave ${input:…} unresolvable and the affected servers would fail to start. If the existing file cannot be parsed, generate fails with an error rather than overwriting it. See the MCP configuration reference.

Rovo Dev CLI MCP note: Rovo Dev documents the per-server transport key as transport (stdio | http | sse), not the canonical type. Rulesync translates on the way out (local → stdio, streamable-http → http) and back on import; ws has no Rovo Dev equivalent, so those servers are skipped with a warning, and a transport value outside Rovo Dev's vocabulary is dropped on import rather than written into the canonical config, whose transport field is a strict enum. disabled is stripped from the servers that are written, since mcp.json is not where a Rovo Dev server is switched on and off — see the toggle handling below. mcp.json is written at both scopes: the global ~/.rovodev/mcp.json, and in project mode the repo-committed .rovodev/mcp.json the Bitbucket Cloud Agentic Pipelines guide documents (pointed at via mcp.mcpConfigPath, which Rulesync now writes into the project config.yml for you: Rovo Dev's mcpConfigPath default points at the global MCP file, so without the pointer the generated project mcp.json is never read. The pointer names one config instead of merging with the default, so it is written only when the project has a Rovo Dev server to run — one that targets rovodev, is not disabled, and names a command/url to start — since otherwise the project would trade the user's global servers for an empty file; writing it is logged, being the step that makes Rovo Dev start those servers. If the last such server is later removed or switched off, the pointer is not taken back out (Rulesync cannot tell its own past value from a user who typed the same string) but the now-empty result is reported with a warning. A mcpConfigPath already aimed somewhere else is left in place — it is the user's choice — and reported with a warning naming the file that stays unread. One value earns its own message in global scope: ~/.rovodev/mcp_config.json, the default the settings reference documents, which Rovo Dev may therefore have written itself rather than the user choosing it. That is exactly the user this pointer exists for, so the warning says what to change and whether that file holds servers they would lose by changing it. The comparison recognizes the other spelling of the same home-directory file — an already-expanded absolute path — so a pointer that already names the generated file is not reported as unread, and a stale one still is. $HOME/... and ${HOME}/... are deliberately not counted as correct: nothing in Atlassian's documentation says Rovo Dev expands environment variables in this setting, and a pointer that resolves literally reads no MCP servers at all, so that spelling gets a warning of its own asking for the ~ form instead. Global scope gets the same treatment, with the home-anchored value ~/.rovodev/mcp.json, because Atlassian's own pages disagree about the default: the settings reference documents ~/.rovodev/mcp_config.json, while the MCP guide has servers registered in ~/.rovodev/mcp.json. Under the first spelling the global file Rulesync writes is never read, so naming it explicitly is what makes the outcome the same either way; the value has to be home-anchored rather than repo-relative, since ~/.rovodev/config.yml is read from whatever directory Rovo Dev runs in. That same disagreement is why the global pointer is withheld when ~/.rovodev/mcp_config.json holds servers of its own — if the settings reference is the spelling in force, those are the servers Rovo Dev is running today, and naming mcp.json instead would stop them being read on every project on the machine. Rulesync does not write that file, so it could neither import them first nor put them back; it reports what it found and leaves the pointer for you to set once you have moved what you want to keep into .rulesync/mcp.jsonc. A mcp_config.json that cannot be parsed, cannot be read (a directory or a permission error at that path, which costs this one decision rather than aborting the run), or carries a shape Rulesync does not recognize all count the same way, since none of them can be shown to be empty; only an mcpServers map with no entries — or a literally empty {} — releases the pointer. Each of those reports also says what to do when the file turns out to hold nothing you need — set mcp.mcpConfigPath yourself, which Rulesync never overwrites, or change it if it is already set — so a file Rulesync cannot see inside is never a dead end. Writing the global pointer is a warning rather than a note, the project pointer changing one repository where this one changes Rovo Dev everywhere. The project file is not gitignored, since committing it is the point). A server the canonical config marks disabled: true is no longer dropped: its definition is written to mcp.json (minus the flag, which the file cannot express) and its name goes to mcp.disabledMcpServers in the sibling config.yml — the key Rovo Dev actually consults — where rulesync owns the toggle for the servers it manages while user keys (allowedMcpServers, ...) and disabled names for unmanaged servers survive. On import, names listed in mcp.disabledMcpServers come back as disabled: true on the matching servers; a config.yml that exists but cannot be parsed fails both directions closed (the import errors instead of silently re-enabling servers, and generate skips disabled definitions it cannot switch off). Since the project mcp.json is committed, prefer env-var references over literal credentials in server env/headers; note that rulesync owns the mcpServers map in that file, so servers hand-added there (rather than to .rulesync/mcp.jsonc) are replaced on the next generate. Rovo Dev's per-server enable_instructions — which decides whether the server's own initialization-response instructions join the agent's system prompt — is authored as the Rulesync-only key rovodevEnableInstructions (Rovo Dev's own enable_instructions spelling is accepted too, so an entry copied out of Atlassian's docs works) and written out as enable_instructions; on import it is lifted back onto rovodevEnableInstructions. It is a tri-state, not a switch that only exists when on: as of the 2026-09-02 revision of Atlassian's MCP page, an absent key or true surfaces the instructions and false is what suppresses them — the opposite of the opt-in wording the key was first documented under. So both booleans are written and both are lifted, and false above all: it is the one value that keeps a third-party server's text out of the prompt, and dropping it would leave the suppression unauthorable and make an import → generate round-trip switch it back on. Leaving the key out authors nothing, matching Rovo Dev's own default. A non-boolean is not read as a suppression in either direction — Rovo Dev surfaces the instructions for anything that is not false — and when both spellings are present on one entry the canonical key decides, so an explicit rovodevEnableInstructions: false overrides an enable_instructions: true left over from a copied example rather than losing to it. Generate emits no warning for this key: writing true now restates the upstream default rather than widening anything. Like musecodeMode, the key is stripped from every other target's output — and here that matters more than tidiness: it decides whether a third-party server's text joins the model's prompt, so it must not reach a tool you were not writing about. See the Rovo Dev MCP docs.

Meta Muse Code MCP note: Muse Code reads MCP servers only from the mcp_servers block of the global user settings file ~/.config/muse/settings.json — no project-scoped MCP location is documented, so the musecode MCP target requires --global. Each server entry carries a transport discriminator: stdio servers get command (a single string), args and env, and remote servers become transport: "streamable_http" with url/headers (Muse Code's only documented remote transport). A server that states sse or ws — or carries a ws:///wss:// URL with no stated type — is skipped with a warning rather than rewritten onto a transport it does not speak. A canonical disabled: true maps to Muse's enabled: false and back. The settings file must carry "schema_version": 1 — Muse Code fails startup with malformed settings file without it — so Rulesync bootstraps the key when it creates the file and preserves an existing value; every other settings key is preserved on round-trip and the file is never deleted. There are no per-server tool allow/deny lists. Muse Code's per-server mode — required (its default) aborts the whole run when the server fails to start, optional skips it with a warning — is authored as the Rulesync-only key musecodeMode and written out as mode; on import a mode of required or optional is lifted back into musecodeMode, while any other value is dropped with a warning naming the server — keeping it would copy a Muse Code key into every other target's generated config, since Rulesync's server schema is loose, and the next generate would drop it from the Muse Code side regardless. musecodeMode is stripped from every other target's output, so it is safe to leave on a shared server entry. See the Muse Code extending docs and configuration docs.

Reasonix note: MCP servers are written as [[plugins]] array-of-tables entries (Reasonix's MCP-compatible external plugins) in reasonix.toml (project) / ~/.reasonix/config.toml (global, via --global). Each entry carries a name plus the standard transport fields: type selects the transport (stdio default — command/args/env; http, a.k.a. streamable-http — url/headers; sse, the legacy 2024-11-05 HTTP+SSE transport, written verbatim — Reasonix re-implemented it in v1.17.18, and collapsing it onto http pointed the client at Streamable HTTP so the server could not connect). The file is treated as shared Reasonix config: Rulesync only replaces the plugins key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. The canonical disabledTools list is written as the v2 line's per-server disabled_tools (raw tools/list names the server's tools are filtered by) and read back on import; Reasonix has no per-server tool allowlist, so enabledTools is not emitted. Blank tool names are dropped with a warning on generate, and on import only the string entries of the list are kept. The legacy v1 line has no such key and ignores it on load. The trusted_read_only_tools array (raw MCP tool names pre-seeded as trusted for planner/read-only use) is neither written nor imported: v1.17.18 retired it along with default_tools_approval_mode, tools.<raw>.approval_mode and approvals_reviewer — installing a server is the authorization decision now, and Reasonix ignores the key on load and strips it the next time it saves that entry. Importing it would put a Reasonix-only dead key into the canonical mcpServers that every MCP target writes out, so it would surface in .mcp.json and the rest. Note that Rulesync owns the plugins key, so the next generate drops the key from an older reasonix.toml as well; nothing is lost that Reasonix still reads. An MCP server whose transport Reasonix does not implement (ws, including a ws:///wss:// URL that states no transport at all) is skipped with a warning rather than written as a type its loader rejects. Each entry also supports startup_timeout_seconds (a per-server cap on the background launch/authorization/initialize/tools/list sequence, overriding the global mcp_startup_timeout_seconds; 0 means defer to that global cap, and is preserved rather than dropped), call_timeout_seconds (a per-server MCP call timeout), tool_timeout_seconds (a per-tool inline table keyed by raw MCP tool name), concurrency (serial stops two calls to that server from running at once; with no value Reasonix substring-matches the server name against a known-stateful list — browser, playwright, puppeteer, chrome, chromium, selenium — and serializes a match, so an explicit value is what settles the question in either direction: serial for a stateful server the list does not catch, parallel for one it does), auto_start (false switches the server off rather than deferring it: Reasonix drops it from the enabled set, so its tools never reach the model and it is reported as disabled rather than deferred, and only an explicit re-enable — recorded in mcp-activation.json, which outranks the file — brings it back), load (v2: always puts the server's tools in the schema from session start; empty or deferred reaches them through use_capability) and oauth_allow_missing_pkce_metadata (v2: lets OAuth proceed with an authorization server that does not advertise PKCE methods; Reasonix honours it only from the user config, so it has no effect in a project reasonix.toml, and rulesync names it in a warning whenever it writes true). All of them round-trip as passthrough fields on the canonical MCP server object rather than through a deep mapping, except auto_start, which canonical disabled drives. A canonical disabled: true is always written as auto_start = false — a stop asked for across every tool wins over an authored auto_start, with a warning when that was anything but false — and an authored auto_start applies only to a server that is not disabled. On import, auto_start = false becomes canonical disabled: true and auto_start itself is not kept (true is the default), so a later edit to disabled is not shadowed by a leftover value. concurrency, auto_start, load and oauth_allow_missing_pkce_metadata are type-checked on generate. A value of the wrong type would make Reasonix fail to load the whole config file, so it is dropped with a warning instead of being written. A concurrency string other than serial / parallel (compared case-insensitively, as Reasonix does) is dropped with a warning too, since Reasonix ignores it and falls back to the server-name default. concurrency, call_timeout_seconds, tool_timeout_seconds, load and the OAuth key have no canonical counterpart at all; the activation store can still flip a server independently of the file, which is runtime state rather than config; and startup_timeout_seconds has a near-equivalent in canonical networkTimeout (which Codex CLI deep-maps to its startup_timeout_sec), but canonical timeouts are milliseconds while Reasonix takes seconds, and Reasonix's meaningful 0 has no canonical spelling — so mapping it would either invent a value or lose one. Vibe's startup_timeout_sec passes through for the same reason. See the Reasonix plugins guide and SPEC.md ([[plugins]] schema).

ZCode MCP note: ZCode reads MCP servers from the mcp.servers block of its own JSON config: <project>/.zcode/config.json (project) and ~/.zcode/cli/config.json (global, via --global). A stdio server carries command, args and env; a remote server carries type — http (what rulesync calls streamable-http) or sse — plus url and optional headers. ZCode's own page shows a JSON example for stdio servers only and describes remote ones through its UI ("Service URL" plus optional headers), so the remote JSON spelling used here is inferred from the configuration Z.ai documents for the other MCP clients it supports. A server with no stated transport and an http(s) URL is written as type: "http", ZCode's default remote transport, while a ws/wss server is skipped with a warning rather than rewritten onto a transport ZCode does not implement. ZCode's per-server toggle is enable, which defaults to true when absent, so a canonical disabled: true is written as enable: false and lifted back on import. The file is shared ZCode config: rulesync only replaces the mcp key (preserving its non-servers siblings and every other top-level key such as model), never deletes the file, and fails closed rather than overwriting a config.json it cannot parse. ZCode's legacy .agents/mcp.json fallback — consulted only while the .zcode file of the same scope lists no server — is neither written nor imported, so a hand-maintained one is left alone. Because rulesync replaces the whole servers map, a server added to config.json by hand is dropped on the next generate; add it to .rulesync/.mcp.json instead. The project file is committable, so prefer env entries that reference environment variables over literal credentials. There are no per-server tool allow/deny lists. See the ZCode MCP services docs. The zcode-plugin target writes the same server shape into a plugin bundle's .mcp.json under mcpServers (project scope only), with a disabled server written as enabled: false, the plugin loader's spelling. See the plugin packaging guide.

IBM Bob MCP note: IBM Bob reads MCP servers from the mcpServers map of a dedicated file: <project>/.bob/mcp.json (project) / ~/.bob/settings/mcp.json (global, via --global); the project entry wins when both define the same server name. Rulesync writes the shape Bob IDE documents: a stdio server carries command and args; a streamable-HTTP server (canonical http, streamable-http, a bare url, or Claude-style httpUrl) is written as type: "streamable-http" plus url; an explicit sse server is written as a bare url with no type, Bob's legacy SSE spelling. env, cwd, headers, timeout, alwaysAllow, and disabled are passed through. The canonical oauth object is flattened onto the keys Bob reads: oauth: true plus clientId, clientSecret and scope (a flat key already on the server wins over the nested one; nested keys Bob has no counterpart for, such as callbackPort, are dropped), while a boolean oauth passes through as Bob's own on/off switch. On import the flat keys are gathered back into the oauth object when oauth is true or absent; oauth: false leaves the entry as authored. A server Bob cannot start or reach — one with no transport, a remote transport without a URL, a ws/wss URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. On import, Bob IDE's type: "streamable-http"/sse entries pass through, a bare url becomes type: "sse", and Bob Shell's httpURL spelling becomes url with type: "http" (Bob Shell's own user file, ~/.bob/mcp_settings.json, is neither written nor imported). Only the mcpServers key is rewritten, so other keys in an existing file are preserved. The global ~/.bob/settings/mcp.json is created and edited by Bob IDE's own MCP settings UI and is therefore never removed by --delete. See the Bob IDE MCP docs, the Bob MCP OAuth docs and the Bob Shell MCP docs.

Tabnine CLI MCP note: Tabnine CLI reads MCP servers from the mcpServers key of the shared .tabnine/agent/settings.json (project) / ~/.tabnine/agent/settings.json (global, via --global). A stdio server carries command and args; a remote server carries url plus an optional type — "http" (streamable HTTP; the canonical streamable-http alias and the Claude-style httpUrl field are folded into it) or "sse". A bare url with no stated transport is written without type, since Tabnine then tries streamable HTTP first and falls back to SSE on its own. Rulesync maps the canonical per-server enabledTools ⇄ Tabnine's includeTools and disabledTools ⇄ excludeTools; env, cwd, headers, timeout and trust pass through. A server Tabnine cannot start or reach — one with no transport, a remote transport without a URL, a ws/wss URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only the mcpServers key is rewritten, so the other settings in the file are preserved, and the file is never removed by --delete. See the MCP server config docs.

Snowflake Cortex Code MCP note: Snowflake Cortex Code reads MCP servers only from the mcpServers map of the user-level ~/.snowflake/cortex/mcp.json — no project-scoped MCP file is documented, so the mcp feature is global-only (--global is required; a project-scope run skips the target with a warning). Every server is written with an explicit type: a stdio server carries type: "stdio", command and args; a remote server carries url plus type: "http" (the canonical http, streamable-http, a bare url, and the Claude-style httpUrl field all fold into it) or type: "sse". env, headers, oauth and timeout pass through. A server Cortex Code cannot start or reach — one with no transport, a remote transport without a URL, a ws/wss URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only the mcpServers key is rewritten, so other keys in an existing file are preserved, and because cortex mcp add edits the same file it is never removed by --delete. See the MCP configuration docs.

Codewhale MCP note: Codewhale reads MCP servers from the servers map (the mcpServers alias is also accepted) of ~/.codewhale/mcp.json (global) and .codewhale/mcp.json (project). The project file is honored only once the workspace is trusted, and its servers override same-named user servers. Stdio servers carry command and args; remote servers carry url and are reached over Streamable HTTP, with transport: "sse" written only for servers declared as SSE. WebSocket servers are skipped with a warning. enabledTools / disabledTools become enabled_tools / disabled_tools. The canonical timeout and networkTimeout (milliseconds) become execute_timeout (the budget for each tool call) and connect_timeout (spawn, initialize and the first tools/list), which Codewhale reads as whole seconds: the value is rounded up to the next second, a negative or zero value is dropped with a warning (Codewhale has no "no timeout" value and fails at once on a zero budget), and an explicit execute_timeout / connect_timeout on the same server wins. Import multiplies them back into milliseconds; read_timeout has no canonical counterpart and passes through unchanged. Other top-level keys (such as timeouts) and whichever server-map key the file already uses are preserved. Because Codewhale's own /mcp commands also edit the file, --delete never removes it.

Continue MCP note: Continue reads every *.json file under .continue/mcpServers/ (project) / ~/.continue/mcpServers/ (global, via --global); Rulesync writes the documented mcp.json there as plain JSON with an mcpServers map (an existing file is read as JSONC), and other files in the directory are untouched. A stdio server carries type: "stdio", command, args and the optional env (envFile is dropped with a warning because Continue ignores it, and cwd and timeout are dropped with a warning because Continue's JSON schema has no place for them); a remote server carries url plus type: "sse" or type: "http" (the canonical streamable-http alias and a bare url both fold into http), with headers passed through (oauth and timeout are dropped with a warning for the same reason). A server Continue cannot start or reach — one with no transport, a remote transport without a URL, a ws/wss URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only the mcpServers key is rewritten, so other top-level keys in an existing mcp.json are preserved. The project file is removed by --delete; the global one, which ~/.continue/ shares with hand-managed servers, is not. See the MCP docs.

CodeBuddy Code MCP note: CodeBuddy Code reads MCP servers from the mcpServers map of .mcp.json at the project root (project scope — the same file Claude Code reads) and of ~/.codebuddy/.mcp.json (global, via --global). Those are the recommended file names; the deprecated mcp.json spellings and the legacy ~/.codebuddy.json are only read by CodeBuddy when the recommended file is absent, and the per-project local scope kept in ~/.codebuddy.json is left to the tool. CodeBuddy's server keys (type — stdio, sse or http —, command, args, env, url, headers, description) are canonical, so both scopes are written in Claude Code's pass-through shape (only rulesync-only fields are stripped) and --targets claudecode,codebuddy yields the same project .mcp.json bytes whichever target generates last (in project mode both apply the claudecode, codebuddy, commandcode and qoder blocks, see the alias groups above). Only the mcpServers key is rewritten, so other top-level keys such as disabledMcpServers are preserved (an existing file is read as JSONC); the canonical disabled flag is passed through as written rather than translated into disabledMcpServers. The project file is removed by --delete; the global one is not. See the MCP docs.

Command Code MCP note: Command Code reads MCP servers from the mcpServers map of .mcp.json at the project root (project scope — the same file Claude Code reads, meant to be committed) and of ~/.commandcode/mcp.json (global, via --global); the per-machine local scope under ~/.commandcode/projects/ is left to the tool. Because the project file is the one the claudecode target writes, project mode writes it in exactly Claude Code's pass-through shape — the canonical servers as they are, only rulesync-only fields stripped — so --targets claudecode,commandcode yields the same bytes whichever target generates last (and both apply both the claudecode and commandcode blocks, see the alias groups above). Command Code reads that shape natively: type is an alias of transport, a bare url means http, and keys it does not know (disabled, timeout) are ignored as they are by Claude Code. Only the global file, which is Command Code's own, is written in its documented spelling: every server carries the transport key — a stdio server transport: "stdio", command, args and the optional env; a remote server url plus transport: "http" (the canonical streamable-http alias and a bare url both fold into http) or transport: "sse", with headers, env and oauth passed through. There a server marked disabled: true is written with Command Code's native enabled: false and read back as disabled: true, other keys (timeout, rulesync-only fields) are left out, and a server Command Code drops at load time — one with no transport, a remote transport without a URL, a ws/wss URL, or a stdio entry without a command — is skipped with a warning rather than written in a form the tool would silently ignore. At both scopes only the mcpServers key is rewritten, so other top-level keys in an existing file are preserved (an existing file is read as JSONC). The project file is removed by --delete; the global one, which ~/.commandcode/ shares with hand-managed servers, is not. See the MCP docs.

Qoder MCP note: Qoder reads MCP servers from the mcpServers map of .mcp.json at the project root (project scope, the file its docs describe as usually committed — the same one the claudecode and commandcode targets write) and of ~/.qoder/settings.json (global, via --global); the per-machine .qoder/settings.local.json is left to the user. Qoder uses the same server keys as rulesync (command, args, env, type, url, headers), so servers are written as they are, with only rulesync-only fields stripped; in project mode the output is byte-identical to the claudecode target's, and all four targets (claudecode, claudecode-legacy, commandcode and qoder) apply the claudecode, commandcode and qoder blocks (see the alias groups above). Other top-level keys are kept: in ~/.qoder/settings.json only mcpServers is rewritten, a file that fails to parse is an error rather than being replaced, and that file is never removed by --delete, while the project .mcp.json is. See the MCP docs.

Crush MCP note: Crush reads MCP servers from the mcp key of its shared JSON config: <project>/crush.json (project) / ~/.config/crush/crush.json (global, via --global). Crush's schema requires a type on every server, so one is always written: a stdio server carries type: "stdio", command, args and env; a remote server carries url plus type: "http" (the canonical http, streamable-http, a bare url, and the Claude-style httpUrl field all fold into it) or type: "sse", with headers passed through. The canonical per-server disabledTools ⇄ Crush's disabled_tools and enabledTools ⇄ enabled_tools (both match the server's own tool names, without the mcp_<server>_ prefix Crush uses in its permissions list); disabled and timeout (which Crush reads as an integer number of seconds, so it is forwarded as authored and a fractional value is rounded up) map onto the same-named fields, and Crush's OAuth fields and sessionless marker are authored under the Crush-only keys crushOauth, crushOauthClientId, crushOauthClientSecret, crushOauthCallbackPort and crushSessionless (written as oauth, oauth_client_id, oauth_client_secret, oauth_callback_port and sessionless; the raw oauth_client_id, oauth_client_secret, oauth_callback_port and sessionless spellings are honoured as a fallback for an entry copied out of a crush.json, but only crushOauth turns the flow on, because the canonical oauth key is Claude Code's { clientId } object and a raw oauth: true would be forwarded to other tools as well). Like Codex's envVars, these keys are stripped from what every other tool sees, so the client secret never lands in another tool's config. A server Crush cannot start or reach — one with no transport, a remote transport without a URL, a ws/wss URL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form; an existing oauth_token, which Crush persists at runtime, is never imported. Note that headers and oauth_client_secret (as crushOauthClientSecret) are copied verbatim into .rulesync/mcp.json on import, so move any literal secret into an environment-variable reference ($VAR) before committing that file. At project scope Crush reads both .crush.json and crush.json and merges them (objects recursively, lists concatenated, .crush.json on top), so neither file hides the other. When a .crush.json already exists it is the file written (otherwise crush.json); if the crush.json next to it still carries the keys rulesync owns — for example from a generate run before .crush.json was added — those entries stay in effect and are named in a warning, since they can only be removed by hand. Import reads the merged pair, the way Crush does; at global scope only ~/.config/crush/crush.json is read. Only the mcp key is rewritten, so the providers, options, permissions and hooks keys in the same file survive a regenerate, a file that fails to parse is an error rather than a partial read, and the file is never removed by --delete. The JSON config is documented as deprecated in favour of the Bash-based crushrc, but it stays supported and is the schema Crush publishes; a crushrc next to it compiles mcp add into the same mcp.<name> entries and wins key by key. See the config docs.

Codebuff (Freebuff) MCP note: Codebuff — renamed to Freebuff; the codebuff and freebuff CLIs share one configuration — reads MCP servers from the mcpServers map of .agents/mcp.json (project) / ~/.agents/mcp.json (global, via --global). Codebuff validates every server against a strict schema and ignores the whole file when one server fails, so only the keys it accepts are written: a stdio server as type: "stdio", command (an array command is split into command and leading args), args and env; a remote server as type (http, or sse; streamable-http becomes http), url (httpUrl is folded into it), headers and params. Other keys (cwd, timeout, tool filters, ...) are dropped with a warning, and a disabled: true or WebSocket (ws) server is skipped with a warning. Codebuff treats every env value that starts with $ as a variable name and ignores the whole file when that variable is unset, so a whole-value canonical ${NAME} is written as $NAME (and converted back on import), a server with any other env value starting with $ (such as ${HOST}:8080 or ${VAR:-default}) is skipped with a warning, and a reference later in a value, or in headers, is left as literal text. A params map whose values are not all strings is dropped with a warning. The global ~/.agents/mcp.json is never deleted by --delete, since ~/.agents/ is shared with other tools. Codebuff asks before loading a repository's .agents/ directory for the first time (CODEBUFF_TRUST_AGENT_DIRS=1 or --trust-agents skips the prompt, e.g. in CI). Codebuff also reads AGENTS.md / CLAUDE.md and .agents/skills/, so use the agentsmd and agentsskills targets for rules and skills; its TypeScript agent definitions under .agents/*.ts are not generated. See the MCP docs.

.rulesync/.aiignore or .rulesyncignore (deprecated) ​

Deprecation notice: The ignore feature is deprecated in favor of the more expressive permissions feature. Existing ignore configurations, generation, import, conversion, and explicit rulesync add ignore scaffolding remain supported throughout Rulesync 14.x. Removal, if any, will be decided separately and will not occur before a future major release. rulesync init no longer enables or scaffolds ignore for new projects.

Rulesync continues to support a single legacy ignore list in either location:

  • .rulesync/.aiignore (preferred legacy location)
  • .rulesyncignore (older project-root location)

Rules and behavior:

  • You may use either location.
  • When both exist, Rulesync prefers .rulesync/.aiignore over .rulesyncignore when reading.
  • Explicitly running rulesync add ignore creates .rulesync/.aiignore when neither location exists.

Example:

ignore
tmp/
credentials/

Migrating to permissions ​

Move each ignore pattern into the read category of .rulesync/permissions.jsonc with the deny action:

jsonc
{
  "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json",
  "permission": {
    "read": {
      "tmp/**": "deny",
      "credentials/**": "deny",
    },
  },
}

This is the closest replacement for preventing an agent from reading ignored paths — except for Zed, whose read-only tools are not permission-gated at all: there the replacement is private_files, which the ignore feature writes, so a read deny rule is dropped with a warning instead. If the old policy was also intended to prevent changes, repeat the patterns under edit and write. Target tools differ in the permission categories they can represent, so review the Supported Tools and Features table and the tool-specific permission notes below before removing the old ignore feature from a multi-tool project.

Where ignore patterns are written per tool ​

Most tools get a dedicated ignore file (for example .cursorignore, .geminiignore, .clineignore). Antigravity CLI is built on the same engine as Gemini CLI, so it reads the project-root .geminiignore file. Claude Code is the exception: it does not read a separate ignore file, so Rulesync writes the deny list into Claude Code's settings file as permissions.deny entries (Read(<pattern>)).

Reasonix has no ignore file either, so its deny list goes into the [permissions] table of the shared reasonix.toml (project) / ~/.reasonix/config.toml (global, via --global) as Read(<pattern>) entries — the same Claude-Code-style rule syntax the permissions feature writes there. deny is used rather than [sandbox].forbid_read because deny rules take glob specifiers and are documented as "a hard block in every mode", while forbid_read takes absolute paths with no documented glob support. The file is shared with the MCP and permissions features: only Read(...) deny entries are replaced, every other table and deny entry is preserved, and the file is never deleted. When the permissions feature also manages the Read category its explicit rules win, and the overwrite is warned about. As with the MCP and permissions features, the file is re-serialized on write, so hand-written comments, blank lines, and key ordering in reasonix.toml are not preserved.

Grok CLI has no ignore file either; its only path-deny surface is a custom sandbox profile. Rulesync writes the deny list into a rulesync-owned [profiles.rulesync] table (extends = "workspace" plus a deny array) in .grok/sandbox.toml (project) / ~/.grok/sandbox.toml (global, via --global). Grok kernel-enforces a profile's deny entries for reads and writes, but only while that profile is active — start Grok with grok --sandbox rulesync, set GROK_SANDBOX=rulesync, or set profile = "rulesync" under [sandbox] in your user ~/.grok/config.toml; generating the file alone changes nothing. Patterns are translated to the glob subset Grok accepts, because Grok refuses to start on a deny glob it cannot interpret: a leading / is dropped (Grok would read it as an absolute filesystem path, while relative entries are anchored at the workspace), and a pattern with no other / gains a leading **/ so it still matches at any depth. A glob denies only the paths it matches itself, not what lies under a matching directory, so each glob is paired with a <glob>/** entry for its contents, and a directory-only pattern (trailing /) keeps only that entry (*.pem → **/*.pem + **/*.pem/**, tmp/ → **/tmp/**); an exact path such as /build/ → build needs no pairing, since Grok denies an existing directory together with its contents. On Linux, though, Grok expands every glob to the files that exist at launch and refuses to start once that exceeds its limit (4096 matches), so an unanchored directory pattern over a large tree (node_modules/ → **/node_modules/**) can stop Grok from starting; anchor such a pattern (/node_modules/) to get an exact, scan-free entry instead (it covers the directory's contents on both platforms when the directory exists at launch; one created later is covered only by name on macOS). Negations (!pattern), brace alternation, backslash escapes, empty or ./.. segments, a ** that is not a whole path segment (**.log), and character classes Grok rejects (a literal ] as the first member, nested [ such as POSIX [[:digit:]], or an unclosed [) cannot be expressed, so those patterns are skipped with a warning. Only the rulesync profile is managed: your other profiles, and any extra keys you add to the rulesync profile itself (such as restrict_network or a different extends), are preserved; when there are no patterns an existing profile is kept with an empty deny list (so a session that selects it still starts) and none is created; and the file is never deleted. Import reads back only the rulesync profile's deny list, folding each <glob>/** entry into its pair (or into a pattern/) and skipping absolute entries. Three upstream caveats apply: when ~/.grok/sandbox.toml and .grok/sandbox.toml both define rulesync differently, Grok uses the user profile and warns at startup, so a project and a global generate with different lists do not combine; on Linux a glob is expanded to the files that exist when Grok starts, so files created later are not covered (macOS enforces globs at runtime), and Grok refuses to start when that scan exceeds its depth or size limits; and the file is re-serialized on write, so comments are not preserved. See the Grok sandbox docs.

Kiro reads .kiroignore in project scope and ~/.kiro/settings/kiroignore in user scope. The kiro, kiro-cli, and kiro-ide targets therefore support --global for the deprecated ignore feature, as do grokcli, reasonix and zed (whose config files exist in both scopes), as well as devin and kilo (below); the remaining ignore targets are project-only.

Kilo Code reads the project-root .kilocodeignore and, in global scope, ~/.kilocode/.kilocodeignore — note the legacy ~/.kilocode/ directory, not ~/.config/kilo/ where the rest of Kilo's global config lives. Kilo loads that global file on every config load and appends the project patterns after it, so the project file wins on conflict (ignore-migrator.ts). The kilo target therefore also supports --global for the ignore feature; like every other dedicated ignore file, the global one is replaced wholesale by the patterns from .rulesync/.aiignore.

Devin writes the project file as .devinignore (reading the pre-rebrand .codeiumignore and .windsurfignore as import fallbacks, in that order — the docs list the three names side by side without defining a precedence, so the order is rulesync's own choice), and in global scope writes ~/.codeium/.codeiumignore. Three things about that global path are worth knowing: it keeps the legacy brand spelling — the rename to .devinignore covered only the project file, so no .devinignore variant is written or read there, not even as a fallback; it is documented in the Devin Desktop docs tree rather than the Devin Local (CLI) one, which is why it sits outside ~/.config/devin where the other global Devin paths live; and it is positioned as an enterprise feature for enforcing ignore rules across many repositories, not as a general per-user setting. See the Devin ignore docs.

Zed has no ignore file: its deny list is the private_files array inside the shared settings file — .zed/settings.json in project scope and ~/.config/zed/settings.json in global scope (%APPDATA%\Zed\settings.json on Windows). private_files is a worktree setting, and Zed layers default → user → project, so the key is honored in the user settings file too. The array is owned wholesale by Rulesync: it is replaced with the patterns from .rulesync/.aiignore on every generation, so a pattern deleted there is retracted from the file Rulesync writes instead of surviving forever. Note that this narrows what Rulesync's own layer contributes, not the effective set: Zed's shipped default and any other settings layer still add their own patterns (see below). When no patterns remain at all, the key is removed rather than written as []. private_files is an ExtendingVec: each settings layer's value is appended to the one below it (merge_from calls extend_from_slice) instead of replacing it, and Zed ships a populated default (**/.env*, **/*.pem, …). Writing the key is therefore purely additive — an empty array would neither disable Zed's secret redaction nor mean anything at all — so omitting it is how Rulesync says it contributes nothing here. Every other key in the file — including the MCP context_servers and permissions agent blocks and unrelated editor settings — is preserved, and the file is never deleted.

Goose retired .gooseignore upstream ("removed some time ago in favour of other ignore things like gitignore etc" — goose#10343), so rulesync no longer generates it; the replacement guidance is .gitignore plus tool permissions. Stale .gooseignore files from earlier versions stay gitignored but are not cleaned up automatically.

Cline's .clineignore is still emitted, but its own docs now title it "deprecate soon" and state it is not a security or access-control boundary — upstream's replacement direction is a Cline plugin enforcing via a beforeTool hook. Treat the matrix ✅ as a deprecated surface.

Crush writes .crushignore at the project root only (no global equivalent), using the same gitignore-style patterns Crush already applies to its own built-in exclusion list in internal/fsext/fileutil.go.

IBM Bob reads a gitignore-style .bobignore at the project root only (there is no user-level equivalent, so --global is not supported), which keeps the listed files out of Bob's context and file operations. See the bobignore docs.

Tabnine CLI reads a gitignore-style .tabnineignore at the project root only (there is no user-level equivalent, so --global is not supported), which the CLI's file search, @ references and directory-listing tools respect alongside .gitignore (the context.fileFiltering.* settings control this). See the settings reference.

Continue reads a gitignore-style .continueignore at the workspace root and a user-level ~/.continue/.continueignore (global, via --global), which keep the listed files out of codebase indexing and the @ context providers alongside .gitignore. See the codebase reference.

Letta Code reads .letta/.lettaignore at the project root only (there is no user-level equivalent, so --global is not supported). It only keeps the listed files out of Letta Code's @ file search — it is not an access-control boundary, so use the permissions feature to deny reads. The patterns are written as-is; note that Letta Code skips ! negation lines, strips a trailing /, matches a pattern containing / against the relative path, and matches any other pattern case-insensitively against the file or directory name. See the settings docs.

Qoder reads a gitignore-style .qoderignore at the project root only (there is no user-level equivalent, so --global is not supported). Qoder documents it for the Qoder IDE, which keeps the listed files out of codebase indexing alongside .gitignore; it is not an access-control boundary, so use the permissions feature to deny reads. See the codebase indexing docs.

Codebuff (Freebuff) reads a gitignore-style .codebuffignore at the project root beside .gitignore (there is no user-level equivalent, so --global is not supported), which keeps the listed files out of the agent's file tree. The file keeps its Codebuff name after the rename to Freebuff, and both the codebuff and freebuff CLIs read it. The patterns are written as-is, ! negation included. See the source.

Hermes Agent uses a project-local rulesync-ignore plugin under .hermes/plugins/. It applies the canonical gitignore-style patterns through pre_tool_call to read_file, write_file, and patch before execution, and filters ignored paths from search_files results through transform_tool_result. This is defense in depth around Hermes file tools; terminal commands and paths already present in conversation context are outside the plugin's enforcement surface. Hermes deliberately requires explicit trust for project plugins, so run it from the trusted project root with that invocation opted in:

sh
HERMES_ENABLE_PROJECT_PLUGINS=1 hermes

Rulesync adds rulesync-ignore to plugins.enabled in $HERMES_HOME/config.yaml but deliberately leaves $HERMES_HOME/.env unchanged. Existing configuration is preserved, explicit plugins.disabled conflicts fail, and --delete retains the additive user-level activation.

For Cursor, Rulesync emits only .cursorignore — the file that blocks access entirely (semantic search, Tab, Agent, Inline Edit, and @-mentions). Cursor also supports a second file, .cursorindexingignore, which excludes files from indexing only while keeping them accessible to the AI on demand. These two files mean different things, and Rulesync's ignore feature models a single canonical ignore list per tool with no per-pattern distinction between "block access" and "exclude from indexing only". Emitting the same patterns to both files would be incorrect, so .cursorindexingignore is intentionally not generated (an intentional non-goal). Author it by hand if you need indexing-only excludes.

By default, Claude Code's deny list is written to the shared.claude/settings.json so that the policy can be committed and reviewed by the team. This is intentional (see issue #1094), but it means that running rulesync gitignore will not add .claude/settings.json to .gitignore — that file may also contain other shared Claude config you actively want to commit.

If you would rather keep the deny list out of version control, opt into the local mode using the per-feature options object form:

jsonc
// rulesync.jsonc
{
  "targets": ["claudecode"],
  "features": {
    "claudecode": {
      "ignore": { "fileMode": "local" },
    },
  },
}
fileModeOutput fileTracked by git by default
"shared" (default).claude/settings.jsonYes — meant to be committed and shared with the team.
"local".claude/settings.local.jsonNo — rulesync gitignore already excludes this file.

Either file gets a "$schema": "https://json.schemastore.org/claude-code-settings.json" key as its first entry when it does not have one yet; an existing $schema is kept as is.

.rulesync/permissions.jsonc ​

.rulesync/permissions.jsonc is the recommended source path and accepts comments and trailing commas. The legacy .rulesync/permissions.json path remains readable for existing projects. When both files exist, the JSONC file takes precedence; write flows update the existing source instead of creating a second variant.

For Hermes Agent imports, Rulesync treats a valid private permissions.rulesync block as provenance, then reconciles it with current native settings. command_allowlist, approvals.deny, and an enabled security.website_blocklist are authoritative for their mapped canonical rules (bash allow, bash deny, and webfetch deny respectively), so hand edits replace stale generated values; an allow in any other category, and a bash allow that generation withheld because a stricter * or bash rule covers it, are kept as the private block recorded them, since the allowlist never carried them. A config with no private block still imports those native rules. Unmodeled approvals, security, skills, and memory settings remain under the hermes override; unrelated root settings such as model are not imported.

rulesync init scaffolds a codexcli block with approval_policy: "on-request", approvals_reviewer: "auto_review", and base_permission_profile: ":danger-full-access". On generation, the profile value becomes Codex's top-level default_permissions.

Permissions define which tool actions are allowed, require confirmation, or are denied. The canonical format uses lowercase tool category names and glob patterns mapped to permission actions.

Permission actions:

  • allow -- Automatically permitted without user confirmation
  • ask -- Requires user confirmation before execution
  • deny -- Blocked from execution

Supported tool categories: bash, read, edit, write, webfetch, websearch, grep, glob, notebookedit, agent, the all-tools key * (a rule written under it is meant for every tool, shell commands included, so a * deny overrides a bash ask or allow on the same pattern, following deny > ask > allow; how faithfully each adapter carries it is described per tool below), and MCP-specific tool names (e.g., mcp__puppeteer__puppeteer_navigate)

Example:

json
{
  "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json",
  "permission": {
    "bash": {
      "git *": "allow",
      "npm run *": "allow",
      "rm -rf *": "deny",
      "*": "ask"
    },
    "edit": {
      "src/**": "allow"
    },
    "read": {
      ".env": "deny",
      "credentials/**": "deny"
    }
  }
}

Rejected keys. Two kinds of key are refused when the source file is read, rather than being carried into generated configs:

  • A blank key (empty, or only whitespace), whether it names a pattern or a category. A blank pattern is a prefix of every command and a substring of every path, so a tool that honors it grants or denies everything, while a tool that filters it — Roo Code keeps only entries passing cmd.trim().length > 0 — ignores it entirely. A blank category is worse still: every target reads categories by name, so nothing under it reaches any tool and the rules written there are silently dead. Rather than let each target decide, Rulesync rejects both on the source file.
  • A key named __proto__, constructor, or prototype. Rulesync strips these from every source document it parses, because assigning them would reach the prototype chain instead of the object, so such an entry could never reach a generated file. Rulesync names the offending path instead of dropping it in silence — in .rulesync/permissions.jsonc, .rulesync/mcp.jsonc, and .rulesync/hooks.jsonc alike.

Importing removes a blank key rather than reproducing it: keeping one would write a source file the very next generate refuses — and that refusal is fatal for the whole file, so a single blank key carried over would take every tool's permissions with it. This applies to blank patterns and blank categories, in the shared permission block and in tool-scoped {toolname}.permission blocks alike, since importing OpenCode or Kilo routes their tool-only categories into the tool-scoped block. The one exception is the patterns inside vibe.permission, whose categories hold sensitive_patterns objects rather than pattern maps, so the keys inside them are field names and are left alone; its category names are filtered like any other. Removals are reported in warnings naming the tool config they were read from and how many keys were dropped from each block — patterns and categories reported separately, because dropping a pattern can widen what the imported configuration allows (a blanket blank pattern may have been the only entry denying anything) while dropping a category only removes rules that were never going to be generated.

When removing a blank pattern leaves a category with no rules at all, the category itself is removed rather than left as an empty object. An empty category means "Rulesync manages this category and it has no rules", which makes the next generate delete the entries the tool's own config already had; removing the category leaves them alone. When that empties a tool-scoped block outright, the permission key is dropped too — and with it the whole {toolname} block if nothing else was authored under it — so no empty override is left behind for the next reader to puzzle over. In a tool-scoped block the removal also changes what the block means, from "override this category with nothing" to "inherit the shared block"; that is deliberate, since the alternative is the next generate deleting rules written by hand in the tool's own config, and every removal is warned about.

Importing merges into the existing file. rulesync import --features permissions merges what it imports into an existing .rulesync/permissions.jsonc (or legacy .rulesync/permissions.json) per category instead of replacing the file, so importing from a second tool keeps what the first import produced. Each tool maps only some categories — Roo Code, for instance, maps shell commands to bash and nothing else — so importing from Roo Code after Claude Code replaces bash and keeps read, edit, webfetch, and the rest:

  • A category the import carries replaces the existing category wholesale: the imported tool is authoritative for the categories it maps, so patterns are never mixed across tools and a re-import from the same tool reproduces the same category. There is no pattern-level conflict to resolve; the last import of a category wins.
  • A category the import does not carry is kept, and so is every other top-level key ($schema, other tools' {toolname} blocks).
  • An imported category with no rules never replaces an existing one, since an empty category would make the next generate delete the rules another tool's import produced.
  • The importing tool's own {toolname} block (for example claudecode when importing from Claude Code) is replaced wholesale, and removed when the import produces none, so a setting removed from the tool's own config disappears here too. Hand-written tool-scoped categories in that block are therefore not kept across a re-import from the same tool. Another tool's block that the import carries — Hermes Agent restores them from its provenance block — merges field by field, and its permission record merges per category.
  • Comments in the existing JSONC file are kept wherever the edit allows. An existing file that fails validation stops the import instead of being overwritten.

Because the file on disk is the merge base, a category deleted by hand comes back only when the next import carries it again; delete it from the tool's own config too to keep it gone. The reverse holds as well: emptying a category in the tool's own config (removing every WebFetch(...) rule from Claude Code, say) does not remove the shared category, because the import no longer carries it and other tools may have contributed it — delete it from .rulesync/permissions.jsonc too. Hermes Agent is the exception to "the tool is authoritative only for what it maps": its import restores the whole canonical document recorded in its provenance block, so every category it recorded is replaced with the recorded value. Only the permissions import merges this way; the other features' imports are unchanged.

Tool-scoped permission blocks ({toolname}.permission) ​

The shared permission block applies to every targeted tool. To scope rules to a single tool, add a tool-scoped {toolname} block with a permission record of the same shape — mirroring {toolname}.hooks in .rulesync/hooks.jsonc and {toolname}.mcpServers in .rulesync/mcp.jsonc:

jsonc
{
  "permission": {
    "bash": { "git *": "allow", "*": "ask" },
  },
  "claudecode": {
    "permission": {
      // Replaces the shared `bash` category for Claude Code only.
      "bash": { "git *": "allow", "git push *": "deny", "*": "ask" },
    },
  },
}
  • Categories are merged per category: a tool-scoped category replaces the shared category wholesale for that tool; shared categories it does not name still apply.
  • Any permissions-capable --targets name is accepted as a block key. kiro-cli/kiro-ide alias to the kiro key and hermesagent to hermes (matching the shared output file each writes).
  • OpenCode, Kilo, and Vibe keep their existing tool-native permission override semantics (bare action strings / tool-only categories / sensitive_patterns — see the tool-specific callouts below); their blocks are consumed by their translators instead of the central merge.

JSON Schema Support ​

Rulesync provides a JSON Schema for editor validation and autocompletion. Add the $schema property to your .rulesync/permissions.jsonc:

json
{
  "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json",
  "permission": {}
}

For Claude Code, this generates permissions.allow, permissions.ask, and permissions.deny arrays in .claude/settings.json (project mode) or ~/.claude/settings.json (global mode) using PascalCase tool names (e.g., Bash(git *), Edit(src/**), Read(.env)). The generated settings file also gets a "$schema": "https://json.schemastore.org/claude-code-settings.json" key as its first entry when it does not have one yet (an existing $schema is kept as is), so editors validate and autocomplete it.

Claude Code's file permission checks match only Edit(path) and Read(path) rules: a Write(path), NotebookEdit(path) or Glob(path) rule "is accepted but never matched by those checks, so Claude Code warns at startup for each allow, deny, or ask rule in one of these unmatched forms" (permissions docs, v2.1.210+). Rulesync therefore writes a canonical write or notebookedit rule that carries a pattern as Edit(pattern), and a glob rule as Read(pattern). A rule whose pattern is * is a tool-name rule with no path — it matches the tool everywhere and produces no warning — so it is still written as the bare Write / NotebookEdit / Glob. Entries an earlier Rulesync wrote in the warned form are replaced on the next generate, and so is a rewritten entry whose action changed, so flipping a rule from deny to allow never leaves the old deny behind to win. Rewriting a rule does not make Rulesync claim the Edit or Read namespace as a whole: a Read(...) deny the ignore feature wrote, or an Edit(...) rule you added to settings.json by hand, is left alone unless the canonical config manages that category itself. Import stays tolerant of both forms, so an existing settings.json still round-trips; a rewritten rule comes back under edit or read rather than the category it was authored in, since that is the rule Claude Code actually applies. Note that this widens a glob allow rule: Read(pattern) permits reading the files' contents, not just listing their names — the docs prescribe the substitution, but author glob allow rules with that in mind. When two categories resolve to the same entry with different actions (edit allowing what write denies, say) both are written and Rulesync warns — Claude Code applies deny first, then ask, then allow. A rule under the all-tools * category has no Claude Code counterpart: a rule there names one tool, and no name stands for every tool, so {"*": {"rm *": "deny"}} is written as *(rm *) — an entry that carries the rule back on import but matches no tool while Claude Code runs. Rulesync warns about every such rule, since an inert deny beside a Bash allow reads as a restriction that is not there; write the rule under the categories it is meant for (bash, read, ...) to have Claude Code enforce it.

Claude Code-only override (claudecode key): Claude Code's permissions object also carries non-list fields with no canonical permission category — notably defaultMode (the session-start permission mode: default | acceptEdits | plan | auto | bypassPermissions) and additionalDirectories (extra working directories). Claude Code ignores a defaultMode of auto or bypassPermissions in .claude/settings.json and .claude/settings.local.json (permission modes), so at project scope Rulesync skips those two values with a warning and writes them only under --global. Add a tool-scoped claudecode override key alongside the shared block to author them: the fields under claudecode.permissions are merged into the settings permissions object and emitted only for Claude Code, while the shared permission block continues to drive the managed allow/ask/deny arrays. The block is a verbatim passthrough (so other/future permissions fields such as the org locks disableBypassPermissionsMode/disableAutoMode can be set too), but any allow/ask/deny placed inside it is ignored — rulesync owns those arrays. On import, the non-list permissions fields round-trip back into the claudecode override. Note that these fields are merged additively into the existing settings.json (so hand-added settings survive): removing a field from the claudecode override does not delete a value already written to settings.json — clear it there by hand.

json
{
  "permission": { "bash": { "git *": "allow" } },
  "claudecode": {
    "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] },
    "sandbox": { "network": { "allowedDomains": ["example.com"], "strictAllowlist": true } },
    "editorMode": "vim",
    "env": { "MY_VAR": "1" }
  }
}

The same override key also carries sandbox, the sibling top-level settings subtree governing the sandbox commands run in (sandbox.network.*, sandbox.filesystem.*, sandbox.credentials, sandbox.allowAppleEvents, ...). It has no canonical permission category either — it constrains how a permitted command runs rather than which commands are permitted — so it is a verbatim passthrough on the same terms, merged into the top level of settings.json and round-tripped back on import — except for the three paths naming an executable, covered by the Trust caveat below, which are dropped in both directions. The merge is recursive, unlike the flat permissions fields above: sandbox subtrees carry restriction lists (network.deniedDomains, filesystem.denyRead), so setting one flag under network must not drop the denials beside it. A sibling key at any depth survives; a list you author replaces the existing list rather than being appended to. Scope caveat: Claude Code honors a subset of sandbox.* only from user settings, managed settings and the --settings flag — filesystem.disabled, network.strictAllowlist, network.tlsTerminate, credentials.allowPlaintextInject, credentials.awsPairs, credentials.sigv4 and allowAppleEvents — and ignores them in a repository's .claude/settings.json / .claude/settings.local.json. Rulesync therefore skips those keys when generating project scope (warning once per key) and emits them only under --global, so it never writes a project-scope sandbox policy that does nothing. The same restriction applies per entry inside credentials.files and credentials.envVars: an entry with "mode": "mask" is ignored in a repository's settings file, so Rulesync drops just those entries at project scope (warning once per list) while keeping the deny entries in the same list, which every scope does honor. Values already in the file are left untouched — both ones you hand-wrote and ones an earlier Rulesync version generated — because the two cannot be told apart and clobbering your file would be worse; so an inert key committed before this behavior existed stays there until you remove it by hand. Import stays scope-agnostic. See the sandboxing docs.

Any other key is a top-level settings.json key. Everything in the claudecode block other than permission, permissions, sandbox and hooks is written straight to the top level of .claude/settings.json and round-trips back on import, so a setting Claude Code adds needs no Rulesync release to become authorable — editorMode, emojiCompletionEnabled, workflowSizeGuideline, keybindingFlavor, env, model, alwaysThinkingEnabled and anything after them all work the same way. The merge is recursive, like sandbox, so setting one key under env keeps the variables already in the file. hooks is excluded because the hooks feature owns it, permission/permissions/sandbox because they have their own handling above, and $schema because it is an editor pointer rather than a Claude Code setting. Scope caveat: the settings reference documents a scope per key, and Rulesync skips a key the file it is writing cannot honor (warning once per key): keys scoped User or managed / User, local, or managed (spellcheck, autoMode, vimInsertModeRemaps, pluginConfigs, sshConfigs, syncClaudeAiSkills, ...) are skipped at project scope and emitted only under --global, while keys scoped Managed (allowManagedHooksOnly, requiredMinimumVersion, strictKnownMarketplaces, ...) or Global config (diffTool, autoConnectIde, teammateDefaultModel, ...) are skipped in both scopes, because neither file Rulesync writes is the file that reads them — set those by hand in the managed settings file or ~/.claude.json. The alternate spellings additionalMarketplaces and allowedMarketplaces are resolved to their canonical keys (extraKnownMarketplaces and strictKnownMarketplaces) before that check, so an alias is treated exactly as the key it spells. The scope caveat also reaches inside env, per variable, following the settings reference's list of variables Claude Code ignores in env: the variables a checked-out repository should not control — CLAUDE_CONFIG_DIR, CLAUDE_CODE_TMPDIR, HOME, TMPDIR, TMP, TEMP and XDG_*; the Windows SystemRoot, ComSpec, ProgramData, LOCALAPPDATA, PATHEXT, PSModulePath and ProgramFiles* / ProgramW6432 (matched without regard to case); OTEL_LOG_RAW_API_BODIES, ENABLE_BETA_TRACING_DETAILED and BETA_TRACING_ENDPOINT; the telemetry switches CLAUDE_CODE_ENABLE_TELEMETRY, CLAUDE_CODE_ENHANCED_TELEMETRY_BETA and ENABLE_ENHANCED_TELEMETRY_BETA, the exporter selectors OTEL_{LOGS,METRICS,TRACES}_EXPORTER, the content variables OTEL_LOG_USER_PROMPTS, OTEL_LOG_ASSISTANT_RESPONSES, OTEL_LOG_TOOL_CONTENT and OTEL_LOG_TOOL_DETAILS, every OTEL_EXPORTER_OTLP_* variable ending in _ENDPOINT, _HEADERS, _PROTOCOL, _CERTIFICATE, _CLIENT_KEY or _INSECURE, and OTEL_EXPORTER_PROMETHEUS_HOST / _PORT (ignored there since Claude Code v2.1.282); and CLAUDE_CODE_PROCESS_WRAPPER, CLAUDE_CODE_SYNC_SKILLS, CLAUDE_CODE_SYNC_PLUGINS, CLAUDE_CODE_PLUGIN_CACHE_DIR and CLAUDE_CODE_PLUGIN_SEED_DIR — are skipped at project scope and emitted only under --global. The values that turn telemetry off are the exception and are still written at project scope: none for the three exporter selectors, and 0, false, no or off for OTEL_LOG_USER_PROMPTS, OTEL_LOG_TOOL_CONTENT and OTEL_LOG_TOOL_DETAILS (compared without regard to case). Claude Code versions before v2.1.251 still honored the directory and session-content variables other than HOME and XDG_CONFIG_HOME from project settings (and versions before v2.1.282 the telemetry group), so on an older Claude Code a project value skipped here would have applied — set it in user settings instead. CLAUDE_CODE_REMOTE, CLAUDE_CODE_ACCOUNT_UUID, CLAUDE_CODE_MESSAGING_SOCKET, CLAUDE_CODE_MESSAGING_TOKEN, CLAUDE_CODE_PROJECT_DIR_NAME, CLAUDE_CODE_RESTRICTED and the CLAUDE_CODE_DISABLE_*_RM_* switches are ignored in every settings file (the messaging socket since Claude Code v2.1.224, the messaging token since v2.1.228 and CLAUDE_CODE_PROJECT_DIR_NAME since v2.1.234), so they are skipped in both scopes — set them in the environment Claude Code starts from. Each warning names the skipped variables but never their values. The reference introduces its directory and Windows groups with "such as", so a variable it does not name is still written. Trust caveat: a permissions file is shareable — rulesync fetch copies .rulesync/permissions.jsonc out of another repository — so a file whose name promises restrictions must not be able to hand Claude Code a command to run. Rulesync therefore refuses the keys whose value is an executed command, in both scopes and in both directions: apiKeyHelper, awsAuthRefresh, awsCredentialExport, fileSuggestion, gcpAuthRefresh, otelHeadersHelper, policyHelper, processWrapper, statusLine and subagentStatusLine are never written (warning once per key) and are silently dropped on import — author commands in .rulesync/hooks.jsonc, where a reviewer expects them, or set these by hand in settings.json. The same line applies inside sandbox, which has its own merge branch: sandbox.ripgrep, sandbox.bwrapPath and sandbox.socatPath each name an executable Claude Code runs, so they are refused in both scopes and dropped on import too. The scope caveat reaches inside sandbox as well: sandbox.filesystem.allowManagedReadPathsOnly and sandbox.network.allowManagedDomainsOnly are scoped Managed, so they are skipped in both scopes for the same reason the Managed top-level keys are — set them in the managed settings file by hand. Keys that widen what Claude Code trusts rather than running something themselves are still written, but every one of them is named in a single summary warning per file — one line listing each setting and what it affects, rather than a run of near-identical lines: env, disableAllHooks, disableSkillShellExecution set to anything but true (which re-opens inline shell execution a user setting had turned off), enableAllProjectMcpServers, enabledMcpjsonServers and allowedMcpServers, autoMode, skipAutoPermissionPrompt and skipDangerousModePermissionPrompt, enabledPlugins and extraKnownMarketplaces, agent and outputStyle (which replace the prompt and tools every session starts with), httpHookAllowedEnvVars and allowedHttpHookUrls (which relax what an existing HTTP hook may send and where), claudeMdExcludes (which drops the CLAUDE.md files its patterns match), crossSessionInbound set to anything but hold or refuse (which lets messages from your other sessions reach Claude), modelOverrides (which decides the inference profile a call is routed to), skipWebFetchPreflight set to anything but false (which turns off the WebFetch domain safety check), remoteControlAtStartup set to anything but false, prUrlTemplate (which rewrites the PR links Claude Code renders), companyAnnouncements, permissions.additionalDirectories, a permissions.defaultMode of bypassPermissions, acceptEdits or auto, and the sandbox paths that loosen the sandbox rather than naming something to run — enabled, autoAllowBashIfSandboxed, allowUnsandboxedCommands, excludedCommands, allowAppleEvents, enableWeakerNestedSandbox, enableWeakerNetworkIsolation, ignoreViolations, filesystem.allowRead, filesystem.allowWrite, network.allowedDomains, network.allowMachLookup, network.allowUnixSockets, network.allowAllUnixSockets, network.allowLocalBinding, network.httpProxyPort and network.socksProxyPort. The allow* lists are in that set because Claude Code merges a list across settings scopes rather than replacing it, so a project file can only ever add to them; their deny* counterparts, which restrict, are not. These warnings fire only on the value that actually loosens the policy, so authoring the restrictive one (allowUnsandboxedCommands: false, an empty excludedCommands) stays quiet. Each condition names the value that stays quiet rather than the one that warns, so a value of the wrong type — a skipWebFetchPreflight of 1 rather than true — is reported rather than passed over. allowUnsandboxedCommands and autoAllowBashIfSandboxed both default to true, and an explicit true is reported even so: a project .claude/settings.json outranks the user file, so writing it there re-opens what a user's false closed. remoteControlAtStartup is the one key whose scope depends on its value — Claude Code honors a false from a project file but ignores a true, so a true is skipped at project scope and emitted only under --global. They are also emitted after the scope filter, so a path the scope drops is reported as skipped rather than as written. Every one of these warnings exists for the same reason: a value that arrived with a fetched override should be visible rather than silent. (env is warned about rather than refused because it has too many ordinary uses to drop, even though a value such as NODE_OPTIONS or PATH does run code.) As with sandbox, the filter only applies to what Rulesync writes: a value already in the file is left untouched, and removing a key from the override block does not delete the value an earlier generate wrote — clear it in settings.json by hand.

For OpenCode, this generates the permission object in opencode.json / opencode.jsonc (project mode) or .config/opencode/opencode.json / .config/opencode/opencode.jsonc (global mode), preserving other existing OpenCode config fields. OpenCode's webfetch, websearch, todowrite, question, and doom_loop keys accept only a single action string, so Rulesync emits their canonical { "*": "allow" } form as "allow". If one of these categories contains pattern-specific rules, Rulesync collapses them to the most restrictive action (deny > ask > allow) and logs a warning because OpenCode cannot represent those patterns; a map without * includes an implicit ask fallback so a narrow allowlist never becomes blanket allow, while an empty map becomes deny instead of falling through to OpenCode's default allow behavior.

Canonical categories are written under the key OpenCode actually consults: agent becomes task (and task imports back as agent), and write is folded into edit, because OpenCode's write tool asks for the edit permission and has no write key. When both write and edit are set, their patterns are merged into one edit map, emitted where the canonical edit category sits (OpenCode applies the last matching rule across keys as well, so the merged map keeps edit's place relative to keys such as *); identical maps are kept as-is, otherwise each pattern keeps the stricter action and the patterns are reordered allow, then ask, then deny (OpenCode's last matching pattern wins, so this order keeps the merged map at least as strict as either input) and a warning is logged. The merge fails closed: an allow carve-out under a broader ask/deny pattern (e.g. "*": "deny", "src/**": "allow") stops taking effect once the two maps differ, so give both categories the same map, or set opencode.permission.edit (which replaces the merged value) when you need an exact pattern order. Keys under opencode.permission are OpenCode's native keys and are emitted as written. An imported edit stays edit. OpenCode has no notebook tool, so notebookedit rules are skipped with a warning.

OpenCode-only override (opencode key): OpenCode exposes permission categories that other tools do not understand (e.g. external_directory). Placing these in the shared permission block would push meaningless entries into Claude Code, Codex, etc. To scope them to OpenCode, add a tool-scoped opencode override key alongside the shared block — mirroring the tool-scoped override keys used by hooks (opencode.hooks) and rules frontmatter. Categories under opencode.permission are merged on top of the shared block per category (the override wins) and are emitted only into opencode.json / opencode.jsonc; every other tool ignores them. Values may use a bare action string ("deny") or, for OpenCode keys that support fine-grained matching, a pattern map ({ "*": "ask" }).

jsonc
{
  "permission": {
    "bash": { "git *": "allow", "*": "ask" },
  },
  // Emitted only into opencode.json's `permission`; never leaks to other tools.
  "opencode": {
    "permission": {
      "external_directory": "deny",
    },
  },
}

On import, any OpenCode category that is not a shared canonical rulesync category (bash, read, edit, write, webfetch, websearch, grep, glob, notebookedit, agent, the all-tools key *, or an mcp__* tool name) is routed into the opencode override rather than the shared block, so a subsequent rulesync generate does not leak it into other tools.

You may also override a shared category for OpenCode specifically (e.g. put webfetch under opencode.permission to give OpenCode a different value than the shared block sends to other tools). On generate this works as expected, but note the override is not round-trip stable for shared categories: re-importing the generated opencode.json classifies a shared category back into the shared block, so prefer expressing OpenCode-only categories here and keeping cross-tool categories in the shared block.

For Hermes Agent, permissions are written into the shared ~/.hermes/config.yaml (global only). Canonical rules map onto the structures Hermes's runtime actually enforces:

  • bash allow patterns → command_allowlist. The allowlist is a list of shell-command patterns, so allow rules in other categories are not written there: a read or edit allow names a path, and an all-tools * allow need not name a command either (it is skipped with a warning; write it under bash to auto-approve it as a command).
  • bash deny patterns → approvals.deny — Hermes's hard denylist, evaluated before --yolo / approvals.mode: off.
  • webfetch deny patterns → security.website_blocklist.domains.
  • The all-tools * category's deny and ask rules are read too — a rule written there covers shell commands as well — but they are not written into approvals.deny, since a pattern under * need not be a command. Like every ask rule (Hermes has no ask tier), they restrict by withholding the bash allow rules they cover: the canonical order is deny > ask > allow whatever the width of the patterns, so {"*": {"npm *": "deny"}, "bash": {"npm publish": "allow"}} writes no npm publish into the allowlist, and {"*": {"*": "ask"}} withholds every allow. Each withheld rule is reported with a warning.
  • Every ask rule, and deny rules in categories other than bash/*/webfetch, have no native per-pattern Hermes primitive; they are skipped with a warning and survive only for round-trip (Rulesync also stores the full canonical config under a private permissions.rulesync key so .rulesync/permissions.jsonc reconstructs losslessly).

Hermes-only override (hermes key): Hermes exposes approval/security controls with no canonical permission category — e.g. approvals (mode, cron_mode, mcp_reload_confirm, ...), security (allow_private_urls, ...), skills.write_approval, memory.write_approval. Add a tool-scoped hermes override key alongside the shared block to author them; its contents are deep-merged into config.yaml (so an approvals.mode here coexists with the approvals.deny derived from canonical deny rules) and are emitted only for Hermes. The block is a verbatim passthrough, so any current or future Hermes config key can be set without Rulesync modeling each one. Note that the deep merge replaces arrays wholesale, so setting hermes.approvals.deny or hermes.security.website_blocklist.domains overrides (does not append to) the list derived from the shared permission block — use it only when you intend to replace the canonical-derived deny list for Hermes. The top-level permissions key is reserved by Rulesync for the round-trip blob, so a permissions key inside the hermes override is ignored.

json
{
  "permission": { "bash": { "rm -rf *": "deny" } },
  "hermes": { "approvals": { "mode": "smart" }, "security": { "allow_private_urls": false } }
}

For Codex CLI, this generates a rulesync named profile in .codex/config.toml under [permissions.rulesync] and sets default_permissions = "rulesync" (project/global depending on mode). It also generates .codex/rules/rulesync.rules from permission.bash entries using prefix_rule(...). Current Rulesync-to-Codex mapping supports bash, read, edit/write, and webfetch categories:

  • bash: generates one prefix_rule(...) per command pattern in .codex/rules/rulesync.rules (allow → allow, ask → prompt, deny → forbidden). Codex matches each pattern word as a literal token and the rule as a command prefix, so glob characters are not wildcards there. A single trailing standalone * is dropped, because a prefix rule already covers whatever follows: sudo * becomes pattern = ["sudo"], which also matches a bare sudo (as the Letta Code and Tabnine <prefix> * translations do). More than one trailing wildcard word (docker * *) requires extra arguments a prefix rule cannot express, so it is skipped like the cases below. The legacy git:* spelling is read as git *. Any other wildcard — inside a word (npm install*), in the middle (git * --no-verify), ? or a [...] class — has no prefix-rule equivalent, and cutting the pattern before it would widen the rule (a deny on npm install* would block every npm command), so the pattern is skipped with a warning and Codex does not enforce it, even a deny. A pattern of only * (or the aliases * * and *:*) is skipped the same way, since a prefix rule cannot match every command; use codexcli.approval_policy for that. The rules file is generate-only: rulesync import reads .codex/config.toml and does not bring permission.bash back from it.
  • read: allow → read, ask/deny → deny in permissions.<profile>.filesystem
  • edit / write: allow → write, while ask/deny → read in permissions.<profile>.filesystem when the path is exact or ends in /**; Codex has no path-level write-approval state, so both non-allow write actions preserve read access without write access and emit a warning. An explicit read: deny/ask on the same pattern takes precedence and emits deny. A write-side-only rule (no read rule for the same pattern) also emits deny when the most specific read rule covering the path is a deny/ask — an exact path X or X/** covering X or anything below it when both are the same kind of path (both absolute, drive, or ~ paths, or both workspace-relative — so a relative C: never covers the drive path C:\secret\k; leading ./ segments are ignored, and for Windows drive paths a backslash counts as a separator and the drive letter is compared case-insensitively, so c:\secret covers C:\secret\k), **, ., ./**, or :workspace_roots covering any workspace-relative pattern, or /** and :root covering every path — because Codex gives the more specific path priority and a read or write entry would re-open reads the broader rule denies (e.g. read: { "/home/me/.ssh/**": "deny" } + write: { "/home/me/.ssh/id_rsa": "deny" } emits deny for both). This applies to a write-side allow too: read: { "~/.ssh/**": "deny" } + edit: { "~/.ssh/known_hosts": "allow" } emits "~/.ssh/known_hosts" = "deny" with a warning, since "writable but not readable" is not expressible in Codex (add read: allow for the same pattern to keep it writable). Only the most specific covering read rule counts, and equally specific rules resolve to the restrictive one: read: { "/x/**": "deny", "/x/y": "allow" } + edit: { "/x/y/z": "deny" } emits "/x/y/z" = "read", while a more specific read: allow that Codex never receives (a non-trailing glob or a Windows device path, skipped as described below) does not count. For absolute, drive, and ~/... paths, coverage resolves ./.. segments lexically, as Codex does, for comparison only (emitted keys keep their authored form), so read: { "/home/me/.ssh/**": "deny" } covers /home/me/public/../.ssh/id_rsa. As an extra safeguard, when the path or the read: allow pattern contains such a segment the narrower allow never wins (e.g. read: { "/home/me/**": "deny", "/home/me/public": "allow" } + edit: { "/home/me/public/../.ssh/id_rsa": "deny" } emits deny). Other broader read denials are not matched, so Rulesync warns when it maps a write-side-only deny to read: a non-trailing glob such as **/*.pem, special paths other than :root and :workspace_roots (e.g. :tmpdir), and ~/... versus the equivalent absolute path (~/.aws/** does not cover /home/me/.aws/credentials, because Codex expands ~ on the machine it runs on, which Rulesync cannot know); no warning is emitted for an explicit read: allow + write-side deny, which asks for exactly Codex's read level. For other globs, Codex supports only deny: Rulesync skips grant-derived read/write entries with a warning so the baseline applies, and emits deny for a write-side ask/deny restriction rather than generating an invalid profile. Windows device paths (\\?\..., \\.\...) are not supported for grants: Codex rejects them as workspace-root paths, and outside Windows their ? is a glob character. When Rulesync runs outside Windows, such a path is not absolute and would be nested under :workspace_roots, where Codex rejects it under the Windows path convention and treats it as a meaningless literal name elsewhere, so Rulesync drops every entry for it — including a deny — with a warning, and the path is not protected by the generated profile; express such a restriction with a plain drive path instead. On Windows, where the path is absolute and emitted at the top level, a grant-derived read/write entry is skipped with a warning and a write-side ask/deny restriction emits deny. Use a plain drive path such as C:\... instead.
  • A Codex "write" entry is imported as both read: allow and edit: allow, since Codex's write level includes read access; this keeps a writable path under a broader read deny (e.g. ":root" = "deny" + ":tmpdir" = "write") writable on the next generate. Import is one-way for the write-side restriction: a Codex "read" entry is imported as read: allow only. Both read: allow + write-side ask/deny and a write-side-only edit/write ask/deny (e.g. edit: { "/x/**": "deny" }, which generates "/x/**" = "read") therefore lose their write restriction on rulesync import, and a later generate hands the path back to the baseline's write access. Rulesync does not re-materialize "read" as an extra edit: deny, because that would push a restriction to every other tool that the Codex profile alone cannot prove was authored; re-add such rules to the canonical permissions after importing.
  • webfetch: allow/deny map to permissions.<profile>.network.domains (Codex does not support ask for domain rules); network.enabled = true is emitted only when at least one allow rule is present. Deny-only domain sets are emitted without enabled, which Codex treats as restricted (its default) while the deny entries still round-trip back into Rulesync rules. Codex rejects the global wildcard * in denied domains at config load time, so webfetch: { "*": "deny" } is skipped with a warning (unlisted domains are denied by Codex's allowlist-first policy anyway); webfetch: { "*": "allow" } is emitted as a regular "*" = "allow" domain entry, which Codex accepts for denylist-only setups (openai/codex#15549). On import, deny entries are always taken, while allow entries are imported only when enabled = true is explicit — Codex treats a missing enabled as restricted, so importing an allow entry from a disabled profile would activate a grant Codex never had. A Codex profile with network.enabled = true but no domains is imported as webfetch: { "*": "allow" }, which reflects Codex's default semantics where enabled = true grants sandbox-wide network access (under Codex's experimental network_proxy feature, enabled = true without an allowlist blocks requests instead, and the regenerated "*" = "allow" entry is the closest equivalent).

Relative filesystem globs such as src/** or **/*.tf are emitted under permissions.<profile>.filesystem.":workspace_roots" instead of the top-level filesystem table, because Codex expects top-level filesystem keys to be absolute paths, ~/..., or named roots. Leading ./ segments are stripped from these table keys before the read/edit/write rules are merged, because Codex rejects a :workspace_roots subpath whose first segment is . (only the exact . key, the workspace root itself, is special-cased): ./ and ./** become . (not the bare glob **, which Codex accepts only with deny), and ./src/** becomes src/**. Rules that end up on the same key are merged as if they had been written with it (e.g. read: { "src/**": "allow" } + edit: { "./src/**": "allow" } emits "src/**" = "write"), and within one category the more restrictive action wins (e.g. ./src and src). A workspace-relative pattern Codex would reject — one with a .. segment (e.g. src/../secret, ../out/**) or an empty leading segment (e.g. .//src, which is never turned into the absolute /src) — would make Codex refuse the whole config, so Rulesync drops it with a warning; like a Windows device path, the dropped rule, even a deny, does not protect the path. Absolute, drive, ~/..., and special-path keys are never rewritten. Codex supports read/write only for exact paths or trailing /** subtree rules; Rulesync skips other grant-derived globs with a warning so the baseline applies, while write-side restrictions fall back to deny. Rulesync also sets glob_scan_max_depth = 8 when generated workspace-root rules contain unbounded ** patterns. A direct ":workspace_roots" rule (e.g. read: { ":workspace_roots": "deny" }) is kept as the table's "." = <access> entry — the key Codex uses for the workspace root itself — when workspace-relative rules turn :workspace_roots into a table, so the workspace does not fall back to the baseline's access (a warning is still logged; if an explicit . or ./ rule also exists, the more restrictive access wins). On import, a "." table entry is imported as the portable . pattern rather than the Codex-only :workspace_roots, so a canonical read: { ".": "deny" } round-trips unchanged.

The :workspace_roots table also receives a default .git carve-out: ".git/**" = "write". Codex's :workspace baseline keeps .git read-only inside workspace roots, which denies basic git workflows (commit/stage operations write to .git/index, .git/objects, refs, and logs; everyday commands such as git remote add, git push -u, and local-scope git config write to .git/config). The write rule reopens the whole subtree, including .git/config — an earlier ".git/config" = "read" security guard (a writable .git/config lets a sandboxed process set keys like core.fsmonitor or core.hooksPath that execute code outside the sandbox) was dropped because it blocked those everyday commands while the protection it added was already partial (.git/hooks/, and .git/modules/** for submodules, remains writable so hook managers such as lefthook and simple-git-hooks keep working; a sandboxed process could still install a hook directly). Users who want stricter isolation can author a more specific rule (e.g. read: { ".git/config": "allow" } or read: { ".git/hooks/**": "allow" }) in the canonical permissions, which wins over the default (Codex resolves the more specific path with priority). Because .git/** is an unbounded ** pattern, the carve-out also means glob_scan_max_depth = 8 is effectively always emitted unless it is suppressed.

The carve-out is skipped in these cases: a user rule for the same pattern always wins per key; the codexcli.git_write_rules override set to false suppresses it entirely (only an explicit false does; the default is true); and it is not injected when codexcli.base_permission_profile is ":read-only" (it would grant .git write access inside a sandbox the user explicitly chose to keep read-only) or when the canonical rules contain a direct ":workspace_roots" pattern (a whole-tree access decision that the defaults must not override). It is also skipped, with a warning, when the most specific other entry of the generated :workspace_roots table that covers .git is "deny" or "read" — for example "**" = "deny" from read: { "**": "deny" }, "." = "deny" from read: { ".": "deny" }, or "." = "read" from edit: { ".": "deny" } — because Codex resolves the more specific .git/** entry with priority, so the carve-out would otherwise reopen .git under that restriction. A narrower rule only keeps the carve-out when it grants .git write access: read: { "**": "deny", ".git": "allow" } emits ".git" = "read" and still skips it, while adding edit: { ".git": "allow" } (".git" = "write") keeps it. :root and /** denies do not suppress it, because they are not table entries and the :workspace baseline re-grants the workspace above them; to keep .git writable anyway, author read and edit allows for .git/** (both are needed, since a write-side allow alone under a broader read deny generates "deny"), or set codexcli.git_write_rules: false to silence the warning when the restriction is intended. Coverage ignores non-trailing globs such as **/.git/**. Like :minimal, the default-valued carve-out is not imported into the Rulesync model on rulesync import — it is re-added on every generate — while customized .git values import normally. Because the skip decision reads only the emitted table, import applies the same test: a ".git/**" = "write" entry in a table that would suppress the default is imported as a user-authored rule, so the result is stable across generate → import → generate. One limitation: the git_write_rules flag itself cannot be recovered from config.toml, so it does not round-trip through rulesync import; if you opted out with false, re-add the flag to the canonical permissions config after importing (and if you want the same .git rules while opted out, author them as canonical read/write rules rather than hand-writing them in config.toml — though note that import cannot tell a user-authored ".git/**" = "write" from the default carve-out, so unless the rest of the table restricts .git as described above, that exact pattern/value pair is still skipped on import and must be re-authored in the canonical config afterwards). Migration note: configs generated before the ".git/config" = "read" default was removed still carry that entry, and rulesync import now treats it as a user-authored rule — it lands in the canonical config as read: { ".git/config": "allow" } and, because Codex gives the more specific path priority, keeps .git/config read-only on every regenerate. If you want the current writable default instead, delete that rule from the canonical permissions after importing. Likewise, configs generated before the carve-out became conditional carry ".git/**" = "write" even in tables that restrict .git (e.g. next to "**" = "deny"); rulesync import treats that entry as user-authored and adds read/edit allows for .git/** to the canonical config, which other tools then receive too. Delete those allows after importing unless you want .git writable.

The generated [permissions.rulesync] profile always extends one of Codex's built-in permission profiles via extends. The baseline is chosen with the codexcli.base_permission_profile override key (":read-only" | ":workspace" | ":danger-full-access") and defaults to ":workspace" when unspecified. Codex's built-in :workspace baseline grants read access to the whole filesystem and write access to the entire workspace root plus /tmp and $TMPDIR (with carve-outs protecting .git, .codex, and .agents), while :read-only keeps command execution read-only; the generated filesystem entries then grant or deny access on top of the chosen baseline. Codex's third built-in profile, :danger-full-access, is rejected by extends at Codex config load time — so selecting it works differently: Rulesync emits default_permissions = ":danger-full-access" directly and skips the managed [permissions.rulesync] profile entirely (with the sandbox removed there is nothing for filesystem/network rules to refine; canonical read/edit/write/webfetch rules are ignored for Codex CLI with a warning, and any stale managed profile from a previous generate is pruned while sibling hand-written profiles are preserved). On import, a profile's extends value round-trips back into codexcli.base_permission_profile when it names one of the two extendable built-ins, and a top-level default_permissions = ":danger-full-access" round-trips the same way; a custom parent profile is skipped and replaced by the managed baseline on regeneration (with a warning).

Rulesync emits ":minimal" = "read" in the generated filesystem table by default. This enables include_platform_defaults() (FileSystemSpecialPath::Minimal), which provides the platform/runtime read access needed for basic sandboxed command execution on macOS, Linux, and Windows. :minimal is the only special path treated as a fixed baseline: it is always present in the generated table and is never imported into Rulesync's own permission model, regardless of its value. A canonical rule for :minimal still overrides the emitted value on generate (e.g. a write: { ":minimal": "allow" } rule emits ":minimal" = "write" — see the FAQ for when that is needed), but because import always skips :minimal, such a customization does not round-trip: after rulesync import, re-author the rule or the next generate falls back to "read". The other special paths :root, :tmpdir, and :slash_tmp are user-managed access rules that are imported into the Rulesync model and re-emitted from it like any ordinary filesystem entry (:root = "deny" becomes a read/edit deny, :tmpdir = "write" becomes an edit allow, and so on). Because they round-trip through .rulesync/permissions.jsonc rather than relying on an existing .codex/config.toml, a restrictive value such as :root = "deny" survives a fresh-clone rulesync generate with no pre-existing Codex config.

network.mode, network.unix_sockets, and description have no equivalent in Rulesync's canonical permissions model and are not generated. If an existing .codex/config.toml already contains these fields on the rulesync profile, Rulesync preserves them on regeneration — as it does any other network key it does not model (e.g. dangerously_allow_all_unix_sockets or Codex's proxy keys), since network settings are user territory by design. network.enabled is only half-managed: Rulesync sets enabled = true itself when the canonical model contains an allow domain, but when a regeneration computes no enabled value, a user-authored enabled is preserved (with a warning) instead of being deleted — see the FAQ for the recommended user-managed entries. The preservation applies only when the existing profile carries no allow domain: an existing enabled next to allow domains is Rulesync's own managed output, so removing every webfetch allow rule from the canonical model removes enabled too (falling back to Codex's restricted default) instead of leaving an unscoped enabled = true behind. Note that filesystem, network.domains, and extends are always managed by Rulesync (filesystem/network.domains derived from edit/write/webfetch rules, extends from codexcli.base_permission_profile), so hand-authored values in those fields will be replaced on regeneration.

Codex CLI-only override (codexcli key): Codex CLI's permission surface is richer than the canonical allow/ask/deny model — its approval workflow, permission-profile baseline, and per-app tool gating have no canonical category. Add a tool-scoped codexcli override to author them: except for base_permission_profile, its fields are written verbatim as top-level .codex/config.toml keys (the override wins per key; existing sibling keys the user set directly are preserved, and table values are shallow-merged) while the shared permission block keeps driving the managed [permissions.rulesync] profile and default_permissions. Supported keys: base_permission_profile (:read-only | :workspace | :danger-full-access, default :workspace — not a top-level key; it becomes the managed profile's extends baseline, or with :danger-full-access the directly-selected default_permissions value, see above), approval_policy (on-request | never, or a { granular = { … } } table kept verbatim; defaults to on-request when neither the override nor the existing config sets it — on-failure is a deprecated alias Codex still reads as on-request and is written with a warning; untrusted was retired in Codex 0.149.0 and makes Codex refuse to start, so an authored untrusted is skipped with a warning (the key then falls back to the existing value or the default), an untrusted left behind in config.toml by an earlier Rulesync is replaced with the default on generate rather than preserved, and an existing untrusted is not imported — each with a pointer to the replacement — a user-level [projects."<path>"] trust_level = "untrusted" entry, which is per-machine state Rulesync does not author), apps (per-app tool gating — apps.<id>.tools.<tool>.approval_mode / .enabled, apps.<id>.default_tools_approval_mode), approvals_reviewer (user | auto_review (legacy alias guardian_subagent), or a table; defaults to auto_review when neither the override nor the existing config sets it), tui (the [tui] table — e.g. vim_mode_default = true for the modal Vim composer added in Codex 0.129.0, and the tui.keymap.* bindings beside it; the shallow merge applies one level deep, so authoring keymap replaces the whole existing [tui.keymap] table rather than merging into it), and git_write_rules (boolean, default true — like base_permission_profile it is not a top-level key: it controls whether the managed profile's :workspace_roots table emits the default .git carve-out described above; only an explicit false suppresses it). Deprecated: sandbox_mode (read-only | workspace-write | danger-full-access) with the sibling sandbox_workspace_write table (network_access, writable_roots, …) belong to Codex's classic sandbox system, which permission profiles supersede — Codex prioritizes these legacy keys over permission profiles when both are present, so authoring them disables the generated [permissions.rulesync] profile; they are still accepted (with a warning) so existing configs round-trip, but use base_permission_profile and the shared permission block instead. On import, the top-level keys round-trip back into the codexcli override, and the managed profile's extends round-trips into base_permission_profile. It is a looseObject, so future top-level Codex config keys can be authored here (merged verbatim on generate; only the listed keys are re-extracted on import). Example: { "permission": { … }, "codexcli": { "base_permission_profile": ":workspace", "approval_policy": "on-request", "approvals_reviewer": "auto_review" } }. Out of scope: mcp_servers.* per-MCP gating is not authorable here — it is owned by the MCP feature (codexcli-mcp.ts writes the mcp_servers tables in the same config.toml), and permissions / default_permissions are owned by the canonical model; any such key placed in the override is skipped with a warning. See the Codex configuration reference and permissions docs.

For Kiro, this generates tool permission settings in .kiro/agents/default.json (project mode):

  • bash maps to toolsSettings.shell.allowedCommands / toolsSettings.shell.deniedCommands
  • read maps to toolsSettings.read.allowedPaths / toolsSettings.read.deniedPaths
  • edit / write map to toolsSettings.write.allowedPaths / toolsSettings.write.deniedPaths
  • grep maps to toolsSettings.grep.allowedPaths / toolsSettings.grep.deniedPaths
  • glob maps to toolsSettings.glob.allowedPaths / toolsSettings.glob.deniedPaths (both emitted only when a rule is present, so existing configs do not gain empty tables)
  • webfetch / websearch with pattern * map to allowedTools entries (web_fetch / web_search)
  • ask rules are skipped with a warning (Kiro config does not support explicit ask entries)

Kiro-only override (kiro key): Kiro's agent config exposes per-tool toolsSettings knobs with no canonical allow/ask/deny category. Author them through a tool-scoped kiro override under toolsSettings: the shell auto-trust flags shell.autoAllowReadonly / shell.denyByDefault, the aws built-in tool's allowedServices / deniedServices (+ autoAllowReadonly), and the web_fetch domain trust arrays trusted / blocked (regex host patterns; Kiro documents these for web_fetch only — web_search has no domain-trust surface). Example: { "permission": { … }, "kiro": { "toolsSettings": { "shell": { "autoAllowReadonly": true }, "aws": { "allowedServices": ["s3"], "deniedServices": ["eks"] }, "web_fetch": { "trusted": [".*github\\.com.*"] } } } }. The override is deep-merged per toolsSettings key (the override wins at the leaf) so authoring shell.autoAllowReadonly keeps the canonical-generated shell.allowedCommands; the shared permission block keeps driving shell.{allowed,denied}Commands, read/write/grep/glob paths, and the web_fetch/web_search allowedTools toggles. Existing non-canonical shell flags are preserved across regenerate even without an override. On import, these Kiro-specific surfaces are lifted into the kiro override so they round-trip. It is a looseObject at every level, so future Kiro toolsSettings fields pass through verbatim. Kiro MCP disabledTools lives in the separate .kiro/settings/mcp.json file and is modeled by the MCP feature; MCP autoApprove remains outside this permissions translator. See the Kiro built-in tools and configuration reference docs.

For Cursor CLI, this generates permissions entries in .cursor/cli.json (project mode) or ~/.cursor/cli-config.json (global mode). Cursor CLI only supports allow and deny decisions, so ask rules are skipped with a warning. Tool categories are mapped to PascalCase Cursor tool names (bash → Shell, read → Read, edit/write → Write, webfetch → WebFetch, mcp__* → Mcp). Existing Cursor-specific entries that Rulesync does not manage (for example, MCP entries with extra fields) are preserved on round-trip. Note Cursor scopes the file asymmetrically — "Only permissions can be configured at the project level. All other CLI settings must be set globally" — so in project mode Rulesync contributes only the permissions key, and no longer stamps version or editor.vimMode there (both are written in global mode, where Cursor reads them). Content already in a project cli.json is passed through untouched either way, including a version an earlier Rulesync version stamped: Rulesync cannot tell a key it wrote from one you wrote, so it does not delete it.

Cursor-only override (cursor key): Cursor's cli.json carries scalar autonomy settings with no canonical permission category — approvalMode (allowlist | auto-review | unrestricted) and a sandbox object (mode/networkAccess). Add a tool-scoped cursor override to author them: its fields are merged into the top level of the config file while the shared permission block keeps driving the permissions.allow/permissions.deny arrays (the override cannot clobber that managed block). These settings are global-only upstream, so they are written only when generating with --global; in project scope they are skipped with a warning naming each one, rather than written into a .cursor/cli.json where Cursor would ignore them and the authored setting would silently never take effect. On import, approvalMode and sandbox round-trip back into the cursor override. It is a looseObject, so sandbox's (currently undocumented) value set passes through verbatim and extra cli.json keys can be authored here (they are merged verbatim on generate); note that only approvalMode and sandbox are re-extracted on import.

json
{
  "permission": { "bash": { "git *": "allow" } },
  "cursor": { "approvalMode": "auto-review" }
}

The separate Cursor IDE permissions.json (mcpAllowlist, terminalAllowlist, autoRun.*) is a different file and is not targeted by this translator.

For GitHub Copilot (copilot), this manages the three chat.tools.*.autoApprove maps in the workspace .vscode/settings.json (project mode only). VS Code has no standalone, environment-agnostic Copilot policy file, so project-level auto-approvals are configured through VS Code Copilot Chat's workspace settings. Three canonical categories have a clean, non-lossy mapping and are emitted: bash → chat.tools.terminal.autoApprove (command patterns), edit → chat.tools.edits.autoApprove (file globs) and webfetch → chat.tools.urls.autoApprove (URL patterns). In all three, allow → true (auto-approve) and deny → false (never auto-approve); an ask rule is represented by omitting the entry, so VS Code falls through to its default in-chat approval prompt. The canonical read category has no VS Code approval surface, and write is deliberately not folded into the edits map alongside edit — doing so would make the two indistinguishable on import — so neither is emitted. VS Code also accepts a { "approveRequest": …, "approveResponse": … } object per URL pattern; that form has no canonical equivalent, so it is skipped on import, and because Rulesync owns the key outright it is replaced whenever the canonical config carries any webfetch rule. .vscode/settings.json is a general workspace file (JSONC), so Rulesync merges only those three keys non-destructively and never deletes the file; every unrelated setting is preserved. VS Code's user-scope settings.json lives at a platform-dependent path outside Rulesync's home-relative global model, so only project scope is supported. The all-or-nothing chat.tools.global.autoApprove boolean and the registry-allowlist chat.mcp.access setting are intentionally not mapped, since collapsing per-pattern rules into them would misrepresent what was configured. See the VS Code agent approvals docs and the edit-approval docs.

For Zoo Code (zoocode), this manages the two command lists zoo-code.allowedCommands and zoo-code.deniedCommands in the workspace .vscode/settings.json (project mode only). Zoo Code is a VS Code extension and has no policy file in its .roo/ tree; these two settings are contributed without a scope, which in VS Code means they are settable per workspace, and ClineProvider.mergeCommandLists() unions the workspace values into the lists the auto-approval decision reads. Only the canonical bash category maps, since Zoo Code gates terminal commands and nothing else through these settings: allow → allowedCommands, deny → deniedCommands, and an ask rule is represented by omitting the pattern from both lists so Zoo Code falls through to its own approval prompt. Entries are matched as command prefixes, and Zoo Code resolves a command matching both lists by the longer prefix — auto-approval needs a strictly longer allowed match, and a denied match that is longer or equal auto-denies — so a pattern present in both lists imports as deny. Patterns are literal prefixes rather than globs: apart from a bare * (the one entry treated as a wildcard), a pattern such as rm -rf * is compared with startsWith and so never matches rm -rf /. Rulesync handles that mismatch by which way it fails. A glob- or regex-shaped deny fails open — the entry never matches, so the command stays auto-approved — so the literal prefix it pins down is added alongside it (rm -rf * also emits rm -rf , /^curl / also emits curl ), which makes the deny take effect and denies at least everything the original named; the addition is reported at generate time so you can narrow it if it is wider than you meant. Your own pattern stays in the file, so importing the settings back does not quietly narrow the canonical rule and change what other targets generate from it. The added prefix comes back with it, though: importing from these targets leaves the derived entry in the canonical config alongside the pattern you wrote, and every other target then emits it too (Bash(rm -rf *) gains a Bash(rm -rf ) beside it). It denies no more than the pattern it came from, so it is redundant rather than wrong — delete it from .rulesync/permissions.jsonc if you would rather not carry it. A deny that pins down no prefix at all is left as-is and reported as inert instead: *.sh would yield the empty prefix, which startsWith matches every command against, and an alternation such as npm run (build|test) names alternatives rather than a prefix — write one entry per alternative. A glob-shaped allow fails closed — it approves fewer commands than it looks like it does, and the rest reach the approval prompt — so it is passed through unchanged and only warned about. Only unambiguous matcher syntax counts here: *, grouping and alternation, a leading ^, and anchored /^…/ regex literals. Everything else is a literal prefix and is left strictly alone. That includes absolute command paths — /bin/sh and /etc/init.d/x have the shape of a regex literal but are commands, which is why the anchor is required — and shell syntax generally, since Zoo Code restores variables, brackets, braces and quoted strings verbatim before matching: echo $HOME, [ -f x ] and mv a{,.bak} really do match, and widening them would auto-deny far more than you wrote. Once the bash category is stated, Rulesync owns both keys, and the two empty cases differ because their contributed defaults do. An empty allow list is written as []: Zoo Code reads the effective setting value, and allowedCommands is contributed with the default ["git log", "git diff", "git show"], so removing the key would silently re-grant those three auto-approvals. An empty deny list retracts its key instead: deniedCommands is contributed as [], so nothing resurfaces, and because VS Code resolves array settings by scope precedence rather than by merging, writing [] per workspace would erase a deny list you hand-authored in your user-scope settings.json. A canonical config that states no bash category at all leaves both keys exactly as you wrote them. .vscode/settings.json is a general workspace file (JSONC) shared with the copilot target's chat.tools.*.autoApprove keys, so Rulesync merges only its own keys non-destructively and never deletes the file. VS Code's user-scope settings.json lives at a platform-dependent path outside Rulesync's home-relative global model, so only project scope is supported. The zoo-code.* namespace is Zoo-era (the v3.74.0 rebrand renamed it from roo-cline.*), so the roo target deliberately does not emit these keys and writes its own lineage's spelling instead — see the Roo Code paragraph below. See the Zoo Code settings contributions.

For Roo Code (roo), this manages the same pair of command lists under the archived lineage's own spelling, roo-cline.allowedCommands and roo-cline.deniedCommands, in the workspace .vscode/settings.json (project mode only). The extension's package name is roo-cline, so that is the namespace its contributed settings live under; both keys are present in Roo Code's final release, v3.54.0, which is why they are in the roo target's scope rather than being a post-fork Zoo Code addition. The mapping is identical to Zoo Code's, and shares its implementation: only the canonical bash category maps, allow → allowedCommands, deny → deniedCommands, an ask rule is represented by omitting the pattern from both lists, once the category is stated an empty allow list is written as [] so the contributed allowedCommands default cannot resurface while an empty deny list retracts its key so a user-scope deny list survives, glob- or regex-shaped denies additionally emit the literal prefix they pin down (and are reported) while glob-shaped allows are passed through with a warning, and a canonical config stating no bash category leaves both keys untouched. Roo Code resolves a command matching both lists by the longer prefix — auto-approval needs a strictly longer allowed match, and a denied match that is longer or equal auto-denies — so a pattern present in both lists still imports as deny. Because roo and zoocode write different keys in the same file, enabling both targets leaves all four keys in .vscode/settings.json and neither adapter touches the other's pair; the general advice to pick one of the two targets per project still applies, since every other feature they share writes the same files. See the Roo Code v3.54.0 settings contributions.

For IBM Bob (bob), permissions are global-only and manage the command lists of the approval block in ~/.bob/settings/settings.json, the user settings file Bob IDE and Bob Shell share. Bob documents that block for the user file only; whether a workspace .bob/settings.json honors it is undocumented, so --global is required. Only the canonical bash category maps, onto the execute_command entry of approval.allowedExecutors: allow → approvedCommands and deny → deniedCommands. Bob matches each entry as a prefix of the full command line, and deniedCommands always takes precedence over approvedCommands. ask has no list of its own — a command in neither list keeps Bob's approval prompt — so an ask rule writes nothing and withholds every allow rule it overlaps, compared at prefix width: { "git ": "allow", "git push *": "ask" } writes no git entry, because that prefix would auto-approve git push. The restricting rules of the all-tools * category are folded in the same way they are for Factory Droid. Because Bob compares literal text, a glob- or regex-shaped deny such as rm -rf * is written together with the literal prefix it pins down (rm -rf ) so the deny takes effect, a glob-shaped allow is left as written (it approves fewer commands than it looks like), and Bob documents no bare "*" wildcard, so a "*" entry is reported as one that never matches. Such a deny — "*", *.sh, or an alternation that pins down no prefix — fails closed: since it can never block anything, every allow entry is withheld (and named in a warning) so the commands it was meant to block reach Bob's approval prompt instead of being auto-approved. Once bash is stated, rulesync owns both lists: approvedCommands is always written ([] when nothing is allowed) and an empty deny list removes deniedCommands. When bash is not stated, the command lists are left untouched. Other canonical categories (read, edit, webfetch, ...) have no per-pattern counterpart — Bob can only switch a whole tool group on — so their rules are skipped with a warning; rulesync does not infer group switches from them. Bob's whole-group switches are authored through the Bob-only bob override block instead, and each approval key the block names replaces the one in the settings file (autoApprove is merged toggle by toggle instead): bob.approval.allowed_permissions (the groups Bob auto-approves: read, edit, execute, mcp, skill, todo, subtask, subagent, mode; a group ID outside that list is written as authored with a warning, so a group Bob adds later still works), bob.approval.permissionOptions (per-group options such as { "groupId": "read", "enableOutsideWorkspace": true }; Bob documents enableOutsideWorkspace for the read group only, so any other group gets a warning), and bob.autoApprove (the top-level boolean autoApprove toggles such as { "skills": true }, merged over the existing object; a toggle other than skills is written with a warning), for example { "permission": {}, "bob": { "approval": { "allowed_permissions": ["read", "todo"] }, "autoApprove": { "skills": true } } }. Because the file is Bob's user settings, a switch the override newly turns on applies to every project on the machine, so it is named in a warning — check it if the permissions file arrived with a repository you cloned. Bob's docs show execute in allowed_permissions next to the command lists but do not say whether the lists apply without it, so check that group yourself. When neither bash nor the bob override is stated, no settings file is created. Everything rulesync does not author is preserved: a group switch the override leaves out, executor entries for other tools, other fields of the execute_command entry, the hooks key written by the hooks feature, and every other setting. On import, approvedCommands and deniedCommands become bash allow and deny rules (deny wins for a pattern in both lists), and allowed_permissions, permissionOptions and autoApprove are lifted into the bob override block — from then on rulesync manages those keys, so a change made later in Bob's settings UI is overwritten by the next generate unless you import again. A key holding an entry the override cannot carry (a non-string group ID, an option field other than groupId/enableOutsideWorkspace, a non-boolean toggle) is not lifted, so a generate leaves it as is. The settings file is never deleted. See the Bob approval settings docs and the Bob Shell skills docs for autoApprove.skills.

For Codewhale (codewhale), global permissions (--global) manage the [[rules]] records of ~/.codewhale/permissions.toml, the sibling of the user config.toml and the only place Codewhale reads permission rules (the project .codewhale/config.toml overlay can only tighten approval and sandbox settings); project permissions manage the repo constitution described at the end of this section. The legacy ~/.deepseek/ location and a CODEWHALE_HOME / CODEWHALE_CONFIG_PATH override are not followed; a warning is printed when either variable is set. Codewhale consults typed rules only for shell commands and its file tools, so bash maps to exec_shell, read to read_file, write to write_file, edit to both edit_file and apply_patch (a write deny / ask is written for apply_patch too, since a patch can create files), grep to grep_files, glob to file_search, and list to list_dir; other categories (webfetch, websearch, mcp, ...) are skipped with a warning, because a rule for any other tool is never evaluated. The deny / ask rules of the all-tools * category are written for shell commands and for every file tool (a glob one Codewhale cannot match as a path fails closed for the file tools, as below); its allow rules are skipped. Codewhale expands no wildcards (*, ? or a [...] class). A bash pattern of * or ** becomes a tool-wide rule, git * becomes the command prefix git (matched case-insensitively on word boundaries), and a wildcard-free allow becomes an exact command (command_exact = true), while a wildcard-free deny or ask becomes a prefix — the way Codewhale's own deny rules match — so it also covers the command with more arguments. A restricting pattern with a wildcard elsewhere is broadened, with a warning, to the literal prefix it pins down (git push* --force → git); an allow of that shape, or a pattern that pins down no prefix, is skipped. A path pattern of * or ** becomes a tool-wide rule and a plain path becomes an exact path; any other glob is skipped, since Codewhale compares paths exactly. A skipped deny / ask fails closed: every allow rule it overlaps (any one command or path matching both) for a tool it was meant to restrict is withheld and named in a warning — the preserved workspace grants described below included — so { "*": "allow", "secrets/**": "deny" } under read writes no read_file allow and those reads keep Codewhale's approval prompt, while an allowed src/a.ts beside it is still written. Commands are compared case-insensitively; paths are compared case-insensitively with empty and . segments dropped (as Codewhale normalizes them) and a ** segment also matching no directory; an allowed directory for grep_files, file_search or list_dir (or the workspace root . for any file tool) covers everything under it; and an absolute, home-relative or .. path is treated as overlapping, since it cannot be compared with a workspace-relative pattern. Within the file Codewhale decides by action before specificity — deny beats ask beats allow whatever the order — so { "git *": "ask", "git status": "allow" } still asks for git status. A typed ask only prompts in an approval posture that can prompt: under Full Access or auto-approval it does not, and with approval_policy = "never" it blocks; a deny always blocks. Rules rulesync cannot express (a workspace-scoped record, an exact deny, a tool other than those above, ...) are authored under a tool-scoped codewhale.rules array of native records, written ahead of the converted rules as authored except that a missing action is written out as ask, its Codewhale default. rulesync owns the file's rules with one exception: records carrying a workspace — the repository-scoped grants Codewhale's approval card remembers — are kept across a regenerate (remove one with Codewhale's /permissions remove, and, once imported, from codewhale.rules as well), while an existing record Codewhale would reject is dropped with a warning. The plain prefix and path ask rules the approval card also appends cannot be told apart from rulesync's output and are replaced, so import first (or remove them with /permissions remove) to keep them. A file that fails to parse is an error. On import, records that round-trip through the mapping above become canonical rules (a prefix as <prefix> *, an exact allow as the literal command, and an edit_file rule — or a write_file restriction — only when a matching apply_patch rule exists), and the rest — including the weaker of two rules for the same pattern — go to codewhale.rules. The file is never deleted and is not created when nothing maps. See the Codewhale configuration docs and the authorization order docs.

In project scope, Codewhale permissions become the enforced protected_invariants of the repo constitution .codewhale/constitution.json: an invariant object with paths that Codewhale compiles into a write hold for write_file, edit_file, apply_patch and fim_edit. Each deny / ask pattern of the write, edit and all-tools * categories becomes one invariant { "text": "rulesync permissions: writes to <glob> are denied", "paths": ["<glob>"], "action": "block", "managed_by": "rulesync" } (ask keeps action: "ask" and the text ... need approval), with the strongest action per glob. Because the hold covers every write tool, a write restriction also holds edits and an edit restriction also holds new files. Repo law has no allow shape, so allow rules are skipped, as are the other categories (read, bash, webfetch, ...), with a warning; use --global for those. Codewhale matches globs (with globset, where * and ? also cross /, and matching is case-sensitive) against workspace-relative paths with empty and . segments dropped, so patterns are normalized the same way (./src/./a.ts → src/a.ts, and a trailing /, also inside a brace group, becomes /**), and an absolute, home-relative, drive-letter or .. pattern is skipped with a warning. Because a glob Codewhale cannot compile makes it hold every write, rulesync writes only *, ? and **: a [...] class widens to ?, and {a,b} groups are expanded into separate paths (so an empty alternative keeps its meaning — .env{,.local} holds both .env and .env.local, where globset would drop the empty one); a pattern with a \ escape, a control character, a lone surrogate, unbalanced braces or more than 64 expansions is skipped with a warning. A codewhale.rules passthrough is only written in global scope. rulesync replaces only the invariants it wrote (marked managed_by: "rulesync", a key Codewhale ignores) and keeps every other key (authority, branch_policy, verification_policy, escalate_when, ...) and every hand-written invariant; a restriction that a hand-written invariant already holds is not repeated, but only when the hand-written path is spelled exactly as rulesync would write it or as every canonical pattern that maps to it (such as an imported docs/{a,b}.md, as long as globset reads that spelling the same way) and both are plain printable ASCII: Codewhale only trims a pattern, so a hand-written ./.env does not hold .env. A hand-written entry Codewhale cannot parse (which makes it hold every write for approval) is kept and reported with a warning. Codewhale reads only the nearest constitution between the workspace and its git root, so creating one below an existing one prints a warning. A new file starts with schema_version: 1; the file is never deleted, is not created when nothing maps, and an existing file that is not a valid JSON object, or whose protected_invariants is neither an array nor null, is an error. On import, every enforced invariant (hand-written ones included; advisory strings, objects without usable paths and entries Codewhale cannot parse, such as "action": null, are skipped) becomes a write and an edit rule per path, block as deny and ask as ask. A block denies the write in every posture; an ask prompts only in the Ask posture and is a hard block in Auto-Review, Full Access and Never, or whenever auto-approval is on. Shell commands that write a protected path are not held by repo law. See the enforced repo-law invariants docs.

For the GitHub Copilot CLI (copilotcli), this manages the two URL lists in the CLI's settings file: .github/copilot/settings.json (project mode, repository scope — the file shipped in CLI v1.0.60) and ~/.copilot/settings.json (global mode, user scope). Only the canonical webfetch category maps: allow → allowedUrls, deny → deniedUrls, and an ask rule is represented by omitting the pattern, since the CLI prompts for any URL that is in neither list. No other category is emitted — the CLI's permissions.allow/ask/deny rule arrays are accepted only in MDM/enterprise managed settings, and interactive tool approvals are machine-written to permissions-config.json, so neither is authorable here. Scope matters: the repository-scope key table documents deniedUrls (union — a repository may add denials, never remove them) but not allowedUrls, so an allow rule is only enforceable at user scope; at project scope allow rules are dropped with a warning telling you to author them with --global, rather than being written to a key the CLI ignores (v1.0.79 additionally warns on startup about unknown top-level keys in the user settings.json, so Rulesync emits documented keys only there too). On import, a project-scope allowedUrls is likewise ignored so a dead entry does not become an enforced allow rule, and a pattern present in both lists imports as deny. settings.json also carries unrelated keys (model, effortLevel, hooks, sandbox.*, …), so Rulesync merges only the URL keys non-destructively and never deletes the file. See the CLI config directory reference.

For Kilo Code, this generates the permission object in kilo.jsonc (project mode) or ~/.config/kilo/kilo.jsonc (global mode). The shape is identical to OpenCode's (Kilo is an OpenCode fork), so categories like bash, read, edit, write, webfetch, and mcp accept either a string catch-all ("allow" | "ask" | "deny") or a { <pattern>: <action> } map. Other top-level keys in kilo.jsonc are preserved on round-trip. The permission object is merged per top-level tool key: for each tool key present in the rulesync output, that key is replaced entirely from rulesync (rulesync owns its managed keys; manual edits inside a managed key will be overwritten on the next generation). Tool keys that exist in the existing kilo.jsonc but are NOT in the rulesync output are preserved verbatim so user-added Kilo-only categories survive regeneration. When a regenerate replaces a key whose existing value contained deny patterns that disappear from the new rulesync output, an aggregated logger.warn enumerates the dropped patterns (matching the project convention used by every other permissions translator). Exact denies are written last: Kilo evaluates the permission object last-match-wins in key order, and new keys are appended after the existing ones. Every key whose value is a plain "deny" and whose name contains no * or ? is therefore moved to the end of the object, so a wildcard key written after it — in particular a catch-all "*": "allow" — cannot lift it, including the {server}_{tool} denies the MCP feature writes for disabledTools. An exact key matches only its own permission name, so this only ever makes that name stricter; every other key keeps its authored order (a "*" deny written after a category allow still wins). An "allow"/"deny" (plain, or a {"*": …} map) naming a tool of a server listed in the same file's mcp block is not imported into the kilo override: the MCP feature imports it as that server's enabledTools/disabledTools and owns it. Edits to other top-level keys (e.g. model) are preserved. Malformed kilo.jsonc aborts the run: the jsonc-parser library would otherwise silently coerce a syntax error to {} and overwrite the corrupted file with an empty permission, dropping the user's existing deny rules. Rulesync now surfaces parse errors so the run aborts before any destructive write — matching the strict JSON.parse behavior used by every other permissions translator.

Kilo-only override (kilo key): Kilo's permission object carries tool-specific keys with no canonical permission category — OpenCode-inherited ones (external_directory, doom_loop, lsp, question, todowrite, skill, task, list) and Kilo-unique ones (agent_manager, notebook_read, notebook_edit, notebook_execute, repo_clone, repo_overview). Add a tool-scoped kilo override key alongside the shared block (mirroring the opencode override) to author these; entries under kilo.permission are merged on top of the shared block per key (the override wins) and are emitted only into kilo.jsonc. Each value may be a bare action string or a pattern map. On import, any Kilo key that is not a shared canonical category (bash, read, edit, webfetch, websearch, grep, glob, the all-tools key *, or an mcp__* tool name) is routed into the kilo override rather than the shared block, so a subsequent rulesync generate does not leak it into other tools.

Kilo-only override (kilo.sandbox): the sandbox block Kilo runs commands in is a security surface orthogonal to per-tool allow/ask/deny, with no canonical category, so it is authored under the same tool-scoped kilo override: enabled (boolean), network (e.g. "deny"), allowed_hosts (a list of host / host:port destination exceptions) and writable_paths. It is shallow-merged into the top-level sandbox key of kilo.jsonc — the override's keys win, unrelated sibling keys you set directly are preserved — and the whole block round-trips back into kilo.sandbox on import. Scope matters here. Kilo honors allowed_hosts and writable_paths from the global config only, and lets a project config merely tighten (enabled: true, network: "deny"); a project-level network denial even clears the global destination exceptions. Rulesync mirrors that rather than writing config Kilo would ignore: at project scope only enabled and network are emitted, and any other key is dropped with a warning telling you to author it with --global. See the sandboxing docs.

Kilo markdown_source scope: Kilo grants permission.markdown_source (external Markdown command sources, written as "<absolute>/*": "allow" patterns) only when the last matching rule comes from the global config and says allow — "Project configuration cannot grant this permission" (workflows docs). A project-level rule that matches last therefore always blocks, whatever its action: deny blocks, ask blocks just like deny (Kilo never prompts for this permission), and a project allow would block too, the opposite of what it says. So when markdown_source is generated at project scope — whether it comes from the shared permission block or from kilo.permission — deny and ask entries are written and allow patterns are dropped with a warning telling you to author it with --global; if nothing survives, the key is removed from the project kilo.jsonc. Global scope writes the rule verbatim.

Name-mismatch traps. Canonical category names do not always match Kilo's key names: Kilo folds write into edit (there is no write key), uses notebook_edit (not the canonical notebookedit) and task/agent_manager (not agent), and has no mcp key (MCP is addressed via mcp__* tool-name keys). Rulesync passes key names through verbatim, so author Kilo keys using Kilo's own names (e.g. put a notebook_edit rule under kilo.permission, not the canonical notebookedit). Kilo also treats a null action as a delete sentinel; Rulesync does not model null and only round-trips allow/ask/deny.

For MiMo Code, this generates the permission object in .mimocode/mimocode.jsonc (project mode; an existing .mimocode/mimocode.json is updated instead) or ~/.config/mimocode/mimocode.jsonc (global mode). MiMo Code is an OpenCode distribution with the same permission schema, so everything described for OpenCode above applies, with the tool-scoped override under a mimocode key (mimocode.permission) instead of opencode; on import, keys that are not shared canonical categories are routed into that mimocode override.

For AugmentCode CLI, this generates toolPermissions entries in .augment/settings.json (project mode) or ~/.augment/settings.json (global mode). The file is read as JSON with Comments, as Auggie documents, and only the toolPermissions key is rewritten, so comments elsewhere in it survive a regenerate. Each entry has toolName, an optional shellInputRegex (only for shell commands), and permission.type ∈ "allow" | "deny" | "ask-user". Tool category mapping: bash → launch-process, read → view, edit → str-replace-editor, write → save-file, webfetch → web-fetch, websearch → web-search. Auggie's permissions reference now names these tools terminal / read / edit / write and lists the legacy names as aliases of them; Rulesync keeps emitting the legacy names, which the shipped CLI (0.36.0 as of this writing) matches exactly, but treats an existing entry authored under a current name as the same managed tool — it imports as the same canonical category (a terminal entry's shellInputRegex is recovered like a launch-process one), a stale terminal / read / edit / write allow or ask-user row is replaced on regenerate instead of surviving as an unmanaged entry, and a deny row under either spelling is preserved as written — its spelling is not rewritten — unless a generated entry re-emits the same shape under the same spelling or the legacy name. Action mapping: rulesync ask → AugmentCode ask-user. For bash patterns other than *, the glob pattern is converted to a regex and emitted as shellInputRegex. The glob → regex conversion maps * to .*, ? to ., escapes \^$.|+(){}[], and anchors at both ends; characters outside that set (notably -, /, :, ,) are emitted verbatim, so Augment will match them literally. Generated entries are sorted deny first, ask second, allow last, with more specific patterns (those carrying shellInputRegex) before catch-alls — this is required because Augment's toolPermissions is evaluated first-match-wins. Existing toolPermissions entries whose toolName is NOT in the rulesync-managed set are preserved on round-trip, with one exception: a legacy toolName: "*" basic entry — an inert artifact of older rulesync versions, since AugmentCode only matches exact tool names — is always dropped. Existing deny entries for ANY managed toolName (launch-process, view, str-replace-editor, save-file, web-fetch, web-search, or the current aliases terminal / read / edit / write) are also preserved (fail-closed) so a user-added deny rule on any managed tool cannot be silently downgraded by regeneration. Existing managed-tool allow / ask-user entries are still replaced (rulesync owns the permissive surface for managed namespaces). Non-bash categories do not have a documented per-input matcher in AugmentCode, so Rulesync emits at most one catch-all entry per tool: if the rulesync category contains any deny rule, Rulesync emits a single deny entry for the entire tool (fail-closed) and warns; otherwise only *-pattern allow/ask rules are emitted and any non-* allow/ask patterns are dropped with a warning. AugmentCode has no wildcard toolName, so the canonical all-tools * category is never emitted as an entry of its own: any deny/ask rule it carries is folded into launch-process — via the same glob shadowing logic bash rules use elsewhere — even when the source states no separate bash category, and warns once. The same restriction is also applied as a blanket deny/ask fallback to any of view / str-replace-editor / save-file / web-fetch / web-search that would otherwise emit no entry of its own; a category that states only non-* patterns still counts as emitting nothing, since those patterns are dropped, so it still falls back rather than silently defaulting to AugmentCode's own permissive behavior. An all-tools allow never forces an entry, and a tool whose own rules do emit an entry is always left untouched. Importing AugmentCode entries back into rulesync recovers bash patterns from shellInputRegex but the other categories always import as the catch-all * pattern. The import direction also applies fail-closed precedence when multiple existing entries collapse to the same (canonical, "*") key (e.g. [{view: deny}, {view: allow}]): the most restrictive action wins regardless of iteration order (precedence: deny > ask > allow), so a user-added deny in the source file is never silently dropped by import order. The launch-process (bash) path is unchanged because each entry has its own shellInputRegex-derived pattern with no "*" collapse. On import (project scope), Rulesync also reads the layered overrides file <workspace>/.augment/settings.local.json — a gitignored, machine-specific file that Auggie merges on top of settings.json — and combines it over the base settings before converting to the canonical model, following Auggie's documented layering (simple values take the local override, mcpServers/plugins replace wholesale, and other objects/lists — including toolPermissions, which Auggie concatenates local-first under first-match — are combined across tiers), so personal permission overrides are picked up without dropping a committed base deny. This overlay is import-only and project-only: Rulesync never writes settings.local.json (it stays a user-owned, gitignored file), and AugmentCode documents no global ~/.augment/settings.local.json, so the overlay is skipped in global mode. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's. Every other top-level key is preserved verbatim through the generate round-trip; the plugin-consumption keys recommendedMarketplaces and enabledPlugins are rewritten only when the augmentcode override below states them.

AugmentCode-only override (augmentcode key): AugmentCode's toolPermissions[] supports "custom policy" entries the canonical allow/ask/deny model cannot express — permission.type of webhook-policy / script-policy (delegating the decision to a webhookUrl / script) and an eventType of tool-response (a post-execution check rather than the default pre-execution tool-call). Author these through a tool-scoped augmentcode override with a toolPermissions array of verbatim entries: { "permission": { … }, "augmentcode": { "toolPermissions": [ { "toolName": "github-api", "permission": { "type": "webhook-policy", "webhookUrl": "https://api.example.com/validate" } }, { "toolName": "view", "eventType": "tool-response", "permission": { "type": "allow" } } ] } }. Authored entries are prepended — ahead of the canonical-generated basic rules — so a webhook/script gate or tool-response check is never shadowed by a regenerated allow/deny/ask entry under first-match-wins. When the override authors toolPermissions it becomes the source of truth for the special entries (the existing file's specials are no longer separately preserved, avoiding a double-emit); without an override, any special entries already present in settings.json are preserved verbatim as before. On import, special entries are lifted verbatim into the augmentcode override (rather than being skipped with a warning) so they round-trip and become user-authorable; basic entries continue to drive the shared permission block. The entry objects stay a loose passthrough so shellInputRegex, webhookUrl, script, and future non-policy fields survive untouched, while the documented bounded fields are validated as enums: permission.type (allow | deny | ask-user | webhook-policy | script-policy) and eventType (tool-call | tool-response). Both project and global scope are supported.

The same override also authors Auggie's plugin-consumption settings, so a repository can tell its contributors which plugin marketplaces and plugins to use: { "augmentcode": { "recommendedMarketplaces": ["acme/team-plugins"], "enabledPlugins": { "code-guard@acme-plugins": true } } }. recommendedMarketplaces is a list of "owner/repo" GitHub marketplaces that Auggie offers to install when someone opens the workspace; Auggie honors it only in the project's .augment/settings.json and ignores it in user or local settings, so it replaces the project list and is skipped with a warning under --global. enabledPlugins maps plugin ids ("plugin-name@marketplace-name") to true/false and is written at either scope. Each key is written only when the override states it — otherwise a value already in the file is left untouched — and a stated key replaces the value in the file, so a plugin id dropped from the override is no longer enabled by the project settings (Auggie deep-merges enabledPlugins across tiers, so the user and local tiers still apply). Because the stated value owns the whole key, an entry added to settings.json after the last import — for example by auggie plugin install --project — is removed on the next generate, with a warning naming it; add them to the override, or run rulesync import --targets augmentcode --features permissions after installing plugins with Auggie, to keep them. Removing a key from the override altogether does not delete the value an earlier generate wrote; clear it in settings.json by hand. Writing a non-empty recommendedMarketplaces or an enabled plugin that differs from the value already in the file is logged as a warning, since both make Auggie load code the repository does not ship (a plugin can carry its own hooks and MCP servers). On import, both keys are lifted into the augmentcode override when they have the documented shape, so a plugin enabled with auggie plugin install --project survives the round-trip. Both are read from the committed settings.json only: a recommendedMarketplaces in settings.local.json is ignored, as Auggie ignores it, and an enabledPlugins there is not imported, so a plugin enabled for one machine is never promoted to the shared settings. A global import skips recommendedMarketplaces, which Auggie ignores in ~/.augment/settings.json. dismissedMarketplaces (managed by Auggie in settings.local.json) and autoUpdateMarketplaces (a personal user setting) are not authored.

For Factory Droid, this generates commandAllowlist / commandDenylist arrays in .factory/settings.json (project mode) or ~/.factory/settings.json (global mode). Factory Droid only gates shell commands through these two lists, so the rulesync bash category is what is translated: allow patterns become commandAllowlist entries (run without confirmation) and deny patterns become commandDenylist entries (always require confirmation; the denylist wins when a command is in both). Factory Droid has no separate ask list — any command not in the allowlist already prompts — so rulesync ask rules write nothing, though an ask does withhold every allow that names any of the same commands (an ask on npm * withholds an allowed npm publish, an ask on npm publish withholds an allowed npm *, and an ask on * --force withholds an allowed git * even though neither pattern covers the other's spelling — the two overlap on every git … --force command), because canonically the stricter rule wins whatever its width, so auto-approving a command the file also asks about would answer the prompt the author wanted. The all-tools * category is read as well, but only for its restricting rules: a deny written there covers shell commands too, so it lands in commandDenylist — skipping it would auto-approve the very command the file blocks. A bash deny keeps the allow beside it, since the denylist outranks the allowlist and a bash pattern names a command by construction: {"bash": {"git *": "allow", "git push *": "deny"}} blocks the push and auto-approves the rest. A deny written under * is entered in the denylist too — for the case where it is a command — but it also withholds the allow rules it covers, because a pattern there need not name a command at all: {"*": {"secrets/**": "deny"}} beside an allowed * would otherwise auto-approve every command while the denylist entry matches none of them. A * deny that had allow rules to overlap, overlapped none of them, and is not also written under bash is reported: nothing observed says it names a command, so the denylist entry may block nothing at all — write it under bash too if it is a command pattern. A config with no allow rules at all, or one that spells the same pattern under bash as well, is not reported, since neither says anything about whether the pattern names a command. A bash ask that withheld no allow is not reported: Droid prompts for whatever its allowlist does not cover, so an ask on a command no allow overlaps is already honored exactly as written. An ask under * writes nothing and withholds the same way a bash one does, but one that withheld nothing is reported on the same terms as the * deny above — a pattern there need not name a command, and withholding is the only way an ask can restrict here, so nothing observed says the rule does anything at all. Working out which allow rules a restriction overlaps is bounded work, so a configuration large enough to exhaust that budget stops comparing patterns and withholds every allow beside a restriction — the fail-closed direction, at the cost of auto-approving less than the config asked for — and says so in a warning. This read is one-way: import writes patterns back into the bash category only, never into *. Its allow rules are deliberately never carried over, since a pattern under * need not be a command at all (secrets/** there denies a path) — the skip is named in a warning rather than made silently; both directions therefore fail closed. Categories other than bash and * cannot be represented in the command allow/deny model and are skipped, with a logger.warn when a skipped category carries a deny or an ask rule (to surface the gap — a foreign ask restricts as surely as a foreign deny). rulesync owns the commandAllowlist / commandDenylist keys (they are replaced from the rulesync output), while every other key in settings.json (e.g. hooks) is preserved verbatim on round-trip — except the Factory-specific security keys covered by the factorydroid override below, which are lifted into that override on import. Importing reads the two lists back into the bash category, with .factory/settings.local.json overlaid on top of settings.json first — Droid layers the two, so importing without the overlay would read permissions it does not actually enforce. Rulesync never writes settings.local.json; generation only ever writes settings.json. Note the consequence for a full round-trip: a value that came from the local file is imported into .rulesync/permissions.jsonc like any other, so the next generate writes it into the shared settings.json. If a personal override should stay personal, drop it from .rulesync/permissions.jsonc after importing. Whatever the local file contributed is named in a warning while importing, so a personal value is visible before it is committed; a key that governs what the tool may run — a sandbox tier, an autonomy ceiling, the hooks it executes — is called out on its own, since publishing one machine's relaxed value makes it the team's. Rulesync gitignores .factory/settings.local.json itself, matching Factory's guidance to keep it out of version control.

Factory Droid-only override (factorydroid key): Factory Droid has security controls that do not fit the per-command allow/ask/deny model — the hard-block commandBlocklist tier (commands that can never run, not even under full autonomy — distinct from an approvable deny), plus networkPolicy (allowedIps), sandbox (enabled/mode/filesystem/network), mcpPolicy, enableDroidShield, autonomy settings (sessionDefaultSettings, maxAutonomyLevel, subagentAutonomyLevel, interactionMode, and the per-tool mcpAutonomyOverrides), the plugin-bootstrap keys extraKnownMarketplaces / enabledPlugins (Droid auto-registers those marketplaces and installs those plugins on start — the upstream distribution path for the same artifacts rulesync generates), the hooksDisabled kill-switch, disabledSkills (an array of skill names to disable without deleting their files), and the organization controls modelPolicy and missionPolicy. Add a tool-scoped factorydroid override to author them: its keys are merged into settings.json (the override wins) while the shared permission block keeps driving commandAllowlist/commandDenylist. On import, these keys are lifted into the factorydroid override — so commandBlocklist now round-trips faithfully (its never-runs guarantee is preserved) rather than being collapsed onto an approvable deny.

json
{
  "permission": { "bash": { "git *": "allow" } },
  "factorydroid": { "commandBlocklist": ["curl *"], "sandbox": { "enabled": true } }
}

For Cline CLI, this generates .cline/command-permissions.json (project mode only). Cline reads this file via the CLINE_COMMAND_PERMISSIONS environment variable; you can wire it up with export CLINE_COMMAND_PERMISSIONS=$(cat .cline/command-permissions.json). The schema is { "allow": [...], "deny": [...], "allowRedirects": false }. Cline only supports shell commands and only allow/deny. Categories other than bash and the all-tools * are dropped, and bash ask rules are translated to deny (fail-closed safety, since Cline lacks ask semantics). The all-tools * category contributes its restricting rules — a deny written there covers shell commands too, so skipping it would auto-approve a command the file blocks — while its allow rules are never carried over, since a pattern under * need not be a command at all (the skip is named in the translation notice rather than made silently); both directions therefore fail closed. A bash deny is written to deny and keeps the allow beside it — Cline documents that deny rules always take precedence, and a bash pattern names a command by construction — which is how a narrow deny carves an exception out of a wider allow (git * allowed, git push denied). A deny under * is written as well, but it also withholds the allow rules it covers, since a pattern there need not name a command: {"*": {"secrets/**": "deny"}} beside an allowed * would otherwise auto-approve every command while the deny entry matches none. A * deny that had allow rules to overlap, overlapped none of them, and is not also written under bash is reported the same way, since nothing observed says it names a command Cline can block. An ask under * is not translated: a pattern written there need not name a command, and denying it outright would turn the ordinary catch-all {"*": {"*": "ask"}} into a block on every command — one the additive deny merge below would then keep forever. It withholds the allow rules it covers instead (whatever the two patterns' widths, since canonically the stricter rule wins regardless of how wide it is), which leaves those commands prompting, exactly as an ask asks. A * ask that had allow rules to overlap, overlapped none of them, and is not also written under bash is reported: withholding is the only way it can restrict here, so nothing observed says the pattern names a command — write it under bash if it is one. One that withheld no allow at all is not reported, since the allow array below is a gate: a command no allow covers already prompts, which is what the ask asked for. Working out which allow rules a restriction overlaps is bounded work, so a configuration large enough to exhaust that budget stops comparing patterns and withholds every allow beside a restriction — the fail-closed direction — and says so in the translation notice. Note that Cline's allow array is a gate rather than a list of exceptions — once it is present, only the commands matching it run without approval — so a config whose every allow is withheld asks about every command. The * read is one-way: import writes patterns back into the bash category only, never into *. All of those translation notices are surfaced via a single aggregated logger.warn per generation (matching the project convention used by every other permissions translator) so the translation stays visible without tripping CI gates that treat error lines as failures. The allow array is wholesale-replaced by rulesync — user-added entries inside allow are not preserved on regenerate. The deny array is additive — user-added denies in the existing file are preserved on every generation alongside the rulesync-derived denies (fail-closed standard). The allowRedirects field (a single global boolean gating shell redirection operators >/>>/<) can be authored from rulesync via a tool-scoped cline override — add "cline": { "allowRedirects": true } alongside the shared permission block. Precedence: the cline override wins, otherwise the existing file value is preserved, otherwise it defaults to false. On import, a true value round-trips back into the cline override (the default false emits no override). Cline does not have a stable per-user file location for command permissions, so global mode is not supported. If a pattern still ends up in both allow and deny — which the additive merge can produce when the existing file carries a hand-written deny matching a generated allow — Rulesync emits a warning, since deny wins and the allow entry therefore has no effect.

For Zed, permissions are global-only and generate the agent.tool_permissions object in ~/.config/zed/settings.json (%APPDATA%\Zed\settings.json on Windows). Zed reads a project .zed/settings.json as ProjectSettingsContent, which has no agent field, so no agent.* key written there takes effect; project-mode generate and import therefore skip Zed permissions, and an agent block an earlier Rulesync version wrote into .zed/settings.json is inert and left for you to remove by hand. Each canonical category becomes a key under agent.tool_permissions.tools.<tool> (tool-name mapping: bash → terminal, edit → edit_file, write → write_file, webfetch → fetch, websearch → search_web; unknown categories pass through unchanged). Per-tool MCP categories are translated: canonical mcp__<server>__<tool> becomes Zed's mcp:<server>:<tool>, and imports back into the canonical spelling, so a category authored once reaches Zed and the other targets alike (a key already written in Zed's spelling is emitted unchanged but still normalizes to the canonical form on import). Only the first separator is split, so a tool whose own name contains __ survives the round-trip. Inside an MCP category only the catch-all * rule is emitted, as the tool's default; pattern-scoped rules are dropped with a warning, because Zed dispatches every MCP tool with a single empty input ("MCP tools are gated only by tool id (no per-input pattern matching)"), so a pattern would be matched against "" rather than against anything meaningful. A category that omits or wildcards either half — mcp__<server>, mcp__<server>__*, mcp__*__<tool> — is dropped with a warning too, since Zed looks the tool up by exact key on the full triple with no glob or prefix matching. Any canonical-spelled mcp__<server>__<tool> entry an earlier Rulesync version left in settings.json is swept on the next generate, whether or not the current config still names that category: it is not a Zed tool name, so it can only be Rulesync's own output, and leaving it would resurrect stale rules on the next import. Read-only categories are not written at all: Zed's gated tool list does not include read_file, grep, find_path or list_directory — they are in Zed's own EXCLUDED_TOOLS and never consult the permission settings, so neither a per-tool entry nor the global default reaches them. Canonical read, grep and glob (and a category naming one of those Zed tools directly) are therefore dropped, with a warning when the category carried a deny or ask rule, rather than written as entries Zed ignores. Zed's read-denial surface is private_files, which the ignore feature writes from .rulesync/.aiignore. An inert entry an earlier Rulesync version wrote is left in place rather than deleted — Rulesync cannot tell it from one you wrote, and Zed ignores it either way — and it still imports back as the canonical category, so remove it by hand if you want it gone. The canonical * category is the exception: its catch-all * rule sets the top-level agent.tool_permissions.default — rung 6 of Zed's precedence ladder, and the mechanism Zed documents for MCP tools — rather than an inert tools["*"] entry (* is not a Zed tool name; a stale tools["*"] entry written by an earlier version is cleaned up when the canonical config carries a * category, and the default imports back as *: { "*": <action> }). Pattern-scoped rules in the * category have no Zed counterpart and are dropped with a warning. Within every other category, the catch-all * pattern sets the per-tool default, while specific patterns become always_allow / always_deny / always_confirm entries of the form { "pattern": <regex>, "case_sensitive": false }. Action mapping: rulesync ask ⇄ Zed confirm (allow/deny are shared). Because Zed matches with regular expressions, patterns are emitted verbatim — author canonical patterns as regexes when targeting Zed. The settings file is shared with the MCP (context_servers) and ignore (private_files) features, so writes merge non-destructively: unrelated settings, a user-set agent.tool_permissions.default (when the canonical config has no * category), and any tools.<tool> entries NOT managed by rulesync are preserved on round-trip. The canonical model has no slot for per-pattern case sensitivity, so rulesync always emits case_sensitive: false; a hand-authored case_sensitive: true on a rulesync-managed tool is overwritten on the next generate.

Zed-only override (zed key): three Zed agent keys sit outside the canonical allow/ask/deny model and are authored verbatim through a tool-scoped zed override. zed.sandbox_permissions is written into agent.sandbox_permissions: Zed's OS-level agent sandbox, which since Zed 1.14.2 (2026-08-05) is on by default for the terminal and fetch tools and by default forbids network access, writing outside the project directories, and writing to .git. Most real setups therefore need to relax one of network_hosts (exact hostnames or leading *. wildcards), allow_all_hosts, write_paths, allow_fs_write_all or allow_unsandboxed — none of which the canonical categories can express, since this is process containment rather than tool gating. zed.profiles is written into agent.profiles, Zed's tool-availability layer: a separate enforcement stage from tool_permissions, because a tool absent from the active profile cannot be used no matter what the permission rules allow (per-profile keys name, tools, enable_all_context_servers, context_servers, default_model). zed.default_profile is written into agent.default_profile, the profile the agent starts in (Zed's default is write), so an authored profile can be selected without picking it by hand in the agent panel; Zed itself rewrites this key whenever you switch profiles there. Example: { "permission": { … }, "zed": { "sandbox_permissions": { "network_hosts": ["*.github.com"], "write_paths": ["/tmp"] }, "profiles": { "review": { "name": "Review", "tools": { "terminal": false } } }, "default_profile": "review" } }. The sandbox_permissions and profiles blocks pass through untouched — Rulesync canonicalizes neither, and validates only the documented profiles keys (sandbox_permissions is unvalidated, since Zed adds to it release over release) — and each is replaced wholesale when the override supplies it, since Zed reads each as a single policy unit; omit the key and whatever is already in settings.json is left alone rather than deleted, so removing a block is a manual edit. On import, all three are lifted back into the zed override so a hand-written sandbox policy, profile set or profile selection round-trips — including approvals you did not write by hand, since Zed saves an always-allow you clicked in the sandbox prompt into agent.sandbox_permissions itself. Read the imported block before committing it: an ad-hoc allow_unsandboxed picked up from your own machine would otherwise be committed to .rulesync/permissions.jsonc and regenerated into the user settings of everyone who runs a global generate from it. The same wholesale replace works the other way too — regenerating from an authored override discards approvals Zed had recorded since. The zed block authors these three keys and nothing else: agent.tool_permissions belongs to the canonical permission block, and any other key — a misspelling, or a blunt instrument such as Zed's agent.always_allow_tool_actions — is ignored with a warning, so nothing reachable from the override can weaken a reviewed deny. Like the sibling agent.tool_permissions, these keys are written in global scope only, since Zed reads no agent key from a project .zed/settings.json. See the Zed sandboxing and agent profiles docs.

For Qwen Code, this generates permissions.allow, permissions.ask, and permissions.deny arrays in .qwen/settings.json (project mode) or ~/.qwen/settings.json (global mode). The format mirrors Claude Code's: entries are Bash(<pattern>), Read(<pattern>), Edit(<pattern>), Write(<pattern>), WebFetch(<pattern>), WebSearch(<pattern>), Grep(<pattern>), Glob(<pattern>), Agent(<pattern>), etc. Other top-level keys in settings.json are preserved on round-trip. Patterns may contain nested parentheses (e.g. Bash(echo (a))); Rulesync uses the last ) as the closing delimiter when parsing, so inner parens round-trip. Malformed entries (missing closing paren, trailing characters) emit a warning; for deny they fall back to the catch-all pattern * (fail-closed: broadening a deny is the safer direction), but for allow / ask they are dropped rather than broadened — silently turning a narrow user rule into * would be a fail-open round-trip. Generation does not create the .qwen/ directory until writeAiFiles runs, so dry-run is side-effect-free.

For Kimi Code, permissions are global-only and generate [[permission.rules]] entries in ~/.kimi-code/config.toml. Canonical categories map to Kimi tool patterns (bash → Bash, read → Read, write → Write, edit → Edit, grep → Grep, glob → Glob, websearch → WebSearch, webfetch → FetchURL, agent → Agent, and mcp__… passes through as the MCP tool name); a * canonical pattern emits the bare tool name and a specific pattern emits Tool(pattern). Actions map 1:1 to Kimi's allow / ask / deny, and generated rules use scope = "user". Kimi evaluates rules first-match-wins, so Rulesync sorts canonical output fail-closed: all deny rules precede ask, all ask rules precede allow, and more-specific patterns precede broader patterns within each action. Kimi does not match MCP tool arguments; an argument-specific MCP allow/ask is skipped with a warning rather than broadened, while an argument-specific deny becomes a whole-tool deny with a warning. The optional kimi-code.defaultPermissionMode override writes Kimi's top-level default_permission_mode (manual / yolo / auto), while kimi-code.rules accepts native rules that canonical categories cannot express and emits them first in their authored order. On import, Rulesync preserves the complete ordered rule list under kimi-code.rules, including rules that could otherwise fit the shared permission model, so regeneration cannot change Kimi's first-match behavior. A kimi-code.tools override writes Kimi's [tools] enabled / disabled lists — a separate enforcement layer from [[permission.rules]], since a rule prompts while these remove the tool from every agent in every session. Entries pass through verbatim because the section uses agent-file tool syntax (exact built-in names, mcp__server__* globs) rather than the canonical category/pattern shape. Note that Kimi registers [tools] in its v2 engine, so today it applies under kimi web and experimental kimi -p rather than the interactive TUI. Like the MCP defaults, the section merges per key: authoring only enabled leaves a hand-written disabled list alone, and dropping the override leaves the section as it stands. Values are carried through exactly as written, empty lists included — enabled = [] is an allowlist admitting nothing, the strictest setting there is, while an absent enabled means no allowlist at all, so the two are never interchanged. The [permission] table merges per key the same way: Rulesync owns only rules, so a hand-written sibling such as dangerous_command_guard = false (the Kimi Code 0.40.0 switch for the built-in dangerous-command policy) is carried through verbatim on regenerate rather than deleted — it is not modeled, so it is neither authorable from Rulesync nor lifted on import. The TOML file is shared with hooks, the MCP timeout defaults and other Kimi settings, so updates merge in place and never delete the file. See the Kimi Code permission docs.

Qwen-only override (qwencode key): Qwen's settings.json exposes autonomy/sandbox controls with no canonical permission category — under tools (approvalMode = plan/default/auto-edit/auto/yolo, autoAccept, sandbox, sandboxImage, disabled, visible (the deferred-tool startup visibility list, union-merged by Qwen across scopes), listDirectory (the { enabled } toggle for the built-in list_directory tool, off by default because glob covers directory listing — note that Qwen re-enables the tool anyway when list_directory is named in the tools.core allowlist, an unrelated sibling key the override preserves rather than owns), todoWrite (the { enabled } toggle for the built-in todo_write tool and its system-prompt guidance, off by default since Qwen Code v0.23.1; experimental.todoStopGuard requires it), eager (the allowlist of eager-by-default tool names whose schemas stay in the initial model request; every other non-exempt one is demoted to deferred, still registered and callable but only after tool_search loads it — and out of reach for a session where tool_search is not registered. Unlike visible and disabled, Qwen does not union this list across scopes, so a project value replaces a user one outright, and an explicit [] defers every non-exempt tool while omitting the key means no restriction at all), workflowsEnabled (which enables the Workflow tool and the /workflows command), workflowNameOnly (which restricts that tool to named workflows, so every run the model starts can be matched by a Workflow(name:...) permission rule; since Qwen Code v0.24.1), and executionSandbox (the Linux tool-execution sandbox policy, { filesystem, network, backend? }; since Qwen Code v0.24.4)), security (folderTrust, the two guardrails on type: "http" hooks — allowedHttpHookUrls, the allowlist of URL patterns a hook may POST to, where an empty list means allow-all, and allowPrivateNetworkHooks, which relaxes the private-IP (SSRF) check — and allowedInsecureVoiceBaseUrls, the base URLs a voice provider may be reached at over cleartext HTTP or a private address, which also carries the provider API key in its Authorization header), and permissions.autoMode (the Auto Mode classifier config: hints.{allow,softDeny,hardDeny}, environment, classifyAllShell). Add a tool-scoped qwencode override to author them: qwencode.tools and qwencode.security are shallow-merged into the matching settings.json group at the top level of that group (an unrelated sibling key such as tools.core is preserved, an override key wins, and a nested object the override supplies such as security.folderTrust replaces the existing one wholesale rather than being deep-merged), while qwencode.autoMode is emitted as permissions.autoMode (replacing the existing autoMode wholesale) and the shared permission block keeps driving the permissions.allow/ask/deny arrays. On import, the documented autonomy keys (tools.{approvalMode,autoAccept,sandbox,sandboxImage,disabled,visible,listDirectory,todoWrite,eager,workflowsEnabled,workflowNameOnly,executionSandbox}, security.{folderTrust,allowedHttpHookUrls,allowPrivateNetworkHooks,allowedInsecureVoiceBaseUrls}, and permissions.autoMode) round-trip back into the override; other tools/security keys are left in settings.json and not extracted. Scope caveats apply to six of those keys, because Qwen Code does not let a workspace decide everything a user can. Four of them — tools.workflowsEnabled, tools.executionSandbox, security.allowPrivateNetworkHooks and security.allowedInsecureVoiceBaseUrls — are on Qwen Code's workspace-restricted list and are stripped out of workspace settings before merging, honoring them in user/system settings only, so that a cloned repository cannot turn on a subagent-spawning tool, choose the sandbox its own commands run in, grant itself private-network access, or add a cleartext destination that its API key is sent to. Rulesync therefore skips those four with a warning when generating project-scoped settings.json (a value already written into the project file by hand is left untouched), and emits them only in global mode — where a granting value is announced, naming the key, the value and what it replaced. "Granting" follows Qwen Code's own reading: any truthy value for the booleans — so 1, the string "false" and an empty array all count, because Qwen Code tests them for truth rather than for content — and a non-empty list for allowedInsecureVoiceBaseUrls, whose list it matches a URL against instead, making the empty one fail-closed — and any executionSandbox value, null included, since it is a policy rather than a switch. (workflowsEnabled also answers to the QWEN_CODE_ENABLE_WORKFLOWS / QWEN_CODE_DISABLE_WORKFLOWS environment variables, disable winning, so a settings value is not the last word.) The other two are written in both scopes, but not silently. security.allowedHttpHookUrls sits on Qwen Code's second, non-overriding list: a workspace value is honored only while no user, system or system-defaults scope sets the key, so a repository may narrow where its own hooks send data but never replace the whitelist a user configured in a higher scope. It does replace a list written by hand in that same project file, though, and an empty list is Qwen Code's allow-all — the emptiest value being the widest one is why every global write that changes the file is announced, not just a granting one. security.folderTrust is read on both sides of the merge: the initial trust decision — the one that settles whether this workspace's own settings are merged at all — is made from user and system settings alone and defaults to trusted, so a project value cannot vouch for its own repository; once the workspace is trusted, the merged value still drives the folder-trust gate, so { "enabled": false } in a project file does switch that gate off. Rulesync emits it with a note saying which half it cannot decide, and announces a global write that changes it. Import lifts all six in either scope, because the file being read carries no scope marker. It flags them when the file it read was the project one, since that is the value regenerating with --global would promote into one Qwen Code enforces everywhere; a value imported from ~/.qwen/settings.json is already there, so it is lifted silently. Review a flagged key before you regenerate globally, especially if the .qwen/settings.json came from a repository you cloned. The remaining keys are neither stripped nor non-overriding — Qwen Code honors them wherever they are written, with one tighten-only exception noted below — but a global write is still announced whenever it changes what the file said, because that is the write that settles how far the agent may go unattended on every project on the machine: the autonomy and containment controls (approvalMode, autoAccept, sandbox, sandboxImage), tools.workflowNameOnly (on Qwen Code's tighten-only list: a workspace may set it to true but a workspace false is ignored, so a repository can add the named-workflows restriction yet never lift one a user or system setting imposes — the project-scope note says so), the registry controls (disabled, which keeps a tool from being registered at all — Qwen Code unions the disabled lists across scopes, so dropping a name only hands the tool back when no other scope still names it — plus visible, listDirectory, todoWrite and eager — which does not remove a tool but demotes every non-exempt eager-by-default one it omits to deferred, and replaces rather than unions the list a higher scope set), the Auto Mode classifier config (permissions.autoMode, whose replacement rewrites the instructions the classifier follows, including any deny hint already configured), and any key rulesync does not model. That last case matters because qwencode.tools and qwencode.security are open objects, so a key Qwen Code adds works before rulesync knows about it — including potent ones such as tools.discoveryCommand, which Qwen Code spawns at startup — and rulesync names it rather than writing it silently. Because those groups are open, the name rulesync reports can be authored too, so a warning quotes the key the same way it quotes the value — serialized, with control characters stripped and long text truncated — and neither can forge a warning line of its own. A project-scoped write of one of these keys is reported as well, naming the key, the value and what that particular key decides — the registry controls are described as registry controls rather than as autonomy ones: Qwen Code honors them in a workspace file, and a plain rulesync generate is the likelier path for an override that arrived with a repository you cloned. For the keys rulesync writes, only a write that changes what the file already said is reported, so re-running generate says nothing new; the note about a key rulesync refuses to write in project scope is printed whatever the file says, because it is about what was left out.

json
{
  "permission": { "bash": { "*": "allow" } },
  "qwencode": {
    "tools": { "approvalMode": "auto-edit" },
    "security": { "folderTrust": { "enabled": true } },
    "autoMode": { "hints": { "allow": ["Running tests"] }, "classifyAllShell": true }
  }
}

Alias overlap: Qwen's Read is a meta-tool that also covers grep/glob/list, so canonical grep/glob rules are emitted as their own Grep(...)/Glob(...) entries but overlap Qwen's Read category at runtime; and Qwen folds web search into web_fetch, so a canonical websearch rule (WebSearch(...)) may not correspond to a distinct Qwen tool. tools.disabled is a hard whole-tool disable (stronger than deny) and is only authorable via the override, not the canonical deny.

For oh-my-pi (omp), this generates the tools.approval and bash.patterns keys of .omp/config.yml (project mode) or ~/.omp/agent/config.yml (global mode, default profile; a named profile or PI_CODING_AGENT_DIR is not followed, and the legacy .omp/settings.json and a global config.yaml are neither read nor written). tools.approval.<tool> is a per-tool allow / prompt / deny policy that oh-my-pi honors in every approval mode, so a category's catch-all * rule is written there (ask becomes prompt). bash, read, edit, write, grep and glob keep their names, websearch becomes web_search, agent becomes task, and any other category (an oh-my-pi-only tool such as eval, best authored under a tool-scoped omp.permission block, or an MCP tool, which must use oh-my-pi's final registered name such as mcp__github_create_issue; a canonical-style mcp__<server>__<tool> name logs a warning) is written verbatim (when agent and task both appear, the stricter policy wins). webfetch, notebookedit and the bare mcp category have no oh-my-pi tool (or key covering every MCP tool) and are skipped with a warning (oh-my-pi fetches web pages through read, which a webfetch rule does not restrict). The all-tools * category has no key of its own: its deny and ask rules restrict bash commands, and they also raise every other tool's policy (the mapped built-in tools, every key Rulesync writes, and every tools.approval key kept from the existing file; an oh-my-pi tool with no key, such as an unset eval, still follows tools.approvalMode): a catch-all one to its own strictness, a pattern-specific one to at least prompt, with a warning. Its allow rules are skipped with a warning. oh-my-pi has no per-path matcher, so a non-bash category with pattern-specific rules collapses to one policy (deny > ask > allow, with an implicit ask when there is no catch-all) and a warning. bash rules become the ordered, first-match-wins bash.patterns list of { match, approval } records, ordered deny, then prompt, then allow so the first match agrees with the canonical precedence; a bash catch-all ask or deny is written there as match: "*", while a bash catch-all allow is written as tools.approval.bash: allow, because by default (without bash.allowCompoundCommands) an allow pattern never approves a compound command line. oh-my-pi, however, lets a critical command such as rm -rf / skip a prompt pattern unless tools.approval.bash prompts (in the default yolo mode it otherwise runs), so when the resolved bash rules contain any ask, Rulesync writes tools.approval.bash: prompt (with a warning unless the bash catch-all is already ask) and a bash catch-all allow becomes a trailing match: "*" allow pattern instead; a command that no allow pattern matches, including a compound command line, then prompts in every approval mode. oh-my-pi treats only * as a wildcard and collapses whitespace runs before matching, so ? and [...] in a bash deny or ask pattern widen to *, while in an allow pattern they are matched literally (both with a warning). The file carries every other oh-my-pi setting, so sibling keys such as tools.approvalMode and bash.enabled are kept, tools.approval entries for tools Rulesync does not manage (anything other than the mapped built-in tools and the keys it writes, e.g. a hand-written eval policy) are kept — so a rule for such a tool that is later removed from .rulesync/permissions.json stays in config.yml until it is deleted by hand — bash.patterns is replaced wholesale (oh-my-pi replaces arrays rather than merging them, so a project bash.patterns list also hides the global one in that project), and the file is never deleted. On import, tools.approval entries become catch-all rules (tools.approval.bash becomes the bash catch-all unless a * pattern already sets it, and a deny always does; a * allow pattern imports as allow only behind an allow policy or next to ask patterns with a prompt policy, which is how Rulesync writes it, and as ask otherwise, since it never approves a compound command line; a prompt policy adds no catch-all next to ask patterns) and bash.patterns become bash rules, with whitespace collapsed and later duplicates of a pattern dropped. See the oh-my-pi settings docs and approval-mode docs.

For Pi Coding Agent, this generates the defaultTools array in .pi/settings.json (project mode) or ~/.pi/agent/settings.json (global mode). Pi exposes no allow/ask/deny rule surface, so no canonical permission category maps onto it — its one repository-syncable tool gate is defaultTools, the list of built-in tools enabled at startup (added in Pi v0.84.2). It is an enable-list rather than an allow/deny rule set, so it is authored through a pi override block in .rulesync/permissions.jsonc — e.g. { "permission": {}, "pi": { "defaultTools": ["bash", "edit", "write"] } } — and round-trips back into it on import. An empty array is meaningful and emitted as written: upstream reads it as "start with no built-in tools" while keeping extension and SDK custom tools. Scope semantics are the opposite of the union merge most targets use: a project defaultTools array replaces the global array rather than adding to it, so the two scopes are written independently and never combined. CLI flags outrank the setting (--tools is a strict allowlist over all tools, --no-tools disables everything, --no-builtin-tools drops the built-in defaults, --exclude-tools filters the result). settings.json is a hand-edited file holding many unrelated keys (theme, defaultModel, packages, sessionDir, …), so writes go through the shared-config gateway with defaultTools as the only owned key — everything else is preserved and the file is never deleted. A config that does not state defaultTools leaves the key exactly as you left it. See the Pi settings reference.

For Warp, this generates the command allow/deny regex lists in Warp's global user settings.toml (global mode only — Warp has no project-scoped permissions file). Since Warp promoted file-backed execution profiles to Stable (2026-07-28), the surface runtime enforcement actually reads is the command_allowlist / command_denylist arrays of the default record under [agents.execution_profiles.<id>]; rulesync merges the lists into that default profile in place whenever the collection exists, preserving every other profile key and every other profile ID. The legacy agent_mode_command_execution_allowlist / agent_mode_command_execution_denylist keys under [agents.profiles] are still written for un-migrated installs and old clients — but on a migrated install they are inert (Warp consumes them only once during its one-shot migration). When the [agents.execution_profiles] collection does not exist yet, rulesync deliberately does not create it: on such an un-migrated install the legacy keys are still live, and creating the collection would mark Warp's migration complete early and strand the user's other legacy settings. Note that rulesync manages only the default profile — if a different execution profile is active in Warp, the generated lists (including deny rules) are not enforced until the user switches back to default. The settings file path differs per platform: macOS ~/.warp/settings.toml, Linux ~/.config/warp-terminal/settings.toml, Windows %LOCALAPPDATA%\warp\Warp\config\settings.toml. The bash category maps (allow → allowlist, deny → denylist); Warp matches commands with regular expressions, so patterns are emitted verbatim — author canonical bash patterns as regexes when targeting Warp (mirrors Zed). Warp has no per-command ask list, so ask rules write nothing — but an ask still withholds every allow that names any of the same commands (an ask on npm * withholds an allowed npm publish, an ask on npm publish withholds an allowed npm *, and an ask on * --force withholds an allowed git * even though neither pattern covers the other's spelling), since canonically the stricter rule wins whatever its width. A bash ask that withheld no allow is not reported, since Warp prompts for whatever its allowlist does not cover and the rule is therefore honored as written. An ask written under * is reported in that case, on the same terms as a * deny: Warp has no ask list to write it to, so withholding is the only way it restricts, and a pattern there need not name a command — .*--force is read as a glob needing a literal leading . and overlaps none. The all-tools * category is read for its restricting rules only, and those never reach the denylist: writing any denylist replaces Warp's built-in default one (see below), and a * pattern need not name a command at all, so an inert secrets/** entry there would trade real protection for a rule that matches nothing. A deny or ask under * therefore withholds the allow rules it covers instead — restricting in the same direction without touching the denylist — and is reported in a warning; author the rule under bash as a regex to have it enforced as a command. Its allow rules are never carried over either, since a pattern under * need not be a command — the skip is named in a warning rather than made silently, and both directions fail closed. Note that this read is one-way: import writes patterns back into the bash category only, never into *. Because Warp's patterns are regexes while the overlap test compares globs, each bash pattern is first rewritten into the widest glob it could stand for: a class ([rf]), a group ((sudo )?), a character escape (\s), a code-point escape together with its digits (\x20, \u0020, \U00000067, \x{263A}), a Unicode-class escape together with its class (\pL, \p{Greek}, \PL), a top-level |, a quantifier together with the atom in front of it (commits? covers git commit, ab*c covers ac), and a missing ^/$ anchor all become *. A construct rulesync cannot read as one sequence makes the whole pattern * — a class, group or quantifier that never closes, a { that spells no repetition ({a|b} is an alternation in braces, not a quantifier), or a class holding a nested [ (Rust's regex crate, which Warp matches with, builds one class out of [a[b]c]) — since guessing at the rest could only narrow the result. The rewrite only ever widens, so an inexact reading withholds an allow rather than writing one the config restricts. Note what an unanchored pattern means here: secret .* matches any command that merely contains secret , so it overlaps an unanchored git .* and withholds it — anchor both ends (^git .*$, ^secret .*$) when an ask on one command should leave the rest auto-approved. Working out which allow rules a restriction overlaps is bounded work, so a configuration large enough to exhaust that budget stops comparing patterns and withholds every allow beside a restriction — the fail-closed direction — and says so in a warning. A pattern written under the all-tools * category is deliberately not rewritten: it is canonical, read by every tool, so it is a glob already and is compared as one (secrets/** there is a path, not a regex that matches every command mentioning secrets). The consequence is worth spelling out: a regex written under * is not understood as one here, so .*--force there is read as a glob needing a literal leading . and overlaps no command — author a Warp command rule under bash, where it is read as the regex you wrote. Categories other than bash and * are skipped (with a warning when they carry deny or ask rules). Writing a command_denylist at all replaces Warp's built-in default denylist — which covers rm, curl, wget, eval, ssh, shells, and other risky command patterns — so rulesync warns whenever it emits a non-empty denylist; add canonical deny rules equivalent to the built-in patterns you want to keep (see the Warp CLI permissions docs). On import, the default execution profile's lists are preferred (falling back to the legacy keys when no collection exists), and a pattern present in both lists resolves to deny (Warp's denylist wins). Both blocks are merged into the existing settings.toml, preserving other Warp settings, and the file is never deleted — nor is it created when nothing maps and it does not exist yet, since a settings file holding no command list would say nothing (an existing one is still rewritten). rulesync owns the command lists (it is the source of truth): they are replaced from the rulesync config on each --global generate, so a manually curated Warp allowlist/denylist not mirrored in .rulesync/permissions.jsonc is overwritten — keep command permissions in rulesync (run rulesync import first to capture an existing hand-curated list). MCP allow/deny is a separate Warp surface not modeled here. See the Warp agent profiles & permissions docs.

Warp-only override (warp key): Warp's [agents.profiles] table also exposes file-read/read-only autonomy knobs that do not fit the per-command allow/ask/deny model — agent_mode_coding_permissions (always_ask_before_reading / always_allow_reading / allow_reading_specific_files), agent_mode_coding_file_read_allowlist (an array of paths the agent may read), and agent_mode_execute_readonly_commands (a boolean auto-executing read-only commands). Add a tool-scoped warp override to author them: its keys are merged into [agents.profiles] (the override wins) while the shared permission block keeps driving the command lists. On import, these keys are lifted from settings.toml into the warp override, so they round-trip faithfully instead of being dropped. These legacy autonomy keys are part of Warp's one-shot migration, so on a migrated install they are inert; their execution-profile counterparts are authored through the nested warp.execution_profile block instead — read_files / apply_code_diffs / execute_commands / mcp_permissions (each agent_decides / always_allow / always_ask), write_to_pty (always_allow / always_ask / ask_on_first_write), ask_user_question (never / ask_except_in_auto_approve / always_ask), run_agents (never_allow / always_allow / always_ask), computer_use (never / always_ask / always_allow), directory_allowlist (paths readable without approval), and mcp_allowlist / mcp_denylist (MCP server IDs). Its keys are merged into the default record of [agents.execution_profiles.<id>] under the same guard as the command lists (only when the collection already exists — creating it would complete Warp's migration early; a warning is logged and the block skipped on an un-migrated install), unknown keys pass through verbatim for forward compatibility (export-only: import lifts back exactly the permission keys listed above, while profile-management keys such as name or the model overrides never round-trip), and the rulesync-owned command_allowlist/command_denylist always win. Example:

json
{
  "permission": { "bash": { "git .*": "allow" } },
  "warp": {
    "agent_mode_coding_permissions": "always_allow_reading",
    "agent_mode_execute_readonly_commands": true,
    "execution_profile": {
      "read_files": "always_allow",
      "directory_allowlist": ["/home/me/projects"],
      "mcp_denylist": ["untrusted-server"]
    }
  }
}

See the Warp settings reference.

For the standalone Warp Agent CLI (target warpcli), this generates the command allow/deny regex lists in the CLI's own settings.toml (global mode only), separate from the Warp app's file: macOS ~/.warp_cli/settings.toml, Linux ~/.config/warp-terminal/cli/settings.toml, Windows %LOCALAPPDATA%\warp\Warp\config\cli\settings.toml. The CLI always runs with the reserved default execution profile, so rulesync writes only the command_allowlist / command_denylist of [agents.execution_profiles.default], converted exactly as for the warp target above (including the warning that writing a denylist replaces the built-in one). Unlike the app, the CLI never read the legacy [agents.profiles] keys and has no settings migration, so no legacy keys are written and the default record is created when absent. The CLI's other execution-profile knobs (read_files, apply_code_diffs, run_agents, …, the same keys as warp.execution_profile) are authored through a tool-scoped warpcli.execution_profile block, merged into the default record before the rulesync-owned command lists, and lifted back into it on import. Every other key of the file is preserved, the file is never deleted, and it is not created when neither a command rule nor a warpcli.execution_profile key maps (an override alone does create it). See the Warp Agent CLI permissions docs.

For deepagents-cli (dcode), this generates the [shell].allow_list array of the user config ~/.deepagents/config.toml (global mode only — dcode reads no project-level config file). dcode auto-approves a shell command when the executable name of every segment of its pipeline is in that list and asks about everything else, matching the first token exactly (no globs) after rejecting command substitution, redirects, and process substitution outright. Canonical bash rules are therefore reduced to their executable: git *, git:*, git commit:*, and a bare git all emit git — every spelling but the first two widens the rule, since dcode holds no arguments to narrow it by, so they are written with a warning. The wildcard pattern * (or its *:* and * * spellings) becomes the sentinel allow_list = ["all"], which auto-approves every command and skips that dangerous-pattern check, so it warns too, and is emitted as the sole entry since dcode rejects the option outright when all shares the list. When the same config restricts anything — a deny or an ask, for commands or for another tool — all is not written at all: turning the dangerous-pattern check off and approving every command unseen, in the name of a rule meant to restrict, would leave you with less than dcode's own default, so the stricter rule wins and the * allow is dropped with a warning. A pattern whose executable still holds a glob (npm-*), a shell metacharacter (git;rm, $(id)), a quote ("git") or an escape (\git), or is longer than the 255 characters any file name can hold, is skipped with a warning — dcode compares the name exactly, so a glob matches nothing, and a name holding a metacharacter, a quote or an escape is one dcode splits on, refuses outright, or reads differently than it is written, as is one that reduces to a sentinel name (all, recommended — use * for "every command"). ask needs no output of its own, since a command outside the list already prompts; deny has no counterpart at all — dcode asks about an unlisted command rather than blocking it — so a deny rule with no allow entry to hold back is reported as skipped. The all-tools * category restricts commands too, so its deny and ask rules are read alongside the bash ones (its allow rules never are — a pattern under * need not be a command at all — so both directions fail closed, and the skipped allow rules are named in a warning): a * deny of rm * beside a bash allow of rm * withholds that allow rather than quietly auto-approving rm. Because an entry auto-approves its executable however it is invoked, an ask or deny on any command that entry would run collides with it — a narrower pattern (npm publish) against an allowed npm *, and equally a pattern naming no executable at all (*delete*, *--privileged*) against an allowed kubectl or docker, since the entry runs those invocations unasked — and dcode has no denylist to settle that collision in — so the colliding allow entries are withheld from allow_list instead: keeping them would auto-approve the very commands the author wanted stopped, while dropping them leaves those executables prompting, which is what an ask asks for and the closest dcode comes to a deny. Only the colliding entries go; allow rules the restriction says nothing about stay. That covers a narrower rule (npm publish or npm* publish against npm *) and a broader one alike (* against every allow, npm* against every allowed name it matches), because canonically the stricter rule wins whatever its width — Rulesync collapses colliding rules as deny > ask > allow, and Claude Code applies deny first, then ask, then allow. A quoted or escaped executable ("git" push, \git push) is read the way dcode reads it — without them — so an odd spelling does not hide the collision. The rule that withheld them is named in a warning, so a deny rule narrower than you meant is visible as the reason a command started prompting. Matching each restriction against what every entry approves is bounded work as well, so a configuration large enough to exhaust that budget treats every allowed executable as colliding and withholds them all — the same fail-closed direction, and the warning says the limit was reached rather than naming a collision nothing compared. On import, entries come back as bash allow rules named by the executable — an entry dcode could not match in the first place (git status, npm-*, anything holding a shell metacharacter, a quote or an escape such as git;rm, $(id) or \git, which dcode splits, rejects or unquotes before it ever compares a name, or a name longer than 255 characters) is skipped with a warning rather than recorded as a permission the tool is not applying, and sentinels are recognized case-insensitively because upstream lowercases them — all as *, and a list mixing all with command names as nothing, since upstream raises on that combination and ignores the option, so those commands are not actually auto-approved. An allow_list holding any non-string element, or one that is not a list or a string at all, imports as nothing too, with a warning: dcode requires every element to be a string and drops the whole option otherwise. A recommended entry is dropped rather than expanded, with a warning of its own, because the curated list behind it is upstream's to edit — the next generate therefore drops those commands from the allowlist, which fails closed, so re-add by name the ones you want. The all-plus-names case warns too, since dcode ignores the whole option there and none of those commands is actually auto-approved. config.toml holds every dcode setting, so the [shell], [startup], [extensions] and [mcp] tables are merged in place and the file is never deleted — nor is it created when nothing maps and it does not exist yet, since a file holding no setting would say nothing (an existing one is still rewritten); the merge is a parse and a re-emit, which keeps every unrelated key and table but does not preserve comments. A shell, startup, extensions or mcp that is not a table at all is left exactly as you have it, with a warning, rather than replaced. See the deepagents-cli configuration docs.

deepagents-only override (deepagents key): dcode's approval mode is a separate axis from the allowlist and has no canonical per-command slot, so it is authored through a tool-scoped deepagents override whose startup block is merged into [startup] verbatim (unknown keys pass through for forward compatibility): mode (manual / auto / yolo, the approval mode a bare launch starts in), yolo_switcher (whether YOLO stays in the Shift+Tab mode cycle), and read_project_dotenv (whether an untrusted repository's .env is loaded into the process environment). On import, exactly those three keys are lifted back into the override, and only when the value is one dcode itself would accept (a known mode, a real boolean for the switches) — anything else is left behind with a warning, because dcode falls back to its own default for it and writing it would produce a permissions file the next generate could not parse. startup.recent is deliberately not lifted at all, since dcode rewrites it as the user cycles modes and committing it would publish one session's state. Generate drops recent for the same reason and one more: with no explicit mode beside it, that key is what restores auto-approval at launch, so writing it would be a second, quieter way for a repository to change a machine's approval mode. Because this block is written into your global config from a .rulesync/permissions.jsonc a repository can carry, a value that relaxes what dcode does on its own — mode of auto or yolo, or a yolo_switcher/read_project_dotenv that was explicitly false being turned back on — is warned about by name, alongside the value it replaces (both booleans default to true upstream, so writing true over an unset key grants nothing and says nothing), and a key rulesync does not know is named too rather than passed through silently.

The same override carries an extensions block, merged into [extensions] the same way. dcode auto-loads *.py from your ~/.deepagents/extensions/ and from a checked-out project's .deepagents/extensions/, so two keys decide whether repository-authored Python is imported into the agent process: enabled (the switch above the whole extension system, true upstream) and trust (ask / always / never, the fallback applied to project extension code when no per-project grant exists, ask upstream). On import, exactly those two keys come back, with trust read the way upstream's parse_trust_policy reads it — trimmed and lowercased, so " Always " is lifted as always rather than dropped — and a value dcode itself would not read is left behind with a warning, as in the startup block. extensions.extra_paths is not modeled: it names machine-local files and directories, which do not belong in a committed permissions file, and it only ever widens what loads — it is not lifted on import, though a hand-written one still passes through on generate like any other unknown key, named in a warning. Because this block too lands in your global config, trust of always, or an enabled that was explicitly false being turned back on, is warned about by name alongside the value it replaces. Example:

The override's mcp block is merged into [mcp] the same way and carries one modeled key, disabled_project_servers: the names of project MCP servers — the ones a project's .mcp.json / .deepagents/.mcp.json declares, which Rulesync's mcp feature writes — that dcode always rejects, a rejection winning over any approval or trust. dcode reads this list from the user-level config.toml only, never from a project file, which is why it is authored here rather than beside the MCP config. It is a canonical-shaped string[]; on import it comes back as the array dcode resolves the value to, so a comma-separated string dcode also accepts ("a, b") is lifted as ["a", "b"], names are trimmed, empty ones dropped, and a non-string element of a list is dropped while the names around it survive — a value that is neither a string nor a list is left behind with a warning, as in the other blocks. The list is written as a whole, so a shorter list from a repository's permissions file would lift a rejection the machine had in place: every name it removes from the existing [mcp].disabled_project_servers is warned about by name. enabled_project_server_approvals — dcode's own store of the project servers you approved at the prompt, each bound to a repository or worktree and a definition fingerprint — is never lifted on import and is dropped on generate, so a repository cannot pre-approve its own servers through a committed permissions file; disabled_servers (the server viewer's toggle) and the deprecated, ignored enabled_project_servers are not modeled and pass through like any other unknown key, named in a warning. Example:

json
{
  "permission": { "bash": { "git *": "allow" } },
  "deepagents": {
    "startup": { "mode": "auto", "yolo_switcher": false },
    "extensions": { "enabled": true, "trust": "never" },
    "mcp": { "disabled_project_servers": ["filesystem"] }
  }
}

For the Antigravity IDE, this generates permissions.allow, permissions.ask, and permissions.deny arrays in the committable workspace .antigravity/settings.json (project mode only). Antigravity 2.0 evaluates these Deny > Ask > Allow and uses action(target) entries; rulesync maps canonical categories onto the IDE action vocabulary: read → read_file, edit/write → write_file, bash → command, webfetch/websearch → read_url, mcp → mcp (the IDE-only execute_url / unsandboxed actions have no canonical equivalent and pass through verbatim). A bash → command pattern is translated, because Antigravity does not read a command target as a glob. It splits the command and the target on whitespace and matches them word by word, and extra words at the end of the command still match. A plain target compares the words literally; a regex: target holds one regex per word, each anchored to its word, so .* never spans a space. Literal words become a plain target (npm run * → command(npm run)). An exact command cannot be expressed: git status becomes command(git status), which also matches git status --short, and an allow or ask warns about it. A glob inside a word becomes a per-word regex (npm run test:* → command(regex:^npm$ ^run$ ^test:.*$)), and each further * word needs one word (docker * * → command(regex:^docker$ ^.*$ ^.*$)). A * with anything after it, in its word or in a later word, can stand for several words, which a word-by-word match cannot say, so git push * --force and rm *.env (which also matches rm -f foo.env) cannot be translated. The same goes for a bracket that lists a space, for a ? or negated bracket that can match one anywhere but at the very end (git?status also matches git status), and for {a,b} alternatives. Such an allow or ask is skipped with a warning (a skipped ask runs without a prompt when Antigravity is set to always proceed). Such a deny fails closed instead: it is widened to its leading literal words with a warning (git push * --force → command(git push), rm *.env → command(rm)), so it also blocks commands it was not meant to. Antigravity evaluates Deny > Ask > Allow, so an overlapping allow such as git push * → command(git push) cannot let git push origin main --force run. Only a deny with no leading literal word (* --force, git?status) is still skipped, and its warning says it is not enforced. A pattern already written as regex:... passes through, unless one of its words is not a valid RE2 regex on its own: a space can split a group (regex:^ls( -la)?$), and RE2 has no lookahead, lookbehind, backreferences or repeats over 1000. Rulesync checks this conservatively and skips such a rule with a warning. Import reverses this: a plain target X reads as X * (or, when it holds *, ?, [ or {, as an escaped regex, since a plain target is literal), and a per-word regex that only uses .* and escapes reads as its glob. File and URL targets are not globs either. A read_file / write_file target is a path (relative to the workspace root, or absolute) and covers everything inside a directory, so src/** → read_file(src), the workspace root (./, ./**) → read_file(.), a plain path is kept, and * / ** become the bare action. A read_url target is a domain and covers its subdomains, so a URL or domain: pattern becomes its hostname (https://example.com/* → read_url(example.com)) and https://* becomes the bare read_url; because the URL path is ignored and subdomains are covered, an allow or ask warns that it covers the whole domain and its subdomains (*.example.com included, since the target also covers example.com itself). Any other glob (**/*.env, src/*) has no Antigravity spelling, so the rule is skipped with a warning — for a deny, the warning says it is not enforced. These targets import verbatim. Because edit/write collapse to write_file and webfetch/websearch collapse to read_url, importing normalizes back to write / webfetch (a documented, lossy mapping). The settings.json file holds other workspace settings, so the permissions block is merged in place — entries for unmanaged actions are preserved — and the file is never deleted. The User-scope settings file is a platform-dependent VS-Code-style path outside rulesync's home-relative global model, so global mode is not supported; the workspace file is intended to be checked into git. See the Antigravity permissions docs.

For the Antigravity CLI (agy), this generates permissions.allow, permissions.ask, and permissions.deny arrays in the global ~/.gemini/antigravity-cli/settings.json (global mode only). The CLI shares Antigravity 2.0's Fine-Grained Permissions Engine with the IDE, so the same action(target) vocabulary and Deny > Ask > Allow precedence apply: read → read_file, edit/write → write_file, bash → command, webfetch/websearch → read_url, mcp → mcp (the engine-only execute_url / unsandboxed actions pass through verbatim). A bash → command pattern is translated, because Antigravity does not read a command target as a glob. It splits the command and the target on whitespace and matches them word by word, and extra words at the end of the command still match. A plain target compares the words literally; a regex: target holds one regex per word, each anchored to its word, so .* never spans a space. Literal words become a plain target (npm run * → command(npm run)). An exact command cannot be expressed: git status becomes command(git status), which also matches git status --short, and an allow or ask warns about it. A glob inside a word becomes a per-word regex (npm run test:* → command(regex:^npm$ ^run$ ^test:.*$)), and each further * word needs one word (docker * * → command(regex:^docker$ ^.*$ ^.*$)). A * with anything after it, in its word or in a later word, can stand for several words, which a word-by-word match cannot say, so git push * --force and rm *.env (which also matches rm -f foo.env) cannot be translated. The same goes for a bracket that lists a space, for a ? or negated bracket that can match one anywhere but at the very end (git?status also matches git status), and for {a,b} alternatives. Such an allow or ask is skipped with a warning (a skipped ask runs without a prompt when Antigravity is set to always proceed). Such a deny fails closed instead: it is widened to its leading literal words with a warning (git push * --force → command(git push), rm *.env → command(rm)), so it also blocks commands it was not meant to. Antigravity evaluates Deny > Ask > Allow, so an overlapping allow such as git push * → command(git push) cannot let git push origin main --force run. Only a deny with no leading literal word (* --force, git?status) is still skipped, and its warning says it is not enforced. A pattern already written as regex:... passes through, unless one of its words is not a valid RE2 regex on its own: a space can split a group (regex:^ls( -la)?$), and RE2 has no lookahead, lookbehind, backreferences or repeats over 1000. Rulesync checks this conservatively and skips such a rule with a warning. Import reverses this: a plain target X reads as X * (or, when it holds *, ?, [ or {, as an escaped regex, since a plain target is literal), and a per-word regex that only uses .* and escapes reads as its glob. File and URL targets are not globs either. A read_file / write_file target is a path (relative to the workspace root, or absolute) and covers everything inside a directory, so src/** → read_file(src), the workspace root (./, ./**) → read_file(.), a plain path is kept, and * / ** become the bare action. A read_url target is a domain and covers its subdomains, so a URL or domain: pattern becomes its hostname (https://example.com/* → read_url(example.com)) and https://* becomes the bare read_url; because the URL path is ignored and subdomains are covered, an allow or ask warns that it covers the whole domain and its subdomains (*.example.com included, since the target also covers example.com itself). Any other glob (**/*.env, src/*) has no Antigravity spelling, so the rule is skipped with a warning — for a deny, the warning says it is not enforced. These targets import verbatim. Because edit/write collapse to write_file and webfetch/websearch collapse to read_url, importing normalizes back to write / webfetch (a documented, lossy mapping). The settings.json holds other CLI settings, so the permissions block is merged in place — entries for unmanaged actions are preserved — and the file is never deleted. Five CLI-only autonomy/sandbox knobs outside the allow/ask/deny arrays can be authored (and round-trip) through an optional antigravity-cli override block in .rulesync/permissions.jsonc: toolPermission (the global autonomy preset — request-review (default) / proceed-in-sandbox / always-proceed / strict), enableTerminalSandbox (a boolean confining agent-run commands to OS containment), artifactReviewPolicy (whether the agent's artifact changes are gated on a review prompt — asks-for-review (default) / agent-decides / always-proceed), allowNonWorkspaceAccess (a boolean, off by default, letting the agent read or write files outside the active workspace roots), and agentMode (the baseline execution mode a session starts in — default / accept-edits / plan). Antigravity applies the allow/deny lists as per-rule exceptions to the preset at runtime, so rulesync authors these keys verbatim as top-level siblings of permissions with no precedence modeling. This override is CLI-only — the Antigravity IDE exposes the same concepts through a GUI with no documented JSON schema, so it does not apply to antigravity-ide. Example: { "permission": { … }, "antigravity-cli": { "toolPermission": "strict", "enableTerminalSandbox": true, "artifactReviewPolicy": "agent-decides", "allowNonWorkspaceAccess": false, "agentMode": "accept-edits" } }. Verified against the Antigravity CLI reference, sandbox docs, settings reference and execution modes. See the Antigravity CLI permissions docs.

For Rovo Dev CLI, this generates the toolPermissions block of config.yml — the global ~/.rovodev/config.yml, and in project mode the repo-committed .rovodev/config.yml that the Bitbucket Cloud Agentic Pipelines guide documents (referenced from bitbucket-pipelines.yml via config.path, or the --config-file CLI flag); the project file is deliberately not gitignored, since committing it is how Rovo Dev permissions get enforced in CI. Rovo Dev's three levels (allow/ask/deny) are an exact 1:1 with rulesync's canonical actions, so action values pass through verbatim. The bash category maps the catch-all * pattern to bash.default and every other pattern to a bash.commands[] entry { command: <pattern as regex>, permission } (Rovo Dev matches commands as regexes, so author bash patterns accordingly). The read category maps to the inspection tools (open_files, expand_code_chunks, expand_folder, grep) and edit/write to the mutation tools (find_and_replace_code, create_file, delete_file, move_file), written under toolPermissions.tools — the depth Rovo Dev documents. (Earlier Rulesync versions wrote them one level up, directly under toolPermissions, where Rovo Dev ignores them; import still reads that legacy shape as a fallback for keys the nested block says nothing about, so an old file is not lost, and a regenerate deletes the stale copies.) Because these per-tool keys hold a single level (no per-pattern rules), only the catch-all * of each category sets the level. Rovo Dev rewrites a single tool key when the user answers "always allow" to one prompt, so the four keys of a category can disagree; import collapses them back onto one catch-all by taking the strictest level (deny > ask > allow) rather than whichever key is read last. Rovo Dev's planning and Atlassian tools split the same way, so they ride the same two categories rather than getting one of their own: read also reaches getJiraIssue and getConfluencePage, and edit/write also reach createJiraIssue, updateJiraIssue, createConfluencePage, updateConfluencePage and createTechnicalPlan (grouped with the mutating tools because it is the planning tool that produces an artifact rather than reading one). Bear that in mind when authoring: an edit: deny reaches Jira and Confluence, not just the working tree. Because edit and write both map onto the same mutation tools, a conflicting catch-all between them cannot be represented; the stricter of the two levels is kept — the same deny > ask > allow rule import uses — and a warning is logged. Non-catch-all allow paths in those categories are surfaced as allowedExternalPaths so explicit grants are not dropped; non-allow non-catch-all rules cannot be expressed per-path and are skipped with a warning. Categories without a clean Rovo Dev target (e.g. webfetch) are skipped with a warning. config.yml holds all of Rovo Dev's settings (agent, sessions, mcp, etc.), so the toolPermissions block is merged in place — every other top-level key is preserved, as is any key inside toolPermissions that Rulesync does not manage — including tools inside toolPermissions.tools that no canonical category maps to, and the toolPermissions.bash sub-keys Rulesync never writes: env (the ${VAR} passthrough that is the documented way to give a CI run a secret whose name matches Rovo Dev's token|key|password|secret|auth|credential filter) and runInSandbox. Only the bash.default and bash.commands leaves are rewritten — the bash map itself is merged, not replaced. Because those sub-keys now survive every generate rather than being cleared by one, treat them as durable state: the project config.yml is committed, so prefer ${VAR} references over literal credentials in bash.env, and note that a runInSandbox: false committed to the file stays in force — a regenerate is not a way to reset it, and says so with a warning when it carries one through. On import, a tool key the file is silent about counts as the implicit fallback level (toolPermissions.default, or Rovo Dev's own ask) rather than as absent, and the category still collapses to the strictest of the set. That matters because Rovo Dev writes a single key when the user answers "always allow" to one prompt: without the fallback, one such answer about create_file would import as a blanket edit: allow, and the next generate would hand that grant to every other tool of the category — Jira and Confluence writes included. A category the file says nothing about at all is still skipped rather than invented.

Migration note. toolPermissions.default and the seven planning/Atlassian keys became Rulesync-owned in the release that added them. Ownership means the first generate after upgrading removes a hand-written value for one of them unless .rulesync/permissions.* produces it — a hand-written tools.createJiraIssue: deny or default: deny with no matching rule in the rulesync source is dropped (with a warning naming each key), falling back to Rovo Dev's ask. Run rulesync import --targets rovodev --features permissions before the first generate to carry those values into the rulesync source.

The canonical all-tools category * maps to toolPermissions.default, the level Rovo Dev falls back to for any tool with no more specific setting (Rovo Dev's own default is ask) — derived from its catch-all exactly as bash.default is derived from bash's, and round-tripped back on import. A * catch-all deny also covers shell commands, so it overrides a bash catch-all ask and bash.default becomes deny (deny > ask > allow). The default is a single level, so a pattern rule inside the * category has no counterpart and is skipped with a warning. The keys Rulesync does manage (default, bash.default, bash.commands, allowedExternalPaths, and the per-tool keys above) are owned rather than merged: each generate rewrites them from .rulesync/permissions.*, so removing a rule there removes it from config.yml too (a source stating no rule at all clears them; one whose rules simply have no Rovo Dev counterpart keeps the block's restrictions but strips its grants — an allow there is normally a leftover of an earlier generate, and dropping one falls back to Rovo Dev's stricter default, whereas clearing the whole block would relax every level), logging a warning naming each owned key it removes — per-tool levels and allowedExternalPaths are written from inside a Rovo Dev session too, by an "always allow" prompt answer and the /directories command, and a hand-edit to one of those keys — including a path added with the in-session /directories command, which writes to allowedExternalPaths — is replaced on the next generate (values only — YAML comments and formatting in the existing file are not retained on rewrite) — and the file is never deleted. See the Rovo Dev CLI settings and tool permissions docs.

For Goose, this generates the user block of the global ~/.config/goose/permission.yaml (global mode only — Goose persists per-tool permission overrides only under the home directory and has no project-scoped permissions file). Goose stores permissions as a YAML map of mode key → { always_allow, ask_before, never_allow }, where each field is a list of tool-name strings; rulesync writes the user-set decisions under the user key. Action mapping is a 1:1: allow → always_allow, ask → ask_before, deny → never_allow. Tool-name mapping: bash → shell; every other category passes through verbatim as the Goose tool name, so edit and write map onto the Developer extension's edit and write tools, and other tools like tree, read_image or an MCP extension's extension__tool round-trip. Goose matches these entries against the public tool name exactly, and the built-in Developer extension exposes its tools unprefixed; the pre-v1.27.0 names developer__shell / developer__text_editor are still accepted on import (developer__text_editor, which both edited and wrote files, as both edit and write) but never written; a literal shell category is written as shell and imports back as bash. The generated names therefore need Goose v1.27.0 or later. Because edit and write are separate Goose tools, an edit rule does not cover write (which creates and overwrites files): author both to restrict file changes. When two categories land on the same Goose tool (e.g. bash and a literal shell), the stricter action wins (deny > ask > allow) and a warning is logged. Because Goose permission lists hold whole tool names rather than per-command/per-path globs, only a category's catch-all * pattern is representable — non-catch-all patterns are skipped with a warning. The permission.yaml file is merged in place: the user block is owned by rulesync, while every other top-level key (notably the smart_approve LLM-decision cache) is preserved, and the file is never deleted — nor is it created when nothing maps and it does not exist yet, since a user block of three empty lists would say nothing (an existing one is still rewritten). See the Goose tool permissions docs.

For the Grok Build CLI (grokcli), this generates Grok's Claude-style [permission] rule arrays — allow / deny / ask — in the project ./.grok/config.toml (project mode) or the user ~/.grok/config.toml (global mode, via --global). Grok documents that "Project configs are limited to MCP servers, plugins, and permission rules, not full user configs" (settings docs), so the fine-grained [permission] rules are valid at both scopes. Each canonical permission.<category>.<pattern> becomes a Grok entry bucketed into the matching array: bash→Bash, read→Read, edit→Edit, grep→Grep, glob→Glob, webfetch→WebFetch, websearch→WebSearch, agent→AgentMessage, and mcp__<server>__<tool>→MCPTool(<server>__<tool>); a * pattern emits the bare tool name (e.g. Bash) and a concrete pattern emits Tool(pattern) (e.g. Bash(git *)). write collapses onto Edit (Grok has no separate Write tool — a documented lossy mapping). Glob is Grok's accepted alias of the Grep filter, so a glob rule is enforced exactly like a grep rule while keeping its own spelling, and on import Glob(...) comes back as glob and Grep(...) as grep. AgentMessage (added in Grok 1.0.10) is the nearest named Grok filter to the canonical agent category, but it governs a narrower operation: it gates only messages sent to an already-running subagent, not the launch of a subagent (Grok checks a Task launch as an Edit access on an internal task:<type> key, which Rulesync does not target). Its pattern is matched against the target subagent id (as a glob or a prefix) rather than the subagent type a Claude Code Agent(...) rule names, so a canonical agent rule does not stop Grok from starting subagents — it only controls steering messages to them; write an id-shaped pattern when targeting Grok. Grok's legacy spellings SendSubagentMessage(...) / SendAgentMessage(...) are read on import and re-emitted as AgentMessage(...). The one category with no Grok tool (notebookedit) is skipped, with a warning when it carries a deny rule. Grok evaluates the arrays with precedence deny > ask > allow, which import mirrors (a tool listed in multiple arrays resolves to the strictest action). The coarse [ui] permission_mode toggle ("ask" / "always-approve") is still written as a backward-compatible fallback for older Grok versions: always-approve when the config is pure-allow, otherwise ask (conservative — never always-approve while any deny/ask rule exists, so it never contradicts the fine-grained arrays). Grok's third mode, auto (classifier-based, toggled in the TUI with /auto), is an exception in both directions: nothing in the canonical model derives it, so Rulesync never writes it — and a config.toml that already selects it keeps it, since overwriting would silently downgrade the user to ask on every generate --global. The fine-grained arrays are still written in that case; only the coarse toggle is left alone. On import, both documented [permission] forms are parsed back into canonical categories: the compact allow/deny/ask arrays and the verbose [[permission.rules]] tables ({ action = "allow", tool = "bash", pattern = "git *" }). The verbose tool field is documented lowercase (any/bash/edit/read/grep/mcp/webfetch/websearch/agent_message) while the compact entries are capitalized, so it is matched case-insensitively and mcp folds into the canonical mcp__… categories exactly as MCPTool(…) does; a rule with no pattern covers the whole tool. Rules from the two forms merge with the same deny > ask > allow precedence, and a rule naming a tool with no canonical category (e.g. any) is skipped. Only when neither form carries a rule do we fall back to the coarse mode (always-approve ⇄ bash: { "*": "allow" }, ask/unset ⇄ bash: { "*": "ask" }). Generate always writes the compact arrays. config.toml is shared with the MCP feature, so rulesync owns the [permission] allow/deny/ask arrays and [ui] permission_mode while every other key (e.g. [mcp_servers], [sandbox]) is preserved, and the file is never deleted — including a hand-authored verbose rules array, which is read on import but left untouched on generate rather than reconciled against the arrays rulesync writes. Migration: a config.toml written by an earlier Rulesync may carry hand-authored WebSearch, Glob, AgentMessage (or legacy SendSubagentMessage / SendAgentMessage) entries that were preserved verbatim as unmanaged; they are now parsed into the canonical websearch / glob / agent categories and treated as Rulesync-owned on generate, so an entry without a matching canonical rule is dropped rather than kept verbatim. See the Grok CLI settings reference and modes docs.

For Vibe (mistral-vibe), this generates per-tool [tools.<tool>] tables in the shared .vibe/config.toml (project mode) or ~/.vibe/config.toml (global mode). Tool-name mapping: bash → bash, read → read_file, edit → edit, write → write_file, webfetch → web_fetch, websearch → web_search, grep → grep, agent → task. These are Vibe's builtin tool names (BaseTool.get_name(), the snake_case of each tool class); edit and write_file are distinct tools — write_file has been create-only since v2.14.0 — so the two canonical categories no longer collapse onto one name. Migration: a config.toml written by an earlier Rulesync may still carry write_file entries derived from the edit category, or inert [tools.fetch] / [tools.search_web] / [tools.agent] blocks. Rulesync only rewrites the names it now emits, so remove those stale entries by hand — a leftover disabled_tools = ["write_file"] keeps Vibe's write_file disabled even though no canonical rule asks for it, and inert [tools.glob] / [tools.notebookedit] tables an earlier Rulesync emitted for tools Vibe does not have stay on disk until removed by hand (new generates skip those categories instead of rewriting them). Within a category, the catch-all * pattern sets the per-tool permission (allow → always, ask → ask, deny → never); a wildcard deny additionally adds the tool to the top-level disabled_tools filter. A wildcard allow deliberately does not touch the top-level enabled_tools key: upstream treats it as an exclusive allowlist (“if set, only these tools will be active”), so expressing allows through it — as earlier Rulesync versions did — silently switched off every other builtin and MCP tool; the per-tool permission = "always" entry carries the allow completely, and a regenerate now removes the exclusive entries an earlier version wrote for the tools it configures (disabled_tools entries, by contrast, are only cleared by a category that actually states a base permission with a * rule — clearing them for a category holding pattern rules alone would silently promote a disabled tool to “enabled, with a denylist”; on import, a disabled_tools entry likewise wins over a [tools.<name>] table that contradicts it — permission and patterns alike are skipped for that tool, because Vibe applies the filter last and unconditionally: reading the table's permission = “ask” (or always plus an allowlist) as the tool's real state would import a hand-written contradiction as a mere prompt and drop the filter on the next generate, and importing one of its patterns as an allow would carry a hole through a switched-off tool into every other tool's generated config. The match is on Vibe's own tool name rather than the canonical category, since name_matches globs the raw name: a bare disabled_tools = [“read”] matches no builtin upstream, so it must not silence [tools.read_file]. The entry itself is still imported as a deny for its canonical category, because Rulesync cannot see Vibe's registry — an MCP server may well publish a tool named exactly read — and denying something that is already off costs nothing where dropping a real deny would. The match follows upstream's name_matches, which trims each entry, skips a blank one, and then applies a case-insensitive fnmatch glob over the raw name — so disabled_tools = ["read_*"], ["READ_FILE"] and [" read_file "] all silence [tools.read_file], while [""] silences nothing and is dropped rather than imported as an empty category: the canonical read category is denied on import, beside the literal read_* entry, which is carried out verbatim so the glob keeps its reach over the tools Rulesync cannot enumerate — an MCP tool registered at run time. A re:-prefixed entry, which Vibe matches as a Python regular expression, is deliberately not evaluated: Python's regex dialect is not JavaScript's, and a wrong verdict either drops a table's rules or carries a disabled tool out as an allow, so such an entry is matched only by its exact spelling and a warning names the tables that were therefore imported as authored; a glob longer than 256 characters is left alone and reported the same way, since a tool name is short in every registry Vibe reads and matching a kilobyte-long entry against a kilobyte-long table name buys nothing. A glob that is resolved is walked step by step against fnmatch's own rules rather than translated into a JavaScript regular expression: the two dialects disagree about exactly the bracket corners (a ] straight after [ is a member to fnmatch and an empty class to JavaScript; an inverted range such as [z-a] is not dropped upstream but closed over, taking the member before it and the member after it with it, so [z-a] matches nothing, [!z-a] matches everything, [b-ax] is just [x], and [a--!] collapses to a bare ! that then negates an empty class and matches everything), and a translated *a*a*a*b backtracks catastrophically against a long tool name where the walk stays linear per *. The deny patterns of a table the filter switches off are carried across even though its allow patterns are not: dropping them would lose a deny the file states outright, which is the broadening direction. Where a canonical category is shared — read_file and a bare read MCP table both land on read, as do task and agent — the blanket deny is re-asserted after every table has been read, so the category is denied whichever order the tables happen to sit in; letting the later table decide made the import depend on that order, and in one of the two orders a tool the file had switched off was imported as a live allow. On generate, a glob left in disabled_tools is for the same reason never deleted to make room for an allow Rulesync just wrote — it reaches tools Rulesync cannot see, and removing it would switch those back on — so the contradiction is reported with a warning instead, since Vibe applies the filter last and unconditionally — the warning names only the globs that actually reach a table Rulesync wrote, and an entry Rulesync does not work out — a re: regular expression, or a glob past the 256-character cap — gets a warning of its own asking you to check it by hand rather than being listed beside tables some other glob matched. Both warnings print each name with its control characters stripped and its width capped at 80 terminal columns, and name at most ten before counting the rest, since the text comes from a config.toml that may have been checked in by someone else); specific patterns become allowlist / denylist entries — these are the keys Vibe's permission engine actually reads (BaseToolConfig), so the legacy allow / deny keys are dropped on generate from every table a category writes (still honored on import). A table carrying both spellings holds one list Vibe enforces and one it ignores, so both are read as a single list and written back under the canonical key: preferring either alone would drop the other, and on the deny side that silently discards a restriction Vibe was applying — multiplied across all three shell tables by the fan-out below. The two sides are not symmetric: reading both deny lists can only restrict further, while reading both allow lists promotes a pattern Vibe was ignoring into one it enforces (an allowlist match is unconditionally allowed even under permission = “never”), so that promotion is announced with a warning naming the patterns — delete them from config.toml if the legacy list was stale. Vibe has no per-pattern ask, so pattern-level ask rules are skipped with a warning. A canonical category with no Vibe builtin tool at all (e.g. glob, notebookedit) is likewise skipped with a warning instead of emitting an inert [tools.<category>] table — a deny written there would look applied while Vibe ignores it. The all-tools * category is skipped for a different reason: Vibe's config has per-tool tables only, and while disabled_tools does match glob patterns (name_matches), an entry there removes the tool from the registry outright rather than acting as a default the sibling [tools.<name>] tables can override — so disabled_tools = ["*"] would silently swallow every allow authored next to the wildcard. A category that is not a canonical Rulesync category is taken at face value as a Vibe tool name and written to [tools.<name>]: Vibe's tool manager resolves that table for every registered tool rather than only the builtins, so this is how per-MCP-tool permissions are authored, and vibe.permission.<name>.sensitive_patterns works there too. Because Vibe publishes an MCP tool as <server>_<tool>, the cross-tool canonical spelling mcp__<server>__<tool> is translated to that name on generate (mcp__github__create_issue → [tools.github_create_issue]; only the first __ is split, so a tool name may itself contain one). The translation is one-way — Vibe's name carries no mcp marker — so import keeps github_create_issue as the category; regenerating from it writes the same table. A server-scoped mcp__<server> category is skipped with a warning, since Vibe has no server-level permission table; so is a wildcard one (mcp__<server>__*, mcp__*__<tool>), because a [tools.<name>] lookup is exact even though disabled_tools entries are glob-matched, which would make [tools."github_*"] an inert deny. Since a non-canonical name is written verbatim, a misspelled builtin (powershel) becomes an inert table; case mistakes (Bash), a mis-cased MCP prefix (MCP__github__x) and a glob in the name (github_*, whose pattern-level rules Vibe never looks up because [tools.<name>] is matched exactly) are the ones detectable without guessing, and those are warned about. The bash category is written to three tables — bash, git_bash and powershell — because Vibe's managed shell is a different tool on each platform: the POSIX one publishes bash, but on Windows it is git_bash or powershell, so a bash deny landing only on [tools.bash] left the shell fully allowed there. Author git_bash or powershell as its own category in the shared permission block to override the fan-out for that shell. A vibe.permission.<shell> entry does not claim it for the base permission: that block carries sensitive_patterns only, so treating it as a claim would strip the bash permission from the shell without putting anything in its place — its patterns are merged onto the fanned-out table instead, and a vibe.permission.<shell> entry does keep vibe.permission.bash's patterns from overwriting the ones authored for that shell. A shell category that expresses nothing Vibe can read (only pattern-level ask rules, or no rules at all) likewise does not claim the shell, since it would otherwise cancel the bash deny and write nothing in its place. The fan-out also stands down for a shell the existing config.toml already configures differently from [tools.bash], and warns when it does — that is a permission decision made outside the bash category, and overwriting it could broaden a permission = "never" into whatever the canonical bash category says. Standing down costs that shell only its base permission and allow patterns: the bash category's deny patterns are still merged into the shell's denylist (and its legacy deny key folded into the canonical one), because Vibe resolves a denylist match before the allowlist and before the configured permission, so those entries can only restrict the shell further — leaving them out would let a deny you authored go silently missing on one of the three shells. A shell that exists only as a disabled_tools entry is skipped there: it is off the registry entirely, so a denylist for it would be inert, and writing one would invent a table you never authored. A shell that has its own category in the shared permission block is neither stood down from nor reported: that category owns the table outright. The one exception is a bash category whose * rule is a deny: that cannot broaden anything — the shell ends up disabled outright, which is at least as strict as whatever it held — and standing down there would leave an authored deny silently absent from one of the three shells, the exact failure the fan-out exists to prevent. A wildcard deny therefore overwrites the shell (with a warning, which also says that the shell's own allowlist / denylist entries are replaced along with its permission — the shell ends up disabled outright, so nothing it held is still enforced). Author that shell as its own category to keep a different permission for it. Only the permission keys the fan-out itself mirrors take part in that comparison (permission, allowlist, denylist — with the legacy allow / deny spellings normalized to the canonical ones — plus disabled_tools membership), so a key outside it on [tools.bash] — a timeout, say, which is carried over for bash but never copied to the aliases — does not make Rulesync's own output look hand-authored and freeze the fan-out on the next generate. sensitive_patterns is outside it for the same reason: that key is written by the vibe.permission pass, which addresses each shell by name, so a vibe.permission.git_bash entry legitimately leaves that shell holding patterns [tools.bash] does not have — counting it would read Rulesync's own output as an outside decision and freeze the fan-out, and mirroring it would overwrite the per-shell patterns you asked for. It is still filled in where a shell has none of its own, so a hand-authored [tools.bash] sensitive_patterns guard travels with the permission it guards instead of the shells receiving the bare allow. The copy keeps the order it was authored in: [tools.bash]'s own list is a key Rulesync does not write, so it is left alone, and sorting only the copy made one guard read as two different lists across the three shells. The fill is one-way — it never clears a shell's patterns — so deleting the [tools.bash] guard afterwards leaves the copies behind on the Windows shells; that asymmetry is reported with a warning, because an import would otherwise read them as per-shell rules and write vibe.permission.<shell>.sensitive_patterns entries you never authored, which claim those shells out of the fan-out for good. Values are compared as sets, not as written: denylist = ["b", "a"] beside denylist = ["a", "b"] is the same decision, and treating the order as a divergence would strand a bash deny on a shell that already agrees with it. A table holding none of those keys — or holding one only as an empty list, which is the absent key spelled out (and which Rulesync drops from its output rather than carrying over) — states no decision and is fanned out over; a key whose value is not a list at all (denylist = “rm -rf *”) does count as a decision, even though Vibe's own BaseToolConfig types it as list[str] and fails to load the file — of the two ways to be wrong about it, deleting the key is the one you cannot recover from (the bash deny merge stands down from such a table too, with a warning, since merging into a non-list would replace it rather than add to it — and in that case, as when the bash category carries no deny patterns at all, the stand-down warning says that nothing from the category reaches the shell rather than promising a merge that does not happen); the stand-down warning is likewise only raised when there is a bash permission to stand down — when only a vibe.permission.bash entry exists, the warning says that its sensitive_patterns are what does not reach the shell. The fan-out is also skipped when the bash category itself expresses nothing Vibe can read: with no permission to spread, mirroring would push whatever [tools.bash] already carries onto shells the file never configured, silently broadening them. When the fan-out does run it mirrors [tools.bash]'s permission keys onto the two aliases rather than merging each alias with its own previous contents (their unmanaged keys are kept): merging would diverge them the first time [tools.bash] carried an entry the aliases lacked — which Rulesync's own output does as soon as a hand-authored denylist is merged into bash — and that divergence would then stand the fan-out down forever, stranding every later bash deny on POSIX. Mirroring is safe precisely because a shell stating a decision of its own has already been excluded, and it makes regenerating idempotent. disabled_tools membership is mirrored too, for the same reason. enabled_tools membership is deliberately excluded: it is an exclusive registry filter rather than a permission, and counting it let enabled_tools = ["powershell"] take that shell out of a bash deny while leaving it the only active tool. On import, a Windows shell table identical to bash is collapsed back into the single bash category, while one that differs is kept as its own. Unknown [tools.*] tables already on disk still round-trip untouched. The config.toml file is shared with the MCP feature, so writes merge non-destructively and the file is never deleted. See mistral-vibe (vibe/core/tools/base.py).

Vibe-only override (vibe key): Vibe's BaseToolConfig also carries a sensitive_patterns list — patterns that escalate to ASK even when the base permission is ALWAYS (allow). The canonical model can only set a pattern to a single allow/ask/deny, so an "allow by default but ask on these patterns" escalation cannot be expressed in the shared block. Add a tool-scoped vibe override to author it: vibe.permission.<category>.sensitive_patterns carries the list per canonical category (e.g. bash, edit), while the shared permission block still sets the base permission and allow/deny lists. On import, a tool's sensitive_patterns round-trips back into the vibe override (the base allow stays in the shared block). rulesync owns the list for any category named in the override (a present list is set, an empty one clears it); categories not named keep whatever the existing config.toml had. A vibe.permission.bash entry is fanned out to the Windows shells like the base permission is, but where a shell's config.toml table already sets different patterns of its own it merges into them instead of replacing them, and warns that it did: those patterns are what still escalates to ASK once the base permission becomes ALWAYS, so they are the author's only remaining defense there, while dropping the override's patterns would strand a newly added guard on that one shell. A union is safe in both directions because sensitive_patterns only ever escalates to ASK. An empty sensitive_patterns = [] counts as no patterns of the shell's own — it is the absent key spelled out, and Vibe's own config dump writes it that way — so the override's list is applied there in full. The reverse case is the exception to “an empty list clears it”: an empty vibe.permission.bash.sensitive_patterns clears the patterns this override owns, but a shell that authored different patterns of its own keeps them (with a warning saying so) — delete them from config.toml to clear them. Name that shell in vibe.permission.<shell>.sensitive_patterns to own its list outright. sensitive_patterns is the only key this override can express — a permission, allowlist or denylist written inside vibe.permission.<category> is ignored with a warning, since the shared permission block is where those belong and the base permission it sets can be the exact opposite of what the ignored key asked for. The override also carries vibe.enabled_tools — the only way to author Vibe's top-level exclusive allowlist. The list is written verbatim in Vibe's tool-name vocabulary (declaring it, even empty, makes rulesync own the whole key), and on import a non-empty enabled_tools is lifted back into the override rather than being misread as a set of "*": "allow" grants. Note the config.toml scope semantics: since v2.24.0 Vibe installs the user and project TOML layers together rather than picking one, so a trusted project config overlays the user config instead of replacing it, and a --global run is no longer discarded wholesale by a project config.toml — the project layer still wins key by key where it sets one, but every key it leaves unset falls through to the global file. Merging is per key: mcp_servers and connectors union-merge by name, tools deep-merges, disabled_tools concatenates, and enabled_tools is replaced wholesale by the higher layer. An org-enforced AdminConfigLayer sits above every layer at runtime and can override anything below it. (Whether a project layer is read at all still depends on Vibe trusting the project, so treat the overlay as the trusted-project behavior.)

json
{
  "permission": { "bash": { "*": "allow" } },
  "vibe": { "permission": { "bash": { "sensitive_patterns": ["rm *", "sudo *"] } } }
}

For Takt, this generates the default_permission_mode under provider_profiles.<provider> in the shared .takt/config.yaml (project mode) or ~/.takt/config.yaml (global mode). Takt does not have per-tool / per-pattern rules; tool gating is a single coarse mode per provider profile, ordered readonly < edit < full (readonly may only read, edit may also edit/write files, full may also run shell commands). The active provider is resolved from runtime.yaml first when Takt is in runtime provider mode (see below) — the provider of the profile named by provider.defaults.profile, with no fallback, matching Takt (a lone profile is not promoted to the default, and defaults.pool/ladder forms resolve at run time) — and otherwise from the top-level provider: key of config.yaml, the sole provider_profiles entry, or the claude default. provider_profiles itself stays in config.yaml in every version: it is not a legacy provider signal, and Takt does not move permission modes into runtime.yaml. The mapping is therefore lossy: on generate, a single mode is derived with this precedence — (1) any deny rule anywhere ⇒ readonly (conservative — keep the narrowest mode whenever the user expressed any restriction); (2) else any edit/write category allow rule ⇒ edit; (3) else any bash category allow rule ⇒ full; (4) else ⇒ readonly (safe default). On import, full ⇄ bash: { "*": "allow" }, edit ⇄ edit: { "*": "allow" }, and readonly (or an unset/unknown mode) ⇄ bash: { "*": "deny" }. config.yaml is shared with other Takt settings, so the mode is merged in place — every other provider profile and all other top-level keys are preserved — and the file is never deleted. Takt's default-deny workflow security policies — workflow_arpeggio (custom_data_source_modules, custom_merge_inline_js, custom_merge_files), workflow_runtime_prepare.custom_scripts, workflow_command_gates.custom_scripts, sync_conflict_resolver.auto_approve_tools, and the allow_git_hooks / allow_git_filters booleans — have no canonical permission category, so they are authored through the takt override block of .rulesync/permissions.* and round-trip on import. Each admits one class of user-supplied code, so only the exact shapes Takt itself accepts are written: a sub-key Takt does not declare is dropped with a warning rather than passed through, since Takt's schemas are strict and reject the whole file on an unknown key, while a value of the wrong type fails when .rulesync/permissions.* is read. Removing one of these keys from config.yaml because the source no longer states it is warned about too — including a key put there by hand, which owning them implies. Deleting .rulesync/permissions.* altogether is different: the feature has no source to generate from, so nothing runs and whatever is in config.yaml stays. These keys are also authoritative rather than merged — revoking one in .rulesync/permissions.* removes it from config.yaml, instead of leaving the capability switched on. workflow_mcp_servers stays with the MCP feature, which derives it from the transports in use.

Two Takt-specific surfaces with no canonical category can be authored (and round-trip) through an optional takt override block in .rulesync/permissions.jsonc: step_permission_overrides (a per-workflow-step map <step> ⇒ readonly/edit/full, written inside the active provider profile and layered by Takt on top of default_permission_mode) and provider_options (a top-level, per-provider table of sandbox/network knobs orthogonal to the mode, e.g. codex.network_access, claude.sandbox.allow_unsandboxed_commands, opencode.allowed_tools). Example: { "permission": { … }, "takt": { "step_permission_overrides": { "ai_review": "readonly" }, "provider_options": { "codex": { "network_access": true } } } }. Note the workflow-step required_permission_mode floor is a field of the workflow YAML, not config.yaml, so it is intentionally out of scope (Takt's config loader hard-rejects unknown top-level keys).

provider_options and Takt 0.56.0 (runtime.yaml). From 0.56.0, provider configuration lives in runtime.yaml (.takt/runtime.yaml for the project, ~/.takt/runtime.yaml globally), and "runtime provider mode" is active as soon as its provider: section carries an actual assignment — a non-empty defaults, profiles or auto_routing, a targets map with at least one non-empty entry, or a named assignments set with a non-empty defaults or such a targets map. A file holding only version: 1, or empty maps such as defaults: {}, is inactive and leaves the legacy resolution in place. Companion reviewers are opt-in (companion.enabled defaults to false), and while they are off Takt evaluates the section without its targets.companions maps and without the profiles only those reach through extends — so a file that merely routes companions is inactive too, and a profile is ignored (its options are not lifted on import) unless an agent assignment also reaches it. While runtime mode is active, any legacy provider setting in config.yaml — provider_options among them — stops Takt with Mixed provider configuration detected before it runs an agent, and Takt generates an active ~/.takt/runtime.yaml on first launch in a fresh environment, so new installs are in runtime mode by default. Rulesync therefore reads runtime.yaml — both the scope being generated and the global one, because Takt collects legacy signals from both config.yaml files — and merges them the way Takt's loader does before deciding anything: provider.profiles and provider.assignments are unions in which a project entry replaces the global entry of the same name, while defaults, targets and auto_routing are taken from the project file whole whenever it states them at all, so a project targets: {} masks the global one rather than merging with it; companion.enabled is on only when neither file turns it off. On that merged document, while runtime mode is active, rulesync refuses to write provider_options into config.yaml, warning instead of quietly emitting a key that would take the install down. Rulesync does not write runtime.yaml itself: a profile is provider- and scope-specific, and Takt replaces a same-named profile wholesale across scopes rather than merging it field by field, so there is no key rulesync could own there without clobbering the user's provider and model. Author those options yourself under provider.profiles.<profile>.options in runtime.yaml — a flat bag applying to that profile's own provider, so the codex: / claude: segment of provider_options is dropped — and remove provider_options from the takt block of .rulesync/permissions.*. Anything already written into config.yaml by hand is left untouched; rulesync does not own that key. On import, both sides are read: the legacy provider_options table and, in runtime mode, each profile's options from the runtime.yaml of the scope being imported (import stays inside the tree it was pointed at), re-keyed by the profile's own provider (the runtime side wins on a collision), so nothing is lost. One consequence worth knowing: importing a runtime-mode install produces a takt.provider_options block that a later generate will refuse and warn about — drop it from the rulesync source once the options are settled in runtime.yaml. Installs with no runtime.yaml, or an inactive one, keep the pre-0.56.0 behavior unchanged: provider_options is written to config.yaml exactly as before.

See the Takt configuration docs.

For Amp, this writes to the shared .amp/settings.json (project mode) or ~/.config/amp/settings.json (global mode), using two permission surfaces. In rulesync's canonical model the category name is the Amp tool name. A whole-tool deny (pattern *) is written to the bare amp.tools.disable array (the tool name is pushed verbatim, preserving builtin: prefixes and the * glob) for backwards compatibility. Every lossy case is written to the ordered amp.permissions array instead of being dropped: an argument-specific deny (pattern !== "*") becomes { tool, action: "reject", matches: { cmd: <pattern> } }, and every allow / ask rule becomes { tool, action, matches?: { cmd } } (the matches object is omitted for the * catch-all). Amp evaluates amp.permissions first-match-wins, so generated entries are ordered deterministically and fail-closed: sorted by tool name, then entries with matches.cmd (more specific) before catch-alls, then by action priority reject < ask < allow, then by cmd. amp.permissions is Amp's legacy / backwards-compatibility surface (gone from the web docs, see below) — it remains functional and is the only place to express allow/ask and argument-specific reject rules. Ownership: rulesync OWNS and wholesale-replaces the allow/ask/reject entries on every generate, but preserves any existing action: "delegate" entry (rulesync's canonical model has no delegate equivalent); preserved delegate entries are placed after the rulesync-generated entries (so the regenerated rules take precedence under first-match-wins). On import, both keys are read and merged into one canonical config: amp.tools.disable[tool] → { tool: { "*": "deny" } }, and each amp.permissions entry → { tool: { (matches?.cmd ?? "*"): mapped } } (reject → deny, allow → allow, ask → ask; delegate is skipped). When both sources target the same tool+pattern, the most restrictive action wins (deny > ask > allow). The settings file is shared with the MCP feature (amp.mcpServers), so all other keys are preserved on round-trip and the file is never deleted. Tool names and cmd patterns that are prototype-pollution keys (__proto__, constructor, prototype) are skipped defensively. The whole settings document is also recursively sanitized on read, by both the permissions and the MCP adapter: every object is rebuilt from its own entries, so a __proto__ key cannot swap an object's prototype and constructor / prototype never survive as own keys. A root __proto__ is therefore stripped and the rest of the file is read normally, rather than failing the "must be a JSON object" check as it did before sanitization was added — the surrounding settings are kept, and only the poisoned key disappears.

Amp shapes with no canonical category are authored (and round-trip) through an optional amp override block in .rulesync/permissions.jsonc: permissions — extra amp.permissions entries with non-cmd matchers (path/url/query/…), regex/array match values, context (thread/subagent), delegate (+to), or reject (+message), appended after the canonical-generated entries (so generated allow/ask/reject rules take precedence under first-match-wins, with authored entries as later fallbacks); mcpPermissions — Amp's amp.mcpPermissions array; guardedFiles — amp.guardedFiles.allowlist (globs allowed without confirmation); and dangerouslyAllowAll — amp.dangerouslyAllowAll. When the override authors permissions it becomes the source of truth for the extra entries; otherwise any hand-authored delegate entry in the existing file is preserved. On import, amp.permissions entries that are not canonical-expressible (non-cmd matcher, delegate, reject+message, context) are lifted verbatim into amp.permissions of the override rather than dropped. Example: { "permission": { … }, "amp": { "dangerouslyAllowAll": false, "guardedFiles": { "allowlist": ["docs/**"] }, "permissions": [{ "tool": "Bash", "action": "delegate", "to": "approve.sh" }] } }. Note that the Permissions section of Amp's web docs now only points at custom plugins and no longer describes amp.permissions, amp.guardedFiles.allowlist or amp.dangerouslyAllowAll; the shipped CLI still honors them and still lists all three in its built-in settings reference (amp --help), alongside the amp permissions subcommands. amp.mcpPermissions (like amp.tools.disable) remains in the web CLI settings reference. See the Amp permissions docs and the plugin permissions example.

For JetBrains Junie CLI, this generates the Action Allowlist rules object in ~/.junie/allowlist.json (global mode only — Junie CLI resolves exactly one allowlist path under its home directory and never reads a project-scope .junie/allowlist.json; verified against release 2383.10). Junie evaluates the allowlist top-to-bottom (first match wins) and groups rules into buckets, onto which rulesync categories map: bash → executables, edit/write → fileEditing, read → readOutsideProject, mcp → mcpTools. Every rule group is written as Junie's AllowListRuleSet object — { "default"?: "allow"|"ask", "rules": [ … ] } — never a bare array: Junie's parser rejects the array form for the whole file and then discards and overwrites allowlist.json, so the shape matters. Earlier rulesync versions emitted the array form; it is still tolerated on import, but only the object form is generated. Each rule carries an action plus either a literal prefix (matches commands that start with it) or a glob pattern (*, **, ?, [abc], [!abc]); rulesync emits pattern when the canonical pattern contains a glob metacharacter (*, ?, [) and prefix otherwise. Junie accepts only allow and ask as actions — there is no deny (a deny fails the whole-file parse) — so a canonical deny is downgraded to the nearest valid action, ask (which still withholds auto-approval), with a warning (allow/ask map 1:1). Categories Junie cannot represent (e.g. webfetch, websearch) are skipped with a warning when they carry rules. rulesync owns each mapped group's rule list (replaced on each generate), while a per-group default and the whole readSecretFile group — which restricts what Junie may read — are preserved from the existing file when not authored via the junie override below. Because edit/write both collapse onto fileEditing, importing normalizes back to edit (a documented, lossy mapping). The allowlist.json file is never deleted. See the Junie Action Allowlist docs.

Junie-only override (junie key): Junie's allowlist.json has settings with no canonical per-glob slot — the top-level autonomy knobs allowReadonlyCommands (a boolean auto-allowing read-only commands) and defaultBehavior (the fallback action when no rule matches; an allow/ask enum — Junie's AllowListDecision accepts nothing else, and an invalid value fails the whole-file parse), plus two group-shaped settings: readSecretFile (the fifth rule group, restricting reads of secret files — canonical read is already taken by readOutsideProject, so this group is authored whole as { "default"?, "rules": [ … ] }) and ruleDefaults (each mapped group's own fallback action, e.g. { "executables": "ask" }). Add a tool-scoped junie override to author them: the scalar knobs are merged onto the top level of allowlist.json (the override wins) while the shared permission block keeps driving the mapped groups' rule lists, and the group-shaped settings land inside the rules object. On import, all of these are lifted from allowlist.json into the junie override, so they are authorable and portable instead of only round-trip-preserved. Any other unmodeled top-level key is preserved verbatim. Example:

json
{
  "permission": { "bash": { "git ": "allow" } },
  "junie": {
    "allowReadonlyCommands": true,
    "defaultBehavior": "ask",
    "ruleDefaults": { "executables": "ask" },
    "readSecretFile": { "rules": [{ "pattern": "**/.env", "action": "ask" }] }
  }
}

For Reasonix, this generates permissions.allow, permissions.ask, and permissions.deny arrays in the [permissions] table of the shared reasonix.toml (project mode) or ~/.reasonix/config.toml (global mode) — the same TOML file the MCP feature's [[plugins]] array-of-tables lives in. The rule syntax mirrors Claude Code's: entries are Bash(<pattern>), Read(<pattern>), Edit(<pattern>), Write(<pattern>), WebFetch(<pattern>), WebSearch(<pattern>), Grep(<pattern>), Glob(<pattern>), NotebookEdit(<pattern>), Agent(<pattern>), etc. (Reasonix's SPEC.md documents these as "Claude Code-style" families; agent → Agent is the one lower-confidence mapping, since Reasonix's own delegation tool is internally named task). [permissions].mode (the writer fallback: ask/allow/deny) has no canonical rulesync equivalent and is preserved untouched. Note that Reasonix narrows a project reasonix.toml before merging it (narrowPermissions in project_scope.go, on both the v1 and v2 lines): project allow, mode and allow_dynamic_bash are user-only and are replaced by the user config's values, while project ask and deny merge additively. A project-mode allow entry (and the allowDynamicBash override below) therefore only takes effect when the user config (--global) carries the same value — or, on v2, when the user has granted it to that workspace in Reasonix's own project-grant store — and Reasonix reports the rest as ignored; see project_scope.go (v2.31.0) and (v1.39.8). The TOML file is shared with the MCP feature, so writes only replace the permissions table — every other table ([[plugins]], [agent], [ui], …) is preserved on round-trip, and the file is never deleted. See SPEC.md §3.7 Permissions.

Reasonix-only override (reasonix key): Reasonix has security axes orthogonal to per-tool allow/ask/deny with no canonical category — the [sandbox] enforcement table (workspace_root, allow_write, forbid_read, bash = enforce/off, network) and the plan-mode read-only command list under [agent] (plan_mode_read_only_commands, which upstream keeps for legacy compatibility only — Plan bash goes through Permissions now). Its sibling plan_mode_allowed_tools left the documented config surface in v1.17.18: an existing value is still lifted out of [agent] on import, so it does not vanish from an imported config, but whenever the override writes [agent] the key is removed from the file with a warning — including a value already there, since leaving that one alone would mean narrowing the list is the one edit that never lands. Add a tool-scoped reasonix override to author them: reasonix.sandbox and reasonix.agent are shallow-merged into the matching reasonix.toml table at its top level (override keys win, unrelated sibling keys such as [agent].model are preserved), while the shared permission block keeps driving [permissions].allow/ask/deny. The [sandbox] values that loosen the enforcement layer under the permission policy — a bash set to anything but enforce, a network that is not false, a non-empty allow_write, and a workspace_root that is anything but a plain relative path inside the project — an absolute path in POSIX or Windows form, a drive-lettered one such as C:temp, a home-relative one, a path holding a .. segment, or one carrying a $… / %…% expansion whose target cannot be known here; as with the other keys, a value that is not the type Reasonix documents is reported rather than assumed harmless — are written, but named in the same one-warning-per-file summary rulesync gives Claude Code's and Devin's trust-affecting sandbox paths. Because the merge replaces forbid_read whole rather than accumulating it, that summary also names the read protections a generate would drop: a forbid_read that loses an entry the file had, one the file holds as something other than a list so what it blocked cannot be read, and a [sandbox] the file holds in some shape other than a table, which the write replaces wholesale. A value already in the file that the override does not author is left alone and not reported. The override also carries rawAllow/rawAsk/rawDeny — verbatim [permissions] entries merged into the generated arrays untranslated. They exist for the first-class Bash=<literal> exact-command form (SPEC §3.7, v1.18.0: metacharacters in the literal are ordinary characters and only the identical complete command matches), which the canonical tool→pattern→action shape cannot express. It is also the pattern-level way to pre-authorize nested or indirect Bash — command and process substitution, eval, source, sh -c and the like, which Reasonix gates harder than a merely dynamic command line — in a headless reasonix run. The blanket alternative is reasonix.allowDynamicBash, which writes [permissions] allow_dynamic_bash (added in v1.19.0): where a rawAllow entry pre-authorizes one exact literal, this lets an Allow fallback — Auto included — cover that whole class at once, with explicit ask and deny rules keeping precedence over it. Setting it to true is written but named in a warning, as with the other keys that widen what a shareable permissions file lets run without a human; leaving the key out of the override keeps whatever the file already had, and it is lifted back into the override on import when it holds the boolean Reasonix documents. YOLO is a run-time posture rather than config, so there is nothing to author for it. Exact entries already in reasonix.toml — Reasonix writes them itself as remembered approvals — are always preserved on generate, even for tools the shared block manages. On import, the whole [sandbox] table round-trips (it is a dedicated security surface), only the plan-mode keys are lifted from [agent], and exact Tool=<literal> entries are lifted into rawAllow/rawAsk/rawDeny instead of masquerading as a bogus tool category in the shared block.

json
{
  "permission": { "bash": { "git status*": "allow" } },
  "reasonix": {
    "sandbox": { "bash": "enforce", "network": false },
    "agent": { "plan_mode_read_only_commands": ["gh pr diff"] },
    "allowDynamicBash": true
  }
}

The retired [[plugins]].trusted_read_only_tools MCP read-only trust list is per-plugin (an array-of-tables shared with the MCP feature) and is not covered by this override.

For Devin Local, this generates the permissions.allow, permissions.ask, and permissions.deny arrays of the shared .devin/config.json (project mode) or ~/.config/devin/config.json (global mode) — in global mode the same file the hooks feature patches (MCP moved out to mcp_config.json in v3000.3); rulesync writes project-scope Devin hooks to a standalone .devin/hooks.v1.json instead, so a project .devin/config.json carries permissions alone. Entries are Devin's scope matchers: read → Read(<glob>), write/edit → Write(<glob>), bash → Exec(<prefix>), webfetch → Fetch(<pattern>), websearch → the bare web_search tool name (accepted in the permission lists since CLI v3000.10.21), plus mcp__server__tool names passed through verbatim. A * pattern collapses to the bare scope (Exec). web_search has no pattern matcher, so pattern-specific websearch rules are collapsed to a single action with a warning (deny > ask > allow; a map without * also counts an implicit ask, so a narrow allowlist never becomes a blanket allow; an empty websearch map emits nothing — like any other empty category it still retracts the web_search entries rulesync manages, which leaves Devin's auto-approve default in place), and on import web_search maps back to websearch. A CLI older than v3000.10.21 rejects a web_search entry, so upgrade before generating one. Devin evaluates the arrays with deny > ask > allow precedence, so a deny rule always wins; on import Write maps back to write, so edit rules round-trip as write. Only the keys rulesync manages are rewritten — permissions, plus sandbox in global mode when the devin override below authors it — and every other key is preserved and the file is never deleted. See the Devin permissions reference.

For Continue, permissions are global-only and generate the allow, ask and exclude lists of ~/.continue/permissions.yaml, the tool-policy file of the Continue CLI (cn) — Continue reads no project-scoped policy file, so a project-scope run is skipped with a warning and --global is required. Each entry is Tool or Tool(pattern): the canonical categories map to Continue's built-in tool names (bash → Bash, read → Read, edit → Edit, write → Write, webfetch → Fetch), the * pattern of a category becomes the bare tool name, the all-tools * category becomes a bare * (a pattern-specific rule under * is skipped with a warning, because Continue would match it against no tool argument; when a bash category exists it is mirrored onto Bash instead), and any other category is passed through as a tool name (which is how an MCP tool is named). deny lands in exclude; a pattern that itself contains a parenthesis is skipped with a warning because Continue's parser rejects it, and a rule that appears in more than one list is named in a warning. Entries already in the file that name a tool the canonical block does not mention are kept, and entries Rulesync cannot parse are left where they are; an empty canonical block writes no file. On import the three lists map back to allow / ask / deny (first list wins for a duplicated entry). See the tool permissions docs.

For CodeBuddy Code, this generates the permissions.allow, permissions.ask and permissions.deny arrays of the shared .codebuddy/settings.json (project mode) or ~/.codebuddy/settings.json (global mode), in Claude Code's rule syntax: Tool or Tool(specifier). The canonical categories map to CodeBuddy's tool names (bash → Bash, read → Read, edit → Edit, write → Write, grep → Grep, glob → Glob, notebookedit → NotebookEdit, webfetch → WebFetch, websearch → WebSearch, agent → Agent, skill → Skill), the * pattern of a category becomes the bare tool name, and patterns are written as they are, so CodeBuddy's own spellings (Bash(npm run test:*), Read(//abs/path), WebFetch(domain:example.com)) work unchanged. Any other category is written as the tool name it is (mcp__github, mcp__github__get_issue, TaskCreate), like the claudecode target; an mcp__ category takes no specifier, so a pattern other than * there is skipped with a warning. The bare mcp category becomes mcp__<pattern> and its * pattern the all-MCP wildcard mcp__*, which CodeBuddy honors in deny and ask only, so an mcp: { "*": "allow" } is skipped with a warning. The all-tools * category's * pattern becomes CodeBuddy's standalone * rule (which does not cover MCP tools); a narrower * pattern is mirrored onto Bash when a bash category exists and is otherwise skipped with a warning. When two canonical rules collapse onto one entry the strictest action wins, matching CodeBuddy's deny > ask > allow precedence. Only the three lists are rewritten, and within them only the entries of the categories the canonical config names: defaultMode, additionalDirectories, trustedDirectories and the other keys of permissions are kept, as is every existing entry for a tool the canonical config does not mention; a list that ends up empty is removed, and the file itself is never deleted. The gitignored .codebuddy/settings.local.json layer is left to CodeBuddy. On import each entry maps back to its category and pattern (mcp__* to the bare mcp category, the standalone * to the all-tools * category, an empty Tool() or unbalanced entry is skipped), with the strictest action winning for an entry listed more than once. See the permissions docs.

For Command Code, this generates the permissions.allow, permissions.ask and permissions.deny arrays of the shared .commandcode/settings.json (project mode) or ~/.commandcode/settings.json (global mode). Each entry is Tool or Tool(pattern): the canonical categories map to Command Code's friendly tool names (bash → Shell, read → Read, edit → Edit, write → Write, grep → Grep, glob → Glob, webfetch → WebFetch, websearch → WebSearch — the last two are resolved by Command Code's rule parser even though the docs only list the first six, and Edit covers Command Code's write_file tool as well as edit_file, so an edit allow also lets new files be created unless a write rule says otherwise, just as Read covers grep and glob (and read_directory / read_multiple_files) besides read_file, so a read rule governs them too — in both cases Command Code's deny > ask > allow precedence decides when the rules disagree; notebookedit and agent are skipped even though the parser resolves NotebookEdit and Agent too, because Command Code has no notebook tool — NotebookEdit is the same edit_file / write_file set as Edit, so a notebookedit rule would govern every file edit — and its permission check answers allow for the agent tool before it consults any rule, so an Agent deny or ask is never enforced), the * pattern of a category becomes the bare tool name, mcp__<server>__<tool> categories pass through verbatim (Command Code matches MCP server and tool names case-sensitively, so the category keeps its spelling) and the bare mcp category becomes mcp__* (Command Code ignores the (specifier) of an mcp__ rule in every list and applies the rule to the whole tool, so a pattern-specific MCP deny/ask is written as the bare tool name with a warning, and a pattern-specific MCP allow — which would grant the whole tool — is not written, with a warning), and the all-tools * category becomes a bare * in deny/ask (Command Code ignores a tool-name wildcard such as * or mcp__* in allow, so it is not written there and a warning is logged; a pattern-specific rule under * is mirrored onto Shell when a bash category exists). A category with no rule name upstream is skipped, with a warning when it carries a deny. Note that a path-scoped Read(pattern), Grep(pattern) or Glob(pattern) rule only matches a call that carries a file_path / path / target_directory input (Command Code's own semantics, as with Claude Code), so a grep: { "**/.env": "deny" } does not stop a grep over the working directory that names no path — use the bare grep / glob deny (the * pattern) or a bash deny when you need a guard. When two canonical rules collapse onto one entry the strictest action wins, matching Command Code's own deny > ask > allow precedence. Only the three lists are rewritten, and within them only the entries of the categories the canonical config names: defaultMode, additionalDirectories, disableBypass and the other keys of the file are kept, and every existing entry for a tool the canonical config does not mention — an internal name such as edit_file, a name wildcard such as edit_*, or a modeled tool without a canonical category — is preserved verbatim, so the approvals Command Code writes into the same lists survive a regenerate. An entry for a rule name this run emits (case-insensitively for the friendly tool names, exactly for MCP names — mcp__<server> from the bare mcp category included, and mcp__<server>__* / mcp__<server>__ are that same whole server) is replaced in whichever list it sits, so flipping a rule from deny to allow does not leave the old deny behind; a mcp.<server> rule you remove from the canonical config, however, leaves its mcp__<server> entry in place like any other unmentioned tool — delete it by hand. A deny or ask entry that is reclaimed this way without an equally strict rule — the same one, or the category-wide * rule that covers it — taking its place (a hand-written mcp__* deny next to a canonical mcp: { github: "deny" }, say) is reported with a warning, so a regenerate never loosens the file silently. A canonical block that names no category leaves every existing entry in place; a list that ends up empty after the rewrite is removed rather than written as [], and the file itself is never deleted. On import the lists map back to allow / ask / deny as Command Code enforces each entry (the friendly tool names fold case, and the internal aliases Command Code's rule parser accepts for the same tools — Bash, PowerShell, shell_command, monitor_command and kill_shell for bash, NotebookEdit for edit, write_file for write, web_fetch / web_search for webfetch / websearch — fold onto their category, while Agent / Task enforce nothing and are skipped; Tool, Tool() and Tool(*) all mean the whole tool; the rule is split at its first unescaped ( with the tool half trimmed and \( / \) in the specifier unescaped, as Command Code's own parser does, and a rule that opens a parenthesis without closing one is no rule at all and is skipped; a Shell specifier has its whitespace trimmed and collapsed the way Command Code's shell matcher does (Shell( ) matches nothing and is skipped; a padded Shell( * ) is a pattern rule, not the bare Shell — only a specifier of exactly * means the whole tool — so it imports as the whole bash category from deny/ask, where it matches every command, and is skipped with a warning from allow, where it is narrower than the bare Shell), whereas every other specifier is taken as written, so a padded Read( * ) matches nothing and is skipped; a bare :* shell specifier (Shell(:*)), whose prefix is empty, matches nothing in Command Code either and is skipped rather than imported as an empty-prefix grant; an mcp__<server>__<tool>(specifier) imports as the whole tool from every list — which is what Command Code enforces for it, the specifier being ignored — with a warning from allow, where that widens the grant as written; MCP names keep their case; a mcp__<server>, mcp__<server>__* or mcp__<server>__ entry becomes its own mcp__<server> category rather than a pattern of the bare mcp one, and a * server (mcp__*, mcp__*__<tool>) is every MCP tool, the bare mcp category; and a differently-cased MCP__... spelling, which Command Code reads as a plain tool-name rule because it only recognizes the MCP shape from an exact mcp__ prefix, imports as what that rule does there: MCP__<server>__<tool> folds to the mcp__ category spelled as written, with a warning that the exact-prefix rule generated from it matches the names exactly where the name rule matched them case-insensitively, MCP__<server>__<tool>(specifier) — whose specifier Command Code globs against the call's command / file_path / path / url / pattern argument — keeps its specifier as the pattern in deny/ask but is skipped in allow with a warning, where such a grant cannot be imported without widening it, the name globs MCP__* (every MCP tool, the bare mcp category) and MCP__<server>__* (the whole server, mcp__<server>) import from deny/ask only, and MCP__<server> names no tool and is left alone), with the strictest action winning for a tool named more than once, unmodeled entries skipped (with a warning when they sit in deny or ask, since an internal-name rule such as edit_file(**/.env) is enforced by Command Code even though rulesync cannot carry it to other targets; it stays in the settings file, where a regenerate keeps it), a tool-half glob in the exact-prefix spelling (mcp__<server>__get_*), which Command Code matches against tool names in every list, imported as the category spelled that way — the one the generator writes back — which other targets read as a literal tool name, a list entry that is not a string skipped on its own with a warning, as Command Code skips it (on regenerate such an entry is dropped from the rewritten list, also with a warning), and a server-less wildcard (*, *(), mcp__*, mcp__*()) in allow skipped with a warning because Command Code ignores it there. See the permissions docs.

For Letta Code, this generates the permissions.allow, permissions.alwaysAsk and permissions.deny arrays of the shared .letta/settings.json (project mode) or ~/.letta/settings.json (global mode). Letta Code checks deny, then alwaysAsk, then its permission mode, and only then allow and its plain ask list, so a canonical ask is written to alwaysAsk: a plain ask entry would lose to a broader allow and to an auto-approving mode. An existing plain ask entry for a managed tool is therefore imported as ask and rewritten into alwaysAsk on the next generate. The categories map to Letta Code's tool names (bash → Bash, read → Read, edit → Edit, write → Write, glob → Glob, grep → Grep); the * pattern of a category becomes the bare tool name. Other categories (webfetch, websearch, mcp, ...) have no rule in Letta Code and are skipped with a warning; a rule under the all-tools * category is applied to Bash. File-tool patterns are passed through as Letta Code globs. Letta Code's ApplyPatch tool takes no path rules, so edit and write path rules do not restrict it. Letta Code matches a Bash rule exactly, or as a prefix when it ends in :*, so a canonical pattern whose only * is trailing becomes a prefix rule — git * becomes Bash(git:*), which also matches gitk since Letta Code trims the space before :* — and a pattern without * stays an exact match. A pattern with a * in the middle cannot be expressed: an allow is dropped with a warning, and a deny or ask is widened to the prefix before the first * with a warning, so it never loosens. When two canonical rules collapse onto one entry the strictest action wins. Only the entries for the tools the canonical config names are rewritten: mode, additionalDirectories and the other keys of permissions, and entries for other tools (Task, ListDir, ...), are kept, and a list that ends up empty is removed. On import allow, ask/alwaysAsk and deny map back to allow, ask and deny (Letta Code's aliases such as shell and read_file fold onto their category, X:* becomes X *), with the strictest action winning for a rule listed more than once. See the permissions docs.

For Qoder, this generates the permissions.allow, permissions.ask and permissions.deny arrays of the shared .qoder/settings.json (project mode) or ~/.qoder/settings.json (global mode); the per-machine .qoder/settings.local.json, where Qoder's /allow and /deny commands persist, is left to you. Qoder checks deny, then ask, then allow. The categories map to Qoder's tool names (bash → Bash, read → Read, edit → Edit, write → Write, grep → Grep, glob → Glob, webfetch → WebFetch, websearch → WebSearch, agent → Agent); the * pattern of a category becomes the bare tool name and any other pattern is written verbatim as Tool(pattern) — Qoder matches Bash payloads exactly, as a :* prefix or as a glob, file payloads as gitignore-style paths (/src/** is rooted at the project, or at the home directory in the user file), and Agent payloads as subagent names. Qoder checks path-scoped writes against Edit(...) rules and path-scoped reads against Read(...) rules, so a write rule with a path is written as Edit(path) and, following Claude Code (whose permission model Qoder mirrors; Qoder does not document Glob payloads), a glob rule with a path as Read(path); such an entry is managed as an Edit / Read entry, so it is only rewritten on a later generate when the canonical config also names edit / read, and it imports back under edit / read. An mcp__... category (mcp__github__create_issue, mcp__github__*, mcp__*) is written as the bare MCP tool name; a narrower pattern under it is skipped with a warning. The all-tools * category's * pattern becomes Qoder's bare * rule; its other patterns are applied to Bash and otherwise skipped with a warning. Other categories (notebookedit, ...) are skipped with a warning. When two canonical rules collapse onto one entry the strictest action wins. Only the entries for the tools the canonical config names are rewritten: additionalDirectories, trustDirectories and the other keys of permissions, and entries for other tools, are kept, and a list that ends up empty is removed. On import each entry maps back to its category and pattern verbatim (Bash(git log:*) stays git log:*), with the strictest action winning for a rule listed more than once. See the permissions docs.

Crush-only override (crush key): Crush has no per-path or per-command permission model — its config carries two tool-wide lists, permissions.allowed_tools (tools that skip the confirmation prompt, as tool or tool:action entries) and options.disabled_tools (built-in tools removed from the agent altogether), written into the shared crush.json (project; .crush.json when that file already exists) / ~/.config/crush/crush.json (global, via --global) by deep merge, so the sibling keys (providers, mcp, hooks, ...) survive and the file is never deleted. At project scope Crush merges .crush.json and crush.json (lists concatenated, .crush.json on top), so allowed_tools/disabled_tools entries left in the crush.json next to a .crush.json stay in effect and are named in a warning rather than retracted; import reads the merged pair. Each canonical category maps to a whole built-in tool name (bash → bash, read → view, edit → edit, write → write, grep → grep, glob → glob, webfetch → fetch; a mcp__<server>__<tool> category becomes Crush's mcp_<server>_<tool> name, and any other category is passed through as a tool name), so only the catch-all * pattern is written and narrower patterns (git *, src/**) are skipped with a warning — Crush prompts per call and cannot be told which arguments to approve. A catch-all allow lands in permissions.allowed_tools; a catch-all deny lands in options.disabled_tools, which only covers Crush's built-in tools, so a deny on an MCP tool is skipped with a warning (disable it through the server's disabled_tools in .rulesync/mcp.jsonc instead); ask writes nothing — a tool in neither list keeps Crush's default prompt. Because an allowed_tools entry approves every invocation of that tool, a catch-all allow is withheld (and named in a warning) when the same category also carries a narrower deny or ask — with bash: { "*": "allow", "rm -rf *": "deny" } nothing is written for bash, so Crush keeps prompting rather than auto-approving the denied commands. The all-tools * category has no Crush counterpart (there is no allow-all list, and emptying disabled_tools is not a disable-all) and is skipped with a warning. Entries in either list that rulesync did not derive — tool:action forms, MCP names, tools with no canonical category — are preserved on regenerate, and a rule that flips to ask (or a category whose catch-all is dropped while the category itself stays declared) retracts the entry it had written; removing a category wholesale leaves the entry rulesync can no longer tell apart from a hand-written one, so set it to ask first to retract it. Author the rest of the permissions block (skip_requests) and the other options keys directly in crush.json; they are untouched. On import, every plain tool entry in permissions.allowed_tools becomes { "*": "allow" } for its category and every options.disabled_tools entry { "*": "deny" } (deny wins when a tool is in both); tool:action entries are narrower than a tool-wide rule and are not imported. See the permissions docs.

Pool note: Pool (Poolside's coding agent CLI) keeps its permission rules in the same YAML settings file as its MCP servers — .poolside/settings.yaml (project; the committed, team-shared file — the untracked .poolside/settings.local.yaml overlay is left to you) and ~/.config/poolside/settings.yaml (global, via --global) — under two top-level keys, written by deep merge so the sibling keys (pool, mcp_servers, sandbox, hooks, ...) survive and the file is never deleted. Tool categories become tools.<name> blocks with allow and deny pattern lists matched against the tool's argument: bash → tools.shell, webfetch → tools.web_fetch, websearch → tools.web_search, and any other non-file category is passed through as tools.<category>. An allow rule lands in allow, a deny rule in deny (Pool lets a denied pattern win over an allowed one, so nothing is withheld for it), and ask writes nothing — a call that matches neither list prompts, which is Pool's default — except that an allow overlapping an ask of any category that lands on the same Pool tool (bash and a pass-through shell both write tools.shell, which Pool matches as one list) is withheld and named in a warning, since an allowed pattern would silence the prompt the config asks for. Pool's tool patterns know one wildcard, *, which matches anything including / (so a ** run is written as *); a pattern that relies on ?, [...] or {a,b} cannot be expressed and is skipped with a warning — and when it is a deny, it withholds the allows it overlaps on the same Pool tool just as an ask does, so the skipped deny is honoured by prompting rather than silently auto-approved. The file categories read, edit and write become paths entries instead, Pool's file-access model: a read allow is a read-only entry ({ path }), an edit or write allow adds write: true (Pool has a single write permission covering edits, creations, deletions and renames), and a read deny is a paths.deny entry, which wins over any allow; a bare * is written as ** because a * in Pool's path globs is one segment, and since those globs know only * and **, a path pattern that relies on ?, [...] or {a,b} is skipped with a warning — a skipped deny withholding the allows it overlaps, as on the tool side, rather than being written as a literal Pool would never match. Pool cannot deny writes without denying reads, so an edit/write deny that is not also a read deny is not written (a warning says so) and only withholds the write flag of the allows it overlaps — with read: { "src/**": "allow" }, edit: { "src/**": "allow", "src/generated/**": "deny" } the entry is written read-only and Pool prompts for the writes; an ask in a file category likewise withholds the overlapping allows (a write entry also grants reading, so a read ask withholds it too). Project settings take project-relative paths and the user settings absolute or ~ paths, so a path of the other kind is skipped with a warning at each scope (a read deny skipped that way exempts no write deny, since nothing lands in paths.deny for it). The all-tools * category has no Pool counterpart: its restrictions are mirrored onto bash and the category itself is skipped with a warning. A mcp__<server>__<tool> category is skipped too — Pool approves MCP tools per server under mcp_servers.<server>.allow, which the MCP feature writes from the server's poolAllow in .rulesync/mcp.jsonc. On regenerate the allow/deny lists of the tools the canonical config names are rebuilt (an empty list retracts its key) and each tool's disabled flag and every unnamed tool are left alone; removing a category wholesale leaves the lists rulesync can no longer tell apart from hand-written ones, so set its rules to ask first to retract them. The paths lists are rebuilt whenever the canonical config carries a file category and left untouched otherwise. On import, each tools.<name> block maps back to its category (shell → bash, web_fetch → webfetch, web_search → websearch, anything else verbatim) with its allow/deny patterns (deny wins when a pattern is in both) and disabled: true as { "*": "deny" }; a paths.allow entry becomes a read allow (plus edit and write allows when it carries write: true) and a paths.deny entry a deny in all three; mcp_servers is not read. See the settings file reference.

Tabnine CLI-only override (tabnine key): Tabnine CLI has no per-path or per-URL permission model — its settings.json carries two tool lists, tools.allowed (tools that skip the confirmation prompt) and tools.exclude (tools removed from discovery), written into the shared .tabnine/agent/settings.json (project) / ~/.tabnine/agent/settings.json (global, via --global) by deep merge, so the sibling keys (mcpServers, hooks, general, context, ...) survive and the file is never deleted. From the canonical block, a bash rule whose pattern is a command prefix (git *, pnpm, or the bare * for the whole tool) becomes run_shell_command(<prefix>) — Tabnine matches the prefix against the start of the command line (so a bare pnpm also covers pnpm install), and any other shell pattern (git * push, *.sh) is skipped with a warning. A bash deny is written as run_shell_command(<prefix>) in tools.exclude — the form Gemini CLI, which Tabnine CLI derives from, honors in excludeTools — but Tabnine documents the prefix syntax for tools.allowed only, so that entry is not relied on to enforce the rule. Because a written prefix approves every longer command line too, each bash allow is compared at that width against every rule that restricts shell commands — a bash deny (written as a prefix or not), a bash ask, and the deny and ask rules of the all-tools * category — and is withheld when it overlaps one, rather than auto-approving the restricted commands: with git * allowed and git push * denied, a bare pnpm allowed and pnpm publish * asked about, or git * allowed and git * --force denied, the allow is not written, and each withheld rule is named in a warning. Every other category maps to a whole built-in tool name (read → read_file, edit → replace, write → write_file, webfetch → web_fetch, websearch → google_web_search, grep → grep_search, glob → glob; an unknown category is passed through as a tool name, which is how an MCP tool is named), so only the * pattern is written and narrower patterns are skipped with a warning. allow lands in tools.allowed, deny in tools.exclude, and ask writes nothing — a tool in neither list keeps Tabnine's default prompt. The rest of the tools group (core, shell.*, enableWebTools, ...) and the general group are authored through the tabnine override, which is deep-merged beneath the two canonical lists — only those two groups are written through the permissions feature; any other key of the override (mcpServers, hooks, ...) is ignored with a warning, since the adapter that owns it writes it. Six keys of those groups are refused outright with a warning rather than written — tools.discoveryCommand, tools.callCommand, tools.shell.pager and tools.sandbox.command, whose values Tabnine CLI spawns as commands, general.tabnineHost, the host every login and API call (and the credentials with them) goes to, and general.preferredEditor, an editor executable path — so a permissions file (one fetched with rulesync fetch included) cannot point it at an executable or a server of its choosing; set them by hand in settings.json if you need them. A run_shell_command(<prefix>) entry in the override's own tools.allowed is a bash allow spelled the Tabnine way, so it joins the canonical allows and is withheld by the same deny/ask comparison rather than written past it; a prefix spelled as a glob (run_shell_command(git *), run_shell_command(*)) is read as that glob — Tabnine would match it literally — and written back as the prefix it denotes (run_shell_command(git), run_shell_command) with a warning; import reads such an entry in settings.json back as the same glob. The override settings that widen what Tabnine does unattended are written as authored but named in a single trust-affecting warning: the override's other tools.allowed entries (a non-shell entry is only checked against a whole-tool deny of its tool: one naming a tool the canonical block excludes is withheld with a warning, since Tabnine never loads an excluded tool), a tools.sandbox that is not the plain true, and a general.defaultApprovalMode other than default or plan. Entries already in tools.allowed/tools.exclude that name a tool the canonical block does not mention (an MCP tool, a hand-written run_shell_command(...) prefix while the block has neither a bash nor a * rule) are kept; the entries of a tool the block does name are rewritten from it (an existing tools.exclude entry that goes this way is named in a warning, since removing it loosens the policy; a dropped tools.allowed entry only brings back the confirmation prompt and is named at info level), and the * category counts as naming the shell tool since its rules restrict shell commands. On import, list entries that cannot be mapped back (a prefix on a non-shell tool, a malformed entry, a tool whose name cannot be a permission category such as __proto__) round-trip through that override too; the import leaves those six refused keys in settings.json rather than lifting them (a generate would only refuse them) and announces the trust-affecting keys it does lift, so the resulting .rulesync/permissions.jsonc is read with the same eyes as one that arrived with a clone. See the built-in tools docs and the settings reference.

Devin-only override (devin key): the sibling top-level sandbox block decides what a permitted command may reach rather than which commands are permitted, so it has no canonical category: allowed_domains / denied_domains (proxy domain patterns, deny beating allow), network_mode (full, the upstream default, allows every HTTP method; limited only GET/HEAD/OPTIONS) and excluded (allow / ask / deny lists of Exec(...) matchers deciding which commands run outside the sandbox: allow runs them outside it, ask asks first, and deny pins them inside it). Author it through a tool-scoped devin override; it is shallow-merged into the sandbox object at its top level, so the override's keys win while sibling keys you set directly are preserved, and the whole block round-trips back into the override on import. Devin documents sandbox as a User Config Only key, so it is written in global mode only — at project scope it is dropped with a warning rather than written into a file Devin would ignore. The values that loosen the sandbox — a network_mode set to anything but limited, and the excluded.allow / excluded.ask lists that take a command out of the sandbox entirely — are still written, but never silently: rulesync names them in one warning per file, as it does for Claude Code's trust-affecting sandbox paths, because a permissions file is shareable and rulesync fetch can copy one into a project. allowed_domains runs the other way — while it is non-empty it is an allowlist, so it is adding to it, or emptying it back to "every domain", that widens — and because Devin replaces these lists rather than accumulating them, the same warning also names the restrictions a generate would drop: a denied_domains or excluded.deny that loses an entry the file had (a swap counts, not just a shorter list), an excluded.deny that disappears because the override states some other excluded key, or an allowed_domains that gains a domain or is emptied. Paths the override leaves alone are not reported, since the merge keeps them exactly as the file had them. The shallow merge only ever writes keys, so removing one from the override does not remove it from config.json — the value an earlier generate wrote stays until you delete it there by hand. The block only takes effect when Devin runs with --sandbox — either passed on the command line or forced on by an organization that sets sandbox enforcement to Required.

json
{
  "permission": { "bash": { "git status *": "allow" } },
  "devin": {
    "sandbox": {
      "allowed_domains": ["github.com", "**.npmjs.org"],
      "network_mode": "limited",
      "excluded": { "allow": ["Exec(git status *)"], "deny": ["Exec(git tag *)"] }
    }
  }
}

See the Devin sandbox docs and the config-file reference.

Note: Interaction with deprecated ignore feature. Both the ignore feature and the permissions feature can manage Read tool deny entries in .claude/settings.json. When both features configure the Read tool, the permissions feature takes precedence and a warning is emitted. Migrate the ignore patterns to read deny rules in .rulesync/permissions.jsonc, then remove ignore from the project features and delete the obsolete ignore source.

Released under the MIT License.