@hasna/hooks 0.2.4 → 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 (3) hide show
  1. package/bin/index.js +431 -28
  2. package/dist/index.js +48 -1
  3. package/package.json +1 -1
package/bin/index.js CHANGED
@@ -3905,6 +3905,25 @@ function writeSettings(settings, scope = "global", target = "claude") {
3905
3905
  function getTargetEventName(internalEvent, target) {
3906
3906
  return EVENT_MAP[target]?.[internalEvent] || internalEvent;
3907
3907
  }
3908
+ function detectConflict(name, scope, target) {
3909
+ const meta = getHook(name);
3910
+ if (!meta || !meta.matcher)
3911
+ return;
3912
+ const registered = getRegisteredHooksForTarget(scope, target);
3913
+ for (const existingName of registered) {
3914
+ if (existingName === name)
3915
+ continue;
3916
+ const existing = getHook(existingName);
3917
+ if (!existing || existing.event !== meta.event || !existing.matcher)
3918
+ continue;
3919
+ const a = meta.matcher.toLowerCase();
3920
+ const b = existing.matcher.toLowerCase();
3921
+ if (a === b || a.includes(b) || b.includes(a)) {
3922
+ return `conflicts with '${existingName}' (same event ${meta.event}, overlapping matcher '${existing.matcher}')`;
3923
+ }
3924
+ }
3925
+ return;
3926
+ }
3908
3927
  function installForTarget(name, scope, overwrite, target, profile) {
3909
3928
  const shortName = shortHookName(name);
3910
3929
  if (!hookExists(shortName)) {
@@ -3914,9 +3933,10 @@ function installForTarget(name, scope, overwrite, target, profile) {
3914
3933
  if (registered.includes(shortName) && !overwrite) {
3915
3934
  return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
3916
3935
  }
3936
+ const conflict = detectConflict(shortName, scope, target);
3917
3937
  try {
3918
3938
  registerHook(shortName, scope, target, profile);
3919
- return { hook: shortName, success: true, scope, target };
3939
+ return { hook: shortName, success: true, scope, target, ...conflict ? { conflict } : {} };
3920
3940
  } catch (error) {
3921
3941
  return {
3922
3942
  hook: shortName,
@@ -4104,6 +4124,29 @@ function touchProfile(id) {
4104
4124
  writeFileSync2(profilePath(id), JSON.stringify(profile, null, 2) + `
4105
4125
  `);
4106
4126
  }
4127
+ function exportProfiles() {
4128
+ return listProfiles();
4129
+ }
4130
+ function importProfiles(profiles) {
4131
+ ensureProfilesDir();
4132
+ let imported = 0;
4133
+ let skipped = 0;
4134
+ for (const profile of profiles) {
4135
+ if (!profile.agent_id || !profile.agent_type) {
4136
+ skipped++;
4137
+ continue;
4138
+ }
4139
+ const path = profilePath(profile.agent_id);
4140
+ if (existsSync2(path)) {
4141
+ skipped++;
4142
+ continue;
4143
+ }
4144
+ writeFileSync2(path, JSON.stringify(profile, null, 2) + `
4145
+ `);
4146
+ imported++;
4147
+ }
4148
+ return { imported, skipped };
4149
+ }
4107
4150
  var PROFILES_DIR;
4108
4151
  var init_profiles = __esm(() => {
4109
4152
  PROFILES_DIR = join2(homedir2(), ".hooks", "profiles");
@@ -4140,13 +4183,20 @@ function createHooksServer() {
4140
4183
  name: "@hasna/hooks",
4141
4184
  version: pkg.version
4142
4185
  });
4143
- 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;
4144
4191
  if (category) {
4145
4192
  const cat = CATEGORIES.find((c) => c.toLowerCase() === category.toLowerCase());
4146
4193
  if (!cat) {
4147
4194
  return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown category: ${category}`, available: [...CATEGORIES] }) }] };
4148
4195
  }
4149
- 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)) }] };
4150
4200
  }
4151
4201
  const result = {};
4152
4202
  for (const cat of CATEGORIES) {
@@ -4154,9 +4204,13 @@ function createHooksServer() {
4154
4204
  }
4155
4205
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
4156
4206
  });
4157
- 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 }) => {
4158
4211
  const results = searchHooks(query);
4159
- 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) }] };
4160
4214
  });
4161
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 }) => {
4162
4216
  const meta = getHook(name);
@@ -4243,7 +4297,7 @@ function createHooksServer() {
4243
4297
  if (hookHealthy)
4244
4298
  healthy.push(name);
4245
4299
  }
4246
- 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 }) }] };
4247
4301
  });
4248
4302
  server.tool("hooks_categories", "List all hook categories with counts", {}, async () => {
4249
4303
  const result = CATEGORIES.map((cat) => ({
@@ -4296,15 +4350,16 @@ function createHooksServer() {
4296
4350
  const registered = getRegisteredHooks(scope);
4297
4351
  const result = registered.map((name) => {
4298
4352
  const meta = getHook(name);
4299
- 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 };
4300
4354
  });
4301
4355
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
4302
4356
  });
4303
4357
  server.tool("hooks_run", "Execute a hook programmatically with the given input and return its output", {
4304
4358
  name: z.string().describe("Hook name (e.g. 'gitguard', 'checkpoint')"),
4305
4359
  input: z.record(z.string(), z.unknown()).default(() => ({})).describe("Hook input as JSON object (HookInput)"),
4306
- profile: z.string().optional().describe("Agent profile ID to inject into hook input")
4307
- }, async ({ name, input, profile }) => {
4360
+ profile: z.string().optional().describe("Agent profile ID to inject into hook input"),
4361
+ timeout_ms: z.number().default(1e4).describe("Timeout in milliseconds (default: 10000)")
4362
+ }, async ({ name, input, profile, timeout_ms }) => {
4308
4363
  const meta = getHook(name);
4309
4364
  if (!meta) {
4310
4365
  return { content: [{ type: "text", text: JSON.stringify({ error: `Hook '${name}' not found` }) }] };
@@ -4332,24 +4387,253 @@ function createHooksServer() {
4332
4387
  stderr: "pipe",
4333
4388
  env: process.env
4334
4389
  });
4335
- const [stdoutText, stderrText, exitCode] = await Promise.all([
4336
- new Response(proc.stdout).text(),
4337
- new Response(proc.stderr).text(),
4338
- proc.exited
4390
+ const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), timeout_ms));
4391
+ const result = await Promise.race([
4392
+ Promise.all([
4393
+ new Response(proc.stdout).text(),
4394
+ new Response(proc.stderr).text(),
4395
+ proc.exited
4396
+ ]).then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode, timedOut: false })),
4397
+ timeoutPromise.then(() => {
4398
+ proc.kill();
4399
+ return { stdout: "", stderr: "", exitCode: -1, timedOut: true };
4400
+ })
4339
4401
  ]);
4340
4402
  let output = {};
4341
4403
  try {
4342
- output = JSON.parse(stdoutText);
4404
+ output = JSON.parse(result.stdout);
4343
4405
  } catch {
4344
- output = { raw: stdoutText };
4406
+ output = result.stdout ? { raw: result.stdout } : {};
4407
+ }
4408
+ return {
4409
+ content: [{
4410
+ type: "text",
4411
+ text: JSON.stringify({
4412
+ hook: name,
4413
+ output,
4414
+ stderr: result.stderr || undefined,
4415
+ exitCode: result.exitCode,
4416
+ ...result.timedOut ? { timedOut: true, timeout_ms } : {}
4417
+ })
4418
+ }]
4419
+ };
4420
+ });
4421
+ server.tool("hooks_update", "Re-register installed hooks to pick up new package version (reinstalls with overwrite)", {
4422
+ hooks: z.array(z.string()).optional().describe("Hook names to update (omit to update all installed hooks)"),
4423
+ scope: z.enum(["global", "project"]).default("global").describe("Scope to update")
4424
+ }, async ({ hooks, scope }) => {
4425
+ const installed = getRegisteredHooks(scope);
4426
+ const toUpdate = hooks && hooks.length > 0 ? hooks : installed;
4427
+ if (toUpdate.length === 0) {
4428
+ return { content: [{ type: "text", text: JSON.stringify({ updated: [], error: "No hooks installed" }) }] };
4429
+ }
4430
+ const results = toUpdate.map((name) => {
4431
+ if (!installed.includes(name)) {
4432
+ return { hook: name, success: false, error: "Not installed" };
4433
+ }
4434
+ return installHook(name, { scope, overwrite: true });
4435
+ });
4436
+ const updated = results.filter((r) => r.success).map((r) => r.hook);
4437
+ const failed = results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error }));
4438
+ return { content: [{ type: "text", text: JSON.stringify({ updated, failed, total: results.length }) }] };
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;
4345
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 }));
4346
4549
  return {
4347
4550
  content: [{
4348
4551
  type: "text",
4349
- text: JSON.stringify({ hook: name, output, stderr: stderrText || undefined, exitCode })
4552
+ text: JSON.stringify({ profile, installed, failed, scope, run_with: `hooks run <name> --profile ${profile.agent_id}` })
4350
4553
  }]
4351
4554
  };
4352
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
+ });
4353
4637
  server.tool("hooks_init", "Register a new agent profile \u2014 returns a unique agent_id for use with hook installation and execution", {
4354
4638
  agent_type: z.enum(["claude", "gemini", "custom"]).default("claude").describe("Type of AI agent"),
4355
4639
  name: z.string().optional().describe("Optional display name for the agent")
@@ -5617,6 +5901,28 @@ function resolveScope(options) {
5617
5901
  return "project";
5618
5902
  return "global";
5619
5903
  }
5904
+ function resolveTarget(options) {
5905
+ if (options.target === "gemini")
5906
+ return "gemini";
5907
+ if (options.target === "all")
5908
+ return "all";
5909
+ return "claude";
5910
+ }
5911
+ function editDistance(a, b) {
5912
+ const m = a.length, n = b.length;
5913
+ const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
5914
+ for (let j = 0;j <= n; j++)
5915
+ dp[0][j] = j;
5916
+ for (let i = 1;i <= m; i++) {
5917
+ for (let j = 1;j <= n; j++) {
5918
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
5919
+ }
5920
+ }
5921
+ return dp[m][n];
5922
+ }
5923
+ function suggestHooks(name, max = 3) {
5924
+ return HOOKS.map((h) => ({ name: h.name, dist: editDistance(name.toLowerCase(), h.name.toLowerCase()) })).filter(({ dist }) => dist <= 4).sort((a, b) => a.dist - b.dist).slice(0, max).map(({ name: n }) => n);
5925
+ }
5620
5926
  program2.name("hooks").description("Install hooks for AI coding agents").version(pkg2.version);
5621
5927
  program2.command("interactive", { isDefault: true }).alias("i").description("Interactive hook browser").action(() => {
5622
5928
  render(/* @__PURE__ */ jsxDEV8(App, {}, undefined, false, undefined, this));
@@ -5696,8 +6002,9 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
5696
6002
  process.stderr.write(stderr);
5697
6003
  process.exit(exitCode);
5698
6004
  });
5699
- program2.command("install").alias("add").argument("[hooks...]", "Hooks to install").option("-o, --overwrite", "Overwrite existing hooks", false).option("-a, --all", "Install all available hooks", false).option("-c, --category <category>", "Install all hooks in a category").option("-g, --global", "Install globally (~/.claude/settings.json)", false).option("-p, --project", "Install for current project (.claude/settings.json)", false).option("--profile <id>", "Agent profile ID to scope hooks to").option("-j, --json", "Output as JSON", false).description("Install one or more hooks").action((hooks, options) => {
6005
+ program2.command("install").alias("add").argument("[hooks...]", "Hooks to install").option("-o, --overwrite", "Overwrite existing hooks", false).option("-a, --all", "Install all available hooks", false).option("-c, --category <category>", "Install all hooks in a category").option("-g, --global", "Install globally (~/.claude/settings.json)", false).option("-p, --project", "Install for current project (.claude/settings.json)", false).option("-t, --target <target>", "Agent target: claude, gemini, all (default: claude)", "claude").option("--profile <id>", "Agent profile ID to scope hooks to").option("--dry-run", "Preview what would be installed without writing to settings", false).option("-j, --json", "Output as JSON", false).description("Install one or more hooks").action((hooks, options) => {
5700
6006
  const scope = resolveScope(options);
6007
+ const target = resolveTarget(options);
5701
6008
  let toInstall = hooks;
5702
6009
  if (options.all) {
5703
6010
  toInstall = HOOKS.map((h) => h.name);
@@ -5718,9 +6025,38 @@ program2.command("install").alias("add").argument("[hooks...]", "Hooks to instal
5718
6025
  render(/* @__PURE__ */ jsxDEV8(App, {}, undefined, false, undefined, this));
5719
6026
  return;
5720
6027
  }
6028
+ if (options.dryRun) {
6029
+ const known = toInstall.filter((n) => getHook(n));
6030
+ const unknown = toInstall.filter((n) => !getHook(n));
6031
+ if (options.json) {
6032
+ console.log(JSON.stringify({ dryRun: true, would_install: known, unknown, scope, target }));
6033
+ return;
6034
+ }
6035
+ console.log(chalk2.bold(`
6036
+ Dry run \u2014 would install (${scope}, ${target}):
6037
+ `));
6038
+ for (const name of known) {
6039
+ const meta = getHook(name);
6040
+ console.log(chalk2.cyan(` ${name}`) + chalk2.dim(` [${meta.event}${meta.matcher ? ` ${meta.matcher}` : ""}]`));
6041
+ }
6042
+ if (unknown.length > 0) {
6043
+ console.log();
6044
+ for (const name of unknown) {
6045
+ const suggestions = suggestHooks(name);
6046
+ console.log(chalk2.red(` \u2717 unknown: ${name}`) + (suggestions.length ? chalk2.dim(` \u2014 did you mean: ${suggestions.join(", ")}?`) : ""));
6047
+ }
6048
+ }
6049
+ return;
6050
+ }
5721
6051
  const results = [];
5722
6052
  for (const name of toInstall) {
5723
- const result = installHook(name, { scope, overwrite: options.overwrite, profile: options.profile });
6053
+ if (!getHook(name)) {
6054
+ const suggestions = suggestHooks(name);
6055
+ const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
6056
+ results.push({ hook: name, success: false, error: `Hook '${name}' not found${hint}` });
6057
+ continue;
6058
+ }
6059
+ const result = installHook(name, { scope, overwrite: options.overwrite, target, profile: options.profile });
5724
6060
  results.push(result);
5725
6061
  }
5726
6062
  if (options.json) {
@@ -5729,13 +6065,14 @@ program2.command("install").alias("add").argument("[hooks...]", "Hooks to instal
5729
6065
  failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error })),
5730
6066
  total: results.length,
5731
6067
  success: results.filter((r) => r.success).length,
5732
- scope
6068
+ scope,
6069
+ target
5733
6070
  }));
5734
6071
  return;
5735
6072
  }
5736
6073
  const settingsFile = scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json";
5737
6074
  console.log(chalk2.bold(`
5738
- Installing hooks (${scope})...
6075
+ Installing hooks (${scope}, ${target})...
5739
6076
  `));
5740
6077
  for (const result of results) {
5741
6078
  if (result.success) {
@@ -5744,6 +6081,9 @@ Installing hooks (${scope})...
5744
6081
  if (meta) {
5745
6082
  console.log(chalk2.dim(` ${meta.event}${meta.matcher ? ` [${meta.matcher}]` : ""} \u2192 hooks run ${result.hook}`));
5746
6083
  }
6084
+ if (result.conflict) {
6085
+ console.log(chalk2.yellow(` \u26A0 Warning: ${result.conflict}`));
6086
+ }
5747
6087
  } else {
5748
6088
  console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
5749
6089
  }
@@ -5838,17 +6178,28 @@ Found ${results.length} hook(s):
5838
6178
  console.log(` ${h.description}`);
5839
6179
  }
5840
6180
  });
5841
- program2.command("remove").alias("rm").argument("<hook>", "Hook to remove").option("-g, --global", "Remove from global settings", false).option("-p, --project", "Remove from project settings", false).option("-j, --json", "Output as JSON", false).description("Remove an installed hook").action((hook, options) => {
6181
+ program2.command("remove").alias("rm").argument("<hook>", "Hook to remove").option("-g, --global", "Remove from global settings", false).option("-p, --project", "Remove from project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, all (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("Remove an installed hook").action((hook, options) => {
5842
6182
  const scope = resolveScope(options);
5843
- const removed = removeHook(hook, scope);
6183
+ const target = resolveTarget(options);
6184
+ if (!getHook(hook)) {
6185
+ const suggestions = suggestHooks(hook);
6186
+ const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
6187
+ if (options.json) {
6188
+ console.log(JSON.stringify({ hook, removed: false, scope, target, error: `Hook '${hook}' not found${hint}`, suggestions }));
6189
+ } else {
6190
+ console.log(chalk2.red(`\u2717 Hook '${hook}' not found${hint}`));
6191
+ }
6192
+ return;
6193
+ }
6194
+ const removed = removeHook(hook, scope, target);
5844
6195
  if (options.json) {
5845
- console.log(JSON.stringify({ hook, removed, scope }));
6196
+ console.log(JSON.stringify({ hook, removed, scope, target }));
5846
6197
  return;
5847
6198
  }
5848
6199
  if (removed) {
5849
- console.log(chalk2.green(`\u2713 Removed ${hook} (${scope})`));
6200
+ console.log(chalk2.green(`\u2713 Removed ${hook} (${scope}, ${target})`));
5850
6201
  } else {
5851
- console.log(chalk2.red(`\u2717 ${hook} is not installed (${scope})`));
6202
+ console.log(chalk2.red(`\u2717 ${hook} is not installed (${scope}, ${target})`));
5852
6203
  }
5853
6204
  });
5854
6205
  program2.command("categories").option("-j, --json", "Output as JSON", false).description("List all categories").action((options) => {
@@ -5871,10 +6222,12 @@ Categories:
5871
6222
  program2.command("info").argument("<hook>", "Hook name").option("-j, --json", "Output as JSON", false).description("Show detailed info about a hook").action((hook, options) => {
5872
6223
  const meta = getHook(hook);
5873
6224
  if (!meta) {
6225
+ const suggestions = suggestHooks(hook);
6226
+ const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
5874
6227
  if (options.json) {
5875
- console.log(JSON.stringify({ error: `Hook '${hook}' not found` }));
6228
+ console.log(JSON.stringify({ error: `Hook '${hook}' not found${hint}`, suggestions }));
5876
6229
  } else {
5877
- console.log(chalk2.red(`Hook '${hook}' not found`));
6230
+ console.log(chalk2.red(`Hook '${hook}' not found${hint}`));
5878
6231
  }
5879
6232
  return;
5880
6233
  }
@@ -5950,7 +6303,7 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
5950
6303
  }
5951
6304
  }
5952
6305
  if (options.json) {
5953
- console.log(JSON.stringify({ healthy, issues, registered, scope }));
6306
+ console.log(JSON.stringify({ healthy: issues.length === 0, healthy_hooks: healthy, issues, registered, scope }));
5954
6307
  return;
5955
6308
  }
5956
6309
  console.log(chalk2.bold(`
@@ -6199,6 +6552,56 @@ Upgrading @hasna/hooks (${pm})...
6199
6552
  \u2713 Upgraded: ${current} \u2192 ${latest}`));
6200
6553
  }
6201
6554
  });
6555
+ program2.command("profile-export").description("Export all agent profiles as JSON (for backup/cross-machine setup)").option("-o, --output <file>", "Write to file instead of stdout").option("-j, --json", "Output as JSON (default: true)", false).action(async (options) => {
6556
+ const profiles = exportProfiles();
6557
+ const json = JSON.stringify(profiles, null, 2);
6558
+ if (options.output) {
6559
+ const { writeFileSync: writeFileSync3 } = await import("fs");
6560
+ writeFileSync3(options.output, json + `
6561
+ `);
6562
+ console.log(chalk2.green(`\u2713 Exported ${profiles.length} profile(s) to ${options.output}`));
6563
+ } else {
6564
+ console.log(json);
6565
+ }
6566
+ });
6567
+ program2.command("profile-import").argument("<file>", "JSON file to import profiles from (use - for stdin)").description("Import agent profiles from a JSON export file").option("-j, --json", "Output result as JSON", false).action(async (file, options) => {
6568
+ let raw;
6569
+ if (file === "-") {
6570
+ raw = await new Response(Bun.stdin.stream()).text();
6571
+ } else {
6572
+ const { readFileSync: readFileSync5 } = await import("fs");
6573
+ try {
6574
+ raw = readFileSync5(file, "utf-8");
6575
+ } catch {
6576
+ if (options.json) {
6577
+ console.log(JSON.stringify({ error: `Cannot read file: ${file}` }));
6578
+ } else {
6579
+ console.log(chalk2.red(`\u2717 Cannot read file: ${file}`));
6580
+ }
6581
+ return;
6582
+ }
6583
+ }
6584
+ let profiles;
6585
+ try {
6586
+ const parsed = JSON.parse(raw);
6587
+ profiles = Array.isArray(parsed) ? parsed : [parsed];
6588
+ } catch {
6589
+ if (options.json) {
6590
+ console.log(JSON.stringify({ error: "Invalid JSON" }));
6591
+ } else {
6592
+ console.log(chalk2.red("\u2717 Invalid JSON"));
6593
+ }
6594
+ return;
6595
+ }
6596
+ const result = importProfiles(profiles);
6597
+ if (options.json) {
6598
+ console.log(JSON.stringify(result));
6599
+ } else {
6600
+ console.log(chalk2.green(`\u2713 Imported ${result.imported} profile(s)`));
6601
+ if (result.skipped > 0)
6602
+ console.log(chalk2.dim(` Skipped ${result.skipped} (already exist or invalid)`));
6603
+ }
6604
+ });
6202
6605
  program2.command("mcp").option("-s, --stdio", "Use stdio transport (for agent MCP integration)", false).option("-p, --port <port>", "Port for SSE transport", "39427").description("Start MCP server for AI agent integration").action(async (options) => {
6203
6606
  if (options.stdio) {
6204
6607
  const { startStdioServer: startStdioServer2 } = await Promise.resolve().then(() => (init_server(), exports_server));
package/dist/index.js CHANGED
@@ -17,8 +17,10 @@ __export(exports_profiles, {
17
17
  updateProfile: () => updateProfile,
18
18
  touchProfile: () => touchProfile,
19
19
  listProfiles: () => listProfiles,
20
+ importProfiles: () => importProfiles,
20
21
  getProfilesDir: () => getProfilesDir,
21
22
  getProfile: () => getProfile,
23
+ exportProfiles: () => exportProfiles,
22
24
  deleteProfile: () => deleteProfile,
23
25
  createProfile: () => createProfile
24
26
  });
@@ -115,6 +117,29 @@ function touchProfile(id) {
115
117
  writeFileSync2(profilePath(id), JSON.stringify(profile, null, 2) + `
116
118
  `);
117
119
  }
120
+ function exportProfiles() {
121
+ return listProfiles();
122
+ }
123
+ function importProfiles(profiles) {
124
+ ensureProfilesDir();
125
+ let imported = 0;
126
+ let skipped = 0;
127
+ for (const profile of profiles) {
128
+ if (!profile.agent_id || !profile.agent_type) {
129
+ skipped++;
130
+ continue;
131
+ }
132
+ const path = profilePath(profile.agent_id);
133
+ if (existsSync2(path)) {
134
+ skipped++;
135
+ continue;
136
+ }
137
+ writeFileSync2(path, JSON.stringify(profile, null, 2) + `
138
+ `);
139
+ imported++;
140
+ }
141
+ return { imported, skipped };
142
+ }
118
143
  var PROFILES_DIR;
119
144
  var init_profiles = __esm(() => {
120
145
  PROFILES_DIR = join2(homedir2(), ".hooks", "profiles");
@@ -529,6 +554,25 @@ function writeSettings(settings, scope = "global", target = "claude") {
529
554
  function getTargetEventName(internalEvent, target) {
530
555
  return EVENT_MAP[target]?.[internalEvent] || internalEvent;
531
556
  }
557
+ function detectConflict(name, scope, target) {
558
+ const meta = getHook(name);
559
+ if (!meta || !meta.matcher)
560
+ return;
561
+ const registered = getRegisteredHooksForTarget(scope, target);
562
+ for (const existingName of registered) {
563
+ if (existingName === name)
564
+ continue;
565
+ const existing = getHook(existingName);
566
+ if (!existing || existing.event !== meta.event || !existing.matcher)
567
+ continue;
568
+ const a = meta.matcher.toLowerCase();
569
+ const b = existing.matcher.toLowerCase();
570
+ if (a === b || a.includes(b) || b.includes(a)) {
571
+ return `conflicts with '${existingName}' (same event ${meta.event}, overlapping matcher '${existing.matcher}')`;
572
+ }
573
+ }
574
+ return;
575
+ }
532
576
  function installForTarget(name, scope, overwrite, target, profile) {
533
577
  const shortName = shortHookName(name);
534
578
  if (!hookExists(shortName)) {
@@ -538,9 +582,10 @@ function installForTarget(name, scope, overwrite, target, profile) {
538
582
  if (registered.includes(shortName) && !overwrite) {
539
583
  return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
540
584
  }
585
+ const conflict = detectConflict(shortName, scope, target);
541
586
  try {
542
587
  registerHook(shortName, scope, target, profile);
543
- return { hook: shortName, success: true, scope, target };
588
+ return { hook: shortName, success: true, scope, target, ...conflict ? { conflict } : {} };
544
589
  } catch (error) {
545
590
  return {
546
591
  hook: shortName,
@@ -711,6 +756,7 @@ export {
711
756
  installHooks,
712
757
  installHookForProject,
713
758
  installHook,
759
+ importProfiles,
714
760
  hookExists,
715
761
  getSettingsPath,
716
762
  getRegisteredHooksForTarget,
@@ -721,6 +767,7 @@ export {
721
767
  getHooksByCategory,
722
768
  getHookPath,
723
769
  getHook,
770
+ exportProfiles,
724
771
  deleteProfile,
725
772
  createProfile,
726
773
  HOOKS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.2.4",
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": {