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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ rulesync.local.jsonc
**/.goosehints
**/.goose/
**/.gooseignore
**/.agents/plugins/
**/.github/copilot-instructions.md
**/.github/instructions/
**/.github/prompts/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte
| Claude Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Codex CLI | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Gemini CLI ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Goose | ✅ | ✅ | | | | | | |
| Goose | ✅ | ✅ | | | | | | |
| GitHub Copilot | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | |
| GitHub Copilot CLI | ✅ | | ✅ | | ✅ | | ✅ | |
| Cursor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Expand Down
98 changes: 62 additions & 36 deletions docs/reference/file-formats.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/reference/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
| Gemini CLI ⚠️ | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | |
| GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | |
| Goose | goose | ✅ 🌏 | ✅ | | | | | | |
| Goose | goose | ✅ 🌏 | ✅ | | | | | ✅ 🌏 | |
| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
| deepagents-cli | deepagents | ✅ | | ✅ 🌏 | | ✅ | ✅ | 🌏 | |
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | 🎮 | 🎮 | 🎮 | ✅ 🌏 | |
Expand Down
98 changes: 62 additions & 36 deletions skills/rulesync/file-formats.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion skills/rulesync/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
| Gemini CLI ⚠️ | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | |
| GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | |
| Goose | goose | ✅ 🌏 | ✅ | | | | | | |
| Goose | goose | ✅ 🌏 | ✅ | | | | | ✅ 🌏 | |
| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
| deepagents-cli | deepagents | ✅ | | ✅ 🌏 | | ✅ | ✅ | 🌏 | |
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | 🎮 | 🎮 | 🎮 | ✅ 🌏 | |
Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/gitignore-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray<GitignoreEntryTag> = [
{ target: "goose", feature: "rules", entry: "**/.goosehints" },
{ target: "goose", feature: "rules", entry: "**/.goose/" },
{ target: "goose", feature: "ignore", entry: "**/.gooseignore" },
// Goose lifecycle hooks plugin (.agents/plugins/<name>/hooks/hooks.json)
{ target: "goose", feature: "hooks", entry: "**/.agents/plugins/" },

// GitHub Copilot
{
Expand Down
25 changes: 23 additions & 2 deletions src/e2e/e2e-hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ describe("E2E: hooks", () => {
{ target: "opencode", outputPath: join(".opencode", "plugins", "rulesync-hooks.js") },
{ target: "codexcli", outputPath: join(".codex", "hooks.json") },
{ target: "geminicli", outputPath: join(".gemini", "settings.json") },
{
target: "goose",
outputPath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"),
},
{ target: "copilot", outputPath: join(".github", "hooks", "copilot-hooks.json") },
{ target: "copilotcli", outputPath: join(".github", "hooks", "copilotcli-hooks.json") },
{ target: "factorydroid", outputPath: join(".factory", "settings.json") },
Expand Down Expand Up @@ -117,8 +121,8 @@ describe("E2E: hooks", () => {
expect(parsed.rulesync.Stop).toBeDefined();
expect(JSON.stringify(parsed)).toContain(".rulesync/hooks/audit.sh");
} else {
// codexcli, geminicli, factorydroid: event-name casing/mapping varies
// per tool, so verify the configured hook command paths are preserved.
// codexcli, geminicli, factorydroid, goose: event-name casing/mapping
// varies per tool, so verify the configured hook command paths are preserved.
assertHookCommandsPreserved(parsed);
}
}
Expand Down Expand Up @@ -343,6 +347,19 @@ describe("E2E: hooks (import)", () => {
},
},
},
{
// Goose reads `.agents/plugins/<name>/hooks/hooks.json` with Claude-style
// PascalCase event names; SessionStart round-trips to canonical `sessionStart`.
target: "goose",
sourcePath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"),
sourceContent: {
hooks: {
SessionStart: [
{ matcher: "", hooks: [{ type: "command", command: "echo session started" }] },
],
},
},
},
])(
"should import $target hooks",
async ({ target, sourcePath, sourceContent, expectedEvent }) => {
Expand All @@ -367,6 +384,10 @@ describe("E2E: hooks (global mode)", () => {
{ target: "claudecode", outputPath: join(".claude", "settings.json") },
{ target: "codexcli", outputPath: join(".codex", "hooks.json") },
{ target: "geminicli", outputPath: join(".gemini", "settings.json") },
{
target: "goose",
outputPath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"),
},
{ target: "opencode", outputPath: join(".config", "opencode", "plugins", "rulesync-hooks.js") },
{ target: "factorydroid", outputPath: join(".factory", "settings.json") },
{ target: "deepagents", outputPath: join(".deepagents", "hooks.json") },
Expand Down
27 changes: 27 additions & 0 deletions src/features/commands/claudecode-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,33 @@ Roundtrip body`;
expect(result.success).toBe(false);
});

it("should accept disallowed-tools in both string and list forms", () => {
const listForm = ClaudecodeCommandFrontmatterSchema.safeParse({
description: "Valid description",
"disallowed-tools": ["Bash", "Edit"],
});
expect(listForm.success).toBe(true);
if (listForm.success) {
expect(listForm.data["disallowed-tools"]).toEqual(["Bash", "Edit"]);
}

const stringForm = ClaudecodeCommandFrontmatterSchema.safeParse({
description: "Valid description",
"disallowed-tools": "Bash, Edit",
});
expect(stringForm.success).toBe(true);
if (stringForm.success) {
expect(stringForm.data["disallowed-tools"]).toBe("Bash, Edit");
}
});

it("should reject invalid disallowed-tools definitions", () => {
const invalidFrontmatter = { description: "Valid description", "disallowed-tools": 123 };
const result = ClaudecodeCommandFrontmatterSchema.safeParse(invalidFrontmatter);

expect(result.success).toBe(false);
});

it("should reject invalid disable-model-invocation definitions", () => {
const invalidFrontmatter = {
description: "Valid description",
Expand Down
3 changes: 3 additions & 0 deletions src/features/commands/claudecode-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import {
export const ClaudecodeCommandFrontmatterSchema = z.looseObject({
description: z.optional(z.string()),
"allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
// Removes the listed tools from the model while the command is active.
// Accepts the space/comma-separated string form or a YAML list, mirroring `allowed-tools`.
"disallowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
"argument-hint": z.optional(z.string()),
model: z.optional(z.string()),
"disable-model-invocation": z.optional(z.boolean()),
Expand Down
30 changes: 30 additions & 0 deletions src/features/hooks/claudecode-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,36 @@ describe("ClaudecodeHooks", () => {
expect(parsed.hooks.WorktreeRemove[0].matcher).toBeUndefined();
});

it("should NOT emit matcher for messageDisplay even if defined in config", async () => {
await ensureDir(join(testDir, ".claude"));
await writeFileContent(join(testDir, ".claude", "settings.json"), JSON.stringify({}));

const config = {
version: 1,
hooks: {
messageDisplay: [{ type: "command", command: "display.sh", matcher: "*.md" }],
},
};
const rulesyncHooks = new RulesyncHooks({
outputRoot: testDir,
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: "hooks.json",
fileContent: JSON.stringify(config),
validate: false,
});

const claudecodeHooks = await ClaudecodeHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks,
validate: false,
});

const content = claudecodeHooks.getFileContent();
const parsed = JSON.parse(content);
expect(parsed.hooks.MessageDisplay).toBeDefined();
expect(parsed.hooks.MessageDisplay[0].matcher).toBeUndefined();
});

it("should warn when matcher is defined on worktree events", async () => {
await ensureDir(join(testDir, ".claude"));
await writeFileContent(join(testDir, ".claude", "settings.json"), JSON.stringify({}));
Expand Down
6 changes: 5 additions & 1 deletion src/features/hooks/claudecode-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import {
type ToolHooksSettablePaths,
} from "./tool-hooks.js";

const CLAUDE_NO_MATCHER_EVENTS: ReadonlySet<string> = new Set(["worktreeCreate", "worktreeRemove"]);
const CLAUDE_NO_MATCHER_EVENTS: ReadonlySet<string> = new Set([
"worktreeCreate",
"worktreeRemove",
"messageDisplay",
]);

const CLAUDE_CONVERTER_CONFIG: ToolHooksConverterConfig = {
supportedEvents: CLAUDE_HOOK_EVENTS,
Expand Down
Loading
Loading