compat with claude skills + hooks support

This commit is contained in:
2026-03-31 10:07:55 +01:00
parent 39e7bddb35
commit 63caa82199
2 changed files with 604 additions and 0 deletions
@@ -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<string, unknown> | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
return value as Record<string, unknown>;
}
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<string, unknown>,
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<string, unknown>;
if (itemRecord.type === "text" && typeof itemRecord.text === "string") {
parts.push(itemRecord.text);
}
}
return parts.join("\n");
}
function normalizeToolInput(input: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = { ...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<string, unknown>,
): Record<string, unknown> {
const response: Record<string, unknown> = {
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<string, unknown> {
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<string, unknown>,
): Promise<CommandRunResult> {
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<string>();
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",
);
}
}
});
}
@@ -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 };
});
}