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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ Edit the `config.json` file to define your setup.
{
"rulesSource": "/path/to/my/central/rules/repository",
"global": ["global-rules/*.md"],
"globalOverrides": {
"claude": ["claude-specific/*.md"],
"codex": ["codex-specific/*.md"]
},
"projects": [
{
"path": "~/Developer/my-backend-api",
Expand All @@ -76,10 +80,13 @@ Edit the `config.json` file to define your setup.

- `rulesSource`: The central directory where you store your rule files (e.g., Markdown files). If omitted, it defaults to the system's data directory.
- `global`: Optional POSIX globs for rules that are combined and written to built-in global target files for supported tools (e.g., `~/.claude/CLAUDE.md`, `~/.gemini/AGENTS.md`, `~/.config/opencode/AGENTS.md`, `~/.codex/AGENTS.md`).
- `projects`: An array defining each project.
- `globalOverrides`: Optional per-harness override globs. Each key is a harness name (`claude`, `gemini`, `opencode`, `codex`) with its own glob patterns. Override rules are appended after the shared `global` rules for that harness only. A rule file must not appear in both `global` and an override for the same harness.
- `projects`: Optional array defining each project. Can be omitted for a globals-only config.
- `path`: The root directory of the project (supports `~` for home directory).
- `rules`: POSIX-style glob patterns to select files from `rulesSource`. Supports negation (`!`).

Config must specify at least one of `global`, `globalOverrides`, or `projects`.

### 3\. Synchronize Rules

To synchronize the rules for all configured projects, run the default command:
Expand Down
41 changes: 41 additions & 0 deletions src/cli/collect-pattern-warnings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export type PatternWarning = {
source: string;
patterns: string[];
};

const GLOBAL_OVERRIDE_PATTERN = /^globalOverrides\.([^:]+): (.+)$/u;

function addPatternWarning(
warnings: PatternWarning[],
source: string,
pattern: string,
): void {
const existing = warnings.find((warning) => warning.source === source);
if (existing) {
existing.patterns.push(pattern);
return;
}
warnings.push({ source, patterns: [pattern] });
}

/**
* Convert raw unmatched patterns from syncGlobal into structured warnings.
*
* Patterns prefixed with `globalOverrides.<harness>:` are grouped under
* the corresponding override source; everything else is grouped under
* `"global"`.
*/
export function collectGlobalPatternWarnings(
unmatchedPatterns: string[],
): PatternWarning[] {
const warnings: PatternWarning[] = [];
for (const pattern of unmatchedPatterns) {
const match = GLOBAL_OVERRIDE_PATTERN.exec(pattern);
if (match?.[1] && match[2]) {
addPatternWarning(warnings, `globalOverrides.${match[1]}`, match[2]);
continue;
}
addPatternWarning(warnings, "global", pattern);
}
return warnings;
}
26 changes: 12 additions & 14 deletions src/cli/run-sync-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import type { SyncResult } from "../core/sync.js";
import type { SkippedEntry } from "../core/execution.js";
import { formatSyncFailureMessage } from "./format-sync-failures.js";

import { collectGlobalPatternWarnings } from "./collect-pattern-warnings.js";
import type { PatternWarning } from "./collect-pattern-warnings.js";

interface SyncCommandOptions {
configPath: string;
verbose: boolean;
Expand All @@ -14,11 +17,6 @@ interface SyncCommandOptions {
json: boolean;
}

type PatternWarning = {
source: string;
patterns: string[];
};

function isFulfilled(
s: PromiseSettledResult<unknown>,
): s is PromiseFulfilledResult<SyncResult> {
Expand All @@ -31,21 +29,18 @@ export async function runSyncCommand(
const { configPath, verbose, dryRun, porcelain, json } = options;
const config = await loadConfig(configPath || DEFAULT_CONFIG_PATH);

const projectsToSync: Project[] = config.projects;
const projectsToSync: Project[] = config.projects ?? [];
Comment thread
Jercik marked this conversation as resolved.
Comment thread
Jercik marked this conversation as resolved.

const { syncProject } = await import("../core/sync.js");
const { syncGlobal } = await import("../core/sync-global.js");

const globalResult = await syncGlobal({ dryRun }, config);

// Track warnings for unmatched patterns
const patternWarnings: PatternWarning[] = [];
if (globalResult.unmatchedPatterns.length > 0) {
patternWarnings.push({
source: "global",
patterns: globalResult.unmatchedPatterns,
});
}
const patternWarnings: PatternWarning[] =
globalResult.unmatchedPatterns.length > 0
? collectGlobalPatternWarnings(globalResult.unmatchedPatterns)
: [];

const settlements = await Promise.allSettled(
projectsToSync.map(async (project: Project) => {
Expand Down Expand Up @@ -165,8 +160,11 @@ export async function runSyncCommand(

const totalWrites = allWritten.length;

if (projectsToSync.length === 0) {
if (projectsToSync.length === 0 && totalWrites === 0) {
console.error("No projects configured; nothing to do.");
} else if (projectsToSync.length === 0) {
Comment thread
Jercik marked this conversation as resolved.
const action = dryRun ? "Would write" : "Wrote";
console.error(`${action} ${String(totalWrites)} global file(s).`);
} else if (totalWrites === 0) {
console.error("No changes. Rules matched no files or files up to date.");
} else {
Expand Down
134 changes: 126 additions & 8 deletions src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ describe("config", () => {

const config = parseConfig(json);
expect(config.projects).toHaveLength(1);
const projects = config.projects ?? [];
// normalizePath will expand ~ to full home directory
expect(config.projects[0]?.path).toMatch(/\/Developer\/project$/u);
expect(config.projects[0]?.rules).toEqual(["python.md"]);
expect(projects[0]?.path).toMatch(/\/Developer\/project$/u);
expect(projects[0]?.rules).toEqual(["python.md"]);
});

it("parses multiple projects", () => {
Expand All @@ -60,7 +61,8 @@ describe("config", () => {

const config = parseConfig(json);
expect(config.projects).toHaveLength(2);
expect(config.projects[1]?.rules).toEqual(["frontend/**/*.md"]);
const projects = config.projects ?? [];
expect(projects[1]?.rules).toEqual(["frontend/**/*.md"]);
});

it("accepts positive and negative POSIX globs in 'rules'", () => {
Expand All @@ -75,7 +77,8 @@ describe("config", () => {
});

const config = parseConfig(json);
expect(config.projects[0]?.rules).toEqual([
const projects = config.projects ?? [];
expect(projects[0]?.rules).toEqual([
"**/*.md",
"frontend/**",
"!test/**",
Expand All @@ -90,8 +93,72 @@ describe("config", () => {
expectTypeOf(config.rulesSource).toBeString();
expect(config.rulesSource).toMatch(/sync-rules[/\\]rules$/u);
expect(config.projects).toHaveLength(1);
expect(config.projects[0]?.path).toMatch(/\//u);
expect(config.projects[0]?.rules).toEqual(["test.md"]);
const projects = config.projects ?? [];
expect(projects[0]?.path).toMatch(/\//u);
expect(projects[0]?.rules).toEqual(["test.md"]);
});

it("accepts config with only global (no projects)", () => {
const json = JSON.stringify({
rulesSource: "/path/to/rules",
global: ["global-rules/*.md"],
});
const config = parseConfig(json);
expect(config.global).toEqual(["global-rules/*.md"]);
expect(config.projects).toBeUndefined();
});

it("accepts config with only globalOverrides (no projects, no global)", () => {
const json = JSON.stringify({
rulesSource: "/path/to/rules",
globalOverrides: {
claude: ["claude-specific/*.md"],
},
});
const config = parseConfig(json);
expect(config.globalOverrides).toEqual({
claude: ["claude-specific/*.md"],
});
expect(config.projects).toBeUndefined();
expect(config.global).toBeUndefined();
});

it("accepts config with global, globalOverrides, and projects", () => {
const json = JSON.stringify({
rulesSource: "/path/to/rules",
global: ["shared/*.md"],
globalOverrides: {
gemini: ["gemini/*.md"],
codex: ["codex/*.md"],
},
projects: [{ path: "./app", rules: ["**/*.md"] }],
});
const config = parseConfig(json);
expect(config.global).toEqual(["shared/*.md"]);
expect(config.globalOverrides).toEqual({
gemini: ["gemini/*.md"],
codex: ["codex/*.md"],
});
expect(config.projects).toHaveLength(1);
});

it("accepts all valid harness names in globalOverrides", () => {
const json = JSON.stringify({
rulesSource: "/path/to/rules",
globalOverrides: {
claude: ["claude/*.md"],
gemini: ["gemini/*.md"],
opencode: ["opencode/*.md"],
codex: ["codex/*.md"],
},
});
const config = parseConfig(json);
expect(Object.keys(config.globalOverrides ?? {})).toEqual([
"claude",
"gemini",
"opencode",
"codex",
]);
});
});

Expand Down Expand Up @@ -171,6 +238,56 @@ describe("config", () => {
});

// Root-level validation errors are covered by the table-driven tests

it("rejects unknown harness names in globalOverrides", () => {
const json = JSON.stringify({
rulesSource: "/path/to/rules",
globalOverrides: {
unknown_harness: ["some/*.md"],
},
});
expect(() => parseConfig(json)).toThrowError(z.ZodError);

let zodError: z.ZodError | undefined;
try {
parseConfig(json);
} catch (error) {
zodError = error as z.ZodError;
}
expect(zodError).toBeDefined();
expect(
zodError?.issues.some((issue) =>
issue.message.includes('Unknown harness "unknown_harness"'),
),
).toBe(true);
});

it("rejects empty glob array in globalOverrides", () => {
const json = JSON.stringify({
rulesSource: "/path/to/rules",
globalOverrides: {
claude: [],
},
});
expect(() => parseConfig(json)).toThrowError(z.ZodError);
});

it("rejects globalOverrides with only negative globs", () => {
const json = JSON.stringify({
rulesSource: "/path/to/rules",
globalOverrides: {
gemini: ["!exclude/*.md"],
},
});
expect(() => parseConfig(json)).toThrowError(z.ZodError);
});

it("rejects config with no global, no globalOverrides, and no projects", () => {
const json = JSON.stringify({
rulesSource: "/path/to/rules",
});
expect(() => parseConfig(json)).toThrowError(z.ZodError);
});
});

// Edge case with many projects removed - adds time with little signal
Expand Down Expand Up @@ -442,8 +559,9 @@ describe("config", () => {
const config = await loadConfig("/path/to/config.json");

// Path should be normalized (~ expanded)
expect(config.projects[0]?.path).toMatch(/\/project$/u);
expect(config.projects[0]?.path).not.toContain("~");
const projects = config.projects ?? [];
expect(projects[0]?.path).toMatch(/\/project$/u);
expect(projects[0]?.path).not.toContain("~");
});
});
});
Loading
Loading