Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ rulesync generate --dry-run --targets claudecode --features rules
rulesync generate --check --targets "*" --features "*"
```

### Shared config files are never created empty

Some 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`, `.devin/config.json`, `.factory/settings.json`, `.grok/config.toml`, `.vibe/config.toml`, `.vscode/settings.json`, `.zed/settings.json`, `kilo.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.

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

## 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
6 changes: 6 additions & 0 deletions skills/rulesync/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ rulesync generate --dry-run --targets claudecode --features rules
rulesync generate --check --targets "*" --features "*"
```

### Shared config files are never created empty

Some 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`, `.devin/config.json`, `.factory/settings.json`, `.grok/config.toml`, `.vibe/config.toml`, `.vscode/settings.json`, `.zed/settings.json`, `kilo.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.

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

## 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
29 changes: 7 additions & 22 deletions src/cli/commands/gitignore-derive.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SHARED_USER_MANAGED_CONFIG_PATHS } from "../../constants/shared-config-paths.js";
import type { ToolRuleExtraFixedFile } from "../../features/rules/tool-rule.js";
import type { Feature } from "../../types/features.js";
import { getProcessorRegistryEntry } from "../../types/processor-registry.js";
Expand All @@ -22,28 +23,12 @@ const TARGETS_NOT_DERIVED: ReadonlySet<string> = new Set([

// Project-scope outputs that rulesync merges into rather than fully owns
// (user-managed settings files), so they are deliberately not gitignored even
// though a feature emits them. Most paths come straight from a tool's default
// getSettablePaths; `.amp/settings.jsonc` (runtime probe twin of
// `.amp/settings.json`) and `.claude/settings.local.json` (claudecode ignore
// `fileMode: "local"` variant) are emitted only under non-default options.
export const DERIVED_PATHS_NOT_GITIGNORED: ReadonlySet<string> = new Set([
"**/.amp/settings.json",
"**/.amp/settings.jsonc",
"**/.antigravity/settings.json",
"**/.claude/settings.json",
"**/.claude/settings.local.json",
"**/.codex/config.toml",
"**/.devin/config.json",
"**/.factory/settings.json",
"**/.grok/config.toml",
"**/.vibe/config.toml",
"**/reasonix.toml",
"**/.vscode/settings.json",
"**/.zed/settings.json",
"**/kilo.json",
"**/kilo.jsonc",
"**/opencode.json",
]);
// though a feature emits them. The list itself lives in
// `src/constants/shared-config-paths.ts` because the same set also decides
// which files must not be created just to hold an empty payload.
export const DERIVED_PATHS_NOT_GITIGNORED: ReadonlySet<string> = new Set(
SHARED_USER_MANAGED_CONFIG_PATHS.map((path) => `**/${path}`),
);

const toPosix = (path: string): string => path.replace(/\\/g, "/");

Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/gitignore-entries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ describe("registry derivation", () => {
"**/.amp/settings.jsonc",
// claudecode ignore feature with `fileMode: "local"`.
"**/.claude/settings.local.json",
// Preferred over `opencode.json` when neither file exists yet, so it is
// chosen at write time rather than declared by getSettablePaths.
"**/opencode.jsonc",
]);
const rawEntries = new Set(deriveAllGitignoreEntriesUnfiltered().map((tag) => tag.entry));
const stale = [...DERIVED_PATHS_NOT_GITIGNORED].filter(
Expand Down
56 changes: 56 additions & 0 deletions src/constants/shared-config-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";

import {
isSharedUserManagedConfigPath,
SHARED_USER_MANAGED_CONFIG_PATHS,
} from "./shared-config-paths.js";

describe("SHARED_USER_MANAGED_CONFIG_PATHS", () => {
it("stores paths without the glob prefix so the gitignore derivation can add it", () => {
for (const path of SHARED_USER_MANAGED_CONFIG_PATHS) {
expect(path.startsWith("*")).toBe(false);
expect(path.startsWith("/")).toBe(false);
}
});
});

describe("isSharedUserManagedConfigPath", () => {
it("matches a listed path at the project root", () => {
expect(isSharedUserManagedConfigPath(".antigravity/settings.json")).toBe(true);
expect(isSharedUserManagedConfigPath(".factory/settings.json")).toBe(true);
expect(isSharedUserManagedConfigPath("opencode.json")).toBe(true);
});

it("matches both twins of a path a generator picks at write time", () => {
expect(isSharedUserManagedConfigPath("opencode.json")).toBe(true);
expect(isSharedUserManagedConfigPath("opencode.jsonc")).toBe(true);
expect(isSharedUserManagedConfigPath("kilo.json")).toBe(true);
expect(isSharedUserManagedConfigPath("kilo.jsonc")).toBe(true);
expect(isSharedUserManagedConfigPath(".amp/settings.json")).toBe(true);
expect(isSharedUserManagedConfigPath(".amp/settings.jsonc")).toBe(true);
});

it("compares the whole tool-relative path, not a suffix of it", () => {
// The output root is never part of the compared path, so a deeper path can
// only come from a tool that relocates the file — a different file.
expect(isSharedUserManagedConfigPath("packages/app/.vscode/settings.json")).toBe(false);
expect(isSharedUserManagedConfigPath(".config/zed/settings.json")).toBe(false);
});

it("normalizes a leading ./ and native separators", () => {
expect(isSharedUserManagedConfigPath("./.claude/settings.json")).toBe(true);
expect(isSharedUserManagedConfigPath(".claude\\settings.json")).toBe(true);
});

it("does not match files rulesync owns outright", () => {
expect(isSharedUserManagedConfigPath(".agents/hooks.json")).toBe(false);
expect(isSharedUserManagedConfigPath(".cursor/mcp.json")).toBe(false);
expect(isSharedUserManagedConfigPath(".junie/allowlist.json")).toBe(false);
expect(isSharedUserManagedConfigPath("AGENTS.md")).toBe(false);
});

it("does not match on a partial final segment", () => {
expect(isSharedUserManagedConfigPath("my-opencode.json")).toBe(false);
expect(isSharedUserManagedConfigPath("not-reasonix.toml")).toBe(false);
});
});
58 changes: 58 additions & 0 deletions src/constants/shared-config-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { toPosixPath } from "../utils/file.js";

/**
* Tool outputs that rulesync merges into rather than fully owns (user-managed
* settings files), as paths relative to the output root. Most come straight from
* a tool's default `getSettablePaths`; the rest are twins a generator only
* chooses at write time or under non-default options: `.amp/settings.jsonc`
* (runtime probe twin of `.amp/settings.json`), `opencode.jsonc` / `kilo.jsonc`
* (preferred over the `.json` twin when neither file exists yet), and
* `.claude/settings.local.json` (claudecode ignore `fileMode: "local"`).
*
* Two behaviors are derived from this single list:
*
* - They are deliberately **not** gitignored (`DERIVED_PATHS_NOT_GITIGNORED` in
* `src/cli/commands/gitignore-derive.ts`), because a user may hand-author
* settings in them that should stay version-controlled.
* - Because they are committable, rulesync must not **create** one just to hold
* an empty payload — that would be pure `git status` noise. See
* `AiFile#shouldSkipCreationWhenPayloadEmpty()`.
*
* Paths are stored without the leading "**" glob prefix; the gitignore
* derivation adds it.
*/
export const SHARED_USER_MANAGED_CONFIG_PATHS: readonly string[] = [
".amp/settings.json",
".amp/settings.jsonc",
".antigravity/settings.json",
".claude/settings.json",
".claude/settings.local.json",
".codex/config.toml",
".devin/config.json",
".factory/settings.json",
".grok/config.toml",
".vibe/config.toml",
"reasonix.toml",
".vscode/settings.json",
".zed/settings.json",
"kilo.json",
"kilo.jsonc",
"opencode.json",
"opencode.jsonc",
];

/**
* Whether an output path is one of the shared, user-managed config files above.
*
* `relativePath` is the tool-relative path (`relativeDirPath` + file name, POSIX
* or native separators), never the output root, so the comparison is exact
* rather than a suffix match. Global-scope outputs are covered exactly when the
* tool keeps the same relative layout under the home directory (e.g.
* `~/.claude/settings.json`); a tool that relocates its global file (e.g. Zed's
* `~/.config/zed/settings.json`) is not matched, which is harmless because the
* `git status` noise this guards against is project-scope by nature.
*/
export function isSharedUserManagedConfigPath(relativePath: string): boolean {
const normalized = toPosixPath(relativePath).replace(/^\.\//, "");
return SHARED_USER_MANAGED_CONFIG_PATHS.includes(normalized);
}
31 changes: 30 additions & 1 deletion src/e2e/e2e-permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
RULESYNC_PERMISSIONS_SCHEMA_URL,
} from "../constants/rulesync-paths.js";
import { PermissionsProcessor } from "../features/permissions/permissions-processor.js";
import { readFileContent, writeFileContent } from "../utils/file.js";
import { fileExists, readFileContent, writeFileContent } from "../utils/file.js";
import {
assertGenerateMatrixCoversTargets,
runGenerate,
Expand Down Expand Up @@ -88,6 +88,35 @@ describe("E2E: permissions", () => {
});
});

it.each([
{ target: "antigravity-ide", relativePaths: [[".antigravity", "settings.json"]] },
{ target: "factorydroid", relativePaths: [[".factory", "settings.json"]] },
{ target: "copilot", relativePaths: [[".vscode", "settings.json"]] },
// opencode writes the `.jsonc` twin when neither file exists yet, so both
// spellings must stay absent.
{ target: "opencode", relativePaths: [["opencode.json"], ["opencode.jsonc"]] },
])(
"should not create the shared $target config file when the permissions payload is empty",
async ({ target, relativePaths }) => {
const testDir = getTestDir();

// A permissions file whose categories map to nothing this tool models, so
// the merge payload for the shared config file comes out empty. These
// paths are deliberately not gitignored, so creating them would leave
// untracked files with no content behind after every generate.
await writeFileContent(
join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH),
JSON.stringify({ permission: {} }, null, 2),
);

await runGenerate({ target, features: "permissions" });

for (const relativePath of relativePaths) {
expect(await fileExists(join(testDir, ...relativePath))).toBe(false);
}
},
);

it("should generate claudecode permissions into .claude/settings.json", async () => {
const testDir = getTestDir();

Expand Down
7 changes: 2 additions & 5 deletions src/features/hooks/antigravity-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
CANONICAL_TO_ANTIGRAVITY_EVENT_NAMES,
} from "../../types/hooks.js";
import { formatError } from "../../utils/error.js";
import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js";
import { readFileContentOrNull } from "../../utils/file.js";
import type { Logger } from "../../utils/logger.js";
import { isPrototypePollutionKey } from "../../utils/prototype-pollution.js";
import type { RulesyncHooks } from "./rulesync-hooks.js";
Expand Down Expand Up @@ -161,11 +161,8 @@ class AntigravityHooks extends ToolHooks {
logger?: Logger;
}): Promise<AntigravityHooks> {
const paths = this.getSettablePaths({ global });
const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
// hooks.json is dedicated to hooks, so any existing content is fully
// replaced; reading it first keeps a stable round-trip when unchanged.
await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));

