From 9ab9fe9254292c5c6703dad855f661b1a2778e7b Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" Date: Tue, 31 Mar 2026 10:07:55 +0100 Subject: [PATCH] compat with claude skills + hooks support --- pi/files/agent/extensions/hooks-resolution.ts | 308 ++++++++++++++++++ .../agent/extensions/skills-resolution.ts | 60 ++++ 2 files changed, 368 insertions(+) create mode 100644 pi/files/agent/extensions/hooks-resolution.ts create mode 100644 pi/files/agent/extensions/skills-resolution.ts diff --git a/pi/files/agent/extensions/hooks-resolution.ts b/pi/files/agent/extensions/hooks-resolution.ts new file mode 100644 index 0000000..072f9ac --- /dev/null +++ b/pi/files/agent/extensions/hooks-resolution.ts @@ -0,0 +1,308 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +type ResolvedPostToolUseHook = { + matcher?: RegExp; + matcherText?: string; + command: string; + source: string; +}; + +type HookState = { + projectDir: string; + postToolUseHooks: ResolvedPostToolUseHook[]; +}; + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + return value as Record; +} + +function walkUpDirectories(startDir: string, stopDir?: string): string[] { + const directories: string[] = []; + const hasStopDir = stopDir !== undefined; + let current = resolve(startDir); + let parent = dirname(current); + let reachedStopDir = hasStopDir && current === stopDir; + let reachedFilesystemRoot = parent === current; + + directories.push(current); + while (!reachedStopDir && !reachedFilesystemRoot) { + current = parent; + parent = dirname(current); + reachedStopDir = hasStopDir && current === stopDir; + reachedFilesystemRoot = parent === current; + directories.push(current); + } + + return directories; +} + +function findNearestGitRoot(startDir: string): string | undefined { + for (const directory of walkUpDirectories(startDir)) { + if (existsSync(join(directory, ".git"))) { + return directory; + } + } + return undefined; +} + +function hasHooksConfig(directory: string): boolean { + const claudeSettingsPath = join(directory, ".claude", "settings.json"); + const ruleSyncHooksPath = join(directory, ".rulesync", "hooks.json"); + const piHooksPath = join(directory, ".pi", "hooks.json"); + return isFile(claudeSettingsPath) || isFile(ruleSyncHooksPath) || isFile(piHooksPath); +} + +function findProjectDir(cwd: string): string { + const gitRoot = findNearestGitRoot(cwd); + for (const directory of walkUpDirectories(cwd, gitRoot)) { + if (hasHooksConfig(directory)) { + return directory; + } + } + + return gitRoot ?? resolve(cwd); +} + +function readJsonFile(path: string): unknown | undefined { + if (!isFile(path)) { + return undefined; + } + + try { + return JSON.parse(readFileSync(path, "utf8")) as unknown; + } catch { + return undefined; + } +} + +function resolveHookCommand(command: string, projectDir: string): string { + return command.replace(/\$CLAUDE_PROJECT_DIR\b/g, projectDir); +} + +function compileMatcher(matcherText: string | undefined): RegExp | undefined { + if (matcherText === undefined) { + return undefined; + } + + try { + return new RegExp(matcherText); + } catch { + return undefined; + } +} + +function createHook( + matcherText: string | undefined, + command: string, + source: string, + projectDir: string, +): ResolvedPostToolUseHook | undefined { + const matcher = compileMatcher(matcherText); + if (matcherText !== undefined && matcher === undefined) { + return undefined; + } + + return { + matcher, + matcherText, + command: resolveHookCommand(command, projectDir), + source, + }; +} + +function getPostToolUseEntries(hooksRecord: Record): unknown[] { + if (Array.isArray(hooksRecord.PostToolUse)) { + return hooksRecord.PostToolUse; + } + if (Array.isArray(hooksRecord.postToolUse)) { + return hooksRecord.postToolUse; + } + return []; +} + +function parseClaudeSettingsHooks( + config: unknown, + source: string, + projectDir: string, +): ResolvedPostToolUseHook[] { + const root = asRecord(config); + const hooksRoot = root ? asRecord(root.hooks) : undefined; + if (!hooksRoot) { + return []; + } + + const entries = getPostToolUseEntries(hooksRoot); + const hooks: ResolvedPostToolUseHook[] = []; + + for (const entry of entries) { + const entryRecord = asRecord(entry); + if (!entryRecord || !Array.isArray(entryRecord.hooks)) { + continue; + } + + const matcherText = typeof entryRecord.matcher === "string" ? entryRecord.matcher : undefined; + for (const nestedHook of entryRecord.hooks) { + const nestedHookRecord = asRecord(nestedHook); + if (!nestedHookRecord) { + continue; + } + + if (nestedHookRecord.type !== "command") { + continue; + } + + if (typeof nestedHookRecord.command !== "string") { + continue; + } + + const hook = createHook(matcherText, nestedHookRecord.command, source, projectDir); + if (hook) { + hooks.push(hook); + } + } + } + + return hooks; +} + +function parseSimpleHooksFile( + config: unknown, + source: string, + projectDir: string, +): ResolvedPostToolUseHook[] { + const root = asRecord(config); + const hooksRoot = root ? asRecord(root.hooks) : undefined; + if (!hooksRoot) { + return []; + } + + const entries = getPostToolUseEntries(hooksRoot); + const hooks: ResolvedPostToolUseHook[] = []; + + for (const entry of entries) { + const entryRecord = asRecord(entry); + if (!entryRecord || typeof entryRecord.command !== "string") { + continue; + } + + const matcherText = typeof entryRecord.matcher === "string" ? entryRecord.matcher : undefined; + const hook = createHook(matcherText, entryRecord.command, source, projectDir); + if (hook) { + hooks.push(hook); + } + } + + return hooks; +} + +function loadHooks(cwd: string): HookState { + const projectDir = findProjectDir(cwd); + const claudeSettingsPath = join(projectDir, ".claude", "settings.json"); + const ruleSyncHooksPath = join(projectDir, ".rulesync", "hooks.json"); + const piHooksPath = join(projectDir, ".pi", "hooks.json"); + + const postToolUseHooks: ResolvedPostToolUseHook[] = []; + + const claudeSettings = readJsonFile(claudeSettingsPath); + if (claudeSettings !== undefined) { + postToolUseHooks.push( + ...parseClaudeSettingsHooks(claudeSettings, claudeSettingsPath, projectDir), + ); + } + + const ruleSyncHooks = readJsonFile(ruleSyncHooksPath); + if (ruleSyncHooks !== undefined) { + postToolUseHooks.push( + ...parseSimpleHooksFile(ruleSyncHooks, ruleSyncHooksPath, projectDir), + ); + } + + const piHooks = readJsonFile(piHooksPath); + if (piHooks !== undefined) { + postToolUseHooks.push(...parseSimpleHooksFile(piHooks, piHooksPath, projectDir)); + } + + return { + projectDir, + postToolUseHooks, + }; +} + +function toClaudeToolName(toolName: string): string { + if (toolName.length === 0) { + return toolName; + } + return toolName[0].toUpperCase() + toolName.slice(1); +} + +function matchesHook(hook: ResolvedPostToolUseHook, toolName: string): boolean { + if (!hook.matcher) { + return true; + } + + const claudeToolName = toClaudeToolName(toolName); + hook.matcher.lastIndex = 0; + if (hook.matcher.test(toolName)) { + return true; + } + + hook.matcher.lastIndex = 0; + return hook.matcher.test(claudeToolName); +} + +export default function(pi: ExtensionAPI) { + let state: HookState = { + projectDir: process.cwd(), + postToolUseHooks: [], + }; + + const refreshHooks = (cwd: string) => { + state = loadHooks(cwd); + }; + + pi.on("session_start", (_event, ctx) => { + refreshHooks(ctx.cwd); + }); + + pi.on("session_switch", (_event, ctx) => { + refreshHooks(ctx.cwd); + }); + + pi.on("tool_execution_end", async (event, ctx) => { + if (state.postToolUseHooks.length === 0) { + return; + } + + for (const hook of state.postToolUseHooks) { + if (!matchesHook(hook, event.toolName)) { + continue; + } + + const result = await pi.exec("bash", ["-lc", hook.command], { + cwd: state.projectDir, + }); + + if (result.code !== 0) { + const matcherLabel = hook.matcherText ?? "*"; + const errorLine = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`; + ctx.ui.notify( + `Hook failed (${matcherLabel}) from ${hook.source}: ${errorLine}`, + "warning", + ); + } + } + }); +} diff --git a/pi/files/agent/extensions/skills-resolution.ts b/pi/files/agent/extensions/skills-resolution.ts new file mode 100644 index 0000000..2b5f001 --- /dev/null +++ b/pi/files/agent/extensions/skills-resolution.ts @@ -0,0 +1,60 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function walkUpDirectories(startDir: string, stopDir?: string): string[] { + const directories: string[] = []; + const hasStopDir = stopDir !== undefined; + + let current = resolve(startDir); + let parent = dirname(current); + let reachedStopDir = hasStopDir && current === stopDir; + let reachedFilesystemRoot = parent === current; + + directories.push(current); + while (!reachedStopDir && !reachedFilesystemRoot) { + current = parent; + parent = dirname(current); + reachedStopDir = hasStopDir && current === stopDir; + reachedFilesystemRoot = parent === current; + directories.push(current); + } + + return directories; +} + +function findNearestGitRoot(startDir: string): string | undefined { + for (const directory of walkUpDirectories(startDir)) { + if (existsSync(join(directory, ".git"))) { + return directory; + } + } + return undefined; +} + +function findClaudeSkillDirs(cwd: string): string[] { + const gitRoot = findNearestGitRoot(cwd); + + return walkUpDirectories(cwd, gitRoot) + .map((directory) => join(directory, ".claude", "skills")) + .filter(isDirectory); +} + +export default function(pi: ExtensionAPI) { + pi.on("resources_discover", (event) => { + const skillPaths = findClaudeSkillDirs(event.cwd); + if (skillPaths.length === 0) { + return; + } + + return { skillPaths }; + }); +}