File Formats
Symlinks
Rulesync follows symbolic links when it discovers source files, whether you use a plain .rulesync/ directory or a separate --input-root. 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).
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: results are deduplicated by real path, so a cycle does not produce duplicated output. Note that the remote-fetch path (rulesync fetch from a Git repository) is a separate, hardened code path that skips symlinks entirely, so untrusted remote content never has its symlinks followed.
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; 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 codexcli 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`.
subprojectPath: "path/to/subproject"
cursor: # cursor specific parameters
alwaysApply: true
description: "Rulesync project overview and development guidelines for unified AI rules management CLI tool"
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
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
---
# 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; 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. 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.
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.
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.
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. 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. 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.
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.
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).
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.
Junie note: Junie CLI resolves project guidelines first-match-wins —
.junie/AGENTS.md→ rootAGENTS.md→ the legacy.junie/guidelines.md/.junie/guidelines/— and documents no file-inclusion mechanism, so Rulesync writes the root rule to.junie/AGENTS.md(project) /~/.junie/AGENTS.md(global, via--global) and folds non-root rules into that single file. 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.
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. 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.
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.
.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.
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, on_session_start, on_session_end, on_session_finalize, on_session_reset, on_skill_lifecycle, subagent_start, subagent_stop, pre_gateway_dispatch, pre_approval_request, post_approval_response, 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 36 of the 37 VALID_HOOKS entries at v0.20.2: 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 and beforeSubmitPrompt the named chat.message hook (both receive (input, output) and expose nothing to match on, so a matcher on either is dropped), 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; 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_end, 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) — tool_call is Pi's only blocking event and its 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), while 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).
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,kilo.hooks,copilot.hooks,copilotcli.hooks,factorydroid.hooks,codexcli.hooks,goose.hooks,deepagents.hooks,kiro.hooks,qwencode.hooks,grokcli.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.
Hook entry keys:
command(required): Shell command to execute when the event fires.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), and to Hermes Agent's~/.hermes/config.yaml(asfail_closed). Hermes only honours it onpre_tool_call, its one blocking-capable event, so afailClosedset on any other canonical event is dropped with a warning.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 and to Qwen Code prompt hooks.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 and AugmentCode. 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), the only place 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.)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.
A field a tool documents on command hooks only (args, env, shell, async, asyncRewake) 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 | Claude Code | Claude Code plugin | Codex CLI | GitHub Copilot | GitHub Copilot CLI | Goose | Hermes Agent | Grok CLI | Cursor | deepagents-cli | Factory Droid | OpenCode | Cline | Kilo Code | Kimi Code | Vibe Code | Qwen Code | Reasonix | Kiro ⚠️ | Kiro CLI | Kiro IDE | Google Antigravity IDE | Google Antigravity CLI | Google Antigravity plugin | JetBrains Junie | AugmentCode | Devin Desktop | Pi Coding Agent |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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 | — | — | — | — | ✅ | ✅ | — | — | — | — | — | — | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
worktreeCreate | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
worktreeRemove | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
workspaceOpen | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
messageDisplay | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — |
todoCreated | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — |
todoCompleted | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — |
stopFailure | — | ✅ | ✅ | — | — | — | — | — | ✅ | — | — | — | — | — | — | ✅ | — | ✅ | — | — | — | — | — | — | — | ✅ | — | — | — |
stopCancelled | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
instructionsLoaded | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — |
userPromptExpansion | — | ✅ | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — |
postToolBatch | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — |
permissionDenied | — | ✅ | ✅ | — | — | — | — | — | ✅ | — | — | — | ✅ | — | ✅ | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — |
taskCreated | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
taskCompleted | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
teammateIdle | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
configChange | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
cwdChanged | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
fileChanged | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
directoryAdded | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
elicitation | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
elicitationResult | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
sessionDelete | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | — | — | — | — |
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.
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).
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.
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),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.- 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).- 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: 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, andenabled. 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. 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. 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.
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. 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.
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. Nine 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. 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. Cline'sTaskResumeandTaskCancelhave no canonical counterpart and are 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 exactly (it has noSubagentStart/SubagentStop). Thematcherregex is preserved, commands are emitted verbatim (Goose exposesPLUGIN_ROOTas a runtime environment variable), and onlycommand-type hooks are supported. 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 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": ... } ] }. All ten of Reasonix's documented events are mapped —preToolUse⇄PreToolUse,postToolUse⇄PostToolUse,beforeSubmitPrompt⇄UserPromptSubmit,stop⇄Stop,sessionStart⇄SessionStart,sessionEnd⇄SessionEnd,subagentStop⇄SubagentStop,postModelInvocation⇄PostLLMCall,notification⇄Notification, andpreCompact⇄PreCompact.match(Reasonix's matcher field name) is honored only onPreToolUse/PostToolUse; 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). 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 have no canonical equivalents, 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. 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.
.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" # Specific trigger for workflow (renames file to review.md)
turbo: true # (Optional, default: true) Append // turbo for auto-execution
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.
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.
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.
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. 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.
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. 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.
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/subtaskfields become frontmatter. A Markdown file takes precedence over an inline entry with the same name.
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.
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.
rulesync/subagents/*.md
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
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
opencode: # for OpenCode-specific parameters
mode: subagent # (optional, defaults to "subagent") OpenCode agent mode
model: anthropic/claude-sonnet-4-20250514
temperature: 0.1
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
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" }]
---
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.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.
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.
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.
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.
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.
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.
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. A Markdown file takes precedence over an inline entry with the same name.
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.
.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, Cursor, Hermes Agent, 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 gets 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 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 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 with no further gating — 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. 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 after every step. 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.
v1 limitation: Amp also discovers subtree-scoped checks (e.g.
api/.agents/checks/), but rulesync sources carry no directory-placement semantics, so those subtree-scoped checks are not generated. See the Amp manual.
.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, copilot,
# copilotcli, cursor, zed, pi, qwencode, grokcli, and factorydroid. 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.
disable-model-invocation: true
# (optional) shared default for tools that support the flag — claudecode, copilot,
# copilotcli, cursor, qwencode, vibe, grokcli, and factorydroid. 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.
user-invocable: false
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 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: # (optional) free-form compatibility metadata
kilo-version: ">=7.0.0"
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: 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
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)
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
vibe: # for Vibe Code-specific parameters (optional)
user-invocable: false # (optional) hide from slash-command invocation, keep model access
allowed-tools: "Bash Read" # (optional) space-delimited or list of allowed tool names
---
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 `"*"`.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. 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.
.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.
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. 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.
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, Rulesync emits.agents/skills/<name>/agents/openai.yamland reads it back on import. 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 (Reasonix supports additional optional keys, but only that pair is modeled); the schema is loose, so any extra keys on an importedSKILL.mdsurvive the round-trip. 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.
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.
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.
.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.
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); and 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).
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.
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, or one that escapes that directory, is ignored, 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.
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. A skipped server's filters are written to thetoolsmap either way, since that map is keyed by server name and reaches serversmcpdoes not list; on import, atoolsentry naming no listed server comes back as a server carrying nothing but the filters, so it survives the round-trip. 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, itstoolsmap works 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.
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,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 OAuth fields (redirect_uri,redirect_host,redirect_port,client_id,client_secret, andscopes) plusidle_timeout_seconds,max_lifetime_seconds,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.
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.
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). Two 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. 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.
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. For the same reason the approval table is never imported into the canonical model; it stays inconfig.toml, where the approval-preserving merge carries it across regenerates. 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-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 client id (oauth.clientId → client_id)
A server's oauth block is preserved in the canonical Claude Code shape (camelCase clientId), but Codex CLI reads the OAuth client id from snake_case oauth.client_id. Without it, 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 duplicates clientId into a sibling client_id, keeping the camelCase key so tools that expect it keep working:
[mcp_servers.slack.oauth]
clientId = "1601185624273.8899143856786"
client_id = "1601185624273.8899143856786"
callbackPort = 3118Only a string clientId is duplicated (a non-string value would not be a usable OAuth client id), and an explicit client_id already present in the source is left untouched. On import, client_id collapses back to the canonical clientId (and is dropped when both are present) so the round-trip stays stable.
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).
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; 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 (mcpConfigPath,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. 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. 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. Reasonix has no per-server tool allow/deny lists. 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) andtool_timeout_seconds(a per-tool inline table keyed by raw MCP tool name). All three round-trip as passthrough fields on the canonical MCP server object rather than through a deep mapping. For the latter two there is no canonical counterpart at all;startup_timeout_secondsdoes have 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).
.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.
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 reasonix and zed (whose config files exist in both scopes), as well as devin; the remaining ignore targets are project-only.
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.
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. |
.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, so hand edits replace stale generated values. 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, 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"
}
}
}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)).
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.
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|bypassPermissions) andadditionalDirectories(extra working directories). 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 } } } }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. 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.sigv4,allowAppleEventsandripgrep— 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.
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.
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:
allowpatterns (all categories) →command_allowlist.bashdenypatterns →approvals.deny— Hermes's hard denylist, evaluated before--yolo/approvals.mode: off.webfetchdenypatterns →security.website_blocklist.domains.- Every
askrule, anddenyrules in categories other thanbash/webfetch, have no native per-pattern Hermes primitive; they 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)read:allow→read,ask/deny→denyinpermissions.<profile>.filesystemedit/write:allow→write,ask/deny→denyinpermissions.<profile>.filesystemwebfetch: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. Rulesync also sets glob_scan_max_depth = 8 when generated workspace-root rules contain unbounded ** patterns.
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 three 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). 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. 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 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.
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(untrusted|on-request(legacy aliason-failure) |never, or a{ granular = { … } }table kept verbatim; defaults toon-requestwhen neither the override nor the existing config sets it),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), 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 a denied prefix wins over an allowed one — so a pattern present in both lists imports as deny. A list that would be empty is retracted rather than written as [], which means the same thing to Zoo Code and leaves no residue behind. 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.*) and Roo Code is EOL, so the roo target deliberately does not emit these keys. See the Zoo Code settings contributions.
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). 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.
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 AugmentCode CLI, this generates toolPermissions entries in .augment/settings.json (project mode) or ~/.augment/settings.json (global mode). 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. 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; existing deny entries for ANY managed toolName (launch-process, view, str-replace-editor, save-file, web-fetch, web-search) 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. 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. An unknown top-level key such as recommendedMarketplaces (added in Auggie CLI 0.20.0) is preserved verbatim through the generate round-trip via the {...settings} merge.
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.
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 only the rulesync bash category 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 are dropped. Categories other than bash cannot be represented in the command allow/deny model and are skipped, with a logger.warn when a skipped category carries a deny rule (to surface the gap). 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.
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,interactionMode), 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, anddisabledSkills(an array of skill names to disable without deleting their files). 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. Non-bash categories are dropped and rulesync ask rules for bash are translated to deny (fail-closed safety, since Cline lacks ask semantics); both 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 ends up in both allow and deny (defensive check; not reachable from a single rulesync config), Rulesync emits a warning because Cline does not document a deterministic deny-priority.
For Zed, this generates the agent.tool_permissions object in .zed/settings.json (project mode) or ~/.config/zed/settings.json (global mode — %APPDATA%\Zed\settings.json on Windows). 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): two Zed surfaces 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). Example:{ "permission": { … }, "zed": { "sandbox_permissions": { "network_hosts": ["*.github.com"], "write_paths": ["/tmp"] }, "profiles": { "review": { "name": "Review", "tools": { "terminal": false } } } } }. Both blocks 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, both are lifted back into thezedoverride so a hand-written sandbox policy or profile set 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 regenerated into the project file and shipped to everyone. The same wholesale replace works the other way too — regenerating from an authored override discards approvals Zed had recorded since. Thezedblock authors these two 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. Both scopes are written, like the siblingagent.tool_permissions: Zed layers user settings under project settings, and a project's.zed/settings.jsonis applied once the worktree is trusted. That trust prompt is the thing to watch when you clone a repository — a project-scopedallow_unsandboxedorallow_all_hostsis a real grant, not an inert one, so review a.zed/settings.jsonyou did not write before trusting the worktree. 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 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),security(folderTrust, plus 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), 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},security.{folderTrust,allowedHttpHookUrls,allowPrivateNetworkHooks}, andpermissions.autoMode) round-trip back into the override; othertools/securitykeys are left insettings.jsonand not extracted. One scope caveat applies: Qwen Code honorssecurity.allowPrivateNetworkHooksonly in user/system settings and deliberately ignores a workspace value, so that a cloned repository cannot grant itself private-network access. Rulesync therefore skips that key with a warning when generating project-scopedsettings.json(a value already written into the project file by hand is left untouched), and emits it only in global mode. Import lifts it in either scope, because the file being read carries no scope marker — so if you import a project.qwen/settings.jsonthat came from a repository you cloned, review the key before regenerating with--global, since that promotes an inert workspace value into one Qwen Code actually enforces.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 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. Only 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 are dropped, and non-bash categories are skipped (with a warning when they carry deny 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. 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 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). 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). 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. 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. 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, 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 → developer__shell, edit → developer__text_editor; every other category passes through verbatim as the Goose tool name (so namespaced tools like developer__text_editor or developer__image_processor round-trip). 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. write collapses onto developer__text_editor too, so a conflicting edit/write catch-all cannot be represented; edit takes precedence and a warning is logged. 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. 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, webfetch→WebFetch, websearch→WebSearch, 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), and categories with no Grok tool (glob, notebookedit, agent) are skipped, with a warning when a skipped category 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) 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 entries that were preserved verbatim as unmanaged; they are now parsed into the canonical websearch category and regenerated as Rulesync-owned entries. 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; 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 (still honored as a fallback on import). 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. 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. 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, or a targets map with at least one non-empty entry. A file holding only version: 1, or empty maps such as defaults: {}, is inactive and leaves the legacy resolution in place. 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 is a union in which a project profile replaces the global profile 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. 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 documented legacy / backwards-compatibility surface — 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.
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" }] } }. See the Amp manual.
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. 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 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; upstream additionally offers the blanket[permissions] allow_dynamic_bashopt-in (added in v1.19.0, which lets an Allow fallback cover that whole class) and YOLO, but authoring either through rulesync is not supported today. 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"] } } }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.
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.
