#!/usr/bin/env npx tsx
/**
* snappy-hooks/api.ts -- Hook management for the Claude Code harness.
*
* Reads hook configuration from ~/.claude/settings.json and checks
* hook scripts in ~/.claude/hooks/ for existence and executability.
*
* Usage:
* npx tsx api.ts list # list all configured hooks
* npx tsx api.ts status # check hook script health
*
* Or import as module:
* import { listHooks, getHookStatus } from "../snappy-hooks/api.ts";
*/
import { existsSync, readFileSync, realpathSync, statSync } from "fs";
import { join } from "path";
import { env } from "../snappy-settings/load.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
const SETTINGS_PATH = join(process.env.HOME!, ".claude/settings.json");
const HOOKS_DIR = join(process.env.HOME!, ".claude/hooks");
interface HookEntry {
type: string;
command: string;
timeout?: number;
}
interface HookGroup {
matcher: string;
hooks: HookEntry[];
}
interface HookConfig {
[event: string]: HookGroup[];
}
/** Lists all hooks configured in ~/.claude/settings.json. */
export function listHooks(): { event: string; matcher: string; command: string; timeout?: number }[] {
if (!existsSync(SETTINGS_PATH)) {
throw new Error(`settings.json not found at ${SETTINGS_PATH}`);
}
const settings = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
const hookConfig: HookConfig = settings.hooks || {};
const results: { event: string; matcher: string; command: string; timeout?: number }[] = [];
for (const [event, groups] of Object.entries(hookConfig)) {
for (const group of groups) {
for (const hook of group.hooks) {
results.push({
event,
matcher: group.matcher,
command: hook.command,
timeout: hook.timeout,
});
}
}
}
return results;
}
interface ScriptStatus {
name: string;
path: string;
exists: boolean;
executable: boolean;
sizeBytes: number;
}
/** Checks all .sh scripts in ~/.claude/hooks/ for existence and executability. */
export function getHookStatus(): ScriptStatus[] {
if (!existsSync(HOOKS_DIR)) {
return [];
}
const { execSync } = require("child_process");
const files: string[] = execSync(`ls ${HOOKS_DIR}/*.sh 2>/dev/null || true`, { encoding: "utf-8" })
.split("\n")
.filter(Boolean);
return files.map((filePath) => {
const stat = statSync(filePath);
const executable = !!(stat.mode & 0o111);
return {
name: filePath.split("/").pop()!,
path: filePath,
exists: true,
executable,
sizeBytes: stat.size,
};
});
}
// --- CLI ---
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
export const HAND_CONTRACT = {
skill: "snappy-hooks",
description: "Hook management for the Claude Code harness. Lists configured hooks and checks script health. Minimal stub skill - most hook work is done directly in ~/.claude/hooks/ or via the update-config skill.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb"),
verbs: {
list: {
args: [], flags: { limit: "--limit" }, effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: {
limit: limitSchema(200, "How many hooks to return"),
} },
},
status: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "list": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
const hooks = boundRows(listHooks(), bound.limit);
if (!hooks.length) {
console.log("No hooks configured in settings.json");
break;
}
for (const h of hooks) {
console.log(`${h.event}\t${h.matcher}\t${h.command}${h.timeout ? `\t(${h.timeout}s)` : ""}`);
}
break;
}
case "status": {
const scripts = getHookStatus();
if (!scripts.length) {
console.log("No hook scripts found in ~/.claude/hooks/");
break;
}
for (const s of scripts) {
const status = s.executable ? "OK" : "NOT EXECUTABLE";
console.log(`${status}\t${s.name}\t${s.sizeBytes}b`);
}
break;
}
default:
console.log("Usage: npx tsx api.ts [list|status]");
}
})();
}
#!/usr/bin/env npx tsx
/**
* snappy-hooks/api.ts -- Hook management for the Claude Code harness.
*
* Reads hook configuration from ~/.claude/settings.json and checks
* hook scripts in ~/.claude/hooks/ for existence and executability.
*
* Usage:
* npx tsx api.ts list # list all configured hooks
* npx tsx api.ts status # check hook script health
*
* Or import as module:
* import { listHooks, getHookStatus } from "../snappy-hooks/api.ts";
*/
import { existsSync, readFileSync, realpathSync, statSync } from "fs";
import { join } from "path";
import { env } from "../snappy-settings/load.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
const SETTINGS_PATH = join(process.env.HOME!, ".claude/settings.json");
const HOOKS_DIR = join(process.env.HOME!, ".claude/hooks");
interface HookEntry {
type: string;
command: string;
timeout?: number;
}
interface HookGroup {
matcher: string;
hooks: HookEntry[];
}
interface HookConfig {
[event: string]: HookGroup[];
}
/** Lists all hooks configured in ~/.claude/settings.json. */
export function listHooks(): { event: string; matcher: string; command: string; timeout?: number }[] {
if (!existsSync(SETTINGS_PATH)) {
throw new Error(`settings.json not found at ${SETTINGS_PATH}`);
}
const settings = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
const hookConfig: HookConfig = settings.hooks || {};
const results: { event: string; matcher: string; command: string; timeout?: number }[] = [];
for (const [event, groups] of Object.entries(hookConfig)) {
for (const group of groups) {
for (const hook of group.hooks) {
results.push({
event,
matcher: group.matcher,
command: hook.command,
timeout: hook.timeout,
});
}
}
}
return results;
}
interface ScriptStatus {
name: string;
path: string;
exists: boolean;
executable: boolean;
sizeBytes: number;
}
/** Checks all .sh scripts in ~/.claude/hooks/ for existence and executability. */
export function getHookStatus(): ScriptStatus[] {
if (!existsSync(HOOKS_DIR)) {
return [];
}
const { execSync } = require("child_process");
const files: string[] = execSync(`ls ${HOOKS_DIR}/*.sh 2>/dev/null || true`, { encoding: "utf-8" })
.split("\n")
.filter(Boolean);
return files.map((filePath) => {
const stat = statSync(filePath);
const executable = !!(stat.mode & 0o111);
return {
name: filePath.split("/").pop()!,
path: filePath,
exists: true,
executable,
sizeBytes: stat.size,
};
});
}
// --- CLI ---
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
export const HAND_CONTRACT = {
skill: "snappy-hooks",
description: "Hook management for the Claude Code harness. Lists configured hooks and checks script health. Minimal stub skill - most hook work is done directly in ~/.claude/hooks/ or via the update-config skill.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb"),
verbs: {
list: {
args: [], flags: { limit: "--limit" }, effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: {
limit: limitSchema(200, "How many hooks to return"),
} },
},
status: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "list": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
const hooks = boundRows(listHooks(), bound.limit);
if (!hooks.length) {
console.log("No hooks configured in settings.json");
break;
}
for (const h of hooks) {
console.log(`${h.event}\t${h.matcher}\t${h.command}${h.timeout ? `\t(${h.timeout}s)` : ""}`);
}
break;
}
case "status": {
const scripts = getHookStatus();
if (!scripts.length) {
console.log("No hook scripts found in ~/.claude/hooks/");
break;
}
for (const s of scripts) {
const status = s.executable ? "OK" : "NOT EXECUTABLE";
console.log(`${status}\t${s.name}\t${s.sizeBytes}b`);
}
break;
}
default:
console.log("Usage: npx tsx api.ts [list|status]");
}
})();
}