diff --git a/pi/files/agent/extensions/hooks-resolution.ts b/pi/files/agent/extensions/hooks-resolution.ts new file mode 100644 index 0000000..df279e6 --- /dev/null +++ b/pi/files/agent/extensions/hooks-resolution.ts @@ -0,0 +1,544 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { spawn } from "node:child_process"; +import * as os from "node:os"; +import { dirname, join, resolve } from "node:path"; +import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@mariozechner/pi-coding-agent"; + +const HOOK_TIMEOUT_MS = 10 * 60 * 1000; +const LOG_FILE = join(os.homedir(), ".pi", "hooks-resolution.log"); + +type HookEventName = "PostToolUse" | "PostToolUseFailure"; + +type ResolvedCommandHook = { + eventName: HookEventName; + matcher?: RegExp; + matcherText?: string; + command: string; + source: string; +}; + +type HookState = { + projectDir: string; + hooks: ResolvedCommandHook[]; +}; + +type CommandRunResult = { + code: number; + stdout: string; + stderr: string; + elapsedMs: number; + timedOut: boolean; +}; + +function logHook(message: string): void { + const line = `[${new Date().toISOString()}] ${message}`; + console.error(`[hooks-resolution] ${line}`); + + try { + mkdirSync(dirname(LOG_FILE), { recursive: true }); + appendFileSync(LOG_FILE, `${line}\n`); + } catch { + // ignore logging failures + } +} + +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( + eventName: HookEventName, + matcherText: string | undefined, + command: string, + source: string, + projectDir: string, +): ResolvedCommandHook | undefined { + const matcher = compileMatcher(matcherText); + if (matcherText !== undefined && matcher === undefined) { + return undefined; + } + + return { + eventName, + matcher, + matcherText, + command: resolveHookCommand(command, projectDir), + source, + }; +} + +function getHookEntries( + hooksRecord: Record, + eventName: HookEventName, +): unknown[] { + const keys = + eventName === "PostToolUse" + ? ["PostToolUse", "postToolUse"] + : ["PostToolUseFailure", "postToolUseFailure"]; + + for (const key of keys) { + const value = hooksRecord[key]; + if (Array.isArray(value)) { + return value; + } + } + + return []; +} + +function parseClaudeSettingsHooks( + config: unknown, + source: string, + projectDir: string, +): ResolvedCommandHook[] { + const root = asRecord(config); + const hooksRoot = root ? asRecord(root.hooks) : undefined; + if (!hooksRoot) { + return []; + } + + const hooks: ResolvedCommandHook[] = []; + const events: HookEventName[] = ["PostToolUse", "PostToolUseFailure"]; + + for (const eventName of events) { + const entries = getHookEntries(hooksRoot, eventName); + 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( + eventName, + matcherText, + nestedHookRecord.command, + source, + projectDir, + ); + if (hook) { + hooks.push(hook); + } + } + } + } + + return hooks; +} + +function parseSimpleHooksFile( + config: unknown, + source: string, + projectDir: string, +): ResolvedCommandHook[] { + const root = asRecord(config); + const hooksRoot = root ? asRecord(root.hooks) : undefined; + if (!hooksRoot) { + return []; + } + + const hooks: ResolvedCommandHook[] = []; + const events: HookEventName[] = ["PostToolUse", "PostToolUseFailure"]; + + for (const eventName of events) { + const entries = getHookEntries(hooksRoot, eventName); + 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( + eventName, + 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 hooks: ResolvedCommandHook[] = []; + + const claudeSettings = readJsonFile(claudeSettingsPath); + if (claudeSettings !== undefined) { + hooks.push(...parseClaudeSettingsHooks(claudeSettings, claudeSettingsPath, projectDir)); + } + + const ruleSyncHooks = readJsonFile(ruleSyncHooksPath); + if (ruleSyncHooks !== undefined) { + hooks.push(...parseSimpleHooksFile(ruleSyncHooks, ruleSyncHooksPath, projectDir)); + } + + const piHooks = readJsonFile(piHooksPath); + if (piHooks !== undefined) { + hooks.push(...parseSimpleHooksFile(piHooks, piHooksPath, projectDir)); + } + + return { + projectDir, + hooks, + }; +} + +function toClaudeToolName(toolName: string): string { + if (toolName === "ls") { + return "LS"; + } + if (toolName.length === 0) { + return toolName; + } + return toolName[0].toUpperCase() + toolName.slice(1); +} + +function matchesHook(hook: ResolvedCommandHook, 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); +} + +function extractTextContent(content: unknown): string { + if (!Array.isArray(content)) { + return ""; + } + + const parts: string[] = []; + for (const item of content) { + if (!item || typeof item !== "object") { + continue; + } + + const itemRecord = item as Record; + if (itemRecord.type === "text" && typeof itemRecord.text === "string") { + parts.push(itemRecord.text); + } + } + + return parts.join("\n"); +} + +function normalizeToolInput(input: Record): Record { + const normalized: Record = { ...input }; + const pathValue = typeof input.path === "string" ? input.path : undefined; + + if (pathValue !== undefined) { + normalized.file_path = pathValue; + normalized.filePath = pathValue; + } + + return normalized; +} + +function buildToolResponse( + event: ToolResultEvent, + normalizedInput: Record, +): Record { + const response: Record = { + is_error: event.isError, + isError: event.isError, + content: event.content, + text: extractTextContent(event.content), + details: event.details ?? null, + }; + + const filePath = + typeof normalizedInput.file_path === "string" ? normalizedInput.file_path : undefined; + if (filePath !== undefined) { + response.file_path = filePath; + response.filePath = filePath; + } + + return response; +} + +function buildHookPayload( + event: ToolResultEvent, + eventName: HookEventName, + ctx: ExtensionContext, + projectDir: string, +): Record { + const normalizedInput = normalizeToolInput(event.input); + const sessionId = ctx.sessionManager.getSessionFile() ?? "ephemeral"; + + return { + session_id: sessionId, + cwd: ctx.cwd, + claude_project_dir: projectDir, + hook_event_name: eventName, + tool_name: toClaudeToolName(event.toolName), + tool_call_id: event.toolCallId, + tool_input: normalizedInput, + tool_response: buildToolResponse(event, normalizedInput), + }; +} + +function runCommandHook( + command: string, + cwd: string, + payload: Record, +): Promise { + return new Promise((resolve) => { + const startedAt = Date.now(); + const child = spawn("bash", ["-lc", command], { + cwd, + env: { ...process.env, CLAUDE_PROJECT_DIR: cwd }, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + let resolved = false; + + const finish = (code: number) => { + if (resolved) { + return; + } + resolved = true; + resolve({ + code, + stdout, + stderr, + elapsedMs: Date.now() - startedAt, + timedOut, + }); + }; + + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + + const killTimer = setTimeout(() => { + child.kill("SIGKILL"); + }, 1000); + (killTimer as NodeJS.Timeout & { unref?: () => void }).unref?.(); + }, HOOK_TIMEOUT_MS); + (timeout as NodeJS.Timeout & { unref?: () => void }).unref?.(); + + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + + child.on("error", (error) => { + clearTimeout(timeout); + stderr += `${error.message}\n`; + finish(-1); + }); + + child.on("close", (code) => { + clearTimeout(timeout); + finish(code ?? -1); + }); + + try { + child.stdin.write(JSON.stringify(payload)); + child.stdin.end(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + stderr += `${message}\n`; + } + }); +} + +function hookEventNameForResult(event: ToolResultEvent): HookEventName { + return event.isError ? "PostToolUseFailure" : "PostToolUse"; +} + +export default function(pi: ExtensionAPI) { + let state: HookState = { + projectDir: process.cwd(), + hooks: [], + }; + + const refreshHooks = (cwd: string) => { + state = loadHooks(cwd); + const postCount = state.hooks.filter((hook) => hook.eventName === "PostToolUse").length; + const failureCount = state.hooks.filter( + (hook) => hook.eventName === "PostToolUseFailure", + ).length; + logHook( + `loaded hooks projectDir=${state.projectDir} postToolUse=${postCount} postToolUseFailure=${failureCount}`, + ); + }; + + pi.on("session_start", (_event, ctx) => { + refreshHooks(ctx.cwd); + }); + + pi.on("session_switch", (_event, ctx) => { + refreshHooks(ctx.cwd); + }); + + pi.on("tool_result", async (event, ctx) => { + if (state.hooks.length === 0) { + return; + } + + const eventName = hookEventNameForResult(event); + const matchingHooks = state.hooks.filter( + (hook) => hook.eventName === eventName && matchesHook(hook, event.toolName), + ); + if (matchingHooks.length === 0) { + return; + } + + const payload = buildHookPayload(event, eventName, ctx, state.projectDir); + const executedCommands = new Set(); + + for (const hook of matchingHooks) { + if (executedCommands.has(hook.command)) { + logHook( + `deduped event=${eventName} tool=${event.toolName} source=${hook.source} command=${JSON.stringify(hook.command)}`, + ); + continue; + } + executedCommands.add(hook.command); + + logHook( + `run event=${eventName} tool=${event.toolName} matcher=${hook.matcherText ?? "*"} source=${hook.source} command=${JSON.stringify(hook.command)}`, + ); + + const result = await runCommandHook(hook.command, state.projectDir, payload); + logHook( + `done event=${eventName} tool=${event.toolName} code=${result.code} durationMs=${result.elapsedMs} timedOut=${result.timedOut}`, + ); + + 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 }; + }); +}