@hasna/hooks 0.0.3 → 0.0.5

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 (36) hide show
  1. package/bin/index.js +352 -20
  2. package/dist/index.js +5 -1
  3. package/hooks/hook-agentmessages/src/check-messages.ts +151 -0
  4. package/hooks/hook-agentmessages/src/install.ts +126 -0
  5. package/hooks/hook-agentmessages/src/session-start.ts +255 -0
  6. package/hooks/hook-agentmessages/src/uninstall.ts +89 -0
  7. package/hooks/hook-branchprotect/src/cli.ts +126 -0
  8. package/hooks/hook-branchprotect/src/hook.ts +88 -0
  9. package/hooks/hook-checkbugs/src/cli.ts +628 -0
  10. package/hooks/hook-checkbugs/src/hook.ts +335 -0
  11. package/hooks/hook-checkdocs/src/cli.ts +628 -0
  12. package/hooks/hook-checkdocs/src/hook.ts +310 -0
  13. package/hooks/hook-checkfiles/src/cli.ts +545 -0
  14. package/hooks/hook-checkfiles/src/hook.ts +321 -0
  15. package/hooks/hook-checklint/src/cli-patch.ts +32 -0
  16. package/hooks/hook-checklint/src/cli.ts +667 -0
  17. package/hooks/hook-checklint/src/hook.ts +473 -0
  18. package/hooks/hook-checkpoint/src/cli.ts +191 -0
  19. package/hooks/hook-checkpoint/src/hook.ts +207 -0
  20. package/hooks/hook-checksecurity/src/cli.ts +601 -0
  21. package/hooks/hook-checksecurity/src/hook.ts +334 -0
  22. package/hooks/hook-checktasks/src/cli.ts +578 -0
  23. package/hooks/hook-checktasks/src/hook.ts +308 -0
  24. package/hooks/hook-checktests/src/cli.ts +627 -0
  25. package/hooks/hook-checktests/src/hook.ts +334 -0
  26. package/hooks/hook-contextrefresh/src/cli.ts +152 -0
  27. package/hooks/hook-contextrefresh/src/hook.ts +148 -0
  28. package/hooks/hook-gitguard/src/cli.ts +159 -0
  29. package/hooks/hook-gitguard/src/hook.ts +129 -0
  30. package/hooks/hook-packageage/src/cli.ts +165 -0
  31. package/hooks/hook-packageage/src/hook.ts +177 -0
  32. package/hooks/hook-phonenotify/src/cli.ts +196 -0
  33. package/hooks/hook-phonenotify/src/hook.ts +139 -0
  34. package/hooks/hook-precompact/src/cli.ts +168 -0
  35. package/hooks/hook-precompact/src/hook.ts +122 -0
  36. package/package.json +2 -1
