Skip to content

File Formats

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:

md
---
root: true # true for root-level rules, false for details such as `.agents/memories/*.md`
localRoot: false # (optional, default: false) true for project-specific local rules. Claude Code: CLAUDE.local.md; Rovodev (Rovo Dev CLI), Roo Code, Zoo Code and Devin: AGENTS.local.md; 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 import also reads that file back as a localRoot: true rule under .rulesync/rules/, keeping the tool-side basename. The imported rule's targets is scoped to the tool it was imported from, not "*" — a wildcard would spread the personal content into other tools' committed root files on the next generate (tools without a separate local file append localRoot bodies to their root file), and importing from several tools would otherwise produce conflicting wildcard localRoot rules. Widen targets by hand if you do want the content shared. The same scoping applies to rulesync convert: converting to a different tool drops the source tool's personal local file rather than folding it into the destination's root file. The derived .gitignore covers the imported copy via .rulesync/rules/*.local.md; run rulesync gitignore after a first import if the project's .gitignore has not been generated yet, so the personal content stays untracked. Project scope only, like localRoot generation itself.

AGENTS.md standard note (agentsmd): Nested AGENTS.md files are the standard's only scoping mechanism — agents read the nearest file in the directory tree, so the closest one wins. Rulesync writes them from agentsmd.subprojectPath and, on import, discovers them by scanning the project for **/AGENTS.md. Hidden directories (other tools' generated output, including rulesync's own) are skipped at any depth, as are node_modules/ and __pycache__/. Build, vendoring and scratch directories (vendor/, third_party/, dist/, build/, out/, target/, coverage/, tmp/, temp/, venv/) are skipped at the project root only, because a top-level build/ is a build directory while packages/build/ is a real subproject. Beyond those names, the scan honors your .gitignore: a file git does not track is not your project's source, and copying a vendored dependency's rule file into version-controlled .rulesync/rules/ would hand third-party instructions to every tool — including the ones that concatenate non-root rules into a single always-loaded file. (Ignore rules come from the .gitignore files at and below the output root — a parent repository's rules are not consulted, so running against a subdirectory only sees that subdirectory's own. The test is applied to the directories above each file, not the file itself, so the **/AGENTS.md entry that rulesync gitignore writes for its own output does not disable the scan; the flip side is that ignoring one individual AGENTS.md no longer keeps it out of the import.) Symbolic links are not followed, so a link committed to a repository cannot pull a file from outside the project into version-controlled .rulesync/. The scan is import-only: a nested file rulesync did not write is never removed by --delete. That means deleting the rulesync rule stops the reference from being listed in the root AGENTS.md, but leaves the subproject file itself on disk — where agents still read it, since the nearest file wins. Remove it by hand. Each discovered file is imported to .rulesync/rules/<directory-with-hyphens>.md (e.g. packages/api/AGENTS.mdpackages-api.md) carrying agentsmd.subprojectPath, so the next generate puts it back where it came from. A subproject that would claim the reserved overview.md name gets an -agents suffix instead, so the root rule is never overwritten; any other pair of sources deriving the same name is reported at import time, since only the last one survives. Import always rewrites .rulesync/rules/, so a subproject whose derived name matches a rule file you wrote by hand replaces it — pick distinct names, or keep hand-written rules out of the derived namespace. See https://agents.md/.

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

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

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

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

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

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

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

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

Warp note (rules): Warp reads project rules from the root AGENTS.md (or the back-compat WARP.md) and does not scan a modular rules directory, so non-root rule bodies are folded into the single root ./AGENTS.md. 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-root AGENTS.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 a pi.systemPrompt: append frontmatter block — those rule bodies are routed to APPEND_SYSTEM.md instead of AGENTS.md, multiple opted-in rules concatenate in source order, and the file is managed by generate/import/delete like the root file (note: if you hand-authored .pi/APPEND_SYSTEM.md before this feature existed, generate --delete for the pi target now treats it as a managed path and removes it unless a rule opts in — import it first to convert it into a canonical rule). The opt-in is ignored on the root: true rule, which always stays on AGENTS.md (routing the root away would leave the context file without a merge target). .pi/SYSTEM.md (project) / ~/.pi/agent/SYSTEM.md (global) replaces the default system prompt entirely — which silently disables Pi's built-in tool instructions — so Rulesync deliberately never emits it and leaves it to be authored by hand. Example:

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

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

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

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

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

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

