Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/guide/separate-rulesync-dir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Separate Rulesync Directory
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This page isn't registered in docs/.vitepress/config.ts sidebar, so two things follow: (1) the page won't appear in the docs site navigation, and (2) scripts/sync-skill-docs.ts iterates the sidebar, so skills/rulesync/separate-rulesync-dir.md will never be generated. That breaks the repo's docs/skills/rulesync/ sync invariant. Please add an entry under the Guide section and re-run the sync script.

Also: the "global is not applied" note at the end of this file is load-bearing behaviour — worth promoting it out of a blockquote into a ::: warning admonition so readers don't miss it.


The `--rulesync-dir <path>` flag lets you point `rulesync generate` at a `.rulesync/` source directory that is different from the current working directory. This decouples where your rule definitions live from where the generated tool configuration files are written.

## Primary use case: centralized rules across all repos

A common workflow is to keep a single set of AI rules in a shared directory (e.g. `~/.aiglobal`) and apply them to every project without switching directories:

```bash
# In any project directory — rules are read from ~/.aiglobal/.rulesync/
rulesync generate --rulesync-dir ~/.aiglobal --targets "*" --features rules
```

Without `--rulesync-dir`, you would have to `cd ~/.aiglobal && rulesync generate` and then `cd -` back, and the output files would land in `~/.aiglobal` instead of the current project.

## Step-by-step setup

1. Create and initialize a shared rules directory:

```bash
mkdir -p ~/.aiglobal
cd ~/.aiglobal
rulesync init
```

2. Edit your shared rules (`~/.aiglobal/.rulesync/rules/overview.md`, etc.) to your preferences.

3. From any project, generate configurations using the shared rules:

```bash
# In your project directory
rulesync generate --rulesync-dir ~/.aiglobal --targets claudecode --features rules
```

4. (Optional) Add `--rulesync-dir ~/.aiglobal` to a shell alias or your project's Makefile/taskfile so you do not need to type it every time.

## Comparison with `--global`

These two flags serve different but complementary purposes:

| | `--rulesync-dir` | `--global` |
| ------------ | ------------------------------------------------- | ---------------------------------------------------------------------- |
| **Changes** | Source location (where `.rulesync/` is read from) | Output location (writes to user-scope config paths, e.g. `~/.claude/`) |
| **Use when** | Your rule definitions live in a non-CWD directory | You want the output to go to the tool's global (user-scope) config |

They can be combined. For example, to read rules from `~/.aiglobal` and write them to Claude Code's global settings:

```bash
rulesync generate --rulesync-dir ~/.aiglobal --global --targets claudecode --features rules
```

> **Note:** Passing `--rulesync-dir` does not automatically enable `--global`. When `--rulesync-dir` is explicitly provided, Rulesync reads `.rulesync/` from that directory, but output scope still follows the CLI flags: use `--global` for user-scope output, and omit it for project-scope output. A `"global": true` setting in the `rulesync.jsonc` under `--rulesync-dir` is not applied unless you also pass `--global`.
40 changes: 40 additions & 0 deletions docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ rulesync generate --dry-run --targets claudecode --features rules
# Check if files are up to date (for CI/CD pipelines)
rulesync generate --check --targets "*" --features "*"

# Generate from a shared rules directory (without cd-ing into it)
rulesync generate --rulesync-dir ~/.aiglobal --targets "*" --features rules

# Install skills from declarative sources in rulesync.jsonc
rulesync install

Expand Down Expand Up @@ -64,6 +67,43 @@ rulesync update --check
rulesync update --force
```

## Generate Command

The `generate` command reads source files from `.rulesync/` and writes AI tool configuration files to the output directories.

### Options

| Option | Description | Default |
| --------------------------- | ----------------------------------------------------------------------------------------- | --------------------- |
| `--targets, -t <tools>` | Comma-separated list of tools (e.g. `claudecode,copilot` or `*`) | From `rulesync.jsonc` |
| `--features, -f <features>` | Comma-separated list of features (rules, commands, subagents, skills, ignore, mcp, hooks) | From `rulesync.jsonc` |
| `--rulesync-dir <path>` | Path to the directory containing `.rulesync/` source files | CWD |
| `--dry-run` | Show what would change without writing files | `false` |
| `--check` | Like `--dry-run` but exits with code 1 if files are not up to date | `false` |
| `--global` | Generate for global (user-scope) configuration files | `false` |
| `--simulate-commands` | Generate simulated commands for tools that do not support them natively | `false` |
| `--simulate-subagents` | Generate simulated subagents for tools that do not support them natively | `false` |
| `--delete` | Delete existing generated files before writing | From `rulesync.jsonc` |

### Examples

```bash
# Generate all features for all configured tools
rulesync generate

