diff --git a/.gitignore b/.gitignore index 76c8168b4..a9750a719 100644 --- a/.gitignore +++ b/.gitignore @@ -363,6 +363,7 @@ rulesync.local.jsonc **/.github/hooks/copilotcli-hooks.json **/.kilo/plugins/rulesync-hooks.js **/.opencode/plugins/rulesync-hooks.js +**/.pi/extensions/rulesync-hooks.ts **/.factory/hooks.json **/.agents/plugins/rulesync/hooks/hooks.json **/.kiro/agents/default.json diff --git a/README.md b/README.md index 133113332..05146e631 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Devin Desktop | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | Warp | ✅ | ✅ | ✅ | | | ✅ | | ✅ | | | Replit | ✅ | | | | | ✅ | | | | -| Pi Coding Agent | ✅ | | | ✅ | | ✅ | | | | +| Pi Coding Agent | ✅ | | | ✅ | | ✅ | ✅ | | | | Zed | ✅ | ✅ | ✅ | | | ✅ | | ✅ | | diff --git a/cspell.json b/cspell.json index e0afcaf95..71a4b0d33 100644 --- a/cspell.json +++ b/cspell.json @@ -124,6 +124,7 @@ "dyoshikawa", "eabi", "eamodio", + "earendil", "eastasianwidth", "elicitations", "émøjî", diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 344fccac8..0250d5e96 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -87,7 +87,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration > **JSONC variant:** hooks can also be authored as `.rulesync/hooks.jsonc` (JSON with comments and trailing commas). When both `hooks.json` and `hooks.jsonc` exist, the `.jsonc` file takes precedence. -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`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). +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`); Pi Coding Agent hooks are emitted as a Rulesync-owned TypeScript extension (`.pi/extensions/rulesync-hooks.ts`, or `~/.pi/agent/extensions/rulesync-hooks.ts` in global mode) that subscribes to Pi's snake_case extension events (`sessionStart` → `session_start`, `stop` → `agent_end`, `preToolUse` → `tool_call` with the matcher tested as a regex against the tool name) and observes events only — command hooks run but cannot block or mutate Pi events; 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`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). Example: diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 6542536dc..3a015afcd 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -39,7 +39,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Warp | warp | ✅ | ✅ | ✅ 🌏 | | | ✅ 🌏 | | 🌏 | | | Replit | replit | ✅ | | | | | ✅ 🌏 | | | | -| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | | | +| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | | | Zed | zed | ✅ 🌏 | ✅ | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index ecd4b28fa..d8d5e339f 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -87,7 +87,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration > **JSONC variant:** hooks can also be authored as `.rulesync/hooks.jsonc` (JSON with comments and trailing commas). When both `hooks.json` and `hooks.jsonc` exist, the `.jsonc` file takes precedence. -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`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). +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`); Pi Coding Agent hooks are emitted as a Rulesync-owned TypeScript extension (`.pi/extensions/rulesync-hooks.ts`, or `~/.pi/agent/extensions/rulesync-hooks.ts` in global mode) that subscribes to Pi's snake_case extension events (`sessionStart` → `session_start`, `stop` → `agent_end`, `preToolUse` → `tool_call` with the matcher tested as a regex against the tool name) and observes events only — command hooks run but cannot block or mutate Pi events; 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`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). Example: diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 6542536dc..3a015afcd 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -39,7 +39,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Warp | warp | ✅ | ✅ | ✅ 🌏 | | | ✅ 🌏 | | 🌏 | | | Replit | replit | ✅ | | | | | ✅ 🌏 | | | | -| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | | | +| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | | | Zed | zed | ✅ 🌏 | ✅ | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | diff --git a/src/constants/pi-paths.ts b/src/constants/pi-paths.ts index 399f718c3..9918051a0 100644 --- a/src/constants/pi-paths.ts +++ b/src/constants/pi-paths.ts @@ -2,9 +2,12 @@ import { join } from "node:path"; export const PI_DIR = ".pi"; const PI_AGENT_DIR = join(PI_DIR, "agent"); +export const PI_AGENT_EXTENSIONS_DIR_PATH = join(PI_AGENT_DIR, "extensions"); export const PI_AGENT_PROMPTS_DIR_PATH = join(PI_AGENT_DIR, "prompts"); export const PI_AGENT_SKILLS_DIR_PATH = join(PI_AGENT_DIR, "skills"); +export const PI_EXTENSIONS_DIR_PATH = join(PI_DIR, "extensions"); export const PI_PROMPTS_DIR_PATH = join(PI_DIR, "prompts"); export const PI_SKILLS_DIR_PATH = join(PI_DIR, "skills"); export const PI_RULE_FILE_NAME = "AGENTS.md"; export const PI_APPEND_SYSTEM_FILE_NAME = "APPEND_SYSTEM.md"; +export const PI_HOOKS_FILE_NAME = "rulesync-hooks.ts"; diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index 17e4e8e03..6ddc1b412 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -37,6 +37,7 @@ const hooksGenerateTargets = [ { target: "cursor", outputPath: join(".cursor", "hooks.json") }, { target: "opencode", outputPath: join(".opencode", "plugins", "rulesync-hooks.js") }, { target: "kilo", outputPath: join(".kilo", "plugins", "rulesync-hooks.js") }, + { target: "pi", outputPath: join(".pi", "extensions", "rulesync-hooks.ts") }, { target: "codexcli", outputPath: join(".codex", "hooks.json") }, { target: "qwencode", outputPath: join(".qwen", "settings.json") }, { @@ -103,6 +104,14 @@ 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 === "pi") { + // Pi emits a TypeScript extension (.pi/extensions/rulesync-hooks.ts) + // that subscribes to snake_case extension events: sessionStart → + // session_start, stop → agent_end. + expect(generatedContent).toContain('pi.on("session_start"'); + expect(generatedContent).toContain('pi.on("agent_end"'); + expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); + expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); } else { const parsed = JSON.parse(generatedContent); @@ -388,6 +397,7 @@ describe("E2E: hooks", () => { // factorydroid now writes a dedicated .factory/hooks.json (isDeletable=true). { target: "cursor", orphanPath: join(".cursor", "hooks.json") }, { target: "opencode", orphanPath: join(".opencode", "plugins", "rulesync-hooks.js") }, + { target: "pi", orphanPath: join(".pi", "extensions", "rulesync-hooks.ts") }, { target: "codexcli", orphanPath: join(".codex", "hooks.json") }, { target: "copilot", orphanPath: join(".github", "hooks", "copilot-hooks.json") }, { target: "factorydroid", orphanPath: join(".factory", "hooks.json") }, @@ -602,6 +612,7 @@ const hooksGlobalTargets = [ }, { target: "opencode", outputPath: join(".config", "opencode", "plugins", "rulesync-hooks.js") }, { target: "kilo", outputPath: join(".config", "kilo", "plugins", "rulesync-hooks.js") }, + { target: "pi", outputPath: join(".pi", "agent", "extensions", "rulesync-hooks.ts") }, { target: "factorydroid", outputPath: join(".factory", "hooks.json") }, { target: "deepagents", outputPath: join(".deepagents", "hooks.json") }, { target: "junie", outputPath: join(".junie", "config.json") }, @@ -667,6 +678,12 @@ describe("E2E: hooks (global mode)", () => { // Kilo's JS plugin differs from OpenCode's shape; assert command paths. expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); + } else if (target === "pi") { + // Pi emits a TypeScript extension subscribing to snake_case events. + expect(generatedContent).toContain('pi.on("session_start"'); + expect(generatedContent).toContain('pi.on("agent_end"'); + expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); + expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); } else if (target === "copilotcli") { // Copilot CLI does not support the `stop` hook event, so audit.sh is // intentionally dropped during generation. diff --git a/src/features/hooks/hooks-processor.test.ts b/src/features/hooks/hooks-processor.test.ts index c7fe5c614..c7a822885 100644 --- a/src/features/hooks/hooks-processor.test.ts +++ b/src/features/hooks/hooks-processor.test.ts @@ -519,7 +519,7 @@ describe("HooksProcessor", () => { }); describe("getToolTargets", () => { - it("should return cursor, claudecode, copilot, copilotcli, opencode, kilo, factorydroid, and kiro for project mode", () => { + it("should return cursor, claudecode, copilot, copilotcli, opencode, kilo, pi, factorydroid, and kiro for project mode", () => { const targets = HooksProcessor.getToolTargets({ global: false }); expect(targets).toEqual([ "antigravity-cli", @@ -531,6 +531,7 @@ describe("HooksProcessor", () => { "copilotcli", "kilo", "opencode", + "pi", "factorydroid", "goose", "kiro", @@ -545,7 +546,7 @@ describe("HooksProcessor", () => { ]); }); - it("should return cursor, claudecode, copilotcli, opencode, kilo, and factorydroid for global mode", () => { + it("should return cursor, claudecode, copilotcli, opencode, kilo, pi, and factorydroid for global mode", () => { const targets = HooksProcessor.getToolTargets({ global: true }); expect(targets).toEqual([ "antigravity-cli", @@ -556,6 +557,7 @@ describe("HooksProcessor", () => { "copilotcli", "kilo", "opencode", + "pi", "factorydroid", "goose", "hermesagent", diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index 1f862aaec..8f1020fe3 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -21,6 +21,7 @@ import { KIRO_HOOK_EVENTS, KIRO_IDE_HOOK_EVENTS, OPENCODE_HOOK_EVENTS, + PI_HOOK_EVENTS, QWENCODE_HOOK_EVENTS, REASONIX_HOOK_EVENTS, VIBE_HOOK_EVENTS, @@ -52,6 +53,7 @@ import { KiroCliHooks } from "./kiro-cli-hooks.js"; import { KiroHooks } from "./kiro-hooks.js"; import { KiroIdeHooks } from "./kiro-ide-hooks.js"; import { OpencodeHooks } from "./opencode-hooks.js"; +import { PiHooks } from "./pi-hooks.js"; import { QwencodeHooks } from "./qwencode-hooks.js"; import { ReasonixHooks } from "./reasonix-hooks.js"; import { RulesyncHooks } from "./rulesync-hooks.js"; @@ -254,6 +256,27 @@ export const toolHooksFactories = new Map; + +function collectPiHandlers({ + effectiveHooks, + eventMap, +}: { + effectiveHooks: HooksConfig["hooks"]; + eventMap: Record; +}): HandlerGroup { + const handlerGroups: HandlerGroup = {}; + for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) { + const piEvent = eventMap[canonicalEvent]; + if (!piEvent) continue; + + const handlers: Handler[] = []; + for (const def of definitions) { + if ((def.type ?? "command") !== "command") continue; + if (!def.command) continue; + handlers.push({ + command: def.command, + matcher: def.matcher ? def.matcher : undefined, + }); + } + + if (handlers.length > 0) { + const existing = handlerGroups[piEvent]; + if (existing) { + existing.push(...handlers); + } else { + handlerGroups[piEvent] = handlers; + } + } + } + return handlerGroups; +} + +function buildSubscriptionLines(handlerGroups: HandlerGroup): string[] { + const lines: string[] = []; + for (const [piEvent, handlers] of Object.entries(handlerGroups)) { + const usesToolName = PI_TOOL_EVENTS.has(piEvent) && handlers.some((h) => h.matcher); + lines.push(` pi.on(${JSON.stringify(piEvent)}, async (${usesToolName ? "event" : ""}) => {`); + for (const handler of handlers) { + const embeddedCommand = JSON.stringify(handler.command); + if (usesToolName && handler.matcher) { + lines.push( + ` if (new RegExp(${matcherToEmbeddedLiteral(handler.matcher)}).test(event.toolName)) {`, + ); + lines.push(` await run(${embeddedCommand});`); + lines.push(" }"); + } else { + lines.push(` await run(${embeddedCommand});`); + } + } + lines.push(" });"); + } + return lines; +} + +/** + * Generate the rulesync-owned Pi extension (a TypeScript module with a + * default-export factory receiving Pi's ExtensionAPI) that subscribes to the + * mapped lifecycle events and executes the configured hook commands via the + * platform shell. The generated extension observes events only: it never + * blocks or mutates Pi events. + * + * @see https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md + */ +export function generatePiExtensionCode({ + config, + supportedEvents, + eventMap, +}: { + config: HooksConfig; + supportedEvents: readonly string[]; + eventMap: Record; +}): string { + const supported: Set = new Set(supportedEvents); + const configHooks = { ...config.hooks, ...config.pi?.hooks }; + const effectiveHooks: HooksConfig["hooks"] = {}; + + for (const [event, defs] of Object.entries(configHooks)) { + if (supported.has(event)) effectiveHooks[event] = defs; + } + + const handlerGroups = collectPiHandlers({ effectiveHooks, eventMap }); + const subscriptionLines = buildSubscriptionLines(handlerGroups); + + const lines: string[] = ["// Generated by rulesync. Do not edit manually."]; + if (subscriptionLines.length === 0) { + lines.push("export default function () {}"); + lines.push(""); + return lines.join("\n"); + } + + lines.push('import { exec } from "node:child_process";'); + lines.push('import { promisify } from "node:util";'); + lines.push(""); + lines.push('import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";'); + lines.push(""); + lines.push("const run = promisify(exec);"); + lines.push(""); + lines.push("export default function (pi: ExtensionAPI) {"); + lines.push(...subscriptionLines); + lines.push("}"); + lines.push(""); + return lines.join("\n"); +} diff --git a/src/features/hooks/pi-hooks.test.ts b/src/features/hooks/pi-hooks.test.ts new file mode 100644 index 000000000..20537a934 --- /dev/null +++ b/src/features/hooks/pi-hooks.test.ts @@ -0,0 +1,355 @@ +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { tsImport } from "tsx/esm/api"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { PiHooks } from "./pi-hooks.js"; +import { RulesyncHooks } from "./rulesync-hooks.js"; + +function buildRulesyncHooks({ + testDir, + config, +}: { + testDir: string; + config: Record; +}): RulesyncHooks { + return new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); +} + +describe("PiHooks", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return .pi/extensions and rulesync-hooks.ts", () => { + const paths = PiHooks.getSettablePaths(); + expect(paths).toEqual({ + relativeDirPath: join(".pi", "extensions"), + relativeFilePath: "rulesync-hooks.ts", + }); + }); + + it("should return .pi/agent/extensions for global mode", () => { + const paths = PiHooks.getSettablePaths({ global: true }); + expect(paths).toEqual({ + relativeDirPath: join(".pi", "agent", "extensions"), + relativeFilePath: "rulesync-hooks.ts", + }); + }); + }); + + describe("fromRulesyncHooks", () => { + it("should filter shared hooks to Pi-supported events and map to snake_case", () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }], + sessionEnd: [{ command: "teardown.sh" }], + stop: [{ command: ".rulesync/hooks/audit.sh" }], + beforeSubmitPrompt: [{ command: "pre-prompt.sh" }], + preModelInvocation: [{ command: "pre-model.sh" }], + preCompact: [{ command: "pre-compact.sh" }], + // notification has no Pi extension event equivalent + notification: [{ command: "notify.sh" }], + // afterFileEdit has no Pi extension event equivalent + afterFileEdit: [{ command: "format.sh" }], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain('pi.on("session_start", async () => {'); + expect(content).toContain(".rulesync/hooks/session-start.sh"); + expect(content).toContain('pi.on("session_shutdown", async () => {'); + expect(content).toContain("teardown.sh"); + expect(content).toContain('pi.on("agent_end", async () => {'); + expect(content).toContain(".rulesync/hooks/audit.sh"); + expect(content).toContain('pi.on("input", async () => {'); + expect(content).toContain("pre-prompt.sh"); + expect(content).toContain('pi.on("context", async () => {'); + expect(content).toContain("pre-model.sh"); + expect(content).toContain('pi.on("session_before_compact", async () => {'); + expect(content).toContain("pre-compact.sh"); + + // Unsupported events should not appear + expect(content).not.toContain("notify.sh"); + expect(content).not.toContain("format.sh"); + }); + + it("should generate tool event handlers honoring matchers against event.toolName", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: "Write|Edit" }], + postToolUse: [{ type: "command", command: "post-tool.sh" }], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain('pi.on("tool_call", async (event) => {'); + expect(content).toContain('if (new RegExp("Write|Edit").test(event.toolName)) {'); + expect(content).toContain("lint.sh"); + // postToolUse has no matcher, so the handler ignores the event payload + expect(content).toContain('pi.on("tool_result", async () => {'); + expect(content).toContain("post-tool.sh"); + }); + + it("should normalize only bare wildcard matcher to regex match-all pattern", () => { + const config = { + version: 1, + hooks: { + preToolUse: [ + { type: "command", command: "all-tools.sh", matcher: "*" }, + { type: "command", command: "read-tools.sh", matcher: "Read*" }, + ], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain('new RegExp(".*")'); + expect(content).toContain('new RegExp("Read*")'); + expect(content).toContain("all-tools.sh"); + expect(content).toContain("read-tools.sh"); + }); + + it("should skip prompt-type hooks", () => { + const config = { + version: 1, + hooks: { + sessionStart: [ + { type: "command", command: ".rulesync/hooks/session-start.sh" }, + { type: "prompt", prompt: "Remember to use TypeScript" }, + ], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain(".rulesync/hooks/session-start.sh"); + expect(content).not.toContain("Remember to use TypeScript"); + }); + + it("should merge config.pi.hooks on top of shared hooks", () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: "shared.sh" }], + }, + pi: { + hooks: { + sessionStart: [{ type: "command", command: "pi-override.sh" }], + stop: [{ command: "pi-only.sh" }], + }, + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain("pi-override.sh"); + expect(content).not.toContain("shared.sh"); + expect(content).toContain("pi-only.sh"); + }); + + it("should generate an inert extension for an empty hooks config", () => { + const config = { + version: 1, + hooks: {}, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + expect(piHooks.getFileContent()).toBe( + [ + "// Generated by rulesync. Do not edit manually.", + "export default function () {}", + "", + ].join("\n"), + ); + }); + + it("should embed commands as JS string literals with quotes and backslashes escaped", () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: 'echo "C:\\temp" `date` ${HOME}' }], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const content = piHooks.getFileContent(); + expect(content).toContain(`await run(${JSON.stringify('echo "C:\\temp" `date` ${HOME}')});`); + }); + + it("should throw on invalid regex in matcher", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: "[invalid" }], + }, + }; + expect(() => + PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }), + ).toThrow("Invalid regex pattern in hook matcher"); + }); + + it("should strip control characters from matcher", () => { + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "command", command: "lint.sh", matcher: "Write\n|Edit\r\0" }], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + expect(piHooks.getFileContent()).toContain('new RegExp("Write|Edit")'); + }); + + it("should generate a loadable TypeScript module that registers the mapped events", async () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: 'echo "hi" `date` ${HOME}' }], + preToolUse: [ + { type: "command", command: "lint.sh", matcher: "Write|Edit" }, + { type: "command", command: "audit.sh" }, + ], + stop: [{ command: "done.sh" }], + }, + }; + const piHooks = PiHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks: buildRulesyncHooks({ testDir, config }), + validate: false, + }); + + const extensionsDir = join(testDir, ".pi", "extensions"); + await ensureDir(extensionsDir); + const filePath = join(extensionsDir, "rulesync-hooks.ts"); + await writeFileContent(filePath, piHooks.getFileContent()); + + const mod = await tsImport(pathToFileURL(filePath).href, import.meta.url); + const on = vi.fn(); + mod.default({ on }); + expect(on.mock.calls.map(([event]) => event)).toEqual([ + "session_start", + "tool_call", + "agent_end", + ]); + expect(on).toHaveBeenCalledWith("session_start", expect.any(Function)); + }); + }); + + describe("toRulesyncHooks", () => { + it("should throw because Pi hooks cannot be converted back", () => { + const piHooks = new PiHooks({ + outputRoot: testDir, + relativeDirPath: join(".pi", "extensions"), + relativeFilePath: "rulesync-hooks.ts", + fileContent: "export default function () {}", + validate: false, + }); + + expect(() => piHooks.toRulesyncHooks()).toThrow( + "Not implemented because Pi hooks are generated as a TypeScript extension file.", + ); + }); + }); + + describe("fromFile", () => { + it("should load from .pi/extensions/rulesync-hooks.ts", async () => { + const extensionsDir = join(testDir, ".pi", "extensions"); + await ensureDir(extensionsDir); + const content = "export default function () {}"; + await writeFileContent(join(extensionsDir, "rulesync-hooks.ts"), content); + + const piHooks = await PiHooks.fromFile({ + outputRoot: testDir, + validate: false, + }); + expect(piHooks).toBeInstanceOf(PiHooks); + expect(piHooks.getFileContent()).toBe(content); + }); + }); + + describe("forDeletion", () => { + it("should return PiHooks instance with empty content for deletion", () => { + const hooks = PiHooks.forDeletion({ + outputRoot: testDir, + relativeDirPath: join(".pi", "extensions"), + relativeFilePath: "rulesync-hooks.ts", + }); + expect(hooks).toBeInstanceOf(PiHooks); + expect(hooks.getFileContent()).toBe(""); + }); + }); + + describe("isDeletable", () => { + it("should return true (extension file is standalone and deletable)", () => { + const hooks = new PiHooks({ + outputRoot: testDir, + relativeDirPath: join(".pi", "extensions"), + relativeFilePath: "rulesync-hooks.ts", + fileContent: "", + validate: false, + }); + expect(hooks.isDeletable()).toBe(true); + }); + }); +}); diff --git a/src/features/hooks/pi-hooks.ts b/src/features/hooks/pi-hooks.ts new file mode 100644 index 000000000..d96bbb370 --- /dev/null +++ b/src/features/hooks/pi-hooks.ts @@ -0,0 +1,108 @@ +import { join } from "node:path"; + +import { + PI_AGENT_EXTENSIONS_DIR_PATH, + PI_EXTENSIONS_DIR_PATH, + PI_HOOKS_FILE_NAME, +} from "../../constants/pi-paths.js"; +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { CANONICAL_TO_PI_EVENT_NAMES, PI_HOOK_EVENTS } from "../../types/hooks.js"; +import { readFileContent } from "../../utils/file.js"; +import { generatePiExtensionCode } from "./pi-extension-generator.js"; +import type { RulesyncHooks } from "./rulesync-hooks.js"; +import { + ToolHooks, + type ToolHooksForDeletionParams, + type ToolHooksFromFileParams, + type ToolHooksFromRulesyncHooksParams, + type ToolHooksSettablePaths, +} from "./tool-hooks.js"; + +/** + * Pi Coding Agent has no static hooks config file; its extension API exposes + * lifecycle events instead. rulesync bridges canonical hooks by generating a + * rulesync-owned TypeScript extension in Pi's extension discovery paths: + * `.pi/extensions/rulesync-hooks.ts` (project) and + * `~/.pi/agent/extensions/rulesync-hooks.ts` (global). + * + * @see https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md + */ +export class PiHooks extends ToolHooks { + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "", + }); + } + + static getSettablePaths(options?: { global?: boolean }): ToolHooksSettablePaths { + return { + relativeDirPath: options?.global ? PI_AGENT_EXTENSIONS_DIR_PATH : PI_EXTENSIONS_DIR_PATH, + relativeFilePath: PI_HOOKS_FILE_NAME, + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + global = false, + }: ToolHooksFromFileParams): Promise { + const paths = PiHooks.getSettablePaths({ global }); + const fileContent = await readFileContent( + join(outputRoot, paths.relativeDirPath, paths.relativeFilePath), + ); + return new PiHooks({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static fromRulesyncHooks({ + outputRoot = process.cwd(), + rulesyncHooks, + validate = true, + global = false, + }: ToolHooksFromRulesyncHooksParams & { global?: boolean }): PiHooks { + const config = rulesyncHooks.getJson(); + const fileContent = generatePiExtensionCode({ + config, + supportedEvents: PI_HOOK_EVENTS, + eventMap: CANONICAL_TO_PI_EVENT_NAMES, + }); + const paths = PiHooks.getSettablePaths({ global }); + return new PiHooks({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + toRulesyncHooks(): RulesyncHooks { + throw new Error( + "Not implemented because Pi hooks are generated as a TypeScript extension file.", + ); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolHooksForDeletionParams): PiHooks { + return new PiHooks({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + }); + } +} diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 6515a7650..a8b5801de 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -270,6 +270,27 @@ export const OPENCODE_HOOK_EVENTS: readonly HookEvent[] = [ /** Hook events supported by Kilo. (Currently identical to OpenCode) */ export const KILO_HOOK_EVENTS: readonly HookEvent[] = OPENCODE_HOOK_EVENTS; +/** + * Hook events supported by Pi Coding Agent, bridged through a generated + * TypeScript extension (Pi has no static hook config file; its extension API + * exposes lifecycle events instead). + * + * Only canonical events with a semantically faithful Pi extension event are + * listed; see CANONICAL_TO_PI_EVENT_NAMES for the mapping. + * + * @see https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md + */ +export const PI_HOOK_EVENTS: readonly HookEvent[] = [ + "sessionStart", + "sessionEnd", + "preToolUse", + "postToolUse", + "preModelInvocation", + "beforeSubmitPrompt", + "stop", + "preCompact", +]; + /** * Hook events supported by GitHub Copilot (cloud coding agent). * @@ -696,6 +717,7 @@ export const HooksConfigSchema = z.looseObject({ copilotcli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), opencode: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), kilo: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), + pi: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), factorydroid: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), codexcli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), goose: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), @@ -910,6 +932,32 @@ export const CANONICAL_TO_OPENCODE_EVENT_NAMES: Record = { export const CANONICAL_TO_KILO_EVENT_NAMES: Record = CANONICAL_TO_OPENCODE_EVENT_NAMES; +/** + * Map canonical camelCase event names to Pi Coding Agent extension event + * names (snake_case). + * + * Mapping notes: `sessionEnd` → `session_shutdown` (fires on session + * teardown), `beforeSubmitPrompt` → `input` (user input interception), + * `preModelInvocation` → `context` (fires before each LLM call), and + * `stop` → `agent_end` (agent finished responding; unlike Claude Code's + * Stop, this also fires before Pi auto-retries or auto-compacts — + * `agent_settled` would skip queued follow-ups instead, a pure trade-off). + * Pi events without a faithful canonical counterpart (e.g. `turn_start`, + * `agent_settled`) are intentionally unmapped. + * + * @see https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md + */ +export const CANONICAL_TO_PI_EVENT_NAMES: Record = { + sessionStart: "session_start", + sessionEnd: "session_shutdown", + preToolUse: "tool_call", + postToolUse: "tool_result", + preModelInvocation: "context", + beforeSubmitPrompt: "input", + stop: "agent_end", + preCompact: "session_before_compact", +}; + /** * Map canonical camelCase event names to Copilot camelCase. */ diff --git a/src/types/tool-target-tuples.ts b/src/types/tool-target-tuples.ts index 6ce570acb..deaf8aead 100644 --- a/src/types/tool-target-tuples.ts +++ b/src/types/tool-target-tuples.ts @@ -205,6 +205,7 @@ export const hooksProcessorToolTargetTuple = [ "copilot", "copilotcli", "opencode", + "pi", "factorydroid", "goose", "hermesagent",