compat with claude skills + hooks support

This commit is contained in:
2026-03-31 10:07:55 +01:00
parent 39e7bddb35
commit 9ab9fe9254
2 changed files with 368 additions and 0 deletions
@@ -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<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(
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<string, unknown>): 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",
);
}
}
});
}