package/bin/index.js CHANGED
@@ -3519,6 +3519,9 @@ var {
3519
3519
 
3520
3520
  // src/cli/index.tsx
3521
3521
  import chalk2 from "chalk";
3522
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
3523
+ import { join as join2 } from "path";
3524
+ import { homedir as homedir2 } from "os";
3522
3525
 
3523
3526
  // src/cli/components/App.tsx
3524
3527
  import { useState as useState7 } from "react";
@@ -4524,7 +4527,7 @@ import { join, dirname } from "path";
4524
4527
  import { homedir } from "os";
4525
4528
  import { fileURLToPath } from "url";
4526
4529
  var __dirname2 = dirname(fileURLToPath(import.meta.url));
4527
- var HOOKS_DIR = existsSync(join(__dirname2, "..", "..", "hooks")) ? join(__dirname2, "..", "..", "hooks") : join(__dirname2, "..", "hooks");
4530
+ var HOOKS_DIR = existsSync(join(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join(__dirname2, "..", "..", "hooks") : join(__dirname2, "..", "hooks");
4528
4531
  var SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
4529
4532
  function getHookPath(name) {
4530
4533
  const hookName = name.startsWith("hook-") ? name : `hook-${name}`;
@@ -4572,6 +4575,10 @@ function installHook(name, options = {}) {
4572
4575
  if (!existsSync(destDir)) {
4573
4576
  mkdirSync(destDir, { recursive: true });
4574
4577
  }
4578
+ if (overwrite && existsSync(destPath)) {
4579
+ const { rmSync } = __require("fs");
4580
+ rmSync(destPath, { recursive: true, force: true });
4581
+ }
4575
4582
  cpSync(sourcePath, destPath, { recursive: true });
4576
4583
  registerHookInSettings(shortName);
4577
4584
  updateHooksIndex(destDir);
@@ -5028,36 +5035,72 @@ function App({ initialHooks, overwrite = false }) {
5028
5035
  // src/cli/index.tsx
5029
5036
  import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
5030
5037
  var program2 = new Command;
5031
- program2.name("hooks").description("Install Claude Code hooks for your project").version("0.0.1");
5038
+ program2.name("hooks").description("Install Claude Code hooks for your project").version("0.0.5");
5032
5039
  program2.command("interactive", { isDefault: true }).alias("i").description("Interactive hook browser").action(() => {
5033
5040
  render(/* @__PURE__ */ jsxDEV8(App, {}, undefined, false, undefined, this));
5034
5041
  });
5035
- program2.command("install").alias("add").argument("[hooks...]", "Hooks to install").option("-o, --overwrite", "Overwrite existing hooks", false).description("Install one or more hooks").action((hooks, options) => {
5036
- if (hooks.length === 0) {
5042
+ 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("-j, --json", "Output as JSON", false).description("Install one or more hooks").action((hooks, options) => {
5043
+ let toInstall = hooks;
5044
+ if (options.all) {
5045
+ toInstall = HOOKS.map((h) => h.name);
5046
+ } else if (options.category) {
5047
+ const category = CATEGORIES.find((c) => c.toLowerCase() === options.category.toLowerCase());
5048
+ if (!category) {
5049
+ if (options.json) {
5050
+ console.log(JSON.stringify({ error: `Unknown category: ${options.category}`, available: [...CATEGORIES] }));
5051
+ } else {
5052
+ console.log(chalk2.red(`Unknown category: ${options.category}`));
5053
+ console.log(chalk2.dim(`Available: ${CATEGORIES.join(", ")}`));
5054
+ }
5055
+ return;
5056
+ }
5057
+ toInstall = getHooksByCategory(category).map((h) => h.name);
5058
+ }
5059
+ if (toInstall.length === 0) {
5037
5060
  render(/* @__PURE__ */ jsxDEV8(App, {}, undefined, false, undefined, this));
5038
5061
  return;
5039
5062
  }
5063
+ const results = [];
5064
+ for (const name of toInstall) {
5065
+ const result = installHook(name, { overwrite: options.overwrite });
5066
+ results.push(result);
5067
+ }
5068
+ if (options.json) {
5069
+ console.log(JSON.stringify({
5070
+ installed: results.filter((r) => r.success).map((r) => r.hook),
5071
+ failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error })),
5072
+ total: results.length,
5073
+ success: results.filter((r) => r.success).length
5074
+ }));
5075
+ return;
5076
+ }
5040
5077
  console.log(chalk2.bold(`
5041
5078
  Installing hooks...
5042
5079
  `));
5043
- for (const name of hooks) {
5044
- const result = installHook(name, { overwrite: options.overwrite });
5080
+ for (const result of results) {
5045
5081
  if (result.success) {
5046
- const meta = getHook(name);
5047
- console.log(chalk2.green(`\u2713 ${name}`));
5082
+ const meta = getHook(result.hook);
5083
+ console.log(chalk2.green(`\u2713 ${result.hook}`));
5048
5084
  if (meta) {
5049
- console.log(chalk2.dim(` ${meta.event}${meta.matcher ? ` [${meta.matcher}]` : ""} \u2192 hook-${name}`));
5085
+ console.log(chalk2.dim(` ${meta.event}${meta.matcher ? ` [${meta.matcher}]` : ""} \u2192 hook-${result.hook}`));
5050
5086
  }
5051
5087
  } else {
5052
- console.log(chalk2.red(`\u2717 ${name}: ${result.error}`));
5088
+ console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
5053
5089
  }
5054
5090
  }
5055
5091
  console.log(chalk2.dim(`
5056
5092
  Hooks installed to .hooks/ and registered in ~/.claude/settings.json`));
5057
5093
  });
5058
- program2.command("list").alias("ls").option("-c, --category <category>", "Filter by category").option("-a, --all", "Show all available hooks", false).option("-i, --installed", "Show only installed hooks", false).option("-r, --registered", "Show hooks registered in Claude settings", false).description("List available or installed hooks").action((options) => {
5094
+ program2.command("list").alias("ls").option("-c, --category <category>", "Filter by category").option("-a, --all", "Show all available hooks", false).option("-i, --installed", "Show only installed hooks", false).option("-r, --registered", "Show hooks registered in Claude settings", false).option("-j, --json", "Output as JSON", false).description("List available or installed hooks").action((options) => {
5059
5095
  if (options.registered) {
5060
5096
  const registered = getRegisteredHooks();
5097
+ if (options.json) {
5098
+ console.log(JSON.stringify(registered.map((name) => {
5099
+ const meta = getHook(name);
5100
+ return { name, event: meta?.event, description: meta?.description };
5101
+ })));
5102
+ return;
5103
+ }
5061
5104
  if (registered.length === 0) {
5062
5105
  console.log(chalk2.dim("No hooks registered in Claude settings"));
5063
5106
  return;
@@ -5073,6 +5116,13 @@ Registered hooks (${registered.length}):
5073
5116
  }
5074
5117
  if (options.installed) {
5075
5118
  const installed = getInstalledHooks();
5119
+ if (options.json) {
5120
+ console.log(JSON.stringify(installed.map((name) => {
5121
+ const meta = getHook(name);
5122
+ return { name, event: meta?.event, version: meta?.version, description: meta?.description };
5123
+ })));
5124
+ return;
5125
+ }
5076
5126
  if (installed.length === 0) {
5077
5127
  console.log(chalk2.dim("No hooks installed"));
5078
5128
  return;
@@ -5088,11 +5138,19 @@ Installed hooks (${installed.length}):
5088
5138
  if (options.category) {
5089
5139
  const category = CATEGORIES.find((c) => c.toLowerCase() === options.category.toLowerCase());
5090
5140
  if (!category) {
5091
- console.log(chalk2.red(`Unknown category: ${options.category}`));
5092
- console.log(chalk2.dim(`Available: ${CATEGORIES.join(", ")}`));
5141
+ if (options.json) {
5142
+ console.log(JSON.stringify({ error: `Unknown category: ${options.category}`, available: [...CATEGORIES] }));
5143
+ } else {
5144
+ console.log(chalk2.red(`Unknown category: ${options.category}`));
5145
+ console.log(chalk2.dim(`Available: ${CATEGORIES.join(", ")}`));
5146
+ }
5093
5147
  return;
5094
5148
  }
5095
5149
  const hooks = getHooksByCategory(category);
5150
+ if (options.json) {
5151
+ console.log(JSON.stringify(hooks));
5152
+ return;
5153
+ }
5096
5154
  console.log(chalk2.bold(`
5097
5155
  ${category} (${hooks.length}):
5098
5156
  `));
@@ -5101,6 +5159,14 @@ ${category} (${hooks.length}):
5101
5159
  }
5102
5160
  return;
5103
5161
  }
5162
+ if (options.json) {
5163
+ const result = {};
5164
+ for (const category of CATEGORIES) {
5165
+ result[category] = getHooksByCategory(category);
5166
+ }
5167
+ console.log(JSON.stringify(result));
5168
+ return;
5169
+ }
5104
5170
  console.log(chalk2.bold(`
5105
5171
  Available hooks (${HOOKS.length}):
5106
5172
  `));
@@ -5113,8 +5179,12 @@ Available hooks (${HOOKS.length}):
5113
5179
  console.log();
5114
5180
  }
5115
5181
  });
5116
- program2.command("search").argument("<query>", "Search term").description("Search for hooks").action((query) => {
5182
+ program2.command("search").argument("<query>", "Search term").option("-j, --json", "Output as JSON", false).description("Search for hooks").action((query, options) => {
5117
5183
  const results = searchHooks(query);
5184
+ if (options.json) {
5185
+ console.log(JSON.stringify(results));
5186
+ return;
5187
+ }
5118
5188
  if (results.length === 0) {
5119
5189
  console.log(chalk2.dim(`No hooks found for "${query}"`));
5120
5190
  return;
@@ -5127,15 +5197,27 @@ Found ${results.length} hook(s):
5127
5197
  console.log(` ${h.description}`);
5128
5198
  }
5129
5199
  });
5130
- program2.command("remove").alias("rm").argument("<hook>", "Hook to remove").description("Remove an installed hook").action((hook) => {
5200
+ program2.command("remove").alias("rm").argument("<hook>", "Hook to remove").option("-j, --json", "Output as JSON", false).description("Remove an installed hook").action((hook, options) => {
5131
5201
  const removed = removeHook(hook);
5202
+ if (options.json) {
5203
+ console.log(JSON.stringify({ hook, removed }));
5204
+ return;
5205
+ }
5132
5206
  if (removed) {
5133
5207
  console.log(chalk2.green(`\u2713 Removed ${hook} (unregistered from Claude settings)`));
5134
5208
  } else {
5135
5209
  console.log(chalk2.red(`\u2717 ${hook} is not installed`));
5136
5210
  }
5137
5211
  });
5138
- program2.command("categories").description("List all categories").action(() => {
5212
+ program2.command("categories").option("-j, --json", "Output as JSON", false).description("List all categories").action((options) => {
5213
+ if (options.json) {
5214
+ const result = CATEGORIES.map((cat) => ({
5215
+ name: cat,
5216
+ count: getHooksByCategory(cat).length
5217
+ }));
5218
+ console.log(JSON.stringify(result));
5219
+ return;
5220
+ }
5139
5221
  console.log(chalk2.bold(`
5140
5222
  Categories:
5141
5223
  `));
@@ -5144,10 +5226,22 @@ Categories:
5144
5226
  console.log(` ${category} (${count})`);
5145
5227
  }
5146
5228
  });
5147
- program2.command("info").argument("<hook>", "Hook name").description("Show detailed info about a hook").action((hook) => {
5229
+ program2.command("info").argument("<hook>", "Hook name").option("-j, --json", "Output as JSON", false).description("Show detailed info about a hook").action((hook, options) => {
5148
5230
  const meta = getHook(hook);
5149
5231
  if (!meta) {
5150
- console.log(chalk2.red(`Hook '${hook}' not found`));
5232
+ if (options.json) {
5233
+ console.log(JSON.stringify({ error: `Hook '${hook}' not found` }));
5234
+ } else {
5235
+ console.log(chalk2.red(`Hook '${hook}' not found`));
5236
+ }
5237
+ return;
5238
+ }
5239
+ const registered = getRegisteredHooks();
5240
+ const isRegistered = registered.includes(meta.name);
5241
+ const installed = getInstalledHooks();
5242
+ const isInstalled = installed.includes(meta.name);
5243
+ if (options.json) {
5244
+ console.log(JSON.stringify({ ...meta, registered: isRegistered, installed: isInstalled }));
5151
5245
  return;
5152
5246
  }
5153
5247
  console.log(chalk2.bold(`
@@ -5156,16 +5250,254 @@ ${meta.displayName}
5156
5250
  console.log(` ${meta.description}`);
5157
5251
  console.log();
5158
5252
  console.log(` ${chalk2.dim("Category:")} ${meta.category}`);
5253
+ console.log(` ${chalk2.dim("Version:")} ${meta.version}`);
5159
5254
  console.log(` ${chalk2.dim("Event:")} ${meta.event}`);
5160
5255
  console.log(` ${chalk2.dim("Matcher:")} ${meta.matcher || "(none)"}`);
5161
5256
  console.log(` ${chalk2.dim("Tags:")} ${meta.tags.join(", ")}`);
5162
5257
  console.log(` ${chalk2.dim("Package:")} hook-${meta.name}`);
5163
5258
  console.log();
5164
- const registered = getRegisteredHooks();
5165
- if (registered.includes(meta.name)) {
5259
+ if (isRegistered) {
5166
5260
  console.log(chalk2.green(" \u25CF Registered in Claude settings"));
5167
5261
  } else {
5168
5262
  console.log(chalk2.dim(" \u25CB Not registered"));
5169
5263
  }
5264
+ if (isInstalled) {
5265
+ console.log(chalk2.green(" \u25CF Installed in .hooks/"));
5266
+ } else {
5267
+ console.log(chalk2.dim(" \u25CB Not installed"));
5268
+ }
5269
+ });
5270
+ program2.command("doctor").option("-j, --json", "Output as JSON", false).description("Check health of installed hooks").action((options) => {
5271
+ const settingsPath = join2(homedir2(), ".claude", "settings.json");
5272
+ const issues = [];
5273
+ const healthy = [];
5274
+ const settingsExist = existsSync2(settingsPath);
5275
+ if (!settingsExist) {
5276
+ issues.push({ hook: "(settings)", issue: "~/.claude/settings.json not found", severity: "warning" });
5277
+ }
5278
+ const installed = getInstalledHooks();
5279
+ const registered = getRegisteredHooks();
5280
+ for (const name of installed) {
5281
+ const meta = getHook(name);
5282
+ const hookDir = join2(process.cwd(), ".hooks", `hook-${name}`);
5283
+ let hookHealthy = true;
5284
+ const srcDir = join2(hookDir, "src");
5285
+ if (!existsSync2(srcDir)) {
5286
+ issues.push({ hook: name, issue: "Missing src/ directory", severity: "error" });
5287
+ hookHealthy = false;
5288
+ }
5289
+ if (!existsSync2(join2(hookDir, "package.json"))) {
5290
+ issues.push({ hook: name, issue: "Missing package.json", severity: "error" });
5291
+ hookHealthy = false;
5292
+ }
5293
+ if (!registered.includes(name)) {
5294
+ issues.push({ hook: name, issue: "Installed but not registered in Claude settings", severity: "warning" });
5295
+ hookHealthy = false;
5296
+ }
5297
+ if (meta && settingsExist) {
5298
+ try {
5299
+ const settings = JSON.parse(readFileSync2(settingsPath, "utf-8"));
5300
+ const eventHooks = settings.hooks?.[meta.event] || [];
5301
+ const found = eventHooks.some((entry) => entry.hooks?.some((h) => h.command?.includes(`hook-${name}`)));
5302
+ if (registered.includes(name) && !found) {
5303
+ issues.push({ hook: name, issue: `Registered but not under ${meta.event} event`, severity: "error" });
5304
+ hookHealthy = false;
5305
+ }
5306
+ } catch {}
5307
+ }
5308
+ if (hookHealthy) {
5309
+ healthy.push(name);
5310
+ }
5311
+ }
5312
+ for (const name of registered) {
5313
+ if (!installed.includes(name)) {
5314
+ issues.push({ hook: name, issue: "Registered in settings but not installed in .hooks/", severity: "warning" });
5315
+ }
5316
+ }
5317
+ if (options.json) {
5318
+ console.log(JSON.stringify({ healthy, issues, installed, registered }));
5319
+ return;
5320
+ }
5321
+ console.log(chalk2.bold(`
5322
+ Hook Health Check
5323
+ `));
5324
+ if (installed.length === 0 && registered.length === 0) {
5325
+ console.log(chalk2.dim(" No hooks installed or registered."));
5326
+ console.log(chalk2.dim(" Run: hooks install gitguard"));
5327
+ return;
5328
+ }
5329
+ if (healthy.length > 0) {
5330
+ console.log(chalk2.green(` \u2713 ${healthy.length} hook(s) healthy:`));
5331
+ for (const name of healthy) {
5332
+ console.log(chalk2.green(` ${name}`));
5333
+ }
5334
+ }
5335
+ if (issues.length > 0) {
5336
+ console.log();
5337
+ for (const issue of issues) {
5338
+ const icon = issue.severity === "error" ? chalk2.red("\u2717") : chalk2.yellow("!");
5339
+ console.log(` ${icon} ${chalk2.cyan(issue.hook)}: ${issue.issue}`);
5340
+ }
5341
+ }
5342
+ if (issues.length === 0) {
5343
+ console.log(chalk2.green(`
5344
+ All hooks healthy!`));
5345
+ }
5346
+ console.log();
5347
+ });
5348
+ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to all installed)").option("-j, --json", "Output as JSON", false).description("Update installed hooks to latest version from package").action((hooks, options) => {
5349
+ const installed = getInstalledHooks();
5350
+ const toUpdate = hooks.length > 0 ? hooks : installed;
5351
+ if (toUpdate.length === 0) {
5352
+ if (options.json) {
5353
+ console.log(JSON.stringify({ updated: [], error: "No hooks installed" }));
5354
+ } else {
5355
+ console.log(chalk2.dim("No hooks installed to update."));
5356
+ }
5357
+ return;
5358
+ }
5359
+ const results = [];
5360
+ for (const name of toUpdate) {
5361
+ if (!installed.includes(name)) {
5362
+ results.push({ hook: name, success: false, error: "Not installed" });
5363
+ continue;
5364
+ }
5365
+ const result = installHook(name, { overwrite: true });
5366
+ results.push(result);
5367
+ }
5368
+ if (options.json) {
5369
+ console.log(JSON.stringify({
5370
+ updated: results.filter((r) => r.success).map((r) => r.hook),
5371
+ failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error }))
5372
+ }));
5373
+ return;
5374
+ }
5375
+ console.log(chalk2.bold(`
5376
+ Updating hooks...
5377
+ `));
5378
+ for (const result of results) {
5379
+ if (result.success) {
5380
+ console.log(chalk2.green(`\u2713 ${result.hook} updated`));
5381
+ } else {
5382
+ console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
5383
+ }
5384
+ }
5385
+ });
5386
+ program2.command("docs").argument("[hook]", "Hook name (shows general docs if omitted)").option("-j, --json", "Output as JSON", false).description("Show documentation for hooks").action((hook, options) => {
5387
+ if (hook) {
5388
+ const meta = getHook(hook);
5389
+ if (!meta) {
5390
+ if (options.json) {
5391
+ console.log(JSON.stringify({ error: `Hook '${hook}' not found` }));
5392
+ } else {
5393
+ console.log(chalk2.red(`Hook '${hook}' not found`));
5394
+ }
5395
+ return;
5396
+ }
5397
+ const hookPath = getHookPath(hook);
5398
+ const readmePath = join2(hookPath, "README.md");
5399
+ let readme = "";
5400
+ if (existsSync2(readmePath)) {
5401
+ readme = readFileSync2(readmePath, "utf-8");
5402
+ }
5403
+ if (options.json) {
5404
+ console.log(JSON.stringify({ ...meta, readme }));
5405
+ return;
5406
+ }
5407
+ console.log(chalk2.bold(`
5408
+ ${meta.displayName} v${meta.version}
5409
+ `));
5410
+ console.log(` ${meta.description}
5411
+ `);
5412
+ console.log(chalk2.bold(" Configuration:"));
5413
+ console.log(` Event: ${meta.event}`);
5414
+ console.log(` Matcher: ${meta.matcher || "(all tools)"}`);
5415
+ console.log(` Package: hook-${meta.name}`);
5416
+ console.log();
5417
+ console.log(chalk2.bold(" Install:"));
5418
+ console.log(` hooks install ${meta.name}`);
5419
+ console.log();
5420
+ if (readme) {
5421
+ console.log(chalk2.bold(` README:
5422
+ `));
5423
+ const lines = readme.split(`
5424
+ `);
5425
+ for (const line of lines) {
5426
+ console.log(` ${line}`);
5427
+ }
5428
+ }
5429
+ return;
5430
+ }
5431
+ const generalDocs = {
5432
+ overview: "Claude Code hooks are scripts that run at specific points in a Claude Code session.",
5433
+ events: {
5434
+ PreToolUse: 'Fires before a tool executes. Can block the operation by returning { "decision": "block" }.',
5435
+ PostToolUse: "Fires after a tool executes. Runs asynchronously, cannot block.",
5436
+ Stop: "Fires when a Claude Code session ends. Useful for notifications and cleanup.",
5437
+ Notification: "Fires on notification events like context compaction."
5438
+ },
5439
+ installation: {
5440
+ single: "hooks install gitguard",
5441
+ multiple: "hooks install gitguard checkpoint packageage",
5442
+ category: 'hooks install --category "Git Safety"',
5443
+ all: "hooks install --all"
5444
+ },
5445
+ management: {
5446
+ list: "hooks list",
5447
+ listInstalled: "hooks list --installed",
5448
+ listRegistered: "hooks list --registered",
5449
+ search: "hooks search <query>",
5450
+ info: "hooks info <name>",
5451
+ remove: "hooks remove <name>",
5452
+ update: "hooks update",
5453
+ doctor: "hooks doctor"
5454
+ },
5455
+ structure: {
5456
+ hooksDir: ".hooks/hook-<name>/",
5457
+ settings: "~/.claude/settings.json",
5458
+ hookSource: ".hooks/hook-<name>/src/hook.ts"
5459
+ }
5460
+ };
5461
+ if (options.json) {
5462
+ console.log(JSON.stringify(generalDocs));
5463
+ return;
5464
+ }
5465
+ console.log(chalk2.bold(`
5466
+ @hasna/hooks Documentation
5467
+ `));
5468
+ console.log(chalk2.bold(` Overview
5469
+ `));
5470
+ console.log(` ${generalDocs.overview}
5471
+ `);
5472
+ console.log(chalk2.bold(` Hook Events
5473
+ `));
5474
+ for (const [event, desc] of Object.entries(generalDocs.events)) {
5475
+ console.log(` ${chalk2.cyan(event)}`);
5476
+ console.log(` ${desc}
5477
+ `);
5478
+ }
5479
+ console.log(chalk2.bold(` Installation
5480
+ `));
5481
+ for (const [label, cmd] of Object.entries(generalDocs.installation)) {
5482
+ console.log(` ${chalk2.dim(label + ":")} ${cmd}`);
5483
+ }
5484
+ console.log(chalk2.bold(`
5485
+ Management
5486
+ `));
5487
+ for (const [label, cmd] of Object.entries(generalDocs.management)) {
5488
+ console.log(` ${chalk2.dim(label + ":")} ${cmd}`);
5489
+ }
5490
+ console.log(chalk2.bold(`
5491
+ File Structure
5492
+ `));
5493
+ for (const [label, path] of Object.entries(generalDocs.structure)) {
5494
+ console.log(` ${chalk2.dim(label + ":")} ${path}`);
5495
+ }
5496
+ console.log(chalk2.bold(`
5497
+ Hook-Specific Docs
5498
+ `));
5499
+ console.log(` hooks docs <name> View README for a specific hook`);
5500
+ console.log(` hooks docs --json Machine-readable documentation`);
5501
+ console.log();
5170
5502
  });
5171
5503
  program2.parse();
package/dist/index.js CHANGED
@@ -177,7 +177,7 @@ import { join, dirname } from "path";
177
177
  import { homedir } from "os";
178
178
  import { fileURLToPath } from "url";
179
179
  var __dirname2 = dirname(fileURLToPath(import.meta.url));
180
- var HOOKS_DIR = existsSync(join(__dirname2, "..", "..", "hooks")) ? join(__dirname2, "..", "..", "hooks") : join(__dirname2, "..", "hooks");
180
+ var HOOKS_DIR = existsSync(join(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join(__dirname2, "..", "..", "hooks") : join(__dirname2, "..", "hooks");
181
181
  var SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
182
182
  function getHookPath(name) {
183
183
  const hookName = name.startsWith("hook-") ? name : `hook-${name}`;
@@ -228,6 +228,10 @@ function installHook(name, options = {}) {
228
228
  if (!existsSync(destDir)) {
229
229
  mkdirSync(destDir, { recursive: true });
230
230
  }
231
+ if (overwrite && existsSync(destPath)) {
232
+ const { rmSync } = __require("fs");
233
+ rmSync(destPath, { recursive: true, force: true });
234
+ }
231
235
  cpSync(sourcePath, destPath, { recursive: true });
232
236
  registerHookInSettings(shortName);
233
237
  updateHooksIndex(destDir);
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Stop hook for service-message
4
+ *
5
+ * Runs when Claude finishes a response. Checks for unread messages
6
+ * and notifies Claude if there are any pending messages.
7
+ *
8
+ * This is efficient because it only runs after Claude completes a turn,
9
+ * not continuously polling.
10
+ */
11
+
12
+ import { homedir } from 'os';
13
+ import { join } from 'path';
14
+ import { readdir } from 'fs/promises';
15
+
16
+ interface HookInput {
17
+ session_id: string;
18
+ cwd: string;
19
+ hook_event_name: string;
20
+ stop_hook_active?: boolean;
21
+ }
22
+
23
+ interface Message {
24
+ id: string;
25
+ timestamp: number;
26
+ from: string;
27
+ to: string;
28
+ project: string;
29
+ subject: string;
30
+ body: string;
31
+ read?: boolean;
32
+ }
33
+
34
+ const SERVICE_DIR = join(homedir(), '.service', 'service-message');
35
+
36
+ /**
37
+ * Sanitize ID to prevent path traversal attacks
38
+ */
39
+ function sanitizeId(id: string): string | null {
40
+ if (!id || typeof id !== 'string') return null;
41
+ // Only allow alphanumeric, dash, underscore
42
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) return null;
43
+ // Reject path traversal attempts
44
+ if (id.includes('..') || id.includes('/') || id.includes('\\')) return null;
45
+ return id;
46
+ }
47
+
48
+ async function readJson<T>(path: string): Promise<T | null> {
49
+ try {
50
+ const file = Bun.file(path);
51
+ if (await file.exists()) {
52
+ return await file.json();
53
+ }
54
+ } catch {}
55
+ return null;
56
+ }
57
+
58
+ async function getUnreadMessages(agentId: string, projectId?: string): Promise<Message[]> {
59
+ const messages: Message[] = [];
60
+ const messagesDir = join(SERVICE_DIR, 'messages');
61
+
62
+ try {
63
+ const rawProjects = projectId ? [projectId] : await readdir(messagesDir);
64
+ // Sanitize all project names to prevent path traversal
65
+ const projects = rawProjects.map(p => sanitizeId(p)).filter((p): p is string => p !== null);
66
+
67
+ for (const proj of projects) {
68
+ // Check inbox
69
+ const inboxDir = join(messagesDir, proj, 'inbox', agentId);
70
+ try {
71
+ const files = await readdir(inboxDir);
72
+ for (const file of files) {
73
+ if (!file.endsWith('.json')) continue;
74
+ // Sanitize filename to prevent path traversal
75
+ const safeFile = sanitizeId(file.replace('.json', ''));
76
+ if (!safeFile) continue;
77
+ const msg = await readJson<Message>(join(inboxDir, `${safeFile}.json`));
78
+ if (msg && !msg.read) {
79
+ messages.push(msg);
80
+ }
81
+ }
82
+ } catch {}
83
+
84
+ // Check broadcast
85
+ const broadcastDir = join(messagesDir, proj, 'broadcast');
86
+ try {
87
+ const files = await readdir(broadcastDir);
88
+ for (const file of files) {
89
+ if (!file.endsWith('.json')) continue;
90
+ // Sanitize filename to prevent path traversal
91
+ const safeFile = sanitizeId(file.replace('.json', ''));
92
+ if (!safeFile) continue;
93
+ const msg = await readJson<Message>(join(broadcastDir, `${safeFile}.json`));
94
+ if (msg && !msg.read && msg.from !== agentId) {
95
+ messages.push(msg);
96
+ }
97
+ }
98
+ } catch {}
99
+ }
100
+ } catch {}
101
+
102
+ return messages.sort((a, b) => b.timestamp - a.timestamp);
103
+ }
104
+
105
+ async function main() {
106
+ // Get agent ID from environment (set by session-start hook)
107
+ const rawAgentId = process.env.SMSG_AGENT_ID;
108
+ const rawProjectId = process.env.SMSG_PROJECT_ID;
109
+
110
+ // Sanitize IDs to prevent path traversal
111
+ const agentId = rawAgentId ? sanitizeId(rawAgentId) : null;
112
+ const projectId = rawProjectId ? sanitizeId(rawProjectId) : undefined;
113
+
114
+ if (!agentId) {
115
+ // Agent not configured or invalid, skip silently
116
+ console.log(JSON.stringify({ continue: true }));
117
+ return;
118
+ }
119
+
120
+ // Check for unread messages
121
+ const unreadMessages = await getUnreadMessages(agentId, projectId);
122
+
123
+ if (unreadMessages.length === 0) {
124
+ // No messages, continue normally
125
+ console.log(JSON.stringify({ continue: true }));
126
+ return;
127
+ }
128
+
129
+ // Format message summary in a friendly way
130
+ const msgList = unreadMessages.slice(0, 3).map(msg => {
131
+ const time = new Date(msg.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
132
+ const preview = msg.body.slice(0, 60).replace(/\n/g, ' ');
133
+ return `šŸ“Ø **${msg.subject}** from \`${msg.from}\` (${time})\n ${preview}${msg.body.length > 60 ? '...' : ''}`;
134
+ }).join('\n\n');
135
+
136
+ const moreNote = unreadMessages.length > 3
137
+ ? `\n\n_...and ${unreadMessages.length - 3} more message(s)_`
138
+ : '';
139
+
140
+ // Inject message in a friendly, readable format
141
+ console.log(JSON.stringify({
142
+ continue: true,
143
+ stopReason: `šŸ“¬ **You have ${unreadMessages.length} unread message(s):**\n\n${msgList}${moreNote}\n\nšŸ’” Use \`service-message inbox\` to see all messages or \`service-message read <id>\` to read one.`
144
+ }));
145
+ }
146
+
147
+ main().catch(() => {
148
+ // Don't block on errors
149
+ console.log(JSON.stringify({ continue: true }));
150
+ process.exit(0);
151
+ });