diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 73e44fec8..d39a63e88 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -254,6 +254,14 @@ The `generate` command reads source files from one or more rulesync source trees > warning — sweeps no skill directory from the inside at all: with the source > only partly read, a generated file it cannot account for may be one it still > wants. +> +> An output directory reached through a symbolic link that leads outside the +> output root — a checked-out `.cursor/commands -> ~/notes`, say — is not swept +> either: what the sweep would list there are files in the link's target, not +> Rulesync's output, so each one is kept and named in a `Refusing to delete` +> warning, the same way a write through such a link is refused. A link that stays +> inside the output root is followed as usual, and an orphan that is itself a +> symbolic link is removed as a link, leaving its target untouched. > **Note on unreadable sources:** This applies to the single-file features — > `mcp`, `hooks`, `permissions`, and `ignore` — each of which is generated from diff --git a/src/generated/docs-content.ts b/src/generated/docs-content.ts index a213362e1..b382092a8 100644 --- a/src/generated/docs-content.ts +++ b/src/generated/docs-content.ts @@ -34,7 +34,7 @@ export const DOCS_CONTENT: Record = { "guide/why-rulesync": "# Why Rulesync?\n\n## Single Source of Truth\n\nAuthor rules once, generate everywhere. Rulesync turns a unified ruleset into tool-native formats so teams stop duplicating instructions across multiple AI assistants.\n\n## Tool Freedom Without Friction\n\nLet developers pick the assistant that fits their flow—Copilot, Cursor, Cline, Claude Code, and more—without rewriting team standards.\n\n## Clean, Auditable Outputs\n\nRulesync emits plain configuration files you can commit, review, and ship. If you ever uninstall Rulesync, your generated files keep working.\n\n## Fast Onboarding & Consistency\n\nNew team members get the same conventions, context, and guardrails immediately, keeping code style and quality consistent across tools.\n\n## Multi-Tool & Modular Workflows\n\nCompose rules, MCP configs, commands, and subagents for different tools or scopes (project vs. global) without fragmenting your workflow.\n\n## Ready for What's Next\n\nAI tool ecosystems evolve quickly. Rulesync helps you add, switch, or retire tools while keeping your rules intact.\n", "reference/cli-commands": - '# CLI Commands\n\n## Quick Commands\n\n```bash\n# Initialize new project (recommended: organized rules structure)\nrulesync init\n\n# Import existing configurations (to .rulesync/rules/ by default)\nrulesync import --targets claudecode --features rules,mcp,commands,subagents,skills,permissions\n\n# Import components from an existing plugin directory\nrulesync import --targets claudecode-plugin --features skills,hooks --output-root ./plugins/review-tools\n\n# Convert configurations from one tool to other tools (skips .rulesync/)\nrulesync convert --from cursor --to copilot,claudecode\nrulesync convert --from cursor --to copilot,claudecode --features rules,mcp\n\n# Fetch configurations from a Git repository\nrulesync fetch owner/repo\nrulesync fetch owner/repo@v1.0.0 --features rules,commands\nrulesync fetch https://github.com/owner/repo --conflict skip\n\n# Generate all features for all tools (new preferred syntax)\nrulesync generate --targets "*" --features "*"\n\n# Generate specific features for specific tools\nrulesync generate --targets copilot,cursor,cline --features rules,mcp\nrulesync generate --targets claudecode --features rules,subagents\n\n# Generate components inside an existing plugin directory\nrulesync generate --targets antigravity-plugin --features rules,mcp,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets augmentcode-plugin --features rules,mcp,commands,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets zcode-plugin --features mcp,commands,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets vibe-plugin --features mcp,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets devin-plugin --features rules,mcp,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets kimi-code-plugin --features rules,commands,subagents,skills --output-roots ./plugins/review-tools\n\n# Generate only rules (no MCP, permissions, commands, or subagents)\nrulesync generate --targets "*" --features rules\n\n# Generate simulated commands and subagents\nrulesync generate --targets copilot,cursor,codexcli --features commands,subagents --simulate-commands --simulate-subagents\n\n# Dry run: show changes without writing files\nrulesync generate --dry-run --targets claudecode --features rules\n\n# Check if files are up to date (for CI/CD pipelines)\nrulesync generate --check --targets "*" --features "*"\n\n# Generate from a shared source tree (without cd-ing into it)\nrulesync generate --input-roots ~/.aiglobal/.rulesync --targets "*" --features rules\n\n# Install rules and skills from declarative sources in rulesync.jsonc\nrulesync install\n\n# Add a source to rulesync.jsonc, update the lockfile, and install it\nrulesync add anthropics/skills --skills skill-creator\n\n# Add a rule source without selecting skills\nrulesync add acme/ai-standards --rules testing-guidelines\n\n# Force re-resolve all source refs (ignore lockfile)\nrulesync install --update\n\n# Fail if lockfile is missing or out of sync (for CI); fetch missing artifacts using locked refs\nrulesync install --frozen\n\n# Report sources whose lockfile entry is behind the remote, without writing anything\n# (exit 1 if any is behind or not locked, 2 if a source cannot be resolved)\nrulesync install --outdated\n\n# Install then generate (typical workflow)\nrulesync install && rulesync generate\n\n# Add generated files to .gitignore\nrulesync gitignore\n\n# Add only specific tool entries to .gitignore\nrulesync gitignore --targets claudecode,copilot\n\n# Add only specific feature entries to .gitignore\nrulesync gitignore --targets copilot --features rules,commands\n\n# Diagnose the configuration files for common problems (read-only)\nrulesync doctor\n\n# Diagnose and fail CI on warnings too\nrulesync doctor --strict\n\n# Print GitHub release notes for a repository (latest 10 by default)\nrulesync release-notes dyoshikawa/rulesync\n\n# Print the most recent 5 releases\nrulesync release-notes dyoshikawa/rulesync --latest 5\n\n# Update rulesync to the latest version (single-binary installs)\nrulesync update\n\n# Check for updates without installing\nrulesync update --check\n\n# Force update even if already at latest version\nrulesync update --force\n```\n\n> **Deprecated feature:** `ignore` remains available to existing projects throughout Rulesync 14.x, but new projects should use `permissions`. Any removal will be decided separately and will not occur before a future major release.\n\n## JSON Output\n\nThe global `--json` flag makes a command print a single result document and nothing else. Because that document is the whole of the output, warnings that would otherwise go to standard error are carried inside it, as a top-level `warnings` array of strings:\n\n```json\n{\n "success": true,\n "timestamp": "2025-01-01T00:00:00.000Z",\n "command": "import",\n "version": "x.y.z",\n "warnings": [".factory/settings.local.json is a machine-local overrides file …"],\n "data": { "…": "…" }\n}\n```\n\nThe key is omitted when nothing warned, and `--silent` suppresses warnings there as it does on the console. `warnings` sits beside `data` rather than inside it so a command\'s own captured keys can never collide with it, and it is reported on the failure document too, where a diagnostic about the input is often what explains the failure.\n\nAt most 100 warnings are reported, each truncated to 1,000 characters and 8,000 characters in total; a run that exceeds any of those limits says so in a final entry rather than growing the document without bound.\n\nBecause that budget is finite, the array carries the diagnostics a run has no other way to report — not a restatement of what `data` already holds. A command that lists something under a captured key writes the list there and warns only about what the list does not say.\n\n## Init Command\n\n`rulesync init` creates the `.rulesync/` directory with sample rule, MCP, subagent, skill, hooks and permissions files, plus a `rulesync.jsonc` configuration file. Existing files are never overwritten.\n\n### User-level defaults\n\nThe generated `rulesync.jsonc` starts from `"targets": ["codexcli", "claudecode", "opencode"]` and `"features": ["rules", "mcp", "subagents", "skills", "hooks", "permissions"]`. To seed every new project with your own set instead, put a `rulesync.jsonc` in the user config directory: `$XDG_CONFIG_HOME/rulesync/rulesync.jsonc`, which is `~/.config/rulesync/rulesync.jsonc` when `XDG_CONFIG_HOME` is unset (a relative `XDG_CONFIG_HOME` is ignored, as the XDG specification requires).\n\n```jsonc\n// ~/.config/rulesync/rulesync.jsonc\n{\n "targets": ["claudecode", "cursor"],\n "features": ["rules", "skills"],\n}\n```\n\n`init` copies only `targets` and `features` from that file; a key it omits keeps the built-in default, and the object form of `targets` is copied as-is (the top-level `features` key is then left out, since the per-target features live inside the object). Every other key is ignored: the file is a template for `init`, not a configuration layer — `generate` and the other commands read only the project\'s `rulesync.jsonc` and `rulesync.local.jsonc`. A user config that exists but fails to parse or schema validation (for example, an unknown target name in the array form of `targets`) makes `init` fail rather than silently writing the defaults. The command reports `Seeded targets and features from ` when the file was used (`seededFrom` in `--json` output).\n\n## Generate Command\n\nThe `generate` command reads source files from one or more rulesync source trees (default: `/.rulesync`; configurable via `--input-roots`) and writes AI tool configuration files to the output directories.\n\n### Options\n\n| Option | Description | Default |\n| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |\n| `--targets, -t ` | Comma-separated list of tools (e.g. `claudecode,copilot` or `*`) | From `rulesync.jsonc` |\n| `--features, -f ` | Comma-separated list of features (rules, commands, subagents, skills, mcp, hooks, permissions, checks; deprecated: ignore) | From `rulesync.jsonc` |\n| `--input-roots ` | Ordered list of rulesync source-tree directories (e.g. `.rulesync`, `.rulesync.local`). Each entry is a source tree itself — no `.rulesync/` join is applied. The first root is required; later roots are optional overlays and may be absent. Later entries override earlier ones for the same relative source path (currently `generate` only). Cannot be combined with `--input-root`. | `/.rulesync` |\n| `--input-root ` | **Deprecated.** Path to the PARENT directory of a `.rulesync/` source tree; kept for backward compatibility and expands internally to `--input-roots /.rulesync`. Prefer `--input-roots`. Cannot be combined with it. | CWD |\n| `--dry-run` | Show what would change without writing files | `false` |\n| `--check` | Like `--dry-run` but exits with code 1 if files are not up to date | `false` |\n| `--global` | Generate for global (user-scope) configuration files | `false` |\n| `--simulate-commands` | Generate simulated commands for tools that do not support them natively | `false` |\n| `--simulate-subagents` | Generate simulated subagents for tools that do not support them natively | `false` |\n| `--simulate-skills` | Generate simulated skills for tools that do not support them natively | `false` |\n| `--delete` | Delete existing generated files before writing | From `rulesync.jsonc` |\n| `--retire-targets ` | Comma-separated list of tools the project no longer uses; deletes the outputs Rulesync manages for them (requires `--features`; see below) | - |\n| `--watch, -w` | Keep running and regenerate whenever rulesync source files change | `false` |\n| `--config, -c ` | Path to the configuration file to load (`rulesync.local.jsonc` next to it still overlays it) | `rulesync.jsonc` |\n\n> **Note on `--check` and shared root files:** A root file that several targets\n> write, such as `AGENTS.md`, is compared against its owner\'s output. The owner\n> is the last target in `rulesync.jsonc` that generates rules, even when\n> `--targets` selects a different target, and its output is built as\n> `rulesync generate` with the same options but without `--targets` would\n> build it. See\n> [Target Order and File Conflicts](../guide/configuration.md#target-order-and-file-conflicts).\n\n> **Retiring a target:** Leaving a tool out of `targets` (or out of\n> `--targets`) never deletes its files — not even with `--delete` — because an\n> omitted target only means "not processed in this run". To remove the outputs\n> of a tool the project has dropped, name it and the features Rulesync\n> generated for it explicitly:\n>\n> ```bash\n> # rulesync.jsonc no longer lists cursor; Rulesync generated its rules and mcp\n> rulesync generate --retire-targets cursor --features rules,mcp --dry-run # preview\n> rulesync generate --retire-targets cursor --features rules,mcp\n> ```\n>\n> For each listed feature, Rulesync deletes the files it would sweep with\n> `--delete` for that tool — the same managed paths, nothing else. Note that\n> `--features` scopes the whole run, not only the retirement — except with the\n> object form of `targets`, where the run keeps each target\'s configured\n> features and `--features` only names what to retire. The listed features\n> apply to every retired tool, and a managed path is deleted even if you\n> edited it by hand, so preview with `--dry-run` first.\n>\n> - Unrelated files beside the managed paths stay, a shared file another tool\n> merges into (such as `.claude/settings.json`) is never removed, and a\n> hand-written legacy root Rulesync only reads (such as `.claude/CLAUDE.md`)\n> is kept.\n> - A path a configured target writes in the same run (for example\n> `AGENTS.md` written by `codexcli` while `agentsmd` is retired) is kept.\n> - Retirement is idempotent, so an interrupted run can simply be repeated.\n> `--check` fails while retired outputs remain, and `--json` lists the\n> retired paths among the deletions.\n> - The option is CLI-only on purpose: retirement is a one-off migration step,\n> not a standing setting. It requires `--features` (without `*`), and cannot\n> be combined with `--targets` or `--watch`. A retired target may not also\n> appear in `targets`.\n> - Retirement is skipped with a warning in global mode, when the\n> configuration file declares targets the run does not cover, and when any\n> `.rulesync` source file could not be read.\n> - With the object form of `outputRoots`, keep an entry for the retired\n> target so its outputs can be located.\n\n> **Note on `--delete` and shared output directories:** Several targets write\n> into one directory on purpose — `.agents/agents/`, `.agents/skills/`, and the\n> rest of the cross-vendor roots. The orphan sweep runs only after every target\n> and every feature in the run has written, and it skips any path the run itself\n> produced, so one target never deletes a sibling\'s freshly written file and an\n> already-synchronized tree stays a no-op under `--check`. What is swept is\n> unchanged: a file in a generated directory that no `.rulesync/` source\n> produces.\n>\n> Takt\'s skills are the one target swept by file name rather than by directory\n> (its rules, commands, and subagents are swept normally). They are flat files\n> sharing a single `.takt/facets/knowledge/` root rather than each getting a\n> directory of its own, so what the sweep removes there is a `.md` file directly\n> under that root which no `.rulesync/skills/` source produces. The root itself\n> and anything nested inside it are left alone. See [Takt](../tools/takt.md).\n>\n> Skill directories are swept from the inside as well, not only as whole\n> directories. Deleting a supporting file from `.rulesync/skills//` while\n> keeping the skill removes the generated copy on the next `generate --delete`,\n> and `generate --check` reports such a leftover as out of date rather than\n> passing — which matters most in a pre-commit hook, where an incremental\n> generate would otherwise commit a stale copy of instructions an agent still\n> reads. Two kinds of file inside a generated skill directory are never swept:\n> **hidden entries** and **symbolic links**. A symbolic link is never\n> Rulesync\'s, since the writer only ever creates real files. A hidden file may\n> be — a hidden supporting file such as a `.env.example` is carried like any\n> other — but a hidden name is also where your own files live (a `.gitkeep`, a\n> `.env` with real values in it), and the sweep cannot tell the two apart, so a\n> stale hidden supporting file is the one leftover it knowingly keeps: delete\n> it by hand. A generated skill\n> directory that is itself a symbolic link — to a vendored checkout, say — is\n> not swept from the inside either: what it reads back was never Rulesync\'s\n> output, and the run says so with a warning. Only files are swept, so a\n> directory left empty by the sweep stays, and a run that could not read every\n> supporting file its sources carry — one it lacked permission to open, or a\n> tree past the limits a skill directory is walked to, each reported as a\n> warning — sweeps no skill directory from the inside at all: with the source\n> only partly read, a generated file it cannot account for may be one it still\n> wants.\n\n> **Note on unreadable sources:** This applies to the single-file features —\n> `mcp`, `hooks`, `permissions`, and `ignore` — each of which is generated from\n> one `.rulesync/` file. When that file exists but cannot be read — malformed\n> JSON/JSONC, or content the schema rejects — the problem is reported as an\n> error, the feature produces no output, and `generate` exits non-zero, naming\n> the affected features. Every other feature in the run still executes first, so\n> one bad file does not hide the rest of the errors. Because that feature\'s\n> output could not be regenerated, `--delete` also skips its orphan sweep,\n> leaving the previously generated files in place rather than deleting\n> configuration the run was unable to rewrite. Under `--watch` the failure is\n> reported and the watcher keeps running, so saving a corrected source\n> regenerates as usual.\n>\n> A source file that is simply absent is not an error: that feature just has\n> nothing to generate, and the run succeeds — it is still logged, but it does\n> not fail the run. Only genuine absence counts; a path that cannot even be\n> checked (a permission error, a symlink loop, a symlink whose target is gone)\n> is treated as unreadable, not as missing.\n>\n> The directory-based features handle an unparseable source file differently,\n> and not uniformly. `subagents` and `checks` report it as a warning and skip\n> that file, because their directories hold free-form Markdown that users also\n> keep notes and READMEs in; the rest of the directory still generates and the\n> run succeeds. `rules`, `commands`, and `skills` do not: an invalid frontmatter\n> there aborts `generate` with that error and a non-zero exit, without the\n> per-feature isolation described above.\n>\n> A source entry that exists but cannot be _read_ is never treated as deleted\n> for any of them, warn-and-skip features included: a `.rulesync/` file or\n> directory whose symbolic link no longer resolves, or an input root that\n> cannot be resolved, stops the run rather than letting `--delete` sweep away\n> what it was supposed to generate.\n\n### Examples\n\n```bash\n# Generate all features for all configured tools\nrulesync generate\n\n# Generate rules for all tools\nrulesync generate --targets "*" --features rules\n\n# Generate from a shared source tree without cd-ing into it\nrulesync generate --input-roots ~/.aiglobal/.rulesync --targets "*" --features rules\n\n# Dry run: preview changes without writing\nrulesync generate --dry-run --targets claudecode --features rules\n\n# CI check: fail if generated files are not up to date\nrulesync generate --check --targets "*" --features "*"\n\n# Watch mode: regenerate on every change to the sources\nrulesync generate --watch\n```\n\n### Mutation plan in JSON output\n\nWith the global `--json` flag, `generate` reports the files it wrote and the orphans it deleted — or, under `--dry-run` and `--check`, would write and delete — as a versioned `plan`, next to the existing per-feature `features` summary:\n\n```json\n{\n "success": true,\n "command": "generate",\n "data": {\n "features": { "…": "…" },\n "plan": {\n "version": 1,\n "operations": [\n { "action": "write", "kind": "file", "feature": "rules", "path": "CLAUDE.md" },\n {\n "action": "delete",\n "kind": "file",\n "feature": "rules",\n "path": ".claude/rules/retired.md"\n },\n {\n "action": "delete",\n "kind": "directory",\n "feature": "skills",\n "path": ".claude/skills/retired"\n }\n ]\n }\n }\n}\n```\n\n- `action` is `write` or `delete`; `kind` is `file` or `directory`. A deleted directory is listed once, by its own path, together with everything under it. Deletions come from the `--delete` orphan sweep, so without `--delete` the plan holds writes only.\n- `path` is relative to the output root, with `/` separators, and the operation does not name the root. A path several targets write is listed once, and so is a relative path that stands for a file in each of several output roots (`--output-roots a b`, or a tool home override such as `HERMES_HOME`): run one output root at a time when a consumer has to tell them apart.\n- The order is deterministic — features in summary order, writes before deletes within a feature, paths sorted — so a `--dry-run` plan can be compared operation by operation with the plan the following real run reports.\n- A failing command\'s document carries no `data`, so when `--check` finds the tree out of date, or a `.rulesync/` source could not be read, the plan is reported as `error.details.plan` instead.\n- A shared configuration file Rulesync merges into (`.claude/settings.json`, `.codex/config.toml`, …) appears as a `write` of the whole file, even when only Rulesync-managed keys change. Two rewrites the `--delete` sweep itself makes are not listed at all: disabling the Hermes Agent commands plugin in its `config.yaml` and retracting Goose slash commands — so `--check` can fail with an empty `operations` list when one of them is the only difference. `version` is bumped whenever an operation gains a new `action` or `kind` value or an existing field changes meaning, so a consumer that authorizes operations should refuse a version it does not know.\n\n### Watch mode\n\n`generate --watch` runs one generation immediately and then keeps running, regenerating whenever the rulesync sources change. It is meant for iterating on rules, commands, subagents or skills without re-running the command by hand.\n\n- **What is watched**: within each input root (`.rulesync/` by default, or every `inputRoots` entry), only the top-level entries `generate` reads: the `rules/`, `commands/`, `subagents/`, `skills/` and `checks/` directories (recursively), the `mcp`, `hooks` and `permissions` files in both their `.jsonc` and legacy `.json` spellings (plus the deprecated `.mcp.json`), and `.aiignore`. Everything else under the root is pruned, so churn in unrelated siblings such as `.git/` or `node_modules/` never triggers a regeneration even when the root is the project directory itself. Symbolic links inside a root are followed, so a linked `rules/` directory reports edits to its targets. The configuration files next to the source tree (`rulesync.jsonc` and `rulesync.local.jsonc`, or the file passed to `--config`) are watched as well. Generated output is never watched, so a regeneration cannot re-trigger the watcher.\n- **Debouncing**: bursts of file-system events (editor save storms, `git checkout` switching many files) are coalesced into a single regeneration after a short quiet period. Changes that arrive while a generation is running trigger exactly one follow-up run.\n- **Errors keep the watcher alive**: a failing generation (e.g. invalid frontmatter saved mid-edit) is reported and watching continues; the process does not exit.\n- **Configuration changes**: editing the configuration file triggers a regeneration, and the new values apply to it because the configuration is re-resolved on every run. The **set of watched paths is fixed at startup**, so changing `inputRoot`/`inputRoots` (or the location of the configuration file itself) requires restarting the command. A warning is printed whenever the configuration file changes as a reminder.\n- **Incompatible flags**: `--watch` cannot be combined with `--check`, `--dry-run` or `--json`. The first two are one-shot verification modes and `--json` emits a single result document when the command exits, which never happens while watching.\n- **Stopping**: `Ctrl+C` (`SIGINT`) or `SIGTERM` closes the watchers and exits normally.\n\n### Tool home overrides win over the output root in global scope\n\nThree tools read their profile location from an environment variable: Hermes Agent (`HERMES_HOME`), Kimi Code (`KIMI_CODE_HOME`), and deepagents-cli (`DEEPAGENTS_HOME`). When one of them is set, `generate --global` and `convert --global` write that tool\'s output under it, **overriding both `outputRoots` and an explicit `--output-roots`** for that target. See [Supported Tools](./supported-tools.md) for what each profile root contains. This is deliberate — the variable names where the tool itself looks, so honoring the flag instead would produce files the tool never reads. Every other target still uses the configured output root.\n\nThe override must be a usable directory: an empty value is ignored (the default profile location applies), and a value that is the filesystem root or an unnormalized path is rejected with an error naming the variable. `DEEPAGENTS_HOME` is additionally held to the spellings dcode itself accepts — an absolute path, or one beginning with `~/` (expanded against the home directory) — so a relative value, a bare `~`, or a `~user` form is rejected rather than resolved against the working directory, since dcode would refuse to start with it. The home directory itself (`DEEPAGENTS_HOME=~/`) is rejected for the same reason: a profile root owns everything beneath it, so that spelling would scatter the profile across your home.\n\n### Shared config files are never created empty\n\nSome outputs are files Rulesync merges into rather than owns, because the tool (or you) keeps unrelated settings there: `.amp/settings.json(c)`, `.antigravity/settings.json`, `.claude/settings.json`, `.claude/settings.local.json`, `.codex/config.toml`, `.copilot/settings.json`, `.devin/config.json`, `.factory/settings.json`, `.github/copilot/settings.json`, `.grok/config.toml`, `.grok/sandbox.toml`, `.vibe/config.toml`, `.vscode/settings.json`, `.zcode/config.json`, `.zcode/cli/config.json`, `.zed/settings.json`, `kilo.json(c)`, `.mimocode/mimocode.json(c)`, `opencode.json(c)`, and `reasonix.toml`. These are deliberately **not** added to `.gitignore` by `rulesync gitignore`, so that settings you hand-author in them stay version-controlled.\n\nBecause they stay committable, `generate` will not **create** one of them just to hold an empty payload: if Rulesync has nothing to contribute (e.g. no permissions map to that tool), the file is left absent instead of being written as `{}`. A file that already exists is always rewritten as usual, so nothing you authored is dropped. Every other generated file is written even when empty, since for a file Rulesync owns its existence is part of the output.\n\n### Comments in shared JSONC files are preserved\n\nSeveral of those shared files are JSONC rather than JSON, because the tools themselves put comments in them — VS Code\'s own "MCP: Add Server" scaffold opens `.vscode/mcp.json` with a comment line. `.vscode/settings.json`, `.vscode/mcp.json`, `.amp/settings.json`, `opencode.json(c)` and `kilo.json(c)` (with their global counterparts) are therefore written back as **edits to the existing text**: only the spans whose values actually changed are rewritten, so the comments, blank lines, and key order everywhere else in the file survive a regenerate, and a `generate` that computes the same content it wrote last time leaves the file byte-identical. A key that Rulesync drops takes its own line with it, but a comment on the line above it stays, because Rulesync cannot tell whether that note described the key or the file around it. A note written after a key on the same line belongs to that key: it is removed with the key, and it stays put when Rulesync adds a new key after it. New keys are appended at the end of the object that holds them, and the region the insert touches is re-indented to the file\'s own indentation — so an object written on a single line is expanded to one key per line when Rulesync adds to it.\n\nRulesync falls back to rewriting the whole document when there is nothing to preserve, nothing to edit against, or no way to edit safely: an empty file; a file that does not parse, a byte-order mark included (for `.vscode/settings.json`, `.vscode/mcp.json` and `.amp/settings.json` an unparsable file stops the command instead, rather than have Rulesync overwrite settings it could not read); a file whose root is not an object; a file using `__proto__`, `constructor` or `prototype` as a key (those keys are dropped from every document Rulesync parses, so they are removed rather than left behind); a file that states the same key twice, where an edit would land on the copy the tool itself ignores; a file so large, with so much of it changing, that editing it key by key would take longer than a rewrite is worth, whether the size is the file Rulesync starts from or the document it is taking (a machine-generated file with thousands of entries, all of them replaced, or a small file handed thousands of new ones); a file whose changed keys write more than about 200 KB of new text between them, indentation included, which costs more to re-indent than to write out; and a file the editor itself refuses, which it reports as an error rather than a result. Except for the empty file, every such whole-document rewrite is reported as a warning naming the file and the reason (once per file and reason per run), so a comment that goes missing is announced by the `generate` that dropped it rather than discovered in the next diff. Files in the other formats — JSON, YAML, TOML — are re-serialized as before, so comments in `.codex/config.toml` or `reasonix.toml` are still not retained.\n\n## Gitignore Command\n\nThe `gitignore` command adds generated AI tool configuration files to `.gitignore`. By default, it emits entries only for the tools listed in the `targets` of your `rulesync.jsonc` (controlled by the `gitignoreTargetsOnly` option, which defaults to `true`). Set `gitignoreTargetsOnly` to `false` to emit entries for all supported tools instead. You can also filter the output per-invocation with `--targets` / `--features`, which take precedence over the config.\n\nYou can route entries to `.gitattributes` instead by setting `gitignoreDestination` to `"gitattributes"` at root, tool, or tool × feature level. More specific settings take precedence.\n\n> **No `rulesync.jsonc` in the project?** Entries for all supported tools are emitted. `gitignoreTargetsOnly` is only applied when a config file exists, so users without a config still get useful `.gitignore` coverage.\n\n> **`agentsmd` entries are always included.** Even when `gitignoreTargetsOnly` is `true` and `agentsmd` is not listed in `targets`, entries for `AGENTS.md` (and related paths) are appended automatically. Because `AGENTS.md` is a de facto standard file read by many AI tools regardless of the target set, its gitignore entries are emitted unconditionally to prevent accidental commits of generated rule files. To opt out of this behavior, pass an explicit `--targets` option that omits `agentsmd`.\n\n### Options\n\n| Option | Description | Default |\n| --------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- |\n| `--targets, -t ` | Comma-separated list of tools to include (e.g., `claudecode,copilot` or `*` for all) | Derived from `targets` / `gitignoreTargetsOnly` |\n| `--features, -f ` | Comma-separated list of features to include (rules, commands, subagents, skills, ignore, mcp, hooks, checks) | `*` (all) |\n\n### Examples\n\n```bash\n# Add all entries (default)\nrulesync gitignore\n\n# Add entries for Claude Code only\nrulesync gitignore --targets claudecode\n\n# Add entries for multiple tools\nrulesync gitignore --targets claudecode,copilot,cursor\n\n# Add only rules and commands entries for Copilot\nrulesync gitignore --targets copilot --features rules,commands\n```\n\n### Behavior\n\n- **Common entries** (e.g., `.rulesync/rules/.curated/`, `.rulesync/skills/.curated/`, `rulesync.local.jsonc`) are always included regardless of filters.\n- **General entries** (e.g., memories, settings) are always included when their target is selected.\n- When re-running, all previously generated rulesync entries are removed before writing the new filtered set.\n\n## Add Command\n\nThe `add` command can scaffold one Rulesync feature file or append one declarative source to `rulesync.jsonc`.\n\n### Feature scaffolding\n\nUse a feature keyword to create a valid, editable starter file:\n\n```bash\n# Named Markdown features\nrulesync add rule --name overview\nrulesync add command --name review-pr.md\nrulesync add subagent --name planner\nrulesync add skill --name project-context\nrulesync add check --name security\n\n# Singleton features\nrulesync add mcp\nrulesync add hooks\nrulesync add permissions\n\n# Deprecated compatibility scaffold; prefer permissions\nrulesync add ignore\n```\n\nNamed features accept a name with or without the `.md` suffix. Skills use the directory layout `.rulesync/skills//SKILL.md`; the other named features create `.md` in their canonical Rulesync directory. Names cannot contain path separators.\n\nWhen the target file exists, interactive execution asks before replacing it. Declining leaves the file unchanged. JSON, silent, and non-interactive execution fail safely; pass `--force` to overwrite explicitly. Singleton scaffolds recognize supported JSONC and legacy variants and replace the effective existing file instead of creating a shadowed canonical file.\n\nFeature keywords are reserved when no source-specific option is present. To add a source whose identifier is also a feature keyword, provide a source option that makes the intent explicit, such as `rulesync add skill --transport npm`.\n\n### Declarative sources\n\nFor any other source identifier, `add` appends one source to `rulesync.jsonc` and immediately runs the declarative source resolver. It preserves JSONC comments, installs selected rules into `.rulesync/rules/.curated/`, installs selected skills into `.rulesync/skills/.curated/`, and updates `rulesync.lock` or `rulesync-npm.lock.json`.\n\n```bash\n# GitHub source (default transport)\nrulesync add anthropics/skills --skills skill-creator\n\n# Rules only; direct .md files are selected from rules/\nrulesync add acme/ai-standards --rules testing-guidelines,typescript-conventions\n\n# Rules and skills from separate paths in one source\nrulesync add acme/ai-assets --rules "*" --rules-path exports/rules --skills review-pr --path exports/skills\n\n# Any Git remote through the git CLI\nrulesync add https://example.com/team/skills.git --transport git --ref main --path skills\n\n# npm-compatible registry\nrulesync add @acme/skill-package --transport npm --registry https://registry.npmjs.org\n```\n\nThe selected configuration file must already exist. Run `rulesync init` first, or pass `--config `. Adding a source whose normalized source identity is already present fails instead of silently creating duplicate lockfile entries; edit the existing entry when changing its options.\n\nThe operation fetches only the source being added; existing declarations are not re-fetched. Existing sources must already be locked and installed, otherwise run `rulesync install` first. The operation is transactional: if the new source fails to install, Rulesync restores the manifest, source lockfiles, curated rules, and curated skills to their pre-command state.\n\n| Option | Description |\n| --------------------- | ------------------------------------------------------------------------------------------------- |\n| `--name ` | Name for a rule, command, subagent, skill, or check scaffold |\n| `--force` | Replace an existing scaffold file without prompting |\n| `--skills ` | Comma-separated skill names. `*` selects all skills. |\n| `--rules ` | Comma-separated rule names. Names may omit `.md`; `*` selects direct `.md` files under rulesPath. |\n| `--transport ` | `github` (default), `git`, or experimental `npm` |\n| `--ref ` | Git ref, npm version, or npm dist-tag |\n| `--path ` | Skills path within the source; defaults to `skills` |\n| `--rules-path ` | Rules path within the source; defaults to `rules` |\n| `--registry ` | npm-compatible registry URL |\n| `--token-env ` | Environment variable containing the npm registry token |\n| `--token ` | GitHub token for private repositories |\n| `--config ` | Configuration file to edit (default: `rulesync.jsonc`) |\n\nWhen neither `--skills` nor `--rules` is provided, all skills are installed for backward compatibility. Providing only `--rules` installs no skills.\n\n## Fetch Command\n\nThe `fetch` command allows you to fetch configuration files directly from a Git repository (GitHub/GitLab).\n\n> [!NOTE]\n> This feature is in development and may change in future releases.\n\n**Note:** The fetch command searches for feature directories (`rules/`, `commands/`, `skills/`, `subagents/`, etc.) directly at the specified path, without requiring a `.rulesync/` directory structure. This allows fetching from external repositories like `vercel-labs/agent-skills` or `anthropics/skills`.\n\n### Source Formats\n\n```bash\n# Full URL format\nrulesync fetch https://github.com/owner/repo\nrulesync fetch https://github.com/owner/repo/tree/branch\nrulesync fetch https://github.com/owner/repo/tree/branch/path/to/subdir\nrulesync fetch https://gitlab.com/owner/repo # GitLab (planned)\n\n# Prefix format\nrulesync fetch github:owner/repo\nrulesync fetch gitlab:owner/repo # GitLab (planned)\n\n# Shorthand format (defaults to GitHub)\nrulesync fetch owner/repo\nrulesync fetch owner/repo@ref # Specify branch/tag/commit\nrulesync fetch owner/repo:path # Specify subdirectory\nrulesync fetch owner/repo@ref:path # Both ref and path\n```\n\n### Options\n\n| Option | Description | Default |\n| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |\n| `--target, -t ` | Target format to interpret files as (e.g., \'rulesync\', \'claudecode\') | `rulesync` |\n| `--features ` | Comma-separated features to fetch (rules, commands, subagents, skills, ignore, mcp, hooks, permissions, checks) | `skills` |\n| `--output ` | Output directory relative to project root | `.rulesync` |\n| `--conflict ` | Conflict resolution: `overwrite` or `skip` | `overwrite` |\n| `--no-prune` | Keep local files inside a fetched skill directory that the remote skill no longer has | Pruning is on |\n| `--ref ` | Git ref (branch/tag/commit) to fetch from | Default branch |\n| `--path ` | Subdirectory in the repository | `.` (root) |\n| `--skills ` | Comma-separated skill names to fetch (requires the skills feature) | All skills |\n| `--interactive, -i` | Interactively select skills to fetch via a checkbox prompt; nothing is selected initially, press `` to select/deselect all (requires the skills feature and a TTY) | Disabled |\n| `--token ` | Git provider token for private repositories | `GITHUB_TOKEN` or `GH_TOKEN` env |\n\n### Executable Bits\n\nSkills can ship scripts that agents are expected to run. The GitHub contents API that `fetch` downloads from carries no file mode, so when a run fetches a skill\'s supporting files `fetch` reads the repository tree once and marks every such file that git records as executable (`100755`) with mode `0755` after writing it (a rules-only fetch makes no tree request); `rulesync generate` then carries that bit into each generated skill directory. Two warnings cover the cases where this cannot be done: a tree listing GitHub cut short (very large repositories), and a tree that could not be read at all. In both cases the files are still fetched, without the bit, and the warning names the file modes as what was lost so you can `chmod` them yourself.\n\n### Pruning Fetched Skill Directories\n\nA skill is a directory (`skills//SKILL.md` plus its supporting files), not a single file. When the upstream skill drops or renames a file, an additive fetch would leave the old local copy in place, and the directory would become a mixture of the current upstream files and orphaned leftovers. Agents read whatever is in the directory, so a stale reference or an outdated script keeps steering them long after upstream removed it.\n\n**The remote skill is therefore the source of truth: by default, `fetch` deletes files inside the skill directories it fetched that the remote does not have.** Every deletion is listed in the summary, so the destructive part of the command is never silent:\n\n```text\nFetched from anthropics/skills@main:\n ✓ skills/pdf/SKILL.md (overwritten)\n ✗ skills/pdf/reference.md (deleted - no longer in the remote skill)\n\nSummary: 1 overwritten, 1 deleted\n```\n\nRulesync also warns separately whenever a run deleted anything, so the one part of the command that cannot be undone is not left to be spotted among the rest of the summary.\n\n> [!WARNING]\n> Pruning removes **any** file in a fetched skill directory that the remote does not have — including one you added yourself. That is what "mirror the remote" means. Keep your own material outside the skill directories you fetch, or pass `--no-prune`.\n>\n> This applies to whatever `--output` points at. `--output .` makes the fetched skill directories your project\'s own `skills/`, so a fetch there prunes the skills you maintain by hand. Fetch into the default `.rulesync/` unless you really mean to mirror a remote repository into your project root.\n\nThe scope is deliberately narrow:\n\n- Only the `skills//` directories fetched in **this** run are pruned. A skill left out by `--skills` or `--interactive` is untouched.\n- Other features (`rules/`, `commands/`, `subagents/`, …) are never pruned — the issue only exists for directory-based skills.\n- Directories the remote skill no longer has are removed as well, and are listed in the summary under their own name with a trailing slash.\n- Symbolic links are unlinked, never followed: a link inside a skill directory can only ever lose the link itself, never whatever it points at. A skill directory that is **itself** a symbolic link is not pruned at all, since deleting through it would reach outside the output directory; Rulesync warns and leaves it alone.\n- A local file that the filesystem holds as the same file as one just fetched — a second name for it on a case-insensitive or Unicode-normalizing filesystem, or a hard link — is kept, even though the remote list does not carry that name. The same applies to a symbolic link that resolves to a file or directory this run wrote: it is kept rather than unlinked, since the fetch may have written through it.\n- A skill directory Rulesync cannot read or delete from — a permission it does not hold, a disk that gave out — stops that skill\'s prune where it failed rather than the whole fetch. Rulesync warns, still lists whatever it had already deleted, and moves on to the next skill.\n- Nothing more than 15 directories below the skill directory is pruned. That is a limit on the local walk, deep enough that a fetched tree stays well inside it; Rulesync warns and leaves anything deeper alone.\n- A skill directory whose name ends in a dot or an ASCII space, whose name has the `NAME~1` shape of a Windows short name, or whose name is a Windows reserved device name such as `NUL` or `COM1`, is never fetched in the first place (see [Skill Names That Look Alike](#skill-names-that-look-alike)), so there is nothing of it to prune. A local directory of such a name is left exactly as it was. A file _inside_ a skill whose own path has such a segment is skipped on its own (see [Remote Paths Windows Reads Differently](#remote-paths-windows-reads-differently)), and the skill it belongs to is then not pruned in that run.\n- A skill directory whose name differs from another in `skills/` — one that was already there, or one this same fetch just wrote — only in ways some filesystems ignore — its case, or whether an accented letter is written composed or decomposed — is not pruned either, for the same reason: macOS and Windows resolve `skills/PDF` to an existing `skills/pdf`, and macOS resolves a decomposed name to the composed directory of the same name, so pruning it would judge the local skill\'s own files stale.\n- A skill whose remote listing came back incomplete — GitHub caps a directory listing at 1,000 entries, and entries such as symlinks and submodules cannot be fetched — is not pruned either. Rulesync warns instead, because a local file that upstream still ships cannot be told apart from one it dropped.\n- `--conflict skip` disables pruning. That flag says to leave existing local files alone, and it also means the local copies are not this run\'s output, so they cannot be judged against the remote list.\n- `--target ` never prunes, because that conversion path does not fetch skills at all.\n\nPass `--no-prune` to get the old purely additive behavior.\n\n#### Remote Paths Windows Reads Differently\n\nA backslash is an ordinary character in a filename on Linux and macOS, and a directory separator on Windows. A colon is ordinary too here, and on Windows it separates a file from one of its alternate data streams, so `skills/pdf::$INDEX_ALLOCATION` is another way of writing `skills/pdf` rather than a directory of its own. A remote file whose path contains either character therefore names one file on some systems and something else on others, so `fetch` skips it and warns rather than picking an interpretation. The rest of the fetch continues normally.\n\nThe same applies, segment by segment, to every path `fetch` writes — a rules file, a directory inside a skill, the file at the end of the path — when a segment is one Windows resolves to a different name: a segment ending in a dot or an ASCII space, which Windows strips, so `skills/deploy/references/notes.` is the existing `notes` there; one shaped like a `NAME~1` 8.3 short name, such as `scripts~1/run.sh`, which opens whatever long name it stands for; or a reserved device name — `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, `CONOUT$`, `COM1`–`COM9`, `LPT1`–`LPT9`, in any case and with any extension, so `nul.md` and `con.sh` count, as do `nul .md` with a space before the extension and `com¹` with a superscript digit, both of which Windows reads as the device — which is not a file there at all but the device itself. Such a file would land beside, or on top of, a sibling the repository never published, so it is skipped with a warning that quotes the segment as it is (a trailing space would not show otherwise), on every platform and not only on Windows, because the same output directory may be checked out on several. A skill directory of such a name is handled one level up, by dropping the whole skill (see [Skill Names That Look Alike](#skill-names-that-look-alike)).\n\nBecause the skipped file is still part of the remote skill, the skill directory it came from is not pruned in that run either — a local copy of a file the remote still ships would otherwise be indistinguishable from one it dropped.\n\n#### Skill Names That Look Alike\n\nA repository can publish two skill directories whose names a terminal draws the same way — `skill` spelled with a Cyrillic `ѕ`, or the fullwidth `skill` beside the plain one. Each is still a separate entry with its own name, so a selection writes exactly the directories that were checked; the risk is only that an entry cannot be told apart by sight from what it appears to be — another entry, a skill you already have, or the plainer, shorter name its own row reads as.\n\nThe interactive prompt therefore prefixes such an entry with `[!]` and the reason, ahead of the name itself:\n\n```text\n? Select skills to fetch (press to select/deselect all)\n ◯ pdf\n ◯ [!] another entry differs from it only by lookalike letters — skill\n ◯ [!] another entry differs from it only by lookalike letters; mi… — ѕkill\n```\n\nThe mark comes first so that a name — which the remote repository chooses — cannot be spelled to look like a mark of its own, or reorder one away. A label wider than one line is shortened with an ellipsis for the same reason; the budget is measured in terminal columns, so a name of ideographic spaces cannot buy extra width by being few characters, and it is taken from the terminal the prompt is drawn in — the window\'s own width, less the five columns the pointer and the checkbox can take between them, and never more than 72 — so a pane split in half shortens the labels along with it. A terminal that does not say how wide it is counts as whatever the `CLI_WIDTH` environment variable says, and as 80 columns when that is unset too, the same fallback the prompt uses when it wraps the rows, and no terminal shortens a label below 16: past that a row would be an ellipsis and little else, and rows that cannot be told apart at all are worse than a row that wraps. The name is measured first and the reasons take the room that is left, which is why the second label above is cut: a cut takes the reasons from the tail, and the mark and the start of the first reason always survive. Two entries whose labels read alike are numbered — `(1) `, `(2) `, in front for the same reason the mark is — so they stay distinct. That covers labels shortened into the same text, and equally `git` beside `ɡit`, where both rows carry the same note and the names are one shape: the numbers do not say which row is which, but they do say there are two of them, and the value behind a label is untouched, so a shortened entry still selects the skill it names. A name that itself begins the way a marked row does is given a mark of its own saying so — judged by the shape it is drawn in rather than the characters it is spelled with, so `(l)` and a `[ǃ]` written with U+01C3 LATIN LETTER RETROFLEX CLICK are marked alongside the plain `(1)` and `[!]`.\n\nAn entry is marked for any of seven reasons:\n\n- **The name carries more whitespace than the row shows.** This reason is written first: the picker cuts an overlong note from its tail, and every other reason says the row may be taken for another one, which can still be checked by eye, while this one says the row itself reaches further than it shows. A run of whitespace is drawn as one gap however long it is, and whitespace at either end of a name is drawn as nothing at all, so ` pdf` and `pdf reader` reach past what can be seen of them. Both halves of a pair like `pdf` and ` pdf` are already marked as sharing a display form; this reason is what marks the padded name when it is alone on the list. A single blank inside a name that is merely not the plain space is not marked here: it is drawn, so it is a question of which character was chosen rather than of how far the name reaches, and the reason below answers that one. At the start it is marked like any other blank, since there the question is not which character was chosen but that the name reaches past where it appears to end. At the end the same holds for every blank but the ASCII space: a name ending in that, or in a dot, is a name Windows folds onto another, and is dropped rather than marked (see below).\n- **The name carries a mark drawn over the character before it.** An enclosing mark — the box of a keycap, U+20E3, or an enclosing circle, U+20DD — is drawn over the character in front of it and takes no column of its own, so `pdf` with one after it occupies the three columns of `pdf` and is a fourth directory underneath. It is drawn, so the name is offered rather than dropped, and marked whether or not a plain `pdf` is on the list. The one place such a mark belongs is an emoji keycap — `1️⃣`, spelled as UTS #51 spells it — which is not marked; every other enclosing mark is. Only enclosing marks count: the combining accents and vowel signs Vietnamese, Devanagari and Arabic are written with are ordinary and are left alone. Listed second, after the whitespace reason, for the same reason that one is first.\n- **The name carries a character drawn as a plain space.** A blank that a terminal draws in one column, exactly as it draws the plain space, without being one: U+00A0 NO-BREAK SPACE, U+1680 OGHAM SPACE MARK, the U+2000–U+200A quads and spaces, U+202F NARROW NO-BREAK SPACE and U+205F MEDIUM MATHEMATICAL SPACE. `pdf` and `reader` joined by one of those reads on screen as exactly `pdf reader`, and because names are compared with their whitespace collapsed and normalized, the pair is already reported when the plain spelling is on the list — this reason is what marks the substitution when it is alone there, the same case the lookalike tables cover for U+2010 HYPHEN. U+3000 IDEOGRAPHIC SPACE is not marked: it is two columns wide, so the row it is printed on tells it apart, and an ordinary `設定 ガイド` written with it is left alone. Listed third, after the mark reason, for the same reason those two come first: it says what the row itself is drawn as, whatever else is on the list.\n- **Another entry has the same display form.** Names are compared with their hidden characters removed, normalized (NFKC), their whitespace collapsed, and lowercased, so `Skill`, `skill`, ` skill` and the fullwidth `skill` all collide.\n- **Another entry differs from it only by lookalike letters.** Two names that read the same once each character is replaced by the Latin letter it is drawn as — `copy` beside the same word spelled entirely in Cyrillic. A name does not have to leave the Latin alphabet to qualify: `c0py` with a zero, `ruIes` with a capital I for the l, `Ⅰist` with the Roman numeral one, and `git` with the script `ɡ` or the dotless `ı` are all marked against the plain spelling. Two letters drawn as one count as well — `forrnat` for `format`, `revievv` for `review` — which no table of single characters can see. `cl` for `d` is left out of that short list on purpose: it opens too many ordinary words, and folding it would report `clone` against a `done` you happen to have. The separator counts too, since nearly every skill name is kebab-case and a name that swaps only its hyphen for U+2010 HYPHEN — drawn identically, belonging to no script, and left alone by the compatibility normalization — would otherwise pass every check as plain ASCII. Neither name mixes scripts on its own, so this pair is visible only by comparing the two.\n- **The name reads as Latin letters but is written in another script.** Every letter of it is drawn as a Latin one without being Latin, which is the whole-script confusable of UTS #39: `copy` spelled with four Cyrillic letters is marked even when no Latin `copy` is on the list. This check uses the narrower list of letters that are drawn as a _lowercase_ Latin letter while being lowercase themselves, so an ordinary Russian or Greek word — `текст`, `κατα` — is not marked: its letters are ones whose capitals resemble Latin capitals, which is not the same thing. Cherokee, Coptic, Lisu, Osage, Deseret and Tifinagh are taken whole instead of letter by letter — a name written in nothing but one of them is marked — since those alphabets are drawn in Latin letter shapes throughout. Canadian Aboriginal Syllabics and Vai are not: their letters are shapes of their own, so a name in either reads as nothing Latin, and only a mixture with Latin is marked.\n- **The name mixes scripts.** A single name built from scripts that share letter shapes, such as `good` with a Cyrillic `о`. The alphabets treated as lookalikes are Latin, Cyrillic, Greek, Armenian, Cherokee, Coptic, Lisu, Canadian Aboriginal Syllabics, Osage, Deseret, Vai and Tifinagh, which are among the ones UTS #39 records as confusable with each other. Japanese, Korean and Chinese names mix scripts by nature and routinely carry Latin, so those combinations are not marked, and neither is Latin beside a script that shares no shapes with it.\n\nThe display-form and lookalike reasons are asked of the skills already in the output directory as well as of the other entries, and say `a local skill` in place of `another entry` when that is where the twin is. Without that comparison a repository publishing only the imitation — `dep1oy` against a `deploy` you have had for months — is a single plain-ASCII name in one script with nothing on the list to compare it against, and every check stays quiet. A local skill that names the directory an entry would be written into is the skill that entry would refresh rather than one imitating it, so it is not a collision and is not marked; a second fetch of the same repository is therefore as quiet as the first. Where the two spellings differ only in case, or only in how the name is composed in Unicode, whether they name one directory is the filesystem\'s answer rather than the listing\'s — macOS and Windows resolve `skills/PDF` to an existing `skills/pdf`, Linux does not — so Rulesync asks it, and marks the pair only where they really are two directories — and only where the two also read alike, so that a local skill a _second_ entry imitates keeps its place in the comparison while the first entry refreshes it. A fullwidth `pdf` is a second directory everywhere and is always marked. Where both another entry and a local skill collide with a name, the entry is the one named, since that is the pair you can compare on screen.\n\nA `fetch` that shows no prompt — a plain one, or one selecting with `--skills` — prints the same reasons as a warning listing the names they apply to, so a scripted run is told what an interactive one would have been shown. The names are judged against everything the repository publishes and everything already in the output directory, as the prompt judges them, and listed for what the run actually writes: `--skills c0py` is told that the name reads like another entry even though the `copy` that makes it so was never fetched.\n\nThe mark is display-only: it never removes a skill from the list. It is also not a complete answer — the table of lookalike letters holds the common pairs rather than every one, and a name written entirely in a script the table does not map is compared against nothing — so treat it as a hint to look closer, not as a guarantee that unmarked entries are distinct.\n\nNames that cannot be shown honestly at all are a separate case: a skill directory whose name carries a control character, one that draws as nothing — a zero-width space, a Hangul filler, a braille blank — or one that draws as nothing but blank space or combining marks with no letter to sit on is dropped rather than marked, with a warning naming it in stripped form. So is a name that merely begins with a combining mark: the mark is drawn over whatever the terminal printed before the name — the checkbox, the quote around a name in a warning — and the name reads as the letters after it, which is what the warning shows. That drop applies to every `fetch`, including a plain one with neither `--skills` nor `--interactive`, because such a directory on disk cannot be told from the plain name it imitates in any line Rulesync prints.\n\nA name that Windows resolves to a different directory is dropped the same way, with a warning that quotes it as it is: a skill directory whose name ends in a dot or an ASCII space — Windows strips both when it opens a path, so `deploy.` is the existing `deploy` there; any other trailing blank it keeps, so a name ending in a no-break or an ideographic space is fetched and only marked — whose name has the `NAME~1` shape of an 8.3 short name, which on a volume that generates short names opens whatever long name it stands for, or whose name is a reserved device name — `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, `CONOUT$`, `COM1`–`COM9` or `LPT1`–`LPT9`, in any case and with any extension, so `nul.md` counts, and so do `nul .md` and `com¹` — which cannot be a directory there at all. Such a name reads as one directory and is written into another, so with the default `--conflict overwrite` it would replace the files of a local skill the repository never published. The drop applies on every platform, not only on Windows, because the same output directory may be checked out on several; a repository that ships `deploy.` and `deploy` side by side loses only the first. The shape only counts at the end of a name, and the reservation only on the whole name before its first dot (spaces before that dot dropped first), so `data~2parser`, `v1.0`, `console` and `com10` are ordinary names and are fetched. The segments below the skill directory are held to the same shapes file by file (see [Remote Paths Windows Reads Differently](#remote-paths-windows-reads-differently)).\n\nA zero-width joiner or a variation selector is dropped only where it hides something. It is kept beside the scripts that are written with one — the Arabic family, the Indic scripts, Mongolian — and beside the pictographs an emoji sequence is built from; anywhere else it can be nothing but padding, so `pdf` with a joiner in it is dropped, and so is `設定` with one between its two characters. Standing where its own script puts it — a Persian or Indic name written with a zero-width non-joiner, an emoji name built from a chain of joiners — it is left alone and the name is fetched normally. A joiner is held to both of its neighbors, since it exists to bind two characters: a name that merely ends in one is a second directory drawn exactly like the name beside it, and is dropped. A variation selector is held only to the character before it, which is the one whose form it selects, so an emoji name may end in one. The keycap sequences — `1️⃣`, `#️⃣`, `*️⃣`, spelled as UTS #51 spells them: one of `0`–`9`, `#` or `*`, then U+FE0F, then U+20E3 — are matched whole, since what they are built on is a digit or an ASCII sign rather than a pictograph. Only the whole sequence is kept: a digit followed by a variation selector with no enclosing keycap behind it is padding still, and is dropped still.\n\n#### Remote Paths in Rulesync\'s Output\n\nPath names come from the remote repository, so every one that Rulesync prints — the fetch summary, warnings, and debug lines — has its control characters stripped first. A crafted path cannot forge or erase the lines around it, which is what makes the record of what was written and deleted worth reading.\n\nThe `--json` output is the exception: it carries each path exactly as the repository spells it, because a machine consumer needs the real name to act on it. Anything that renders a value out of that JSON into a terminal has to strip it itself.\n\n### Examples\n\n```bash\n# Fetch skills from external repositories\nrulesync fetch vercel-labs/agent-skills\nrulesync fetch anthropics/skills\n\n# Fetch only specific skills by name\nrulesync fetch anthropics/skills --skills pdf,docx\n\n# Interactively select which skills to fetch (checkbox prompt)\n# Nothing is checked when the prompt opens: press to select the\n# highlighted skill, to select/deselect all, to invert, and to confirm.\nrulesync fetch anthropics/skills --interactive\n\n# Interactively select skills with some pre-checked\nrulesync fetch anthropics/skills --interactive --skills pdf\n\n# Fetch all features from a public repository\nrulesync fetch dyoshikawa/rulesync --path .rulesync --features "*"\n\n# Fetch only rules and commands from a specific tag\nrulesync fetch owner/repo@v1.0.0 --features rules,commands\n\n# Fetch from a private repository (uses GITHUB_TOKEN env var)\nexport GITHUB_TOKEN=ghp_xxxx\nrulesync fetch owner/private-repo\n\n# Or use GitHub CLI to get the token\nGITHUB_TOKEN=$(gh auth token) rulesync fetch owner/private-repo\n\n# Preserve existing files (skip conflicts)\nrulesync fetch owner/repo --conflict skip\n\n# Keep local files a fetched skill no longer has upstream\nrulesync fetch anthropics/skills --no-prune\n\n# Fetch from a monorepo subdirectory\nrulesync fetch owner/repo:packages/my-package\n```\n\n## Convert Command\n\nThe `convert` command converts configuration files from one AI tool directly to one or more destination tools **without creating `.rulesync/` files on disk**. The intermediate rulesync representation is kept in memory only.\n\nThis is useful when you want to translate a one-shot tool-to-tool conversion (e.g., "I have Cursor rules, give me Claude Code and Copilot equivalents") without adopting rulesync\'s managed source-of-truth workflow.\n\n### Options\n\n| Option | Description | Default |\n| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------- |\n| `--from ` | Source tool to convert from (single tool, e.g., `cursor`, `claudecode`) | Required |\n| `--to ` | Comma-separated list of destination tools (e.g., `copilot,claudecode`) | Required |\n| `--features, -f ` | Comma-separated list of features to convert (rules, commands, subagents, skills, ignore, mcp, hooks, permissions, checks) | `*` (all) |\n| `--verbose, -V` | Verbose output | `false` |\n| `--silent, -s` | Suppress all output | `false` |\n| `--global, -g` | Convert for global (user scope) configuration files | `false` |\n| `--dry-run` | Show changes without writing files | `false` |\n\n### Examples\n\n```bash\n# Convert Cursor rules to Copilot and Claude Code\nrulesync convert --from cursor --to copilot,claudecode --features rules\n\n# Convert all features Cursor and Copilot both support\nrulesync convert --from cursor --to copilot\n\n# Convert MCP configuration from Claude Code to Cursor\nrulesync convert --from claudecode --to cursor --features mcp\n\n# Dry run to preview the conversion\nrulesync convert --from cursor --to copilot,claudecode --dry-run\n```\n\n### Behavior\n\n- The intermediate rulesync files produced during conversion are **never** written to disk. Only destination tool files are written.\n- Features that exist for the source tool but are not supported by a given destination tool are skipped with a warning.\n- When `--features` is omitted, the command attempts every feature the source tool supports.\n- Passing the source tool inside `--to` is rejected, because converting a tool onto itself is lossy.\n- With `--dry-run`, no destination files are written; the command prints a summary prefixed with `[DRY RUN]` listing what would have been converted.\n\n## Doctor Command\n\nThe `doctor` command runs read-only diagnostics against the configuration files (`rulesync.jsonc` and `rulesync.local.jsonc`) and reports problems grouped by severity (`error` / `warning` / `info`). It never writes files, which makes it a safe first step when generation does not behave as expected, and a cheap CI guard.\n\nIt is especially useful for catching **silently ignored configuration**: the config schema is non-strict, so a misspelled key such as `"target"` instead of `"targets"` is normally swallowed without any error. `doctor` reports every unknown key with a "did you mean" suggestion.\n\n### Checks\n\n- JSONC parse errors, reported with line and column.\n- Unknown or misspelled top-level keys, with a "did you mean" suggestion.\n- Unknown tool targets and features (array and object forms), with the nearest valid name suggested.\n- Deprecated features (`ignore`, superseded by `permissions`).\n- Object-form `targets` combined with `features` — including the case where the conflict only appears after merging `rulesync.jsonc` with `rulesync.local.jsonc`.\n- Conflicting target pairs (e.g. `claudecode` + `claudecode-legacy`).\n- `$schema` presence and whether it points at the current config schema URL.\n- Structural schema violations on any other key (wrong types, malformed `sources` entries).\n- `sources[].tokenEnv` naming an environment variable that is not set.\n- `inputRoot` or the first `inputRoots` entry pointing at a directory that does not exist. Later `inputRoots` entries are optional overlays and may be absent.\n- `inputRoot` or an `inputRoots` entry set to an empty string, which makes `generate` fail with `outputRoot cannot be an empty string` before it resolves any source tree.\n- Duplicate entries in `inputRoots` (warning; duplicates are ignored at generate time).\n\n### Options\n\n| Option | Description | Default |\n| --------------------- | --------------------------------- | ---------------- |\n| `--config, -c ` | Path to configuration file | `rulesync.jsonc` |\n| `--strict` | Treat warnings as errors (exit 1) | `false` |\n| `--verbose, -V` | Verbose output | `false` |\n| `--silent, -s` | Suppress all output | `false` |\n\n### Examples\n\n```bash\n# Diagnose the project configuration\nrulesync doctor\n\n# Fail CI on warnings too\nrulesync doctor --strict\n\n# Machine-readable output for editors and CI\nrulesync --json doctor\n\n# Diagnose a configuration file at a custom location\nrulesync doctor --config ./configs/rulesync.jsonc\n```\n\n### Behavior\n\n- Exits with code `1` when any `error`-severity diagnostic is present (or any `warning` with `--strict`), and `0` otherwise.\n- With the global `--json` flag, diagnostics and a severity summary are emitted as structured JSON: in `data` on success (exit 0), and in `error.details` of the standard error document (code `DOCTOR_FAILED`) on failure.\n- A missing configuration file is reported as `info` only — rulesync runs fine with built-in defaults.\n\n## Docs Command\n\nThe `docs` command prints the bundled Rulesync documentation to standard output, so both humans and coding agents can retrieve it directly in the terminal without browsing the repository or website. The documentation is embedded in the CLI at build time, so it works in installed npm distributions and compiled binaries alike.\n\nDocument identifiers follow the `docs/` hierarchy without the `docs/` prefix or the `.md` extension (both are accepted and stripped when supplied). Identifiers that try to escape the bundled tree — absolute paths, drive letters, `..` segments — are rejected.\n\n### Usage\n\n```bash\n# List every available document identifier\nrulesync docs\n\n# Print a document (top-level or nested)\nrulesync docs faq\nrulesync docs guide/configuration\n\n# Ranked full-text search across the bundled documentation\nrulesync docs --search "global mode"\n```\n\n### Search\n\n`--search ` builds an in-memory BM25+ index (via MiniSearch) over document paths, titles, headings, and body content, with stronger boosts for titles and headings. Up to 10 results are printed, one per line, as ` — `. Matching is exact-term; no prefix or fuzzy expansion is applied.\n\n### Behavior\n\n- `rulesync docs` with no argument lists all document identifiers, one per line, sorted.\n- A missing document, an invalid identifier, an empty search text, a search with no matches, or combining a document argument with `--search` each exit with code 1 and an explanatory error.\n- Document output is printed verbatim, so it can be piped to other tools.\n- The global `--json` flag is not supported (the command\'s output is raw Markdown, not a JSON document) and exits with code 1.\n\n## Release Notes Command\n\nThe `release-notes` command prints GitHub release notes for any repository, so you can review what changed in an upstream AI coding tool — or in Rulesync itself — without leaving the terminal. Releases are fetched through the GitHub Releases API and rendered as Markdown on standard output, newest first.\n\nThe repository is given as `owner/repo` or a full `https://github.com/owner/repo` URL. Unlike `fetch`, ref (`@`) and path (`:`) suffixes are rejected — use `--tag` to select a single release. Only GitHub is supported; other Git providers have no equivalent Releases API.\n\n### Usage\n\n```bash\n# Latest 10 releases (default)\nrulesync release-notes dyoshikawa/rulesync\n\n# Most recent N releases\nrulesync release-notes dyoshikawa/rulesync --latest 5\n\n# Releases published within a date range (either end may be omitted)\nrulesync release-notes dyoshikawa/rulesync --since 2026-01-01 --until 2026-06-30\n\n# A single release by tag name\nrulesync release-notes dyoshikawa/rulesync --tag v16.11.0\n\n# Every release between two tags, inclusive\nrulesync release-notes dyoshikawa/rulesync --from v16.0.0 --to v16.11.0\n\n# Include prereleases\nrulesync release-notes dyoshikawa/rulesync --include-prereleases\n\n# Machine-readable output\nrulesync --json release-notes dyoshikawa/rulesync --latest 3\n```\n\n### Filtering\n\nThe four filtering modes — `--latest`, `--since`/`--until`, `--tag`, and `--from`/`--to` — are mutually exclusive; combining them exits with code 1. With no filter, the latest 10 releases are printed.\n\n| Option | Description |\n| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `--latest ` | Print the most recent `` releases. Must be a positive integer. |\n| `--since ` / `--until ` | Print releases published within the range, both ends inclusive. Either end may be omitted for an open-ended range. Dates are parsed as ISO 8601, e.g. `2026-01-31`; a bare date given to `--until` covers that whole day in UTC. |\n| `--tag ` | Print a single release by tag name. Named `--tag` rather than `--version` because `--version` is the global flag that prints the Rulesync version. |\n| `--from ` / `--to ` | Print every release between two tags, inclusive. Both are required. |\n| `--include-prereleases` | Include prereleases in the output. |\n| `--token ` | GitHub token for private repositories or higher rate limits. |\n\nTag ranges are resolved by position in the repository\'s release history, not by parsing semver, so non-semver tag names work and the order of `--from` and `--to` does not matter. A tag that does not appear in the history exits with code 1.\n\n### Authentication\n\nRequests are unauthenticated by default, which is enough for public repositories but subject to GitHub\'s stricter anonymous rate limit. Set `GITHUB_TOKEN` or `GH_TOKEN` (or pass `--token`) for private repositories and higher limits:\n\n```bash\nGITHUB_TOKEN=$(gh auth token) rulesync release-notes owner/private-repo\n```\n\n### Behavior\n\n- Draft releases are never printed: they are unpublished and only visible to accounts with write access.\n- Prereleases are excluded unless `--include-prereleases` is given, matching how GitHub itself resolves the "latest" release. `--tag` is the exception — an explicitly named tag is printed regardless of its prerelease status.\n- A repository with no matching releases prints a warning and exits with code `0`.\n- Range queries walk at most 10 API pages (1,000 releases); tags older than that are reported as not found.\n- Date ranges scan the whole walked history rather than stopping at the first out-of-range release, because the API orders releases by creation date and a release published from a long-lived branch can appear out of publication order.\n- Default output is Markdown on standard output, so it can be piped to other tools. With the global `--json` flag, the releases are emitted as structured `data` instead and no Markdown is printed; failures use the standard error document with code `RELEASE_NOTES_FAILED`.\n', + '# CLI Commands\n\n## Quick Commands\n\n```bash\n# Initialize new project (recommended: organized rules structure)\nrulesync init\n\n# Import existing configurations (to .rulesync/rules/ by default)\nrulesync import --targets claudecode --features rules,mcp,commands,subagents,skills,permissions\n\n# Import components from an existing plugin directory\nrulesync import --targets claudecode-plugin --features skills,hooks --output-root ./plugins/review-tools\n\n# Convert configurations from one tool to other tools (skips .rulesync/)\nrulesync convert --from cursor --to copilot,claudecode\nrulesync convert --from cursor --to copilot,claudecode --features rules,mcp\n\n# Fetch configurations from a Git repository\nrulesync fetch owner/repo\nrulesync fetch owner/repo@v1.0.0 --features rules,commands\nrulesync fetch https://github.com/owner/repo --conflict skip\n\n# Generate all features for all tools (new preferred syntax)\nrulesync generate --targets "*" --features "*"\n\n# Generate specific features for specific tools\nrulesync generate --targets copilot,cursor,cline --features rules,mcp\nrulesync generate --targets claudecode --features rules,subagents\n\n# Generate components inside an existing plugin directory\nrulesync generate --targets antigravity-plugin --features rules,mcp,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets augmentcode-plugin --features rules,mcp,commands,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets zcode-plugin --features mcp,commands,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets vibe-plugin --features mcp,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets devin-plugin --features rules,mcp,subagents,skills,hooks --output-roots ./plugins/review-tools\nrulesync generate --targets kimi-code-plugin --features rules,commands,subagents,skills --output-roots ./plugins/review-tools\n\n# Generate only rules (no MCP, permissions, commands, or subagents)\nrulesync generate --targets "*" --features rules\n\n# Generate simulated commands and subagents\nrulesync generate --targets copilot,cursor,codexcli --features commands,subagents --simulate-commands --simulate-subagents\n\n# Dry run: show changes without writing files\nrulesync generate --dry-run --targets claudecode --features rules\n\n# Check if files are up to date (for CI/CD pipelines)\nrulesync generate --check --targets "*" --features "*"\n\n# Generate from a shared source tree (without cd-ing into it)\nrulesync generate --input-roots ~/.aiglobal/.rulesync --targets "*" --features rules\n\n# Install rules and skills from declarative sources in rulesync.jsonc\nrulesync install\n\n# Add a source to rulesync.jsonc, update the lockfile, and install it\nrulesync add anthropics/skills --skills skill-creator\n\n# Add a rule source without selecting skills\nrulesync add acme/ai-standards --rules testing-guidelines\n\n# Force re-resolve all source refs (ignore lockfile)\nrulesync install --update\n\n# Fail if lockfile is missing or out of sync (for CI); fetch missing artifacts using locked refs\nrulesync install --frozen\n\n# Report sources whose lockfile entry is behind the remote, without writing anything\n# (exit 1 if any is behind or not locked, 2 if a source cannot be resolved)\nrulesync install --outdated\n\n# Install then generate (typical workflow)\nrulesync install && rulesync generate\n\n# Add generated files to .gitignore\nrulesync gitignore\n\n# Add only specific tool entries to .gitignore\nrulesync gitignore --targets claudecode,copilot\n\n# Add only specific feature entries to .gitignore\nrulesync gitignore --targets copilot --features rules,commands\n\n# Diagnose the configuration files for common problems (read-only)\nrulesync doctor\n\n# Diagnose and fail CI on warnings too\nrulesync doctor --strict\n\n# Print GitHub release notes for a repository (latest 10 by default)\nrulesync release-notes dyoshikawa/rulesync\n\n# Print the most recent 5 releases\nrulesync release-notes dyoshikawa/rulesync --latest 5\n\n# Update rulesync to the latest version (single-binary installs)\nrulesync update\n\n# Check for updates without installing\nrulesync update --check\n\n# Force update even if already at latest version\nrulesync update --force\n```\n\n> **Deprecated feature:** `ignore` remains available to existing projects throughout Rulesync 14.x, but new projects should use `permissions`. Any removal will be decided separately and will not occur before a future major release.\n\n## JSON Output\n\nThe global `--json` flag makes a command print a single result document and nothing else. Because that document is the whole of the output, warnings that would otherwise go to standard error are carried inside it, as a top-level `warnings` array of strings:\n\n```json\n{\n "success": true,\n "timestamp": "2025-01-01T00:00:00.000Z",\n "command": "import",\n "version": "x.y.z",\n "warnings": [".factory/settings.local.json is a machine-local overrides file …"],\n "data": { "…": "…" }\n}\n```\n\nThe key is omitted when nothing warned, and `--silent` suppresses warnings there as it does on the console. `warnings` sits beside `data` rather than inside it so a command\'s own captured keys can never collide with it, and it is reported on the failure document too, where a diagnostic about the input is often what explains the failure.\n\nAt most 100 warnings are reported, each truncated to 1,000 characters and 8,000 characters in total; a run that exceeds any of those limits says so in a final entry rather than growing the document without bound.\n\nBecause that budget is finite, the array carries the diagnostics a run has no other way to report — not a restatement of what `data` already holds. A command that lists something under a captured key writes the list there and warns only about what the list does not say.\n\n## Init Command\n\n`rulesync init` creates the `.rulesync/` directory with sample rule, MCP, subagent, skill, hooks and permissions files, plus a `rulesync.jsonc` configuration file. Existing files are never overwritten.\n\n### User-level defaults\n\nThe generated `rulesync.jsonc` starts from `"targets": ["codexcli", "claudecode", "opencode"]` and `"features": ["rules", "mcp", "subagents", "skills", "hooks", "permissions"]`. To seed every new project with your own set instead, put a `rulesync.jsonc` in the user config directory: `$XDG_CONFIG_HOME/rulesync/rulesync.jsonc`, which is `~/.config/rulesync/rulesync.jsonc` when `XDG_CONFIG_HOME` is unset (a relative `XDG_CONFIG_HOME` is ignored, as the XDG specification requires).\n\n```jsonc\n// ~/.config/rulesync/rulesync.jsonc\n{\n "targets": ["claudecode", "cursor"],\n "features": ["rules", "skills"],\n}\n```\n\n`init` copies only `targets` and `features` from that file; a key it omits keeps the built-in default, and the object form of `targets` is copied as-is (the top-level `features` key is then left out, since the per-target features live inside the object). Every other key is ignored: the file is a template for `init`, not a configuration layer — `generate` and the other commands read only the project\'s `rulesync.jsonc` and `rulesync.local.jsonc`. A user config that exists but fails to parse or schema validation (for example, an unknown target name in the array form of `targets`) makes `init` fail rather than silently writing the defaults. The command reports `Seeded targets and features from ` when the file was used (`seededFrom` in `--json` output).\n\n## Generate Command\n\nThe `generate` command reads source files from one or more rulesync source trees (default: `/.rulesync`; configurable via `--input-roots`) and writes AI tool configuration files to the output directories.\n\n### Options\n\n| Option | Description | Default |\n| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |\n| `--targets, -t ` | Comma-separated list of tools (e.g. `claudecode,copilot` or `*`) | From `rulesync.jsonc` |\n| `--features, -f ` | Comma-separated list of features (rules, commands, subagents, skills, mcp, hooks, permissions, checks; deprecated: ignore) | From `rulesync.jsonc` |\n| `--input-roots ` | Ordered list of rulesync source-tree directories (e.g. `.rulesync`, `.rulesync.local`). Each entry is a source tree itself — no `.rulesync/` join is applied. The first root is required; later roots are optional overlays and may be absent. Later entries override earlier ones for the same relative source path (currently `generate` only). Cannot be combined with `--input-root`. | `/.rulesync` |\n| `--input-root ` | **Deprecated.** Path to the PARENT directory of a `.rulesync/` source tree; kept for backward compatibility and expands internally to `--input-roots /.rulesync`. Prefer `--input-roots`. Cannot be combined with it. | CWD |\n| `--dry-run` | Show what would change without writing files | `false` |\n| `--check` | Like `--dry-run` but exits with code 1 if files are not up to date | `false` |\n| `--global` | Generate for global (user-scope) configuration files | `false` |\n| `--simulate-commands` | Generate simulated commands for tools that do not support them natively | `false` |\n| `--simulate-subagents` | Generate simulated subagents for tools that do not support them natively | `false` |\n| `--simulate-skills` | Generate simulated skills for tools that do not support them natively | `false` |\n| `--delete` | Delete existing generated files before writing | From `rulesync.jsonc` |\n| `--retire-targets ` | Comma-separated list of tools the project no longer uses; deletes the outputs Rulesync manages for them (requires `--features`; see below) | - |\n| `--watch, -w` | Keep running and regenerate whenever rulesync source files change | `false` |\n| `--config, -c ` | Path to the configuration file to load (`rulesync.local.jsonc` next to it still overlays it) | `rulesync.jsonc` |\n\n> **Note on `--check` and shared root files:** A root file that several targets\n> write, such as `AGENTS.md`, is compared against its owner\'s output. The owner\n> is the last target in `rulesync.jsonc` that generates rules, even when\n> `--targets` selects a different target, and its output is built as\n> `rulesync generate` with the same options but without `--targets` would\n> build it. See\n> [Target Order and File Conflicts](../guide/configuration.md#target-order-and-file-conflicts).\n\n> **Retiring a target:** Leaving a tool out of `targets` (or out of\n> `--targets`) never deletes its files — not even with `--delete` — because an\n> omitted target only means "not processed in this run". To remove the outputs\n> of a tool the project has dropped, name it and the features Rulesync\n> generated for it explicitly:\n>\n> ```bash\n> # rulesync.jsonc no longer lists cursor; Rulesync generated its rules and mcp\n> rulesync generate --retire-targets cursor --features rules,mcp --dry-run # preview\n> rulesync generate --retire-targets cursor --features rules,mcp\n> ```\n>\n> For each listed feature, Rulesync deletes the files it would sweep with\n> `--delete` for that tool — the same managed paths, nothing else. Note that\n> `--features` scopes the whole run, not only the retirement — except with the\n> object form of `targets`, where the run keeps each target\'s configured\n> features and `--features` only names what to retire. The listed features\n> apply to every retired tool, and a managed path is deleted even if you\n> edited it by hand, so preview with `--dry-run` first.\n>\n> - Unrelated files beside the managed paths stay, a shared file another tool\n> merges into (such as `.claude/settings.json`) is never removed, and a\n> hand-written legacy root Rulesync only reads (such as `.claude/CLAUDE.md`)\n> is kept.\n> - A path a configured target writes in the same run (for example\n> `AGENTS.md` written by `codexcli` while `agentsmd` is retired) is kept.\n> - Retirement is idempotent, so an interrupted run can simply be repeated.\n> `--check` fails while retired outputs remain, and `--json` lists the\n> retired paths among the deletions.\n> - The option is CLI-only on purpose: retirement is a one-off migration step,\n> not a standing setting. It requires `--features` (without `*`), and cannot\n> be combined with `--targets` or `--watch`. A retired target may not also\n> appear in `targets`.\n> - Retirement is skipped with a warning in global mode, when the\n> configuration file declares targets the run does not cover, and when any\n> `.rulesync` source file could not be read.\n> - With the object form of `outputRoots`, keep an entry for the retired\n> target so its outputs can be located.\n\n> **Note on `--delete` and shared output directories:** Several targets write\n> into one directory on purpose — `.agents/agents/`, `.agents/skills/`, and the\n> rest of the cross-vendor roots. The orphan sweep runs only after every target\n> and every feature in the run has written, and it skips any path the run itself\n> produced, so one target never deletes a sibling\'s freshly written file and an\n> already-synchronized tree stays a no-op under `--check`. What is swept is\n> unchanged: a file in a generated directory that no `.rulesync/` source\n> produces.\n>\n> Takt\'s skills are the one target swept by file name rather than by directory\n> (its rules, commands, and subagents are swept normally). They are flat files\n> sharing a single `.takt/facets/knowledge/` root rather than each getting a\n> directory of its own, so what the sweep removes there is a `.md` file directly\n> under that root which no `.rulesync/skills/` source produces. The root itself\n> and anything nested inside it are left alone. See [Takt](../tools/takt.md).\n>\n> Skill directories are swept from the inside as well, not only as whole\n> directories. Deleting a supporting file from `.rulesync/skills//` while\n> keeping the skill removes the generated copy on the next `generate --delete`,\n> and `generate --check` reports such a leftover as out of date rather than\n> passing — which matters most in a pre-commit hook, where an incremental\n> generate would otherwise commit a stale copy of instructions an agent still\n> reads. Two kinds of file inside a generated skill directory are never swept:\n> **hidden entries** and **symbolic links**. A symbolic link is never\n> Rulesync\'s, since the writer only ever creates real files. A hidden file may\n> be — a hidden supporting file such as a `.env.example` is carried like any\n> other — but a hidden name is also where your own files live (a `.gitkeep`, a\n> `.env` with real values in it), and the sweep cannot tell the two apart, so a\n> stale hidden supporting file is the one leftover it knowingly keeps: delete\n> it by hand. A generated skill\n> directory that is itself a symbolic link — to a vendored checkout, say — is\n> not swept from the inside either: what it reads back was never Rulesync\'s\n> output, and the run says so with a warning. Only files are swept, so a\n> directory left empty by the sweep stays, and a run that could not read every\n> supporting file its sources carry — one it lacked permission to open, or a\n> tree past the limits a skill directory is walked to, each reported as a\n> warning — sweeps no skill directory from the inside at all: with the source\n> only partly read, a generated file it cannot account for may be one it still\n> wants.\n>\n> An output directory reached through a symbolic link that leads outside the\n> output root — a checked-out `.cursor/commands -> ~/notes`, say — is not swept\n> either: what the sweep would list there are files in the link\'s target, not\n> Rulesync\'s output, so each one is kept and named in a `Refusing to delete`\n> warning, the same way a write through such a link is refused. A link that stays\n> inside the output root is followed as usual, and an orphan that is itself a\n> symbolic link is removed as a link, leaving its target untouched.\n\n> **Note on unreadable sources:** This applies to the single-file features —\n> `mcp`, `hooks`, `permissions`, and `ignore` — each of which is generated from\n> one `.rulesync/` file. When that file exists but cannot be read — malformed\n> JSON/JSONC, or content the schema rejects — the problem is reported as an\n> error, the feature produces no output, and `generate` exits non-zero, naming\n> the affected features. Every other feature in the run still executes first, so\n> one bad file does not hide the rest of the errors. Because that feature\'s\n> output could not be regenerated, `--delete` also skips its orphan sweep,\n> leaving the previously generated files in place rather than deleting\n> configuration the run was unable to rewrite. Under `--watch` the failure is\n> reported and the watcher keeps running, so saving a corrected source\n> regenerates as usual.\n>\n> A source file that is simply absent is not an error: that feature just has\n> nothing to generate, and the run succeeds — it is still logged, but it does\n> not fail the run. Only genuine absence counts; a path that cannot even be\n> checked (a permission error, a symlink loop, a symlink whose target is gone)\n> is treated as unreadable, not as missing.\n>\n> The directory-based features handle an unparseable source file differently,\n> and not uniformly. `subagents` and `checks` report it as a warning and skip\n> that file, because their directories hold free-form Markdown that users also\n> keep notes and READMEs in; the rest of the directory still generates and the\n> run succeeds. `rules`, `commands`, and `skills` do not: an invalid frontmatter\n> there aborts `generate` with that error and a non-zero exit, without the\n> per-feature isolation described above.\n>\n> A source entry that exists but cannot be _read_ is never treated as deleted\n> for any of them, warn-and-skip features included: a `.rulesync/` file or\n> directory whose symbolic link no longer resolves, or an input root that\n> cannot be resolved, stops the run rather than letting `--delete` sweep away\n> what it was supposed to generate.\n\n### Examples\n\n```bash\n# Generate all features for all configured tools\nrulesync generate\n\n# Generate rules for all tools\nrulesync generate --targets "*" --features rules\n\n# Generate from a shared source tree without cd-ing into it\nrulesync generate --input-roots ~/.aiglobal/.rulesync --targets "*" --features rules\n\n# Dry run: preview changes without writing\nrulesync generate --dry-run --targets claudecode --features rules\n\n# CI check: fail if generated files are not up to date\nrulesync generate --check --targets "*" --features "*"\n\n# Watch mode: regenerate on every change to the sources\nrulesync generate --watch\n```\n\n### Mutation plan in JSON output\n\nWith the global `--json` flag, `generate` reports the files it wrote and the orphans it deleted — or, under `--dry-run` and `--check`, would write and delete — as a versioned `plan`, next to the existing per-feature `features` summary:\n\n```json\n{\n "success": true,\n "command": "generate",\n "data": {\n "features": { "…": "…" },\n "plan": {\n "version": 1,\n "operations": [\n { "action": "write", "kind": "file", "feature": "rules", "path": "CLAUDE.md" },\n {\n "action": "delete",\n "kind": "file",\n "feature": "rules",\n "path": ".claude/rules/retired.md"\n },\n {\n "action": "delete",\n "kind": "directory",\n "feature": "skills",\n "path": ".claude/skills/retired"\n }\n ]\n }\n }\n}\n```\n\n- `action` is `write` or `delete`; `kind` is `file` or `directory`. A deleted directory is listed once, by its own path, together with everything under it. Deletions come from the `--delete` orphan sweep, so without `--delete` the plan holds writes only.\n- `path` is relative to the output root, with `/` separators, and the operation does not name the root. A path several targets write is listed once, and so is a relative path that stands for a file in each of several output roots (`--output-roots a b`, or a tool home override such as `HERMES_HOME`): run one output root at a time when a consumer has to tell them apart.\n- The order is deterministic — features in summary order, writes before deletes within a feature, paths sorted — so a `--dry-run` plan can be compared operation by operation with the plan the following real run reports.\n- A failing command\'s document carries no `data`, so when `--check` finds the tree out of date, or a `.rulesync/` source could not be read, the plan is reported as `error.details.plan` instead.\n- A shared configuration file Rulesync merges into (`.claude/settings.json`, `.codex/config.toml`, …) appears as a `write` of the whole file, even when only Rulesync-managed keys change. Two rewrites the `--delete` sweep itself makes are not listed at all: disabling the Hermes Agent commands plugin in its `config.yaml` and retracting Goose slash commands — so `--check` can fail with an empty `operations` list when one of them is the only difference. `version` is bumped whenever an operation gains a new `action` or `kind` value or an existing field changes meaning, so a consumer that authorizes operations should refuse a version it does not know.\n\n### Watch mode\n\n`generate --watch` runs one generation immediately and then keeps running, regenerating whenever the rulesync sources change. It is meant for iterating on rules, commands, subagents or skills without re-running the command by hand.\n\n- **What is watched**: within each input root (`.rulesync/` by default, or every `inputRoots` entry), only the top-level entries `generate` reads: the `rules/`, `commands/`, `subagents/`, `skills/` and `checks/` directories (recursively), the `mcp`, `hooks` and `permissions` files in both their `.jsonc` and legacy `.json` spellings (plus the deprecated `.mcp.json`), and `.aiignore`. Everything else under the root is pruned, so churn in unrelated siblings such as `.git/` or `node_modules/` never triggers a regeneration even when the root is the project directory itself. Symbolic links inside a root are followed, so a linked `rules/` directory reports edits to its targets. The configuration files next to the source tree (`rulesync.jsonc` and `rulesync.local.jsonc`, or the file passed to `--config`) are watched as well. Generated output is never watched, so a regeneration cannot re-trigger the watcher.\n- **Debouncing**: bursts of file-system events (editor save storms, `git checkout` switching many files) are coalesced into a single regeneration after a short quiet period. Changes that arrive while a generation is running trigger exactly one follow-up run.\n- **Errors keep the watcher alive**: a failing generation (e.g. invalid frontmatter saved mid-edit) is reported and watching continues; the process does not exit.\n- **Configuration changes**: editing the configuration file triggers a regeneration, and the new values apply to it because the configuration is re-resolved on every run. The **set of watched paths is fixed at startup**, so changing `inputRoot`/`inputRoots` (or the location of the configuration file itself) requires restarting the command. A warning is printed whenever the configuration file changes as a reminder.\n- **Incompatible flags**: `--watch` cannot be combined with `--check`, `--dry-run` or `--json`. The first two are one-shot verification modes and `--json` emits a single result document when the command exits, which never happens while watching.\n- **Stopping**: `Ctrl+C` (`SIGINT`) or `SIGTERM` closes the watchers and exits normally.\n\n### Tool home overrides win over the output root in global scope\n\nThree tools read their profile location from an environment variable: Hermes Agent (`HERMES_HOME`), Kimi Code (`KIMI_CODE_HOME`), and deepagents-cli (`DEEPAGENTS_HOME`). When one of them is set, `generate --global` and `convert --global` write that tool\'s output under it, **overriding both `outputRoots` and an explicit `--output-roots`** for that target. See [Supported Tools](./supported-tools.md) for what each profile root contains. This is deliberate — the variable names where the tool itself looks, so honoring the flag instead would produce files the tool never reads. Every other target still uses the configured output root.\n\nThe override must be a usable directory: an empty value is ignored (the default profile location applies), and a value that is the filesystem root or an unnormalized path is rejected with an error naming the variable. `DEEPAGENTS_HOME` is additionally held to the spellings dcode itself accepts — an absolute path, or one beginning with `~/` (expanded against the home directory) — so a relative value, a bare `~`, or a `~user` form is rejected rather than resolved against the working directory, since dcode would refuse to start with it. The home directory itself (`DEEPAGENTS_HOME=~/`) is rejected for the same reason: a profile root owns everything beneath it, so that spelling would scatter the profile across your home.\n\n### Shared config files are never created empty\n\nSome outputs are files Rulesync merges into rather than owns, because the tool (or you) keeps unrelated settings there: `.amp/settings.json(c)`, `.antigravity/settings.json`, `.claude/settings.json`, `.claude/settings.local.json`, `.codex/config.toml`, `.copilot/settings.json`, `.devin/config.json`, `.factory/settings.json`, `.github/copilot/settings.json`, `.grok/config.toml`, `.grok/sandbox.toml`, `.vibe/config.toml`, `.vscode/settings.json`, `.zcode/config.json`, `.zcode/cli/config.json`, `.zed/settings.json`, `kilo.json(c)`, `.mimocode/mimocode.json(c)`, `opencode.json(c)`, and `reasonix.toml`. These are deliberately **not** added to `.gitignore` by `rulesync gitignore`, so that settings you hand-author in them stay version-controlled.\n\nBecause they stay committable, `generate` will not **create** one of them just to hold an empty payload: if Rulesync has nothing to contribute (e.g. no permissions map to that tool), the file is left absent instead of being written as `{}`. A file that already exists is always rewritten as usual, so nothing you authored is dropped. Every other generated file is written even when empty, since for a file Rulesync owns its existence is part of the output.\n\n### Comments in shared JSONC files are preserved\n\nSeveral of those shared files are JSONC rather than JSON, because the tools themselves put comments in them — VS Code\'s own "MCP: Add Server" scaffold opens `.vscode/mcp.json` with a comment line. `.vscode/settings.json`, `.vscode/mcp.json`, `.amp/settings.json`, `opencode.json(c)` and `kilo.json(c)` (with their global counterparts) are therefore written back as **edits to the existing text**: only the spans whose values actually changed are rewritten, so the comments, blank lines, and key order everywhere else in the file survive a regenerate, and a `generate` that computes the same content it wrote last time leaves the file byte-identical. A key that Rulesync drops takes its own line with it, but a comment on the line above it stays, because Rulesync cannot tell whether that note described the key or the file around it. A note written after a key on the same line belongs to that key: it is removed with the key, and it stays put when Rulesync adds a new key after it. New keys are appended at the end of the object that holds them, and the region the insert touches is re-indented to the file\'s own indentation — so an object written on a single line is expanded to one key per line when Rulesync adds to it.\n\nRulesync falls back to rewriting the whole document when there is nothing to preserve, nothing to edit against, or no way to edit safely: an empty file; a file that does not parse, a byte-order mark included (for `.vscode/settings.json`, `.vscode/mcp.json` and `.amp/settings.json` an unparsable file stops the command instead, rather than have Rulesync overwrite settings it could not read); a file whose root is not an object; a file using `__proto__`, `constructor` or `prototype` as a key (those keys are dropped from every document Rulesync parses, so they are removed rather than left behind); a file that states the same key twice, where an edit would land on the copy the tool itself ignores; a file so large, with so much of it changing, that editing it key by key would take longer than a rewrite is worth, whether the size is the file Rulesync starts from or the document it is taking (a machine-generated file with thousands of entries, all of them replaced, or a small file handed thousands of new ones); a file whose changed keys write more than about 200 KB of new text between them, indentation included, which costs more to re-indent than to write out; and a file the editor itself refuses, which it reports as an error rather than a result. Except for the empty file, every such whole-document rewrite is reported as a warning naming the file and the reason (once per file and reason per run), so a comment that goes missing is announced by the `generate` that dropped it rather than discovered in the next diff. Files in the other formats — JSON, YAML, TOML — are re-serialized as before, so comments in `.codex/config.toml` or `reasonix.toml` are still not retained.\n\n## Gitignore Command\n\nThe `gitignore` command adds generated AI tool configuration files to `.gitignore`. By default, it emits entries only for the tools listed in the `targets` of your `rulesync.jsonc` (controlled by the `gitignoreTargetsOnly` option, which defaults to `true`). Set `gitignoreTargetsOnly` to `false` to emit entries for all supported tools instead. You can also filter the output per-invocation with `--targets` / `--features`, which take precedence over the config.\n\nYou can route entries to `.gitattributes` instead by setting `gitignoreDestination` to `"gitattributes"` at root, tool, or tool × feature level. More specific settings take precedence.\n\n> **No `rulesync.jsonc` in the project?** Entries for all supported tools are emitted. `gitignoreTargetsOnly` is only applied when a config file exists, so users without a config still get useful `.gitignore` coverage.\n\n> **`agentsmd` entries are always included.** Even when `gitignoreTargetsOnly` is `true` and `agentsmd` is not listed in `targets`, entries for `AGENTS.md` (and related paths) are appended automatically. Because `AGENTS.md` is a de facto standard file read by many AI tools regardless of the target set, its gitignore entries are emitted unconditionally to prevent accidental commits of generated rule files. To opt out of this behavior, pass an explicit `--targets` option that omits `agentsmd`.\n\n### Options\n\n| Option | Description | Default |\n| --------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- |\n| `--targets, -t ` | Comma-separated list of tools to include (e.g., `claudecode,copilot` or `*` for all) | Derived from `targets` / `gitignoreTargetsOnly` |\n| `--features, -f ` | Comma-separated list of features to include (rules, commands, subagents, skills, ignore, mcp, hooks, checks) | `*` (all) |\n\n### Examples\n\n```bash\n# Add all entries (default)\nrulesync gitignore\n\n# Add entries for Claude Code only\nrulesync gitignore --targets claudecode\n\n# Add entries for multiple tools\nrulesync gitignore --targets claudecode,copilot,cursor\n\n# Add only rules and commands entries for Copilot\nrulesync gitignore --targets copilot --features rules,commands\n```\n\n### Behavior\n\n- **Common entries** (e.g., `.rulesync/rules/.curated/`, `.rulesync/skills/.curated/`, `rulesync.local.jsonc`) are always included regardless of filters.\n- **General entries** (e.g., memories, settings) are always included when their target is selected.\n- When re-running, all previously generated rulesync entries are removed before writing the new filtered set.\n\n## Add Command\n\nThe `add` command can scaffold one Rulesync feature file or append one declarative source to `rulesync.jsonc`.\n\n### Feature scaffolding\n\nUse a feature keyword to create a valid, editable starter file:\n\n```bash\n# Named Markdown features\nrulesync add rule --name overview\nrulesync add command --name review-pr.md\nrulesync add subagent --name planner\nrulesync add skill --name project-context\nrulesync add check --name security\n\n# Singleton features\nrulesync add mcp\nrulesync add hooks\nrulesync add permissions\n\n# Deprecated compatibility scaffold; prefer permissions\nrulesync add ignore\n```\n\nNamed features accept a name with or without the `.md` suffix. Skills use the directory layout `.rulesync/skills//SKILL.md`; the other named features create `.md` in their canonical Rulesync directory. Names cannot contain path separators.\n\nWhen the target file exists, interactive execution asks before replacing it. Declining leaves the file unchanged. JSON, silent, and non-interactive execution fail safely; pass `--force` to overwrite explicitly. Singleton scaffolds recognize supported JSONC and legacy variants and replace the effective existing file instead of creating a shadowed canonical file.\n\nFeature keywords are reserved when no source-specific option is present. To add a source whose identifier is also a feature keyword, provide a source option that makes the intent explicit, such as `rulesync add skill --transport npm`.\n\n### Declarative sources\n\nFor any other source identifier, `add` appends one source to `rulesync.jsonc` and immediately runs the declarative source resolver. It preserves JSONC comments, installs selected rules into `.rulesync/rules/.curated/`, installs selected skills into `.rulesync/skills/.curated/`, and updates `rulesync.lock` or `rulesync-npm.lock.json`.\n\n```bash\n# GitHub source (default transport)\nrulesync add anthropics/skills --skills skill-creator\n\n# Rules only; direct .md files are selected from rules/\nrulesync add acme/ai-standards --rules testing-guidelines,typescript-conventions\n\n# Rules and skills from separate paths in one source\nrulesync add acme/ai-assets --rules "*" --rules-path exports/rules --skills review-pr --path exports/skills\n\n# Any Git remote through the git CLI\nrulesync add https://example.com/team/skills.git --transport git --ref main --path skills\n\n# npm-compatible registry\nrulesync add @acme/skill-package --transport npm --registry https://registry.npmjs.org\n```\n\nThe selected configuration file must already exist. Run `rulesync init` first, or pass `--config `. Adding a source whose normalized source identity is already present fails instead of silently creating duplicate lockfile entries; edit the existing entry when changing its options.\n\nThe operation fetches only the source being added; existing declarations are not re-fetched. Existing sources must already be locked and installed, otherwise run `rulesync install` first. The operation is transactional: if the new source fails to install, Rulesync restores the manifest, source lockfiles, curated rules, and curated skills to their pre-command state.\n\n| Option | Description |\n| --------------------- | ------------------------------------------------------------------------------------------------- |\n| `--name ` | Name for a rule, command, subagent, skill, or check scaffold |\n| `--force` | Replace an existing scaffold file without prompting |\n| `--skills ` | Comma-separated skill names. `*` selects all skills. |\n| `--rules ` | Comma-separated rule names. Names may omit `.md`; `*` selects direct `.md` files under rulesPath. |\n| `--transport ` | `github` (default), `git`, or experimental `npm` |\n| `--ref ` | Git ref, npm version, or npm dist-tag |\n| `--path ` | Skills path within the source; defaults to `skills` |\n| `--rules-path ` | Rules path within the source; defaults to `rules` |\n| `--registry ` | npm-compatible registry URL |\n| `--token-env ` | Environment variable containing the npm registry token |\n| `--token ` | GitHub token for private repositories |\n| `--config ` | Configuration file to edit (default: `rulesync.jsonc`) |\n\nWhen neither `--skills` nor `--rules` is provided, all skills are installed for backward compatibility. Providing only `--rules` installs no skills.\n\n## Fetch Command\n\nThe `fetch` command allows you to fetch configuration files directly from a Git repository (GitHub/GitLab).\n\n> [!NOTE]\n> This feature is in development and may change in future releases.\n\n**Note:** The fetch command searches for feature directories (`rules/`, `commands/`, `skills/`, `subagents/`, etc.) directly at the specified path, without requiring a `.rulesync/` directory structure. This allows fetching from external repositories like `vercel-labs/agent-skills` or `anthropics/skills`.\n\n### Source Formats\n\n```bash\n# Full URL format\nrulesync fetch https://github.com/owner/repo\nrulesync fetch https://github.com/owner/repo/tree/branch\nrulesync fetch https://github.com/owner/repo/tree/branch/path/to/subdir\nrulesync fetch https://gitlab.com/owner/repo # GitLab (planned)\n\n# Prefix format\nrulesync fetch github:owner/repo\nrulesync fetch gitlab:owner/repo # GitLab (planned)\n\n# Shorthand format (defaults to GitHub)\nrulesync fetch owner/repo\nrulesync fetch owner/repo@ref # Specify branch/tag/commit\nrulesync fetch owner/repo:path # Specify subdirectory\nrulesync fetch owner/repo@ref:path # Both ref and path\n```\n\n### Options\n\n| Option | Description | Default |\n| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |\n| `--target, -t ` | Target format to interpret files as (e.g., \'rulesync\', \'claudecode\') | `rulesync` |\n| `--features ` | Comma-separated features to fetch (rules, commands, subagents, skills, ignore, mcp, hooks, permissions, checks) | `skills` |\n| `--output ` | Output directory relative to project root | `.rulesync` |\n| `--conflict ` | Conflict resolution: `overwrite` or `skip` | `overwrite` |\n| `--no-prune` | Keep local files inside a fetched skill directory that the remote skill no longer has | Pruning is on |\n| `--ref ` | Git ref (branch/tag/commit) to fetch from | Default branch |\n| `--path ` | Subdirectory in the repository | `.` (root) |\n| `--skills ` | Comma-separated skill names to fetch (requires the skills feature) | All skills |\n| `--interactive, -i` | Interactively select skills to fetch via a checkbox prompt; nothing is selected initially, press `` to select/deselect all (requires the skills feature and a TTY) | Disabled |\n| `--token ` | Git provider token for private repositories | `GITHUB_TOKEN` or `GH_TOKEN` env |\n\n### Executable Bits\n\nSkills can ship scripts that agents are expected to run. The GitHub contents API that `fetch` downloads from carries no file mode, so when a run fetches a skill\'s supporting files `fetch` reads the repository tree once and marks every such file that git records as executable (`100755`) with mode `0755` after writing it (a rules-only fetch makes no tree request); `rulesync generate` then carries that bit into each generated skill directory. Two warnings cover the cases where this cannot be done: a tree listing GitHub cut short (very large repositories), and a tree that could not be read at all. In both cases the files are still fetched, without the bit, and the warning names the file modes as what was lost so you can `chmod` them yourself.\n\n### Pruning Fetched Skill Directories\n\nA skill is a directory (`skills//SKILL.md` plus its supporting files), not a single file. When the upstream skill drops or renames a file, an additive fetch would leave the old local copy in place, and the directory would become a mixture of the current upstream files and orphaned leftovers. Agents read whatever is in the directory, so a stale reference or an outdated script keeps steering them long after upstream removed it.\n\n**The remote skill is therefore the source of truth: by default, `fetch` deletes files inside the skill directories it fetched that the remote does not have.** Every deletion is listed in the summary, so the destructive part of the command is never silent:\n\n```text\nFetched from anthropics/skills@main:\n ✓ skills/pdf/SKILL.md (overwritten)\n ✗ skills/pdf/reference.md (deleted - no longer in the remote skill)\n\nSummary: 1 overwritten, 1 deleted\n```\n\nRulesync also warns separately whenever a run deleted anything, so the one part of the command that cannot be undone is not left to be spotted among the rest of the summary.\n\n> [!WARNING]\n> Pruning removes **any** file in a fetched skill directory that the remote does not have — including one you added yourself. That is what "mirror the remote" means. Keep your own material outside the skill directories you fetch, or pass `--no-prune`.\n>\n> This applies to whatever `--output` points at. `--output .` makes the fetched skill directories your project\'s own `skills/`, so a fetch there prunes the skills you maintain by hand. Fetch into the default `.rulesync/` unless you really mean to mirror a remote repository into your project root.\n\nThe scope is deliberately narrow:\n\n- Only the `skills//` directories fetched in **this** run are pruned. A skill left out by `--skills` or `--interactive` is untouched.\n- Other features (`rules/`, `commands/`, `subagents/`, …) are never pruned — the issue only exists for directory-based skills.\n- Directories the remote skill no longer has are removed as well, and are listed in the summary under their own name with a trailing slash.\n- Symbolic links are unlinked, never followed: a link inside a skill directory can only ever lose the link itself, never whatever it points at. A skill directory that is **itself** a symbolic link is not pruned at all, since deleting through it would reach outside the output directory; Rulesync warns and leaves it alone.\n- A local file that the filesystem holds as the same file as one just fetched — a second name for it on a case-insensitive or Unicode-normalizing filesystem, or a hard link — is kept, even though the remote list does not carry that name. The same applies to a symbolic link that resolves to a file or directory this run wrote: it is kept rather than unlinked, since the fetch may have written through it.\n- A skill directory Rulesync cannot read or delete from — a permission it does not hold, a disk that gave out — stops that skill\'s prune where it failed rather than the whole fetch. Rulesync warns, still lists whatever it had already deleted, and moves on to the next skill.\n- Nothing more than 15 directories below the skill directory is pruned. That is a limit on the local walk, deep enough that a fetched tree stays well inside it; Rulesync warns and leaves anything deeper alone.\n- A skill directory whose name ends in a dot or an ASCII space, whose name has the `NAME~1` shape of a Windows short name, or whose name is a Windows reserved device name such as `NUL` or `COM1`, is never fetched in the first place (see [Skill Names That Look Alike](#skill-names-that-look-alike)), so there is nothing of it to prune. A local directory of such a name is left exactly as it was. A file _inside_ a skill whose own path has such a segment is skipped on its own (see [Remote Paths Windows Reads Differently](#remote-paths-windows-reads-differently)), and the skill it belongs to is then not pruned in that run.\n- A skill directory whose name differs from another in `skills/` — one that was already there, or one this same fetch just wrote — only in ways some filesystems ignore — its case, or whether an accented letter is written composed or decomposed — is not pruned either, for the same reason: macOS and Windows resolve `skills/PDF` to an existing `skills/pdf`, and macOS resolves a decomposed name to the composed directory of the same name, so pruning it would judge the local skill\'s own files stale.\n- A skill whose remote listing came back incomplete — GitHub caps a directory listing at 1,000 entries, and entries such as symlinks and submodules cannot be fetched — is not pruned either. Rulesync warns instead, because a local file that upstream still ships cannot be told apart from one it dropped.\n- `--conflict skip` disables pruning. That flag says to leave existing local files alone, and it also means the local copies are not this run\'s output, so they cannot be judged against the remote list.\n- `--target ` never prunes, because that conversion path does not fetch skills at all.\n\nPass `--no-prune` to get the old purely additive behavior.\n\n#### Remote Paths Windows Reads Differently\n\nA backslash is an ordinary character in a filename on Linux and macOS, and a directory separator on Windows. A colon is ordinary too here, and on Windows it separates a file from one of its alternate data streams, so `skills/pdf::$INDEX_ALLOCATION` is another way of writing `skills/pdf` rather than a directory of its own. A remote file whose path contains either character therefore names one file on some systems and something else on others, so `fetch` skips it and warns rather than picking an interpretation. The rest of the fetch continues normally.\n\nThe same applies, segment by segment, to every path `fetch` writes — a rules file, a directory inside a skill, the file at the end of the path — when a segment is one Windows resolves to a different name: a segment ending in a dot or an ASCII space, which Windows strips, so `skills/deploy/references/notes.` is the existing `notes` there; one shaped like a `NAME~1` 8.3 short name, such as `scripts~1/run.sh`, which opens whatever long name it stands for; or a reserved device name — `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, `CONOUT$`, `COM1`–`COM9`, `LPT1`–`LPT9`, in any case and with any extension, so `nul.md` and `con.sh` count, as do `nul .md` with a space before the extension and `com¹` with a superscript digit, both of which Windows reads as the device — which is not a file there at all but the device itself. Such a file would land beside, or on top of, a sibling the repository never published, so it is skipped with a warning that quotes the segment as it is (a trailing space would not show otherwise), on every platform and not only on Windows, because the same output directory may be checked out on several. A skill directory of such a name is handled one level up, by dropping the whole skill (see [Skill Names That Look Alike](#skill-names-that-look-alike)).\n\nBecause the skipped file is still part of the remote skill, the skill directory it came from is not pruned in that run either — a local copy of a file the remote still ships would otherwise be indistinguishable from one it dropped.\n\n#### Skill Names That Look Alike\n\nA repository can publish two skill directories whose names a terminal draws the same way — `skill` spelled with a Cyrillic `ѕ`, or the fullwidth `skill` beside the plain one. Each is still a separate entry with its own name, so a selection writes exactly the directories that were checked; the risk is only that an entry cannot be told apart by sight from what it appears to be — another entry, a skill you already have, or the plainer, shorter name its own row reads as.\n\nThe interactive prompt therefore prefixes such an entry with `[!]` and the reason, ahead of the name itself:\n\n```text\n? Select skills to fetch (press to select/deselect all)\n ◯ pdf\n ◯ [!] another entry differs from it only by lookalike letters — skill\n ◯ [!] another entry differs from it only by lookalike letters; mi… — ѕkill\n```\n\nThe mark comes first so that a name — which the remote repository chooses — cannot be spelled to look like a mark of its own, or reorder one away. A label wider than one line is shortened with an ellipsis for the same reason; the budget is measured in terminal columns, so a name of ideographic spaces cannot buy extra width by being few characters, and it is taken from the terminal the prompt is drawn in — the window\'s own width, less the five columns the pointer and the checkbox can take between them, and never more than 72 — so a pane split in half shortens the labels along with it. A terminal that does not say how wide it is counts as whatever the `CLI_WIDTH` environment variable says, and as 80 columns when that is unset too, the same fallback the prompt uses when it wraps the rows, and no terminal shortens a label below 16: past that a row would be an ellipsis and little else, and rows that cannot be told apart at all are worse than a row that wraps. The name is measured first and the reasons take the room that is left, which is why the second label above is cut: a cut takes the reasons from the tail, and the mark and the start of the first reason always survive. Two entries whose labels read alike are numbered — `(1) `, `(2) `, in front for the same reason the mark is — so they stay distinct. That covers labels shortened into the same text, and equally `git` beside `ɡit`, where both rows carry the same note and the names are one shape: the numbers do not say which row is which, but they do say there are two of them, and the value behind a label is untouched, so a shortened entry still selects the skill it names. A name that itself begins the way a marked row does is given a mark of its own saying so — judged by the shape it is drawn in rather than the characters it is spelled with, so `(l)` and a `[ǃ]` written with U+01C3 LATIN LETTER RETROFLEX CLICK are marked alongside the plain `(1)` and `[!]`.\n\nAn entry is marked for any of seven reasons:\n\n- **The name carries more whitespace than the row shows.** This reason is written first: the picker cuts an overlong note from its tail, and every other reason says the row may be taken for another one, which can still be checked by eye, while this one says the row itself reaches further than it shows. A run of whitespace is drawn as one gap however long it is, and whitespace at either end of a name is drawn as nothing at all, so ` pdf` and `pdf reader` reach past what can be seen of them. Both halves of a pair like `pdf` and ` pdf` are already marked as sharing a display form; this reason is what marks the padded name when it is alone on the list. A single blank inside a name that is merely not the plain space is not marked here: it is drawn, so it is a question of which character was chosen rather than of how far the name reaches, and the reason below answers that one. At the start it is marked like any other blank, since there the question is not which character was chosen but that the name reaches past where it appears to end. At the end the same holds for every blank but the ASCII space: a name ending in that, or in a dot, is a name Windows folds onto another, and is dropped rather than marked (see below).\n- **The name carries a mark drawn over the character before it.** An enclosing mark — the box of a keycap, U+20E3, or an enclosing circle, U+20DD — is drawn over the character in front of it and takes no column of its own, so `pdf` with one after it occupies the three columns of `pdf` and is a fourth directory underneath. It is drawn, so the name is offered rather than dropped, and marked whether or not a plain `pdf` is on the list. The one place such a mark belongs is an emoji keycap — `1️⃣`, spelled as UTS #51 spells it — which is not marked; every other enclosing mark is. Only enclosing marks count: the combining accents and vowel signs Vietnamese, Devanagari and Arabic are written with are ordinary and are left alone. Listed second, after the whitespace reason, for the same reason that one is first.\n- **The name carries a character drawn as a plain space.** A blank that a terminal draws in one column, exactly as it draws the plain space, without being one: U+00A0 NO-BREAK SPACE, U+1680 OGHAM SPACE MARK, the U+2000–U+200A quads and spaces, U+202F NARROW NO-BREAK SPACE and U+205F MEDIUM MATHEMATICAL SPACE. `pdf` and `reader` joined by one of those reads on screen as exactly `pdf reader`, and because names are compared with their whitespace collapsed and normalized, the pair is already reported when the plain spelling is on the list — this reason is what marks the substitution when it is alone there, the same case the lookalike tables cover for U+2010 HYPHEN. U+3000 IDEOGRAPHIC SPACE is not marked: it is two columns wide, so the row it is printed on tells it apart, and an ordinary `設定 ガイド` written with it is left alone. Listed third, after the mark reason, for the same reason those two come first: it says what the row itself is drawn as, whatever else is on the list.\n- **Another entry has the same display form.** Names are compared with their hidden characters removed, normalized (NFKC), their whitespace collapsed, and lowercased, so `Skill`, `skill`, ` skill` and the fullwidth `skill` all collide.\n- **Another entry differs from it only by lookalike letters.** Two names that read the same once each character is replaced by the Latin letter it is drawn as — `copy` beside the same word spelled entirely in Cyrillic. A name does not have to leave the Latin alphabet to qualify: `c0py` with a zero, `ruIes` with a capital I for the l, `Ⅰist` with the Roman numeral one, and `git` with the script `ɡ` or the dotless `ı` are all marked against the plain spelling. Two letters drawn as one count as well — `forrnat` for `format`, `revievv` for `review` — which no table of single characters can see. `cl` for `d` is left out of that short list on purpose: it opens too many ordinary words, and folding it would report `clone` against a `done` you happen to have. The separator counts too, since nearly every skill name is kebab-case and a name that swaps only its hyphen for U+2010 HYPHEN — drawn identically, belonging to no script, and left alone by the compatibility normalization — would otherwise pass every check as plain ASCII. Neither name mixes scripts on its own, so this pair is visible only by comparing the two.\n- **The name reads as Latin letters but is written in another script.** Every letter of it is drawn as a Latin one without being Latin, which is the whole-script confusable of UTS #39: `copy` spelled with four Cyrillic letters is marked even when no Latin `copy` is on the list. This check uses the narrower list of letters that are drawn as a _lowercase_ Latin letter while being lowercase themselves, so an ordinary Russian or Greek word — `текст`, `κατα` — is not marked: its letters are ones whose capitals resemble Latin capitals, which is not the same thing. Cherokee, Coptic, Lisu, Osage, Deseret and Tifinagh are taken whole instead of letter by letter — a name written in nothing but one of them is marked — since those alphabets are drawn in Latin letter shapes throughout. Canadian Aboriginal Syllabics and Vai are not: their letters are shapes of their own, so a name in either reads as nothing Latin, and only a mixture with Latin is marked.\n- **The name mixes scripts.** A single name built from scripts that share letter shapes, such as `good` with a Cyrillic `о`. The alphabets treated as lookalikes are Latin, Cyrillic, Greek, Armenian, Cherokee, Coptic, Lisu, Canadian Aboriginal Syllabics, Osage, Deseret, Vai and Tifinagh, which are among the ones UTS #39 records as confusable with each other. Japanese, Korean and Chinese names mix scripts by nature and routinely carry Latin, so those combinations are not marked, and neither is Latin beside a script that shares no shapes with it.\n\nThe display-form and lookalike reasons are asked of the skills already in the output directory as well as of the other entries, and say `a local skill` in place of `another entry` when that is where the twin is. Without that comparison a repository publishing only the imitation — `dep1oy` against a `deploy` you have had for months — is a single plain-ASCII name in one script with nothing on the list to compare it against, and every check stays quiet. A local skill that names the directory an entry would be written into is the skill that entry would refresh rather than one imitating it, so it is not a collision and is not marked; a second fetch of the same repository is therefore as quiet as the first. Where the two spellings differ only in case, or only in how the name is composed in Unicode, whether they name one directory is the filesystem\'s answer rather than the listing\'s — macOS and Windows resolve `skills/PDF` to an existing `skills/pdf`, Linux does not — so Rulesync asks it, and marks the pair only where they really are two directories — and only where the two also read alike, so that a local skill a _second_ entry imitates keeps its place in the comparison while the first entry refreshes it. A fullwidth `pdf` is a second directory everywhere and is always marked. Where both another entry and a local skill collide with a name, the entry is the one named, since that is the pair you can compare on screen.\n\nA `fetch` that shows no prompt — a plain one, or one selecting with `--skills` — prints the same reasons as a warning listing the names they apply to, so a scripted run is told what an interactive one would have been shown. The names are judged against everything the repository publishes and everything already in the output directory, as the prompt judges them, and listed for what the run actually writes: `--skills c0py` is told that the name reads like another entry even though the `copy` that makes it so was never fetched.\n\nThe mark is display-only: it never removes a skill from the list. It is also not a complete answer — the table of lookalike letters holds the common pairs rather than every one, and a name written entirely in a script the table does not map is compared against nothing — so treat it as a hint to look closer, not as a guarantee that unmarked entries are distinct.\n\nNames that cannot be shown honestly at all are a separate case: a skill directory whose name carries a control character, one that draws as nothing — a zero-width space, a Hangul filler, a braille blank — or one that draws as nothing but blank space or combining marks with no letter to sit on is dropped rather than marked, with a warning naming it in stripped form. So is a name that merely begins with a combining mark: the mark is drawn over whatever the terminal printed before the name — the checkbox, the quote around a name in a warning — and the name reads as the letters after it, which is what the warning shows. That drop applies to every `fetch`, including a plain one with neither `--skills` nor `--interactive`, because such a directory on disk cannot be told from the plain name it imitates in any line Rulesync prints.\n\nA name that Windows resolves to a different directory is dropped the same way, with a warning that quotes it as it is: a skill directory whose name ends in a dot or an ASCII space — Windows strips both when it opens a path, so `deploy.` is the existing `deploy` there; any other trailing blank it keeps, so a name ending in a no-break or an ideographic space is fetched and only marked — whose name has the `NAME~1` shape of an 8.3 short name, which on a volume that generates short names opens whatever long name it stands for, or whose name is a reserved device name — `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, `CONOUT$`, `COM1`–`COM9` or `LPT1`–`LPT9`, in any case and with any extension, so `nul.md` counts, and so do `nul .md` and `com¹` — which cannot be a directory there at all. Such a name reads as one directory and is written into another, so with the default `--conflict overwrite` it would replace the files of a local skill the repository never published. The drop applies on every platform, not only on Windows, because the same output directory may be checked out on several; a repository that ships `deploy.` and `deploy` side by side loses only the first. The shape only counts at the end of a name, and the reservation only on the whole name before its first dot (spaces before that dot dropped first), so `data~2parser`, `v1.0`, `console` and `com10` are ordinary names and are fetched. The segments below the skill directory are held to the same shapes file by file (see [Remote Paths Windows Reads Differently](#remote-paths-windows-reads-differently)).\n\nA zero-width joiner or a variation selector is dropped only where it hides something. It is kept beside the scripts that are written with one — the Arabic family, the Indic scripts, Mongolian — and beside the pictographs an emoji sequence is built from; anywhere else it can be nothing but padding, so `pdf` with a joiner in it is dropped, and so is `設定` with one between its two characters. Standing where its own script puts it — a Persian or Indic name written with a zero-width non-joiner, an emoji name built from a chain of joiners — it is left alone and the name is fetched normally. A joiner is held to both of its neighbors, since it exists to bind two characters: a name that merely ends in one is a second directory drawn exactly like the name beside it, and is dropped. A variation selector is held only to the character before it, which is the one whose form it selects, so an emoji name may end in one. The keycap sequences — `1️⃣`, `#️⃣`, `*️⃣`, spelled as UTS #51 spells them: one of `0`–`9`, `#` or `*`, then U+FE0F, then U+20E3 — are matched whole, since what they are built on is a digit or an ASCII sign rather than a pictograph. Only the whole sequence is kept: a digit followed by a variation selector with no enclosing keycap behind it is padding still, and is dropped still.\n\n#### Remote Paths in Rulesync\'s Output\n\nPath names come from the remote repository, so every one that Rulesync prints — the fetch summary, warnings, and debug lines — has its control characters stripped first. A crafted path cannot forge or erase the lines around it, which is what makes the record of what was written and deleted worth reading.\n\nThe `--json` output is the exception: it carries each path exactly as the repository spells it, because a machine consumer needs the real name to act on it. Anything that renders a value out of that JSON into a terminal has to strip it itself.\n\n### Examples\n\n```bash\n# Fetch skills from external repositories\nrulesync fetch vercel-labs/agent-skills\nrulesync fetch anthropics/skills\n\n# Fetch only specific skills by name\nrulesync fetch anthropics/skills --skills pdf,docx\n\n# Interactively select which skills to fetch (checkbox prompt)\n# Nothing is checked when the prompt opens: press to select the\n# highlighted skill, to select/deselect all, to invert, and to confirm.\nrulesync fetch anthropics/skills --interactive\n\n# Interactively select skills with some pre-checked\nrulesync fetch anthropics/skills --interactive --skills pdf\n\n# Fetch all features from a public repository\nrulesync fetch dyoshikawa/rulesync --path .rulesync --features "*"\n\n# Fetch only rules and commands from a specific tag\nrulesync fetch owner/repo@v1.0.0 --features rules,commands\n\n# Fetch from a private repository (uses GITHUB_TOKEN env var)\nexport GITHUB_TOKEN=ghp_xxxx\nrulesync fetch owner/private-repo\n\n# Or use GitHub CLI to get the token\nGITHUB_TOKEN=$(gh auth token) rulesync fetch owner/private-repo\n\n# Preserve existing files (skip conflicts)\nrulesync fetch owner/repo --conflict skip\n\n# Keep local files a fetched skill no longer has upstream\nrulesync fetch anthropics/skills --no-prune\n\n# Fetch from a monorepo subdirectory\nrulesync fetch owner/repo:packages/my-package\n```\n\n## Convert Command\n\nThe `convert` command converts configuration files from one AI tool directly to one or more destination tools **without creating `.rulesync/` files on disk**. The intermediate rulesync representation is kept in memory only.\n\nThis is useful when you want to translate a one-shot tool-to-tool conversion (e.g., "I have Cursor rules, give me Claude Code and Copilot equivalents") without adopting rulesync\'s managed source-of-truth workflow.\n\n### Options\n\n| Option | Description | Default |\n| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------- |\n| `--from ` | Source tool to convert from (single tool, e.g., `cursor`, `claudecode`) | Required |\n| `--to ` | Comma-separated list of destination tools (e.g., `copilot,claudecode`) | Required |\n| `--features, -f ` | Comma-separated list of features to convert (rules, commands, subagents, skills, ignore, mcp, hooks, permissions, checks) | `*` (all) |\n| `--verbose, -V` | Verbose output | `false` |\n| `--silent, -s` | Suppress all output | `false` |\n| `--global, -g` | Convert for global (user scope) configuration files | `false` |\n| `--dry-run` | Show changes without writing files | `false` |\n\n### Examples\n\n```bash\n# Convert Cursor rules to Copilot and Claude Code\nrulesync convert --from cursor --to copilot,claudecode --features rules\n\n# Convert all features Cursor and Copilot both support\nrulesync convert --from cursor --to copilot\n\n# Convert MCP configuration from Claude Code to Cursor\nrulesync convert --from claudecode --to cursor --features mcp\n\n# Dry run to preview the conversion\nrulesync convert --from cursor --to copilot,claudecode --dry-run\n```\n\n### Behavior\n\n- The intermediate rulesync files produced during conversion are **never** written to disk. Only destination tool files are written.\n- Features that exist for the source tool but are not supported by a given destination tool are skipped with a warning.\n- When `--features` is omitted, the command attempts every feature the source tool supports.\n- Passing the source tool inside `--to` is rejected, because converting a tool onto itself is lossy.\n- With `--dry-run`, no destination files are written; the command prints a summary prefixed with `[DRY RUN]` listing what would have been converted.\n\n## Doctor Command\n\nThe `doctor` command runs read-only diagnostics against the configuration files (`rulesync.jsonc` and `rulesync.local.jsonc`) and reports problems grouped by severity (`error` / `warning` / `info`). It never writes files, which makes it a safe first step when generation does not behave as expected, and a cheap CI guard.\n\nIt is especially useful for catching **silently ignored configuration**: the config schema is non-strict, so a misspelled key such as `"target"` instead of `"targets"` is normally swallowed without any error. `doctor` reports every unknown key with a "did you mean" suggestion.\n\n### Checks\n\n- JSONC parse errors, reported with line and column.\n- Unknown or misspelled top-level keys, with a "did you mean" suggestion.\n- Unknown tool targets and features (array and object forms), with the nearest valid name suggested.\n- Deprecated features (`ignore`, superseded by `permissions`).\n- Object-form `targets` combined with `features` — including the case where the conflict only appears after merging `rulesync.jsonc` with `rulesync.local.jsonc`.\n- Conflicting target pairs (e.g. `claudecode` + `claudecode-legacy`).\n- `$schema` presence and whether it points at the current config schema URL.\n- Structural schema violations on any other key (wrong types, malformed `sources` entries).\n- `sources[].tokenEnv` naming an environment variable that is not set.\n- `inputRoot` or the first `inputRoots` entry pointing at a directory that does not exist. Later `inputRoots` entries are optional overlays and may be absent.\n- `inputRoot` or an `inputRoots` entry set to an empty string, which makes `generate` fail with `outputRoot cannot be an empty string` before it resolves any source tree.\n- Duplicate entries in `inputRoots` (warning; duplicates are ignored at generate time).\n\n### Options\n\n| Option | Description | Default |\n| --------------------- | --------------------------------- | ---------------- |\n| `--config, -c ` | Path to configuration file | `rulesync.jsonc` |\n| `--strict` | Treat warnings as errors (exit 1) | `false` |\n| `--verbose, -V` | Verbose output | `false` |\n| `--silent, -s` | Suppress all output | `false` |\n\n### Examples\n\n```bash\n# Diagnose the project configuration\nrulesync doctor\n\n# Fail CI on warnings too\nrulesync doctor --strict\n\n# Machine-readable output for editors and CI\nrulesync --json doctor\n\n# Diagnose a configuration file at a custom location\nrulesync doctor --config ./configs/rulesync.jsonc\n```\n\n### Behavior\n\n- Exits with code `1` when any `error`-severity diagnostic is present (or any `warning` with `--strict`), and `0` otherwise.\n- With the global `--json` flag, diagnostics and a severity summary are emitted as structured JSON: in `data` on success (exit 0), and in `error.details` of the standard error document (code `DOCTOR_FAILED`) on failure.\n- A missing configuration file is reported as `info` only — rulesync runs fine with built-in defaults.\n\n## Docs Command\n\nThe `docs` command prints the bundled Rulesync documentation to standard output, so both humans and coding agents can retrieve it directly in the terminal without browsing the repository or website. The documentation is embedded in the CLI at build time, so it works in installed npm distributions and compiled binaries alike.\n\nDocument identifiers follow the `docs/` hierarchy without the `docs/` prefix or the `.md` extension (both are accepted and stripped when supplied). Identifiers that try to escape the bundled tree — absolute paths, drive letters, `..` segments — are rejected.\n\n### Usage\n\n```bash\n# List every available document identifier\nrulesync docs\n\n# Print a document (top-level or nested)\nrulesync docs faq\nrulesync docs guide/configuration\n\n# Ranked full-text search across the bundled documentation\nrulesync docs --search "global mode"\n```\n\n### Search\n\n`--search ` builds an in-memory BM25+ index (via MiniSearch) over document paths, titles, headings, and body content, with stronger boosts for titles and headings. Up to 10 results are printed, one per line, as ` — `. Matching is exact-term; no prefix or fuzzy expansion is applied.\n\n### Behavior\n\n- `rulesync docs` with no argument lists all document identifiers, one per line, sorted.\n- A missing document, an invalid identifier, an empty search text, a search with no matches, or combining a document argument with `--search` each exit with code 1 and an explanatory error.\n- Document output is printed verbatim, so it can be piped to other tools.\n- The global `--json` flag is not supported (the command\'s output is raw Markdown, not a JSON document) and exits with code 1.\n\n## Release Notes Command\n\nThe `release-notes` command prints GitHub release notes for any repository, so you can review what changed in an upstream AI coding tool — or in Rulesync itself — without leaving the terminal. Releases are fetched through the GitHub Releases API and rendered as Markdown on standard output, newest first.\n\nThe repository is given as `owner/repo` or a full `https://github.com/owner/repo` URL. Unlike `fetch`, ref (`@`) and path (`:`) suffixes are rejected — use `--tag` to select a single release. Only GitHub is supported; other Git providers have no equivalent Releases API.\n\n### Usage\n\n```bash\n# Latest 10 releases (default)\nrulesync release-notes dyoshikawa/rulesync\n\n# Most recent N releases\nrulesync release-notes dyoshikawa/rulesync --latest 5\n\n# Releases published within a date range (either end may be omitted)\nrulesync release-notes dyoshikawa/rulesync --since 2026-01-01 --until 2026-06-30\n\n# A single release by tag name\nrulesync release-notes dyoshikawa/rulesync --tag v16.11.0\n\n# Every release between two tags, inclusive\nrulesync release-notes dyoshikawa/rulesync --from v16.0.0 --to v16.11.0\n\n# Include prereleases\nrulesync release-notes dyoshikawa/rulesync --include-prereleases\n\n# Machine-readable output\nrulesync --json release-notes dyoshikawa/rulesync --latest 3\n```\n\n### Filtering\n\nThe four filtering modes — `--latest`, `--since`/`--until`, `--tag`, and `--from`/`--to` — are mutually exclusive; combining them exits with code 1. With no filter, the latest 10 releases are printed.\n\n| Option | Description |\n| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `--latest ` | Print the most recent `` releases. Must be a positive integer. |\n| `--since ` / `--until ` | Print releases published within the range, both ends inclusive. Either end may be omitted for an open-ended range. Dates are parsed as ISO 8601, e.g. `2026-01-31`; a bare date given to `--until` covers that whole day in UTC. |\n| `--tag ` | Print a single release by tag name. Named `--tag` rather than `--version` because `--version` is the global flag that prints the Rulesync version. |\n| `--from ` / `--to ` | Print every release between two tags, inclusive. Both are required. |\n| `--include-prereleases` | Include prereleases in the output. |\n| `--token ` | GitHub token for private repositories or higher rate limits. |\n\nTag ranges are resolved by position in the repository\'s release history, not by parsing semver, so non-semver tag names work and the order of `--from` and `--to` does not matter. A tag that does not appear in the history exits with code 1.\n\n### Authentication\n\nRequests are unauthenticated by default, which is enough for public repositories but subject to GitHub\'s stricter anonymous rate limit. Set `GITHUB_TOKEN` or `GH_TOKEN` (or pass `--token`) for private repositories and higher limits:\n\n```bash\nGITHUB_TOKEN=$(gh auth token) rulesync release-notes owner/private-repo\n```\n\n### Behavior\n\n- Draft releases are never printed: they are unpublished and only visible to accounts with write access.\n- Prereleases are excluded unless `--include-prereleases` is given, matching how GitHub itself resolves the "latest" release. `--tag` is the exception — an explicitly named tag is printed regardless of its prerelease status.\n- A repository with no matching releases prints a warning and exits with code `0`.\n- Range queries walk at most 10 API pages (1,000 releases); tags older than that are reported as not found.\n- Date ranges scan the whole walked history rather than stopping at the first out-of-range release, because the API orders releases by creation date and a release published from a long-lived branch can appear out of publication order.\n- Default output is Markdown on standard output, so it can be piped to other tools. With the global `--json` flag, the releases are emitted as structured `data` instead and no Markdown is printed; failures use the standard error document with code `RELEASE_NOTES_FAILED`.\n', "reference/command-syntax": '# Command Syntax\n\nSlash commands authored under `.rulesync/commands/*.md` use a **universal syntax** that mirrors Claude Code\'s command placeholders. When rulesync generates a tool-specific command file, it rewrites these placeholders into the syntax that the target tool understands. The reverse rewrite happens on import, so a rulesync ↔ tool round-trip preserves the original universal form.\n\n## Universal placeholders\n\n| Placeholder | Meaning |\n| ------------ | ------------------------------------------------------------------------ |\n| `$ARGUMENTS` | The full argument string the user supplied when invoking the command. |\n| `` !`cmd` `` | Inline shell expansion. The agent runs `cmd` and substitutes its output. |\n\nThese are written exactly as Claude Code accepts them, so writing a rulesync command body is the same as writing a Claude Code command body.\n\n## Per-tool translation\n\nThe table below shows how each placeholder is translated for the supported tools. "pass-through" means the placeholder is emitted verbatim because the target tool already understands the universal form.\n\n| Tool | `$ARGUMENTS` | `` !`cmd` `` |\n| ----------------- | ---------------------- | --------------------------- |\n| Claude Code | pass-through | pass-through |\n| CodeBuddy Code | pass-through | pass-through |\n| Codex CLI[^codex] | pass-through (literal) | pass-through (literal) |\n| JetBrains Junie | `$prompt`[^junie] | pass-through (literal) |\n| Pi | pass-through | pass-through (literal)[^pi] |\n| Tabnine CLI | `{{args}}` | `!{cmd}` |\n| Other tools[^1] | pass-through (literal) | pass-through (literal) |\n\n[^1]: Tools not listed do not have a documented translation; their command body is emitted as-is.\n\n[^codex]: Codex CLI prompt files are forwarded to the LLM verbatim; the placeholders are passed to the model as literal text rather than being substituted by the engine.\n\n[^junie]: Junie treats every `$name` in a command template as a named argument and runs the command only once all of them are provided, so a literal `$ARGUMENTS` would become a required argument called `ARGUMENTS`. rulesync therefore rewrites it to Junie\'s free-form `$prompt` argument and adds `allowPromptArgument: true` to the generated frontmatter (an explicit `junie.allowPromptArgument` in the rulesync command takes precedence). The rewrite is skipped — `$ARGUMENTS` is emitted verbatim — when `junie.allowPromptArgument` is explicitly `false`, or when the body already contains any `$prompt` token, since merging the two would not survive a round-trip. On import, `$prompt` is rewritten back to `$ARGUMENTS` only when the command sets `allowPromptArgument: true` and the body holds no literal `$ARGUMENTS` — without that flag `$prompt` is an ordinary named argument and is kept as-is. Junie documents no shell expansion, so `` !`cmd` `` stays literal. See the [Junie custom slash commands docs](https://junie.jetbrains.com/docs/custom-slash-commands.html).\n\n[^pi]: Pi natively expands `$ARGUMENTS` (along with `$1`, `$2`, `$@`), so `$ARGUMENTS` is a real pass-through there. rulesync still emits `` !`cmd` `` verbatim for Pi, but does not assume Pi expands inline shell snippets — treat that placeholder as literal text on Pi\'s side.\n\nThe translation also runs in reverse when you import an existing tool command file via `rulesync import`, so a tool-native placeholder is rewritten back to the universal form in the generated `.rulesync/commands/*.md`.\n\n## Example\n\nGiven the following rulesync command:\n\n```md\n---\ntargets: ["claudecode"]\ndescription: "Summarize git diff"\n---\n\nSummarize the diff:\n!`git diff`\n\nFocus on $ARGUMENTS.\n```\n\nrulesync generates `.claude/commands/summarize.md`, passing the placeholders through verbatim because Claude Code already understands the universal form.\n\n## Notes\n\n- If you author a command with explicit tool-specific syntax (e.g. you write a tool-native placeholder directly in a rulesync command body), rulesync does **not** re-translate the already-tool-native form. Stick to the universal placeholders to keep commands portable across tools.\n- The translation is purely textual and is applied to the entire body. It does not skip fenced or inline code blocks, so ` ```js\\n$ARGUMENTS\\n``` ` in a rulesync body will still be rewritten when generating tool output. There is **no escape syntax** for the universal placeholders — backslashes are not consumed by the regex, so `\\$ARGUMENTS` is rewritten alongside the placeholder rather than producing a literal `$ARGUMENTS`.\n- The shell expansion regex matches a single backtick-delimited segment without embedded backticks or newlines (`` !`...` ``). Multi-line shell snippets are not supported, and a backtick inside the command body is not allowed.\n', "reference/file-formats": diff --git a/src/types/feature-processor.test.ts b/src/types/feature-processor.test.ts index a5ebe8a16..e21630561 100644 --- a/src/types/feature-processor.test.ts +++ b/src/types/feature-processor.test.ts @@ -227,6 +227,110 @@ describe("FeatureProcessor", () => { expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining("kept.md")); }); + it.skipIf(process.platform === "win32")( + "should refuse to delete through a linked directory that leads out of the output root", + async () => { + const logger = createMockLogger(); + const root = join(testDir, "root"); + const outside = join(testDir, "outside"); + await mkdir(root, { recursive: true }); + await mkdir(outside, { recursive: true }); + // A checked-out repository can link a whole output directory elsewhere; + // the files listed through it belong to that directory, not the project. + await symlink(outside, join(root, ".tool")); + const processor = new TestProcessor({ logger, outputRoot: root }); + + const linkedPath = join(root, ".tool", "notes.md"); + const orphanPath = join(root, "orphan.md"); + const count = await processor.removeOrphanAiFiles( + [ + createMockFile(linkedPath, { outputRoot: root }), + createMockFile(orphanPath, { outputRoot: root }), + ], + [], + ); + + expect(count).toBe(1); + expect(removeFile).toHaveBeenCalledExactlyOnceWith(orphanPath); + expect(processor.getRemovedPaths()).toEqual([{ path: orphanPath, kind: "file" }]); + expect(logger.warn).toHaveBeenCalledWith( + `Refusing to delete ${JSON.stringify(linkedPath)}: it resolves outside ` + + `${JSON.stringify(root)} through a symbolic link`, + ); + }, + ); + + it.skipIf(process.platform === "win32")( + "should refuse a linked directory out of the output root before the dry-run report", + async () => { + const logger = createMockLogger(); + const root = join(testDir, "root"); + const outside = join(testDir, "outside"); + await mkdir(root, { recursive: true }); + await mkdir(outside, { recursive: true }); + await symlink(outside, join(root, ".tool")); + const processor = new TestProcessor({ logger, outputRoot: root, dryRun: true }); + + const count = await processor.removeOrphanAiFiles( + [createMockFile(join(root, ".tool", "notes.md"), { outputRoot: root })], + [], + ); + + // `--check` must not report a deletion a real run would refuse. + expect(count).toBe(0); + expect(processor.getRemovedPaths()).toEqual([]); + expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining("[DRY RUN]")); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("through a symbolic link"), + ); + }, + ); + + it.skipIf(process.platform === "win32")( + "should still delete an orphan that is itself a link out of the output root", + async () => { + const logger = createMockLogger(); + const root = join(testDir, "root"); + const outside = join(testDir, "outside.md"); + await mkdir(join(root, ".tool"), { recursive: true }); + await writeFile(outside, "outside"); + // Removing the link unlinks it where it is spelled; its target is untouched. + const linkPath = join(root, ".tool", "linked.md"); + await symlink(outside, linkPath); + const processor = new TestProcessor({ logger, outputRoot: root }); + + const count = await processor.removeOrphanAiFiles( + [createMockFile(linkPath, { outputRoot: root })], + [], + ); + + expect(count).toBe(1); + expect(removeFile).toHaveBeenCalledExactlyOnceWith(linkPath); + expect(logger.warn).not.toHaveBeenCalled(); + }, + ); + + it.skipIf(process.platform === "win32")( + "should delete through a linked directory that stays inside the output root", + async () => { + const logger = createMockLogger(); + const root = join(testDir, "root"); + await mkdir(join(root, "dotfiles", ".tool"), { recursive: true }); + await symlink(join(root, "dotfiles", ".tool"), join(root, ".tool")); + const processor = new TestProcessor({ logger, outputRoot: root }); + + const linkedPath = join(root, ".tool", "orphan.md"); + const count = await processor.removeOrphanAiFiles( + [createMockFile(linkedPath, { outputRoot: root })], + [], + ); + + expect(count).toBe(1); + expect(removeFile).toHaveBeenCalledExactlyOnceWith(linkedPath); + expect(logger.warn).not.toHaveBeenCalled(); + }, + ); + it("should not remove any files when existing is empty", async () => { const processor = new TestProcessor({ logger: createMockLogger(), outputRoot: testDir }); diff --git a/src/types/feature-processor.ts b/src/types/feature-processor.ts index 53df2efc5..2fb8b61bf 100644 --- a/src/types/feature-processor.ts +++ b/src/types/feature-processor.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { RULESYNC_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js"; import type { DeletedPath } from "../lib/orphan-sweep.js"; @@ -197,8 +197,19 @@ export abstract class FeatureProcessor extends RulesyncSourceConsumer { (f) => !generatedPaths.has(caseFoldIdentity(f.getFilePath())), ); + let removedCount = 0; for (const aiFile of orphanFiles) { const filePath = aiFile.getFilePath(); + // Asked before the dry-run branch so `--check` and a real run agree. + if ( + await refusesDeleteOutsideRoot({ + logger: this.logger, + rootPath: aiFile.getOutputRoot(), + filePath, + }) + ) { + continue; + } // The path is an on-disk name rulesync did not choose, so it is stripped // like every other untrusted string this file logs: a name carrying // `\x1b[2K\r` would otherwise rewrite the line and hide what was deleted. @@ -213,9 +224,10 @@ export abstract class FeatureProcessor extends RulesyncSourceConsumer { this.logger.info(`Deleted: ${loggedPath}`); } this.removedPaths.push({ path: aiFile.getRelativePathFromCwd(), kind: "file" }); + removedCount++; } - return orphanFiles.length; + return removedCount; } /** @@ -383,6 +395,36 @@ export async function refusesWriteOutsideRoot({ return true; } +/** + * Whether deleting `filePath` has to be refused because a link on the way to + * the directory holding it leads out of `rootPath`, warning about the refusal. + * + * Deletion candidates are enumerated without following links *inside* the + * scanned directory, but the scanned directory itself is reached through + * whatever links its path contains: a checked-out repository can carry + * `.cursor/commands -> ~/notes`, and every file in `~/notes` would then read as + * an orphan. Only the parent is judged, because removing a file that is itself + * a link unlinks the link where it is spelled and never touches its target. + */ +async function refusesDeleteOutsideRoot({ + logger, + rootPath, + filePath, +}: { + logger: Logger; + rootPath: string; + filePath: string; +}): Promise { + if (!(await writablePathEscapesRoot({ rootPath, targetPath: dirname(filePath) }))) { + return false; + } + logger.warn( + `Refusing to delete ${quoteForLog(filePath)}: it resolves outside ` + + `${quoteForLog(rootPath)} through a symbolic link`, + ); + return true; +} + /** * {@link refusesWriteOutsideRoot} over every path a single unit of output * writes, stopping at the first refusal: a directory written as one unit is