Junie note: Junie CLI resolves project guidelines first-match-wins.junie/AGENTS.md → root AGENTS.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.md is still accepted as an import fallback. Earlier Rulesync versions emitted non-root rules to .junie/memories/*.md, which is not a documented Junie read path; those files are no longer generated (stale outputs stay gitignored but are not cleaned up automatically). See the Junie guidelines docs.

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

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

.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 — sessionStartsession.created, stopsession.idle, afterFileEditfile.edited, permissionRequestpermission.asked, permissionDeniedpermission.replied (which fires for every reply, so the generated handler is gated on event.properties.reply === "reject"), notificationtui.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), postCompactsession.compacted, afterErrorsession.error, fileChangedfile.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 (sessionStartsession_start, stopagent_end, preToolUsetool_call with the matcher tested as a regex against the tool name, preCompactsession_before_compact, postCompactsession_compact, postModelInvocationmessage_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. beforeSubmitPromptuserPromptSubmitted, stopagentStop, afterErrorerrorOccurred) 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:

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

Top-level keys:

  • version: Schema version (currently 1).
  • hooks: Map of canonical event names to an array of hook entries. These are dispatched to every tool that supports the given event.
  • amp.hooks, cursor.hooks, claudecode.hooks, opencode.hooks, 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. afterFileEdit for Cursor/OpenCode/Kilo, worktreeCreate for Claude Code, afterError for Copilot/Copilot CLI, PostFileSave/PreTaskExec for Kiro) can coexist with shared ones without leaking to other tools. copilotcli.hooks falls back to copilot.hooks, which in turn falls back to the shared hooks block.

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 only command); hooks with a type a tool does not support are skipped for that tool with a warning. See notes below.
  • matcher (optional): Regex used by tools that scope hooks to specific tool names (e.g. preToolUse, postToolUse, notification). Ignored by events that do not take a matcher (e.g. sessionStart, worktreeCreate, worktreeRemove).
  • timeout (optional): Per-hook timeout in seconds, forwarded to tools that support it.
  • cacheTtl (optional): Number of seconds to cache a successful hook result. Forwarded to the deprecated kiro alias's agent-config format as cache_ttl_seconds; 0 disables caching and Kiro never caches AgentSpawn hooks.
  • failClosed (optional): Boolean. When true, a hook failure (crash, timeout, invalid JSON) blocks the action instead of allowing it through. Passed through to Cursor's .cursor/hooks.json, to JetBrains Junie's ~/.junie/config.json (as Junie's equivalently-named blockOnError flag), and to Hermes Agent's ~/.hermes/config.yaml (as fail_closed). Hermes only honours it on pre_tool_call, its one blocking-capable event, so a failClosed set on any other canonical event is dropped with a warning.
  • commandRegex (optional): Regex applied to the shell command string, narrowing an Execute matcher group further (e.g. "^git "). Forwarded to Factory Droid, which skips invalid regex values. Like matcher, it belongs to the whole matcher group, so every hook sharing that matcher receives it.
  • async (optional): Boolean. When true, the hook command runs in the background without blocking. Forwarded to Qwen Code (.qwen/settings.json), JetBrains Junie (~/.junie/config.json, same field name), Claude Code and Codex CLI (.codex/hooks.json, same field name — Codex runs up to eight background hooks concurrently per session and queues the rest).
  • env (optional, command hooks): a map of extra environment variables merged into the hook process's environment. Forwarded to Qwen Code (.qwen/settings.json), Copilot CLI, and Grok CLI (.grok/hooks/rulesync.json, upstream HookConfig.env, merged into the spawned command's extra_env). Documented on command hooks only, so it is neither emitted on a hook of another type nor imported from one (a value found there is dropped with a warning). For Grok CLI, an entry whose key is empty or contains =, or whose key or value contains a newline, carriage return or NUL, is refused in both directions — the tool rebuilds each entry into a KEY=VALUE string, so such a key would name a different variable than it appears to.
  • shell (optional): Either "bash" or "powershell" — the only two interpreter values any tool accepts. Forwarded to Qwen Code, Claude Code, Copilot and Copilot CLI command hooks; for the two Copilot targets it names the bash/powershell field the command is written into, and leaving it unset selects their portable command field. Like args, async and asyncRewake, it is documented on command hooks only, so it is neither emitted on a hook of another type nor imported from one (a value found there is dropped with a warning).
  • url / headers / allowedEnvVars (optional, http hooks): the POST target URL, request headers (values support $VAR interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks.
  • server / tool / input (optional, mcp_tool hooks): the configured MCP server name, the tool to call on it, and the (arbitrary JSON) arguments, whose string values support ${path} substitution from the hook input. Forwarded to Claude Code mcp_tool hooks.
  • model (optional, prompt / agent hooks): the model used for evaluation (defaults to a fast model). Forwarded to Claude Code prompt/agent hooks and to Qwen Code prompt hooks.
  • args (optional, command hooks): an argument list. When present — an empty list counts, and is the form the Claude Code docs use — the tool spawns command directly as an executable with these arguments. There is no shell, so Rulesync writes the project-directory prefix as the braced placeholder ${CLAUDE_PROJECT_DIR}/… that Claude Code substitutes itself, rather than the quoted shell form. Forwarded to Claude Code and AugmentCode. Only command is prefixed; entries of args are passed through exactly as written.
  • asyncRewake (optional): boolean. Like async, but wakes Claude when the hook exits with code 2. Forwarded to Claude Code command hooks.
  • once (optional): boolean. Run the hook once per session, then remove it. Forwarded to Claude Code (honored in skill frontmatter; accepted but ignored in settings files) and Qwen Code http hooks.
  • continueOnBlock (optional): boolean. Feed a blocking hook's rejection reason back to the model and continue the turn instead of ending it. Forwarded to Claude Code.
  • commandWindows (optional): a Windows-only override for command, so one hook set can be cross-platform. Forwarded to Codex CLI command hooks (.codex/hooks.json), which is the only tool that accepts it.
  • additionalContextLimit (optional): a non-negative integer. The token threshold above which the tool writes the hook's additional context to a file and passes that path instead of the text itself (upstream default 2500). Forwarded to Codex CLI command hooks (.codex/hooks.json), which is the only tool that accepts it.
  • statusMessage (optional): the progress text shown while the hook runs. Forwarded to Qwen Code (command and http hooks) and to Codex CLI command hooks.
  • enabled (optional): boolean, default true. Whether the hook is active. Forwarded to Kiro's standalone hooks file (.kiro/hooks/rulesync.json, written by the kiro-cli and kiro-ide targets), the only place with a per-hook on-disk enable flag Rulesync writes; an imported enabled: false round-trips, so a deliberately disabled hook is not silently switched back on by the next generate. Every other target has no way to express it, so the hook is emitted there as an ordinary active hook and a warning is logged at generate time — to turn a hook off everywhere, remove it rather than setting enabled: false. (Antigravity has an enabled flag of its own, but on the named hook group rather than the individual definition, so it is not driven by this field.)
  • if (optional): a single permission rule (same syntax as settings.json permission rules, e.g. "Bash(rm *)") that filters a hook by tool arguments in addition to the tool name. Forwarded to Claude Code, where it is evaluated only on tool events (preToolUse, postToolUse, postToolUseFailure, permissionRequest, permissionDenied); it round-trips as an opaque string.

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

EventAmpClaude CodeClaude Code pluginCodex CLIGitHub CopilotGitHub Copilot CLIGooseHermes AgentGrok CLICursordeepagents-cliFactory DroidOpenCodeClineKilo CodeKimi CodeVibe CodeQwen CodeReasonixKiro ⚠️Kiro CLIKiro IDEGoogle Antigravity IDEGoogle Antigravity CLIGoogle Antigravity pluginJetBrains JunieAugmentCodeDevin DesktopPi 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, and cwdChanged are the Claude Code events the matcher table lists as not supporting the matcher field (they fire on every occurrence). A matcher authored on one of them is dropped with a warning rather than written into settings.json to be ignored. directoryAdded is not one of them: the matcher table documents it as filtering on how the directory was added (slash_command, register_repo_root), so a matcher written on it is emitted as-is.

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

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

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

Note: GitHub Copilot's format uses separate powershell and bash fields for hooks, plus a portable command field that upstream copies into both when neither is present. Rulesync picks between them with the canonical shell selector, and writes the portable command field when a hook does not set one. Earlier versions chose the field from the platform Rulesync happened to run on; regenerate to get a machine-independent file.

Note: Hook file paths per tool:

  • Copilot (cloud agent / VS Code) — project: <project>/.github/hooks/copilot-hooks.json; global: ~/.copilot/hooks/copilot-ide-hooks.json. Command hooks carry bash/powershell with optional timeoutSec, plus the canonical env map and a pass-through cwd. On import, timeout is honored as an alias for timeoutSec when timeoutSec is absent. Which command field is written is chosen by the canonical shell selector; without it the portable command field is written, which upstream copies to both. It is deliberately not chosen from the platform Rulesync runs on: the cloud agent runs hooks in a Linux sandbox where only bash and command are honored, so a powershell entry generated on a Windows machine would simply never run. It also keeps the output identical everywhere, which matters because the cloud agent reads this file from the repository. For the same reason, an imported entry carrying both fields resolves to bash (with a warning) on every platform. VS Code and the coding agent both document ~/.copilot/hooks as the user scope and load every *.json in that folder; the Copilot CLI's global file already occupies copilot-hooks.json there, so the VS Code target uses a distinct filename and the two never overwrite each other. Note the flip side of "every *.json is loaded": generating both copilot and copilotcli in global mode leaves two files in that one folder, and a reader of the folder runs the hooks from both — so a command present in your canonical config fires twice per event. Generate only one of the two globally unless you want that.
  • Copilot CLI — project: <project>/.github/hooks/copilotcli-hooks.json; global: ~/.copilot/hooks/copilot-hooks.json. The Copilot CLI docs let you choose any filename inside .github/hooks/, so Rulesync uses the CLI-specific name to avoid colliding with the cloud-agent file when both targets are enabled. The global path is a Rulesync convention; the official Copilot CLI documentation does not currently enumerate a global hooks location, so this placement may change if the spec later mandates an alternate layout. Copilot CLI uses a wider event surface than the shared cloud-agent set (sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse, postToolUseFailure, agentStopstop, subagentStart, subagentStop, errorOccurredafterError, preCompact, permissionRequest, notification, userPromptTransformeduserPromptExpansion, preMcpToolCallbeforeMCPExecution) and supports three hook types: command (bash/powershell with optional timeoutSec, plus pass-through cwd/env; on import the portable command field is read as the cross-platform fallback when neither shell field is present, and timeout is honored as an alias for timeoutSec when timeoutSec is absent. On generate the canonical shell selector chooses bash or powershell; without it the portable command field is written, so the generated file does not depend on the machine Rulesync ran on. An imported entry carrying both shell fields resolves to bash (with a warning) on every platform, so importing the same file yields the same canonical config everywhere), prompt (a prompt string — Copilot CLI only honors prompt hooks on sessionStart, so prompt hooks on other events are dropped), and http (url/headers/allowedEnvVars with optional timeoutSec). An entry's optional matcher field is emitted and round-tripped on the six events the hooks reference documents as matcher-aware — preToolUse and postToolUse (regex on the tool name), permissionRequest (tool name), notification (notification type), preCompact (the trigger, manual or auto) and subagentStart (agent name); on any other event a matcher is dropped with a warning because the CLI does not honor it there. See the hooks reference.
  • Antigravity IDE / Antigravity CLI — project: <project>/.agents/hooks.json; global: ~/.gemini/config/hooks.json. Both targets share the same dedicated hooks.json (a Claude-Code-style matcher map nested under a generated rulesync hook name), so enabling both writes the same file.
  • Devin Desktop (formerly Windsurf) — project: <project>/.windsurf/hooks.json; global: ~/.codeium/windsurf/hooks.json. The Cascade Hooks file location is unchanged by the Devin Desktop rebrand.
  • Factory Droid — project: <project>/.factory/hooks.json; global: ~/.factory/hooks.json. A standalone hooks.json is keyed directly by event name ({"PreToolUse": [...]}); the hooks wrapper Droid documents belongs to settings.json only, so Rulesync writes the bare event map. The file is Rulesync-owned and rewritten wholesale, which also repairs a hooks.json an earlier version left in the wrapped shape — Droid found no known event key at the top level of that file, so none of those hooks ever fired; regenerate to fix. Import accepts both shapes: the top level when it names an event, and the hooks key otherwise, which is also how the legacy .factory/settings.json read-time fallback is understood.
  • AugmentCode — project: <project>/.augment/settings.json; global: ~/.augment/settings.json. Hooks are merged under the top-level hooks key of the shared settings file (which also holds toolPermissions).
  • Kimi Code — global only: ~/.kimi-code/config.toml. Hooks are merged into the shared [[hooks]] array without replacing unrelated model, provider, or permission settings.
  • Vibe Code — project: <project>/.vibe/hooks.toml; global: ~/.vibe/hooks.toml. Stable since v2.21.0, which removed the enable_experimental_hooks flag: declaring a hook is enough, so Rulesync writes nothing into .vibe/config.toml for hooks.

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

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

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

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

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

Note: Devin Desktop (formerly Windsurf) Cascade Hooks (GA) are written to a dedicated hooks.json whose top-level hooks key maps each Cascade event name to a flat array of hook objects (no matcher, no type, no inner hooks wrapper, and no timeout). Each object carries command and/or powershell, plus optional show_output and working_directory. Rulesync splits the generic tool lifecycle into Devin's file/command/MCP-specific events, so the canonical events map bijectively: beforeReadFilepre_read_code, beforeTabFileReadpost_read_code, afterTabFileEditpre_write_code, afterFileEditpost_write_code, beforeShellExecutionpre_run_command, afterShellExecutionpost_run_command, beforeMCPExecutionpre_mcp_tool_use, afterMCPExecutionpost_mcp_tool_use, beforeSubmitPromptpre_user_prompt, afterAgentResponsepost_cascade_response, beforeAgentResponsepost_cascade_response_with_transcript, and worktreeCreatepost_setup_worktree. Canonical events with no Devin equivalent (e.g. sessionStart, stop) are dropped with a logged warning. The Cascade Hooks file location (.windsurf/hooks.json / ~/.codeium/windsurf/hooks.json) is retained from the Windsurf era and is unaffected by the rebrand.

Note: AugmentCode (Auggie CLI) hooks are merged under the top-level hooks key of the shared .augment/settings.json (project) / ~/.augment/settings.json (global), mirroring Claude Code's per-event matcher arrays ({ "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }). The hooks block is merged in place so it coexists with the toolPermissions block from the permissions feature. Seven lifecycle events are supported — preToolUsePreToolUse, postToolUsePostToolUse, sessionStartSessionStart, sessionEndSessionEnd, stopStop, notificationNotification, and beforeSubmitPromptPromptSubmit (added in Auggie 0.27.0). The matcher field (a case-sensitive regex, default .*, with mcp:* support) applies only to the tool events PreToolUse/PostToolUse; any matcher on the session events (including Notification and PromptSubmit) is dropped with a logged warning. Two Auggie-specific fields round-trip as well: a command hook's args (extra argv the runner appends, authored as args on the canonical hook) and the matcher group's metadata (includeConversationData / includeMCPMetadata / includeUserContext, which select what the runner puts in the JSON payload the script receives). metadata belongs to the group upstream, so it is authored on any hook of the group and re-applied to every hook of that group on import. Both matter because the hooks key is owned in the shared settings file: a value not written here is erased from a hand-written settings.json on the next generate. Commands are emitted verbatim — Auggie exposes AUGMENT_PROJECT_DIR as a runtime environment variable, not as an inline command substitution, so no directory prefix is added. Only command-type hooks are supported. On import (project scope), Rulesync also reads the layered overrides file <workspace>/.augment/settings.local.json — a gitignored, machine-specific file that Auggie merges on top of settings.json — and combines it over the base settings before importing, following Auggie's documented layering (simple values take the local override, mcpServers/plugins replace wholesale, and other objects/lists — including the hooks events — are combined across tiers), so personal hook overrides are picked up without dropping base events. This overlay is import-only and project-only: Rulesync never writes settings.local.json, AugmentCode documents no global ~/.augment/settings.local.json, so the overlay is skipped in global mode.

Note: Vibe Code (mistral-vibe) hooks are written to a dedicated .vibe/hooks.toml (project) / ~/.vibe/hooks.toml (global) as a flat [[hooks]] TOML array. Each entry carries its own event type, a command, and optional name, timeout (seconds, default 60), and description. Tool-hook entries (pre_tool / post_tool) additionally carry a tool-name match (an fnmatch glob like bash/mcp_* or a re:-prefixed regex, case-insensitive — the canonical matcher field; * means "any tool") and an optional strict flag; post_agent carries neither. Three events are supported — preToolUsepre_tool, postToolUsepost_tool, and stoppost_agent (fires after every assistant turn that ends without pending tool calls). Only command-type hooks are emitted. Vibe v2.21.0 graduated hooks from experimental: it renamed all three types (before_toolpre_tool, after_toolpost_tool, post_agent_turnpost_agent) and removed the enable_experimental_hooks flag, so declaring a hook is enough and Rulesync no longer writes an auxiliary .vibe/config.toml. HookType is a strict enum upstream, so an entry using an old name is rejected outright.

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

Note: Goose hooks follow the Open Plugins spec: Rulesync writes a plugin directory hooks/hooks.json that Goose auto-discovers at startup. Locations are <project>/.agents/plugins/rulesync/hooks/hooks.json (project) and ~/.agents/plugins/rulesync/hooks/hooks.json (global). The JSON shape matches Claude Code's ({ "hooks": { "EventName": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "..." } ] } ] } }). Eleven lifecycle events are supported — sessionStartSessionStart, sessionEndSessionEnd, stopStop, beforeSubmitPromptUserPromptSubmit, preToolUsePreToolUse, postToolUsePostToolUse, postToolUseFailurePostToolUseFailure, beforeReadFileBeforeReadFile, afterFileEditAfterFileEdit, beforeShellExecutionBeforeShellExecution, and afterShellExecutionAfterShellExecution — matching Goose's HookEvent enum exactly (it has no SubagentStart/SubagentStop). The matcher regex is preserved, commands are emitted verbatim (Goose exposes PLUGIN_ROOT as a runtime environment variable), and only command-type hooks are supported. One exception applies to the matcher: Goose compiles it with Regex::new and silently drops the whole rule when compilation fails, and the canonical catch-all "*" is not a valid regex, so it is emitted as no matcher (which Goose treats as match-all) instead of verbatim.

Note: Qwen Code hooks are written under the top-level hooks key of .qwen/settings.json (project) / ~/.qwen/settings.json (global), using Claude-style PascalCase per-matcher arrays ({ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }). Qwen's supported event set differs from Gemini CLI's, so rulesync defines a Qwen-specific mapping. Twenty-two lifecycle events are supported — sessionStartSessionStart, sessionEndSessionEnd, preToolUsePreToolUse, postToolUsePostToolUse, postToolUseFailurePostToolUseFailure, postToolBatchPostToolBatch, beforeSubmitPromptUserPromptSubmit, userPromptExpansionUserPromptExpansion, stopStop, stopFailureStopFailure, subagentStartSubagentStart, subagentStopSubagentStop, preCompactPreCompact, postCompactPostCompact, permissionRequestPermissionRequest, permissionDeniedPermissionDenied, notificationNotification, instructionsLoadedInstructionsLoaded, todoCreatedTodoCreated, todoCompletedTodoCompleted, messageDisplayMessageDisplay (fires repeatedly as the reply streams; added in Qwen Code v0.19.10), and sessionDeleteSessionDelete (fires after an explicitly selected session is deleted, via the interactive /delete command or the ACP deleteSession request; matcher-less, added in Qwen Code v0.21.3). Commands are emitted verbatim (no $GEMINI_PROJECT_DIR rewriting). Qwen's four hook types are supported: command, prompt (which carries the required prompt body — with $ARGUMENTS interpolation — and an optional model override, both round-tripped; a prompt hook without a prompt is warned about at generate time since Qwen Code loads it and fails it at runtime), http (which carries a url and POSTs JSON to it; the type and URL round-trip), and function. Per-hook fields added in Qwen Code PR #2827 round-trip as well: command hooks carry async (run in the background), env (extra subprocess environment variables), and shell (bash/powershell); http hooks carry headers (with ${VAR} interpolation), allowedEnvVars (the env-var allowlist), and once (single execution per event per session); statusMessage (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level sequential flag (parallel by default) and the top-level disableAllHooks switch are both round-tripped, and other top-level keys in settings.json are preserved. See the Qwen Code hooks docs.

Note: Reasonix hooks are written to a dedicated .reasonix/settings.json (project) / ~/.reasonix/settings.json (global) — a Claude-Code-style but standalone JSON file, separate from the [permissions]/[[plugins]] TOML config. Unlike Claude Code, each event key maps directly to a flat array of hook objects (no matcher/hooks wrapper): { "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ... } ] }. All ten of Reasonix's documented events are mapped — preToolUsePreToolUse, postToolUsePostToolUse, beforeSubmitPromptUserPromptSubmit, stopStop, sessionStartSessionStart, sessionEndSessionEnd, subagentStopSubagentStop, postModelInvocationPostLLMCall, notificationNotification, and preCompactPreCompact. match (Reasonix's matcher field name) is honored only on PreToolUse/PostToolUse; a matcher on any other event is dropped with a warning. The canonical timeout field is documented in seconds, while Reasonix's timeout is milliseconds, so rulesync converts (× 1000 on generate, ÷ 1000 on import). Only command-type hooks are supported. The settings.json file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the Reasonix Hooks guide.

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

Note: Kimi Code hooks are global-only and written as flat [[hooks]] entries in ~/.kimi-code/config.toml, with event, command, and optional matcher/timeout. Rulesync maps fourteen canonical lifecycle events to Kimi's PascalCase names: sessionStart, sessionEnd, beforeSubmitPrompt, preToolUse, postToolUse, postToolUseFailure, permissionRequest, stop, stopFailure, notification, subagentStart, subagentStop, preCompact, and postCompact. Kimi's native PermissionResult, Interrupt, TurnStarted, UserPromptQueued, TaskStarted, and SessionHeartbeat events have no canonical equivalents, but they can be written and preserved through the kimi-code.hooks override under their native names. (TaskStarted is deliberately not folded into the canonical taskCreated: it fires when a background task starts and matches on task kind, while taskCreated models Claude Code's blocking, matcher-less TaskCreated fired during task creation.) Only command hooks are emitted. A matcher is dropped with a warning on Stop, SessionHeartbeat, and Interrupt, the three events whose Event Reference row documents the matcher as an empty string. Kimi treats matcher as a regular expression tested against the event target, so on these events it is tested against "" and any non-trivial matcher never matches — such a hook silently never ran. Dropping the matcher is therefore a behavior change for existing configs: the hook now fires, which is what authoring it meant. Every other event matches a real value (UserPromptSubmit the submitted prompt text, PermissionRequest and PermissionResult the tool name, PreCompact the trigger, and so on), so matchers there are kept. Kimi normally runs these user-level hooks with each current session project as the working directory, which would let an unrelated repository substitute a relative script or influence commands such as npm test. Rulesync therefore wraps every generated command so it first changes to the trusted absolute directory containing the source .rulesync/hooks.jsonc; relative paths and project-aware commands consistently resolve against that source rather than whichever repository Kimi later opens. Kimi requires timeout to be an integer from 1 to 600 seconds; invalid canonical values are omitted with a warning so Kimi can still load the config. The shared TOML file is merged in place and never deleted. See the Kimi Code hooks docs.

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

Example:

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

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

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

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

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

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

rulesync/commands/*.md

Example:

md
---
description: "Review a pull request" # command description
targets: ["*"] # * = all, or specific tools
copilot: # copilot specific parameters (optional)
  description: "Review a pull request"
  agent: "agent" # (optional) VS Code prompt-file agent: "ask", "agent", "plan", or a custom agent name (replaces the deprecated "mode")
antigravity: # antigravity specific parameters
  trigger: "/review" # 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 codexcli commands still generate the global-only ~/.codex/prompts/*.md custom-prompt files described above — they remain functional and no removal date has been announced, so this behavior is unchanged for now. For new reusable instructions, prefer rulesync's codexcli skills support (see .rulesync/skills/*/SKILL.md below) instead.

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

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

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

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

