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
7 changes: 4 additions & 3 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration

## `.rulesync/hooks.json`

Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`).
Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`).

Example:

Expand Down Expand Up @@ -136,9 +136,9 @@ Events present in the shared `hooks` block but unsupported by a given tool are s
| `preModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — |
| `postModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — |
| `postToolUseFailure` | ✅ | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | ✅ |
| `stop` | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
| `stop` | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
| `subagentStart` | ✅ | — | — | — | — | ✅ | — | — | ✅ | — | — | — | — | — | — | ✅ |
| `subagentStop` | ✅ | ✅ | — | — | | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ |
| `subagentStop` | ✅ | ✅ | — | — | | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ |
| `preCompact` | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — |
| `postCompact` | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — |
| `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ |
Expand Down Expand Up @@ -230,6 +230,7 @@ description: "Review a pull request" # command description
targets: ["*"] # * = all, or specific tools
copilot: # copilot specific parameters (optional)
description: "Review a pull request"
agent: "agent" # (optional) VS Code prompt-file agent: "ask", "agent", "plan", or a custom agent name (replaces the deprecated "mode")
antigravity: # antigravity specific parameters
trigger: "/review" # Specific trigger for workflow (renames file to review.md)
turbo: true # (Optional, default: true) Append // turbo for auto-execution
Expand Down
7 changes: 4 additions & 3 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration

## `.rulesync/hooks.json`

Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`).
Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`).

Example:

Expand Down Expand Up @@ -136,9 +136,9 @@ Events present in the shared `hooks` block but unsupported by a given tool are s
| `preModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — |
| `postModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — |
| `postToolUseFailure` | ✅ | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | ✅ |
| `stop` | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
| `stop` | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
| `subagentStart` | ✅ | — | — | — | — | ✅ | — | — | ✅ | — | — | — | — | — | — | ✅ |
| `subagentStop` | ✅ | ✅ | — | — | | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ |
| `subagentStop` | ✅ | ✅ | — | — | | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ |
| `preCompact` | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — |
| `postCompact` | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — |
| `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ |
Expand Down Expand Up @@ -230,6 +230,7 @@ description: "Review a pull request" # command description
targets: ["*"] # * = all, or specific tools
copilot: # copilot specific parameters (optional)
description: "Review a pull request"
agent: "agent" # (optional) VS Code prompt-file agent: "ask", "agent", "plan", or a custom agent name (replaces the deprecated "mode")
antigravity: # antigravity specific parameters
trigger: "/review" # Specific trigger for workflow (renames file to review.md)
turbo: true # (Optional, default: true) Append // turbo for auto-execution
Expand Down
38 changes: 34 additions & 4 deletions src/e2e/e2e-hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,14 @@ describe("E2E: hooks", () => {
expect(parsed.hooks.agentSpawn[0].command).toBe(".rulesync/hooks/session-start.sh");
expect(parsed.hooks.stop[0].command).toBe(".rulesync/hooks/audit.sh");
} else if (target === "copilot" || target === "copilotcli") {
// Copilot and Copilot CLI use camelCase event names. Neither supports
// the `stop` hook event (see COPILOT_HOOK_EVENTS in src/types/hooks.ts),
// so audit.sh is intentionally dropped during generation and cannot be
// asserted here.
// Copilot and Copilot CLI use camelCase event names and both map the
// canonical `stop` event to `agentStop` (see COPILOT_HOOK_EVENTS /
// COPILOTCLI_HOOK_EVENTS in src/types/hooks.ts).
expect(parsed.hooks).toBeDefined();
expect(parsed.hooks.sessionStart).toBeDefined();
expect(parsed.hooks.agentStop).toBeDefined();
expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh");
expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/audit.sh");
} else if (target === "augmentcode") {
// AugmentCode mirrors Claude's PascalCase event names but emits commands
// verbatim (AUGMENT_PROJECT_DIR is a runtime env var, not an inline prefix).
Expand All @@ -128,6 +129,35 @@ describe("E2E: hooks", () => {
}
});

it("should map canonical stop/subagentStop to copilot agentStop/subagentStop", async () => {
const testDir = getTestDir();

const hooksContent = JSON.stringify(
{
version: 1,
hooks: {
stop: [{ command: ".rulesync/hooks/agent-stop.sh" }],
subagentStop: [{ command: ".rulesync/hooks/subagent-stop.sh" }],
},
},
null,
2,
);
await writeFileContent(join(testDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH), hooksContent);

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

const generatedContent = await readFileContent(
join(testDir, ".github", "hooks", "copilot-hooks.json"),
);
const parsed = JSON.parse(generatedContent);
// Canonical `stop` → `agentStop`, `subagentStop` → `subagentStop`.
expect(parsed.hooks.agentStop).toBeDefined();
expect(JSON.stringify(parsed.hooks.agentStop)).toContain(".rulesync/hooks/agent-stop.sh");
expect(parsed.hooks.subagentStop).toBeDefined();
expect(JSON.stringify(parsed.hooks.subagentStop)).toContain(".rulesync/hooks/subagent-stop.sh");
});

it("should generate devin hooks", async () => {
const testDir = getTestDir();

Expand Down
62 changes: 60 additions & 2 deletions src/features/commands/copilot-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ Body content`;
expect(rulesyncCommand.getFrontmatter()).toEqual({
targets: ["*"],
description: "Test description",
// The deprecated `mode` is migrated to `agent` in the copilot section.
copilot: { agent: "agent" },
});
expect(rulesyncCommand.getRelativeFilePath()).toBe("test-command.md");
});
Expand Down Expand Up @@ -640,9 +642,10 @@ Body content`;
const rulesyncCommand = command.toRulesyncCommand();
const frontmatter = rulesyncCommand.getFrontmatter();

// mode should not be in copilot section (it's excluded from extra fields)
// The deprecated `mode` is migrated to `agent`; other extra fields persist.
expect(frontmatter.copilot).toEqual({
"custom-field": { nested: "value" },
agent: "agent",
});
});

Expand Down Expand Up @@ -678,7 +681,6 @@ Body content`;
relativeDirPath: join(".github", "prompts"),
relativeFilePath: "test.prompt.md",
frontmatter: {
mode: "agent",
description: "Test command",
},
body: "Test body",
Expand All @@ -689,6 +691,62 @@ Body content`;

expect(frontmatter.copilot).toBeUndefined();
});

