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
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,20 @@ rulesync.local.jsonc
**/.copilot/hooks/
**/.hermes/plugins/rulesync-ignore/
**/.hermes/plugins/rulesync-checks/
**/.clinerules/hooks/Notification
**/.clinerules/hooks/Notification.ps1
**/.clinerules/hooks/PostToolUse
**/.clinerules/hooks/PostToolUse.ps1
**/.clinerules/hooks/PreCompact
**/.clinerules/hooks/PreCompact.ps1
**/.clinerules/hooks/PreToolUse
**/.clinerules/hooks/PreToolUse.ps1
**/.clinerules/hooks/TaskComplete
**/.clinerules/hooks/TaskComplete.ps1
**/.clinerules/hooks/TaskStart
**/.clinerules/hooks/TaskStart.ps1
**/.clinerules/hooks/UserPromptSubmit
**/.clinerules/hooks/UserPromptSubmit.ps1
**/.roomodes
**/.codexignore
**/.codex/rules/rulesync.rules
Expand Down Expand Up @@ -383,6 +397,7 @@ rulesync.local.jsonc
**/.opencode/plugins/rulesync-hooks.js
**/.pi/extensions/rulesync-hooks.ts
**/.factory/hooks.json
**/.clinerules/hooks/rulesync-hooks.json
**/.agents/plugins/rulesync/hooks/hooks.json
**/.deepagents/hooks.json
**/.kiro/agents/default.json
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte
| deepagents-cli | ✅ | | ✅ | | ✅ | ✅ | ✅ | | |
| Factory Droid | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
| OpenCode | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
| Cline | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | |
| Cline | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | |
| Kilo Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
| Kimi Code | ✅ | | ✅ | | ✅ | ✅ | ✅ | ✅ | |
| Roo Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | |
Expand Down
3 changes: 3 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"src/generated/**"
],
"words": [
"diffable",
"extensionless",
"LASTEXITCODE",
"PKCE",
"serde",
"codegen",
Expand Down
102 changes: 52 additions & 50 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 @@ -21,7 +21,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
| deepagents-cli | deepagents | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | |
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| Cline | cline | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ | |
| Cline | cline | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | |
| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| Kimi Code | kimi-code | ✅ 🌏 | | ✅ 🌏 🔧 | | ✅ 🌏 | ✅ 🌏 | 🌏 | 🌏 | |
| Roo Code | roo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ 🌏 | | | |
Expand Down
16 changes: 16 additions & 0 deletions src/cli/commands/gitignore-entries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,22 @@ describe("registry derivation", () => {
// .gooseignore and the inert sub-recipe subagents directory.
"goose::ignore::**/.gooseignore",
"goose::subagents::**/.goose/recipes/subagents/",
// Cline's hook scripts come from getAuxiliaryFiles, not getSettablePaths,
// and are listed individually so a hand-authored hook stays tracked.
"cline::hooks::**/.clinerules/hooks/Notification",
"cline::hooks::**/.clinerules/hooks/Notification.ps1",
"cline::hooks::**/.clinerules/hooks/PostToolUse",
"cline::hooks::**/.clinerules/hooks/PostToolUse.ps1",
"cline::hooks::**/.clinerules/hooks/PreCompact",
"cline::hooks::**/.clinerules/hooks/PreCompact.ps1",
"cline::hooks::**/.clinerules/hooks/PreToolUse",
"cline::hooks::**/.clinerules/hooks/PreToolUse.ps1",
"cline::hooks::**/.clinerules/hooks/TaskComplete",
"cline::hooks::**/.clinerules/hooks/TaskComplete.ps1",
"cline::hooks::**/.clinerules/hooks/TaskStart",
"cline::hooks::**/.clinerules/hooks/TaskStart.ps1",
"cline::hooks::**/.clinerules/hooks/UserPromptSubmit",
"cline::hooks::**/.clinerules/hooks/UserPromptSubmit.ps1",
// The allowlist is user-scope only; earlier versions wrote a project
// `.junie/allowlist.json` Junie never reads (issue #2411).
"junie::permissions::**/.junie/allowlist.json",
Expand Down
20 changes: 20 additions & 0 deletions src/cli/commands/gitignore-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,26 @@ export const HAND_MAINTAINED_GITIGNORE_ENTRIES: ReadonlyArray<GitignoreEntryTag>
{ target: "hermesagent", feature: "ignore", entry: "**/.hermes/plugins/rulesync-ignore/" },
{ target: "hermesagent", feature: "checks", entry: "**/.hermes/plugins/rulesync-checks/" },

// Cline's hook scripts are produced via `ClineHooks.getAuxiliaryFiles`, not
// `getSettablePaths` (which only exposes the manifest), and their names are
// fixed by Cline's VALID_HOOK_TYPES. They are listed one by one rather than as
// a directory so a hand-authored hook in the same directory — one rulesync
// deliberately never manages — stays tracked.
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/Notification" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/Notification.ps1" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/PostToolUse" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/PostToolUse.ps1" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/PreCompact" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/PreCompact.ps1" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/PreToolUse" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/PreToolUse.ps1" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/TaskComplete" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/TaskComplete.ps1" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/TaskStart" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/TaskStart.ps1" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/UserPromptSubmit" },
{ target: "cline", feature: "hooks", entry: "**/.clinerules/hooks/UserPromptSubmit.ps1" },

// Roo aggregates subagents into a single `.roomodes` file (no settable path).
{ target: "roo", feature: "subagents", entry: "**/.roomodes" },

Expand Down
11 changes: 11 additions & 0 deletions src/constants/cline-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,14 @@ export const CLINE_MCP_DIR_PATH = join(CLINE_DIR, "data", "settings");
export const CLINE_MCP_FILE_NAME = "cline_mcp_settings.json";
export const CLINE_PERMISSIONS_FILE_NAME = "command-permissions.json";
export const CLINE_IGNORE_FILE_NAME = ".clineignore";
// File-based hooks: Cline resolves one executable per lifecycle event, named
// exactly after the event, from `<workspace>/.clinerules/hooks/` (project) and
// `~/Documents/Cline/Hooks/` (global) — `resolveHooksDirectory` in
// `apps/vscode/src/core/hooks/utils.ts`.
export const CLINE_HOOKS_DIR_PATH = join(CLINERULES_DIR, "hooks");
export const CLINE_HOOKS_GLOBAL_DIR_PATH = join("Documents", "Cline", "Hooks");
// Manifest of the hook scripts rulesync generated. Cline resolves hooks by
// exact event name, so an extra file in the directory is inert; it gives the
// adapter an owned, diffable, deletable primary file and records which scripts
// belong to rulesync.
export const CLINE_HOOKS_MANIFEST_FILE_NAME = "rulesync-hooks.json";
17 changes: 17 additions & 0 deletions src/e2e/e2e-hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const hooksGenerateTargets = [
{ target: "antigravity-cli", outputPath: join(".agents", "hooks.json") },
{ target: "augmentcode", outputPath: join(".augment", "settings.json") },
{ target: "grokcli", outputPath: join(".grok", "hooks", "rulesync.json") },
{ target: "cline", outputPath: join(".clinerules", "hooks", "rulesync-hooks.json") },
] as const;

// Targets exercised by dedicated `it`s (bespoke per-tool serialization).
Expand Down Expand Up @@ -115,6 +116,16 @@ describe("E2E: hooks", () => {
// so assert the canonical command paths survive rather than parsing JSON.
expect(generatedContent).toContain(".rulesync/hooks/session-start.sh");
expect(generatedContent).toContain(".rulesync/hooks/audit.sh");
} else if (target === "cline") {
// Cline resolves an executable per event; the generated file at the
// settable path is the manifest naming the scripts rulesync owns.
expect(JSON.parse(generatedContent).events).toEqual(["TaskStart"]);
const script = await readFileContent(join(testDir, ".clinerules", "hooks", "TaskStart"));
expect(script).toContain(".rulesync/hooks/session-start.sh");
expect(script).toContain("#!/bin/bash");
expect(
await readFileContent(join(testDir, ".clinerules", "hooks", "TaskStart.ps1")),
).toContain(".rulesync/hooks/session-start.sh");
} else if (target === "pi") {
// Pi emits a TypeScript extension (.pi/extensions/rulesync-hooks.ts)
// that subscribes to snake_case extension events: sessionStart →
Expand Down Expand Up @@ -692,6 +703,7 @@ const hooksGlobalTargets = [
{ target: "kiro-ide", outputPath: join(".kiro", "hooks", "rulesync.json") },
{ target: "kiro-cli", outputPath: join(".kiro", "hooks", "rulesync.json") },
{ target: "grokcli", outputPath: join(".grok", "hooks", "rulesync.json") },
{ target: "cline", outputPath: join("Documents", "Cline", "Hooks", "rulesync-hooks.json") },
] as const;

// Global targets exercised by dedicated `it`s (bespoke per-tool serialization).
Expand Down Expand Up @@ -754,6 +766,11 @@ describe("E2E: hooks (global mode)", () => {
expect(generatedContent).toContain("RulesyncHooksPlugin");
expect(generatedContent).toContain(".rulesync/hooks/session-start.sh");
expect(generatedContent).toContain(".rulesync/hooks/audit.sh");
} else if (target === "cline") {
expect(JSON.parse(generatedContent).events).toEqual(["TaskStart"]);
expect(
await readFileContent(join(homeDir, "Documents", "Cline", "Hooks", "TaskStart")),
).toContain(".rulesync/hooks/session-start.sh");
} else if (target === "kilo") {
// Kilo's JS plugin differs from OpenCode's shape; assert command paths.
expect(generatedContent).toContain(".rulesync/hooks/session-start.sh");
Expand Down
180 changes: 180 additions & 0 deletions src/features/hooks/cline-hooks-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { CONTROL_CHARS, type HooksConfig } from "../../types/hooks.js";

/**
* Marker line every generated hook script carries. Cline resolves hooks by
* exact event name from a directory users also hand-author scripts in, so the
* marker is what tells a rulesync-owned script apart from a user's own: only
* files carrying it are rewritten or cleaned up.
*/
export const CLINE_HOOK_SCRIPT_MARKER = "rulesync-owned: cline-hooks";

/** Exit code a hook command uses to cancel the task (Claude Code convention). */
const CANCEL_EXIT_CODE = 2;

function sanitizeCommand(command: string): string {
let sanitized = command;
for (const char of CONTROL_CHARS) {
sanitized = sanitized.replaceAll(char, "");
}
return sanitized;
}

/** Single-quote a string for POSIX shells. */
function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}

/** Single-quote a string for PowerShell. */
function powerShellQuote(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}

export function collectClineHookCommands({
effectiveHooks,
eventMap,
}: {
effectiveHooks: HooksConfig["hooks"];
eventMap: Record<string, string>;
}): Record<string, string[]> {
const commandsByEvent: Record<string, string[]> = {};
for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
const clineEvent = eventMap[canonicalEvent];
if (!clineEvent) continue;

const commands = definitions
.filter((definition) => (definition.type ?? "command") === "command" && definition.command)
.map((definition) => sanitizeCommand(definition.command as string))
.filter((command) => command.trim() !== "");
if (commands.length === 0) continue;

const existing = commandsByEvent[clineEvent];
if (existing) {
existing.push(...commands);
} else {
commandsByEvent[clineEvent] = commands;
}
}
return commandsByEvent;
}

/**
* A POSIX wrapper script for one Cline hook event.
*
* Cline spawns the file itself (`spawn(scriptPath, [], { shell: true })`), feeds
* the event payload on stdin and reads a JSON result from stdout. The wrapper
* therefore forwards the payload to each configured command in order and
* translates the exit codes: `2` cancels the task, any other failure is
* reported through `errorMessage` without cancelling, mirroring how the
* canonical `command` hook type behaves for the tools that support blocking.
*/
export function generateClineHookScript({
event,
commands,
}: {
event: string;
commands: string[];
}): string {
const lines = [
"#!/bin/bash",
`# ${event} hook generated by rulesync — edit .rulesync/hooks.jsonc and regenerate.`,
`# ${CLINE_HOOK_SCRIPT_MARKER}`,
"",
"payload=$(cat)",
"cancel=false",
"error_message=''",
"",
];

for (const command of commands) {
const quoted = shellQuote(command);
lines.push(
'if [ "$cancel" = false ]; then',
// The command is passed as a single quoted argument rather than spliced
// into this script, so its own quotes, comments and operators cannot
// break the wrapper. A command the shell cannot parse would exit 2 —
// the cancel code — so its syntax is checked first and a typo reports an
// error instead of silently cancelling the task.
` if bash -n -c ${quoted} 2>/dev/null; then`,
` hook_stderr=$(printf '%s' "$payload" | bash -c ${quoted} 2>&1 >/dev/null)`,
" hook_status=$?",
` if [ "$hook_status" -eq ${CANCEL_EXIT_CODE} ]; then`,
" cancel=true",
' error_message="$hook_stderr"',
' elif [ "$hook_status" -ne 0 ]; then',
` printf '%s\\n' "rulesync ${event} hook failed (exit $hook_status): $hook_stderr" >&2`,
' error_message="$hook_stderr"',
" fi",
" else",
` error_message="rulesync ${event} hook command is not valid shell syntax"`,
` printf '%s\\n' "$error_message" >&2`,
" fi",
"fi",
"",
);
}

lines.push(
"escape_json() {",
// Newlines, tabs and carriage returns become spaces and any other control
// character is dropped: a raw control character in a JSON string makes the
// whole result unparseable, which would silently discard a `cancel`.
` printf '%s' "$1" | tr '\\n\\r\\t' ' ' | tr -d '\\000-\\037' | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g'`,
"}",
"",
`printf '{"cancel": %s, "contextModification": "", "errorMessage": "%s"}\\n' "$cancel" "$(escape_json "$error_message")"`,
"",
);

return lines.join("\n");
}

/**
* The PowerShell twin of {@link generateClineHookScript}. On Windows Cline
* resolves only `<Event>.ps1` and runs it through `powershell -File`, so both
* spellings are written and the platform picks one.
*/
export function generateClineHookPowerShellScript({
event,
commands,
}: {
event: string;
commands: string[];
}): string {
const lines = [
`# ${event} hook generated by rulesync — edit .rulesync/hooks.jsonc and regenerate.`,
`# ${CLINE_HOOK_SCRIPT_MARKER}`,
"",
"$payload = [Console]::In.ReadToEnd()",
"$cancel = $false",
"$errorMessage = ''",
"",
];

for (const command of commands) {
lines.push(
"if (-not $cancel) {",
` $hookStderr = ($payload | & cmd /c ${powerShellQuote(command)} 2>&1 | Out-String)`,
" $hookStatus = $LASTEXITCODE",
` if ($hookStatus -eq ${CANCEL_EXIT_CODE}) {`,
" $cancel = $true",
" $errorMessage = $hookStderr",
" } elseif ($hookStatus -ne 0) {",
` Write-Error ${powerShellQuote(`rulesync ${event} hook failed`)}`,
" $errorMessage = $hookStderr",
" }",
"}",
"",
);
}

lines.push(
"@{",
" cancel = $cancel",
' contextModification = ""',
" errorMessage = $errorMessage",
"} | ConvertTo-Json -Compress",
"",
);

return lines.join("\n");
}
Loading
Loading