Junie skills note: Junie treats description as optional in a skill's SKILL.md — "If description is not provided in the frontmatter, Junie CLI extracts the first paragraph of the body content as the description." Rulesync's canonical frontmatter requires one, so on import a missing description is filled in the same way, from the first body paragraph (wrapped lines joined into a single line, since it becomes a YAML scalar); a SKILL.md Junie loads fine therefore no longer aborts the import. Markdown headings are skipped, matching upstream's "If the body is also empty or contains only headings, the skill will fail to load" — so a body opening with # Skill Name yields the prose beneath it, not the title. That matters beyond the import: an imported description is written back out explicitly on the next generate, so importing the title would replace Junie's own correct fallback with it, for every tool. A fenced code block is not special-cased — it is ordinary content, so a body whose first non-heading paragraph is a fence yields the fence text, which is a good reason to author a description explicitly. When nothing remains (an empty or headings-only body), Junie could not load the skill either, so Rulesync skips that one skill with a warning and keeps importing the rest. Generation always writes an explicit description, which Junie's own docs recommend. See the agent skills docs.

Vibe skills note: Vibe discovers skills under .vibe/skills/ (project) and ~/.vibe/skills/ (global), plus the shared .agents/skills/ root at both scopes — Vibe's user_skills_dirs returns ~/.vibe/skills and ~/.agents/skills alike. Rulesync registers the shared root as an import fallback at either scope; it is import-only and is never removed by Vibe-target orphan deletion.

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

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

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