it("should migrate deprecated mode to agent in copilot section", () => {
const command = new CopilotCommand({
outputRoot: testDir,
relativeDirPath: join(".github", "prompts"),
relativeFilePath: "test.prompt.md",
frontmatter: {
mode: "plan",
description: "Test command",
},
body: "Test body",
validate: false,
});

const rulesyncCommand = command.toRulesyncCommand();
expect(rulesyncCommand.getFrontmatter().copilot).toEqual({ agent: "plan" });
});

it("should prefer explicit agent over deprecated mode", () => {
const command = new CopilotCommand({
outputRoot: testDir,
relativeDirPath: join(".github", "prompts"),
relativeFilePath: "test.prompt.md",
frontmatter: {
agent: "agent",
mode: "plan",
description: "Test command",
},
body: "Test body",
validate: false,
});

const rulesyncCommand = command.toRulesyncCommand();
expect(rulesyncCommand.getFrontmatter().copilot).toEqual({ agent: "agent" });
});

it("should round-trip agent through fromRulesyncCommand and toRulesyncCommand", () => {
const original = new RulesyncCommand({
outputRoot: testDir,
relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH,
relativeFilePath: "agent-roundtrip.md",
frontmatter: {
targets: ["copilot"],
description: "Agent roundtrip",
copilot: { agent: "plan" },
},
body: "Body content",
fileContent: "",
});

const copilot = CopilotCommand.fromRulesyncCommand({ rulesyncCommand: original });
expect(copilot.getFrontmatter().agent).toBe("plan");

const backToRulesync = copilot.toRulesyncCommand();
expect(backToRulesync.getFrontmatter().copilot).toEqual({ agent: "plan" });
});
});

describe("isTargetedByRulesyncCommand", () => {
Expand Down
22 changes: 19 additions & 3 deletions src/features/commands/copilot-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import {

// looseObject preserves unknown keys during parsing (like passthrough in Zod 3)
export const CopilotCommandFrontmatterSchema = z.looseObject({
// `agent` is the current VS Code prompt-file field (values `ask` | `agent` |
// `plan` | a custom agent name). See
// https://code.visualstudio.com/docs/copilot/customization/prompt-files
agent: z.optional(z.string()),
// `mode` is the deprecated predecessor of `agent`; still accepted for
// backward compatibility and migrated to `agent` on import.
mode: z.optional(z.string()),
description: z.optional(z.string()),
});
Expand Down Expand Up @@ -67,13 +73,23 @@ export class CopilotCommand extends ToolCommand {
}

toRulesyncCommand(): RulesyncCommand {
const { mode: _mode, description, ...restFields } = this.frontmatter;
const { mode, agent, description, ...restFields } = this.frontmatter;

// Migrate the deprecated `mode` field to `agent`. If both are present, the
// explicit `agent` value wins.
const resolvedAgent = agent ?? mode;

const copilotFields = {
...restFields,
...(resolvedAgent !== undefined && { agent: resolvedAgent }),
};

const rulesyncFrontmatter: RulesyncCommandFrontmatter = {
targets: ["*"],
description,
// Preserve extra fields in copilot section (excluding mode which is fixed)
...(Object.keys(restFields).length > 0 && { copilot: restFields }),
// Preserve extra copilot-specific fields (including the normalized `agent`;
// the deprecated `mode` is dropped in favor of `agent`).
...(Object.keys(copilotFields).length > 0 && { copilot: copilotFields }),
};

// Strip .prompt.md extension and normalize to .md
Expand Down
Loading