Skip to content
Merged
10 changes: 8 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,14 @@ mcp-schema.json
docs/.vitepress/dist
docs/.vitepress/cache

**/.rovodev/

# Generated by Rulesync
.rulesync/skills/.curated/
.rulesync/rules/*.local.md
rulesync.local.jsonc
!.rulesync/.aiignore
**/AGENTS.local.md
**/AGENTS.md
**/.agents/
**/.augment/rules/
Expand Down Expand Up @@ -261,7 +264,7 @@ rulesync.local.jsonc
**/.factory/settings.json
**/GEMINI.md
**/.gemini/commands/
**/.gemini/subagents/
**/.gemini/agents/
**/.gemini/skills/
**/.geminiignore
**/.gemini/memories/
Expand Down Expand Up @@ -304,6 +307,9 @@ rulesync.local.jsonc
**/.rooignore
**/.roo/mcp.json
**/.roo/subagents/
**/.rovodev/
**/.rovodev/AGENTS.md
**/.rovodev/subagents/
**/.rovodev/skills/
**/.agents/skills/
**/.warp/
**/WARP.md
2 changes: 1 addition & 1 deletion .lintstagedrc.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export default {
"*": ["npx secretlint"],
"package.json": ["npx sort-package-json"],
"docs/**/*.md": ["tsx scripts/sync-skill-docs.ts", "git add skills/rulesync/"],
"docs/**/*.md": ["node --import tsx/esm scripts/sync-skill-docs.ts", "git add skills/rulesync/"],
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 change from tsx to node --import tsx/esm looks unrelated to the geminicli subagents fix. Consider splitting it into its own commit or at least noting it in the PR description so it does not get lost in the diff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Regarding the .lintstagedrc.js change: this was introduced because the tsx CLI binary creates a Unix IPC socket at /tmp/tsx-*/...pipe during execution. In my local environment (Claude Code's sandbox), access to /tmp/ is restricted, which caused the pre-commit hook to fail on every commit attempt.

Switching to node --import tsx/esm avoids the socket entirely and unblocked the hook for me. That said, this is specific to my sandbox setup — tsx works fine in a normal terminal and the change has no effect for other contributors.

Happy to revert it if you'd prefer to keep the diff clean. Alternatively, node --import tsx/esm is slightly more explicit and avoids spawning an extra subprocess, so it could be kept as a minor improvement. Your call.

// Regenerate tool configurations when rulesync source files change
".rulesync/**/*": [() => "pnpm dev generate"],
};
2 changes: 2 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ Based on the user's instruction, create a plan while analyzing the related files
Attention, again, you are just the planner, so though you can read any files and run any commands for analysis, please don't write any code.
```

> **Gemini CLI note:** Subagents are generated to `.gemini/agents/`. Additionally, rulesync automatically injects `"experimental": { "enableAgents": true }` into `.gemini/settings.json` when generating Gemini CLI subagents, which is required for the agents feature to work.

## `.rulesync/skills/*/SKILL.md`

Example:
Expand Down
2 changes: 2 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ Based on the user's instruction, create a plan while analyzing the related files
Attention, again, you are just the planner, so though you can read any files and run any commands for analysis, please don't write any code.
```

> **Gemini CLI note:** Subagents are generated to `.gemini/agents/`. Additionally, rulesync automatically injects `"experimental": { "enableAgents": true }` into `.gemini/settings.json` when generating Gemini CLI subagents, which is required for the agents feature to work.

## `.rulesync/skills/*/SKILL.md`

Example:
Expand Down
27 changes: 26 additions & 1 deletion src/cli/commands/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};

// Setup processor static method mocks
Expand All @@ -88,6 +89,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
} as any;
});
vi.mocked(IgnoreProcessor).mockImplementation(function () {
Expand All @@ -97,6 +99,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
} as any;
});
vi.mocked(McpProcessor).mockImplementation(function () {
Expand All @@ -106,6 +109,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
} as any;
});
vi.mocked(SubagentsProcessor).mockImplementation(function () {
Expand All @@ -115,6 +119,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
} as any;
});
vi.mocked(CommandsProcessor).mockImplementation(function () {
Expand All @@ -124,6 +129,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
} as any;
});
});
Expand Down Expand Up @@ -230,6 +236,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};
vi.mocked(RulesProcessor).mockImplementation(function () {
return customMockInstance as any;
Expand Down Expand Up @@ -330,6 +337,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};
vi.mocked(McpProcessor).mockImplementation(function () {
return customMockInstance as any;
Expand Down Expand Up @@ -584,7 +592,8 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 0, paths: [] }),
} as any;
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};
});

const options: GenerateOptions = {};
Expand All @@ -604,20 +613,25 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 2, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};

const mcpMock = {
loadToolFiles: vi.fn().mockResolvedValue([]),
removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};

const commandsMock = {
loadToolFiles: vi.fn().mockResolvedValue([]),
removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};

vi.mocked(RulesProcessor).mockImplementation(function () {
Expand Down Expand Up @@ -673,7 +687,9 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};

vi.mocked(RulesProcessor).mockImplementation(function () {
return customMockInstance as any;
});
Expand Down Expand Up @@ -779,6 +795,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};
vi.mocked(RulesProcessor).mockImplementation(function () {
return customMockInstance as any;
Expand Down Expand Up @@ -909,7 +926,9 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 5, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};

vi.mocked(RulesProcessor).mockImplementation(function () {
return customMockInstance as any;
});
Expand Down Expand Up @@ -944,6 +963,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
} as any;
});
vi.mocked(McpProcessor).mockImplementation(function () {
Expand All @@ -953,6 +973,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
} as any;
});
vi.mocked(CommandsProcessor).mockImplementation(function () {
Expand All @@ -962,6 +983,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
} as any;
});
vi.mocked(SubagentsProcessor).mockImplementation(function () {
Expand All @@ -971,6 +993,7 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
} as any;
});

Expand Down Expand Up @@ -1000,7 +1023,9 @@ describe("generateCommand", () => {
loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
writeAiFiles: vi.fn().mockResolvedValue({ count: 2, paths: [] }),
postGenerate: vi.fn().mockResolvedValue({ count: 0, paths: [], hasDiff: false }),
};

vi.mocked(RulesProcessor).mockImplementation(function () {
return mockRulesProcessor as any;
});
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/gitignore-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray<GitignoreEntryTag> = [
// Gemini CLI
{ target: "geminicli", feature: "rules", entry: "**/GEMINI.md" },
{ target: "geminicli", feature: "commands", entry: "**/.gemini/commands/" },
{ target: "geminicli", feature: "subagents", entry: "**/.gemini/subagents/" },
{ target: "geminicli", feature: "subagents", entry: "**/.gemini/agents/" },
{ target: "geminicli", feature: "skills", entry: "**/.gemini/skills/" },
{ target: "geminicli", feature: "ignore", entry: "**/.geminiignore" },
{ target: "geminicli", feature: "general", entry: "**/.gemini/memories/" },
Expand Down
8 changes: 6 additions & 2 deletions src/e2e/e2e-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export async function runGenerate({
features,
...(global ? ["--global"] : []),
];
return execFileAsync(rulesyncCmd, args, env ? { env: { ...process.env, ...env } } : {});
// Filter out NODE_ENV to allow logging in E2E tests
const { NODE_ENV: _env, ...restEnv } = process.env;
return execFileAsync(rulesyncCmd, args, { env: { ...restEnv, ...env } });
}

/**
Expand All @@ -74,7 +76,9 @@ export async function runImport({
features: string;
}): Promise<{ stdout: string; stderr: string }> {
const args = [...rulesyncArgs, "import", "--targets", target, "--features", features];
return execFileAsync(rulesyncCmd, args);
// Filter out NODE_ENV to allow logging in E2E tests
const { NODE_ENV: _env, ...restEnv } = process.env;
return execFileAsync(rulesyncCmd, args, { env: { ...restEnv } });
}

/**
Expand Down
103 changes: 103 additions & 0 deletions src/e2e/e2e-subagents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe("E2E: subagents", () => {
it.each([
{ target: "claudecode", outputPath: join(".claude", "agents", "planner.md") },
{ target: "cursor", outputPath: join(".cursor", "agents", "planner.md") },
{ target: "geminicli", outputPath: join(".gemini", "agents", "planner.md") },
])("should generate $target subagents", async ({ target, outputPath }) => {
const testDir = getTestDir();

Expand All @@ -42,6 +43,108 @@ You are the planner. Analyze files and create a plan.
expect(generatedContent).toContain("Analyze files and create a plan.");
});

it("should inject experimental.enableAgents into .gemini/settings.json when generating geminicli subagents", async () => {
const testDir = getTestDir();

const subagentContent = `---
name: planner
targets: ["geminicli"]
description: "Plans implementation tasks"
---
You are the planner. Analyze files and create a plan.
`;
await writeFileContent(
join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"),
subagentContent,
);

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

const settingsContent = await readFileContent(join(testDir, ".gemini", "settings.json"));
const settings = JSON.parse(settingsContent);
expect(settings.experimental?.enableAgents).toBe(true);
});

it("should preserve existing settings when injecting experimental.enableAgents", async () => {
const testDir = getTestDir();

await writeFileContent(
join(testDir, ".gemini", "settings.json"),
JSON.stringify({ mcpServers: { myServer: { command: "node" } } }, null, 2),
);

const subagentContent = `---
name: planner
targets: ["geminicli"]
description: "Plans implementation tasks"
---
You are the planner.
`;
await writeFileContent(
join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"),
subagentContent,
);

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

const settingsContent = await readFileContent(join(testDir, ".gemini", "settings.json"));
const settings = JSON.parse(settingsContent);
expect(settings.experimental?.enableAgents).toBe(true);
expect(settings.mcpServers).toEqual({ myServer: { command: "node" } });
});

it("should preserve other experimental flags when injecting experimental.enableAgents", async () => {
const testDir = getTestDir();

await writeFileContent(
join(testDir, ".gemini", "settings.json"),
JSON.stringify({ experimental: { otherFlag: true } }, null, 2),
);

const subagentContent = `---
name: planner
targets: ["geminicli"]
description: "Plans implementation tasks"
---
You are the planner.
`;
await writeFileContent(
join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"),
subagentContent,
);

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

const settingsContent = await readFileContent(join(testDir, ".gemini", "settings.json"));
const settings = JSON.parse(settingsContent);
expect(settings.experimental?.enableAgents).toBe(true);
expect(settings.experimental?.otherFlag).toBe(true);
});

it("should be idempotent when generating geminicli subagents", async () => {
const testDir = getTestDir();

const subagentContent = `---
name: planner
targets: ["geminicli"]
description: "Plans implementation tasks"
---
You are the planner.
`;
await writeFileContent(
join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"),
subagentContent,
);

// First run
const result1 = await runGenerate({ target: "geminicli", features: "subagents" });
expect(result1.stdout).toContain(".gemini/settings.json");

// Second run
const result2 = await runGenerate({ target: "geminicli", features: "subagents" });
expect(result2.stdout).not.toContain(".gemini/settings.json");
});

it("should preserve opencode.mode when generating OpenCode subagents", async () => {
const testDir = getTestDir();

Expand Down
Loading