Qwen Code note: Custom commands are emitted as Markdown files (not TOML — TOML is deprecated upstream) under .qwen/commands/ (project) and ~/.qwen/commands/ (global, via --global). The file is an optional YAML frontmatter block followed by the prompt body; besides description, Qwen Code's command loader reads when_to_use (invocation guidance), argument-hint (completion hint), and disable-model-invocation, all typed and round-tripped. Subdirectory namespacing is supported: .qwen/commands/git/commit.md becomes the /git:commit command. Any extra fields are preserved on round-trip under the qwencode: block.

OpenCode import note: OpenCode lets commands live both as Markdown files under .opencode/commands/*.md and inline in opencode.json/opencode.jsonc under the top-level command key. On import, rulesync reads both: each inline entry's template becomes the command body and its description/agent/model/subtask fields 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.md is /git:commit — so nested rulesync commands keep their nesting rather than being flattened to a basename. If you generated AugmentCode commands with an earlier Rulesync, the flattened files it wrote are still on disk under their old names; --delete removes them. Auggie also discovers commands under the cross-tool .agents/commands/ root, so import reads that root too and treats a command found there as if it lived under .augment/commands/ — the command's name is its path under whichever root it came from. Generation stays on .augment/commands/, and .agents/commands/ is never written to or swept for orphans, since the files there may belong to another tool — Rulesync itself writes that root for the agentsmd target, so a command already imported from .augment/commands/ is not imported again from there under a flattened name. Auggie's other shared root, .claude/commands/, is deliberately not read: it is Claude Code's own output, which Rulesync already imports as that target. Importing from a shared root is announced, because the result is a Rulesync command written for every target on the next generate. See the custom commands docs.

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

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

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

rulesync/subagents/*.md

Example:

md
---
name: planner # subagent name
targets: ["*"] # * = all, or specific tools
description: >- # subagent description
  This is the general-purpose planner. The user asks the agent to plan to
  suggest a specification, implement a new feature, refactor the codebase, or
  fix a bug. This agent can be called by the user explicitly only.
claudecode: # for claudecode-specific parameters
  model: inherit # opus, sonnet, haiku, fable, a full model id, or inherit (default)
  tools: ["Read", "Write"] # (optional) allowed tools (string or list)
  disallowedTools: ["Bash"] # (optional) tools to remove (string or list)
  permissionMode: default # (optional) default | acceptEdits | bypassPermissions | plan
  maxTurns: 20 # (optional) maximum agentic turns
  skills: ["skill-creator"] # (optional) Agent Skills to utilize (string or list)
  color: cyan # (optional) UI color (e.g. red, blue, green, cyan, ...)
  memory: project # (optional) user | project | local
  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. Both antigravity-ide and antigravity-cli read the same two locations, so enabling both writes the same file — the same way they already share .agents/hooks.json. Antigravity also accepts a directory form (<name>/agent.md); Rulesync emits and imports the flat file form only. name and description are required upstream, so a canonical subagent without a description gets a minimal generated fallback rather than a file Antigravity refuses to load. Because the two share that file, every Antigravity target reads the antigravity-ide and antigravity-cli blocks merged in a fixed order (the CLI block wins) — the same rule the MCP feature uses for the same shared-output reason — so generation order never changes the file's content; the antigravity-plugin block is layered on top for the plugin bundle only. Besides the shared name/description, those blocks accept these optional fields (all preserved on round-trip): tools (string list), mainAgent (boolean, default true), subagent (boolean, default true), model (inherit | flash | pro), commandExecutionPolicy (off | auto | eager | sandbox), mcpServers, skills, and plugins. hidden and inheritMcp appear in the v1.1.6 release notes but not in the documented frontmatter table, so they pass through verbatim with no behavior modeled around them; the schema is loose, so any extra keys survive the round-trip too. The antigravity-plugin target writes the same file format into a plugin bundle's agents/ directory (project scope only). See the Antigravity subagents docs and the plugin bundle layout.

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

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

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

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

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

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

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

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

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

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

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

OpenCode import note: OpenCode lets agents live both as Markdown files under .opencode/agents/*.md and inline in opencode.json/opencode.jsonc under the top-level agent key. On import, rulesync reads both: each inline entry's prompt becomes the subagent body (a "{file:./path}" reference is resolved relative to the config file's location, as OpenCode does), and the remaining fields (description/mode/model/tools/permission/...) become frontmatter under the opencode: block. 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. Set kilo.mode: subagent to opt into hidden/subagent-only behavior.

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

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

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

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

.rulesync/checks/*.md

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

Example:

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

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

Amp, 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:

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

## security

Review the diff for injection vulnerabilities.

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

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

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

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

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

Never store PII data in BigQuery tables.

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

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

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

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

sh
HERMES_ENABLE_PROJECT_PLUGINS=1 hermes

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

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

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

A command gate's command is run by Takt 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 fieldSource
namethe source file basename without .md (required by Amp)
descriptiondescription
severity-defaultseverity
toolstools

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

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:

md
---
name: example-skill # skill name
description: >- # skill description
  A sample skill that demonstrates the skill format
targets: ["*"] # * = all, or specific tools
# (optional) shared default for tools that support the flag — claudecode, 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.md in a skill directory is copied byte for byte, to whichever tool root the skill is generated into. Most of them are user assets — images, archives, fixtures whose CRLF line endings or missing trailing newline are deliberate — so unlike SKILL.md, whose body and frontmatter Rulesync composes, they get no UTF-8 round-trip, no line-ending normalization and no trailing newline appended. Change detection compares them byte for byte too, so a supporting file written by an older Rulesync (which normalized text files) or edited in place by a formatter is rewritten from the source on the next generate. The one exception is a supporting file Rulesync composes itself rather than carries through — Codex CLI's agents/openai.yaml — which is compared by parsed content so that re-indenting it does not report a change on every generate.

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

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

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

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

Note: Codex CLI reads UI metadata, invocation policy, and tool dependencies from an agents/openai.yaml sidecar next to SKILL.md (Codex's SKILL.md frontmatter only carries name and description). When codexcli.interface, codexcli.policy, or codexcli.dependencies is present, Rulesync emits .agents/skills/<name>/agents/openai.yaml and reads it back on import. If the sidecar is emitted and interface.short_description is absent, the legacy codexcli.short-description is routed there. See the Codex skills docs.

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

Reasonix note: Reasonix discovers Anthropic-style directory-layout skills (<name>/SKILL.md) under .reasonix/skills/ (project) / ~/.reasonix/skills/ (global, via --global). Rulesync emits the portable name/description frontmatter (Reasonix supports additional optional keys, but only that pair is modeled); the schema is loose, so any extra keys on an imported SKILL.md survive 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/skills plus ~/.agents/skills (user). Rulesync emits the shared .agents/skills/ directory in project mode and only the XDG-default ~/.config/muse/skills in global mode (via --global), so a skill is written exactly once. Muse Code's compatibility scans of repo-local .codex/skills and .claude/skills belong to other tools and are not emitted for musecode. Only the portable name/description frontmatter pair is modeled; the schema is loose, so extra keys on an imported SKILL.md survive the round-trip. See the Muse Code extending docs.

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

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

.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:

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

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

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

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

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

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

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

JSON Schema Support

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

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

Transport types (type / transport)

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

OpenCode skills note: on import, Rulesync also reads the skills.paths array of opencode.json / opencode.jsonc ("Additional paths to skill folders") and scans each entry as an extra skill root, so skills a project keeps outside .opencode/skills/ are no longer invisible to rulesync import. These roots are import-only — generation keeps writing to Rulesync's own managed root — and a skill of the same name found in a managed root still wins. Each entry is resolved against the directory the config was read from — the project root in project mode, ~/.config/opencode/ in global mode — which is what OpenCode itself does. An absolute path, 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.urls is 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, command as an array). Rulesync maps stdio/local ⇄ Kilo local and http/sse ⇄ Kilo remote; on import, Kilo remote is normalized to the canonical http transport (the deprecated sse is no longer emitted). The Kilo-specific timeout (local + remote, a positive integer in milliseconds) and oauth (remote only — either an OAuth-config object or false to disable auto-detection) fields are preserved on round-trip. The kilo.jsonc skills config key (skills.paths for extra skill locations and skills.urls for remote skill manifests) is likewise preserved when Rulesync writes the file. A bare {"enabled": false} entry — Kilo's way of switching off a server another config layer defines, such as the global config or a marketplace — round-trips as itself: it imports as a canonical server carrying only disabled: true/disabled: false and no transport, and a server in that shape is written back as {"enabled": …} rather than as a local server with an empty command it cannot start. The enabled state has to be stated outright in both directions: for a transport-less server that says nothing about disabled, a toggle already in kilo.jsonc is left exactly as it is, and if there is none the server is skipped with a warning — a toggle overrides the layer that defines the server, so writing enabled: true for it would switch back on what you turned off there. Kilo's per-tool enabledTools/disabledTools reach the generated file at all now — they used to be stripped before this adapter saw them, so a filter read out of kilo.jsonc was deleted from it on the next generate. A skipped server's filters are written to the tools map either way, since that map is keyed by server name and reaches servers mcp does not list; on import, a tools entry 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-scoped kilo.mcpServers block rather than the shared mcpServers map, because an entry with no command and no url is a server the other tools' configs cannot start. All of this applies equally to OpenCode: its published schema carries the same bare-toggle union member, it round-trips a toggle as itself under the same explicit-state rule, its tools map works the same way, and its transport-less servers land in opencode.mcpServers. The entry must carry no field of a local or remote server (type, command, url, headers, environment, cwd, timeout, oauth); an entry that is malformed in some other way still fails loudly rather than being quietly read as a toggle and written back with its command, headers, or OAuth secrets gone, while an unrelated key Kilo adds later is accepted rather than failing the run (it is not carried across the round-trip, though — a toggle imports as its enabled state and nothing else). Since a toggle keeps nothing but its enabled state, a canonical server that declares no transport but still carries fields such as args or env is written as a toggle with those fields dropped and a warning naming them. A server that names a transport it cannot reach — a type with no command, an http with no url — is skipped with a warning instead, because {"type": "local", "command": []} is a server Kilo cannot start. An existing kilo.jsonc carrying that shape (earlier Rulesync versions wrote it) imports as a server with no transport rather than failing the run. The same applies to OpenCode, whose config uses the same shape. Rejecting it used to fail the whole --targets kilo run rather than the MCP feature alone, because kilo.jsonc is the file the rules feature writes too.

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

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

Hermes Agent note: Hermes MCP servers live under mcp_servers in the shared ~/.hermes/config.yaml. Rulesync preserves OAuth fields (redirect_uri, redirect_host, redirect_port, client_id, client_secret, and scopes) plus idle_timeout_seconds, max_lifetime_seconds, ssl_verify (true/false or a PEM CA-bundle path), skip_preflight, keepalive_interval (liveness ping cadence in seconds), trust (full or untrusted, where every write-capable tool call needs approval — copied verbatim, since Hermes reads any unrecognized value as untrusted), and the sampling, elicitation, and identity_header mappings (carried as opaque objects so new sub-keys keep working). A canonical sse server is written with Hermes's own transport: sse (v0.20.0) and imports back as type: "sse"; without it Hermes connects to a url server over Streamable HTTP, so the transport would silently change. Streamable HTTP is Hermes's default and stays implicit. On import, portable server fields remain in shared mcpServers; Hermes-only fields are isolated in the full hermesagent.mcpServers.<name> replacement block so they cannot leak to other targets.

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

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

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

MCP Tool Config (enabledTools / disabledTools)

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

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

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

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

deepagents note: MCP servers are written to .deepagents/.mcp.json (project) / ~/.deepagents/.mcp.json (global). 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 only stdio, sse and http (plus the aliases streamable_http / streamable-httphttp), so canonical local is written as stdio and streamable-http as http; canonical ws has no counterpart and the server is skipped with a warning at generate time, where you can still see it. Whether the value lands under type or transport follows whichever key you authored — dcode reads the two interchangeably, and with neither set it infers http from a url and stdio otherwise. On import, both spellings of the streamable_http alias come back as canonical http. Tool filters: canonical enabledTools becomes allowedTools (dcode never reads enabledTools, and it ignores unknown keys silently, so forwarding the canonical name would be a no-op), while disabledTools carries its own name; each entry is a tool name or an fnmatch glob, and import lifts allowedTools back. Upstream rejects a server that sets both filters and rejects an empty list, and each case is resolved the way that does not hand the model more tools than your canonical config allows. Setting both is valid canonically — other targets apply the two lists independently — but has no form here, so the server is skipped with a warning rather than written without filters, which would leave it running with every tool including the denied ones. An empty enabledTools likewise skips the server, since an allowlist of nothing means no tools at all and dropping the key would publish all of them. An empty disabledTools is the one genuine no-op, so only that key is dropped (with a warning) and the server is still written. See the MCP tools docs.

Qwen Code note: MCP servers are written to the mcpServers key of .qwen/settings.json (project) / ~/.qwen/settings.json (global, via --global). Qwen supports stdio (command/args), SSE (url), and HTTP (httpUrl) transports. Rulesync maps the canonical per-server enabledTools ⇄ Qwen's includeTools (allowlist) and disabledTools ⇄ Qwen's excludeTools (denylist). Other top-level keys in settings.json are preserved on round-trip.

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

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

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

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

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

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

Generated ~/.codex/config.toml:

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

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

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

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

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

See the Codex MCP reference for both fields.

Codex-specific: OAuth client id (oauth.clientIdclient_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:

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

Only 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 the mcp_servers key and preserves every other table on round-trip, and it is never deleted. Unlike Codex CLI, Grok uses a literal env table (it does not support the env_vars runtime-passthrough list) and has no per-server tool allow/deny lists, so the only field rename is disabled (rulesync) ⇄ enabled = false (grok); an active server simply omits enabled. Servers with no environment variables are emitted without a dangling [mcp_servers.<name>.env] table (empty nested tables are stripped), and a server whose entire configuration would be empty is dropped with a warning.

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

Goose configures MCP servers in two locations depending on scope:

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

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

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

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

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

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

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

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

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

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

Rovo Dev CLI MCP note: Rovo Dev documents the per-server transport key as transport (stdio | http | sse), not the canonical type. Rulesync translates on the way out (localstdio, streamable-httphttp) and back on import; ws has no Rovo Dev equivalent, so those servers are skipped with a warning, and a transport value outside Rovo Dev's vocabulary is dropped on import rather than written into the canonical config, whose transport field is a strict enum. disabled is stripped from the servers that are written, since mcp.json is not where a Rovo Dev server is switched on and off — see the toggle handling below. mcp.json is written at both scopes: the global ~/.rovodev/mcp.json, and in project mode the repo-committed .rovodev/mcp.json the Bitbucket Cloud Agentic Pipelines guide documents (pointed at via mcp.mcpConfigPath; not gitignored, since committing it is the point). A server the canonical config marks disabled: true is no longer dropped: its definition is written to mcp.json (minus the flag, which the file cannot express) and its name goes to mcp.disabledMcpServers in the sibling config.yml — the key Rovo Dev actually consults — where rulesync owns the toggle for the servers it manages while user keys (mcpConfigPath, allowedMcpServers, ...) and disabled names for unmanaged servers survive. On import, names listed in mcp.disabledMcpServers come back as disabled: true on the matching servers; a config.yml that exists but cannot be parsed fails both directions closed (the import errors instead of silently re-enabling servers, and generate skips disabled definitions it cannot switch off). Since the project mcp.json is committed, prefer env-var references over literal credentials in server env/headers; note that rulesync owns the mcpServers map in that file, so servers hand-added there (rather than to .rulesync/mcp.jsonc) are replaced on the next generate. See the Rovo Dev MCP docs.

Meta Muse Code MCP note: Muse Code reads MCP servers only from the mcp_servers block of the global user settings file ~/.config/muse/settings.json — no project-scoped MCP location is documented, so the musecode MCP target requires --global. Each server entry carries a transport discriminator: stdio servers get command (a single string), args and env, and remote servers become transport: "streamable_http" with url/headers (Muse Code's only documented remote transport). A server that states sse or ws — or carries a ws:///wss:// URL with no stated type — is skipped with a warning rather than rewritten onto a transport it does not speak. A canonical disabled: true maps to Muse's enabled: false and back. The settings file must carry "schema_version": 1 — Muse Code fails startup with malformed settings file without it — so Rulesync bootstraps the key when it creates the file and preserves an existing value; every other settings key is preserved on round-trip and the file is never deleted. There are no per-server tool allow/deny lists. See the Muse Code extending docs and configuration docs.

Reasonix note: MCP servers are written as [[plugins]] array-of-tables entries (Reasonix's MCP-compatible external plugins) in reasonix.toml (project) / ~/.reasonix/config.toml (global, via --global). Each entry carries a name plus the standard transport fields: type selects the transport (stdio default — command/args/env; http, a.k.a. streamable-httpurl/headers; sse, the legacy 2024-11-05 HTTP+SSE transport, written verbatim — Reasonix re-implemented it in v1.17.18, and collapsing it onto http pointed the client at Streamable HTTP so the server could not connect). The file is treated as shared Reasonix config: Rulesync only replaces the plugins key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists. The trusted_read_only_tools array (raw MCP tool names pre-seeded as trusted for planner/read-only use) is neither written nor imported: v1.17.18 retired it along with default_tools_approval_mode, tools.<raw>.approval_mode and approvals_reviewer — installing a server is the authorization decision now, and Reasonix ignores the key on load and strips it the next time it saves that entry. Importing it would put a Reasonix-only dead key into the canonical mcpServers that every MCP target writes out, so it would surface in .mcp.json and the rest. Note that Rulesync owns the plugins key, so the next generate drops the key from an older reasonix.toml as well; nothing is lost that Reasonix still reads. An MCP server whose transport Reasonix does not implement (ws, including a ws:///wss:// URL that states no transport at all) is skipped with a warning rather than written as a type its loader rejects. Each entry also supports startup_timeout_seconds (a per-server cap on the background launch/authorization/initialize/tools/list sequence, overriding the global mcp_startup_timeout_seconds; 0 means defer to that global cap, and is preserved rather than dropped), call_timeout_seconds (a per-server MCP call timeout) and tool_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_seconds does have a near-equivalent in canonical networkTimeout (which Codex CLI deep-maps to its startup_timeout_sec), but canonical timeouts are milliseconds while Reasonix takes seconds, and Reasonix's meaningful 0 has no canonical spelling — so mapping it would either invent a value or lose one. Vibe's startup_timeout_sec passes through for the same reason. See the Reasonix plugins guide and SPEC.md ([[plugins]] schema).

.rulesync/.aiignore or .rulesyncignore (deprecated)

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

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

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

Rules and behavior:

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

Example:

ignore
tmp/
credentials/

Migrating to permissions

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

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

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

Where ignore patterns are written per tool

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

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

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:

sh
HERMES_ENABLE_PROJECT_PLUGINS=1 hermes

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

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

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

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

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

.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 confirmation
  • ask -- Requires user confirmation before execution
  • deny -- 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:

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

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

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

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

JSON Schema Support

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

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

For Claude Code, this generates permissions.allow, permissions.ask, and permissions.deny arrays in .claude/settings.json (project mode) or ~/.claude/settings.json (global mode) using PascalCase tool names (e.g., Bash(git *), Edit(src/**), Read(.env)).

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

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

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

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

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

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

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

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

  • allow patterns (all categories) → command_allowlist.
  • bash deny patterns → approvals.deny — Hermes's hard denylist, evaluated before --yolo / approvals.mode: off.
  • webfetch deny patterns → security.website_blocklist.domains.
  • Every ask rule, and deny rules in categories other than bash/webfetch, have no native per-pattern Hermes primitive; they survive only for round-trip (Rulesync also stores the full canonical config under a private permissions.rulesync key so .rulesync/permissions.jsonc reconstructs losslessly).

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

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

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

  • bash: generates one prefix_rule(...) per command pattern in .codex/rules/rulesync.rules (allowallow, askprompt, denyforbidden)
  • read: allowread, ask/denydeny in permissions.<profile>.filesystem
  • edit / write: allowwrite, ask/denydeny in permissions.<profile>.filesystem
  • webfetch: allow/deny map to permissions.<profile>.network.domains (Codex does not support ask for domain rules); network.enabled = true is emitted only when at least one allow rule is present. Deny-only domain sets are emitted without enabled, which Codex treats as restricted (its default) while the deny entries still round-trip back into Rulesync rules. Codex rejects the global wildcard * in denied domains at config load time, so webfetch: { "*": "deny" } is skipped with a warning (unlisted domains are denied by Codex's allowlist-first policy anyway); webfetch: { "*": "allow" } is emitted as a regular "*" = "allow" domain entry, which Codex accepts for denylist-only setups (openai/codex#15549). On import, deny entries are always taken, while allow entries are imported only when enabled = true is explicit — Codex treats a missing enabled as restricted, so importing an allow entry from a disabled profile would activate a grant Codex never had. A Codex profile with network.enabled = true but no domains is imported as webfetch: { "*": "allow" }, which reflects Codex's default semantics where enabled = true grants sandbox-wide network access (under Codex's experimental network_proxy feature, enabled = true without an allowlist blocks requests instead, and the regenerated "*" = "allow" entry is the closest equivalent).

Relative filesystem globs such as src/** or **/*.tf are emitted under permissions.<profile>.filesystem.":workspace_roots" instead of the top-level filesystem table, because Codex expects top-level filesystem keys to be absolute paths, ~/..., or named roots. 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 (codexcli key): Codex CLI's permission surface is richer than the canonical allow/ask/deny model — its approval workflow, permission-profile baseline, and per-app tool gating have no canonical category. Add a tool-scoped codexcli override to author them: except for base_permission_profile, its fields are written verbatim as top-level .codex/config.toml keys (the override wins per key; existing sibling keys the user set directly are preserved, and table values are shallow-merged) while the shared permission block keeps driving the managed [permissions.rulesync] profile and default_permissions. Supported keys: base_permission_profile (:read-only | :workspace | :danger-full-access, default :workspace — not a top-level key; it becomes the managed profile's extends baseline, or with :danger-full-access the directly-selected default_permissions value, see above), approval_policy (untrusted | on-request (legacy alias on-failure) | never, or a { granular = { … } } table kept verbatim; defaults to on-request when 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 alias guardian_subagent), or a table; defaults to auto_review when neither the override nor the existing config sets it), and git_write_rules (boolean, default true — like base_permission_profile it is not a top-level key: it controls whether the managed profile's :workspace_roots table emits the default .git carve-out described above; only an explicit false suppresses it). Deprecated: sandbox_mode (read-only | workspace-write | danger-full-access) with the sibling sandbox_workspace_write table (network_access, writable_roots, …) belong to Codex's classic sandbox system, which permission profiles supersede — Codex prioritizes these legacy keys over permission profiles when both are present, so authoring them disables the generated [permissions.rulesync] profile; they are still accepted (with a warning) so existing configs round-trip, but use base_permission_profile and the shared permission block instead. On import, the top-level keys round-trip back into the codexcli override, and the managed profile's extends round-trips into base_permission_profile. It is a looseObject, so future top-level Codex config keys can be authored here (merged verbatim on generate; only the listed keys are re-extracted on import). Example: { "permission": { … }, "codexcli": { "base_permission_profile": ":workspace", "approval_policy": "on-request", "approvals_reviewer": "auto_review" } }. Out of scope: mcp_servers.* per-MCP gating is not authorable here — it is owned by the MCP feature (codexcli-mcp.ts writes the mcp_servers tables in the same config.toml), and permissions / default_permissions are owned by the canonical model; any such key placed in the override is skipped with a warning. See the Codex configuration reference and permissions docs.

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

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

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

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

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

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

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

For GitHub Copilot (copilot), this manages the three chat.tools.*.autoApprove maps in the workspace .vscode/settings.json (project mode only). VS Code has no standalone, environment-agnostic Copilot policy file, so project-level auto-approvals are configured through VS Code Copilot Chat's workspace settings. Three canonical categories have a clean, non-lossy mapping and are emitted: bashchat.tools.terminal.autoApprove (command patterns), editchat.tools.edits.autoApprove (file globs) and webfetchchat.tools.urls.autoApprove (URL patterns). In all three, allowtrue (auto-approve) and denyfalse (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: allowallowedCommands, denydeniedCommands, 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: allowallowedUrls, denydeniedUrls, 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 (kilo key): Kilo's permission object carries tool-specific keys with no canonical permission category — OpenCode-inherited ones (external_directory, doom_loop, lsp, question, todowrite, skill, task, list) and Kilo-unique ones (agent_manager, notebook_read, notebook_edit, notebook_execute, repo_clone, repo_overview). Add a tool-scoped kilo override key alongside the shared block (mirroring the opencode override) to author these; entries under kilo.permission are merged on top of the shared block per key (the override wins) and are emitted only into kilo.jsonc. Each value may be a bare action string or a pattern map. On import, any Kilo key that is not a shared canonical category (bash, read, edit, webfetch, websearch, grep, glob, the all-tools key *, or an mcp__* tool name) is routed into the kilo override rather than the shared block, so a subsequent rulesync generate does not leak it into other tools.

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

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

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

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

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

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

Zed-only override (zed key): two Zed surfaces sit outside the canonical allow/ask/deny model and are authored verbatim through a tool-scoped zed override. zed.sandbox_permissions is written into agent.sandbox_permissions: Zed's OS-level agent sandbox, which since Zed 1.14.2 (2026-08-05) is on by default for the terminal and fetch tools and by default forbids network access, writing outside the project directories, and writing to .git. Most real setups therefore need to relax one of network_hosts (exact hostnames or leading *. wildcards), allow_all_hosts, write_paths, allow_fs_write_all or allow_unsandboxed — none of which the canonical categories can express, since this is process containment rather than tool gating. zed.profiles is written into agent.profiles, Zed's tool-availability layer: a separate enforcement stage from tool_permissions, because a tool absent from the active profile cannot be used no matter what the permission rules allow (per-profile keys name, tools, enable_all_context_servers, context_servers, default_model). 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 documented profiles keys (sandbox_permissions is unvalidated, since Zed adds to it release over release) — and each is replaced wholesale when the override supplies it, since Zed reads each as a single policy unit; omit the key and whatever is already in settings.json is left alone rather than deleted, so removing a block is a manual edit. On import, both are lifted back into the zed override 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 into agent.sandbox_permissions itself. Read the imported block before committing it: an ad-hoc allow_unsandboxed picked up from your own machine would otherwise be 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. The zed block authors these two keys and nothing else: agent.tool_permissions belongs to the canonical permission block, and any other key — a misspelling, or a blunt instrument such as Zed's agent.always_allow_tool_actions — is ignored with a warning, so nothing reachable from the override can weaken a reviewed deny. Both scopes are written, like the sibling agent.tool_permissions: Zed layers user settings under project settings, and a project's .zed/settings.json is applied once the worktree is trusted. That trust prompt is the thing to watch when you clone a repository — a project-scoped allow_unsandboxed or allow_all_hosts is a real grant, not an inert one, so review a .zed/settings.json you 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 (bashBash, readRead, writeWrite, editEdit, grepGrep, globGlob, websearchWebSearch, webfetchFetchURL, agentAgent, 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 (qwencode key): Qwen's settings.json exposes autonomy/sandbox controls with no canonical permission category — under tools (approvalMode = plan/default/auto-edit/auto/yolo, autoAccept, sandbox, sandboxImage, disabled, visible — the deferred-tool startup visibility list, union-merged by Qwen across scopes), security (folderTrust, plus the two guardrails on type: "http" hooks — allowedHttpHookUrls, the allowlist of URL patterns a hook may POST to, where an empty list means allow-all, and allowPrivateNetworkHooks, which relaxes the private-IP (SSRF) check), and permissions.autoMode (the Auto Mode classifier config: hints.{allow,softDeny,hardDeny}, environment, classifyAllShell). Add a tool-scoped qwencode override to author them: qwencode.tools and qwencode.security are shallow-merged into the matching settings.json group at the top level of that group (an unrelated sibling key such as tools.core is preserved, an override key wins, and a nested object the override supplies such as security.folderTrust replaces the existing one wholesale rather than being deep-merged), while qwencode.autoMode is emitted as permissions.autoMode (replacing the existing autoMode wholesale) and the shared permission block keeps driving the permissions.allow/ask/deny arrays. On import, the documented autonomy keys (tools.{approvalMode,autoAccept,sandbox,sandboxImage,disabled,visible}, security.{folderTrust,allowedHttpHookUrls,allowPrivateNetworkHooks}, and permissions.autoMode) round-trip back into the override; other tools/security keys are left in settings.json and not extracted. One scope caveat applies: Qwen Code honors security.allowPrivateNetworkHooks only 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-scoped settings.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.json that 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 Read is a meta-tool that also covers grep/glob/list, so canonical grep/glob rules are emitted as their own Grep(...)/Glob(...) entries but overlap Qwen's Read category at runtime; and Qwen folds web search into web_fetch, so a canonical websearch rule (WebSearch(...)) may not correspond to a distinct Qwen tool. tools.disabled is a hard whole-tool disable (stronger than deny) and is only authorable via the override, not the canonical deny.

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

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

See the Warp settings reference.

For the 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: readread_file, edit/writewrite_file, bashcommand, webfetch/websearchread_url, mcpmcp (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: readread_file, edit/writewrite_file, bashcommand, webfetch/websearchread_url, mcpmcp (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: allowalways_allow, askask_before, denynever_allow. Tool-name mapping: bashdeveloper__shell, editdeveloper__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: bashBash, readRead, editEdit, grepGrep, webfetchWebFetch, websearchWebSearch, 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-approvebash: { "*": "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: bashbash, readread_file, editedit, writewrite_file, webfetchweb_fetch, websearchweb_search, grepgrep, agenttask. 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 (allowalways, askask, denynever); 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 (vibe key): Vibe's BaseToolConfig also carries a sensitive_patterns list — patterns that escalate to ASK even when the base permission is ALWAYS (allow). The canonical model can only set a pattern to a single allow/ask/deny, so an "allow by default but ask on these patterns" escalation cannot be expressed in the shared block. Add a tool-scoped vibe override to author it: vibe.permission.<category>.sensitive_patterns carries the list per canonical category (e.g. bash, edit), while the shared permission block still sets the base permission and allow/deny lists. On import, a tool's sensitive_patterns round-trips back into the vibe override (the base allow stays in the shared block). rulesync owns the list for any category named in the override (a present list is set, an empty one clears it); categories not named keep whatever the existing config.toml had. The override also carries vibe.enabled_tools — the only way to author Vibe's top-level exclusive allowlist. The list is written verbatim in Vibe's tool-name vocabulary (declaring it, even empty, makes rulesync own the whole key), and on import a non-empty enabled_tools is lifted back into the override rather than being misread as a set of "*": "allow" grants. Note the config.toml scope semantics: since v2.24.0 Vibe installs the user and project TOML layers together rather than picking one, so a trusted project config overlays the user config instead of replacing it, and a --global run is no longer discarded wholesale by a project config.toml — the project layer still wins key by key where it sets one, but every key it leaves unset falls through to the global file. Merging is per key: mcp_servers and connectors union-merge by name, tools deep-merges, disabled_tools concatenates, and enabled_tools is replaced wholesale by the higher layer. An org-enforced AdminConfigLayer sits above every layer at runtime and can override anything below it. (Whether a project layer is read at all still depends on Vibe trusting the project, so treat the overlay as the trusted-project behavior.)

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

For Takt, this generates the default_permission_mode under provider_profiles.<provider> in the shared .takt/config.yaml (project mode) or ~/.takt/config.yaml (global mode). Takt does not have per-tool / per-pattern rules; tool gating is a single coarse mode per provider profile, ordered readonly < edit < full (readonly may only read, edit may also edit/write files, full may also run shell commands). The active provider is resolved from runtime.yaml first when Takt is in runtime provider mode (see below) — the provider of the profile named by provider.defaults.profile, with no fallback, matching Takt (a lone profile is not promoted to the default, and defaults.pool/ladder forms resolve at run time) — and otherwise from the top-level provider: key of config.yaml, the sole provider_profiles entry, or the claude default. provider_profiles itself stays in config.yaml in every version: it is not a legacy provider signal, and Takt does not move permission modes into runtime.yaml. The mapping is therefore lossy: on generate, a single mode is derived with this precedence — (1) any deny rule anywhere ⇒ readonly (conservative — keep the narrowest mode whenever the user expressed any restriction); (2) else any edit/write category allow rule ⇒ edit; (3) else any bash category allow rule ⇒ full; (4) else ⇒ readonly (safe default). On import, fullbash: { "*": "allow" }, editedit: { "*": "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 policiesworkflow_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.yamlprovider_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 } } (rejectdeny, allowallow, askask; 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; guardedFilesamp.guardedFiles.allowlist (globs allowed without confirmation); and dangerouslyAllowAllamp.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: bashexecutables, edit/writefileEditing, readreadOutsideProject, mcpmcpTools. Every rule group is written as Junie's AllowListRuleSet object{ "default"?: "allow"|"ask", "rules": [ … ] } — never a bare array: Junie's parser rejects the array form for the whole file and then discards and overwrites allowlist.json, so the shape matters. Earlier rulesync versions emitted the array form; it is still tolerated on import, but only the object form is generated. Each rule carries an action plus either a literal prefix (matches commands that start with it) or a glob pattern (*, **, ?, [abc], [!abc]); rulesync emits pattern when the canonical pattern contains a glob metacharacter (*, ?, [) and prefix otherwise. Junie accepts only allow and ask as actions — there is no deny (a deny fails the whole-file parse) — so a canonical deny is downgraded to the nearest valid action, ask (which still withholds auto-approval), with a warning (allow/ask map 1:1). Categories Junie cannot represent (e.g. webfetch, websearch) are skipped with a warning when they carry rules. rulesync owns each mapped group's rule list (replaced on each generate), while a per-group default and the whole readSecretFile group — which restricts what Junie may read — are preserved from the existing file when not authored via the junie override below. Because edit/write both collapse onto fileEditing, importing normalizes back to edit (a documented, lossy mapping). The allowlist.json file is never deleted. See the Junie Action Allowlist docs.

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

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

For Reasonix, this generates permissions.allow, permissions.ask, and permissions.deny arrays in the [permissions] table of the shared reasonix.toml (project mode) or ~/.reasonix/config.toml (global mode) — the same TOML file the MCP feature's [[plugins]] array-of-tables lives in. The rule syntax mirrors Claude Code's: entries are Bash(<pattern>), Read(<pattern>), Edit(<pattern>), Write(<pattern>), WebFetch(<pattern>), WebSearch(<pattern>), Grep(<pattern>), Glob(<pattern>), NotebookEdit(<pattern>), Agent(<pattern>), etc. (Reasonix's SPEC.md documents these as "Claude Code-style" families; agentAgent 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 (reasonix key): Reasonix has security axes orthogonal to per-tool allow/ask/deny with no canonical category — the [sandbox] enforcement table (workspace_root, allow_write, forbid_read, bash = enforce/off, network) and the plan-mode read-only command list under [agent] (plan_mode_read_only_commands, which upstream keeps for legacy compatibility only — Plan bash goes through Permissions now). Its sibling plan_mode_allowed_tools left the documented config surface in v1.17.18: an existing value is still lifted out of [agent] on import, so it does not vanish from an imported config, but whenever the override writes [agent] the key is removed from the file with a warning — including a value already there, since leaving that one alone would mean narrowing the list is the one edit that never lands. Add a tool-scoped reasonix override to author them: reasonix.sandbox and reasonix.agent are shallow-merged into the matching reasonix.toml table at its top level (override keys win, unrelated sibling keys such as [agent].model are preserved), while the shared permission block keeps driving [permissions].allow/ask/deny. The override also carries rawAllow/rawAsk/rawDeny — verbatim [permissions] entries merged into the generated arrays untranslated. They exist for the first-class Bash=<literal> exact-command form (SPEC §3.7, v1.18.0: metacharacters in the literal are ordinary characters and only the identical complete command matches), which the canonical tool→pattern→action shape cannot express. It is also the pattern-level way to pre-authorize nested or indirect Bash — command and process substitution, eval, source, sh -c and the like, which Reasonix gates harder than a merely dynamic command line — in a headless reasonix run; upstream additionally offers the blanket [permissions] allow_dynamic_bash opt-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 in reasonix.toml — Reasonix writes them itself as remembered approvals — are always preserved on generate, even for tools the shared block manages. On import, the whole [sandbox] table round-trips (it is a dedicated security surface), only the plan-mode keys are lifted from [agent], and exact Tool=<literal> entries are lifted into rawAllow/rawAsk/rawDeny instead of masquerading as a bogus tool category in the shared block.

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

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

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

Released under the MIT License.