// replaced; the write happens later in `writeAiFiles`.
const config = rulesyncHooks.getJson();
const eventMap = canonicalToToolHooks({
config,
Expand Down
7 changes: 2 additions & 5 deletions src/features/hooks/augmentcode-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from "../../types/hooks.js";
import { readAugmentcodeSettingsWithLocalOverlay } from "../../utils/augmentcode-settings.js";
import { formatError } from "../../utils/error.js";
import { readOrInitializeFileContent } from "../../utils/file.js";
import { readFileContentOrNull } from "../../utils/file.js";
import type { Logger } from "../../utils/logger.js";
import { applySharedConfigPatch, sharedConfigFileKey } from "../shared/shared-config-gateway.js";
import type { RulesyncHooks } from "./rulesync-hooks.js";
Expand Down Expand Up @@ -122,10 +122,7 @@ export class AugmentcodeHooks extends ToolHooks {
}): Promise<AugmentcodeHooks> {
const paths = AugmentcodeHooks.getSettablePaths({ global });
const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
const existingContent = await readOrInitializeFileContent(
filePath,
JSON.stringify({}, null, 2),
);
const existingContent = (await readFileContentOrNull(filePath)) ?? JSON.stringify({}, null, 2);
const config = rulesyncHooks.getJson();
const augmentHooks = canonicalToToolHooks({
config,
Expand Down
7 changes: 2 additions & 5 deletions src/features/hooks/claudecode-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
CANONICAL_TO_CLAUDE_EVENT_NAMES,
} from "../../types/hooks.js";
import { formatError } from "../../utils/error.js";
import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js";
import { readFileContentOrNull } from "../../utils/file.js";
import type { Logger } from "../../utils/logger.js";
import {
applySharedConfigPatch,
Expand Down Expand Up @@ -110,10 +110,7 @@ export class ClaudecodeHooks extends ToolHooks {
}): Promise<ClaudecodeHooks> {
const paths = this.getSettablePaths({ global });
const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
const existingContent = await readOrInitializeFileContent(
filePath,
JSON.stringify({}, null, 2),
);
const existingContent = (await readFileContentOrNull(filePath)) ?? JSON.stringify({}, null, 2);
const config = rulesyncHooks.getJson();
const claudeHooks = canonicalToToolHooks({
config,
Expand Down
13 changes: 5 additions & 8 deletions src/features/hooks/devin-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
DEVIN_TO_CANONICAL_EVENT_NAMES,
} from "../../types/hooks.js";
import { formatError } from "../../utils/error.js";
import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js";
import { readFileContentOrNull } from "../../utils/file.js";
import type { Logger } from "../../utils/logger.js";
import { isRecord } from "../../utils/type-guards.js";
import { applySharedConfigPatch, sharedConfigFileKey } from "../shared/shared-config-gateway.js";
Expand Down Expand Up @@ -149,10 +149,8 @@ export class DevinHooks extends ToolHooks {
// Global hooks live under the `hooks` key of the shared config.json, which
// also carries `mcpServers` / `permissions` from the other features, so
// read-modify-write and preserve the sibling keys.
const existingContent = await readOrInitializeFileContent(
filePath,
JSON.stringify({}, null, 2),
);
const existingContent =
(await readFileContentOrNull(filePath)) ?? JSON.stringify({}, null, 2);
fileContent = applySharedConfigPatch({
fileKey: sharedConfigFileKey(paths),
feature: "hooks",
Expand All @@ -161,9 +159,8 @@ export class DevinHooks extends ToolHooks {
filePath,
});
} else {
// The project hooks.v1.json is dedicated to hooks; reading it first keeps a
// stable round-trip when unchanged.
await readOrInitializeFileContent(filePath, JSON.stringify({}, null, 2));
// The project hooks.v1.json is dedicated to hooks, so any existing content
// is fully replaced; the write happens later in `writeAiFiles`.
fileContent = JSON.stringify(devinHooks, null, 2);
}

Expand Down
7 changes: 2 additions & 5 deletions src/features/hooks/factorydroid-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
CANONICAL_TO_FACTORYDROID_EVENT_NAMES,
} from "../../types/hooks.js";
import { formatError } from "../../utils/error.js";
import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js";
import { readFileContentOrNull } from "../../utils/file.js";
import type { Logger } from "../../utils/logger.js";
import type { RulesyncHooks } from "./rulesync-hooks.js";
import type { ToolHooksConverterConfig } from "./tool-hooks-converter.js";
Expand Down Expand Up @@ -96,10 +96,7 @@ export class FactorydroidHooks extends ToolHooks {
}): Promise<FactorydroidHooks> {
const paths = FactorydroidHooks.getSettablePaths({ global });
const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
const existingContent = await readOrInitializeFileContent(
filePath,
JSON.stringify({}, null, 2),
);
const existingContent = (await readFileContentOrNull(filePath)) ?? JSON.stringify({}, null, 2);
let settings: Record<string, unknown>;
try {
settings = JSON.parse(existingContent);
Expand Down
Loading
Loading