compat with claude skills + hooks support
This commit is contained in:
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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