@hasna/hooks 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/index.js +215 -7
  2. package/package.json +1 -1
package/bin/index.js CHANGED
@@ -4183,13 +4183,20 @@ function createHooksServer() {
4183
4183
  name: "@hasna/hooks",
4184
4184
  version: pkg.version
4185
4185
  });
4186
- server.tool("hooks_list", "List all available hooks, optionally filtered by category", { category: z.string().optional().describe("Filter by category name (e.g. 'Git Safety', 'Code Quality', 'Security', 'Notifications', 'Context Management')") }, async ({ category }) => {
4186
+ server.tool("hooks_list", "List all available hooks, optionally filtered by category. Use compact:true to get minimal output (name+event+matcher only) \u2014 saves tokens.", {
4187
+ category: z.string().optional().describe("Filter by category name (e.g. 'Git Safety', 'Code Quality', 'Security')"),
4188
+ compact: z.boolean().default(false).describe("Return minimal fields only: name, event, matcher. Reduces token usage.")
4189
+ }, async ({ category, compact }) => {
4190
+ const slim = (hooks) => compact ? hooks.map((h) => ({ name: h.name, event: h.event, matcher: h.matcher })) : hooks;
4187
4191
  if (category) {
4188
4192
  const cat = CATEGORIES.find((c) => c.toLowerCase() === category.toLowerCase());
4189
4193
  if (!cat) {
4190
4194
  return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown category: ${category}`, available: [...CATEGORIES] }) }] };
4191
4195
  }
4192
- return { content: [{ type: "text", text: JSON.stringify(getHooksByCategory(cat)) }] };
4196
+ return { content: [{ type: "text", text: JSON.stringify(slim(getHooksByCategory(cat))) }] };
4197
+ }
4198
+ if (compact) {
4199
+ return { content: [{ type: "text", text: JSON.stringify(slim(HOOKS)) }] };
4193
4200
  }
4194
4201
  const result = {};
4195
4202
  for (const cat of CATEGORIES) {
@@ -4197,9 +4204,13 @@ function createHooksServer() {
4197
4204
  }
4198
4205
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
4199
4206
  });
4200
- server.tool("hooks_search", "Search for hooks by name, description, or tags", { query: z.string().describe("Search query") }, async ({ query }) => {
4207
+ server.tool("hooks_search", "Search for hooks by name, description, or tags. Use compact:true for minimal output to save tokens.", {
4208
+ query: z.string().describe("Search query"),
4209
+ compact: z.boolean().default(false).describe("Return minimal fields only: name, event, matcher.")
4210
+ }, async ({ query, compact }) => {
4201
4211
  const results = searchHooks(query);
4202
- return { content: [{ type: "text", text: JSON.stringify(results) }] };
4212
+ const out = compact ? results.map((h) => ({ name: h.name, event: h.event, matcher: h.matcher })) : results;
4213
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
4203
4214
  });
4204
4215
  server.tool("hooks_info", "Get detailed information about a specific hook including install status", { name: z.string().describe("Hook name (e.g. 'gitguard', 'checkpoint')") }, async ({ name }) => {
4205
4216
  const meta = getHook(name);
@@ -4286,7 +4297,7 @@ function createHooksServer() {
4286
4297
  if (hookHealthy)
4287
4298
  healthy.push(name);
4288
4299
  }
4289
- return { content: [{ type: "text", text: JSON.stringify({ healthy, issues, registered, scope }) }] };
4300
+ return { content: [{ type: "text", text: JSON.stringify({ healthy: issues.length === 0, healthy_hooks: healthy, issues, registered, scope }) }] };
4290
4301
  });
4291
4302
  server.tool("hooks_categories", "List all hook categories with counts", {}, async () => {
4292
4303
  const result = CATEGORIES.map((cat) => ({
@@ -4339,7 +4350,7 @@ function createHooksServer() {
4339
4350
  const registered = getRegisteredHooks(scope);
4340
4351
  const result = registered.map((name) => {
4341
4352
  const meta = getHook(name);
4342
- return { name, event: meta?.event, version: meta?.version, description: meta?.description };
4353
+ return { name, event: meta?.event, matcher: meta?.matcher ?? "", version: meta?.version, description: meta?.description };
4343
4354
  });
4344
4355
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
4345
4356
  });
@@ -4426,6 +4437,203 @@ function createHooksServer() {
4426
4437
  const failed = results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error }));
4427
4438
  return { content: [{ type: "text", text: JSON.stringify({ updated, failed, total: results.length }) }] };
4428
4439
  });
4440
+ server.tool("hooks_context", "Get full agent context in one call: installed hooks (with event+matcher), active profile, settings path, and doctor status. Call this once at session start instead of making 4 separate calls.", {
4441
+ scope: z.enum(["global", "project"]).default("global").describe("Scope to inspect"),
4442
+ profile: z.string().optional().describe("Agent profile ID to include in context")
4443
+ }, async ({ scope, profile }) => {
4444
+ const settingsPath = getSettingsPath(scope);
4445
+ const registered = getRegisteredHooks(scope);
4446
+ const hooks = registered.map((name) => {
4447
+ const meta = getHook(name);
4448
+ return { name, event: meta?.event, matcher: meta?.matcher ?? "", version: meta?.version };
4449
+ });
4450
+ const issues = [];
4451
+ for (const name of registered) {
4452
+ if (!hookExists(name)) {
4453
+ issues.push({ hook: name, issue: "Hook not found in package", severity: "error" });
4454
+ }
4455
+ }
4456
+ const healthy = issues.length === 0;
4457
+ const ctx = {
4458
+ scope,
4459
+ settings_path: settingsPath,
4460
+ settings_exists: existsSync3(settingsPath),
4461
+ registered_hooks: hooks,
4462
+ hook_count: hooks.length,
4463
+ healthy,
4464
+ issues,
4465
+ version: pkg.version
4466
+ };
4467
+ if (profile) {
4468
+ const p = getProfile(profile);
4469
+ ctx.profile = p ?? null;
4470
+ }
4471
+ return { content: [{ type: "text", text: JSON.stringify(ctx) }] };
4472
+ });
4473
+ server.tool("hooks_preview", "Simulate which installed PreToolUse hooks would fire for a given tool call and what decision each returns. Use this to understand your hook environment before taking an action.", {
4474
+ tool_name: z.string().describe("Tool name to simulate (e.g. 'Bash', 'Write', 'Edit')"),
4475
+ tool_input: z.record(z.string(), z.unknown()).default(() => ({})).describe("Tool input to pass to matching hooks"),
4476
+ scope: z.enum(["global", "project"]).default("global").describe("Scope to check"),
4477
+ timeout_ms: z.number().default(5000).describe("Per-hook timeout in milliseconds")
4478
+ }, async ({ tool_name, tool_input, scope, timeout_ms }) => {
4479
+ const registered = getRegisteredHooks(scope);
4480
+ const matchingHooks = registered.filter((name) => {
4481
+ const meta = getHook(name);
4482
+ if (!meta || meta.event !== "PreToolUse")
4483
+ return false;
4484
+ if (!meta.matcher)
4485
+ return true;
4486
+ try {
4487
+ return new RegExp(meta.matcher).test(tool_name);
4488
+ } catch {
4489
+ return false;
4490
+ }
4491
+ });
4492
+ if (matchingHooks.length === 0) {
4493
+ return { content: [{ type: "text", text: JSON.stringify({ tool_name, matching_hooks: [], result: "no_hooks_match", decision: "approve" }) }] };
4494
+ }
4495
+ const input = { tool_name, tool_input };
4496
+ const results = await Promise.all(matchingHooks.map(async (name) => {
4497
+ const hookDir = getHookPath(name);
4498
+ const hookScript = join3(hookDir, "src", "hook.ts");
4499
+ if (!existsSync3(hookScript))
4500
+ return { name, decision: "approve", error: "script not found" };
4501
+ const proc = Bun.spawn(["bun", "run", hookScript], {
4502
+ stdin: new Response(JSON.stringify(input)),
4503
+ stdout: "pipe",
4504
+ stderr: "pipe",
4505
+ env: process.env
4506
+ });
4507
+ const timeout = new Promise((r) => setTimeout(() => r(null), timeout_ms));
4508
+ const res = await Promise.race([
4509
+ Promise.all([new Response(proc.stdout).text(), proc.exited]).then(([stdout]) => ({ stdout, timedOut: false })),
4510
+ timeout.then(() => {
4511
+ proc.kill();
4512
+ return { stdout: "", timedOut: true };
4513
+ })
4514
+ ]);
4515
+ if (res.timedOut)
4516
+ return { name, decision: "approve", timedOut: true };
4517
+ let output = {};
4518
+ try {
4519
+ output = JSON.parse(res.stdout);
4520
+ } catch {}
4521
+ return { name, decision: output.decision ?? "approve", reason: output.reason, raw: output };
4522
+ }));
4523
+ const blocked = results.find((r) => r.decision === "block");
4524
+ return {
4525
+ content: [{
4526
+ type: "text",
4527
+ text: JSON.stringify({
4528
+ tool_name,
4529
+ matching_hooks: matchingHooks,
4530
+ results,
4531
+ decision: blocked ? "block" : "approve",
4532
+ blocked_by: blocked?.name ?? null,
4533
+ blocked_reason: blocked?.reason ?? null
4534
+ })
4535
+ }]
4536
+ };
4537
+ });
4538
+ server.tool("hooks_setup", "Single-shot agent onboarding: create an agent profile + install recommended hooks in one call. Ideal for agents setting up hooks at session start.", {
4539
+ agent_type: z.enum(["claude", "gemini", "custom"]).default("claude").describe("Type of AI agent"),
4540
+ name: z.string().optional().describe("Optional display name for the agent"),
4541
+ hooks: z.array(z.string()).optional().describe("Hook names to install (omit for sensible defaults: gitguard, checkpoint, checktests, protectfiles)"),
4542
+ scope: z.enum(["global", "project"]).default("global").describe("Install scope")
4543
+ }, async ({ agent_type, name, hooks, scope }) => {
4544
+ const profile = createProfile({ agent_type, name });
4545
+ const toInstall = hooks && hooks.length > 0 ? hooks : ["gitguard", "checkpoint", "checktests", "protectfiles"];
4546
+ const results = toInstall.map((h) => installHook(h, { scope, overwrite: false, profile: profile.agent_id }));
4547
+ const installed = results.filter((r) => r.success).map((r) => r.hook);
4548
+ const failed = results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error }));
4549
+ return {
4550
+ content: [{
4551
+ type: "text",
4552
+ text: JSON.stringify({ profile, installed, failed, scope, run_with: `hooks run <name> --profile ${profile.agent_id}` })
4553
+ }]
4554
+ };
4555
+ });
4556
+ server.tool("hooks_batch_run", "Run multiple hooks in parallel in a single call. Returns all results at once \u2014 more efficient than N separate hooks_run calls.", {
4557
+ hooks: z.array(z.object({
4558
+ name: z.string().describe("Hook name"),
4559
+ input: z.record(z.string(), z.unknown()).default(() => ({})).describe("Hook input JSON")
4560
+ })).describe("List of hooks to run with their inputs"),
4561
+ timeout_ms: z.number().default(1e4).describe("Per-hook timeout in milliseconds")
4562
+ }, async ({ hooks, timeout_ms }) => {
4563
+ const results = await Promise.all(hooks.map(async ({ name, input }) => {
4564
+ const meta = getHook(name);
4565
+ if (!meta)
4566
+ return { name, error: `Hook '${name}' not found` };
4567
+ const hookScript = join3(getHookPath(name), "src", "hook.ts");
4568
+ if (!existsSync3(hookScript))
4569
+ return { name, error: "script not found" };
4570
+ const proc = Bun.spawn(["bun", "run", hookScript], {
4571
+ stdin: new Response(JSON.stringify(input)),
4572
+ stdout: "pipe",
4573
+ stderr: "pipe",
4574
+ env: process.env
4575
+ });
4576
+ const timeout = new Promise((r) => setTimeout(() => r(null), timeout_ms));
4577
+ const res = await Promise.race([
4578
+ Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]).then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode, timedOut: false })),
4579
+ timeout.then(() => {
4580
+ proc.kill();
4581
+ return { stdout: "", stderr: "", exitCode: -1, timedOut: true };
4582
+ })
4583
+ ]);
4584
+ let output = {};
4585
+ try {
4586
+ output = JSON.parse(res.stdout);
4587
+ } catch {
4588
+ output = res.stdout ? { raw: res.stdout } : {};
4589
+ }
4590
+ return { name, output, exitCode: res.exitCode, ...res.timedOut ? { timedOut: true } : {} };
4591
+ }));
4592
+ return { content: [{ type: "text", text: JSON.stringify({ results, count: results.length }) }] };
4593
+ });
4594
+ server.tool("hooks_disable", "Temporarily disable a registered hook without removing it. Stores disabled list in settings under hooks.__disabled.", {
4595
+ name: z.string().describe("Hook name to disable"),
4596
+ scope: z.enum(["global", "project"]).default("global").describe("Scope")
4597
+ }, async ({ name, scope }) => {
4598
+ const settingsPath = getSettingsPath(scope);
4599
+ let settings = {};
4600
+ try {
4601
+ if (existsSync3(settingsPath))
4602
+ settings = JSON.parse(readFileSync3(settingsPath, "utf-8"));
4603
+ } catch {}
4604
+ if (!settings.hooks)
4605
+ settings.hooks = {};
4606
+ const disabled = settings.hooks.__disabled ?? [];
4607
+ if (!disabled.includes(name))
4608
+ disabled.push(name);
4609
+ settings.hooks.__disabled = disabled;
4610
+ const { writeFileSync: writeFileSync3, mkdirSync: mkdirSync3 } = await import("fs");
4611
+ const { dirname: dirname3 } = await import("path");
4612
+ mkdirSync3(dirname3(settingsPath), { recursive: true });
4613
+ writeFileSync3(settingsPath, JSON.stringify(settings, null, 2) + `
4614
+ `);
4615
+ return { content: [{ type: "text", text: JSON.stringify({ hook: name, disabled: true, scope }) }] };
4616
+ });
4617
+ server.tool("hooks_enable", "Re-enable a previously disabled hook.", {
4618
+ name: z.string().describe("Hook name to enable"),
4619
+ scope: z.enum(["global", "project"]).default("global").describe("Scope")
4620
+ }, async ({ name, scope }) => {
4621
+ const settingsPath = getSettingsPath(scope);
4622
+ let settings = {};
4623
+ try {
4624
+ if (existsSync3(settingsPath))
4625
+ settings = JSON.parse(readFileSync3(settingsPath, "utf-8"));
4626
+ } catch {}
4627
+ if (settings.hooks?.__disabled) {
4628
+ settings.hooks.__disabled = settings.hooks.__disabled.filter((n) => n !== name);
4629
+ if (settings.hooks.__disabled.length === 0)
4630
+ delete settings.hooks.__disabled;
4631
+ const { writeFileSync: writeFileSync3 } = await import("fs");
4632
+ writeFileSync3(settingsPath, JSON.stringify(settings, null, 2) + `
4633
+ `);
4634
+ }
4635
+ return { content: [{ type: "text", text: JSON.stringify({ hook: name, disabled: false, scope }) }] };
4636
+ });
4429
4637
  server.tool("hooks_init", "Register a new agent profile \u2014 returns a unique agent_id for use with hook installation and execution", {
4430
4638
  agent_type: z.enum(["claude", "gemini", "custom"]).default("claude").describe("Type of AI agent"),
4431
4639
  name: z.string().optional().describe("Optional display name for the agent")
@@ -6095,7 +6303,7 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
6095
6303
  }
6096
6304
  }
6097
6305
  if (options.json) {
6098
- console.log(JSON.stringify({ healthy, issues, registered, scope }));
6306
+ console.log(JSON.stringify({ healthy: issues.length === 0, healthy_hooks: healthy, issues, registered, scope }));
6099
6307
  return;
6100
6308
  }
6101
6309
  console.log(chalk2.bold(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {