compat with claude skills + hooks support
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user