# Generate rules for all tools
rulesync generate --targets "*" --features rules

# Generate from a shared directory without cd-ing into it
rulesync generate --rulesync-dir ~/.aiglobal --targets "*" --features rules

# Dry run: preview changes without writing
rulesync generate --dry-run --targets claudecode --features rules

# CI check: fail if generated files are not up to date
rulesync generate --check --targets "*" --features "*"
```

## Gitignore Command

The `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.
Expand Down
40 changes: 40 additions & 0 deletions skills/rulesync/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ rulesync generate --dry-run --targets claudecode --features rules
# Check if files are up to date (for CI/CD pipelines)
rulesync generate --check --targets "*" --features "*"

# Generate from a shared rules directory (without cd-ing into it)
rulesync generate --rulesync-dir ~/.aiglobal --targets "*" --features rules

# Install skills from declarative sources in rulesync.jsonc
rulesync install

Expand Down Expand Up @@ -64,6 +67,43 @@ rulesync update --check
rulesync update --force
```

## Generate Command

The `generate` command reads source files from `.rulesync/` and writes AI tool configuration files to the output directories.

### Options

| Option | Description | Default |
| --------------------------- | ----------------------------------------------------------------------------------------- | --------------------- |
| `--targets, -t <tools>` | Comma-separated list of tools (e.g. `claudecode,copilot` or `*`) | From `rulesync.jsonc` |
| `--features, -f <features>` | Comma-separated list of features (rules, commands, subagents, skills, ignore, mcp, hooks) | From `rulesync.jsonc` |
| `--rulesync-dir <path>` | Path to the directory containing `.rulesync/` source files | CWD |
| `--dry-run` | Show what would change without writing files | `false` |
| `--check` | Like `--dry-run` but exits with code 1 if files are not up to date | `false` |
| `--global` | Generate for global (user-scope) configuration files | `false` |
| `--simulate-commands` | Generate simulated commands for tools that do not support them natively | `false` |
| `--simulate-subagents` | Generate simulated subagents for tools that do not support them natively | `false` |
| `--delete` | Delete existing generated files before writing | From `rulesync.jsonc` |

### Examples

```bash
# Generate all features for all configured tools
rulesync generate

# Generate rules for all tools
rulesync generate --targets "*" --features rules

# Generate from a shared directory without cd-ing into it
rulesync generate --rulesync-dir ~/.aiglobal --targets "*" --features rules

# Dry run: preview changes without writing
rulesync generate --dry-run --targets claudecode --features rules

# CI check: fail if generated files are not up to date
rulesync generate --check --targets "*" --features "*"
```

## Gitignore Command

The `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.
Expand Down
128 changes: 128 additions & 0 deletions src/cli/commands/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe("generateCommand", () => {
getDryRun: vi.fn().mockReturnValue(false),
getCheck: vi.fn().mockReturnValue(false),
isPreviewMode: vi.fn().mockReturnValue(false),
getRulesyncDir: vi.fn().mockReturnValue(process.cwd()),
};

vi.mocked(ConfigResolver.resolve).mockResolvedValue(mockConfig);
Expand Down Expand Up @@ -1058,6 +1059,133 @@ describe("generateCommand", () => {
});
});

describe("rulesyncDir decoupling", () => {
// Rules source dir (where .rulesync/ lives) is independent of output baseDirs.
const rulesyncDir = "/central/rulesync-source";
const baseDirs = ["/project/app-one", "/project/app-two"];

beforeEach(() => {
mockConfig.getRulesyncDir.mockReturnValue(rulesyncDir);
mockConfig.getBaseDirs.mockReturnValue(baseDirs);
});

it("should check for .rulesync under rulesyncDir, not under baseDirs", async () => {
mockConfig.getFeatures.mockReturnValue(["rules"]);

await generateCommand(mockLogger, {});

expect(fileExists).toHaveBeenCalledWith("/central/rulesync-source/.rulesync");
expect(fileExists).not.toHaveBeenCalledWith("/project/app-one/.rulesync");
expect(fileExists).not.toHaveBeenCalledWith("/project/app-two/.rulesync");
});

it("should construct RulesProcessor with rulesyncDir distinct from baseDir for each output dir", async () => {
mockConfig.getFeatures.mockReturnValue(["rules"]);

await generateCommand(mockLogger, {});

expect(RulesProcessor).toHaveBeenCalledTimes(2);
expect(RulesProcessor).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: "/project/app-one",
rulesyncDir,
toolTarget: "claudecode",
}),
);
expect(RulesProcessor).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: "/project/app-two",
rulesyncDir,
toolTarget: "claudecode",
}),
);
});

