File Formats
Symlinks
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:
---
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 importalso reads that file back as alocalRoot: truerule under.rulesync/rules/, keeping the tool-side basename. The imported rule'stargetsis 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 appendlocalRootbodies to their root file), and importing from several tools would otherwise produce conflicting wildcardlocalRootrules. Widentargetsby hand if you do want the content shared. The same scoping applies torulesync 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.gitignorecovers the imported copy via.rulesync/rules/*.local.md; runrulesync gitignoreafter a first import if the project's.gitignorehas not been generated yet, so the personal content stays untracked. Project scope only, likelocalRootgeneration itself.
AGENTS.md standard note (
agentsmd): NestedAGENTS.mdfiles are the standard's only scoping mechanism — agents read the nearest file in the directory tree, so the closest one wins. Rulesync writes them fromagentsmd.subprojectPathand, on import, discovers them by scanning the project for**/AGENTS.md. The path can also be derived from the rule'sglobs: setagentsmd.subprojectPath: "auto"on one rule, orderiveSubprojectPathFromGlobs: trueinrulesync.jsoncfor 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 aspackages/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; withderiveSubprojectPathFromGlobsthe fallback is silent, since most rules carry activation globs such as["src/**/*.ts", "test/**/*.ts"]that were never meant to name a directory. An explicitagentsmd.subprojectPathalways wins over the derived value, an explicit""opts a single rule out of the config option (default placement, nothing derived), androot: truerules never nest. Two rules that resolve to the same directory — derived, explicit, or one of each — collide exactly as two explicitsubprojectPathrules do today (composed in source order foragentsmd; see each target's collision policy). Note thatglobskeeps its existing meaning for the targets that read it as a conditional-activation hint (Cursor, Cline, KirofileMatch, 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, sogenerate --deletetreats the two identically: it sweeps the.agents/memories/*.mdfile 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 arenode_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-levelbuild/is a build directory whilepackages/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.gitignorefiles 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.mdentry thatrulesync gitignorewrites for its own output does not disable the scan; the flip side is that ignoring one individualAGENTS.mdno 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 rootAGENTS.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) carryingagentsmd.subprojectPath, so the next generate puts it back where it came from. A subproject that would claim the reservedoverview.mdname gets an-agentssuffix 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/*.mdand uses aninclusionfrontmatter block to decide when each is loaded (always,fileMatchwith afileMatchPattern,manual, orauto— which auto-includes the file when a request matches its companiondescription, keyed byname). Rulesync derives this for non-root steering files: an explicitkiro.inclusionblock round-trips as-is (carryingname/descriptionthrough forauto); otherwise specific (non-wildcard)globsmap toinclusion: 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 carrieragentsmd.subprojectPathis written to<dir>/AGENTS.mdinstead of.kiro/steering/: Kiro CLI 2.18.0 and IDE 1.0.309 loadAGENTS.mdas 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 (noinclusionblock — that frontmatter belongs to.kiro/steering/*.md), and imports back as akiro-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, sogenerate --deletenever sweeps them. Nesting is project scope only — under~/.kiro/steering/there is no workspace tree to scope against, sosubprojectPathis 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 rootAGENTS.mdis 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--deleteremoves 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.mdand 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 theinstructionsarray of the sharedkilo.jsonc(the rootAGENTS.mdis auto-loaded and is therefore not registered). This merge is non-destructive: existing keys such asmcp,tools, andpermissionare preserved. Within theinstructionslist 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 noinstructionsregistration, because Kilo auto-discovers every~/.kilo/rules/*.mdon config load; writing the files is enough, and no globalkilo.jsoncis touched by the rules feature.
Kimi Code note: Kimi Code reads
.kimi-code/AGENTS.mdat project scope and~/.kimi-code/AGENTS.mdat user scope. WhenKIMI_CODE_HOMEis set, Rulesync follows Kimi and resolves every global Kimi-specific file (AGENTS.md,mcp.json,config.toml,skills/, andagents/) 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. Thekimi-code-plugintarget instead folds every rule into a plugin bundle'sSYSTEM.md(project scope only;localRootrules are not packaged); see Plugin Packaging.
deepagents note: deepagents-cli (dcode) reads
.deepagents/AGENTS.mdat project scope and~/.deepagents/agent/AGENTS.mdat user scope. WhenDEEPAGENTS_HOMEis set, Rulesync follows dcode and treats that directory as the profile root itself, so every global deepagents file moves under it with the.deepagentssegment 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. Theagentsegment is dcode's default agent profile; a different[agents].defaultinconfig.tomlis not followed. See Tool home overrides for how the variable is validated.
OpenCode note: OpenCode writes the root rule to the auto-loaded
AGENTS.mdand non-root rules to.opencode/memories/*.md. Because OpenCode auto-loads only the rootAGENTS.mdplus files explicitly listed in theinstructionsarray ofopencode.json(it does not auto-discover a rules directory), Rulesync also registers each generated non-root rule file in theinstructionsarray of the sharedopencode.json/opencode.jsonc(the rootAGENTS.mdis auto-loaded and is therefore not registered). The same applies in global mode (via--global): OpenCode readsinstructionsfrom the global~/.config/opencode/opencode.jsontoo, so global non-root rules are written to~/.config/opencode/memories/*.mdand 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 asmcp,tools, andpermissionare preserved. Within theinstructionslist rulesync owns the entries under its managed rules directory (.opencode/memories/, ormemories/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
mimocodetarget reuses the OpenCode behavior above with MiMo's paths: the root rule goes toAGENTS.md(global:~/.config/mimocode/AGENTS.md), non-root rules to.mimocode/memories/*.md(global:~/.config/mimocode/memories/*.md), and they are registered in theinstructionsarray of.mimocode/mimocode.json/.mimocode/mimocode.jsonc(global:~/.config/mimocode/mimocode.json(c)). MiMo Code resolves a relativeinstructionsentry 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.mdand 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 readsAGENTS.md, but that file belongs to theagentsmdtarget, so thegitlabduotarget 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 mapsglobs⇄ Qwen'spaths(a picomatch glob array) anddescription⇄description. A rule with specificpathsis conditional — Qwen lazily injects it only when the model touches a matching file — while a rule withoutpaths(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 rootQWEN.mdis unchanged. AlocalRoot: truerule is emitted to.qwen/QWEN.local.md(project scope only) — Qwen Code v0.16.2's personal project context file, loaded after the sharedQWEN.mdso it can override team instructions; the file is covered by the derived.gitignoresince 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 mapsglobs⇄ Cline'spaths(a glob array; the rule loads only when a matching file is in context) anddescription⇄description. A rule with specificglobsemitspaths; a rule with universal globs (**/*or*) emitsalwaysApply: 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.mdand 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 discoversAGENTS.md/AGENT.md/AGENTS.local.mdin the workspace only (the current directory, plus subdirectories holding a.roofolder whenenableSubfolderRulesis on), so a home-directoryAGENTS.mdis 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 theAGENTS.mdbasename so it still reads as the root overview — which is also what identifies it as the root rule onimport --global. AlocalRootrule stays project-only:AGENTS.local.mdis a workspace file, so global generation skips it with a warning. See the Roo Code custom instructions docs.
Response language note: When
rulesync.jsoncsets the rootlanguagekey, the generated root rule file (the one built from theroot: truerule —AGENTS.md,GEMINI.md,.github/copilot-instructions.md,.cursor/rules/overview.mdc, ...) ends with a thematic break followed byYou 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 nativelanguagesetting, so Rulesync writes"language": "<name>"into.claude/settings.local.json(project) or~/.claude/settings.json(global) through the shared settings gateway instead of touchingCLAUDE.md. On import the block is recognized for every supported language and stripped from the rulesync rule, so the language lives inrulesync.jsoncalone. See Response Language.
Cursor note (rules): Cursor's Always Apply template is
alwaysApply: truewithglobsomitted, 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 canonicalglobs: ["**/*"], so the tools that have noalwaysApplyequivalent still apply the rule everywhere, but recordscursor.globs: []. Generate drops a universal glob (**/*or*) from an Always Apply rule wherever it came from, so the.mdcgoes back out with noglobsline. 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 inventedglobs: **/*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 incursor.globsor in the canonicalglobs. Rules that are not Always Apply are untouched: an emptyglobsstays empty for both fields.
The two ways
cursor.globscan be absent mean different things, here and everywhere else a tool-specificglobsappears. Omitting the key means "no Cursor-specific opinion", so the canonicalglobsis 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 canonicalglobs: ["docs/**"]andcursor: { alwaysApply: true, globs: [] }applies to the whole project in Cursor while the canonical file reads as if it were scoped todocs/. Deletingcursor.globstherefore 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 thatcursor.globs: []on a rule that has noalwaysApplyleavesdescriptionas 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 andAGENTS.md(written by theagentsmdtarget, notcursor) — 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/rulesplus the project-rootAGENTS.mdandCLAUDE.md. There is therefore no~/.cursor/rulesequivalent to generate, andrulesync generate --global --targets cursor --features rulesskips 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-compatWARP.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 readsAGENTS.mdfiles 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 carryingagentsmd.subprojectPath(explicit, or derived fromglobsvia"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 rootAGENTS.mdcarries 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>.mdwithtargets: ["*"], exactly as described in the Vibe Code note below — and, likewise, a nested file emitted from a rule scoped withtargets: ["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 legacyWARP.mdpriority overAGENTS.mdin the same directory, so when aWARP.mdexists next to the generated root or nestedAGENTS.md, Warp never reads thatAGENTS.md; rulesync warns on generate but neither writes nor deletesWARP.md— move its content into.rulesync/rules/and remove or rename it. AWARP.mdis not imported. Other targets (e.g. Cline) own the same global path; as with the shared project-rootAGENTS.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 readscrush.md/Crush.md, their.localvariants, and the cross-toolAGENTS.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. AlocalRoot: truerule 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.localvariant. Crush does not gitignore that file for you, so the derived.gitignorecovers it. In global mode (via--global), the root rule is written to~/.config/crush/CRUSH.mdwith 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 api.systemPrompt: appendfrontmatter block — those rule bodies are routed toAPPEND_SYSTEM.mdinstead ofAGENTS.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.mdbefore this feature existed,generate --deletefor thepitarget 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 theroot: truerule, which always stays onAGENTS.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.mdbeforeAGENTS.md,AGENTS.MD,CLAUDE.mdandCLAUDE.MDin every directory it scans (including the global~/.pi/agent/one), loading it instead of the others from that directory. Setpi.contextFile: overrideon theroot: truerule to emit the root context file under that name — useful when another target owns the sharedAGENTS.md, or when aCLAUDE.mdsits 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 inAGENTS.md.AGENTS.override.mdis Pi-exclusive, so it is imported and deleted like the root file, and toggling the flag off cleans it up. The project-rootAGENTS.mdis never deleted on Pi's behalf, with or without the flag:agentsmd,codexcli,warpand others write that same path, so thepitarget leaves a stale one behind rather than removing another target's output (the global~/.pi/agent/AGENTS.mdis 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 rootAGENTS.mdat the same depth, and each non-root rule to.omp/rules/<name>.md/~/.omp/agent/rules/<name>.mdwithdescription,globsandalwaysApplyfrontmatter. oh-my-pi drops a rule that is neitheralwaysApply: truenor described, so a rule whoseglobsare empty or match everything (**/*) is written asalwaysApply: true, and a glob-scoped rule without adescriptiongets 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*.mdand*.mdcfiles oh-my-pi reads, and an imported.mdcrule becomes a.mdrulesync rule; generation always writes.md, so delete the original.mdcfile after importing it (oh-my-pi treatsfoo.mdandfoo.mdcas the same rule). Flattening does not round-trip: importing.omp/rules/frontend-style.mdcreates.rulesync/rules/frontend-style.mdbeside 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), butgeneratewith--deletestill removes nested.mdfiles 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 aPI_CODING_AGENT_DIRoverride 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 thefactorydroid.channelfrontmatter key. Opted-in rule bodies are routed to the channel's file instead ofAGENTS.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.mdat the project root: "Always-on design-system, UX, visual, and interaction guidance", loaded independently ofAGENTS.md's coding guidelines.channel: threat-model→.factory/threat-model.md: the attack-surface map Factory's Security Review reads ("if.factory/threat-model.mdexists, 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: truerule and in global mode: Factory's docs describe root and nestedDESIGN.mdfiles likeAGENTS.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'sdescriptionbecomes the style'sdescriptionfrontmatter (shown below its name in the picker), and the optionalfactorydroid.nameitsname(the picker name, which Droid otherwise takes from the file name; generate warns whennameis set withoutchannel: 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>.mdwith the same frontmatter. Because Droid loads only direct.mdchildren ofoutput-styles/and reserves its built-inDefaultandConcisestyles, generate fails for an output-style rule nested in a subdirectory of.rulesync/rules/or whose style name isdefault/concise(case-insensitive). Unlike.factory/rules/, the directory is not swept bygenerate --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 reasonrulesync gitignoreadds no entry for it, and generate overwrites a hand-written style of the same file name without a warning — with--globalthat includes a personal style in~/.factory/output-styles/, so pick a rule file name no style of your own uses. Import brings back every.mdfile in the directory, hand-written styles included, but skips any style reached through a symbolic link — a linked file or a linkedoutput-stylesdirectory, at either scope — so a link's target is never copied into.rulesync/rules/; a style whose file name matches a.factory/rules/*.mdrule imports to the same.rulesync/rules/path, and only one of the two is kept (a warning names both). Give an output-style ruletargets: ["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 (theoutputStylesetting) 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 whosetriggeractivation modes (always_on,glob,manual,model_decision) are driven by thedevinfrontmatter 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/*.mdwith the sametrigger/globsfrontmatter. 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). Thedevin-plugintarget writes the root rule to a plugin bundle'sAGENTS.mdand other rules to itsrules/<name>.md(project scope only;localRootrules 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'sglobsas that frontmatter on the generated.agents/memories/*.mdfile (in addition to the advisoryapplyTovalue in the root file's TOON table, which Amp does not enforce), and restores it into the canonicalglobson 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/*.mdfile (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 ofalways,manually,by model decision(with aninstructions: <condition>line),by file patterns(with apatterns: <glob>, <glob>line — comma-separated, not a YAML list) oroff. 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 noapply: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 unlessaiassistant.applysets it explicitly:globswith no universal entry becomeapply: by file patternswith the globs aspatterns; adescriptionon a rule without anyglobsbecomesapply: by model decisionwith the description (flattened to one line) asinstructions; everything else — the root rule, any list containing a universal glob (**/*,*) and rules with neither — isapply: always. Because the plugin splitspatternson every comma, a brace alternation such assrc/**/*.{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 explicitaiassistant.applyis 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 explicitby file patternswrites every glob, universal ones included, aspatterns. 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.applyis kept whenever the next generate would not derive the same type on its own —manually/offand unrecognized values, which have no canonical counterpart, and aby file patterns/by model decisionblock whosepatterns/instructionsline is missing or universal — so a regenerate reproduces the file. This format comes from the plugin itself (AiRulesServiceand 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/*.mdfile (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: falsewithglobs(specific files),alwaysApply: falsewithdescription(apply intelligently), andalwaysApply: falsealone (manual, via#Rulein chat). The mode is derived from the canonical frontmatter unlesstrae.alwaysApplysets it:globswith no universal entry givealwaysApply: falseplus 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 assrc/**/*.ts,src/**/*.tsx; a glob that still holds a comma, such as one past the expansion cap, is written as is with a warning); adescriptionon a rule without anyglobsgivesalwaysApply: falseplus the description; everything else — the root rule, any list containing a universal glob (**/*,*) and rules with neither — isalwaysApply: true, written withoutglobs(an explicittrae.alwaysApply: truekeeps the specific globs, andtrae.alwaysApply: falsekeeps every glob, universal ones included, so a file-scoped**/*rule does not turn into a manual one). On importalwaysApply: truewithout globs maps toglobs: ["**/*"], an unquotedglobsvalue 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), andtrae.alwaysApplyis kept only when the next generate would not derive the same value (a manual rule, oralwaysApply: truebeside specific globs). Trae'sscenefield (scene: git_messagealso applies the rule when Trae generates a Git commit message, as in thegit-commit-message.mdits Source Control panel creates) round-trips throughtrae.scene. As for any rules directory,generate --deleteremoves.trae/rules/*.mdfiles that no Rulesync rule produces, so import a hand-made rule such asgit-commit-message.mdfirst if you want to keep it. Global rules are not generated: Trae documents~/.trae/user_rulesonly 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 carryingagentsmd.subprojectPath(explicit or derived fromglobs) is written to<subprojectPath>/.trae/rules/<name>.mdinstead (project scope only), with the same frontmatter mapping and itsglobsleft 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/entryrulesync gitignorewrites 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) withtargets: ["trae"]and thesubprojectPathcarried, 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 --deletenever removes a nested.trae/rules/file, so delete one by hand after dropping its rule. MCP servers go to the project.trae/mcp.jsonas a plainmcpServersobject (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 inenvorheaders(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→ rootAGENTS.mdcombined with.junie/playbook.mdand every.junie/rules/*.md→ the legacy.junie/guidelines.md/.junie/guidelines/. The multi-file branch is unreachable whenever.junie/AGENTS.mdexists, 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/*.mdbeside it would produce files Junie never reads, and moving the root output to the project-rootAGENTS.mdwould change every existing output path and collide with theagentsmdtarget, so the fold stays. The multi-file branch is read on import, but only while.junie/AGENTS.mdis absent — exactly when Junie itself reads it. Hand-authored.junie/rules/*.md(that directory itself, not a tree below it) and.junie/playbook.mdare 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 thegeneratethat follows folds their content into.junie/AGENTS.mdand leaves the originals untouched. Later imports skip them with a single warning naming up to ten skipped files (the rest are folded into anand N morecount): re-reading rules the root file already contains would fold the same content in again on every import/generate cycle. Only.junie/AGENTS.mdcloses that gate — the legacy.junie/guidelines.mddoes 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.mdand 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.mdexists. In that pre-.junie/AGENTS.mdlayout Junie's root file is the project-rootAGENTS.md, which belongs to theagentsmdtarget.rulesync importtakes one target at a time, so runrulesync import --targets agentsmdbeforerulesync import --targets junieif you want a root rule as well: ajunie-only import of that layout yields non-root rules alone, and the nextgeneratewrites.junie/AGENTS.md— at which point Junie stops reading the project-rootAGENTS.mdwhose content was never imported. The legacy.junie/guidelines.mdis 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-toolAGENTS.md/CLAUDE.md) by walking user-home → ancestors → project root/local. Rulesync writes the vendorREASONIX.mdat 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. AlocalRoot: truerule goes to./REASONIX.local.mdinstead (project scope only): Reasonix loads that personal variant beside eachREASONIX.mdin the workspace chain, ahead of it (LocalDocumentNamesininternal/instruction/resolver.go), so the personal body stays out of the committed file; the derived.gitignorelists it, andrulesync importreads it back as a reasonix-scopedlocalRootrule. 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 carryingagentsmd.subprojectPathis 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, nestedREASONIX.mdfiles 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.mdwithtargets: ["reasonix"]and thesubprojectPathcarried, so the next generate puts them back. The-reasonixsuffix 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 bothagentsmdandreasonixwith asubprojectPathproduces a nestedAGENTS.mdand a nestedREASONIX.mdin 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 everyAGENTS.mdit finds along the way (find_subdirectory_agents_md), injecting the result into theread_filetool's output since v2.19.1 — so a non-root rule carryingagentsmd.subprojectPathis 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>.mdwithtargets: ["*"]— importing the same file throughagentsmdandvibetherefore yields one rulesync rule, not two copies that would fold duplicated content back into the sameAGENTS.md. Because the emitted file is the plain per-directoryAGENTS.md, a rule scoped withtargets: ["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
.gitboundary and loads one instruction file per directory level, preferringAGENTS.mdoverCLAUDE.mdwhen both exist. Themusecodetarget writes the root rule to the shared project-rootAGENTS.md(the same fileagentsmd,codexcliand 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 themusecoderules 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.mdand the workspaceAGENTS.mdat the project root — and appends them in that order. Its docs are explicit that it "does not merge multipleAGENTS.mdfiles across directory levels" and "does not scan child directories", so there is no nested rules surface to emit: rulesync writes the project-rootAGENTS.md(project) /~/.zcode/AGENTS.md(global, via--global) and folds non-root rules into that single file.CLAUDE.mdis deliberately not written forzcode: 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.mdinto 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'sAGENTS.mdas project context. Rulesync writes the project-rootAGENTS.md(project) /~/.openclaw/workspace/AGENTS.md(global, via--global— only the default workspace;OPENCLAW_WORKSPACE_DIR,OPENCLAW_PROFILE,OPENCLAW_STATE_DIR,agents.defaults.workspaceand per-agentagents.entries.*.workspacesettings 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'sAGENTS.mdinstead). OpenClaw seeds the workspaceAGENTS.mdwith its own template on onboarding, andrulesync generate --globalreplaces that file wholesale — runrulesync import --targets openclaw --globalfirst 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 pastagents.defaults.bootstrapMaxChars(20,000 characters by default) and all bootstrap files together pastagents.defaults.bootstrapTotalMaxChars(60,000 by default), and injects a short missing-file marker whenAGENTS.mdis absent — so a--delete --globalrun that no longer produces any rule foropenclawremoves 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 theagentsskillstarget; 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 oneAGENTS.mdper directory from the project root down to the working directory, deeper files later in the prompt — so a non-root rule carryingagentsmd.subprojectPathis emitted as a nested<subprojectPath>/AGENTS.md(project scope only) instead of being folded. A rule scoped only byglobs(e.g.["services/svc1/**"]) is still folded unless you opt in withagentsmd.subprojectPath: "auto"on the rule orderiveSubprojectPathFromGlobs: trueinrulesync.jsonc(see the AGENTS.md standard note above for how the directory is derived). Nesting matters for size: Codex stops reading projectAGENTS.mdfiles once their combined size on the root-to-working-directory chain reachesproject_doc_max_bytes(32 KiB by default) and drops the rest without telling you, sorulesync generatewarns in project scope when the generated rootAGENTS.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 authorproject_doc_max_bytesyet; if you raised it in.codex/config.tomlyourself, the warning still uses the default and can be ignored. The personal~/.codex/AGENTS.mdis 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'ssubprojectPath— remove it by hand) and land in.rulesync/rules/<directory-with-hyphens>.mdwithtargets: ["*"], exactly as described in the Vibe Code note above — and, likewise, a nested file emitted from a rule scoped withtargets: ["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 honoursXDG_CONFIG_HOMEupstream) and folds non-root rules into that single file, since it has no modular rules directory. Directory-scoped rules are the exception: Pool loads everyAGENTS.mdfrom the repository root down through the working directory, deeper files taking precedence — so a non-root rule carryingagentsmd.subprojectPathis 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>.mdwithtargets: ["*"]. Because the emitted file is the plain per-directoryAGENTS.md, a rule scoped withtargets: ["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.mdand 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-rootAGENTS.md(project) /~/.dsh/AGENTS.md(global, via--global— only the$DSH_HOMEdefault 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.mdoverlay upstream, so global scope is root-only. Directory-scoped rules are the exception: a non-root rule carryingagentsmd.subprojectPathis 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>.mdwithtargets: ["*"]. The harness also readsCLAUDE.mdand theAGENTS.local.md/CLAUDE.local.mdoverlays at every level of the project chain, collapsing a sibling that duplicates itsAGENTS.md; those are theclaudecodetarget's files, and rulesync'sdshtarget emitsAGENTS.mdonly. 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.mdinstead ofCLAUDE.mdwhen the working directory (or any directory above it) has noCLAUDE.md,.claude/CLAUDE.mdorCLAUDE.local.md, while.claude/rules/*.mdkeeps loading alongsideAGENTS.md(Claude Code memory docs). When another target such asagentsmdorcodexclialready writes the project-rootAGENTS.md, set"includeRoot": falseon theclaudecodetarget'srulesoptions to skip the rootCLAUDE.mdand the personalCLAUDE.local.mdwhile still generating.claude/rules/*.md. Withdeleteenabled, aCLAUDE.mdorCLAUDE.local.mdleft over from an earlier run at the project root is removed; a hand-authored.claude/CLAUDE.mdis never deleted, but a warning points out that it still shadowsAGENTS.md. Withoutdelete, nothing is removed: a warning lists every project-rootCLAUDE.md/CLAUDE.local.mdand.claude/CLAUDE.mdthat still exists, and you must remove them by hand for Claude Code to readAGENTS.md. Aroot: trueorlocalRoot: truerule whosetargetsname onlyclaudecodeis dropped with a warning naming the file, since no other target generates it; add the target that writesAGENTS.mdto itstargetsor turn it into a non-root rule.ruleDiscoveryModehas no effect with this option, since the references it adds are written intoCLAUDE.md. The option applies to project scope only: in global mode~/.claude/CLAUDE.mddoes not shadowAGENTS.md, so it is still generated (with a warning). Keep ittrue(the default) when some of your Claude Code sessions do not readAGENTS.md—AGENTS.mdsupport 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 toCODEBUDDY.mdat the project root (also read from.codebuddy/CODEBUDDY.mdas 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, gitignoredCODEBUDDY.local.mdis materialized next to the root file, mirroring Claude Code'sCLAUDE.local.md. Non-root frontmatter supportsdescription,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 documentedpaths: {src,lib}/**/*.ts, tests/**/*.test.tsform imports as two globs),alwaysApplyandenabled, closer to the Cursor rule model than Claude Code'spaths-only schema; put tool-specific overrides in thecodebuddy:block of the canonical rule. Unlike Cursor, CodeBuddy's documentedalwaysApplydefault istrue:pathsonly scopes a rule whenalwaysApply: falsesits alongside it, andalwaysApply: falsewith nopathsmakes CodeBuddy drop the rule entirely. Rulesync applies those rules for you — a rule with non-universal globs is emitted with an explicitalwaysApply: false, and analwaysApply: falsewith nothing to trigger on is left implicit rather than written out. The shipped CLI is looser than its docs table and already treatspathswithoutalwaysApplyas a path-triggered rule, so import follows the CLI: such a file becomes a rule scoped to those globs, andalwaysApplyis carried over only when the file spells it out.enabled: falsestops CodeBuddy loading the rule at all and survives an import/generate round trip. See the CodeBuddy Code memory docs and the.codebuddydirectory 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.mdas its primary instruction file and every Markdown file under.bob/rules/as additional always-on rules; the user-level counterparts are~/.bob/AGENTS.mdand~/.bob/rules/. Rulesync writes the root rule toAGENTS.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
$nameargument in its template is provided, so the universal$ARGUMENTSis translated to Junie's free-form$promptargument andallowPromptArgument: trueis added to the generated.junie/commands/*.mdfrontmatter (and reversed on import — see Command Syntax for the exact conditions). Setjunie.allowPromptArgumentin the rulesync frontmatter to override the derived value;falsekeeps$ARGUMENTSverbatim. Other keys in thejunieblock 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 newtabninecommand is an OpenCode distribution covered by--targets opencode— see Supported tools > Deprecation notes. The legacy Tabnine CLI loads the project-rootTABNINE.mdas 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, soTABNINE.mdalso 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.mdfile", 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--deleteremoves 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
cortexCLI, also called "CoCo") reads the project-rootAGENTS.mdas its instruction file. It documents no modular rules directory and no user-level instruction file, so Rulesync folds every non-root rule into the singleAGENTS.md(the same fold mode Codex CLI uses for rules that are not directory-scoped) and therulesfeature is project-only (--globalis 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.mdas its project instruction file and then every.codewhale/rules/*.mdfile in filename order, so Rulesync writes the root rule toAGENTS.mdand each non-root rule to.codewhale/rules/<name>.md. Codewhale does not walk into nested subprojectAGENTS.mdfiles, soagentsmd.subprojectPathis not honored. In global mode (--global), Codewhale prepends~/.codewhale/AGENTS.mdto 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
continuetarget is frozen at that final source tree — see Supported tools > Deprecation notes. Continue (the open-source IDE extension andcnCLI) reads the project-rootAGENTS.mdas 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 toAGENTS.mdand non-root rules to.continue/rules/*.md. Continue documents no user-levelAGENTS.md, so in global mode the root rule is written as~/.continue/rules/AGENTS.mdand told apart from the other rules by its basename (the convention the Roo Code target also uses). A non-root rule carriesdescription,globsand the Continue-specificregex/alwaysApplykeys: a rule with noglobs/regexis always applied, one withglobsis applied when a file in context matches,alwaysApply: trueforces it on andalwaysApply: falsemakes it depend onglobs/regexonly. The universal globs**/*and*are therefore dropped on generate (a globless rule is already always-on), as is any glob next toalwaysApply: true. Thecontinuefrontmatter block (name,alwaysApply,regex, plusglobsanddescriptionoverrides of the canonical keys) authors those fields; on import a globless always-on rule maps toglobs: ["**/*"],alwaysApply: falsewithoutglobsmaps toglobs: [], andname/alwaysApply/regexare lifted into thecontinueblock. See the rules docs.
Command Code note: Command Code (the open-source terminal coding agent published as
command-codeon npm) reads the project-rootAGENTS.mdas its instruction file and the user-level~/.commandcode/AGENTS.md(global, via--global). AnAGENTS.mdin 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 singleAGENTS.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
qodercliterminal agent and the JetBrains plugin, which share one.qoder/layout) reads the project-rootAGENTS.mdplus the per-machineAGENTS.local.mdoverlay (written for alocalRoot: truerule), and auto-loads the topic rules under.qoder/rules/*.md; the user scope is~/.qoder/AGENTS.mdand~/.qoder/rules/*.md(global, via--global). Rulesync writes the root rule as plain Markdown toAGENTS.mdand each non-root rule to.qoder/rules/<name>.mdwith atriggerfrontmatter: specificglobsbecometrigger: globwith agloblist, and a rule with no globs or a match-everything glob (**/*) becomestrigger: always_on. Adescriptionis carried along. Setqoder.trigger(always_on,glob,manualormodel_decision, where Qoder needs a non-emptydescription) and optionallyqoder.globin the rulesync frontmatter to pick the mode yourself; aglobtrigger without specific globs is written asalways_onwith a warning, and amodel_decisiontrigger without a description is written with a warning. On import the compatibility spellings are read too (alwaysApply: true/falseasalways_on/manual, as Qoder documents them, a barepathsorgloblist asglob); amanualormodel_decisionrule keeps its mode in theqoderblock, and so does any frontmatter key rulesync does not map. Aglobrule with an empty glob list, which Qoder never applies, is imported withqoder.trigger: globand a warning; the next generate writes it asalways_on, so addglobsor setqoder.trigger: manualto 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 aPI_CODING_AGENT_DIRoverride 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-agentand follows the Pi mapping above forsessionStart,sessionEnd,preToolUse,postToolUse,postToolUseFailure,preModelInvocation,postModelInvocation,beforeSubmitPrompt,preCompactandpostCompact, with the same matcher, blocking and reason-sanitizing behavior, except that a failingbeforeSubmitPromptcommand cancels the prompt with oh-my-pi's{ handled: true }result instead of Pi's{ action: "handled" }.stopandnotificationare not supported yet: oh-my-pi has neitheragent_before_settlenorui_prompt_start, and mappingstopto oh-my-pi's ownsession_stopevent is not implemented. oh-my-pi fails atool_callclosed when its handlers exceedextensionHandlers.toolCallTimeoutMs(30 seconds by default), so a slowpreToolUsecommand blocks the tool call, and it givessession_shutdownhandlers only 2 seconds. The prompt gate is narrower than the tool gate: oh-my-pi firesinputonly for prompts submitted interactively or over RPC (print mode and ACP input bypass it), and aninputhandler that exceeds the 30-second handler budget lets the prompt through. Tool-scoped hooks go under anomp.hooksblock. See the oh-my-pi extension docs and extension loading docs.
Example:
{
"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 (currently1).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.afterFileEditfor Cursor/OpenCode/Kilo,worktreeCreatefor Claude Code,afterErrorfor Copilot/Copilot CLI,PostFileSave/PreTaskExecfor Kiro) can coexist with shared ones without leaking to other tools.copilotcli.hooksfalls back tocopilot.hooks, which in turn falls back to the sharedhooksblock.
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-cscript ofsh,bashand similar shells (combined flags such asbash -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(alsopython3orpython3.12),ruby,perlorphp, as innode ./x.jsorpython3 "./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 areenvand the assignments after it (env FOO=1 ./x.sh), although nothing is anchored after anenvoption (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 aspython3 -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
./xbecomes"$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 throughnpxthat is not a script runner, such asnpx 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.namewords are never anchored, and neither is an unquoted or double-quotedsh -cscript, which the inner shell would split again once expanded, nor a-cscript handed to another command (sudo sh -c '…', also written\sudo, orfind . -execdir sh -c '…' \;), which runs elsewhere if at all. A quote on the line aftersh -cstarts 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 ashellfield (Claude Code, including its plugin hooks); every other target runs the hook in its own shell, soshelldoes not change what is anchored there.Import converts the variable back to
./wherever it starts a path, so a hook such aspython3 "$CLAUDE_PROJECT_DIR/hooks/check.py"becomespython3 "./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), useseval, 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-cscript 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 theclaudecode.hooksoverride. A working directory changed by other means (for examplegit -C dir,env -C dir,uv run --directory dir,pnpm -r exec,pnpm --filter=pkg execorbun --cwd=dir run) or by an obfuscatedcd(such asc\d) is not detected, and neither is a path in a-cscript that follows a single quote embedded as'\''.- Anchored positions: the command word of each later command in a chain or pipeline, or inside
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 onlycommand); 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 deprecatedkiroalias's agent-config format ascache_ttl_seconds;0disables caching and Kiro never cachesAgentSpawnhooks.failClosed(optional): Boolean. Whentrue, 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-namedblockOnErrorflag), to Hermes Agent's~/.hermes/config.yaml(asfail_closed), and to Goose's pluginhooks.json(ason_failure: "block", Goose v1.48.0+). Hermes only honours it onpre_tool_call(its one blocking-capable event) and Goose only readson_failureonPreToolUse, so afailClosedset on any other canonical event is dropped with a warning for those targets.commandRegex(optional): Regex applied to the shell command string, narrowing anExecutematcher group further (e.g."^git "). Forwarded to Factory Droid, which skips invalid regex values. Likematcher, it belongs to the whole matcher group, so every hook sharing that matcher receives it.async(optional): Boolean. Whentrue, 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,commandhooks): 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, upstreamHookConfig.env, merged into the spawned command'sextra_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 aKEY=VALUEstring, 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 thebash/powershellfield the command is written into, and leaving it unset selects their portablecommandfield. Likeargs,asyncandasyncRewake, 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,httphooks): the POST target URL, request headers (values support$VARinterpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks.server/tool/input(optional,mcp_toolhooks): 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/agenthooks): 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 Cursorcommandhooks).args(optional,commandhooks): an argument list. When present — an empty list counts, and is the form the Claude Code docs use — the tool spawnscommanddirectly 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-onlyexec+argspair). Onlycommandis prefixed; entries ofargsare passed through exactly as written.asyncRewake(optional): boolean. Likeasync, 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 forcommand, 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, defaulttrue. Whether the hook is active. Forwarded to Kiro's standalone hooks file (.kiro/hooks/rulesync.json, written by thekiro-cliandkiro-idetargets) and to ZCode's per-hookenabledin.zcode/config.json/~/.zcode/cli/config.jsonand in a ZCode plugin'shooks/hooks.json— the places with a per-hook on-disk enable flag Rulesync writes; an importedenabled: falseround-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 settingenabled: false. (Antigravity has anenabledflag 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'srundecides whether the command executes. The optionalconfirmCommandinside the block names a command whose JSON stdout drives that prompt dynamically ({ "skip": true }suppresses it and skips the hook; a replacementquestion/optionsobject overrides the static prompt; on failure or timeout the static prompt is used). Forwarded verbatim to Kiro's standalone hooks file (kiro-cliandkiro-ide), which honors it onStop-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 assettings.jsonpermission 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,commandhooks): 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");commandthen 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 asourcekey 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
| Event | Amp | IBM Bob | Claude Code | Claude Code plugin | CodeBuddy Code | Codewhale | Codex CLI | Command Code | GitHub Copilot | GitHub Copilot CLI | Crush | Goose | Hermes Agent | Grok CLI | Cursor | deepagents-cli | Factory Droid | OpenCode | MiMo Code | GitLab Duo CLI | Cline | Kilo Code | Kimi Code | Tabnine CLI ⚠️ | Continue ⚠️ | Snowflake Cortex Code | Vibe Code | Vibe Code plugin | Qoder | Qwen Code | Reasonix | Kiro ⚠️ | Kiro CLI | Kiro IDE | Letta Code | Google Antigravity IDE | Google Antigravity CLI | Google Antigravity plugin | JetBrains Junie | AugmentCode | AugmentCode plugin | Devin Desktop | Devin plugin | Pi Coding Agent | oh-my-pi | ZCode (Z.ai) | ZCode plugin | Pool (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, andcwdChangedare the Claude Code events the matcher table lists as not supporting thematcherfield (they fire on every occurrence). A matcher authored on one of them is dropped with a warning rather than written intosettings.jsonto be ignored.directoryAddedis 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 forpreModelSwitchandpostModelSwitch(Claude Code v2.1.251), whose matcher is tested against the canonical name of the model the session switches to (e.g..*opus.*);preModelSwitchcan block the switch.
Note: Rulesync implements OpenCode hooks as a plugin at
.opencode/plugins/rulesync-hooks.jsand 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.jswith the OpenCode event mapping. Entries undermimocode.hooksare 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 forsessionStart→session.start,preToolUse→tool.call,postToolUse→tool.result,beforeSubmitPrompt→agent.start, andstop→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 failingpreToolUsecommand 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
$VARfor 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
powershellandbashfields for hooks, plus a portablecommandfield that upstream copies into both when neither is present. Rulesync picks between them with the canonicalshellselector, and writes the portablecommandfield 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 mapsbashto itsosx/linuxandpowershellto itswindowsoverrides; its own per-OS keyswindows,linuxandosx(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 nocommand— such an entry lands in the sharedhooksblock without a canonicalcommand, so every other target would generate it command-less; the import warns, and adding acommandfixes it. VS Code also documents that it ignoresmatchervalues, so a hook with amatcheris 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 carrybash/powershellwith optionaltimeoutSec, plus the canonicalenvmap and a pass-throughcwd. On import,timeoutis honored as an alias fortimeoutSecwhentimeoutSecis absent. Which command field is written is chosen by the canonicalshellselector; without it the portablecommandfield 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 onlybashandcommandare honored, so apowershellentry 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 tobash(with a warning) on every platform. VS Code and the coding agent both document~/.copilot/hooksas the user scope and load every*.jsonin that folder; the Copilot CLI's global file already occupiescopilot-hooks.jsonthere, so the VS Code target uses a distinct filename and the two never overwrite each other. Note the flip side of "every*.jsonis loaded": generating bothcopilotandcopilotcliin 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/powershellwith optionaltimeoutSec, plus pass-throughcwd/env; on import the portablecommandfield is read as the cross-platform fallback when neither shell field is present, andtimeoutis honored as an alias fortimeoutSecwhentimeoutSecis absent. On generate the canonicalshellselector choosesbashorpowershell; without it the portablecommandfield is written, so the generated file does not depend on the machine Rulesync ran on. An imported entry carrying both shell fields resolves tobash(with a warning) on every platform, so importing the same file yields the same canonical config everywhere. The CLI-only shell-free form —execnaming an executable plus anargslist, spawned without a shell — is the canonicalcommand+argsexec form: a canonicalargslist (an empty one counts) makes Rulesync writeexec/argsinstead of a shell field, dropping anyshellselector with a warning, and an importedexecentry lands ascommand+args(an empty list when upstream omittedargs) rather than as a hook with no command; anexeccombined withbash/powershell/command, which the hooks reference says not to do, imports as theexecwith a warning),prompt(apromptstring — Copilot CLI only honors prompt hooks onsessionStart, so prompt hooks on other events are dropped), andhttp(url/headers/allowedEnvVarswith optionaltimeoutSec). An entry's optionalmatcherfield is emitted and round-tripped on the six events the hooks reference documents as matcher-aware —preToolUseandpostToolUse(regex on the tool name),permissionRequest(tool name),notification(notification type),preCompact(the trigger,manualorauto) andsubagentStart(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 dedicatedhooks.json(a Claude-Code-style matcher map nested under a generatedrulesynchook 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 standalonehooks.jsonis keyed directly by event name ({"PreToolUse": [...]}); thehookswrapper Droid documents belongs tosettings.jsononly, so Rulesync writes the bare event map. The file is Rulesync-owned and rewritten wholesale, which also repairs ahooks.jsonan 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 thehookskey otherwise, which is also how the legacy.factory/settings.jsonread-time fallback is understood. Import falls back in order:.factory/hooks.json, then the.factory/settings.jsonhookskey (with.factory/settings.local.jsonoverlaid, since Droid reads the pair as one) when those settings actually carry ahookskey — an unrelatedsettings.local.jsontherefore does not shadow the next step — then the pre-1.0.factory/hooks/hooks.json— last because Droid renames that file tohooks.migrated.jsononce it has migrated it, so a copy still sitting there is the least likely to be live. Only.factory/hooks.jsonis ever written. As with permissions below, a hook that came from the machine-local file is imported into.rulesync/hooks.jsonlike any other, so the nextgeneratewrites 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.jsonafter 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-levelhookskey of the shared settings file (which also holdstoolPermissions).- IBM Bob — project:
<project>/.bob/settings.json; global:~/.bob/settings/settings.json. Hooks are merged under the top-levelhookskey 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-levelhookskey 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-levelhookskey of either file; the sibling keys Cortex Code keeps there are left untouched, and neither file is removed by--delete. The per-hookenabledflag and the Remote Hookssourceobject ({ "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-levelhookskey 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-levelhookskey of the settings file, next topermissions; the sibling keys CodeBuddy keeps there are left untouched, and the file is never removed by--delete. The gitignored.codebuddy/settings.local.jsonlayer is left to CodeBuddy.- Command Code — project:
<project>/.commandcode/settings.json; global:~/.commandcode/settings.json. Hooks are merged under the top-levelhookskey of the settings file, next topermissions; 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-levelhookskey of the settings file, next topermissions; the sibling keys Letta Code keeps there (including thehooks.disabledswitch) 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-levelhookskey of the settings file, next topermissionsand (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--deleteremoves 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-hooksor setGITLAB_ENABLE_PROJECT_HOOKS=trueto run them. OnlysessionStart⇄SessionStartis documented, so every other event is skipped with a warning. Onlycommandhooks are emitted;matcher(a regex on the session source,startuporresume) andtimeout(seconds) round-trip. The CLI exportsDUO_PROJECT_DIRto 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.jsonwhen that file already exists); global:~/.config/crush/crush.json. Hooks are written as the flathooks.PreToolUselist 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 thehooksblock of Pool's shared YAML settings file; the sibling keys Pool keeps there (pool,mcp_servers,tools,paths, ...) and the non-event keys of thehooksblock 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 theenable_experimental_hooksflag: declaring a hook is enough, so Rulesync writes nothing into.vibe/config.tomlfor hooks.
Note: Claude Code hooks are merged under the
hookskey 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$schemathe 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-cliandkiro-idetargets write the standalone{ "version": "v1", "hooks": [ … ] }file that both products read today —.kiro/hooks/rulesync.jsonin project scope and~/.kiro/hooks/rulesync.jsonin user scope — with one array entry per hook carryingname,trigger, an optionalmatcher, anaction({ "type": "command", "command": … }for a canonicalcommandhook,{ "type": "agent", "prompt": … }for aprompthook), an optionaltimeoutin seconds,enabled, and — onStop-trigger command hooks — the optionalconfirmprompt (with its nestedconfirmCommand). Triggers are PascalCase (sessionStart⇄SessionStart,stop⇄Stop, …); triggers with no canonical event, such asPostFileSaveorPreTaskExec, are reachable through the sharedkiro.hooksoverride block and pass through verbatim. Both targets write the same filename, so they read that one block rather than per-targetkiro-cli.hooks/kiro-ide.hooksblocks — 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 underkiro-cli.hooksorkiro-ide.hooksis read by nothing and reported with a warning; move it tokiro.hooks(the same key the deprecatedkiroalias, and the Kiro MCP and permissions wiring, already use). A second filename would not help either, since Kiro runs every*.jsonin the directory and both products read the same one. The deprecatedkiroalias 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, …) inkiro.hooksis dropped from the alias's.kiro/agents/default.jsonoutput 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
kiroalias still writes the older embedded format:.kiro/agents/default.jsonunder thehooksfield, merged with any existing agent configuration (tools, allowedTools, etc.). There, bothsessionEndandstopmap to Kiro'sstopevent, onlycommand-type hooks are supported (prompt-type hooks are silently skipped), per-hook timeouts aretimeout_ms(milliseconds), andcache_ttl_secondsmaps to the canonicalcacheTtlfield in both directions. Kiro's hooks migration guide states this format "does not work in 3.0", so preferkiro-cli.If you generated
kiro-clihooks with an earlier Rulesync version, thehooksblock it left in.kiro/agents/default.jsonis 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 hooksnow 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 noSessionEnd, so a canonicalsessionEndhook is dropped with a warning — usestopinstead — andcacheTtlhas no counterpart outside the agent-config format.
Note: Antigravity (IDE and CLI) writes a dedicated
hooks.jsonkeyed 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 namerulesync. It supports five lifecycle events —preToolUse⇄PreToolUse,postToolUse⇄PostToolUse,preModelInvocation⇄PreInvocation,postModelInvocation⇄PostInvocation, andstop⇄Stop— wherePreInvocation/PostInvocation/Stopare 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-hookenabledflag is ignored.
Note: Devin Desktop (formerly Windsurf) Cascade Hooks (GA) are written to a dedicated
hooks.jsonwhose top-levelhookskey maps each Cascade event name to a flat array of hook objects (nomatcher, notype, no innerhookswrapper, and notimeout). Each object carriescommandand/orpowershell, plus optionalshow_outputandworking_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, andworktreeCreate⇄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
hookskey 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": ... } ] } ] }). Thehooksblock is merged in place so it coexists with thetoolPermissionsblock from the permissions feature. Auggie documents that its settings files accept JSON with Comments (comments and trailing commas), so Rulesync readssettings.json/settings.local.jsonas 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, andbeforeSubmitPrompt⇄PromptSubmit(added in Auggie 0.27.0). Thematcherfield (a case-sensitive regex, default.*, withmcp:*support) applies only to the tool eventsPreToolUse/PostToolUse; any matcher on the session events (includingNotificationandPromptSubmit) is dropped with a logged warning. Two Auggie-specific fields round-trip as well: a command hook'sargs(extra argv the runner appends, authored asargson the canonical hook) and the matcher group'smetadata(includeConversationData/includeMCPMetadata/includeUserContext, which select what the runner puts in the JSON payload the script receives).metadatabelongs 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 thehookskey is owned in the shared settings file: a value not written here is erased from a hand-writtensettings.jsonon the next generate. Commands are emitted verbatim — Auggie exposesAUGMENT_PROJECT_DIRas a runtime environment variable, not as an inline command substitution, so no directory prefix is added. Onlycommand-type hooks are supported. Auggie expressestimeoutin milliseconds (default60000), 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 ofsettings.json— and combines it over the base settings before importing, following Auggie's documented layering (simple values take the local override,mcpServers/pluginsreplace wholesale, and other objects/lists — including thehooksevents — 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 writessettings.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-plugintarget writes the same hooks format into a plugin bundle'shooks/hooks.json(project scope only) as a{ "hooks": { ... } }document that Rulesync owns as a whole, reading theaugmentcodeoverride 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
hookskey 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 thehookskey 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), andstop⇄Stop. Thematcherfield applies only to the tool eventsPreToolUse/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).commandandhttphooks are supported: Bob Shell spells the webhook handlerhttps({ "type": "https", "url": "...", "timeout": <seconds> }), so a canonicalhttphook is written withtype: "https"and read back ashttpon import;prompt,agentandmcp_toolhooks are skipped. Bob-only entries can be authored under thebob.hooksoverride 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
hookskey 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 thehookskey is rewritten, so the other settings Tabnine keeps in that file survive a regenerate — including the siblinghooksConfigblock (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, andnotification⇄Notification. Tabnine accepts amatcheron every event: a regex over the tool name onBeforeTool/AfterTool, an exact string on the other events (for example theNotificationkind), with an omitted matcher or.*meaning match-all — so the canonical catch-all"*"is emitted as no matcher. Tabnine expressestimeoutin milliseconds (default60000), so the canonical seconds value is multiplied by 1000 on generate and divided on import;name,descriptionandsequential(which makes the hooks of one matcher group run in order) round-trip through the canonical entry, andenvis written only when every entry is a safeKEY=VALUEpair (an unsafe entry is dropped with a warning). Commands are emitted verbatim — Tabnine expands$TABNINE_PROJECT_DIRat runtime, so keep it in the command rather than a resolved path — and onlycommand-type hooks are supported; a hook with nocommandis skipped with a warning because Tabnine would discard it. Tabnine-only entries can be authored under thetabnine.hooksoverride key. See the hooks docs and the hook configuration reference.
Note: Snowflake Cortex Code hooks are merged under the top-level
hookskey 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 thehookskey 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, andsetup⇄Setup. Bothcommandandprompthook types are written (httphooks are skipped with a warning). Cortex Code compilesmatcheras 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 onUserPromptSubmitorStopis dropped with a logged warning.timeoutis in seconds (default 60) and the per-hookenabledflag 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 thecortexcode.hooksoverride 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, andstopFailure→session_error. Onlycommandhooks are emitted; Codewhale runs them through the shell. On the three tool events (preToolUse,postToolUse,afterError) a matcher becomes atool_namecondition (a|-separated list becomes ananycondition, 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 exampleexec_shell,write_file,edit_file), which Rulesync does not translate.timeoutbecomestimeout_secs(a positive whole number of seconds; note that a globaldefault_timeout_secsoverrides every per-hook value), andname,background,continue_on_errorand a raw Codewhaleconditionobject pass through. Codewhale-only events (mode_change,shell_env,session_idle,waiting_for_user,session_busy) can be written by their native name undercodewhale.hooks, where import also files them, and are emitted verbatim. Codewhale runs a project.codewhale/hooks.tomlonly 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
hookskey 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 thehookskey 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,worktreeCreateandworktreeRemove. Continue acceptscommand,prompt,httpandagenthooks (themcp_toolandfunctiontypes are skipped with a warning); aprompt/agenthook carries itsmodel, and thestatusMessage,onceandasync(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. Anhttphook'sheadersare written as authored into the committable.continue/settings.json— Continue expands$VAR/${VAR}references in hook headers for the variables named in the hook'sallowedEnvVars, so writeAuthorization: Bearer $TOKENwithallowedEnvVars: ["TOKEN"]rather than a literal secret. Continue-only entries can be authored under thecontinue.hooksoverride key. See the Continue CLI hooks source.
Note: CodeBuddy Code hooks are merged under the top-level
hookskey 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 thehookskey is rewritten, so thepermissionsblock 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),promptandagenthooks are emitted (mcp_toolis skipped with a warning), and a prompt hook'scontinueOnBlockflag is carried both ways. CodeBuddy documents prompt hooks forStop,UserPromptSubmitandPreToolUseonly; rulesync does not filter prompt hooks per event, so keep them on those events.matcheris passed through verbatim;UserPromptSubmit,Stop,SubagentStopandPostCompact(and the plugin-only events that fire on every occurrence) take none, so a matcher there is dropped with a logged warning.timeoutis 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 thecodebuddy.hooksoverride key. See the hooks docs and the plugins reference.
Note: Command Code hooks are merged under the top-level
hookskey 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 thehookskey is rewritten, so thepermissionsblock 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⇄StopandsessionStart⇄SessionStart. Onlycommandhooks exist (prompt,httpand the other types are skipped with a warning).matcheris 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,SEARCHfor grep,GLOB,LIST,WEB_FETCH,WEB_SEARCH, ...;*matches every tool), so a Claude-Code-styleBashmatcher never fires there — write Command-Code-specific matchers under thecommandcode.hooksoverride key.StopandSessionStarttake none, so a matcher there is dropped with a logged warning.timeoutis 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 thecommandcode.hooksoverride key. See the hooks docs.
Note: Letta Code hooks are merged under the top-level
hookskey of.letta/settings.json(project) /~/.letta/settings.json(global), in Claude Code's per-event matcher-array shape. The event keys ofhooksare rewritten as a whole, so existing Letta Codeprompthooks there are replaced (with a warning); thehooks.disabledswitch, thepermissionsblock 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, andbeforeSubmitPrompt⇄UserPromptSubmit. Onlycommandhooks are emitted (Letta Code also runsprompthooks, but only on some events, sopromptand the other types are skipped with a warning).matcheris kept on the four tool events (PreToolUse,PostToolUse,PostToolUseFailure,PermissionRequest) and dropped with a warning elsewhere.timeoutis 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 asLETTA_WORKING_DIR), so commands are written verbatim. Letta-Code-only entries can be authored under thelettacode.hooksoverride 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
hookskey of.qoder/settings.json(project) /~/.qoder/settings.json(global), in Claude Code's per-event matcher-array shape; the per-machine.qoder/settings.local.jsonis left to you. The event keys ofhooksare rewritten as a whole, while thepermissionsblock, themcpServersmap 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). Thecommand,http,promptandagenthandler types are emitted (mcp_toolis skipped with a warning), withtimeoutin seconds,modelonprompt/agenthooks,url/headers/allowedEnvVarsonhttphooks,if,statusMessageandonceon any hook, andshell,env,args,asyncandasyncRewakeoncommandhooks.matcheris dropped with a warning onUserPromptSubmit,Stop,CwdChanged,WorktreeCreate,WorktreeRemove,TaskCreated,TaskCompletedandTeammateIdle, 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 theqoder.hooksoverride key. See the hooks docs. Note: Crush hooks are written under the top-levelhookskey 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 } ] } }.PreToolUseis the only event Crush fires (the only one its hooks docs list), sopreToolUse⇄PreToolUseis the whole mapping and every other canonical event is skipped with a warning; onlycommandhooks are supported.matcheris 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.timeoutis 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.jsonandcrush.jsonand merges them (objects recursively, lists concatenated,.crush.jsonon top), so neither file hides the other. When a.crush.jsonalready exists it is the file written (otherwisecrush.json); if thecrush.jsonnext to it still carries the keys rulesync owns — for example from a generate run before.crush.jsonwas 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.jsonis read. Only thehookskey is rewritten, so theproviders,options,mcpandpermissionskeys 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-basedcrushrccompiles itshook addbuiltin into the samehooks.PreToolUselist and overrides the JSON key by key, so acrushrcnext to the generated file takes precedence. Import readshooks.PreToolUseback aspreToolUse(Crush accepts the key in any case and with underscores, sopre_tool_usefolds into the same event); any other event key in the file is filed under thecrush.hooksoverride so it round-trips verbatim. See the hooks docs.
Pool note: Pool (Poolside's coding agent CLI) hooks are written under the top-level
hookskey of Pool's shared YAML settings file —.poolside/settings.yaml(project; the committed, team-shared file — the untracked.poolside/settings.local.yamloverlay 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⇄SessionStartandstop⇄Stop; every other canonical event is skipped with a warning, and onlycommandhooks are supported (acommandhook without a command is skipped with a warning). Pool's schema requiresmatcheron every entry but only tests it onPreToolUseandPostToolUse, against the tool name (""or*matches any tool, a bare name is an exact match,a|ba list, anything else a regular expression), so the canonicalmatcheris 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.timeoutis 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 astool_nameandtool_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 samenameat 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 thehooksblock are rewritten:stop_hook_max_continuations(the cap Pool puts onStopcontinuations) 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 thepool.hooksoverride 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 eventtype, acommand, and optionalname,timeout(seconds, default 60), anddescription. Tool-hook entries (pre_tool/post_tool) additionally carry a tool-namematch(an fnmatch glob likebash/mcp_*or are:-prefixed regex, case-insensitive — the canonicalmatcherfield;*means "any tool") and an optionalstrictflag;post_agentcarries neither. Three events are supported —preToolUse⇄pre_tool,postToolUse⇄post_tool, andstop⇄post_agent(fires after every assistant turn that ends without pending tool calls). Onlycommand-type hooks are emitted. Thecommandruns through a shell, so pipes,&&, redirects and$VARwork. Vibe v2.25.1–v2.25.4 briefly skipped any hook whosecommandcontained 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 theenable_experimental_hooksflag, so declaring a hook is enough and Rulesync no longer writes an auxiliary.vibe/config.toml.HookTypeis a strict enum upstream, so an entry using an old name is rejected outright. Import still reads the three old spellings, so ahooks.tomlleft behind from before the rename is repaired into the current names rather than being lost. Thevibe-plugintarget writes the same format into a plugin bundle'sai.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>.ps1on Windows. Rulesync emits a wrapper script per configured event in both spellings (the POSIX one with mode0755, since Cline spawns the file itself), plus arulesync-hooks.jsonmanifest 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 exiting2cancels the task, any other non-zero exit is surfaced througherrorMessagewithout 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'sVALID_HOOK_TYPESand the SDK/CLI'sHookConfigFileName, which dropsNotificationbut addsTaskErrorandSessionShutdown. 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.ps1is a no-op off Windows, and the extensionless script is a no-op on Windows. Both are needed: off Windows the runtime runs the.ps1throughpwsh, and on Windows it infers the extensionless file's interpreter from its#!/bin/bashshebang and normalizes it to a barebash, so with Git Bash onPATHthe two spellings both execute your commands — a genuine double fire. (The Unix side was noise rather than duplication: the.ps1body shells out throughcmd /c, which Unix does not have, so it failed on every fire instead.) The PowerShell guard tests that$IsWindowsis both defined and false, since it does not exist at all in the Windows PowerShell 5.1 thatpowershell -Filestarts; the POSIX guard matches$OSTYPE/unameagainst themsys/cygwin/mingwfamily.TaskCancelfires instead ofTaskCompletewhen a task is aborted (the VS Code extension runs it fromafterRunwhen the result status isaborted, the SDK/CLI asagent_abort), so it is the same shape as canonicalstopCancelledon Grok CLI and Codex CLI; it is observation-only and cannot cancel anything. Cline'sTaskResumehas no canonical counterpart and is left unmapped; onlycommand-type hooks are supported, andmatcheris ignored because the wrapper is a plain shell script with no payload parser. Each command is passed tobash -cas a single quoted argument, so its own quotes and operators cannot break the wrapper; a command that is not valid shell syntax is reported througherrorMessageinstead of cancelling (an unparseable command would otherwise exit2). Note that the same command string runs underbashon Unix andcmd /con 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 arulesync-owned: cline-hooksmarker 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 theclinetarget with--deleteremoves the marked scripts outright; andrulesync gitignorelists 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.gitignoreif 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 (AgentHooksfrom@cline/core) is a separate mechanism that Rulesync does not target. SeeVALID_HOOK_TYPESandHookConfigFileNamein the Cline source.
Note: Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory
hooks/hooks.jsonthat 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, andafterShellExecution⇄AfterShellExecution— matching Goose'sHookEventenum (it has noSubagentStart/SubagentStop; its twelfth value,PreToolUseResultadded in v1.48.0, has no canonical counterpart yet and is not emitted). Thematcherregex is preserved, commands are emitted verbatim (Goose exposesPLUGIN_ROOTas a runtime environment variable), and onlycommand-type hooks are supported. A canonicalfailClosed: trueon apreToolUsecommand hook is written as Goose'son_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;falseis omitted as Goose's default, and on importon_failure: "block"/"allow"read back asfailClosed: true/false. Goose only readson_failureonPreToolUse, so afailClosed: trueon any other event is dropped with a warning rather than emitted. One exception applies to the matcher: Goose compiles it withRegex::newand 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
hookskey 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), andsessionDelete⇄SessionDelete(fires after an explicitly selected session is deleted, via the interactive/deletecommand or the ACPdeleteSessionrequest; matcher-less, added in Qwen Code v0.21.3). Commands are emitted verbatim (no$GEMINI_PROJECT_DIRrewriting). Qwen's four hook types are supported:command,prompt(which carries the requiredpromptbody — with$ARGUMENTSinterpolation — and an optionalmodeloverride, both round-tripped; a prompt hook without apromptis warned about at generate time since Qwen Code loads it and fails it at runtime),http(which carries aurland POSTs JSON to it; the type and URL round-trip), andfunction. Per-hook fields added in Qwen Code PR #2827 round-trip as well: command hooks carryasync(run in the background),env(extra subprocess environment variables), andshell(bash/powershell); http hooks carryheaders(with${VAR}interpolation),allowedEnvVars(the env-var allowlist), andonce(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 canonicaltimeout(seconds) is converted for command hooks: since Qwen Code v0.23.4 (PR #11615) a command hooktimeoutis read in seconds, but a value of1000or more keeps its legacy millisecond meaning, so a canonicaltimeoutbelow1000is written as is and one of1000or more is written in milliseconds (1800becomes1800000). On import, a command hooktimeoutof1000or more is read as milliseconds and divided by 1000 (10000becomes10). http and prompt hook timeouts are always seconds upstream and pass through unchanged, as dofunctionhook timeouts (milliseconds upstream, SDK-registered). The group-levelsequentialflag (parallel by default) and the top-leveldisableAllHooksswitch are both round-tripped, and other top-level keys insettings.jsonare 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 (nomatcher/hookswrapper):{ "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ..., "cwd": "...", "env": { ... } } ] }. All thirteen of Reasonix's events (theEventslist ininternal/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, andpreCompact⇄PreCompact.match(Reasonix's matcher field name) is honored only on the tool-scoped eventsPreToolUse/PostToolUse/PostToolUseFailure/PermissionRequest; a matcher on any other event is dropped with a warning. The canonicaltimeoutfield is documented in seconds, while Reasonix'stimeoutis milliseconds, so rulesync converts (× 1000on generate,÷ 1000on import). Each entry also carries the canonicalenvmap and a pass-throughcwd(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. Onlycommand-type hooks are supported. Thesettings.jsonfile 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.jsonthat 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-levelhookskey 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, andpostCompact⇄PostCompact.StopCancelledruns instead ofStopwhen a turn ends without completing — a user interrupt, a declined permission prompt, the--max-turnslimit, or a no-progress bail-out — so astophook alone does not cover interrupted turns; it is observation-only and cannot block. Amatcher(a regex) is honored on every event exceptStopandUserPromptSubmit, 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 onPreToolUse/PostToolUse/PostToolUseFailure/PermissionDenied, the notification type onNotification(e.g.idle_prompt), the subagent type onSubagentStart/SubagentStop(e.g.explore), the start source onSessionStart, the end reason onSessionEnd, the compaction trigger (manualorauto) onPreCompact/PostCompact, the error type onStopFailure(rate_limit,authentication_failed, …), and the cancellation reason onStopCancelled(user_interrupt,permission_rejected,permission_cancelled,max_turns,no_progress, orunknown). 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: acommandhook runs a command, and anhttphook POSTs the payload to itsurl. A command hook'senvmap (upstreamHookConfig.env, merged into the spawned command'sextra_env) round-trips as well. Note that a.rulesync/hooks.*obtained withrulesync fetchcan 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, withevent,command, and optionalmatcher/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, andpostCompact. Kimi's nativePermissionResult,Interrupt,TurnStarted,UserPromptQueued,TaskStarted, andSessionHeartbeatevents are not mapped from canonical events for Kimi (Interruptis the same shape as canonicalstopCancelled, 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 thekimi-code.hooksoverride under their native names. (TaskStartedis deliberately not folded into the canonicaltaskCreated: it fires when a background task starts and matches on task kind, whiletaskCreatedmodels Claude Code's blocking, matcher-lessTaskCreatedfired during task creation.) Onlycommandhooks are emitted. A matcher is dropped with a warning onStop,SessionHeartbeat, andInterrupt, the three events whose Event Reference row documents the matcher as an empty string. Kimi treatsmatcheras 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 (UserPromptSubmitthe submitted prompt text,PermissionRequestandPermissionResultthe tool name,PreCompactthe 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 asnpm 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$PWDorgit rev-parsesees the source directory instead; such a hook can read the session project from thecwdfield Kimi passes on stdin, or you can set"kimi-code": { "runFromSourceDirectory": false }in.rulesync/hooks.jsoncto 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 requirestimeoutto 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 canonicalstopCancelledevent maps to Codex'sInterrupt(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 configuredmatcheron it, its output cannot prevent the interruption (only an optionalsystemMessageJSON is honoured), and command timeouts are capped at 3s (1s by default). Rulesync forwards the hook as authored, so keeptimeoutan 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:
{
"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.jsoninstead, so a previously generated project-scope.copilot/mcp-config.jsonis 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:
---
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-ideandantigravity-clicommands are emitted as skills (.agents/skills/<name>/SKILL.md, invoked as/<name>) instead of.agents/workflows/<name>.md. The workflow-onlyturbooption and the# Workflow:header are no longer written. Legacy workflows are still imported byrulesync import. Runningrulesync generate --deleteremoves every*.mdfile 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 byrulesync generate --deletewith 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
codexclicommands still generate the global-only~/.codex/prompts/*.mdcustom-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'scodexcliskills support (see.rulesync/skills/*/SKILL.mdbelow) instead.
GitHub Copilot prompt-files note: the
copilottarget 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'scopilotcommands 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'scopilotskills support (see.rulesync/skills/*/SKILL.mdbelow), 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]/$Nargument 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), withname/descriptionfrontmatter 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--deleteare no-ops forwarpbecause 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 sameSKILL.mdpath. 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), withname/descriptionfrontmatter 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--deleteare no-ops fordevinbecause 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 sameSKILL.mdpath, so keep command and skill names distinct for this target. Thedevin-plugintarget writes skills into a plugin bundle'sskills/<name>/SKILL.mdin 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-toolsbecomes a space-separated scalar (a YAML list is joined),compatibilitybecomes a string (an object is flattened tokey: valuepairs), andmetadatavalues are stringified so the block stays a string→string map. Generation also checks the normative constraints and warns — without failing the run — whennameis empty, longer than 64 characters, contains anything but lowercase letters, digits and single hyphens, or does not match its parent directory name; whendescriptionis empty or longer than 1024 characters; whencompatibilityexceeds 500 characters; or when anallowed-toolslist 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-descriptionviolation, 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.hermesagentreads 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 requirescompatibilityto be 1–500 characters when present. On import,allowed-toolsis normalized back to the canonical rulesync list, so a generate → import round trip leaves.rulesync/skills/**in the shape it started in (thecompatibilityandmetadatacoercions are one-way, because the legacy object/number forms have no conformant equivalent).hermesagentreads the sameagentsskillsblock and applies the same normalization in both directions, so one rulesync source never produces two different on-disk spellings — except formetadata, which stays structured there because Hermes readsmetadata.hermes.*as YAML. Ahermesagent: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 ownskills-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.mdwhose 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-levelkey: valuelines 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, soallowed-tools: Read # TODO: add Bash lateris 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-toolsaccepts either the spec's space-separated string or a canonical rulesync list and is always emitted as the string;replit.compatibilitylikewise accepts the spec's string alongside the legacy object form. On import,allowed-toolsis normalized back to the list, mirroringdeepagents— so keep list entries free of whitespace, since the space-separated form cannot represent an entry such asBash(git commit:*)and a client would read it back as two. An objectcompatibilityis 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
descriptionas optional in a skill'sSKILL.md— "Ifdescriptionis 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 missingdescriptionis filled in the same way, from the first body paragraph (wrapped lines joined into a single line, since it becomes a YAML scalar); aSKILL.mdJunie 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 Nameyields 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 adescriptionexplicitly. 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 explicitdescription, 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 (theagentsskillstarget 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 forkimi-code. Names are compared case-insensitively there too, so.agents/skills/My-Skilldoes 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-locationor theskill-locationskey of itsconfig.json— is out of scope for thejunietarget: Rulesync neither reads nor writes those paths, so a skill kept only there stays invisible torulesync 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'suser_skills_dirsreturns~/.vibe/skillsand~/.agents/skillsalike. 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 namesvibeandskill-creatorare reserved for Vibe's built-in skills: itsSkillManagerskips 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 shippedvibeplugin 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-invocationrequires Vibe v2.25.5 or later. Since v2.25.5 Vibe honorsdisable-model-invocation: trueinSKILL.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 thevibe:block, falling back to the root-leveldisable-model-invocation, and round-trips it into thevibe: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. Thevibe-plugintarget writes the same format into a plugin bundle'sskills/<name>/SKILL.md(project scope only). See the plugin packaging guide.
Pi skills note: Pi implements the Agent Skills specification, so
pi.allowed-toolsaccepts either the spec's space-delimited string or a canonical rulesync list and is always emitted as the string;pi.compatibilitylikewise accepts the spec's string alongside the legacy object form. Importing a spec-conformantSKILL.mdused to fail outright. On import,allowed-toolsis normalized back to the list, mirroringdeepagents; keep list entries free of whitespace, since the space-delimited form cannot represent an entry such asBash(git commit:*). Anallowed-toolsvalue that normalizes to the empty string (an empty list) is dropped rather than written. An objectcompatibilityis 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 therulesync-commandsplugin under~/.hermes/plugins/, and enables it in~/.hermes/config.yaml. The plugin registers each spec with Hermes'sctx.register_command()plugin API and dispatches the prompt throughdelegate_task; invocation arguments are appended to the prompt..rulesync/skills/<name>/SKILL.mdstill 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 withrulesync 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.mddoes 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; besidesdescription, Qwen Code's command loader readswhen_to_use(invocation guidance),argument-hint(completion hint), anddisable-model-invocation, all typed and round-tripped. Subdirectory namespacing is supported:.qwen/commands/git/commit.mdbecomes the/git:commitcommand. Any extra fields are preserved on round-trip under theqwencode:block.
OpenCode import note: OpenCode lets commands live both as Markdown files under
.opencode/commands/*.mdand inline inopencode.json/opencode.jsoncunder the top-levelcommandkey. On import, rulesync reads both: each inline entry'stemplatebecomes the command body and itsdescription/agent/model/variant/subtaskfields become frontmatter. A Markdown file takes precedence over an inline entry with the same name. Entries under the OpenCode V2 spellingcommandsare read too, since OpenCode V1 also loads them (from v1.18.24): an entry without atemplateis skipped, aprovider/model#variantmodel (or a{ providerID, model, variant }object) is split intomodelandvariant, and acommandentry wins over acommandsentry of the same name, as in OpenCode. Asubagentin acommandsentry is read assubtasklike everywhere else, although OpenCode V1 itself ignores it there. OpenCode V2 renamedsubtasktosubagent(keepingsubtaskas a deprecated alias), while OpenCode V1 accepts onlysubtask, so rulesync readssubagentassubtask— in Markdown files, inline entries, and theopencode(or MiMo Code'smimocode) key of.rulesync/commands/*.mdalike, withsubagentwinning when both are set, as in V2 — and always writessubtask, 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/goalname 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 isgoalfor thekilotarget with a warning, at both scopes, and generates the rest; rename the command or exclude it fromkiloviatargets. The check runs on the name as written — with the default basename flatteningnested/goal.mdis also skipped, while with theflattenedCommandNaming: "path"config option it becomesnested-goal.mdand is written. Import is unaffected: agoal.mdalready in.kilo/commands/is read as it is. Seepackages/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 amimocodekey, which takes the same fields as theopencodekey; on import, inlinecommandentries in.mimocode/mimocode.json(c)are read as for OpenCode, except the V2commandsspelling, 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'sprompts/directory. The format is Pi's (descriptionandargument-hint, with$1/$@/$ARGUMENTSplaceholders); tool-specific frontmatter goes under anompkey, which takes the same fields as thepikey.
GitLab Duo CLI note: Custom slash commands are Markdown files under the cross-tool
.agents/commands/(project, the same directory theagentsmdtarget writes) and~/.gitlab/duo/commands/(global, via--global), invoked as/<name>where the name is the file stem. Only the optionaldescriptionfrontmatter 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.mdis/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;--deleteremoves 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 theagentsmdtarget, 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. Theaugmentcode-plugintarget writes the same format into a plugin bundle'scommands/directory (project scope only). Auggie reads onlydescriptionandmodelfrom a plugin command, so every other field in theaugmentcodesection (such asargument-hint) is dropped with a warning; import reads only the bundle's owncommands/, 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 supportsdescriptionandargument-hint, and the body uses the same$ARGUMENTS/$1…$Nplaceholder syntax. Subdirectory namespacing is supported (git/commit.md→/git:commit). Any extra fields are preserved on round-trip under thereasonix: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 emitsdescriptionplus, from thegrokcli:block,argument-hint,user-invocable(default true) anddisable-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 nestedgit/commit.mdis flattened ontocommit.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 thegrokcli: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.ymlmanifest 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.ymlmanifest with one{ name, description, content_file }entry per prompt,content_filepointing atprompts/<name>.md(resolved relative toprompts.yml, matching Rovo Dev's own resolution order). Thepromptsarray is fully replaced from the current rulesync commands on each generate (mirrors the Rovodev MCP adapter fully replacingmcpServers); 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 thedescriptionshown in the command picker plus anargument-hint. ZCode's command scan is flat, so subdirectory namespacing is not modeled. Any extra frontmatter fields are preserved on round-trip under thezcode:block. See the ZCode commands docs. Thezcode-plugintarget writes the same format into a plugin bundle'scommands/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-plugintarget writes commands into a plugin bundle'scommands/directory (project scope only), keeping subdirectories. Kimi registers each one as/<plugin>:<name>, taking the name from the relative path unless frontmatternameoverrides it, and substitutes$ARGUMENTSin the body. Rulesync writesdescriptionplus any keys of an optionalkimi-code-pluginsection (such asname) as frontmatter, and import moves every key other thandescriptionback 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.mdbecomes the/frontend:componentcommand. Rulesync emits the canonicaldescription(a same-named key in thecodebuddy:block is ignored); the other documented keys (argument-hint,model,allowed-tools,disable-model-invocation) and any extra ones round-trip through thecodebuddy: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 frontmatternamereplaces it. A project command cannot take the name of a protected built-in such as/trust,/undo,/configor/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 frontmatternameis not a single slash-command token (Codewhale then falls back to the file stem).descriptionis written from the canonical field, and the other keys of thecodewhalesection (name,usage,arguments,argument-hint,allowed-tools,pausable,alias/aliases,hidden) are written beside it. Codewhale reads the frontmatter line by line askey: valuepairs 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 splitsallowed-toolsandaliaseson), 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.$ARGUMENTSand$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>.mdper command, invoked as/<name>. The optional YAML frontmatter carriesdescriptionandargument-hint; unknown keys are preserved through thebobfrontmatter 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>.tomlper command with adescriptionstring and thepromptbody, invoked as/<name>. Subdirectory namespacing is supported (.tabnine/agent/commands/git/commit.tomlbecomes/git:commit), so nested paths under.rulesync/commands/are kept as-is. Tabnine CLI documents the Gemini CLI placeholders, so the universal$ARGUMENTSis translated toand!`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 thetabninefrontmatter block: an import puts them there and a generate writes them back afterdescriptionandprompt. 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>.mdper command, invoked as/<name>once the frontmatter declaresinvokable: true. Rulesync always writesname(the file stem),descriptionandinvokable: 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 thecontinuefrontmatter block; an import dropsinvokable(re-emitted on generate) and keepsnamethere 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>.mdper 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.mdbecomes/git:commit), so nested rulesync commands keep their subdirectories. Rulesync writes the rulesyncdescription, which Qoder requires (a command without one is written without it), and the fields of theqoderfrontmatter block on top (for examplename, which Qoder only uses as the display label in its command list); on import every key other thandescriptiongoes back into theqoderblock. See the commands docs.
JetBrains AI Assistant note: the
aiassistanttarget 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 keyllm.chat.input.language.user.commands.enabled(defaultfalsein the plugin'splugin.xmlas 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 theaiassistanttarget 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:
---
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 animportfollowed bygenerate --deleteround-trip safe for grouped agents such asreview/security-reviewer.md. Markdown without valid agent frontmatter (for example, a nestedREADME.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 atclaudecodeinstead 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 frontmattername, the shallower path wins; ties are resolved lexicographically. File paths are also checked separately for case-insensitive collisions, so differently named agents atReview/Agent.mdandreview/agent.mdcannot 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. Bothantigravity-ideandantigravity-cliread 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.nameanddescriptionare 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 theantigravity-ideandantigravity-cliblocks 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; theantigravity-pluginblock is layered on top for the plugin bundle only. Besides the sharedname/description, those blocks accept these optional fields (all preserved on round-trip):tools(string list),mainAgent(boolean, defaulttrue),subagent(boolean, defaulttrue),model(inherit|flash|pro),commandExecutionPolicy(off|auto|eager|sandbox),mcpServers,skills, andplugins.hiddenandinheritMcpappear 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. Theantigravity-plugintarget writes the same file format into a plugin bundle'sagents/directory (project scope only). See the Antigravity subagents docs and the plugin bundle layout.
AugmentCode plugin note: The
augmentcode-plugintarget writes AugmentCode subagents into a plugin bundle'sagents/<name>.md(project scope only). Auggie names a plugin agent after its file and reads onlydescription,modelandhiddenfrom its frontmatter, so Rulesync keeps those (plus thenameRulesync's format requires) and drops every other field in theaugmentcodesection with a warning. That includestoolsanddisabled_tools: an agent whose tools were restricted runs with the full tool set when shipped in a plugin, so keep such an agent on theaugmentcodetarget if the restriction matters. A subagent whosenamediffers 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 onlyAGENTS.mditself. 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 simulatedagentsmdsubagent writer therefore emits there too, and because it has no frontmatter model of its own it emits exactly what the Antigravity targets emit — same mergedantigravity-ide→antigravity-cliblocks, 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 foragentsmdand the Antigravity targets, sogenerate --deletesweeps orphans there: a hand-authored agent in that directory that no.rulesync/subagents/*.mdproduces 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:generateclaims 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--targetsnarrows that claim set, though — a latergenerate --targets antigravity-ide --deleteknows nothing about theantigravity-cli-pinned file and sweeps it — so generate the writers of a shared directory together. Because the file is shared, anagentsmd-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 withrulesync fetchbecomes a live, executable agent definition as soon as any target that writes this path runs, so review.rulesync/subagents/*.mdafter 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 sharedname/description, theqwencode: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), andhooks(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 requiresnameanddescription, so a rulesync subagent without a description is written with the placeholder<name> subagentand a warning; thetabnine:block accepts the optionalkind(local|remote),tools(allowlist of tool names),model,temperature,max_turnsandtimeout_minsfields, all preserved on round-trip together with any key Tabnine adds later. Subagents are an experimental Tabnine CLI feature that must be switched on withexperimental.enableAgents: trueinsettings.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 requiresname; thecortexcode:block accepts the optionaldescription,tools(allowlist of tool names) andmodelfields, all preserved on round-trip together with any key Cortex Code adds later. (Cortex Code also falls back to.claude/agents/, which theclaudecodetarget 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 writesid(the file name, with characters other than letters, digits,.,_and-replaced by_),display_name(the subagentname),description, and the body as[instructions] text. Codewhale rejects profiles with unknown fields, so only therole_hint,base_role,loadout,model,providerandreasoning_effortkeys of thecodewhalesection 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 acceptsmodel_hint/model_idformodelandthinking/reasoningforreasoning_effort; those spellings are written under the canonical key (the canonical spelling wins when both are set). Import readsdisplay_name(falling back toname, thenid),[instructions] text(falling back to the legacypersona), and the alias spellings intomodel/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 requiresname;nameanddescriptioncome from the rulesync subagent's top-level fields, and thecommandcode:block accepts the documented optional fields —toolsanddisallowedTools(a comma/space-separated string or a YAML array,"*"grants every tool),model,reasoningEffort,maxTurns,permissionMode,backgroundandshowOutput— all preserved on round-trip together with any key Command Code adds later. Command Code reserves the namesexplore,plan,reviewandgeneralfor its built-in agents and silently ignores a custom agent whosename— sanitized to[a-zA-Z0-9_-]and lowercased, soReviewcounts — 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 frontmattername, not the file name.nameanddescriptioncome from the rulesync subagent's top-level fields, and theqoder:block accepts Qoder's optional fields —toolsanddisallowedTools(a comma-separated string or a YAML list),model,permissionMode,maxTurns,timeoutMins,color,skills,isolation,mcpServers,hooksand 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 requiresnameanddescription, both taken from the rulesync subagent's top-level fields; thelettacode:block carries the optional fields Letta Code reads —tools(a comma-separated string such asGlob, Grep, Read, orall),model,skills,forkandlaunchProfile— and any other key, all preserved on round-trip. Letta Code readstoolsandskillsonly as comma-separated strings (a YAML list would be ignored, which fortoolsmeans 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 whosenameis not lowercase letters, digits and hyphens starting with a letter, one namedclaude-codeorcodex(reserved), and one without adescription(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 sharednameand requireddescriptionfields are written to YAML frontmatter; Kimi-specificwhenToUse,override,tools,disallowedTools, andsubagentsfields can be authored under thekimi-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>.mdusing 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. Thekimi-code-plugintarget writes the same format into a plugin bundle'sagents/<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 JSONnamefield 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 thekiro-clitarget retaintargets: ["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:nameanddescriptionare required (Cline cli-v3.0.23+ refuses to load an agent whosedescriptionis missing or empty — a canonical subagent without one gets a minimal generated fallback rather than a file Cline cannot load), and the typed optional fieldstools,skills,providerId,modelId, andmaxIterationsround-trip through thecline:section. Import reads.ymlalongside.yaml, matching Cline'sisYamlFile().
Devin note: Devin Local custom subagent profiles are emitted as
AGENT.mdfiles 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). TheAGENT.mdis a YAML frontmatter block followed by the subagent's system prompt. Besides the sharedname/description, thedevinsubagent 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 withallow/deny/askstring lists, override tool permissions), andmax-nesting(integer, enable nested subagent spawning up to the given depth). See the Devin subagents docs. Thedevin-plugintarget writes the same format into a plugin bundle'sagents/<name>/AGENT.md(project scope only). See the plugin packaging guide.
Reasonix note: Reasonix native subagents are Skill profiles emitted as
SKILL.mdfiles 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 declaresinvocation: manualandrunAs: 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 sharedname/description, thereasonixsubagent 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), andcolor(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 aroo:frontmatter section withmode: architecton a rule to route it there; the section is shared by therooandzoocodetargets, 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 theroot: truerule, which has no mode-specific counterpart. On import, a file under a mode directory comes back as.rulesync/rules/{name}-{mode}.mdcarryingroo.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 --deleteonly clears.roo/rules/— because arules-*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 readsmodeSlugsfrom a skill's own frontmatter at higher priority than the directory it sits in, so the existingroo: modeSlugsfrontmatter 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 beyondname/description— most usefullymodeSlugs: string[]for mode targeting — is authored via theroo:section of.rulesync/skills/*/SKILL.mdand lifted back into it on import, so it survives the round-trip. A localRoot rule is emitted asAGENTS.local.md, the personal, gitignored override file Roo loads alongsideAGENTS.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
~/.rooand the project.roo/layout — the.zoorenaming is confined to provider/auth code — so thezoocodetarget reuses therooadapters' path model verbatim across rules (includingAGENTS.local.mdlocal-root handling), ignore (.rooignore), MCP (.roo/mcp.json), commands (.roo/commands/), skills (.roo/skills/,roo:frontmatter section), and subagents (the aggregated.roomodesfile). Shared mode/skill fields keep riding theroo:frontmatter sections, so one rulesync source never produces two spellings; targeting bothrooandzoocodewrites the same files, so pick one target per project — and note the fail-open hazard the shared.roomodescreates: a--targets roogenerate rewrites it withoutallowedMcpServers, so opening that workspace in Zoo Code makes every MCP server available to the mode. The post-fork divergence is carried by thezoocode: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 intozoocode: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 issystem_prompt_id, whilesystem_promptis a read-only property on its config schema and unknown TOML keys are ignored rather than rejected — so a profile carryingsystem_promptloads fine and silently runs with the default system prompt. Rulesync therefore writes the body to.vibe/prompts/<name>.mdand setssystem_prompt_id = "<name>", the same mechanism Vibe's own builtin profiles use (EXPLOREsets"system_prompt_id": "explore"). The two files are always written together, becauseVibeConfigSchema._check_system_promptevaluates the id during validation and an unresolvable one makesAgentRegistry._try_loaddrop the agent with a warning. On import,system_prompt_idis resolved against.vibe/prompts/and becomes the canonical body; a legacysystem_promptis still read, and an id that resolves to nothing is preserved in thevibe:block so a hand-maintained prompt file keeps working. A subagent with an empty body writes no prompt file and leaves anysystem_prompt_idyou authored alone. Note that.vibe/prompts/is not swept by orphan deletion —generate --deleteonly clears.vibe/agents/— so a prompt file left behind by a removed subagent has to be deleted by hand. Thevibe-plugintarget writes subagents into a plugin bundle'sai.mistral.vibe/agents/<name>.toml(project scope only), in Vibe's plugin agent document with the prompt inline asinstructions. See the plugin packaging guide.
Roo note (as of 2026-06-16): Roo Code reads project custom modes from a single aggregated
.roomodesfile at the workspace root (YAML; JSON also accepted). Rulesync therefore collapses every Roo-targeted subagent into that file'scustomModesarray — each subagent becomes one mode whoseslugis derived from the file name (sanitized to^[a-zA-Z0-9-]+$),name/descriptioncome from the shared frontmatter, androleDefinitionis the subagent body. The optionalroo:block suppliesgroups(defaults to["read", "edit", "command", "mcp"]),whenToUse,customInstructions, an explicitslug, and aroleDefinitionoverride. (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 (
exploreandgeneral), so Rulesync maps subagents onto Bob's custom modes instead, the same way therootarget fills.roomodes. Every Bob-targeted subagent becomes one entry in thecustomModesarray of the project file.bob/custom_modes.yaml(project scope only: Bob IDE documents~/.bob/settings/custom_modes.yamlas the global file while Bob Shell documents~/.bob/custom_modes.yaml, so no global file is written). Theslugis derived from the file name (sanitized to^[a-zA-Z0-9-]+$),name/descriptioncome from the shared frontmatter, androleDefinitionis the subagent body. The optionalbob:block suppliesgroups,whenToUse,customInstructions,allowedSubagents, an explicitslug, and aroleDefinitionoverride. It is separate from theroo: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 hascommandinstead ofexecute); withoutbob.groupsa mode gets["read", "edit", "execute", "mcp"]. The Bob Shell custom-modes page still lists Roo'sread,edit,browser,command,mcpinstead, so setbob.groupsexplicitly for a mode you use in Bob Shell. On import every mode comes back as its own.rulesync/subagents/<slug>.mdtargeted atbob, with the other mode fields in thebob:block. Rulesync owns the whole file: generating rewrites it from.rulesync/subagents/, andgenerate --deleteremoves it when no subagent targetsbob, 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/*.mdand inline inopencode.json/opencode.jsoncunder the top-levelagentkey. On import, rulesync reads both: each inline entry'spromptbecomes 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 theopencode:block. Entries under the OpenCode V2 spellingagentsare read too, lowered the way OpenCode V1 (from v1.18.24) lowers them:systembecomes the body,disabledbecomesdisable,request.bodybecomesoptions, aprovider/model#variantmodel (or a{ providerID, model, variant }object) is split intomodelandvariant, other V2-only fields (such aspermissions) are not read, and anagententry wins over anagentsentry of the same name. A Markdown file takes precedence over an inline entry with the same name, compared case-insensitively —.opencode/agents/planner.mdand an inlinePlannerare 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 amimocodekey, which takes the same fields as theopencodekey; on import, inlineagententries in.mimocode/mimocode.json(c)are read as for OpenCode, except the V2agentsspelling, 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 bothnameanddescription, so Rulesync falls back to thenamewhen a subagent has nodescription. Put oh-my-pi's own frontmatter (tools,model,spawns,thinkingLevel,blocking, …) under anompkey; it is copied verbatim. The namesmainandsubare 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. Setkilo.mode: subagentto opt into hidden/subagent-only behavior.
Besides mode, the kilo subagent block accepts these optional fields (all preserved on round-trip):
| Field | Type | Notes |
|---|---|---|
displayName | string | Human-friendly name shown in pickers |
model | string | Model id |
variant | string | Model variant |
temperature | number | Sampling temperature |
top_p | number | Nucleus-sampling parameter |
permission | string | object | Permission profile name, or a per-tool { <tool>: { allow, deny, ask } } object |
prompt | string | Inline system prompt |
color | string | UI color |
native | boolean | Native (built-in) agent flag |
hidden | boolean | Hide from top-level picker |
disable | boolean | Disable the agent |
deprecated | boolean | Mark as deprecated |
steps | positive integer | Maximum 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) |
options | object | Free-form key/value options |
Migration note (
steps): earlier Rulesync versions typedstepsas 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 akiloblock (or a.kilo/agents/*.mdfile) 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 enablesrulesync-subagentsin$HERMES_HOME/config.yaml. Run Hermes from the trusted project root withHERMES_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.agentsmap in its settings file,.poolside/settings.yamlfor the project and~/.config/poolside/settings.yamlfor--global. Rulesync writes one entry per subagent, keyed by the frontmattername, withtype(defaulting toin_process),descriptionandinstructions(the Markdown body, unlesspool.instructionsoverrides it) plus whatever else thepool:block carries, such ascommand/args/envfor acommandagent oragent_serverfor anagent_serverone. Rulesync owns the wholesubagentsblock: on every run it recomputessubagents.agentsfrom the Rulesync subagents targeting Pool, dropping agents a previous run generated but keepssubagents.defaultand Pool's built-ingeneralentry (a Rulesync subagent namedgeneralis 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, andrulesync convert --to poollikewise 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 poolfanssubagents.agents(exceptgeneral) back out to one.rulesync/subagents/<name>.mdper agent, movinginstructionsinto the body and the remaining keys into thepool: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 thatpermissionModeis discarded, so apermissionModein thezcode:block is written for--globalonly 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:nameanddescriptionare required, andmodel,thoughtLevel,color,tools,disallowedTools,maxTurns(a positive integer),injectAgentsMdandmcpServersare optional. Put those optional fields in thezcode:block of the canonical subagent; any extra keys are preserved on round-trip through the same block. See the ZCode subagents docs. Thezcode-plugintarget writes the same format into a plugin bundle'sagents/<name>.md(project scope only); ZCode reads plugin agents like~/.zcode/agents/ones, sopermissionModeis 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 requirednameanddescription, both taken from the canonical subagent (same-named keys in thecodebuddy:block are ignored, sonamealways matches the file name). The optional keys CodeBuddy documents (tools,model,permissionMode,skills,mcpServers,disallowedTools,effort,maxTurns,background,initialPrompt,memory,hooks) go in thecodebuddy:block and are written verbatim, sotoolscan 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:
---
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:
<!-- 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:
---
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.
---
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:
HERMES_ENABLE_PROJECT_PLUGINS=1 hermesRulesync 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:
---
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 field | Source |
|---|---|
name | the source file basename without .md (required by Amp) |
description | description |
severity-default | severity |
tools | tools |
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-checksplugin (amp plugins add @amp/review-checks); theamp reviewcommand 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
aiassistanttarget 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:
---
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.mdin 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 unlikeSKILL.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 the0o111bits set) is written executable too, with its permission bits masked to0755(group and world write bits are dropped, as are setuid, setgid and sticky), so ascripts/run.shan 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'sagents/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 requiredSKILL.md", and a hidden.env.exampleor.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,.hgand.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.jsonand.gemini/oauth_creds.json. A credential name counts wherever it appears in the path, not only as the last segment:.env/productionand.netrc/machinehold 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/orKeychains/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../_sharedthere 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..envand 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.defaultsand compound spellings such as.env.local.exampleare carried, being the case this support exists for, while.env.localand.env.productionhold real values.- Build, cache and virtual-environment trees:
.cache,.venv,.tox,.mypy_cache,.pytest_cache,.ruff_cache,.gradle,.next,.nuxt,.turbo,.parcel-cache,.nyc_outputand.terraform. These are regenerated from the skill's own sources, and copying a.venvinto 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 (.envbecomes.envthere). 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 namedassetspointing 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.mdrefers 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,/sysor/devis refused at every hop rather than only at its destination:/proc/<pid>/fd/Nandexeresolve 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 namedfdspointing at/proc/self/fdleaves no/procin the pathfds/3to 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 onlyAGENTS.mditself. It is the Agent Skills project location, which the nativeagentsskillstarget writes. Several targets write there —agentsskills,agentsmd,aiassistant,codexcli,amp,zed,replitand 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--targetscan 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
agentsmdwriter 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 barename/descriptionpair and silently droplicense,compatibility,metadataandallowed-tools. It now emits exactly whatagentsskillsemits, so a simulated writer can never degrade the file a native target owns.
AugmentCode plugin note: The
augmentcode-plugintarget writes skills into a plugin bundle'sskills/<name>/SKILL.mdin the same format as.augment/skills/(project scope only), since Auggie reads plugin skills the same way; import reads only the bundle's ownskills/, 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-skilland.agents/skills/My-Skillare 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 frontmatternamefolded 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 importandrulesync convertdrop 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, importagentsskillsin the same run, so the skill still reaches.rulesync/and a lateragentsskillsgenerate does not prune it as an orphan. Slash commands are not covered by this rule yet:augmentcodereads both.augment/commands/and the shared.agents/commands/, but their names are still compared exactly, sodeploy.mdandDeploy.mdare 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 inapps/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 likeapps/web:deploy).rulesync import --targets claudecode --features skillsdiscovers those nested directories (import-only, lenient, same dependency/build-directory exclusions as the nestedAGENTS.mdscan; 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/skillsdirectory 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 inapps/web/.claude/skills/getsclaudecode.paths: ["apps/web/**"]written for it. Glob metacharacters in the directory names are escaped, so a Next.js-styleapp/[slug]/.claude/skills/still derives a literal match. Apathsvalue the skill already declares is kept as-is — Claude Code does not document whether a nested skill'spathsresolves 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 theclaudecode:block only — other targets with their ownpathsfield (cursor:,qwencode:) are untouched. To scope a skill's activation to a subtree yourself, write thepathsfrontmatter, or run a separate generate with--output-roots <subdir>for physical co-location.
Claude Code
argument-hintand import leniency note: Claude Code's docs writeargument-hint: [issue-number]unquoted, which YAML reads as a one-element list. On import (andrulesync convert --from claudecode), a listargument-hintin a.claude/skills/*/SKILL.mdor.claude/commands/*.mdis accepted and joined back into the bracketed string, one placeholder per entry ([issue-number];[filename, format]becomes[filename] [format]; an empty list is dropped), soclaudecode.argument-hintin.rulesync/is always a string and is emitted quoted on generate. The unquoted multi-placeholder formargument-hint: [filename] [format]is not valid YAML at all; quote it ("[filename] [format]"). Because Claude Code keeps loading the other skills when oneSKILL.mdis 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/*.mdstill 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 theclaudecodefrontmatter section.
Note: Codex CLI reads UI metadata, invocation policy, and tool dependencies from an
agents/openai.yamlsidecar next toSKILL.md(Codex'sSKILL.mdfrontmatter only carriesnameanddescription). Whencodexcli.interface,codexcli.policy, orcodexcli.dependenciesis present, or the rootdisable-model-invocationistrue, Rulesync emits.agents/skills/<name>/agents/openai.yamland reads it back on import. The rootdisable-model-invocation: truemaps ontopolicy.allow_implicit_invocation: false(Codex then only runs the skill when invoked explicitly via$skill); an explicitcodexcli.policy.allow_implicit_invocationtakes precedence over the root flag, and a rootfalseor absent flag adds nothing (Codex already defaults totrue). On import,policy.allow_implicit_invocationis kept undercodexcli.policyrather than promoted to the rootdisable-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 andinterface.short_descriptionis absent, the legacycodexcli.short-descriptionis routed there. See the Codex skills docs.
Takt-driven Codex note: Rulesync's
codexcliskills 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 execis the documented exception: each scope defaults to inheritance when it is not explicitly configured.) The setting isprovider_options.codex.skills.repofor the project tree and.userfor 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 wheneverruntime.yamlis inactive (a file carrying onlyversion: 1counts as inactive and leaves the legacy resolution in place), it belongs inprovider_optionsin.takt/config.yaml— which is also where ataktblock in.rulesync/permissions.*writes it. From 0.56.0,runtime.yamlowns provider configuration, and while it is active any legacy provider setting inconfig.yaml—provider_optionsincluded — stops Takt withMixed provider configuration detectedbefore it runs an agent. Takt generates~/.takt/runtime.yamlactive on first launch in a fresh environment, so a new install is in runtime mode by default; there, set the flag inruntime.yamlunder theoptionsof a profile whose provider is Codex, and keepprovider_optionsout ofconfig.yaml. Mind the shape when you move it: a profile'soptionsis a flat bag applying to that profile's own provider, so thecodexsegment is dropped —options: { skills: { repo: true } }, notoptions: { 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 writingprovider_optionsinline: a workflow, step, or parallel sub-step can declarecapabilities: 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-commandson 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 portablename/descriptionfrontmatter; the schema is loose, so an importedSKILL.mdcarrying extra keys still parses, but only the modeled keys are carried into the canonical skill (others, such asargument-hint, are dropped on import). The invocation flags are modeled too: the root-leveldisable-model-invocation/user-invocabledefaults (overridable per tool under areasonix:section) are written as Reasonix's owndisable-model-invocation/user-invocablekeys, which the v2 line reads (internal/ext/skill/invocation_flags.go, since v2.28.0). The legacy v1 line only knowsinvocation: 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 getsinvocation: 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 writesmanual. On import, the native flags land in thereasonix:section (Reasonix'syes/no,on/offand1/0spellings are read as booleans; like Reasonix, an unreadabledisable-model-invocationfails closed and is read astrue, while an unreadableuser-invocableis ignored), and aninvocationis kept asreasonix.invocation— except themanualthat accompaniesdisable-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/skillsplus~/.agents/skills(user). Rulesync emits the shared.agents/skills/directory in project mode and only the XDG-default~/.config/muse/skillsin global mode (via--global), so a skill is written exactly once. Muse Code's compatibility scans of repo-local.codex/skillsand.claude/skillsbelong to other tools and are not emitted formusecode. Only the portablename/descriptionfrontmatter pair is modeled; the schema is loose, so extra keys on an importedSKILL.mdsurvive 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: anamemust 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 adescriptionshould 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.mdto 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 owndisable-model-invocationis authored in thezed: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, andallowed-tools) round-trip throughagentsskillsand are normalized to the Agent Skills spec shapes described above (soallowed-toolsis written and imported the same way as foragentsskills); Hermes-native fields such asversion,author,platforms,environments,required_environment_variables,required_credential_files, andmetadata.hermesround-trip throughhermesagent. Canonicalnameanddescriptionalways 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 underimportOnlySkillRoots, likejunie/vibe/kimi-code), scanned after.warp/skills/under the cross-root duplicate rule above, and never touched bywarp-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 aswarpskills — 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>.mdskills; for flat files, a missingnamecomes from the filename and a missingdescriptionfalls 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 frontmattername: 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. Besidesname/description, Rulesync maps Kimi'stype,whenToUse,disableModelInvocation, andargumentsfrontmatter through thekimi-code:block and preserves supporting files beside directory-layoutSKILL.md. The shared top-leveldisable-model-invocationvalue supplies the Kimi flag unless the tool-specific block overrides it. See the Kimi Code Agent Skills docs. Thekimi-code-plugintarget writes the same format into a plugin bundle'sskills/<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.mdin project mode and~/.zcode/skills/<name>/SKILL.mdwith--global. That project path is inferred from the import dialog and from ZCode's other workspace assets, not documented. ZCode's SkillSKILL.mdfield reference allowlists exactly five frontmatter keys — requiredname/descriptionplus optionalwhen_to_use(extra trigger-timing context),license, andmetadata(an object that may carry extras such asauthor/version) — and states that fields outside that list "are ignored and do not affect loading". Rulesync models all five:licenseandmetadatafall back to the root-level Agent Skills packaging defaults, andwhen_to_useis authored in thezcode:block. There is no documentedcompatibilityfield, so the canonicalcompatibilityis not written forzcode. The schema is loose, so an importedSKILL.mdcarrying extra keys still parses, but only those five are carried into the canonical skill. ZCode rejects adescriptionlonger 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. Thezcode-plugintarget writes the same format into a plugin bundle'sskills/<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). TheSKILL.mdfrontmatter carriesnameanddescription; any additional keys round-trip through thebobfrontmatter 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). TheSKILL.mdfrontmatter carriesnameanddescription; any additional keys round-trip through thetabninefrontmatter 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). TheSKILL.mdfrontmatter carriesnameanddescription, plus an optionaltoolsallowlist; any additional keys round-trip through thecortexcodefrontmatter block. (Cortex Code also falls back to.claude/skills/, which theclaudecodetarget 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).nameanddescriptionare written from the canonical fields, and any Codewhale-specific frontmatter (for exampleinvocation) is carried through thecodewhalesection 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 writesnameanddescriptionfrom the canonical skill (same-named keys in thecodebuddy:block are ignored), plus the shareddisable-model-invocation/user-invocabledefaults; the other documented keys (allowed-tools,context,agent,model,hooks) and any extra ones go in thecodebuddy:block. CodeBuddy treats every SKILL.md key as optional, so on import a missingnamefalls back to the directory name and a missingdescriptionto 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). TheSKILL.mdfrontmatter requiresname(which must match the directory name) anddescription; any additional keys round-trip through thecontinuefrontmatter block, and supporting files next toSKILL.mdare carried along. (Continue also falls back to.claude/skills/, which theclaudecodetarget 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). TheSKILL.mdfrontmatter requiresname(matching the directory name) anddescription; 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 thecommandcodefrontmatter block, and supporting files next toSKILL.mdare 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 thecommandcodeblock omits them; a value in the block wins. See the skills docs.
Qoder note: Qoder reads Agent Skills directories (
<name>/SKILL.md, with the requirednameanddescriptionfrontmatter) 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 theagentsskillstarget, and supporting files next toSKILL.mdare 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 theagentsskillstarget writes) and~/.letta/skills/(global, via--global). TheSKILL.mdfrontmatter takesnameanddescription; the optional keys Letta Code reads (when_to_use,argument-hint,category,tags,disable-model-invocation,user-invocable) round-trip through thelettacodefrontmatter block, and supporting files next toSKILL.mdare carried along. The two invocation gates fall back to the root-level rulesync value when thelettacodeblock omits them. See the skills docs.
DeepSeek Harness note: DeepSeek Harness scans
<projectRoot>/.dsh/skills(project) and~/.dsh/skills(global, the$DSH_HOMEdefault) for skills, alongside the.agents/skillsroots theagentsskillstarget already writes, so rulesync emits.dsh/skills/<name>/SKILL.mdin project mode and~/.dsh/skills/<name>/SKILL.mdwith--globaland leaves the.agents/roots toagentsskills. The harness discovers only the top level of each root — a<name>/SKILL.mdbundle or a flat<name>.mdfile — 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 requiredname/descriptionplus optionalwhenToUse(extra trigger-timing context),metadata(a free-form object),disable-model-invocationanduser-invocable. Rulesync models all six:metadataand the two invocation flags fall back to the root-level shared defaults, andwhenToUseis authored in thedsh:block. The harness also acceptsyes/no,on/offand1/0spellings 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 importedSKILL.mdwithuser-invocable: maybeis rejected rather than silently dropped. There is no documentedlicenseorcompatibilityfield, so those canonical fields are not written fordsh; the schema is loose, so an imported file carrying extra keys still parses. The harness also requiresnameto be kebab-case; rulesync does not enforce that fordsh, 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 (agentsskillsand friends), so each skill is still written exactly once. Only the requiredname/descriptionpair is modeled — Pool does not enforceallowed-toolsorcompatibility— and the schema is loose, so extra keys survive an import. Pool only loads a skill whose directory name equals itsname, 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 globalCRUSH.mdand MCP config live under, distinct from the project-only.crush/directory name. Unless$CRUSH_SKILLS_DIRis 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. Besidesname/description, thecrush:block round-tripsuser-invocable,disable-model-invocation, and the sharedlicense/compatibility/metadatapackaging trio, all falling back to their root-level defaults when the section omits them; since Crush'sUserInvocablefield is a non-pointer Gobool, an omitteduser-invocableresolves tofalse, keeping the skill reachable by the model but hidden from Crush's command palette.compatibilityandmetadataare normalized to the bare string /map[string]stringshapes 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:
{
"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:
{
"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
nullremoves the shared server for that tool. - Any MCP-capable
--targetsname 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 deprecatedclaudecode-legacytarget reads theclaudecodeblock; thekiro-cli/kiro-idetargets read thekiroblock (all three write the same.kiro/settings/mcp.json); theantigravity-ide/antigravity-clitargets both apply bothantigravity-*blocks in a fixed order (antigravity-idefirst, thenantigravity-cli— the CLI block wins per server) because they share their output file at both scopes (.agents/mcp_config.jsonin project mode,~/.gemini/config/mcp_config.jsonin global mode); and in project mode theclaudecode(orclaudecode-legacy),codebuddy,commandcodeandqodertargets all apply theclaudecode,codebuddy,commandcodeandqoderblocks 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.jsonand~/.qoder/settings.json), so there each reads only its own block.
Generation filter: per-server
enabled. Set"enabled": falseon 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": trueis opt-in clarity. This is distinct from the canonicaldisabled, which is a pass-through field the tools read (written asdisabled: true, or translated to each tool's own spelling):enabled: falsewins and drops the server entirely, whiledisabledonly 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 nativeenabledfield with different semantics — and import never invents it: a tool's native enabled/disabled state keeps mapping to the canonicaldisabled(though a stray hand-writtenenabledin 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 withoutenabled: falsere-emits the server for that tool (per-tool re-enabling); and on merge-style shared configs (e.g. Hermes Agent'sconfig.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}.mcpServersblock(s).
JetBrains AI Assistant note: Rulesync writes the native
{ "mcpServers": { ... } }configuration to.ai/mcp/mcp.jsonin project mode and~/.ai/mcp/mcp.jsonin 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 inmcp_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 useserverUrlin place of the canonicalurl, 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:
{
"$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.pathsarray ofopencode.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 torulesync 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.urlsis a remote-fetch surface and is out of scope for a file-based generator. The OpenCode V2 flatskillsarray, which OpenCode V1 also accepts (from v1.18.24), is read the same way: its entries that are nothttp(s)://URLs are taken asskills.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 singularskill/directory on import. Tool-specific frontmatter goes under amimocodekey, which takes the same fields as theopencodekey so a skill round-trips (MiMo Code itself ignores them). On import, theskills.pathsarray 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 anompkey, which takes the same fields as thepikey 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 projectskills/directory is read by GitLab Duo from the committed repository, sorulesync gitignoredoes not ignore it. Every skill targetinggitlabduo— including atargets: ["*"]one — is written there, so if your repository already uses a top-levelskills/directory for something else (a skill collection, skills distributed to other tools), limit such skills'targetsor leavegitlabduoout of the skills feature. User-level skills are an experiment and are loaded only when the CLI is started with--enable-global-skills trueorGITLAB_ENABLE_GLOBAL_SKILLS=true. Rulesync always uses the default~/.gitlab/duo/location and does not followGLAB_CONFIG_DIRorXDG_CONFIG_HOME. Because the project rootskills/directory may hold unrelated content, only a subdirectory that contains aSKILL.mdis imported. For the same reasongenerate --deletenever removes a skill directory from the projectskills/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-authoredskills/<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) andcodemode(OpenCode V2) through verbatim in both directions. Other tools' MCP keys are never copied intoopencode.json. Rulesync reads and writes OpenCode's V1 file shape — a flatmcpmap keyed by server name — soprotocolandcodemodereach the adapter only when a canonical server carries them: authored by hand in.rulesync/mcp.jsonc, or imported from a flat-mcpopencode.jsonentry 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, thetimeoutobject) 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,commandas an array). Rulesync mapsstdio/local⇄ Kilolocalandhttp/sse⇄ Kiloremote; on import, Kiloremoteis normalized to the canonicalhttptransport (the deprecatedsseis no longer emitted). The Kilo-specifictimeout(local + remote, a positive integer in milliseconds) andoauth(remote only — either an OAuth-config object orfalseto disable auto-detection) fields are preserved on round-trip. Thekilo.jsoncskillsconfig key (skills.pathsfor extra skill locations andskills.urlsfor 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 onlydisabled: true/disabled: falseand 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 aboutdisabled, a toggle already inkilo.jsoncis 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 writingenabled: truefor it would switch back on what you turned off there. Kilo's per-toolenabledTools/disabledToolsreach the generated file at all now — they used to be stripped before this adapter saw them, so a filter read out ofkilo.jsoncwas deleted from it on the next generate. The filters are written as Kilo MCP tool permission keys in thepermissionblock —enabledToolsas"{server}_{tool}": "allow",disabledToolsas"{server}_{tool}": "deny", with both names sanitized the way Kilo names MCP tools (every character outsidea-z A-Z 0-9 _ -becomes_, except that a*or?in a tool name is kept, sodisabledTools: ["*"]becomes the wildcard key"{server}_*") — rather than the legacy top-leveltoolsmap, which Kilo only folds intopermissionthrough a compatibility shim. Thepermissionblock is shared with the permissions feature: for every server the canonical config or the file'smcpblock 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 adeny, or a legacytoolsentry set tofalse, that nodisabledToolslists 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 asexternal_directoryfor a server namedexternal, orrepo_clonefor one namedrepo), and every other key are left alone; the same servers' entries are removed from a legacytoolsmap. 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 listedgithub) is treated as the listed server's. Note that in Kilo"allow"means the tool runs without asking, so anenabledToolsentry is auto-approved even when a catch-all"*": "ask"comes before it. When two servers sanitize to the same key, adenywins over anallow. Because Kilo evaluatespermissionlast-match-wins in key order, the permissions feature moves exact"deny"keys to the end of the block, so a broadallowit appends can never lift an MCPdisabledToolsdeny. A wildcarddisabledToolsentry ("{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 legacytoolsmap. A skipped server's filters are written either way, since a permission key is keyed by server name and reaches serversmcpdoes not list. On import,permissionkeys (a plain"allow"/"deny"or a{"*": …}map) of a listed server are read back as filters, and so is the legacytoolsmap (permissionwins a key both state, as in Kilo's own shim); a legacytoolsentry naming no listed server comes back as a server carrying nothing but the filters. Apermissionkey 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 itskilooverride instead. A server with no transport — a toggle, or one of those filter-only entries — is imported into the tool-scopedkilo.mcpServersblock rather than the sharedmcpServersmap, 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-leveltoolsmap (keyed by server name in the same way), and its transport-less servers land inopencode.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 asargsorenvis written as a toggle with those fields dropped and a warning naming them. A server that names a transport it cannot reach — atypewith nocommand, anhttpwith nourl— is skipped with a warning instead, because{"type": "local", "command": []}is a server Kilo cannot start. An existingkilo.jsonccarrying 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 kilorun rather than the MCP feature alone, becausekilo.jsoncis 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}inenvandheadersvalues is written as{env:VAR}. A projectkilo.jsoncis untrusted — any{env:in it makes Kilo drop the whole file (or, inside MCPheaders, 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 sharedkilo.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(andtools) keys of.mimocode/mimocode.jsonc(project; an existing.mimocode/mimocode.jsonis updated instead) or~/.config/mimocode/mimocode.jsonc(global). Everything described for OpenCode applies, and a tool-scoped block goes undermimocode.mcpServers.
oh-my-pi note: oh-my-pi (
omp) reads themcpServersmap of.omp/mcp.json(project) /~/.omp/agent/mcp.json(global). Rulesync writes each server with an explicittype(stdio,http— also forstreamable-http— orsse), a stringcommandplusargsfor stdio servers, andenabled: falsefor adisabledserver; 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/disabledToolshave no oh-my-pi counterpart and are not written. A tool-scoped block goes underomp.mcpServers.
Pi note: Pi (1.0+) has built-in MCP support and reads the
mcpServersmap of.pi/mcp.json(project — read only after project trust is granted) /~/.pi/agent/mcp.json(global). Rulesync writes each server with an explicittype(stdio, orhttp— also forstreamable-http), a stringcommandplusargsfor stdio servers,enabled: falsefor adisabledserver, andtimeoutconverted 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, sosseservers — like WebSocket servers and any remote server whoseurlis nothttp(s)://— are skipped with a warning. Pi-specific keys such asexposure,toolExposure,description,oauthandauthpass through; put them under api.mcpServersblock to keep them out of other tools' configs. Pi acceptsauthonly 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, whichpi mcp addand/mcpalso edit, is never deleted. Pi expands${VAR}and$VARinenvandheadersvalues, 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 incommand,argsandcwdit expands only a leading~/, so a server carrying such a reference gets a warning (write~/...rather than${HOME}/...there).enabledTools/disabledToolshave no Pi counterpart and are not written. A project entry withoutcommand,urlortype, 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 explicittype(stdio,httporsse), inferred fromcommand/urlwhen the source leaves it out;streamable-httpbecomeshttp, and servers with another transport (such asws) are skipped with a warning. The GitLab-specificapprovedTools(trueor a list of tool names that run without a prompt) is passed through — put it under agitlabduo.mcpServersblock to keep it out of other tools' configs.
Zed note: Zed configures MCP servers under
context_serversin its shared settings file (.zed/settings.jsonproject,~/.config/zed/settings.jsonglobal —%APPDATA%\Zed\settings.jsonon Windows), whose value is an untagged shape with notypefield: 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 seriouslydisabled: true, which left the server enabled):disabled: truebecomesenabled: false(and imports back asdisabled: true), thehttpUrlalias is normalized tourl, an arraycommandis flattened to Zed's single command string with the rest prepended toargs, and canonical-only fields (type/transport,alwaysAllow,trust,cwd,networkTimeout, the Kiro lists) are dropped. Fields rulesync does not model — a remote server'soauthblock, an extension server'ssettings— pass through untouched, so they are best authored in the tool-scopedzed.mcpServersblock. A server Zed cannot start is skipped with a warning rather than written broken: ansseorwsserver (Zed has neither transport), a remote server with nourl, a local one with nocommand. 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-scopedzed.mcpServersblock rather than the sharedmcpServersmap, 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 plusenv,cwd,headers,bearerTokenEnvVar,enabled,deferred(Kimi Code 0.43.0: load the server's tools on demand instead of inline),startupTimeoutMs,toolTimeoutMs,enabledTools, anddisabledTools; Rulesync preserves the canonical fields that Kimi accepts. Canonicallocalmaps to stdio andstreamable-httpmaps to HTTP. WebSocket servers are skipped with a warning because Kimi has no WebSocket transport. Akimi-codeblock may also carrystartupTimeoutMs/toolTimeoutMs, which are not per-server: they become Kimi's[mcp] startup_timeout_ms/tool_timeout_msdefaults in the shared global~/.kimi-code/config.toml, applying to every MCP server including ones Rulesync did not write (a per-server value inmcp.jsonstill wins). Global scope only, sinceconfig.tomlhas no project counterpart, and merged in place so thehooksandpermissionsections 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 fromconfig.tomlby 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_serversin the shared~/.hermes/config.yaml. Rulesync preserves theoauthkeys 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, andscope) —scopeis the space-separated string Hermes sends on registration and device login; ascopeslist, which earlier Rulesync versions wrote but Hermes never read, is folded intoscopein both directions — plusprotocol(auto/stateless/legacy, v0.21),lazy(defer the connection to first tool use, v0.21),idle_timeout_seconds,max_lifetime_seconds(also under alifecycle:mapping, carried as an opaque object),ssl_verify(true/falseor a PEM CA-bundle path),skip_preflight,keepalive_interval(liveness ping cadence in seconds),trust(fulloruntrusted, where every write-capable tool call needs approval — copied verbatim, since Hermes reads any unrecognized value asuntrusted), and thesampling,elicitation, andidentity_headermappings (carried as opaque objects so new sub-keys keep working). A canonicalsseserver is written with Hermes's owntransport: sse(v0.20.0) and imports back astype: "sse"; without it Hermes connects to aurlserver 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 sharedmcpServers; Hermes-only fields are isolated in the fullhermesagent.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.jsonthat permissions and hooks keep patching in place. Rulesync no longer writes the legacyconfig.jsonmcpServerskey — Devin auto-migrates it away on startup, so re-seeding it would fight the migration — but import still falls back to that key when nomcp_config.jsonexists, so pre-v3000.3 repos migrate cleanly. The gitignored personal override.devin/mcp_config.local.jsonis never read or written (it is covered by the derived.gitignore). See the Devin MCP configuration docs. Thedevin-plugintarget instead writes a plugin bundle's.mcp.jsonundermcpServers(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 directoryworking_directory(used for resolving relative paths), so the canonicalcwdis translated to it on generate and back on import; a tool-nativeworking_directoryalready on the server wins overcwd. See the Warp MCP docs.
Warp Agent CLI note: The standalone Warp Agent CLI (the
warpbinary, targetwarpcli) 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.jsonin that directory only — "Project-scoped MCP config files in repositories are not detected" — sowarpcliis global-only (--global). The file format is the Warp app's (including thecwd⇄working_directorytranslation), and awarpclitool-scoped block in.rulesync/mcp.jsoncapplies to it alone. Rules and skills are shared with the app (AGENTS.md,.agents/skills/), so keep generating them with thewarptarget. See the Warp Agent CLI configuration docs.
Pool note: Pool reads MCP servers from the
mcp_serversblock of its YAML settings file:.poolside/settings.yaml(project; the committed, team-shared file — the untracked.poolside/settings.local.yamloverlay 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 ascommand/args/cwd/env; remote servers as atransportblock (type: httportype: sse,url, andheadersas Pool's list of"Name: value"strings, which imports back to the canonical map).streamable-httpis written ashttp;wsservers are skipped with a warning because Pool offers no WebSocket transport. Tool filters map onto Pool's own switches:enabledToolsbecomesenabled_tools(the tools Pool exposes at all) anddisabledToolsbecomesdeny(patterns agents may never use); Pool's approval allowlist is authored on the canonical server as the namespacedpoolAllow(written asallow, imported back aspoolAllow, and stripped from every other tool's output). Note that Pool'sdeny/enabled_toolsaccept glob patterns while the canonical lists are literal tool names, so a pattern imported from Pool is carried to other tools'disabledTools/enabledToolsas a literal name — the import warns when it sees one (allowis exempt, sincepoolAllownever reaches another tool). Because.poolside/settings.yamlis committed, preferenventries that reference environment variables (or Pool's ownsecretskey) over literal credentials inheaders. 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 aninsertrow for the bundled@deepseek-ai/dsh-mcp-clientplugin in a Cordis patch layer. Rulesync writes those rows into the user-owned home layer~/.dsh/cordis.patch.yml(the$DSH_HOMEdefault), sodshis 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-clientrow. 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 itsidso id-targeted patches keep applying; rows of servers no longer defined are removed; new servers are appended to the first plain top-levelinsertentry (or a new one), with the idmcp-<name>(suffixed if that id is already taken). Every other patch entry and insert row — including their!!jsexpressions — 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!!jsexpression inside an MCP row rulesync rewrites is replaced by the canonical value. On import, a!!jsvalue inenvorheadersis dropped, and a row whoseserverName,transport,command,url,cwd,args,disabledor one of the dsh-only fields below is (or, forreconnect, contains) a!!jsexpression is skipped with a warning — define that server in.rulesync/mcp.jsoncyourself, since the next generate removes the row otherwise. Stdio servers becometransport: stdiowithcommand/args/env/cwd;http/streamable-httpservers becometransport: streamable-httpwithurl/headers;sseandwsservers 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. Canonicaldisabled: truebecomes the entry-leveldisabledswitch. The client-onlytoolCallTimeoutMs,failOnStartupError,maxInstructionBytesandreconnectfields are carried when authored on a server; import puts them in adsh.mcpServersreplacement block so they never leak to other tools; canonicaltimeoutis not mapped.enabledTools/disabledToolsare 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_serversmap (command/args/envortype/url/headers) is declared per workflow step inside individual workflow YAML files; there is no top-levelmcp_serverskey inconfig.yaml, and Takt's config loader hard-rejects unknown top-level keys (introduced with MCP support in Takt v0.21.0). Whatconfig.yamldoes hold is the default-deny transport allowlistworkflow_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.jsoncservers 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 inconfig.yamland 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 emptymcpServersmap. 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.
{
"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
mcpServersin.kiro/settings/mcp.json(project) and~/.kiro/settings/mcp.json(global). Kiro supportsdisabledToolsnatively and Rulesync preserves it on generate and import. Kiro does not expose a corresponding per-serverenabledToolsallowlist, so that field is omitted for Kiro targets. The two Rulesync-only authoring keys are translated onto the field names Kiro actually reads:kiroAutoApprovebecomesautoApprove(tools run without a confirmation prompt) andkiroAutoBlockbecomesdisabledTools(tools hidden from the agent). A server that already spellsautoApproveordisabledToolsnatively keeps working — the two lists are merged rather than one overwriting the other. On import,autoApproveis lifted back intokiroAutoApprove;disabledToolsstays as-is because it is already a canonical Rulesync field with the same meaning, sokiroAutoBlockhas no import counterpart. That makeskiroAutoBlocka redundant spelling ofdisabledTools: a Kiro config imported after a generate comes back as canonicaldisabledTools, which then also reaches the other targets that support it. Prefer authoringdisabledToolsdirectly, which makes that scope explicit from the start.
Roo Code / Zoo Code note: Both targets write
.roo/mcp.jsonthrough the same adapter, and their per-server MCP schema is denylist-only:disabledToolsclears each named tool'senabledForPrompt(the tool stops being offered to the model), and there is no correspondingenabledToolsallowlist, so that field is omitted for these targets. The server config is emitted verbatim, sodisabledToolsround-trips as itself. Earlier Rulesync versions stripped it before the adapter saw it, which meant a filter you had written into.roo/mcp.jsonby 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 onlystdio,sseandhttp(plus the aliasesstreamable_http/streamable-http→http), so canonicallocalis written asstdioandstreamable-httpashttp; canonicalwshas no counterpart and the server is skipped with a warning at generate time, where you can still see it. Whether the value lands undertypeortransportfollows whichever key you authored — dcode reads the two interchangeably, and with neither set it infershttpfrom aurlandstdiootherwise. On import, both spellings of thestreamable_httpalias come back as canonicalhttp. Tool filters: canonicalenabledToolsbecomesallowedTools(dcode never readsenabledTools, and it ignores unknown keys silently, so forwarding the canonical name would be a no-op), whiledisabledToolscarries its own name; each entry is a tool name or anfnmatchglob, and import liftsallowedToolsback. 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 emptyenabledToolslikewise skips the server, since an allowlist of nothing means no tools at all and dropping the key would publish all of them. An emptydisabledToolsis 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. AnhttpUrlis written asurl(dcode never reads thehttpUrlalias and would take such a server for stdio) with the transport pinned tohttpwhen none is set, sincehttpUrlnames a streamable HTTP endpoint (an authoredtypeortransportis kept); when both are set,urlwins andhttpUrlis dropped. An arraycommandis split into its first element and anargslist that the rest is prepended to, because FastMCP accepts only a stringcommand; an empty array leaves nothing to spawn, so that server is skipped with a warning (on a remote server the emptycommandis simply dropped). A server withdisabled: trueis skipped with a warning, because dcode has no per-serverdisabledkey — its only off switch is the server viewer's[mcp].disabled_serverstoggle inconfig.toml— and writing the key would leave the server running;disabled: falseis simply dropped. See the MCP tools docs.
Qwen Code note: MCP servers are written to the
mcpServerskey 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-serverenabledTools⇄ Qwen'sincludeTools(allowlist) anddisabledTools⇄ Qwen'sexcludeTools(denylist). Other top-level keys insettings.jsonare preserved on round-trip. In project mode, import also reads the Claude-parity.mcp.jsonat 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 itssettings.jsondefinition, matching Qwen Code's precedence (user settings < project.mcp.json< workspace settings); generation still writes only.qwen/settings.jsonand never touches.mcp.json, so after an import and a regenerate the imported servers live in both files and thesettings.jsoncopy wins — edit them through.rulesync/mcp.jsoncfrom then on. A malformed.mcp.jsonis skipped with a warning. Qwen's server-level allow/deny lists —mcp.allowedandmcp.excludedinsettings.json, MCP server names or*/?globs over themcpServerskeys, withexcludedwinning when a server matches both — are authored asallowed/excludedin theqwencodeblock 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 themcpobject, such asmcp.serverCommand— keep whatever valuesettings.jsonalready holds; removing a list from the block therefore does not remove it fromsettings.json. To clear a list, author it as an empty array ("excluded": []) for one generate, or delete it fromsettings.jsonby 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 Onlybecomespostgres_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 likemcp_1a2b3c4dinstead of being dropped; rename the server in.rulesync/mcp.jsoncto pick a readable Codex name. This normalization is one-way: importing back from the generatedconfig.tomlyields 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.headersis written ashttp_headers(and imports back asheaders), 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) andnetworkTimeout⇄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 acceptsstartup_timeout_ms, which imports verbatim intonetworkTimeoutunless the config setsstartup_timeout_sectoo — Codex prefers the seconds spelling when both are present, and so does Rulesync. The canonicaltoolsarray is not written: Codex declarestoolsas 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 — useenabledTools/disabledTools, which map onto Codex'senabled_tools/disabled_tools. Codex's table itself is authored through the namespacedcodexcliToolskey 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 fromcommandversusurl— plusalwaysAllow,trust, and the Kiro lists) are dropped silently; on import a server carrying aurland nocommandgetstype: "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 isstreamable_http, so a canonicalsse(orws, orstreamable-http) server comes back from a round-trip ashttp. Fields Rulesync does not model, such asenv_http_headersandbearer_token_env_var, pass through under their own names.
Codex CLI environment-variable note: Codex does not expand
${VAR}references inurlorhttp_headers— the values are sent literally. On generate, Rulesync therefore rewrites the two header shapes Codex can source from the environment:Authorization: Bearer ${VAR}becomesbearer_token_env_var = "VAR", and a header whose whole value is${VAR}becomes anenv_http_headersentry ("X-Api-Key" = "VAR"). The header name and theBearerscheme are matched case-insensitively. Abearer_token_env_varorenv_http_headersentry 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 inurl(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 keepsbearer_token_env_varandenv_http_headersas pass-through keys rather than restoring the canonicalheadersentries.
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.
{
"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:
[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:
[mcp_servers.slack.oauth]
client_id = "1601185624273.8899143856786"
callback_port = 3118A 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.
{
"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:
[mcp_servers.example]
url = "https://example.com/mcp"
default_tools_approval_mode = "writes"
[mcp_servers.example.tools.example_tool]
approval_mode = "approve"codexcliToolsmay also sit on a shared server; it is stripped before every other tool's MCP config is written, likeenvVars.- The two known fields are validated strictly, because Codex rejects the whole
config.tomlon an unknownapproval_modeor a zerooutput_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 incodexcliToolswins, and every saved tool or field it does not name is kept (authoring onlyoutput_token_limitfor a tool keeps its savedapproval_mode). Removing a tool fromcodexcliToolstherefore does not remove its entry fromconfig.toml; delete it there by hand. - On import, the
toolstable is lifted intocodexcliToolson 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 themcp_serverskey and preserves every other table on round-trip, and it is never deleted. Unlike Codex CLI, Grok uses a literalenvtable (it does not support theenv_varsruntime-passthrough list) and has no per-server tool allow/deny lists, so the only field rename isdisabled(rulesync) ⇄enabled = false(grok); an active server simply omitsenabled. 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 arraycommandfolds its tail intoargs),env→envs,url/httpUrl→uri, anddisabled: true→enabled: false. Thetypeis derived —command⇒stdio, a remoteurl⇒streamable_http(orssewhen the canonicaltypeissse). Each extension also carries its ownname. A canonical server with nocommandand nourlis skipped with a warning rather than written as astdioextension with nocmd, which Goose cannot start. Generation merges theextensions:block into the existingconfig.yaml, preserving other Goose settings (model, provider, ...), and the file is never deleted. Theextensions:map itself is co-owned: Goose's ownbuiltin/platform/frontend/inline_pythonextensions (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.jsonis 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 abuiltinused 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.jsonat 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 expressurl/headers, so remote (http/sse) servers are skipped with a warning in project mode; sync them with--globalto~/.config/goose/config.yamlinstead. The.mcp.jsonmanifest 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
copilottarget writes.vscode/mcp.json, which has three documented top-level sections:servers,inputs(secret prompts referenced as${input:id}) andsandbox(filesystem/network rules for sandboxed servers, added in VS Code v1.112). Rulesync owns and replaces onlyservers; 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 aninputsentry 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 canonicaltype. Rulesync translates on the way out (local→stdio,streamable-http→http) and back on import;wshas no Rovo Dev equivalent, so those servers are skipped with a warning, and atransportvalue outside Rovo Dev's vocabulary is dropped on import rather than written into the canonical config, whose transport field is a strict enum.disabledis stripped from the servers that are written, sincemcp.jsonis not where a Rovo Dev server is switched on and off — see the toggle handling below.mcp.jsonis written at both scopes: the global~/.rovodev/mcp.json, and in project mode the repo-committed.rovodev/mcp.jsonthe Bitbucket Cloud Agentic Pipelines guide documents (pointed at viamcp.mcpConfigPath, which Rulesync now writes into the projectconfig.ymlfor you: Rovo Dev'smcpConfigPathdefault points at the global MCP file, so without the pointer the generated projectmcp.jsonis 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 targetsrovodev, is not disabled, and names acommand/urlto 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. AmcpConfigPathalready 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.ymlis read from whatever directory Rovo Dev runs in. That same disagreement is why the global pointer is withheld when~/.rovodev/mcp_config.jsonholds servers of its own — if the settings reference is the spelling in force, those are the servers Rovo Dev is running today, and namingmcp.jsoninstead 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. Amcp_config.jsonthat 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 anmcpServersmap 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 — setmcp.mcpConfigPathyourself, 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 marksdisabled: trueis no longer dropped: its definition is written tomcp.json(minus the flag, which the file cannot express) and its name goes tomcp.disabledMcpServersin the siblingconfig.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 inmcp.disabledMcpServerscome back asdisabled: trueon the matching servers; aconfig.ymlthat 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 projectmcp.jsonis committed, prefer env-var references over literal credentials in serverenv/headers; note that rulesync owns themcpServersmap in that file, so servers hand-added there (rather than to.rulesync/mcp.jsonc) are replaced on the next generate. Rovo Dev's per-serverenable_instructions— which decides whether the server's own initialization-response instructions join the agent's system prompt — is authored as the Rulesync-only keyrovodevEnableInstructions(Rovo Dev's ownenable_instructionsspelling is accepted too, so an entry copied out of Atlassian's docs works) and written out asenable_instructions; on import it is lifted back ontorovodevEnableInstructions. 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 ortruesurfaces the instructions andfalseis 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, andfalseabove 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 notfalse— and when both spellings are present on one entry the canonical key decides, so an explicitrovodevEnableInstructions: falseoverrides anenable_instructions: trueleft over from a copied example rather than losing to it. Generate emits no warning for this key: writingtruenow restates the upstream default rather than widening anything. LikemusecodeMode, 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_serversblock of the global user settings file~/.config/muse/settings.json— no project-scoped MCP location is documented, so themusecodeMCP target requires--global. Each server entry carries atransportdiscriminator:stdioservers getcommand(a single string),argsandenv, and remote servers becometransport: "streamable_http"withurl/headers(Muse Code's only documented remote transport). A server that statessseorws— or carries aws:///wss://URL with no stated type — is skipped with a warning rather than rewritten onto a transport it does not speak. A canonicaldisabled: truemaps to Muse'senabled: falseand back. The settings file must carry"schema_version": 1— Muse Code fails startup withmalformed settings filewithout 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-servermode—required(its default) aborts the whole run when the server fails to start,optionalskips it with a warning — is authored as the Rulesync-only keymusecodeModeand written out asmode; on import amodeofrequiredoroptionalis lifted back intomusecodeMode, 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.musecodeModeis 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) inreasonix.toml(project) /~/.reasonix/config.toml(global, via--global). Each entry carries anameplus the standard transport fields:typeselects the transport (stdiodefault —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 ontohttppointed the client at Streamable HTTP so the server could not connect). The file is treated as shared Reasonix config: Rulesync only replaces thepluginskey and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. The canonicaldisabledToolslist is written as the v2 line's per-serverdisabled_tools(rawtools/listnames the server's tools are filtered by) and read back on import; Reasonix has no per-server tool allowlist, soenabledToolsis 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. Thetrusted_read_only_toolsarray (raw MCP tool names pre-seeded as trusted for planner/read-only use) is neither written nor imported: v1.17.18 retired it along withdefault_tools_approval_mode,tools.<raw>.approval_modeandapprovals_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 canonicalmcpServersthat every MCP target writes out, so it would surface in.mcp.jsonand the rest. Note that Rulesync owns thepluginskey, so the next generate drops the key from an olderreasonix.tomlas well; nothing is lost that Reasonix still reads. An MCP server whose transport Reasonix does not implement (ws, including aws:///wss://URL that states no transport at all) is skipped with a warning rather than written as atypeits loader rejects. Each entry also supportsstartup_timeout_seconds(a per-server cap on the background launch/authorization/initialize/tools/listsequence, overriding the globalmcp_startup_timeout_seconds;0means 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(serialstops 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:serialfor a stateful server the list does not catch,parallelfor one it does),auto_start(falseswitches 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 asdisabledrather thandeferred, and only an explicit re-enable — recorded inmcp-activation.json, which outranks the file — brings it back),load(v2:alwaysputs the server's tools in the schema from session start; empty ordeferredreaches them throughuse_capability) andoauth_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 projectreasonix.toml, and rulesync names it in a warning whenever it writestrue). All of them round-trip as passthrough fields on the canonical MCP server object rather than through a deep mapping, exceptauto_start, which canonicaldisableddrives. A canonicaldisabled: trueis always written asauto_start = false— a stop asked for across every tool wins over an authoredauto_start, with a warning when that was anything butfalse— and an authoredauto_startapplies only to a server that is not disabled. On import,auto_start = falsebecomes canonicaldisabled: trueandauto_startitself is not kept (trueis the default), so a later edit todisabledis not shadowed by a leftover value.concurrency,auto_start,loadandoauth_allow_missing_pkce_metadataare 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. Aconcurrencystring other thanserial/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,loadand 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; andstartup_timeout_secondshas a near-equivalent in canonicalnetworkTimeout(which Codex CLI deep-maps to itsstartup_timeout_sec), but canonical timeouts are milliseconds while Reasonix takes seconds, and Reasonix's meaningful0has no canonical spelling — so mapping it would either invent a value or lose one. Vibe'sstartup_timeout_secpasses 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.serversblock of its own JSON config:<project>/.zcode/config.json(project) and~/.zcode/cli/config.json(global, via--global). A stdio server carriescommand,argsandenv; a remote server carriestype—http(what rulesync callsstreamable-http) orsse— plusurland optionalheaders. 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 anhttp(s)URL is written astype: "http", ZCode's default remote transport, while aws/wssserver is skipped with a warning rather than rewritten onto a transport ZCode does not implement. ZCode's per-server toggle isenable, which defaults totruewhen absent, so a canonicaldisabled: trueis written asenable: falseand lifted back on import. The file is shared ZCode config: rulesync only replaces themcpkey (preserving its non-serverssiblings and every other top-level key such asmodel), never deletes the file, and fails closed rather than overwriting aconfig.jsonit cannot parse. ZCode's legacy.agents/mcp.jsonfallback — consulted only while the.zcodefile of the same scope lists no server — is neither written nor imported, so a hand-maintained one is left alone. Because rulesync replaces the wholeserversmap, a server added toconfig.jsonby hand is dropped on the nextgenerate; add it to.rulesync/.mcp.jsoninstead. The project file is committable, so preferenventries that reference environment variables over literal credentials. There are no per-server tool allow/deny lists. See the ZCode MCP services docs. Thezcode-plugintarget writes the same server shape into a plugin bundle's.mcp.jsonundermcpServers(project scope only), with a disabled server written asenabled: false, the plugin loader's spelling. See the plugin packaging guide.
IBM Bob MCP note: IBM Bob reads MCP servers from the
mcpServersmap 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 carriescommandandargs; a streamable-HTTP server (canonicalhttp,streamable-http, a bareurl, or Claude-stylehttpUrl) is written astype: "streamable-http"plusurl; an explicitsseserver is written as a bareurlwith notype, Bob's legacy SSE spelling.env,cwd,headers,timeout,alwaysAllow, anddisabledare passed through. The canonicaloauthobject is flattened onto the keys Bob reads:oauth: trueplusclientId,clientSecretandscope(a flat key already on the server wins over the nested one; nested keys Bob has no counterpart for, such ascallbackPort, are dropped), while a booleanoauthpasses through as Bob's own on/off switch. On import the flat keys are gathered back into theoauthobject whenoauthistrueor absent;oauth: falseleaves the entry as authored. A server Bob cannot start or reach — one with no transport, a remote transport without a URL, aws/wssURL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. On import, Bob IDE'stype: "streamable-http"/sseentries pass through, a bareurlbecomestype: "sse", and Bob Shell'shttpURLspelling becomesurlwithtype: "http"(Bob Shell's own user file,~/.bob/mcp_settings.json, is neither written nor imported). Only themcpServerskey is rewritten, so other keys in an existing file are preserved. The global~/.bob/settings/mcp.jsonis 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
mcpServerskey of the shared.tabnine/agent/settings.json(project) /~/.tabnine/agent/settings.json(global, via--global). A stdio server carriescommandandargs; a remote server carriesurlplus an optionaltype—"http"(streamable HTTP; the canonicalstreamable-httpalias and the Claude-stylehttpUrlfield are folded into it) or"sse". A bareurlwith no stated transport is written withouttype, since Tabnine then tries streamable HTTP first and falls back to SSE on its own. Rulesync maps the canonical per-serverenabledTools⇄ Tabnine'sincludeToolsanddisabledTools⇄excludeTools;env,cwd,headers,timeoutandtrustpass through. A server Tabnine cannot start or reach — one with no transport, a remote transport without a URL, aws/wssURL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only themcpServerskey 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
mcpServersmap of the user-level~/.snowflake/cortex/mcp.json— no project-scoped MCP file is documented, so themcpfeature is global-only (--globalis required; a project-scope run skips the target with a warning). Every server is written with an explicittype: a stdio server carriestype: "stdio",commandandargs; a remote server carriesurlplustype: "http"(the canonicalhttp,streamable-http, a bareurl, and the Claude-stylehttpUrlfield all fold into it) ortype: "sse".env,headers,oauthandtimeoutpass through. A server Cortex Code cannot start or reach — one with no transport, a remote transport without a URL, aws/wssURL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only themcpServerskey is rewritten, so other keys in an existing file are preserved, and becausecortex mcp addedits the same file it is never removed by--delete. See the MCP configuration docs.
Codewhale MCP note: Codewhale reads MCP servers from the
serversmap (themcpServersalias 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 carrycommandandargs; remote servers carryurland are reached over Streamable HTTP, withtransport: "sse"written only for servers declared as SSE. WebSocket servers are skipped with a warning.enabledTools/disabledToolsbecomeenabled_tools/disabled_tools. The canonicaltimeoutandnetworkTimeout(milliseconds) becomeexecute_timeout(the budget for each tool call) andconnect_timeout(spawn,initializeand the firsttools/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 explicitexecute_timeout/connect_timeouton the same server wins. Import multiplies them back into milliseconds;read_timeouthas no canonical counterpart and passes through unchanged. Other top-level keys (such astimeouts) and whichever server-map key the file already uses are preserved. Because Codewhale's own/mcpcommands also edit the file,--deletenever removes it.
Continue MCP note: Continue reads every
*.jsonfile under.continue/mcpServers/(project) /~/.continue/mcpServers/(global, via--global); Rulesync writes the documentedmcp.jsonthere as plain JSON with anmcpServersmap (an existing file is read as JSONC), and other files in the directory are untouched. A stdio server carriestype: "stdio",command,argsand the optionalenv(envFileis dropped with a warning because Continue ignores it, andcwdandtimeoutare dropped with a warning because Continue's JSON schema has no place for them); a remote server carriesurlplustype: "sse"ortype: "http"(the canonicalstreamable-httpalias and a bareurlboth fold intohttp), withheaderspassed through (oauthandtimeoutare 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, aws/wssURL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form. Only themcpServerskey is rewritten, so other top-level keys in an existingmcp.jsonare 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
mcpServersmap of.mcp.jsonat 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 deprecatedmcp.jsonspellings and the legacy~/.codebuddy.jsonare only read by CodeBuddy when the recommended file is absent, and the per-project local scope kept in~/.codebuddy.jsonis left to the tool. CodeBuddy's server keys (type—stdio,sseorhttp—,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,codebuddyyields the same project.mcp.jsonbytes whichever target generates last (in project mode both apply theclaudecode,codebuddy,commandcodeandqoderblocks, see the alias groups above). Only themcpServerskey is rewritten, so other top-level keys such asdisabledMcpServersare preserved (an existing file is read as JSONC); the canonicaldisabledflag is passed through as written rather than translated intodisabledMcpServers. 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
mcpServersmap of.mcp.jsonat 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 theclaudecodetarget 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,commandcodeyields the same bytes whichever target generates last (and both apply both theclaudecodeandcommandcodeblocks, see the alias groups above). Command Code reads that shape natively:typeis an alias oftransport, a bareurlmeanshttp, 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 thetransportkey — a stdio servertransport: "stdio",command,argsand the optionalenv; a remote serverurlplustransport: "http"(the canonicalstreamable-httpalias and a bareurlboth fold intohttp) ortransport: "sse", withheaders,envandoauthpassed through. There a server markeddisabled: trueis written with Command Code's nativeenabled: falseand read back asdisabled: 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, aws/wssURL, 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 themcpServerskey 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
mcpServersmap of.mcp.jsonat the project root (project scope, the file its docs describe as usually committed — the same one theclaudecodeandcommandcodetargets write) and of~/.qoder/settings.json(global, via--global); the per-machine.qoder/settings.local.jsonis 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 theclaudecodetarget's, and all four targets (claudecode,claudecode-legacy,commandcodeandqoder) apply theclaudecode,commandcodeandqoderblocks (see the alias groups above). Other top-level keys are kept: in~/.qoder/settings.jsononlymcpServersis 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.jsonis. See the MCP docs.
Crush MCP note: Crush reads MCP servers from the
mcpkey of its shared JSON config:<project>/crush.json(project) /~/.config/crush/crush.json(global, via--global). Crush's schema requires atypeon every server, so one is always written: a stdio server carriestype: "stdio",command,argsandenv; a remote server carriesurlplustype: "http"(the canonicalhttp,streamable-http, a bareurl, and the Claude-stylehttpUrlfield all fold into it) ortype: "sse", withheaderspassed through. The canonical per-serverdisabledTools⇄ Crush'sdisabled_toolsandenabledTools⇄enabled_tools(both match the server's own tool names, without themcp_<server>_prefix Crush uses in its permissions list);disabledandtimeout(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 andsessionlessmarker are authored under the Crush-only keyscrushOauth,crushOauthClientId,crushOauthClientSecret,crushOauthCallbackPortandcrushSessionless(written asoauth,oauth_client_id,oauth_client_secret,oauth_callback_portandsessionless; the rawoauth_client_id,oauth_client_secret,oauth_callback_portandsessionlessspellings are honoured as a fallback for an entry copied out of acrush.json, but onlycrushOauthturns the flow on, because the canonicaloauthkey is Claude Code's{ clientId }object and a rawoauth: truewould be forwarded to other tools as well). Like Codex'senvVars, 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, aws/wssURL, or a stdio entry without a command — is skipped with a warning rather than written in a broken form; an existingoauth_token, which Crush persists at runtime, is never imported. Note thatheadersandoauth_client_secret(ascrushOauthClientSecret) are copied verbatim into.rulesync/mcp.jsonon import, so move any literal secret into an environment-variable reference ($VAR) before committing that file. At project scope Crush reads both.crush.jsonandcrush.jsonand merges them (objects recursively, lists concatenated,.crush.jsonon top), so neither file hides the other. When a.crush.jsonalready exists it is the file written (otherwisecrush.json); if thecrush.jsonnext to it still carries the keys rulesync owns — for example from a generate run before.crush.jsonwas 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.jsonis read. Only themcpkey is rewritten, so theproviders,options,permissionsandhookskeys 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-basedcrushrc, but it stays supported and is the schema Crush publishes; acrushrcnext to it compilesmcp addinto the samemcp.<name>entries and wins key by key. See the config docs.
Codebuff (Freebuff) MCP note: Codebuff — renamed to Freebuff; the
codebuffandfreebuffCLIs share one configuration — reads MCP servers from themcpServersmap 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 astype: "stdio",command(an arraycommandis split intocommandand leadingargs),argsandenv; a remote server astype(http, orsse;streamable-httpbecomeshttp),url(httpUrlis folded into it),headersandparams. Other keys (cwd,timeout, tool filters, ...) are dropped with a warning, and adisabled: trueor WebSocket (ws) server is skipped with a warning. Codebuff treats everyenvvalue 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 otherenvvalue starting with$(such as${HOST}:8080or${VAR:-default}) is skipped with a warning, and a reference later in a value, or inheaders, is left as literal text. Aparamsmap whose values are not all strings is dropped with a warning. The global~/.agents/mcp.jsonis 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=1or--trust-agentsskips the prompt, e.g. in CI). Codebuff also readsAGENTS.md/CLAUDE.mdand.agents/skills/, so use theagentsmdandagentsskillstargets for rules and skills; its TypeScript agent definitions under.agents/*.tsare not generated. See the MCP docs.
.rulesync/.aiignore or .rulesyncignore (deprecated)
Deprecation notice: The
ignorefeature is deprecated in favor of the more expressivepermissionsfeature. Existing ignore configurations, generation, import, conversion, and explicitrulesync add ignorescaffolding remain supported throughout Rulesync 14.x. Removal, if any, will be decided separately and will not occur before a future major release.rulesync initno 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/.aiignoreover.rulesyncignorewhen reading. - Explicitly running
rulesync add ignorecreates.rulesync/.aiignorewhen neither location exists.
Example:
tmp/
credentials/Migrating to permissions
Move each ignore pattern into the read category of .rulesync/permissions.jsonc with the deny action:
{
"$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:
HERMES_ENABLE_PROJECT_PLUGINS=1 hermesRulesync 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:
// rulesync.jsonc
{
"targets": ["claudecode"],
"features": {
"claudecode": {
"ignore": { "fileMode": "local" },
},
},
}fileMode | Output file | Tracked by git by default |
|---|---|---|
"shared" (default) | .claude/settings.json | Yes — meant to be committed and shared with the team. |
"local" | .claude/settings.local.json | No — 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 confirmationask-- Requires user confirmation before executiondeny-- 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:
{
"$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, orprototype. 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.jsoncalike.
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
generatedelete the rules another tool's import produced. - The importing tool's own
{toolname}block (for exampleclaudecodewhen 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 itspermissionrecord 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:
{
"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
--targetsname is accepted as a block key.kiro-cli/kiro-idealias to thekirokey andhermesagenttohermes(matching the shared output file each writes). - OpenCode, Kilo, and Vibe keep their existing tool-native
permissionoverride 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:
{
"$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 (
claudecodekey): Claude Code'spermissionsobject also carries non-list fields with no canonical permission category — notablydefaultMode(the session-start permission mode:default|acceptEdits|plan|auto|bypassPermissions) andadditionalDirectories(extra working directories). Claude Code ignores adefaultModeofautoorbypassPermissionsin.claude/settings.jsonand.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-scopedclaudecodeoverride key alongside the shared block to author them: the fields underclaudecode.permissionsare merged into the settingspermissionsobject and emitted only for Claude Code, while the sharedpermissionblock continues to drive the managedallow/ask/denyarrays. The block is a verbatim passthrough (so other/futurepermissionsfields such as the org locksdisableBypassPermissionsMode/disableAutoModecan be set too), but anyallow/ask/denyplaced inside it is ignored — rulesync owns those arrays. On import, the non-listpermissionsfields round-trip back into theclaudecodeoverride. Note that these fields are merged additively into the existingsettings.json(so hand-added settings survive): removing a field from theclaudecodeoverride does not delete a value already written tosettings.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 ofsettings.jsonand 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 flatpermissionsfields above:sandboxsubtrees carry restriction lists (network.deniedDomains,filesystem.denyRead), so setting one flag undernetworkmust 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 ofsandbox.*only from user settings, managed settings and the--settingsflag —filesystem.disabled,network.strictAllowlist,network.tlsTerminate,credentials.allowPlaintextInject,credentials.awsPairs,credentials.sigv4andallowAppleEvents— 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 insidecredentials.filesandcredentials.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 thedenyentries 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.jsonkey. Everything in theclaudecodeblock other thanpermission,permissions,sandboxandhooksis written straight to the top level of.claude/settings.jsonand round-trips back on import, so a setting Claude Code adds needs no Rulesync release to become authorable —editorMode,emojiCompletionEnabled,workflowSizeGuideline,keybindingFlavor,env,model,alwaysThinkingEnabledand anything after them all work the same way. The merge is recursive, likesandbox, so setting one key underenvkeeps the variables already in the file.hooksis excluded because the hooks feature owns it,permission/permissions/sandboxbecause they have their own handling above, and$schemabecause 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 scopedUser or managed/User, local, or managed(spellcheck,autoMode,vimInsertModeRemaps,pluginConfigs,sshConfigs,syncClaudeAiSkills, ...) are skipped at project scope and emitted only under--global, while keys scopedManaged(allowManagedHooksOnly,requiredMinimumVersion,strictKnownMarketplaces, ...) orGlobal 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 spellingsadditionalMarketplacesandallowedMarketplacesare resolved to their canonical keys (extraKnownMarketplacesandstrictKnownMarketplaces) before that check, so an alias is treated exactly as the key it spells. The scope caveat also reaches insideenv, per variable, following the settings reference's list of variables Claude Code ignores inenv: the variables a checked-out repository should not control —CLAUDE_CONFIG_DIR,CLAUDE_CODE_TMPDIR,HOME,TMPDIR,TMP,TEMPandXDG_*; the WindowsSystemRoot,ComSpec,ProgramData,LOCALAPPDATA,PATHEXT,PSModulePathandProgramFiles*/ProgramW6432(matched without regard to case);OTEL_LOG_RAW_API_BODIES,ENABLE_BETA_TRACING_DETAILEDandBETA_TRACING_ENDPOINT; the telemetry switchesCLAUDE_CODE_ENABLE_TELEMETRY,CLAUDE_CODE_ENHANCED_TELEMETRY_BETAandENABLE_ENHANCED_TELEMETRY_BETA, the exporter selectorsOTEL_{LOGS,METRICS,TRACES}_EXPORTER, the content variablesOTEL_LOG_USER_PROMPTS,OTEL_LOG_ASSISTANT_RESPONSES,OTEL_LOG_TOOL_CONTENTandOTEL_LOG_TOOL_DETAILS, everyOTEL_EXPORTER_OTLP_*variable ending in_ENDPOINT,_HEADERS,_PROTOCOL,_CERTIFICATE,_CLIENT_KEYor_INSECURE, andOTEL_EXPORTER_PROMETHEUS_HOST/_PORT(ignored there since Claude Code v2.1.282); andCLAUDE_CODE_PROCESS_WRAPPER,CLAUDE_CODE_SYNC_SKILLS,CLAUDE_CODE_SYNC_PLUGINS,CLAUDE_CODE_PLUGIN_CACHE_DIRandCLAUDE_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:nonefor the three exporter selectors, and0,false,nooroffforOTEL_LOG_USER_PROMPTS,OTEL_LOG_TOOL_CONTENTandOTEL_LOG_TOOL_DETAILS(compared without regard to case). Claude Code versions before v2.1.251 still honored the directory and session-content variables other thanHOMEandXDG_CONFIG_HOMEfrom 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_RESTRICTEDand theCLAUDE_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 andCLAUDE_CODE_PROJECT_DIR_NAMEsince 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 fetchcopies.rulesync/permissions.jsoncout 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,statusLineandsubagentStatusLineare 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 insettings.json. The same line applies insidesandbox, which has its own merge branch:sandbox.ripgrep,sandbox.bwrapPathandsandbox.socatPatheach name an executable Claude Code runs, so they are refused in both scopes and dropped on import too. The scope caveat reaches insidesandboxas well:sandbox.filesystem.allowManagedReadPathsOnlyandsandbox.network.allowManagedDomainsOnlyare scopedManaged, so they are skipped in both scopes for the same reason theManagedtop-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,disableSkillShellExecutionset to anything buttrue(which re-opens inline shell execution a user setting had turned off),enableAllProjectMcpServers,enabledMcpjsonServersandallowedMcpServers,autoMode,skipAutoPermissionPromptandskipDangerousModePermissionPrompt,enabledPluginsandextraKnownMarketplaces,agentandoutputStyle(which replace the prompt and tools every session starts with),httpHookAllowedEnvVarsandallowedHttpHookUrls(which relax what an existing HTTP hook may send and where),claudeMdExcludes(which drops the CLAUDE.md files its patterns match),crossSessionInboundset to anything butholdorrefuse(which lets messages from your other sessions reach Claude),modelOverrides(which decides the inference profile a call is routed to),skipWebFetchPreflightset to anything butfalse(which turns off the WebFetch domain safety check),remoteControlAtStartupset to anything butfalse,prUrlTemplate(which rewrites the PR links Claude Code renders),companyAnnouncements,permissions.additionalDirectories, apermissions.defaultModeofbypassPermissions,acceptEditsorauto, and thesandboxpaths 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.httpProxyPortandnetwork.socksProxyPort. Theallow*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; theirdeny*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 emptyexcludedCommands) stays quiet. Each condition names the value that stays quiet rather than the one that warns, so a value of the wrong type — askipWebFetchPreflightof1rather thantrue— is reported rather than passed over.allowUnsandboxedCommandsandautoAllowBashIfSandboxedboth default totrue, and an explicittrueis reported even so: a project.claude/settings.jsonoutranks the user file, so writing it there re-opens what a user'sfalseclosed.remoteControlAtStartupis the one key whose scope depends on its value — Claude Code honors afalsefrom a project file but ignores atrue, so atrueis 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. (envis warned about rather than refused because it has too many ordinary uses to drop, even though a value such asNODE_OPTIONSorPATHdoes run code.) As withsandbox, 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 insettings.jsonby 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 (
opencodekey): OpenCode exposes permission categories that other tools do not understand (e.g.external_directory). Placing these in the sharedpermissionblock would push meaningless entries into Claude Code, Codex, etc. To scope them to OpenCode, add a tool-scopedopencodeoverride key alongside the shared block — mirroring the tool-scoped override keys used by hooks (opencode.hooks) and rules frontmatter. Categories underopencode.permissionare merged on top of the shared block per category (the override wins) and are emitted only intoopencode.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 anmcp__*tool name) is routed into theopencodeoverride rather than the shared block, so a subsequentrulesync generatedoes not leak it into other tools.You may also override a shared category for OpenCode specifically (e.g. put
webfetchunderopencode.permissionto 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 generatedopencode.jsonclassifies 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:
bashallowpatterns →command_allowlist. The allowlist is a list of shell-command patterns, soallowrules in other categories are not written there: areadoreditallow names a path, and an all-tools*allow need not name a command either (it is skipped with a warning; write it underbashto auto-approve it as a command).bashdenypatterns →approvals.deny— Hermes's hard denylist, evaluated before--yolo/approvals.mode: off.webfetchdenypatterns →security.website_blocklist.domains.- The all-tools
*category'sdenyandaskrules are read too — a rule written there covers shell commands as well — but they are not written intoapprovals.deny, since a pattern under*need not be a command. Like everyaskrule (Hermes has no ask tier), they restrict by withholding thebashallowrules they cover: the canonical order isdeny > ask > allowwhatever the width of the patterns, so{"*": {"npm *": "deny"}, "bash": {"npm publish": "allow"}}writes nonpm publishinto the allowlist, and{"*": {"*": "ask"}}withholds everyallow. Each withheld rule is reported with a warning. - Every
askrule, anddenyrules in categories other thanbash/*/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 privatepermissions.rulesynckey so.rulesync/permissions.jsoncreconstructs losslessly).
Hermes-only override (
hermeskey): 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-scopedhermesoverride key alongside the shared block to author them; its contents are deep-merged intoconfig.yaml(so anapprovals.modehere coexists with theapprovals.denyderived 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 settinghermes.approvals.denyorhermes.security.website_blocklist.domainsoverrides (does not append to) the list derived from the sharedpermissionblock — use it only when you intend to replace the canonical-derived deny list for Hermes. The top-levelpermissionskey is reserved by Rulesync for the round-trip blob, so apermissionskey inside thehermesoverride 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 oneprefix_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 *becomespattern = ["sudo"], which also matches a baresudo(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 legacygit:*spelling is read asgit *. 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 onnpm install*would block everynpmcommand), 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; usecodexcli.approval_policyfor that. The rules file is generate-only:rulesync importreads.codex/config.tomland does not bringpermission.bashback from it.read:allow→read,ask/deny→denyinpermissions.<profile>.filesystemedit/write:allow→write, whileask/deny→readinpermissions.<profile>.filesystemwhen 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 explicitread: deny/askon the same pattern takes precedence and emitsdeny. A write-side-only rule (noreadrule for the same pattern) also emitsdenywhen the most specificreadrule covering the path is adeny/ask— an exact pathXorX/**coveringXor anything below it when both are the same kind of path (both absolute, drive, or~paths, or both workspace-relative — so a relativeC:never covers the drive pathC:\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, soc:\secretcoversC:\secret\k),**,.,./**, or:workspace_rootscovering any workspace-relative pattern, or/**and:rootcovering every path — because Codex gives the more specific path priority and areadorwriteentry would re-open reads the broader rule denies (e.g.read: { "/home/me/.ssh/**": "deny" }+write: { "/home/me/.ssh/id_rsa": "deny" }emitsdenyfor both). This applies to a write-sideallowtoo: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 (addread: allowfor the same pattern to keep it writable). Only the most specific coveringreadrule 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 specificread: allowthat 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), soread: { "/home/me/.ssh/**": "deny" }covers/home/me/public/../.ssh/id_rsa. As an extra safeguard, when the path or theread: allowpattern 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" }emitsdeny). Other broader read denials are not matched, so Rulesync warns when it maps a write-side-onlydenytoread: a non-trailing glob such as**/*.pem, special paths other than:rootand: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 explicitread: allow+ write-sidedeny, which asks for exactly Codex'sreadlevel. For other globs, Codex supports onlydeny: Rulesync skips grant-derivedread/writeentries with a warning so the baseline applies, and emitsdenyfor a write-sideask/denyrestriction 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 adeny— 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-derivedread/writeentry is skipped with a warning and a write-sideask/denyrestriction emitsdeny. Use a plain drive path such asC:\...instead.- A Codex
"write"entry is imported as bothread: allowandedit: allow, since Codex'swritelevel 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 asread: allowonly. Bothread: allow+ write-sideask/denyand a write-side-onlyedit/writeask/deny(e.g.edit: { "/x/**": "deny" }, which generates"/x/**" = "read") therefore lose their write restriction onrulesync import, and a later generate hands the path back to the baseline's write access. Rulesync does not re-materialize"read"as an extraedit: 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/denymap topermissions.<profile>.network.domains(Codex does not supportaskfor domain rules);network.enabled = trueis emitted only when at least oneallowrule is present. Deny-only domain sets are emitted withoutenabled, 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, sowebfetch: { "*": "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,denyentries are always taken, whileallowentries are imported only whenenabled = trueis explicit — Codex treats a missingenabledas restricted, so importing an allow entry from a disabled profile would activate a grant Codex never had. A Codex profile withnetwork.enabled = truebut nodomainsis imported aswebfetch: { "*": "allow" }, which reflects Codex's default semantics whereenabled = truegrants sandbox-wide network access (under Codex's experimentalnetwork_proxyfeature,enabled = truewithout 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 (
codexclikey): 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-scopedcodexclioverride to author them: except forbase_permission_profile, its fields are written verbatim as top-level.codex/config.tomlkeys (the override wins per key; existing sibling keys the user set directly are preserved, and table values are shallow-merged) while the sharedpermissionblock keeps driving the managed[permissions.rulesync]profile anddefault_permissions. Supported keys:base_permission_profile(:read-only|:workspace|:danger-full-access, default:workspace— not a top-level key; it becomes the managed profile'sextendsbaseline, or with:danger-full-accessthe directly-selecteddefault_permissionsvalue, see above),approval_policy(on-request|never, or a{ granular = { … } }table kept verbatim; defaults toon-requestwhen neither the override nor the existing config sets it —on-failureis a deprecated alias Codex still reads ason-requestand is written with a warning;untrustedwas retired in Codex 0.149.0 and makes Codex refuse to start, so an authoreduntrustedis skipped with a warning (the key then falls back to the existing value or the default), anuntrustedleft behind inconfig.tomlby an earlier Rulesync is replaced with the default on generate rather than preserved, and an existinguntrustedis 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 aliasguardian_subagent), or a table; defaults toauto_reviewwhen neither the override nor the existing config sets it),tui(the[tui]table — e.g.vim_mode_default = truefor the modal Vim composer added in Codex 0.129.0, and thetui.keymap.*bindings beside it; the shallow merge applies one level deep, so authoringkeymapreplaces the whole existing[tui.keymap]table rather than merging into it), andgit_write_rules(boolean, defaulttrue— likebase_permission_profileit is not a top-level key: it controls whether the managed profile's:workspace_rootstable emits the default.gitcarve-out described above; only an explicitfalsesuppresses it). Deprecated:sandbox_mode(read-only|workspace-write|danger-full-access) with the siblingsandbox_workspace_writetable (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 usebase_permission_profileand the sharedpermissionblock instead. On import, the top-level keys round-trip back into thecodexclioverride, and the managed profile'sextendsround-trips intobase_permission_profile. It is alooseObject, 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.tswrites themcp_serverstables in the sameconfig.toml), andpermissions/default_permissionsare 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):
bashmaps totoolsSettings.shell.allowedCommands/toolsSettings.shell.deniedCommandsreadmaps totoolsSettings.read.allowedPaths/toolsSettings.read.deniedPathsedit/writemap totoolsSettings.write.allowedPaths/toolsSettings.write.deniedPathsgrepmaps totoolsSettings.grep.allowedPaths/toolsSettings.grep.deniedPathsglobmaps totoolsSettings.glob.allowedPaths/toolsSettings.glob.deniedPaths(both emitted only when a rule is present, so existing configs do not gain empty tables)webfetch/websearchwith pattern*map toallowedToolsentries (web_fetch/web_search)askrules are skipped with a warning (Kiro config does not support explicit ask entries)
Kiro-only override (
kirokey): Kiro's agent config exposes per-tooltoolsSettingsknobs with no canonical allow/ask/deny category. Author them through a tool-scopedkirooverride undertoolsSettings: the shell auto-trust flagsshell.autoAllowReadonly/shell.denyByDefault, theawsbuilt-in tool'sallowedServices/deniedServices(+autoAllowReadonly), and theweb_fetchdomain trust arraystrusted/blocked(regex host patterns; Kiro documents these forweb_fetchonly —web_searchhas 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 pertoolsSettingskey (the override wins at the leaf) so authoringshell.autoAllowReadonlykeeps the canonical-generatedshell.allowedCommands; the sharedpermissionblock keeps drivingshell.{allowed,denied}Commands,read/write/grep/globpaths, and theweb_fetch/web_searchallowedToolstoggles. Existing non-canonicalshellflags are preserved across regenerate even without an override. On import, these Kiro-specific surfaces are lifted into thekirooverride so they round-trip. It is alooseObjectat every level, so future KirotoolsSettingsfields pass through verbatim. Kiro MCPdisabledToolslives in the separate.kiro/settings/mcp.jsonfile and is modeled by the MCP feature; MCPautoApproveremains 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 (
cursorkey): Cursor'scli.jsoncarries scalar autonomy settings with no canonical permission category —approvalMode(allowlist|auto-review|unrestricted) and asandboxobject (mode/networkAccess). Add a tool-scopedcursoroverride to author them: its fields are merged into the top level of the config file while the sharedpermissionblock keeps driving thepermissions.allow/permissions.denyarrays (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.jsonwhere Cursor would ignore them and the authored setting would silently never take effect. On import,approvalModeandsandboxround-trip back into thecursoroverride. It is alooseObject, sosandbox's (currently undocumented) value set passes through verbatim and extracli.jsonkeys can be authored here (they are merged verbatim on generate); note that onlyapprovalModeandsandboxare 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 (
kilokey): Kilo'spermissionobject 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-scopedkilooverride key alongside the shared block (mirroring theopencodeoverride) to author these; entries underkilo.permissionare merged on top of the shared block per key (the override wins) and are emitted only intokilo.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 anmcp__*tool name) is routed into thekilooverride rather than the shared block, so a subsequentrulesync generatedoes not leak it into other tools.Kilo-only override (
kilo.sandbox): thesandboxblock 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-scopedkilooverride:enabled(boolean),network(e.g."deny"),allowed_hosts(a list ofhost/host:portdestination exceptions) andwritable_paths. It is shallow-merged into the top-levelsandboxkey ofkilo.jsonc— the override's keys win, unrelated sibling keys you set directly are preserved — and the whole block round-trips back intokilo.sandboxon import. Scope matters here. Kilo honorsallowed_hostsandwritable_pathsfrom 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 onlyenabledandnetworkare emitted, and any other key is dropped with a warning telling you to author it with--global. See the sandboxing docs.
Kilo
markdown_sourcescope: Kilo grantspermission.markdown_source(external Markdown command sources, written as"<absolute>/*": "allow"patterns) only when the last matching rule comes from the global config and saysallow— "Project configuration cannot grant this permission" (workflows docs). A project-level rule that matches last therefore always blocks, whatever its action:denyblocks,askblocks just likedeny(Kilo never prompts for this permission), and a projectallowwould block too, the opposite of what it says. So whenmarkdown_sourceis generated at project scope — whether it comes from the sharedpermissionblock or fromkilo.permission—denyandaskentries are written andallowpatterns are dropped with a warning telling you to author it with--global; if nothing survives, the key is removed from the projectkilo.jsonc. Global scope writes the rule verbatim.
Name-mismatch traps. Canonical category names do not always match Kilo's key names: Kilo folds
writeintoedit(there is nowritekey), usesnotebook_edit(not the canonicalnotebookedit) andtask/agent_manager(notagent), and has nomcpkey (MCP is addressed viamcp__*tool-name keys). Rulesync passes key names through verbatim, so author Kilo keys using Kilo's own names (e.g. put anotebook_editrule underkilo.permission, not the canonicalnotebookedit). Kilo also treats anullaction as a delete sentinel; Rulesync does not modelnulland only round-tripsallow/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 (
augmentcodekey): AugmentCode'stoolPermissions[]supports "custom policy" entries the canonical allow/ask/deny model cannot express —permission.typeofwebhook-policy/script-policy(delegating the decision to awebhookUrl/script) and aneventTypeoftool-response(a post-execution check rather than the default pre-executiontool-call). Author these through a tool-scopedaugmentcodeoverride with atoolPermissionsarray 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 authorstoolPermissionsit 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 insettings.jsonare preserved verbatim as before. On import, special entries are lifted verbatim into theaugmentcodeoverride (rather than being skipped with a warning) so they round-trip and become user-authorable; basic entries continue to drive the sharedpermissionblock. The entry objects stay a loose passthrough soshellInputRegex,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) andeventType(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 } } }.recommendedMarketplacesis 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.jsonand ignores it in user or local settings, so it replaces the project list and is skipped with a warning under--global.enabledPluginsmaps plugin ids ("plugin-name@marketplace-name") totrue/falseand 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-mergesenabledPluginsacross tiers, so the user and local tiers still apply). Because the stated value owns the whole key, an entry added tosettings.jsonafter the last import — for example byauggie plugin install --project— is removed on the next generate, with a warning naming it; add them to the override, or runrulesync import --targets augmentcode --features permissionsafter 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 insettings.jsonby hand. Writing a non-emptyrecommendedMarketplacesor 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 theaugmentcodeoverride when they have the documented shape, so a plugin enabled withauggie plugin install --projectsurvives the round-trip. Both are read from the committedsettings.jsononly: arecommendedMarketplacesinsettings.local.jsonis ignored, as Auggie ignores it, and anenabledPluginsthere is not imported, so a plugin enabled for one machine is never promoted to the shared settings. A global import skipsrecommendedMarketplaces, which Auggie ignores in~/.augment/settings.json.dismissedMarketplaces(managed by Auggie insettings.local.json) andautoUpdateMarketplaces(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 (
factorydroidkey): Factory Droid has security controls that do not fit the per-commandallow/ask/denymodel — the hard-blockcommandBlocklisttier (commands that can never run, not even under full autonomy — distinct from an approvabledeny), plusnetworkPolicy(allowedIps),sandbox(enabled/mode/filesystem/network),mcpPolicy,enableDroidShield, autonomy settings (sessionDefaultSettings,maxAutonomyLevel,subagentAutonomyLevel,interactionMode, and the per-toolmcpAutonomyOverrides), the plugin-bootstrap keysextraKnownMarketplaces/enabledPlugins(Droid auto-registers those marketplaces and installs those plugins on start — the upstream distribution path for the same artifacts rulesync generates), thehooksDisabledkill-switch,disabledSkills(an array of skill names to disable without deleting their files), and the organization controlsmodelPolicyandmissionPolicy. Add a tool-scopedfactorydroidoverride to author them: its keys are merged intosettings.json(the override wins) while the sharedpermissionblock keeps drivingcommandAllowlist/commandDenylist. On import, these keys are lifted into thefactorydroidoverride — socommandBlocklistnow round-trips faithfully (its never-runs guarantee is preserved) rather than being collapsed onto an approvabledeny.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 (
zedkey): three Zedagentkeys sit outside the canonical allow/ask/deny model and are authored verbatim through a tool-scopedzedoverride.zed.sandbox_permissionsis written intoagent.sandbox_permissions: Zed's OS-level agent sandbox, which since Zed 1.14.2 (2026-08-05) is on by default for theterminalandfetchtools and by default forbids network access, writing outside the project directories, and writing to.git. Most real setups therefore need to relax one ofnetwork_hosts(exact hostnames or leading*.wildcards),allow_all_hosts,write_paths,allow_fs_write_allorallow_unsandboxed— none of which the canonical categories can express, since this is process containment rather than tool gating.zed.profilesis written intoagent.profiles, Zed's tool-availability layer: a separate enforcement stage fromtool_permissions, because a tool absent from the active profile cannot be used no matter what the permission rules allow (per-profile keysname,tools,enable_all_context_servers,context_servers,default_model).zed.default_profileis written intoagent.default_profile, the profile the agent starts in (Zed's default iswrite), 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" } }. Thesandbox_permissionsandprofilesblocks pass through untouched — Rulesync canonicalizes neither, and validates only the documentedprofileskeys (sandbox_permissionsis 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 insettings.jsonis left alone rather than deleted, so removing a block is a manual edit. On import, all three are lifted back into thezedoverride 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 intoagent.sandbox_permissionsitself. Read the imported block before committing it: an ad-hocallow_unsandboxedpicked up from your own machine would otherwise be committed to.rulesync/permissions.jsoncand 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. Thezedblock authors these three keys and nothing else:agent.tool_permissionsbelongs to the canonicalpermissionblock, and any other key — a misspelling, or a blunt instrument such as Zed'sagent.always_allow_tool_actions— is ignored with a warning, so nothing reachable from the override can weaken a reviewed deny. Like the siblingagent.tool_permissions, these keys are written in global scope only, since Zed reads noagentkey 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 (
qwencodekey): Qwen'ssettings.jsonexposes autonomy/sandbox controls with no canonical permission category — undertools(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-inlist_directorytool, off by default becauseglobcovers directory listing — note that Qwen re-enables the tool anyway whenlist_directoryis named in thetools.coreallowlist, an unrelated sibling key the override preserves rather than owns),todoWrite(the{ enabled }toggle for the built-intodo_writetool and its system-prompt guidance, off by default since Qwen Code v0.23.1;experimental.todoStopGuardrequires 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 aftertool_searchloads it — and out of reach for a session wheretool_searchis not registered. Unlikevisibleanddisabled, 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/workflowscommand),workflowNameOnly(which restricts that tool to named workflows, so every run the model starts can be matched by aWorkflow(name:...)permission rule; since Qwen Code v0.24.1), andexecutionSandbox(the Linux tool-execution sandbox policy,{ filesystem, network, backend? }; since Qwen Code v0.24.4)),security(folderTrust, the two guardrails ontype: "http"hooks —allowedHttpHookUrls, the allowlist of URL patterns a hook may POST to, where an empty list means allow-all, andallowPrivateNetworkHooks, which relaxes the private-IP (SSRF) check — andallowedInsecureVoiceBaseUrls, 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 itsAuthorizationheader), andpermissions.autoMode(the Auto Mode classifier config:hints.{allow,softDeny,hardDeny},environment,classifyAllShell). Add a tool-scopedqwencodeoverride to author them:qwencode.toolsandqwencode.securityare shallow-merged into the matchingsettings.jsongroup at the top level of that group (an unrelated sibling key such astools.coreis preserved, an override key wins, and a nested object the override supplies such assecurity.folderTrustreplaces the existing one wholesale rather than being deep-merged), whileqwencode.autoModeis emitted aspermissions.autoMode(replacing the existingautoModewholesale) and the sharedpermissionblock keeps driving thepermissions.allow/ask/denyarrays. On import, the documented autonomy keys (tools.{approvalMode,autoAccept,sandbox,sandboxImage,disabled,visible,listDirectory,todoWrite,eager,workflowsEnabled,workflowNameOnly,executionSandbox},security.{folderTrust,allowedHttpHookUrls,allowPrivateNetworkHooks,allowedInsecureVoiceBaseUrls}, andpermissions.autoMode) round-trip back into the override; othertools/securitykeys are left insettings.jsonand 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.allowPrivateNetworkHooksandsecurity.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-scopedsettings.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 — so1, 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 forallowedInsecureVoiceBaseUrls, whose list it matches a URL against instead, making the empty one fail-closed — and anyexecutionSandboxvalue,nullincluded, since it is a policy rather than a switch. (workflowsEnabledalso answers to theQWEN_CODE_ENABLE_WORKFLOWS/QWEN_CODE_DISABLE_WORKFLOWSenvironment variables, disable winning, so a settings value is not the last word.) The other two are written in both scopes, but not silently.security.allowedHttpHookUrlssits 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.folderTrustis 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--globalwould promote into one Qwen Code enforces everywhere; a value imported from~/.qwen/settings.jsonis already there, so it is lifted silently. Review a flagged key before you regenerate globally, especially if the.qwen/settings.jsoncame 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 totruebut a workspacefalseis 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 — plusvisible,listDirectory,todoWriteandeager— 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 becauseqwencode.toolsandqwencode.securityare open objects, so a key Qwen Code adds works before rulesync knows about it — including potent ones such astools.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 plainrulesync generateis 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-runninggeneratesays 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
Readis a meta-tool that also covers grep/glob/list, so canonicalgrep/globrules are emitted as their ownGrep(...)/Glob(...)entries but overlap Qwen'sReadcategory at runtime; and Qwen folds web search intoweb_fetch, so a canonicalwebsearchrule (WebSearch(...)) may not correspond to a distinct Qwen tool.tools.disabledis a hard whole-tool disable (stronger thandeny) and is only authorable via the override, not the canonicaldeny.
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 (
warpkey): Warp's[agents.profiles]table also exposes file-read/read-only autonomy knobs that do not fit the per-commandallow/ask/denymodel —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), andagent_mode_execute_readonly_commands(a boolean auto-executing read-only commands). Add a tool-scopedwarpoverride to author them: its keys are merged into[agents.profiles](the override wins) while the sharedpermissionblock keeps driving the command lists. On import, these keys are lifted fromsettings.tomlinto thewarpoverride, 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 nestedwarp.execution_profileblock instead —read_files/apply_code_diffs/execute_commands/mcp_permissions(eachagent_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), andmcp_allowlist/mcp_denylist(MCP server IDs). Its keys are merged into thedefaultrecord 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 asnameor the model overrides never round-trip), and the rulesync-ownedcommand_allowlist/command_denylistalways 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 (
deepagentskey): 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-scopeddeepagentsoverride whosestartupblock 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), andread_project_dotenv(whether an untrusted repository's.envis 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 knownmode, 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.recentis deliberately not lifted at all, since dcode rewrites it as the user cycles modes and committing it would publish one session's state. Generate dropsrecentfor the same reason and one more: with no explicitmodebeside 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.jsonca repository can carry, a value that relaxes what dcode does on its own —modeofautooryolo, or ayolo_switcher/read_project_dotenvthat was explicitlyfalsebeing turned back on — is warned about by name, alongside the value it replaces (both booleans default totrueupstream, so writingtrueover 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
extensionsblock, merged into[extensions]the same way. dcode auto-loads*.pyfrom 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,trueupstream) andtrust(ask/always/never, the fallback applied to project extension code when no per-project grant exists,askupstream). On import, exactly those two keys come back, withtrustread the way upstream'sparse_trust_policyreads it — trimmed and lowercased, so" Always "is lifted asalwaysrather than dropped — and a value dcode itself would not read is left behind with a warning, as in thestartupblock.extensions.extra_pathsis 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,trustofalways, or anenabledthat was explicitlyfalsebeing turned back on, is warned about by name alongside the value it replaces. Example:The override's
mcpblock 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.jsondeclares, which Rulesync'smcpfeature writes — that dcode always rejects, a rejection winning over any approval or trust. dcode reads this list from the user-levelconfig.tomlonly, never from a project file, which is why it is authored here rather than beside the MCP config. It is a canonical-shapedstring[]; 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_serversis 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, ignoredenabled_project_serversare 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 (
vibekey): Vibe'sBaseToolConfigalso carries asensitive_patternslist — patterns that escalate to ASK even when the base permission is ALWAYS (allow). The canonical model can only set a pattern to a singleallow/ask/deny, so an "allow by default but ask on these patterns" escalation cannot be expressed in the shared block. Add a tool-scopedvibeoverride to author it:vibe.permission.<category>.sensitive_patternscarries the list per canonical category (e.g.bash,edit), while the sharedpermissionblock still sets the base permission and allow/deny lists. On import, a tool'ssensitive_patternsround-trips back into thevibeoverride (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 existingconfig.tomlhad. Avibe.permission.bashentry is fanned out to the Windows shells like the base permission is, but where a shell'sconfig.tomltable 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 becausesensitive_patternsonly ever escalates to ASK. An emptysensitive_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 emptyvibe.permission.bash.sensitive_patternsclears the patterns this override owns, but a shell that authored different patterns of its own keeps them (with a warning saying so) — delete them fromconfig.tomlto clear them. Name that shell invibe.permission.<shell>.sensitive_patternsto own its list outright.sensitive_patternsis the only key this override can express — apermission,allowlistordenylistwritten insidevibe.permission.<category>is ignored with a warning, since the sharedpermissionblock 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 carriesvibe.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-emptyenabled_toolsis lifted back into the override rather than being misread as a set of"*": "allow"grants. Note theconfig.tomlscope 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--globalrun is no longer discarded wholesale by a projectconfig.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_serversandconnectorsunion-merge by name,toolsdeep-merges,disabled_toolsconcatenates, andenabled_toolsis replaced wholesale by the higher layer. An org-enforcedAdminConfigLayersits 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 (
juniekey): Junie'sallowlist.jsonhas settings with no canonical per-glob slot — the top-level autonomy knobsallowReadonlyCommands(a boolean auto-allowing read-only commands) anddefaultBehavior(the fallback action when no rule matches; anallow/askenum — Junie'sAllowListDecisionaccepts 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 — canonicalreadis already taken byreadOutsideProject, so this group is authored whole as{ "default"?, "rules": [ … ] }) andruleDefaults(each mapped group's own fallback action, e.g.{ "executables": "ask" }). Add a tool-scopedjunieoverride to author them: the scalar knobs are merged onto the top level ofallowlist.json(the override wins) while the sharedpermissionblock keeps driving the mapped groups' rule lists, and the group-shaped settings land inside therulesobject. On import, all of these are lifted fromallowlist.jsoninto thejunieoverride, 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 (
reasonixkey): 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 siblingplan_mode_allowed_toolsleft 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-scopedreasonixoverride to author them:reasonix.sandboxandreasonix.agentare shallow-merged into the matchingreasonix.tomltable at its top level (override keys win, unrelated sibling keys such as[agent].modelare preserved), while the sharedpermissionblock keeps driving[permissions].allow/ask/deny. The[sandbox]values that loosen the enforcement layer under the permission policy — abashset to anything butenforce, anetworkthat is notfalse, a non-emptyallow_write, and aworkspace_rootthat is anything but a plain relative path inside the project — an absolute path in POSIX or Windows form, a drive-lettered one such asC: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 replacesforbid_readwhole rather than accumulating it, that summary also names the read protections a generate would drop: aforbid_readthat 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 carriesrawAllow/rawAsk/rawDeny— verbatim[permissions]entries merged into the generated arrays untranslated. They exist for the first-classBash=<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 -cand the like, which Reasonix gates harder than a merely dynamic command line — in a headlessreasonix run. The blanket alternative isreasonix.allowDynamicBash, which writes[permissions] allow_dynamic_bash(added in v1.19.0): where arawAllowentry pre-authorizes one exact literal, this lets an Allow fallback — Auto included — cover that whole class at once, with explicitaskanddenyrules keeping precedence over it. Setting it totrueis 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 inreasonix.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 exactTool=<literal>entries are lifted intorawAllow/rawAsk/rawDenyinstead 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_toolsMCP 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 (
crushkey): 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, astoolortool:actionentries) andoptions.disabled_tools(built-in tools removed from the agent altogether), written into the sharedcrush.json(project;.crush.jsonwhen 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.jsonandcrush.json(lists concatenated,.crush.jsonon top), soallowed_tools/disabled_toolsentries left in thecrush.jsonnext to a.crush.jsonstay 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; amcp__<server>__<tool>category becomes Crush'smcp_<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-allallowlands inpermissions.allowed_tools; a catch-alldenylands inoptions.disabled_tools, which only covers Crush's built-in tools, so adenyon an MCP tool is skipped with a warning (disable it through the server'sdisabled_toolsin.rulesync/mcp.jsoncinstead);askwrites nothing — a tool in neither list keeps Crush's default prompt. Because anallowed_toolsentry approves every invocation of that tool, a catch-allallowis withheld (and named in a warning) when the same category also carries a narrowerdenyorask— withbash: { "*": "allow", "rm -rf *": "deny" }nothing is written forbash, 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 emptyingdisabled_toolsis not a disable-all) and is skipped with a warning. Entries in either list that rulesync did not derive —tool:actionforms, MCP names, tools with no canonical category — are preserved on regenerate, and a rule that flips toask(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 toaskfirst to retract it. Author the rest of thepermissionsblock (skip_requests) and the otheroptionskeys directly incrush.json; they are untouched. On import, every plain tool entry inpermissions.allowed_toolsbecomes{ "*": "allow" }for its category and everyoptions.disabled_toolsentry{ "*": "deny" }(deny wins when a tool is in both);tool:actionentries 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.yamloverlay 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 becometools.<name>blocks withallowanddenypattern 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 astools.<category>. Anallowrule lands inallow, adenyrule indeny(Pool lets a denied pattern win over an allowed one, so nothing is withheld for it), andaskwrites nothing — a call that matches neither list prompts, which is Pool's default — except that anallowoverlapping anaskof any category that lands on the same Pool tool (bashand a pass-throughshellboth writetools.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 adeny, it withholds the allows it overlaps on the same Pool tool just as anaskdoes, so the skipped deny is honoured by prompting rather than silently auto-approved. The file categoriesread,editandwritebecomepathsentries instead, Pool's file-access model: areadallow is a read-only entry ({ path }), aneditorwriteallow addswrite: true(Pool has a single write permission covering edits, creations, deletions and renames), and areaddeny is apaths.denyentry, 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 anedit/writedeny that is not also areaddeny is not written (a warning says so) and only withholds the write flag of the allows it overlaps — withread: { "src/**": "allow" }, edit: { "src/**": "allow", "src/generated/**": "deny" }the entry is written read-only and Pool prompts for the writes; anaskin a file category likewise withholds the overlapping allows (a write entry also grants reading, so areadask 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 (areaddeny skipped that way exempts no write deny, since nothing lands inpaths.denyfor it). The all-tools*category has no Pool counterpart: its restrictions are mirrored ontobashand the category itself is skipped with a warning. Amcp__<server>__<tool>category is skipped too — Pool approves MCP tools per server undermcp_servers.<server>.allow, which the MCP feature writes from the server'spoolAllowin.rulesync/mcp.jsonc. On regenerate theallow/denylists of the tools the canonical config names are rebuilt (an empty list retracts its key) and each tool'sdisabledflag 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 toaskfirst to retract them. Thepathslists are rebuilt whenever the canonical config carries a file category and left untouched otherwise. On import, eachtools.<name>block maps back to its category (shell→bash,web_fetch→webfetch,web_search→websearch, anything else verbatim) with itsallow/denypatterns (deny wins when a pattern is in both) anddisabled: trueas{ "*": "deny" }; apaths.allowentry becomes areadallow (pluseditandwriteallows when it carrieswrite: true) and apaths.denyentry a deny in all three;mcp_serversis not read. See the settings file reference.
Tabnine CLI-only override (
tabninekey): Tabnine CLI has no per-path or per-URL permission model — itssettings.jsoncarries two tool lists,tools.allowed(tools that skip the confirmation prompt) andtools.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, abashrule whose pattern is a command prefix (git *,pnpm, or the bare*for the whole tool) becomesrun_shell_command(<prefix>)— Tabnine matches the prefix against the start of the command line (so a barepnpmalso coverspnpm install), and any other shell pattern (git * push,*.sh) is skipped with a warning. Abashdenyis written asrun_shell_command(<prefix>)intools.exclude— the form Gemini CLI, which Tabnine CLI derives from, honors inexcludeTools— but Tabnine documents the prefix syntax fortools.allowedonly, so that entry is not relied on to enforce the rule. Because a written prefix approves every longer command line too, eachbashallowis compared at that width against every rule that restricts shell commands — abashdeny(written as a prefix or not), abashask, and thedenyandaskrules of the all-tools*category — and is withheld when it overlaps one, rather than auto-approving the restricted commands: withgit *allowed andgit push *denied, a barepnpmallowed andpnpm publish *asked about, orgit *allowed andgit * --forcedenied, 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.allowlands intools.allowed,denyintools.exclude, andaskwrites nothing — a tool in neither list keeps Tabnine's default prompt. The rest of thetoolsgroup (core,shell.*,enableWebTools, ...) and thegeneralgroup are authored through thetabnineoverride, 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.pagerandtools.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, andgeneral.preferredEditor, an editor executable path — so a permissions file (one fetched withrulesync fetchincluded) cannot point it at an executable or a server of its choosing; set them by hand insettings.jsonif you need them. Arun_shell_command(<prefix>)entry in the override's owntools.allowedis abashallow 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 insettings.jsonback 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 othertools.allowedentries (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), atools.sandboxthat is not the plaintrue, and ageneral.defaultApprovalModeother thandefaultorplan. Entries already intools.allowed/tools.excludethat name a tool the canonical block does not mention (an MCP tool, a hand-writtenrun_shell_command(...)prefix while the block has neither abashnor a*rule) are kept; the entries of a tool the block does name are rewritten from it (an existingtools.excludeentry that goes this way is named in a warning, since removing it loosens the policy; a droppedtools.allowedentry 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 insettings.jsonrather than lifting them (a generate would only refuse them) and announces the trust-affecting keys it does lift, so the resulting.rulesync/permissions.jsoncis 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 (
devinkey): the sibling top-levelsandboxblock 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;limitedonly GET/HEAD/OPTIONS) andexcluded(allow/ask/denylists ofExec(...)matchers deciding which commands run outside the sandbox:allowruns them outside it,askasks first, anddenypins them inside it). Author it through a tool-scopeddevinoverride; it is shallow-merged into thesandboxobject 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 documentssandboxas 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 — anetwork_modeset to anything butlimited, and theexcluded.allow/excluded.asklists 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-affectingsandboxpaths, because a permissions file is shareable andrulesync fetchcan copy one into a project.allowed_domainsruns 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: adenied_domainsorexcluded.denythat loses an entry the file had (a swap counts, not just a shorter list), anexcluded.denythat disappears because the override states some otherexcludedkey, or anallowed_domainsthat 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 fromconfig.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
Readtool deny entries in.claude/settings.json. When both features configure theReadtool, the permissions feature takes precedence and a warning is emitted. Migrate the ignore patterns toreaddeny rules in.rulesync/permissions.jsonc, then removeignorefrom the project features and delete the obsolete ignore source.