it("should pass rulesyncDir to IgnoreProcessor independently of baseDir", async () => {
mockConfig.getFeatures.mockReturnValue(["ignore"]);

await generateCommand(mockLogger, {});

expect(IgnoreProcessor).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: "/project/app-one",
rulesyncDir,
toolTarget: "claudecode",
}),
);
expect(IgnoreProcessor).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: "/project/app-two",
rulesyncDir,
toolTarget: "claudecode",
}),
);
});

it("should pass rulesyncDir to McpProcessor independently of baseDir", async () => {
mockConfig.getFeatures.mockReturnValue(["mcp"]);

await generateCommand(mockLogger, {});

expect(McpProcessor).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: "/project/app-one",
rulesyncDir,
toolTarget: "claudecode",
}),
);
expect(McpProcessor).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: "/project/app-two",
rulesyncDir,
toolTarget: "claudecode",
}),
);
});

it("should pass rulesyncDir to CommandsProcessor independently of baseDir", async () => {
mockConfig.getFeatures.mockReturnValue(["commands"]);

await generateCommand(mockLogger, {});

expect(CommandsProcessor).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: "/project/app-one",
rulesyncDir,
toolTarget: "claudecode",
}),
);
expect(CommandsProcessor).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: "/project/app-two",
rulesyncDir,
toolTarget: "claudecode",
}),
);
});

it("should pass rulesyncDir to SubagentsProcessor independently of baseDir", async () => {
mockConfig.getFeatures.mockReturnValue(["subagents"]);

await generateCommand(mockLogger, {});

expect(SubagentsProcessor).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: "/project/app-one",
rulesyncDir,
toolTarget: "claudecode",
}),
);
expect(SubagentsProcessor).toHaveBeenCalledWith(
expect.objectContaining({
baseDir: "/project/app-two",
rulesyncDir,
toolTarget: "claudecode",
}),
);
});
});

describe("integration scenarios", () => {
it("should handle mixed success and failure scenarios", async () => {
mockConfig.getFeatures.mockReturnValue(["rules", "ignore"]);
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function generateCommand(logger: Logger, options: GenerateOptions):

logger.debug("Generating files...");

if (!(await checkRulesyncDirExists({ baseDir: process.cwd() }))) {
if (!(await checkRulesyncDirExists({ baseDir: config.getRulesyncDir() }))) {
throw new CLIError(
".rulesync directory not found. Run 'rulesync init' first.",
ErrorCodes.RULESYNC_DIR_NOT_FOUND,
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe("importCommand", () => {
getFeatureOptions: vi.fn().mockReturnValue(undefined),
getGlobal: vi.fn().mockReturnValue(false),
getBaseDirs: vi.fn().mockReturnValue(["."]),
getRulesyncDir: vi.fn().mockReturnValue(process.cwd()),
};

vi.mocked(ConfigResolver.resolve).mockResolvedValue(mockConfig);
Expand Down
6 changes: 5 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ const main = async () => {
)
.option("--delete", "Delete all existing files in output directories before generating")
.option(
"-b, --base-dir <paths>",
"-b, --base-dirs <paths>",
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming -b, --base-dir to -b, --base-dirs is a breaking change for anyone already scripting --base-dir (commander will reject the old flag as unknown). The PR description says "default behaviour unchanged", which isn't quite true with this rename bundled in. Could we either keep the old name as a deprecated alias (commander supports defining both) or split the rename into its own release-noted PR?

"Base directories to generate files (comma-separated for multiple paths)",
parseCommaSeparatedList,
)
Expand All @@ -215,6 +215,10 @@ const main = async () => {
"--simulate-skills",
"Generate simulated skills. This feature is only available for copilot, cursor and codexcli.",
)
.option(
"--rulesync-dir <path>",
"Path to the directory containing .rulesync/ (parent of .rulesync/)",
)
.option("--dry-run", "Dry run: show changes without writing files")
.option("--check", "Check if files are up to date (exits with code 1 if changes needed)")
.action(
Expand Down
Loading