@hasna/hooks 0.6.3 → 0.6.4

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.
package/bin/index.js CHANGED
@@ -4354,7 +4354,8 @@ function resolveHook(name) {
4354
4354
  events: custom.manifest.events,
4355
4355
  description: custom.manifest.description ?? "Custom hook",
4356
4356
  scriptPath: custom.scriptPath,
4357
- meta: meta2
4357
+ meta: meta2,
4358
+ timeoutMs: custom.manifest.timeout_ms ?? null
4358
4359
  };
4359
4360
  }
4360
4361
  const meta = getHook(shortManifestName(name));
@@ -4368,7 +4369,8 @@ function resolveHook(name) {
4368
4369
  events: meta.events && meta.events.length > 0 ? meta.events : [meta.event],
4369
4370
  description: meta.description,
4370
4371
  scriptPath,
4371
- meta
4372
+ meta,
4373
+ timeoutMs: null
4372
4374
  };
4373
4375
  }
4374
4376
  var init_resolve = __esm(() => {
@@ -4376,1126 +4378,1420 @@ var init_resolve = __esm(() => {
4376
4378
  init_manifest();
4377
4379
  });
4378
4380
 
4379
- // src/lib/installer.ts
4380
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
4381
- import { join as join5, dirname as dirname3 } from "path";
4381
+ // src/db/schema.ts
4382
+ var CREATE_HOOK_EVENTS_TABLE = `
4383
+ CREATE TABLE IF NOT EXISTS hook_events (
4384
+ id TEXT PRIMARY KEY,
4385
+ timestamp TEXT NOT NULL,
4386
+ session_id TEXT NOT NULL,
4387
+ hook_name TEXT NOT NULL,
4388
+ event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart')),
4389
+ tool_name TEXT,
4390
+ tool_input TEXT,
4391
+ result TEXT CHECK (result IN ('continue', 'block', NULL)),
4392
+ error TEXT,
4393
+ duration_ms INTEGER,
4394
+ project_dir TEXT,
4395
+ metadata TEXT
4396
+ )
4397
+ `, CREATE_INDEXES;
4398
+ var init_schema = __esm(() => {
4399
+ CREATE_INDEXES = [
4400
+ `CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
4401
+ `CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
4402
+ `CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
4403
+ `CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
4404
+ `CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`
4405
+ ];
4406
+ });
4407
+
4408
+ // src/db/migrations/001_initial.ts
4409
+ function up(db) {
4410
+ db.exec(CREATE_HOOK_EVENTS_TABLE);
4411
+ for (const idx of CREATE_INDEXES) {
4412
+ db.exec(idx);
4413
+ }
4414
+ }
4415
+ var init_001_initial = __esm(() => {
4416
+ init_schema();
4417
+ });
4418
+
4419
+ // src/db/migrations/002_session_events.ts
4420
+ function up2(db) {
4421
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
4422
+ if (!row?.sql)
4423
+ return;
4424
+ if (row.sql.includes("SessionStart"))
4425
+ return;
4426
+ db.exec("BEGIN");
4427
+ try {
4428
+ db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
4429
+ db.exec(CREATE_HOOK_EVENTS_TABLE);
4430
+ db.exec(`INSERT INTO hook_events
4431
+ (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
4432
+ SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
4433
+ FROM hook_events_old`);
4434
+ db.exec("DROP TABLE hook_events_old");
4435
+ for (const idx of CREATE_INDEXES) {
4436
+ db.exec(idx);
4437
+ }
4438
+ db.exec("COMMIT");
4439
+ } catch (err) {
4440
+ db.exec("ROLLBACK");
4441
+ throw err;
4442
+ }
4443
+ }
4444
+ var init_002_session_events = __esm(() => {
4445
+ init_schema();
4446
+ });
4447
+
4448
+ // src/db/migrations/003_user_prompt_submit_event.ts
4449
+ function up3(db) {
4450
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
4451
+ if (!row?.sql)
4452
+ return;
4453
+ if (row.sql.includes("UserPromptSubmit"))
4454
+ return;
4455
+ db.exec("BEGIN");
4456
+ try {
4457
+ db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
4458
+ db.exec(CREATE_HOOK_EVENTS_TABLE);
4459
+ db.exec(`INSERT INTO hook_events
4460
+ (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
4461
+ SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
4462
+ FROM hook_events_old`);
4463
+ db.exec("DROP TABLE hook_events_old");
4464
+ for (const idx of CREATE_INDEXES) {
4465
+ db.exec(idx);
4466
+ }
4467
+ db.exec("COMMIT");
4468
+ } catch (err) {
4469
+ db.exec("ROLLBACK");
4470
+ throw err;
4471
+ }
4472
+ }
4473
+ var init_003_user_prompt_submit_event = __esm(() => {
4474
+ init_schema();
4475
+ });
4476
+
4477
+ // src/db/migrations/004_hooks_table.ts
4478
+ function up4(db) {
4479
+ db.exec(CREATE_HOOKS_TABLE);
4480
+ for (const idx of CREATE_HOOKS_INDEXES) {
4481
+ db.exec(idx);
4482
+ }
4483
+ }
4484
+ var CREATE_HOOKS_TABLE = `
4485
+ CREATE TABLE IF NOT EXISTS hooks (
4486
+ id TEXT PRIMARY KEY,
4487
+ name TEXT NOT NULL,
4488
+ version TEXT NOT NULL,
4489
+ sha256 TEXT NOT NULL,
4490
+ source_type TEXT NOT NULL,
4491
+ source_ref TEXT,
4492
+ installed_at TEXT NOT NULL,
4493
+ enabled INTEGER NOT NULL DEFAULT 1,
4494
+ last_verified_at TEXT
4495
+ )
4496
+ `, CREATE_HOOKS_INDEXES;
4497
+ var init_004_hooks_table = __esm(() => {
4498
+ CREATE_HOOKS_INDEXES = [
4499
+ `CREATE INDEX IF NOT EXISTS idx_hooks_name ON hooks (name)`
4500
+ ];
4501
+ });
4502
+
4503
+ // src/db/migrations/005_subagent_start_event.ts
4504
+ function up5(db) {
4505
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
4506
+ if (!row?.sql)
4507
+ return;
4508
+ if (row.sql.includes("SubagentStart"))
4509
+ return;
4510
+ db.exec("BEGIN");
4511
+ try {
4512
+ db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
4513
+ db.exec(CREATE_HOOK_EVENTS_TABLE);
4514
+ db.exec(`INSERT INTO hook_events
4515
+ (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
4516
+ SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
4517
+ FROM hook_events_old`);
4518
+ db.exec("DROP TABLE hook_events_old");
4519
+ for (const idx of CREATE_INDEXES) {
4520
+ db.exec(idx);
4521
+ }
4522
+ db.exec("COMMIT");
4523
+ } catch (err) {
4524
+ db.exec("ROLLBACK");
4525
+ throw err;
4526
+ }
4527
+ }
4528
+ var init_005_subagent_start_event = __esm(() => {
4529
+ init_schema();
4530
+ });
4531
+
4532
+ // src/db/migrations/index.ts
4533
+ function ensureMigrationsTable(db) {
4534
+ db.exec(`
4535
+ CREATE TABLE IF NOT EXISTS schema_migrations (
4536
+ version TEXT PRIMARY KEY,
4537
+ applied_at TEXT NOT NULL
4538
+ )
4539
+ `);
4540
+ }
4541
+ function getApplied(db) {
4542
+ const rows = db.query("SELECT version FROM schema_migrations").all();
4543
+ return new Set(rows.map((r) => r.version));
4544
+ }
4545
+ function runMigrations(db) {
4546
+ ensureMigrationsTable(db);
4547
+ const applied = getApplied(db);
4548
+ for (const migration of MIGRATIONS) {
4549
+ if (applied.has(migration.version))
4550
+ continue;
4551
+ migration.up(db);
4552
+ db.run("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", [
4553
+ migration.version,
4554
+ new Date().toISOString()
4555
+ ]);
4556
+ }
4557
+ }
4558
+ var MIGRATIONS;
4559
+ var init_migrations = __esm(() => {
4560
+ init_001_initial();
4561
+ init_002_session_events();
4562
+ init_003_user_prompt_submit_event();
4563
+ init_004_hooks_table();
4564
+ init_005_subagent_start_event();
4565
+ MIGRATIONS = [
4566
+ { version: "001_initial", up },
4567
+ { version: "002_session_events", up: up2 },
4568
+ { version: "003_user_prompt_submit_event", up: up3 },
4569
+ { version: "004_hooks_table", up: up4 },
4570
+ { version: "005_subagent_start_event", up: up5 }
4571
+ ];
4572
+ });
4573
+
4574
+ // src/db/legacy-import.ts
4575
+ import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
4576
+ import { join as join5 } from "path";
4382
4577
  import { homedir as homedir3 } from "os";
4383
- import { fileURLToPath as fileURLToPath2 } from "url";
4384
- function normalizeHookName(name) {
4385
- return name.startsWith("hook-") ? name : `hook-${name}`;
4578
+ function ensureMetaTable(db) {
4579
+ db.exec(`
4580
+ CREATE TABLE IF NOT EXISTS _meta (
4581
+ key TEXT PRIMARY KEY,
4582
+ value TEXT NOT NULL
4583
+ )
4584
+ `);
4386
4585
  }
4387
- function shortHookName(name) {
4388
- return name.startsWith("hook-") ? name.slice("hook-".length) : name;
4586
+ function isAlreadyDone(db) {
4587
+ ensureMetaTable(db);
4588
+ const row = db.query("SELECT value FROM _meta WHERE key = ?").get(META_KEY);
4589
+ return row?.value === "1";
4389
4590
  }
4390
- function removeHookEntriesByName(entries, hookName) {
4391
- return entries.filter((entry) => !entry.hooks?.some((h) => {
4392
- const match = h.command?.match(/^hooks run ([\w-]+)/);
4393
- return match && match[1] === hookName;
4394
- }));
4591
+ function markDone(db) {
4592
+ db.run("INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)", [META_KEY, "1"]);
4593
+ }
4594
+ function nanoid() {
4595
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 21);
4596
+ }
4597
+ function importJsonlFile(db, filePath) {
4598
+ let count = 0;
4599
+ try {
4600
+ const lines = readFileSync3(filePath, "utf-8").split(`
4601
+ `).filter(Boolean);
4602
+ for (const line of lines) {
4603
+ try {
4604
+ const entry = JSON.parse(line);
4605
+ db.run(`INSERT OR IGNORE INTO hook_events
4606
+ (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, project_dir)
4607
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
4608
+ nanoid(),
4609
+ entry.timestamp ?? new Date().toISOString(),
4610
+ entry.session_id ?? "legacy",
4611
+ "sessionlog",
4612
+ "PostToolUse",
4613
+ entry.tool_name ?? null,
4614
+ entry.tool_input ? String(entry.tool_input).slice(0, 500) : null,
4615
+ null
4616
+ ]);
4617
+ count++;
4618
+ } catch {}
4619
+ }
4620
+ } catch {}
4621
+ return count;
4622
+ }
4623
+ function importErrorsLog(db, filePath) {
4624
+ let count = 0;
4625
+ try {
4626
+ const lines = readFileSync3(filePath, "utf-8").split(`
4627
+ `).filter(Boolean);
4628
+ const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
4629
+ for (const line of lines) {
4630
+ try {
4631
+ const m = line.match(linePattern);
4632
+ if (!m)
4633
+ continue;
4634
+ const [, timestamp, sessionPrefix, , errorMsg] = m;
4635
+ db.run(`INSERT OR IGNORE INTO hook_events
4636
+ (id, timestamp, session_id, hook_name, event_type, error)
4637
+ VALUES (?, ?, ?, ?, ?, ?)`, [
4638
+ nanoid(),
4639
+ timestamp,
4640
+ sessionPrefix ? `legacy-${sessionPrefix}` : "legacy",
4641
+ "errornotify",
4642
+ "PostToolUse",
4643
+ errorMsg.slice(0, 500)
4644
+ ]);
4645
+ count++;
4646
+ } catch {}
4647
+ }
4648
+ } catch {}
4649
+ return count;
4650
+ }
4651
+ function runLegacyImport(db, homeDir = homedir3()) {
4652
+ try {
4653
+ if (isAlreadyDone(db))
4654
+ return;
4655
+ let total = 0;
4656
+ const claudeProjectsDir = join5(homeDir, ".claude", "projects");
4657
+ if (existsSync5(claudeProjectsDir)) {
4658
+ try {
4659
+ const projectDirs = readdirSync2(claudeProjectsDir);
4660
+ for (const dir of projectDirs) {
4661
+ const projectDir = join5(claudeProjectsDir, dir);
4662
+ try {
4663
+ const files = readdirSync2(projectDir);
4664
+ for (const file of files) {
4665
+ if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
4666
+ total += importJsonlFile(db, join5(projectDir, file));
4667
+ }
4668
+ if (file === "errors.log") {
4669
+ total += importErrorsLog(db, join5(projectDir, file));
4670
+ }
4671
+ }
4672
+ } catch {}
4673
+ }
4674
+ } catch {}
4675
+ }
4676
+ markDone(db);
4677
+ if (total > 0) {
4678
+ process.stderr.write(`[hooks] Imported ${total} legacy log entries into SQLite.
4679
+ `);
4680
+ }
4681
+ } catch (err) {
4682
+ process.stderr.write(`[hooks] Legacy import failed (non-fatal): ${err}
4683
+ `);
4684
+ }
4685
+ }
4686
+ var META_KEY = "legacy_import_done";
4687
+ var init_legacy_import = () => {};
4688
+
4689
+ // src/db/retention.ts
4690
+ function runRetention(db, days) {
4691
+ const envDays = parseInt(process.env.HOOKS_RETENTION_DAYS ?? "30");
4692
+ const retentionDays = days ?? (isNaN(envDays) || envDays <= 0 ? 30 : envDays);
4693
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
4694
+ try {
4695
+ db.run("DELETE FROM hook_events WHERE timestamp < ?", [cutoff]);
4696
+ const changes = db.query("SELECT changes() as changes").get()?.changes ?? 0;
4697
+ return changes;
4698
+ } catch {
4699
+ return 0;
4700
+ }
4701
+ }
4702
+
4703
+ // src/db/index.ts
4704
+ var exports_db = {};
4705
+ __export(exports_db, {
4706
+ getDbPath: () => getDbPath,
4707
+ getDb: () => getDb,
4708
+ createTestDb: () => createTestDb,
4709
+ closeDb: () => closeDb
4710
+ });
4711
+ import { Database } from "bun:sqlite";
4712
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, cpSync } from "fs";
4713
+ import { join as join6 } from "path";
4714
+ import { homedir as homedir4 } from "os";
4715
+ function resolveDataDir() {
4716
+ const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
4717
+ if (explicit)
4718
+ return explicit;
4719
+ const newDir = join6(homedir4(), ".hasna", "hooks");
4720
+ const oldDir = join6(homedir4(), ".hooks");
4721
+ if (!existsSync6(newDir) && existsSync6(oldDir)) {
4722
+ mkdirSync3(join6(homedir4(), ".hasna"), { recursive: true });
4723
+ cpSync(oldDir, newDir, { recursive: true });
4724
+ }
4725
+ return newDir;
4726
+ }
4727
+ function getDbPath() {
4728
+ const explicitDb = process.env.HASNA_HOOKS_DB_PATH ?? process.env.HOOKS_DB_PATH;
4729
+ if (explicitDb)
4730
+ return explicitDb;
4731
+ const dataDir = resolveDataDir();
4732
+ return join6(dataDir, "hooks.db");
4733
+ }
4734
+ function ensureDir(dbPath) {
4735
+ const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
4736
+ if (dir && !existsSync6(dir)) {
4737
+ mkdirSync3(dir, { recursive: true });
4738
+ }
4739
+ }
4740
+ function getDb() {
4741
+ if (instance)
4742
+ return instance;
4743
+ const dbPath = getDbPath();
4744
+ const isNew = dbPath === ":memory:" || !existsSync6(dbPath);
4745
+ ensureDir(dbPath);
4746
+ instance = new Database(dbPath);
4747
+ instance.exec("PRAGMA busy_timeout=5000");
4748
+ instance.exec("PRAGMA journal_mode=WAL");
4749
+ instance.exec("PRAGMA foreign_keys=ON");
4750
+ runMigrations(instance);
4751
+ runRetention(instance);
4752
+ instance.exec(`CREATE TABLE IF NOT EXISTS feedback (
4753
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
4754
+ message TEXT NOT NULL,
4755
+ email TEXT,
4756
+ category TEXT DEFAULT 'general',
4757
+ version TEXT,
4758
+ machine_id TEXT,
4759
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
4760
+ )`);
4761
+ if (isNew) {
4762
+ runLegacyImport(instance);
4763
+ }
4764
+ return instance;
4765
+ }
4766
+ function closeDb() {
4767
+ if (instance) {
4768
+ instance.close();
4769
+ instance = null;
4770
+ }
4771
+ }
4772
+ function createTestDb() {
4773
+ const db = new Database(":memory:");
4774
+ db.exec("PRAGMA journal_mode=WAL");
4775
+ db.exec("PRAGMA foreign_keys=ON");
4776
+ return db;
4777
+ }
4778
+ var instance = null;
4779
+ var init_db = __esm(() => {
4780
+ init_migrations();
4781
+ init_legacy_import();
4782
+ });
4783
+
4784
+ // src/lib/store.ts
4785
+ var exports_store = {};
4786
+ __export(exports_store, {
4787
+ writeLock: () => writeLock,
4788
+ verifyScriptHash: () => verifyScriptHash,
4789
+ upsertHookRecord: () => upsertHookRecord,
4790
+ sha256Of: () => sha256Of,
4791
+ sha256File: () => sha256File,
4792
+ setPinnedHook: () => setPinnedHook,
4793
+ retrustHook: () => retrustHook,
4794
+ removePinnedHook: () => removePinnedHook,
4795
+ removeHookRecord: () => removeHookRecord,
4796
+ removeHookFromStore: () => removeHookFromStore,
4797
+ readLock: () => readLock,
4798
+ pinInstalledHook: () => pinInstalledHook,
4799
+ listHookRecords: () => listHookRecords,
4800
+ getPinnedHook: () => getPinnedHook,
4801
+ getHookRecord: () => getHookRecord,
4802
+ checkScriptHash: () => checkScriptHash
4803
+ });
4804
+ import { createHash } from "crypto";
4805
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
4806
+ import { readFile as readFile2 } from "fs/promises";
4807
+ function sha256Of(input) {
4808
+ return createHash("sha256").update(input).digest("hex");
4395
4809
  }
4396
- function getTargetSettingsDir(target) {
4397
- if (target === "codewith")
4398
- return ".codewith";
4399
- if (target === "gemini")
4400
- return ".gemini";
4401
- return ".claude";
4810
+ async function sha256File(path) {
4811
+ const buf = await readFile2(path);
4812
+ return sha256Of(buf);
4402
4813
  }
4403
- function getGlobalSettingsPathOverride(target) {
4404
- if (target === "claude")
4405
- return process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH;
4406
- if (target === "gemini")
4407
- return process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH;
4408
- return;
4814
+ function getHookRecord(db, name) {
4815
+ return db.query("SELECT * FROM hooks WHERE name = ?").get(name);
4409
4816
  }
4410
- function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
4411
- if (target === "codewith" && codewithConfigPath)
4412
- return codewithConfigPath;
4413
- if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
4414
- return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
4415
- }
4416
- const globalOverride = scope === "global" ? getGlobalSettingsPathOverride(target) : undefined;
4417
- if (globalOverride)
4418
- return globalOverride;
4419
- const dir = getTargetSettingsDir(target);
4420
- if (scope === "project") {
4421
- return target === "codewith" ? join5(process.cwd(), dir, "config.toml") : join5(process.cwd(), dir, "settings.json");
4422
- }
4423
- return target === "codewith" ? join5(homedir3(), dir, "config.toml") : join5(homedir3(), dir, "settings.json");
4817
+ function listHookRecords(db) {
4818
+ return db.query("SELECT * FROM hooks ORDER BY name").all();
4424
4819
  }
4425
- function getHookPath(name) {
4426
- const shortName = shortHookName(name);
4427
- const resolved = resolveHookDir(shortName);
4428
- if (resolved)
4429
- return resolved;
4430
- return join5(HOOKS_DIR, normalizeHookName(shortName));
4820
+ function upsertHookRecord(db, record) {
4821
+ const existing = getHookRecord(db, record.name);
4822
+ const id = existing?.id ?? record.name;
4823
+ const installedAt = existing?.installed_at ?? new Date().toISOString();
4824
+ const enabled = record.enabled ?? existing?.enabled ?? 1;
4825
+ db.run(`INSERT INTO hooks (id, name, version, sha256, source_type, source_ref, installed_at, enabled, last_verified_at)
4826
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
4827
+ ON CONFLICT(id) DO UPDATE SET
4828
+ version = excluded.version,
4829
+ sha256 = excluded.sha256,
4830
+ source_type = excluded.source_type,
4831
+ source_ref = excluded.source_ref,
4832
+ enabled = excluded.enabled,
4833
+ last_verified_at = excluded.last_verified_at`, [
4834
+ id,
4835
+ record.name,
4836
+ record.version,
4837
+ record.sha256,
4838
+ record.source_type,
4839
+ record.source_ref ?? existing?.source_ref ?? null,
4840
+ installedAt,
4841
+ enabled,
4842
+ record.last_verified_at ?? null
4843
+ ]);
4431
4844
  }
4432
- function hookExists(name) {
4433
- return existsSync5(getHookPath(name));
4845
+ function removeHookRecord(db, name) {
4846
+ const res = db.run("DELETE FROM hooks WHERE name = ?", [name]);
4847
+ return res.changes > 0;
4434
4848
  }
4435
- function readSettings(scope = "global", target = "claude") {
4436
- const path = getSettingsPath(scope, target);
4849
+ function readLock() {
4437
4850
  try {
4438
- if (existsSync5(path)) {
4439
- return JSON.parse(readFileSync3(path, "utf-8"));
4440
- }
4441
- } catch (error) {
4442
- console.warn(`[hooks] Failed to read settings at ${path}: ${error instanceof Error ? error.message : String(error)}`);
4851
+ const path = getLockPath();
4852
+ if (!existsSync7(path))
4853
+ return { hooks: {} };
4854
+ const parsed = JSON.parse(readFileSync4(path, "utf-8"));
4855
+ return { hooks: parsed.hooks ?? {} };
4856
+ } catch {
4857
+ return { hooks: {} };
4443
4858
  }
4444
- return {};
4445
4859
  }
4446
- function writeSettings(settings, scope = "global", target = "claude") {
4447
- const path = getSettingsPath(scope, target);
4448
- const dir = dirname3(path);
4449
- if (!existsSync5(dir)) {
4450
- mkdirSync3(dir, { recursive: true });
4860
+ function writeLock(lock) {
4861
+ const path = getLockPath();
4862
+ mkdirSync4(path.substring(0, path.lastIndexOf("/")) || ".", { recursive: true });
4863
+ const sorted = {};
4864
+ for (const name of Object.keys(lock.hooks).sort()) {
4865
+ sorted[name] = lock.hooks[name];
4451
4866
  }
4452
- writeFileSync3(path, JSON.stringify(settings, null, 2) + `
4453
- `);
4867
+ writeFileSync3(path, JSON.stringify({ hooks: sorted }, null, 2) + `
4868
+ `, "utf-8");
4869
+ return path;
4454
4870
  }
4455
- function getTargetEventName(internalEvent, target) {
4456
- return EVENT_MAP[target]?.[internalEvent] ?? null;
4871
+ function setPinnedHook(name, entry) {
4872
+ const lock = readLock();
4873
+ lock.hooks[name] = entry;
4874
+ return writeLock(lock);
4457
4875
  }
4458
- function isEventSupported(internalEvent, target) {
4459
- return getTargetEventName(internalEvent, target) !== null;
4876
+ function getPinnedHook(name) {
4877
+ return readLock().hooks[name];
4460
4878
  }
4461
- function tomlString(value) {
4462
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4879
+ function removePinnedHook(name) {
4880
+ const lock = readLock();
4881
+ if (!(name in lock.hooks))
4882
+ return false;
4883
+ delete lock.hooks[name];
4884
+ writeLock(lock);
4885
+ return true;
4463
4886
  }
4464
- function codewithMatcher(matcher) {
4465
- if (!matcher)
4466
- return;
4467
- if (matcher.startsWith("^"))
4468
- return matcher;
4469
- if (matcher.includes("|"))
4470
- return `^(${matcher})$`;
4471
- return `^${matcher}$`;
4887
+ function pinInstalledHook(name, version, sha256, source, sourceRef) {
4888
+ const now2 = new Date().toISOString();
4889
+ const db = getDb();
4890
+ upsertHookRecord(db, {
4891
+ name,
4892
+ version,
4893
+ sha256,
4894
+ source_type: source,
4895
+ source_ref: sourceRef ?? null,
4896
+ last_verified_at: now2
4897
+ });
4898
+ setPinnedHook(name, { version, sha256, source });
4472
4899
  }
4473
- function codewithTimeout(name) {
4474
- switch (name) {
4475
- case "knowledge-context":
4476
- return 6;
4477
- case "session-start":
4478
- return 8;
4479
- case "pre-bash":
4480
- return 20;
4481
- case "prompt-guard":
4482
- return 3;
4483
- case "worktree-guard":
4484
- case "stop-sync":
4485
- return 5;
4486
- default:
4487
- return 10;
4488
- }
4900
+ function removeHookFromStore(name) {
4901
+ const removedPin = removePinnedHook(name);
4902
+ const db = getDb();
4903
+ const removedRecord = removeHookRecord(db, name);
4904
+ return { removedPin, removedRecord };
4489
4905
  }
4490
- function codewithStatusMessage(name) {
4491
- switch (name) {
4492
- case "knowledge-context":
4493
- return "Loading Knowledge context";
4494
- case "session-start":
4495
- return "Checking Hasna session context";
4496
- case "pre-bash":
4497
- return "Checking Bash safety";
4498
- case "prompt-guard":
4499
- return "Checking prompt safety";
4500
- case "worktree-guard":
4501
- return "Checking worktree safety";
4502
- case "stop-sync":
4503
- return "Syncing turn-end heartbeat";
4504
- default:
4505
- return `Running ${name}`;
4906
+ function checkScriptHash(name, actual) {
4907
+ const db = getDb();
4908
+ const record = getHookRecord(db, name);
4909
+ const pin = getPinnedHook(name);
4910
+ const expected = record?.sha256 ?? pin?.sha256 ?? null;
4911
+ if (expected !== null && expected !== actual) {
4912
+ return { ok: false, pinned: true, expected, actual, name };
4506
4913
  }
4507
- }
4508
- function buildCodewithTomlFragment(name, profile) {
4509
- const shortName = shortHookName(name);
4510
- const meta = resolveHookMeta(shortName);
4511
- if (!meta)
4512
- throw new Error(`Hook '${shortName}' not found`);
4513
- const command = profile ? `hooks run ${shortName} --profile ${profile}` : `hooks run ${shortName}`;
4514
- const matcher = codewithMatcher(meta.matcher);
4515
- const fragments = [];
4516
- for (const event of getHookEvents(meta)) {
4517
- const eventKey = getTargetEventName(event, "codewith");
4518
- if (!eventKey) {
4519
- throw new Error(`Hook '${shortName}' uses event '${event}', which is not supported by the Codewith target`);
4520
- }
4521
- const lines = [
4522
- `[[hooks.${eventKey}]]`
4523
- ];
4524
- if (matcher)
4525
- lines.push(`matcher = ${tomlString(matcher)}`);
4526
- lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
4527
- fragments.push(lines.join(`
4528
- `));
4914
+ if (expected === null) {
4915
+ const now3 = new Date().toISOString();
4916
+ const version = pin?.version ?? record?.version ?? "0.0.0";
4917
+ const source = pin?.source ?? record?.source_type ?? "local";
4918
+ upsertHookRecord(db, {
4919
+ name,
4920
+ version,
4921
+ sha256: actual,
4922
+ source_type: source,
4923
+ last_verified_at: now3
4924
+ });
4925
+ setPinnedHook(name, { version, sha256: actual, source });
4926
+ return { ok: true, pinned: false, expected: null, actual, name };
4529
4927
  }
4530
- return `${fragments.join(`
4928
+ const now2 = new Date().toISOString();
4929
+ upsertHookRecord(db, {
4930
+ name,
4931
+ version: record?.version ?? pin?.version ?? "0.0.0",
4932
+ sha256: expected,
4933
+ source_type: record?.source_type ?? pin?.source ?? "local",
4934
+ last_verified_at: now2
4935
+ });
4936
+ return { ok: true, pinned: true, expected, actual, name };
4937
+ }
4938
+ async function verifyScriptHash(name, scriptPath) {
4939
+ return checkScriptHash(name, await sha256File(scriptPath));
4940
+ }
4941
+ function retrustHook(name, scriptPath, version, source) {
4942
+ const actual = sha256Of(readFileSync4(scriptPath));
4943
+ const db = getDb();
4944
+ upsertHookRecord(db, {
4945
+ name,
4946
+ version,
4947
+ sha256: actual,
4948
+ source_type: source,
4949
+ last_verified_at: new Date().toISOString()
4950
+ });
4951
+ setPinnedHook(name, { version, sha256: actual, source });
4952
+ return { ok: true, pinned: true, expected: actual, actual, name };
4953
+ }
4954
+ var init_store = __esm(() => {
4955
+ init_db();
4956
+ init_config();
4957
+ });
4531
4958
 
4532
- `)}
4533
- `;
4959
+ // src/lib/installer.ts
4960
+ var exports_installer = {};
4961
+ __export(exports_installer, {
4962
+ uninstallHook: () => uninstallHook,
4963
+ removeHook: () => removeHook,
4964
+ removeCodewithHookEntry: () => removeCodewithHookEntry,
4965
+ isEventSupported: () => isEventSupported,
4966
+ installHooks: () => installHooks,
4967
+ installHook: () => installHook,
4968
+ hookExists: () => hookExists,
4969
+ getSettingsPath: () => getSettingsPath,
4970
+ getRegisteredHooksForTarget: () => getRegisteredHooksForTarget,
4971
+ getRegisteredHooks: () => getRegisteredHooks,
4972
+ getInstalledHooks: () => getInstalledHooks,
4973
+ getHookPath: () => getHookPath,
4974
+ buildCodewithTomlFragment: () => buildCodewithTomlFragment
4975
+ });
4976
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "fs";
4977
+ import { join as join7, dirname as dirname3 } from "path";
4978
+ import { homedir as homedir5 } from "os";
4979
+ import { fileURLToPath as fileURLToPath2 } from "url";
4980
+ function normalizeHookName(name) {
4981
+ return name.startsWith("hook-") ? name : `hook-${name}`;
4534
4982
  }
4535
- function readCodewithConfig(scope, configPath) {
4536
- const path = getSettingsPath(scope, "codewith", configPath);
4537
- try {
4538
- return existsSync5(path) ? readFileSync3(path, "utf-8") : "";
4539
- } catch {
4540
- return "";
4541
- }
4983
+ function shortHookName(name) {
4984
+ return name.startsWith("hook-") ? name.slice("hook-".length) : name;
4985
+ }
4986
+ function removeHookEntriesByName(entries, hookName) {
4987
+ return entries.filter((entry) => !entry.hooks?.some((h) => {
4988
+ const match = h.command?.match(/^hooks run ([\w-]+)/);
4989
+ return match && match[1] === hookName;
4990
+ }));
4542
4991
  }
4543
- function appendCodewithFragment(fragment, scope, configPath) {
4544
- const path = getSettingsPath(scope, "codewith", configPath);
4545
- const dir = dirname3(path);
4546
- if (!existsSync5(dir))
4547
- mkdirSync3(dir, { recursive: true });
4548
- const existing = existsSync5(path) ? readFileSync3(path, "utf-8") : "";
4549
- const sep2 = existing.trim() ? `
4550
-
4551
- ` : "";
4552
- writeFileSync3(path, `${existing.replace(/\s*$/, "")}${sep2}${fragment}`);
4553
- return path;
4992
+ function getTargetSettingsDir(target) {
4993
+ if (target === "codewith")
4994
+ return ".codewith";
4995
+ if (target === "gemini")
4996
+ return ".gemini";
4997
+ return ".claude";
4554
4998
  }
4555
- function detectConflict(name, scope, target) {
4556
- const meta = resolveHookMeta(name);
4557
- if (!meta || !meta.matcher)
4558
- return;
4559
- const events = new Set(getHookEvents(meta));
4560
- const registered = getRegisteredHooksForTarget(scope, target);
4561
- for (const existingName of registered) {
4562
- if (existingName === name)
4563
- continue;
4564
- const existing = getHook(existingName);
4565
- if (!existing || !existing.matcher)
4566
- continue;
4567
- if (!getHookEvents(existing).some((event) => events.has(event)))
4568
- continue;
4569
- const a = meta.matcher.toLowerCase();
4570
- const b = existing.matcher.toLowerCase();
4571
- if (a === b || a.includes(b) || b.includes(a)) {
4572
- return `conflicts with '${existingName}' (same event ${meta.event}, overlapping matcher '${existing.matcher}')`;
4573
- }
4574
- }
4999
+ function getGlobalSettingsPathOverride(target) {
5000
+ if (target === "claude")
5001
+ return process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH;
5002
+ if (target === "gemini")
5003
+ return process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH;
4575
5004
  return;
4576
5005
  }
4577
- function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
4578
- const shortName = shortHookName(name);
4579
- if (!hookExists(shortName)) {
4580
- return { hook: shortName, success: false, error: `Hook '${shortName}' not found`, target };
4581
- }
4582
- if (target === "codewith") {
4583
- try {
4584
- const fragment = buildCodewithTomlFragment(shortName, profile);
4585
- if (codewithMode !== "write") {
4586
- return {
4587
- hook: shortName,
4588
- success: true,
4589
- scope,
4590
- target,
4591
- fragment,
4592
- applied: false,
4593
- note: "Codewith install is fragment-only by default; open-configs should own applying this TOML."
4594
- };
4595
- }
4596
- if (!codewithConfigPath) {
4597
- return {
4598
- hook: shortName,
4599
- success: false,
4600
- error: "Direct Codewith writes require an explicit --codewith-config path; refusing to write default ~/.codewith/config.toml.",
4601
- scope,
4602
- target,
4603
- fragment,
4604
- applied: false
4605
- };
4606
- }
4607
- const existing = readCodewithConfig(scope, codewithConfigPath);
4608
- if (!overwrite && new RegExp(`command\\s*=\\s*["']hooks run ${shortName}(?:\\s|["'])`).test(existing)) {
4609
- return { hook: shortName, success: false, error: "Already installed. Use --overwrite to append another fragment.", scope, target };
4610
- }
4611
- const path = appendCodewithFragment(fragment, scope, codewithConfigPath);
4612
- return {
4613
- hook: shortName,
4614
- success: true,
4615
- scope,
4616
- target,
4617
- fragment,
4618
- applied: true,
4619
- configPath: path,
4620
- note: "Direct Codewith config write was explicitly requested; prefer open-configs for managed machines."
4621
- };
4622
- } catch (error) {
4623
- return {
4624
- hook: shortName,
4625
- success: false,
4626
- error: error instanceof Error ? error.message : "Unknown error",
4627
- target
4628
- };
4629
- }
4630
- }
4631
- const registered = getRegisteredHooksForTarget(scope, target);
4632
- if (registered.includes(shortName) && !overwrite) {
4633
- return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
5006
+ function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
5007
+ if (target === "codewith" && codewithConfigPath)
5008
+ return codewithConfigPath;
5009
+ if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
5010
+ return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
4634
5011
  }
4635
- const conflict = detectConflict(shortName, scope, target);
4636
- try {
4637
- registerHook(shortName, scope, target, profile);
4638
- return { hook: shortName, success: true, scope, target, ...conflict ? { conflict } : {} };
4639
- } catch (error) {
4640
- return {
4641
- hook: shortName,
4642
- success: false,
4643
- error: error instanceof Error ? error.message : "Unknown error",
4644
- target
4645
- };
5012
+ const globalOverride = scope === "global" ? getGlobalSettingsPathOverride(target) : undefined;
5013
+ if (globalOverride)
5014
+ return globalOverride;
5015
+ const dir = getTargetSettingsDir(target);
5016
+ if (scope === "project") {
5017
+ return target === "codewith" ? join7(process.cwd(), dir, "config.toml") : join7(process.cwd(), dir, "settings.json");
4646
5018
  }
5019
+ return target === "codewith" ? join7(homedir5(), dir, "config.toml") : join7(homedir5(), dir, "settings.json");
4647
5020
  }
4648
- function installHook(name, options = {}) {
4649
- const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath } = options;
4650
- if (target === "all") {
4651
- const shortName = shortHookName(name);
4652
- const meta = resolveHookMeta(shortName);
4653
- if (meta) {
4654
- const unsupportedTargets = ["claude", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
4655
- if (unsupportedTargets.length > 0) {
4656
- return {
4657
- hook: shortName,
4658
- success: false,
4659
- error: `Event(s) '${getHookEvents(meta).join(", ")}' are not supported by target(s): ${unsupportedTargets.join(", ")}`,
4660
- target: "all"
4661
- };
4662
- }
4663
- }
4664
- const claudeResult = installForTarget(name, scope, overwrite, "claude", profile);
4665
- if (!claudeResult.success) {
4666
- return {
4667
- ...claudeResult,
4668
- error: `Failed for target 'claude': ${claudeResult.error}`,
4669
- target: "all"
4670
- };
4671
- }
4672
- const codewithResult = installForTarget(name, scope, overwrite, "codewith", profile, codewithMode, codewithConfigPath);
4673
- if (!codewithResult.success) {
4674
- return {
4675
- ...codewithResult,
4676
- error: `Failed for target 'codewith': ${codewithResult.error}`,
4677
- target: "all"
4678
- };
4679
- }
4680
- return { ...claudeResult, target: "all" };
4681
- }
4682
- return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath);
5021
+ function getHookPath(name) {
5022
+ const shortName = shortHookName(name);
5023
+ const resolved = resolveHookDir(shortName);
5024
+ if (resolved)
5025
+ return resolved;
5026
+ return join7(HOOKS_DIR, normalizeHookName(shortName));
4683
5027
  }
4684
- function registerHook(name, scope = "global", target = "claude", profile) {
4685
- const meta = resolveHookMeta(name);
4686
- if (!meta)
4687
- return;
4688
- const eventKeys = getHookEvents(meta).map((event) => {
4689
- const eventKey = getTargetEventName(event, target);
4690
- if (eventKey === null) {
4691
- throw new Error(`Event '${event}' is not supported by target '${target}'`);
4692
- }
4693
- return eventKey;
4694
- });
4695
- const uniqueEventKeys = [...new Set(eventKeys)];
4696
- if (uniqueEventKeys.length === 0) {
4697
- throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
4698
- }
4699
- const settings = readSettings(scope, target);
4700
- if (!settings.hooks)
4701
- settings.hooks = {};
4702
- removeHookFromAllEvents(settings, name);
4703
- const hookCommand = profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`;
4704
- for (const eventKey of uniqueEventKeys) {
4705
- if (!settings.hooks[eventKey])
4706
- settings.hooks[eventKey] = [];
4707
- const entry = {
4708
- hooks: [{ type: "command", command: hookCommand }]
4709
- };
4710
- if (meta.matcher) {
4711
- entry.matcher = meta.matcher;
4712
- }
4713
- settings.hooks[eventKey].push(entry);
4714
- }
4715
- writeSettings(settings, scope, target);
5028
+ function hookExists(name) {
5029
+ return existsSync8(getHookPath(name));
4716
5030
  }
4717
- function removeHookFromAllEvents(settings, name) {
4718
- if (!settings.hooks)
4719
- return;
4720
- for (const key of Object.keys(settings.hooks)) {
4721
- settings.hooks[key] = removeHookEntriesByName(settings.hooks[key], name);
4722
- if (settings.hooks[key].length === 0) {
4723
- delete settings.hooks[key];
5031
+ function readSettings(scope = "global", target = "claude") {
5032
+ const path = getSettingsPath(scope, target);
5033
+ try {
5034
+ if (existsSync8(path)) {
5035
+ return JSON.parse(readFileSync5(path, "utf-8"));
4724
5036
  }
5037
+ } catch (error) {
5038
+ console.warn(`[hooks] Failed to read settings at ${path}: ${error instanceof Error ? error.message : String(error)}`);
4725
5039
  }
5040
+ return {};
4726
5041
  }
4727
- function unregisterHook(name, scope = "global", target = "claude") {
4728
- const meta = resolveHookMeta(name);
4729
- if (!meta)
4730
- return;
4731
- const settings = readSettings(scope, target);
4732
- if (!settings.hooks)
4733
- return;
4734
- removeHookFromAllEvents(settings, name);
4735
- if (Object.keys(settings.hooks).length === 0) {
4736
- delete settings.hooks;
5042
+ function writeSettings(settings, scope = "global", target = "claude") {
5043
+ const path = getSettingsPath(scope, target);
5044
+ const dir = dirname3(path);
5045
+ if (!existsSync8(dir)) {
5046
+ mkdirSync5(dir, { recursive: true });
4737
5047
  }
4738
- writeSettings(settings, scope, target);
5048
+ writeFileSync4(path, JSON.stringify(settings, null, 2) + `
5049
+ `);
4739
5050
  }
4740
- function getRegisteredHooksForTarget(scope = "global", target = "claude") {
4741
- if (target === "codewith") {
4742
- const config = readCodewithConfig(scope);
4743
- const registered2 = [];
4744
- const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
4745
- let match;
4746
- while (match = re.exec(config)) {
4747
- registered2.push(match[1]);
4748
- }
4749
- return [...new Set(registered2)];
4750
- }
4751
- const settings = readSettings(scope, target);
4752
- if (!settings.hooks)
4753
- return [];
4754
- const registered = [];
4755
- for (const eventKey of Object.keys(settings.hooks)) {
4756
- for (const entry of settings.hooks[eventKey]) {
4757
- for (const hook of entry.hooks || []) {
4758
- const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
4759
- const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
4760
- const match = newMatch || oldMatch;
4761
- if (match) {
4762
- registered.push(match[1]);
4763
- }
4764
- }
4765
- }
4766
- }
4767
- return [...new Set(registered)];
5051
+ function getTargetEventName(internalEvent, target) {
5052
+ return EVENT_MAP[target]?.[internalEvent] ?? null;
4768
5053
  }
4769
- function getRegisteredHooks(scope = "global") {
4770
- return getRegisteredHooksForTarget(scope, "claude");
5054
+ function isEventSupported(internalEvent, target) {
5055
+ return getTargetEventName(internalEvent, target) !== null;
5056
+ }
5057
+ function tomlString(value) {
5058
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4771
5059
  }
4772
- function removeHook(name, scope = "global", target = "claude") {
4773
- const shortName = shortHookName(name);
4774
- if (target === "all") {
4775
- const claudeRemoved = removeHookForTarget(shortName, scope, "claude");
4776
- const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
4777
- return claudeRemoved || geminiRemoved;
4778
- }
4779
- if (target === "codewith") {
4780
- return false;
5060
+ function codewithMatcher(matcher) {
5061
+ if (!matcher)
5062
+ return;
5063
+ if (matcher.startsWith("^"))
5064
+ return matcher;
5065
+ if (matcher.includes("|"))
5066
+ return `^(${matcher})$`;
5067
+ return `^${matcher}$`;
5068
+ }
5069
+ function codewithTimeout(name) {
5070
+ switch (name) {
5071
+ case "knowledge-context":
5072
+ return 6;
5073
+ case "session-start":
5074
+ return 8;
5075
+ case "pre-bash":
5076
+ return 20;
5077
+ case "prompt-guard":
5078
+ return 3;
5079
+ case "worktree-guard":
5080
+ case "stop-sync":
5081
+ return 5;
5082
+ default:
5083
+ return 10;
4781
5084
  }
4782
- return removeHookForTarget(shortName, scope, target);
4783
5085
  }
4784
- function removeHookForTarget(name, scope, target) {
4785
- const registered = getRegisteredHooksForTarget(scope, target);
4786
- if (!registered.includes(name)) {
4787
- return false;
5086
+ function codewithStatusMessage(name) {
5087
+ switch (name) {
5088
+ case "knowledge-context":
5089
+ return "Loading Knowledge context";
5090
+ case "session-start":
5091
+ return "Checking Hasna session context";
5092
+ case "pre-bash":
5093
+ return "Checking Bash safety";
5094
+ case "prompt-guard":
5095
+ return "Checking prompt safety";
5096
+ case "worktree-guard":
5097
+ return "Checking worktree safety";
5098
+ case "stop-sync":
5099
+ return "Syncing turn-end heartbeat";
5100
+ default:
5101
+ return `Running ${name}`;
4788
5102
  }
4789
- unregisterHook(name, scope, target);
4790
- return true;
4791
5103
  }
4792
- var __dirname2, HOOKS_DIR, EVENT_MAP, getInstalledHooks;
4793
- var init_installer = __esm(() => {
4794
- init_registry();
4795
- init_resolve();
4796
- __dirname2 = dirname3(fileURLToPath2(import.meta.url));
4797
- HOOKS_DIR = existsSync5(join5(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join5(__dirname2, "..", "..", "hooks") : join5(__dirname2, "..", "hooks");
4798
- EVENT_MAP = {
4799
- claude: {
4800
- PreToolUse: "PreToolUse",
4801
- PostToolUse: "PostToolUse",
4802
- Stop: "Stop",
4803
- Notification: "Notification",
4804
- SessionStart: "SessionStart",
4805
- SessionEnd: "SessionEnd",
4806
- UserPromptSubmit: null,
4807
- SubagentStart: null
4808
- },
4809
- gemini: {
4810
- PreToolUse: "BeforeTool",
4811
- PostToolUse: "AfterTool",
4812
- Stop: "AfterAgent",
4813
- Notification: "Notification",
4814
- SessionStart: null,
4815
- SessionEnd: null,
4816
- UserPromptSubmit: null,
4817
- SubagentStart: null
4818
- },
4819
- codewith: {
4820
- PreToolUse: "PreToolUse",
4821
- PostToolUse: "PostToolUse",
4822
- Stop: "Stop",
4823
- Notification: null,
4824
- SessionStart: "SessionStart",
4825
- SessionEnd: null,
4826
- UserPromptSubmit: "UserPromptSubmit",
4827
- SubagentStart: "SubagentStart"
5104
+ function buildCodewithTomlFragment(name, profile) {
5105
+ const shortName = shortHookName(name);
5106
+ const meta = resolveHookMeta(shortName);
5107
+ if (!meta)
5108
+ throw new Error(`Hook '${shortName}' not found`);
5109
+ const command = profile ? `hooks run ${shortName} --profile ${profile}` : `hooks run ${shortName}`;
5110
+ const matcher = codewithMatcher(meta.matcher);
5111
+ const fragments = [];
5112
+ for (const event of getHookEvents(meta)) {
5113
+ const eventKey = getTargetEventName(event, "codewith");
5114
+ if (!eventKey) {
5115
+ throw new Error(`Hook '${shortName}' uses event '${event}', which is not supported by the Codewith target`);
4828
5116
  }
4829
- };
4830
- getInstalledHooks = getRegisteredHooks;
4831
- });
5117
+ const lines = [
5118
+ `[[hooks.${eventKey}]]`
5119
+ ];
5120
+ if (matcher)
5121
+ lines.push(`matcher = ${tomlString(matcher)}`);
5122
+ lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
5123
+ fragments.push(lines.join(`
5124
+ `));
5125
+ }
5126
+ return `${fragments.join(`
4832
5127
 
4833
- // src/lib/registration.ts
4834
- function safeMatcherTest(pattern, value) {
5128
+ `)}
5129
+ `;
5130
+ }
5131
+ function readCodewithConfig(scope, configPath) {
5132
+ const path = getSettingsPath(scope, "codewith", configPath);
4835
5133
  try {
4836
- return new RegExp(pattern).test(value);
5134
+ return existsSync8(path) ? readFileSync5(path, "utf-8") : "";
4837
5135
  } catch {
4838
- return false;
5136
+ return "";
4839
5137
  }
4840
5138
  }
4841
- function hookRegisteredInSettings(settings, name, eventSpec, hookMatcher) {
4842
- const separator = eventSpec.indexOf(":");
4843
- const eventName = separator === -1 ? eventSpec : eventSpec.slice(0, separator);
4844
- const specMatcher = separator === -1 ? "" : eventSpec.slice(separator + 1);
4845
- const matcher = specMatcher || hookMatcher || "";
4846
- const hooks = settings.hooks?.[eventName];
4847
- if (!Array.isArray(hooks))
4848
- return false;
4849
- return hooks.some((entry) => entry?.hooks?.some((h) => {
4850
- const match = h?.command?.match(/^hooks run ([\w-]+)/);
4851
- if (!match || match[1] !== name)
4852
- return false;
4853
- if (!matcher)
4854
- return true;
4855
- const entryMatcher = typeof entry.matcher === "string" ? entry.matcher : "";
4856
- if (!entryMatcher)
4857
- return true;
4858
- return entryMatcher === matcher || safeMatcherTest(entryMatcher, matcher) || safeMatcherTest(matcher, entryMatcher);
4859
- }));
4860
- }
5139
+ function appendCodewithFragment(fragment, scope, configPath) {
5140
+ const path = getSettingsPath(scope, "codewith", configPath);
5141
+ const dir = dirname3(path);
5142
+ if (!existsSync8(dir))
5143
+ mkdirSync5(dir, { recursive: true });
5144
+ const existing = existsSync8(path) ? readFileSync5(path, "utf-8") : "";
5145
+ const sep2 = existing.trim() ? `
4861
5146
 
4862
- // src/lib/profiles.ts
4863
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, readdirSync as readdirSync2, rmSync, cpSync } from "fs";
4864
- import { join as join6 } from "path";
4865
- import { homedir as homedir4 } from "os";
4866
- function resolveProfilesDir() {
4867
- const newDir = join6(homedir4(), ".hasna", "hooks", "profiles");
4868
- const oldDir = join6(homedir4(), ".hooks", "profiles");
4869
- if (!existsSync6(newDir) && existsSync6(oldDir)) {
4870
- mkdirSync4(join6(homedir4(), ".hasna", "hooks"), { recursive: true });
4871
- cpSync(oldDir, newDir, { recursive: true });
4872
- }
4873
- return newDir;
5147
+ ` : "";
5148
+ writeFileSync4(path, `${existing.replace(/\s*$/, "")}${sep2}${fragment}`);
5149
+ return path;
4874
5150
  }
4875
- function ensureProfilesDir() {
4876
- if (!existsSync6(PROFILES_DIR)) {
4877
- mkdirSync4(PROFILES_DIR, { recursive: true });
5151
+ function detectConflict(name, scope, target) {
5152
+ const meta = resolveHookMeta(name);
5153
+ if (!meta || !meta.matcher)
5154
+ return;
5155
+ const events = new Set(getHookEvents(meta));
5156
+ const registered = getRegisteredHooksForTarget(scope, target);
5157
+ for (const existingName of registered) {
5158
+ if (existingName === name)
5159
+ continue;
5160
+ const existing = getHook(existingName);
5161
+ if (!existing || !existing.matcher)
5162
+ continue;
5163
+ if (!getHookEvents(existing).some((event) => events.has(event)))
5164
+ continue;
5165
+ const a = meta.matcher.toLowerCase();
5166
+ const b = existing.matcher.toLowerCase();
5167
+ if (a === b || a.includes(b) || b.includes(a)) {
5168
+ return `conflicts with '${existingName}' (same event ${meta.event}, overlapping matcher '${existing.matcher}')`;
5169
+ }
4878
5170
  }
5171
+ return;
4879
5172
  }
4880
- function profilePath(id) {
4881
- return join6(PROFILES_DIR, `${id}.json`);
4882
- }
4883
- function shortUuid() {
4884
- return crypto.randomUUID().slice(0, 8);
4885
- }
4886
- function createProfile(input) {
4887
- ensureProfilesDir();
4888
- const id = shortUuid();
4889
- const now2 = new Date().toISOString();
4890
- const profile = {
4891
- agent_id: id,
4892
- agent_type: input.agent_type,
4893
- created_at: now2,
4894
- last_seen_at: now2,
4895
- preferences: {}
4896
- };
4897
- if (input.name) {
4898
- profile.name = input.name;
5173
+ function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath) {
5174
+ const shortName = shortHookName(name);
5175
+ if (!hookExists(shortName)) {
5176
+ return { hook: shortName, success: false, error: `Hook '${shortName}' not found`, target };
5177
+ }
5178
+ if (target === "codewith") {
5179
+ try {
5180
+ const fragment = buildCodewithTomlFragment(shortName, profile);
5181
+ if (codewithMode !== "write") {
5182
+ return {
5183
+ hook: shortName,
5184
+ success: true,
5185
+ scope,
5186
+ target,
5187
+ fragment,
5188
+ applied: false,
5189
+ note: "Codewith install is fragment-only by default; open-configs should own applying this TOML."
5190
+ };
5191
+ }
5192
+ if (!codewithConfigPath) {
5193
+ return {
5194
+ hook: shortName,
5195
+ success: false,
5196
+ error: "Direct Codewith writes require an explicit --codewith-config path; refusing to write default ~/.codewith/config.toml.",
5197
+ scope,
5198
+ target,
5199
+ fragment,
5200
+ applied: false
5201
+ };
5202
+ }
5203
+ const existing = readCodewithConfig(scope, codewithConfigPath);
5204
+ if (!overwrite && new RegExp(`command\\s*=\\s*["']hooks run ${shortName}(?:\\s|["'])`).test(existing)) {
5205
+ return { hook: shortName, success: false, error: "Already installed. Use --overwrite to append another fragment.", scope, target };
5206
+ }
5207
+ const path = appendCodewithFragment(fragment, scope, codewithConfigPath);
5208
+ return {
5209
+ hook: shortName,
5210
+ success: true,
5211
+ scope,
5212
+ target,
5213
+ fragment,
5214
+ applied: true,
5215
+ configPath: path,
5216
+ note: "Direct Codewith config write was explicitly requested; prefer open-configs for managed machines."
5217
+ };
5218
+ } catch (error) {
5219
+ return {
5220
+ hook: shortName,
5221
+ success: false,
5222
+ error: error instanceof Error ? error.message : "Unknown error",
5223
+ target
5224
+ };
5225
+ }
5226
+ }
5227
+ const registered = getRegisteredHooksForTarget(scope, target);
5228
+ if (registered.includes(shortName) && !overwrite) {
5229
+ return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
4899
5230
  }
4900
- writeFileSync4(profilePath(id), JSON.stringify(profile, null, 2) + `
4901
- `);
4902
- return profile;
4903
- }
4904
- function getProfile(id) {
4905
- const path = profilePath(id);
5231
+ const conflict = detectConflict(shortName, scope, target);
4906
5232
  try {
4907
- if (!existsSync6(path))
4908
- return null;
4909
- return JSON.parse(readFileSync4(path, "utf-8"));
4910
- } catch {
4911
- return null;
5233
+ registerHook(shortName, scope, target, profile);
5234
+ return { hook: shortName, success: true, scope, target, ...conflict ? { conflict } : {} };
5235
+ } catch (error) {
5236
+ return {
5237
+ hook: shortName,
5238
+ success: false,
5239
+ error: error instanceof Error ? error.message : "Unknown error",
5240
+ target
5241
+ };
4912
5242
  }
4913
5243
  }
4914
- function listProfiles() {
4915
- if (!existsSync6(PROFILES_DIR))
4916
- return [];
4917
- try {
4918
- const files = readdirSync2(PROFILES_DIR).filter((f) => f.endsWith(".json"));
4919
- const profiles = [];
4920
- for (const file of files) {
4921
- try {
4922
- const content = readFileSync4(join6(PROFILES_DIR, file), "utf-8");
4923
- profiles.push(JSON.parse(content));
4924
- } catch {}
5244
+ function installHook(name, options = {}) {
5245
+ const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath } = options;
5246
+ if (target === "all") {
5247
+ const shortName = shortHookName(name);
5248
+ const meta = resolveHookMeta(shortName);
5249
+ if (meta) {
5250
+ const unsupportedTargets = ["claude", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
5251
+ if (unsupportedTargets.length > 0) {
5252
+ return {
5253
+ hook: shortName,
5254
+ success: false,
5255
+ error: `Event(s) '${getHookEvents(meta).join(", ")}' are not supported by target(s): ${unsupportedTargets.join(", ")}`,
5256
+ target: "all"
5257
+ };
5258
+ }
4925
5259
  }
4926
- return profiles.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
4927
- } catch {
4928
- return [];
5260
+ const claudeResult = installForTarget(name, scope, overwrite, "claude", profile);
5261
+ if (!claudeResult.success) {
5262
+ return {
5263
+ ...claudeResult,
5264
+ error: `Failed for target 'claude': ${claudeResult.error}`,
5265
+ target: "all"
5266
+ };
5267
+ }
5268
+ const codewithResult = installForTarget(name, scope, overwrite, "codewith", profile, codewithMode, codewithConfigPath);
5269
+ if (!codewithResult.success) {
5270
+ return {
5271
+ ...codewithResult,
5272
+ error: `Failed for target 'codewith': ${codewithResult.error}`,
5273
+ target: "all"
5274
+ };
5275
+ }
5276
+ return { ...claudeResult, target: "all" };
4929
5277
  }
5278
+ return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath);
4930
5279
  }
4931
- function touchProfile(id) {
4932
- const profile = getProfile(id);
4933
- if (!profile)
5280
+ function registerHook(name, scope = "global", target = "claude", profile) {
5281
+ const meta = resolveHookMeta(name);
5282
+ if (!meta)
4934
5283
  return;
4935
- profile.last_seen_at = new Date().toISOString();
4936
- writeFileSync4(profilePath(id), JSON.stringify(profile, null, 2) + `
4937
- `);
4938
- }
4939
- function exportProfiles() {
4940
- return listProfiles();
4941
- }
4942
- function importProfiles(profiles) {
4943
- ensureProfilesDir();
4944
- let imported = 0;
4945
- let skipped = 0;
4946
- for (const profile of profiles) {
4947
- if (!profile.agent_id || !profile.agent_type) {
4948
- skipped++;
4949
- continue;
4950
- }
4951
- const path = profilePath(profile.agent_id);
4952
- if (existsSync6(path)) {
4953
- skipped++;
4954
- continue;
5284
+ const eventKeys = getHookEvents(meta).map((event) => {
5285
+ const eventKey = getTargetEventName(event, target);
5286
+ if (eventKey === null) {
5287
+ throw new Error(`Event '${event}' is not supported by target '${target}'`);
4955
5288
  }
4956
- writeFileSync4(path, JSON.stringify(profile, null, 2) + `
4957
- `);
4958
- imported++;
5289
+ return eventKey;
5290
+ });
5291
+ const uniqueEventKeys = [...new Set(eventKeys)];
5292
+ if (uniqueEventKeys.length === 0) {
5293
+ throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
4959
5294
  }
4960
- return { imported, skipped };
4961
- }
4962
- var PROFILES_DIR;
4963
- var init_profiles = __esm(() => {
4964
- PROFILES_DIR = resolveProfilesDir();
4965
- });
4966
-
4967
- // src/db/schema.ts
4968
- var CREATE_HOOK_EVENTS_TABLE = `
4969
- CREATE TABLE IF NOT EXISTS hook_events (
4970
- id TEXT PRIMARY KEY,
4971
- timestamp TEXT NOT NULL,
4972
- session_id TEXT NOT NULL,
4973
- hook_name TEXT NOT NULL,
4974
- event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
4975
- tool_name TEXT,
4976
- tool_input TEXT,
4977
- result TEXT CHECK (result IN ('continue', 'block', NULL)),
4978
- error TEXT,
4979
- duration_ms INTEGER,
4980
- project_dir TEXT,
4981
- metadata TEXT
4982
- )
4983
- `, CREATE_INDEXES;
4984
- var init_schema = __esm(() => {
4985
- CREATE_INDEXES = [
4986
- `CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
4987
- `CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
4988
- `CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
4989
- `CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
4990
- `CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`
4991
- ];
4992
- });
4993
-
4994
- // src/db/migrations/001_initial.ts
4995
- function up(db) {
4996
- db.exec(CREATE_HOOK_EVENTS_TABLE);
4997
- for (const idx of CREATE_INDEXES) {
4998
- db.exec(idx);
5295
+ const settings = readSettings(scope, target);
5296
+ if (!settings.hooks)
5297
+ settings.hooks = {};
5298
+ removeHookFromAllEvents(settings, name);
5299
+ const hookCommand = profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`;
5300
+ for (const eventKey of uniqueEventKeys) {
5301
+ if (!settings.hooks[eventKey])
5302
+ settings.hooks[eventKey] = [];
5303
+ const entry = {
5304
+ hooks: [{ type: "command", command: hookCommand }]
5305
+ };
5306
+ if (meta.matcher) {
5307
+ entry.matcher = meta.matcher;
5308
+ }
5309
+ settings.hooks[eventKey].push(entry);
4999
5310
  }
5311
+ writeSettings(settings, scope, target);
5000
5312
  }
5001
- var init_001_initial = __esm(() => {
5002
- init_schema();
5003
- });
5004
-
5005
- // src/db/migrations/002_session_events.ts
5006
- function up2(db) {
5007
- const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
5008
- if (!row?.sql)
5009
- return;
5010
- if (row.sql.includes("SessionStart"))
5313
+ function removeHookFromAllEvents(settings, name) {
5314
+ if (!settings.hooks)
5011
5315
  return;
5012
- db.exec("BEGIN");
5013
- try {
5014
- db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
5015
- db.exec(CREATE_HOOK_EVENTS_TABLE);
5016
- db.exec(`INSERT INTO hook_events
5017
- (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
5018
- SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
5019
- FROM hook_events_old`);
5020
- db.exec("DROP TABLE hook_events_old");
5021
- for (const idx of CREATE_INDEXES) {
5022
- db.exec(idx);
5316
+ for (const key of Object.keys(settings.hooks)) {
5317
+ settings.hooks[key] = removeHookEntriesByName(settings.hooks[key], name);
5318
+ if (settings.hooks[key].length === 0) {
5319
+ delete settings.hooks[key];
5023
5320
  }
5024
- db.exec("COMMIT");
5025
- } catch (err) {
5026
- db.exec("ROLLBACK");
5027
- throw err;
5028
5321
  }
5029
5322
  }
5030
- var init_002_session_events = __esm(() => {
5031
- init_schema();
5032
- });
5033
-
5034
- // src/db/migrations/003_user_prompt_submit_event.ts
5035
- function up3(db) {
5036
- const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
5037
- if (!row?.sql)
5038
- return;
5039
- if (row.sql.includes("UserPromptSubmit"))
5323
+ function unregisterHook(name, scope = "global", target = "claude") {
5324
+ const settings = readSettings(scope, target);
5325
+ if (!settings.hooks)
5040
5326
  return;
5041
- db.exec("BEGIN");
5042
- try {
5043
- db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
5044
- db.exec(CREATE_HOOK_EVENTS_TABLE);
5045
- db.exec(`INSERT INTO hook_events
5046
- (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
5047
- SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
5048
- FROM hook_events_old`);
5049
- db.exec("DROP TABLE hook_events_old");
5050
- for (const idx of CREATE_INDEXES) {
5051
- db.exec(idx);
5327
+ removeHookFromAllEvents(settings, name);
5328
+ if (Object.keys(settings.hooks).length === 0) {
5329
+ delete settings.hooks;
5330
+ }
5331
+ writeSettings(settings, scope, target);
5332
+ }
5333
+ function installHooks(names, options = {}) {
5334
+ return names.map((name) => installHook(name, options));
5335
+ }
5336
+ function getRegisteredHooksForTarget(scope = "global", target = "claude") {
5337
+ if (target === "codewith") {
5338
+ const config = readCodewithConfig(scope);
5339
+ const registered2 = [];
5340
+ const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
5341
+ let match;
5342
+ while (match = re.exec(config)) {
5343
+ registered2.push(match[1]);
5052
5344
  }
5053
- db.exec("COMMIT");
5054
- } catch (err) {
5055
- db.exec("ROLLBACK");
5056
- throw err;
5345
+ return [...new Set(registered2)];
5346
+ }
5347
+ const settings = readSettings(scope, target);
5348
+ if (!settings.hooks)
5349
+ return [];
5350
+ const registered = [];
5351
+ for (const eventKey of Object.keys(settings.hooks)) {
5352
+ for (const entry of settings.hooks[eventKey]) {
5353
+ for (const hook of entry.hooks || []) {
5354
+ const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
5355
+ const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
5356
+ const match = newMatch || oldMatch;
5357
+ if (match) {
5358
+ registered.push(match[1]);
5359
+ }
5360
+ }
5361
+ }
5362
+ }
5363
+ return [...new Set(registered)];
5364
+ }
5365
+ function getRegisteredHooks(scope = "global") {
5366
+ return getRegisteredHooksForTarget(scope, "claude");
5367
+ }
5368
+ function removeHook(name, scope = "global", target = "claude") {
5369
+ const shortName = shortHookName(name);
5370
+ if (target === "all") {
5371
+ const claudeRemoved = removeHookForTarget(shortName, scope, "claude");
5372
+ const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
5373
+ return claudeRemoved || geminiRemoved;
5057
5374
  }
5058
- }
5059
- var init_003_user_prompt_submit_event = __esm(() => {
5060
- init_schema();
5061
- });
5062
-
5063
- // src/db/migrations/004_hooks_table.ts
5064
- function up4(db) {
5065
- db.exec(CREATE_HOOKS_TABLE);
5066
- for (const idx of CREATE_HOOKS_INDEXES) {
5067
- db.exec(idx);
5375
+ if (target === "codewith") {
5376
+ return false;
5068
5377
  }
5378
+ return removeHookForTarget(shortName, scope, target);
5069
5379
  }
5070
- var CREATE_HOOKS_TABLE = `
5071
- CREATE TABLE IF NOT EXISTS hooks (
5072
- id TEXT PRIMARY KEY,
5073
- name TEXT NOT NULL,
5074
- version TEXT NOT NULL,
5075
- sha256 TEXT NOT NULL,
5076
- source_type TEXT NOT NULL,
5077
- source_ref TEXT,
5078
- installed_at TEXT NOT NULL,
5079
- enabled INTEGER NOT NULL DEFAULT 1,
5080
- last_verified_at TEXT
5081
- )
5082
- `, CREATE_HOOKS_INDEXES;
5083
- var init_004_hooks_table = __esm(() => {
5084
- CREATE_HOOKS_INDEXES = [
5085
- `CREATE INDEX IF NOT EXISTS idx_hooks_name ON hooks (name)`
5086
- ];
5087
- });
5088
-
5089
- // src/db/migrations/index.ts
5090
- function ensureMigrationsTable(db) {
5091
- db.exec(`
5092
- CREATE TABLE IF NOT EXISTS schema_migrations (
5093
- version TEXT PRIMARY KEY,
5094
- applied_at TEXT NOT NULL
5095
- )
5096
- `);
5097
- }
5098
- function getApplied(db) {
5099
- const rows = db.query("SELECT version FROM schema_migrations").all();
5100
- return new Set(rows.map((r) => r.version));
5380
+ function removeHookForTarget(name, scope, target) {
5381
+ const registered = getRegisteredHooksForTarget(scope, target);
5382
+ if (!registered.includes(name)) {
5383
+ return false;
5384
+ }
5385
+ unregisterHook(name, scope, target);
5386
+ return true;
5101
5387
  }
5102
- function runMigrations(db) {
5103
- ensureMigrationsTable(db);
5104
- const applied = getApplied(db);
5105
- for (const migration of MIGRATIONS) {
5106
- if (applied.has(migration.version))
5388
+ function removeCodewithHookEntry(configText, name) {
5389
+ const sections = configText.split(/\n\s*\n/);
5390
+ const nameRe = new RegExp(`^command\\s*=\\s*"hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?"$`);
5391
+ const entryHeaderRe = /^\[\[hooks\.([\w]+)\.hooks\]\]$/;
5392
+ const eventHeaderRe = /^\[\[hooks\.([\w]+)\]\]$/;
5393
+ const eventsWithEntries = new Set;
5394
+ const kept = [];
5395
+ let removed = false;
5396
+ for (const section of sections) {
5397
+ const lines = section.split(`
5398
+ `);
5399
+ const entryMatch = entryHeaderRe.exec(lines[0] ?? "");
5400
+ if (entryMatch) {
5401
+ const event = entryMatch[1];
5402
+ eventsWithEntries.add(event);
5403
+ if (lines.some((line) => nameRe.test(line.trim()))) {
5404
+ removed = true;
5405
+ continue;
5406
+ }
5407
+ kept.push({ text: section });
5107
5408
  continue;
5108
- migration.up(db);
5109
- db.run("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", [
5110
- migration.version,
5111
- new Date().toISOString()
5112
- ]);
5409
+ }
5410
+ const eventMatch = eventHeaderRe.exec(lines[0] ?? "");
5411
+ kept.push({ text: section, eventHeaderFor: eventMatch ? eventMatch[1] : undefined });
5113
5412
  }
5114
- }
5115
- var MIGRATIONS;
5116
- var init_migrations = __esm(() => {
5117
- init_001_initial();
5118
- init_002_session_events();
5119
- init_003_user_prompt_submit_event();
5120
- init_004_hooks_table();
5121
- MIGRATIONS = [
5122
- { version: "001_initial", up },
5123
- { version: "002_session_events", up: up2 },
5124
- { version: "003_user_prompt_submit_event", up: up3 },
5125
- { version: "004_hooks_table", up: up4 }
5126
- ];
5127
- });
5413
+ const keptEntriesByEvent = new Set;
5414
+ for (const item of kept) {
5415
+ const m = entryHeaderRe.exec(item.text.split(`
5416
+ `)[0] ?? "");
5417
+ if (m)
5418
+ keptEntriesByEvent.add(m[1]);
5419
+ }
5420
+ const finalSections = kept.filter((item) => {
5421
+ if (item.eventHeaderFor === undefined)
5422
+ return true;
5423
+ if (!eventsWithEntries.has(item.eventHeaderFor))
5424
+ return true;
5425
+ return keptEntriesByEvent.has(item.eventHeaderFor);
5426
+ }).map((item) => item.text);
5427
+ return {
5428
+ text: finalSections.join(`
5128
5429
 
5129
- // src/db/legacy-import.ts
5130
- import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync5 } from "fs";
5131
- import { join as join7 } from "path";
5132
- import { homedir as homedir5 } from "os";
5133
- function ensureMetaTable(db) {
5134
- db.exec(`
5135
- CREATE TABLE IF NOT EXISTS _meta (
5136
- key TEXT PRIMARY KEY,
5137
- value TEXT NOT NULL
5138
- )
5139
- `);
5140
- }
5141
- function isAlreadyDone(db) {
5142
- ensureMetaTable(db);
5143
- const row = db.query("SELECT value FROM _meta WHERE key = ?").get(META_KEY);
5144
- return row?.value === "1";
5430
+ `) + (configText.endsWith(`
5431
+ `) ? `
5432
+ ` : ""),
5433
+ removed
5434
+ };
5145
5435
  }
5146
- function markDone(db) {
5147
- db.run("INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)", [META_KEY, "1"]);
5436
+ function escapeRegExp(value) {
5437
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5148
5438
  }
5149
- function nanoid() {
5150
- return crypto.randomUUID().replace(/-/g, "").slice(0, 21);
5439
+ function codewithHasHookEntryInText(configText, name) {
5440
+ return new RegExp(`hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?(?:$|\\s|")`).test(configText);
5151
5441
  }
5152
- function importJsonlFile(db, filePath) {
5153
- let count = 0;
5442
+ function codewithHasHookEntry(name) {
5443
+ const path = getSettingsPath("global", "codewith");
5444
+ if (!existsSync8(path))
5445
+ return false;
5154
5446
  try {
5155
- const lines = readFileSync5(filePath, "utf-8").split(`
5156
- `).filter(Boolean);
5157
- for (const line of lines) {
5158
- try {
5159
- const entry = JSON.parse(line);
5160
- db.run(`INSERT OR IGNORE INTO hook_events
5161
- (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, project_dir)
5162
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
5163
- nanoid(),
5164
- entry.timestamp ?? new Date().toISOString(),
5165
- entry.session_id ?? "legacy",
5166
- "sessionlog",
5167
- "PostToolUse",
5168
- entry.tool_name ?? null,
5169
- entry.tool_input ? String(entry.tool_input).slice(0, 500) : null,
5170
- null
5171
- ]);
5172
- count++;
5173
- } catch {}
5174
- }
5175
- } catch {}
5176
- return count;
5447
+ return codewithHasHookEntryInText(readFileSync5(path, "utf-8"), name);
5448
+ } catch {
5449
+ return false;
5450
+ }
5177
5451
  }
5178
- function importErrorsLog(db, filePath) {
5179
- let count = 0;
5180
- try {
5181
- const lines = readFileSync5(filePath, "utf-8").split(`
5182
- `).filter(Boolean);
5183
- const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
5184
- for (const line of lines) {
5185
- try {
5186
- const m = line.match(linePattern);
5187
- if (!m)
5188
- continue;
5189
- const [, timestamp, sessionPrefix, , errorMsg] = m;
5190
- db.run(`INSERT OR IGNORE INTO hook_events
5191
- (id, timestamp, session_id, hook_name, event_type, error)
5192
- VALUES (?, ?, ?, ?, ?, ?)`, [
5193
- nanoid(),
5194
- timestamp,
5195
- sessionPrefix ? `legacy-${sessionPrefix}` : "legacy",
5196
- "errornotify",
5197
- "PostToolUse",
5198
- errorMsg.slice(0, 500)
5199
- ]);
5200
- count++;
5201
- } catch {}
5452
+ function uninstallHook(name, scope = "global", target = "claude") {
5453
+ const shortName = shortHookName(name);
5454
+ const custom = readCustomManifest(shortName);
5455
+ const bundledMeta = custom ? undefined : getHook(shortName);
5456
+ const existsInTarget = (t) => getRegisteredHooksForTarget("global", t).includes(shortName) || getRegisteredHooksForTarget("project", t).includes(shortName);
5457
+ const registeredClaudeOrGemini = target === "all" ? existsInTarget("claude") || existsInTarget("gemini") : false;
5458
+ const registeredGlobal = registeredClaudeOrGemini || getRegisteredHooksForTarget("global", target === "all" ? "claude" : target).includes(shortName);
5459
+ const registeredProject = registeredClaudeOrGemini || getRegisteredHooksForTarget("project", target === "all" ? "claude" : target).includes(shortName);
5460
+ const codewithOnly = (target === "codewith" || target === "all") && codewithHasHookEntry(shortName);
5461
+ if (!custom && !bundledMeta && !registeredGlobal && !registeredProject && !codewithOnly) {
5462
+ return { name: shortName, removed: false, source: null, settingsScopes: [], storeDirRemoved: false, pinRemoved: false, dbRecordRemoved: false, registrationsRemaining: [], error: `Hook '${shortName}' not found` };
5463
+ }
5464
+ const writableTargets = target === "all" ? ["claude", "gemini"] : target === "codewith" ? [] : [target];
5465
+ const scopes = [];
5466
+ for (const s of ["global", "project"]) {
5467
+ for (const t of writableTargets) {
5468
+ if (getRegisteredHooksForTarget(s, t).includes(shortName)) {
5469
+ unregisterHook(shortName, s, t);
5470
+ if (!scopes.includes(s))
5471
+ scopes.push(s);
5472
+ }
5202
5473
  }
5203
- } catch {}
5204
- return count;
5205
- }
5206
- function runLegacyImport(db, homeDir = homedir5()) {
5207
- try {
5208
- if (isAlreadyDone(db))
5209
- return;
5210
- let total = 0;
5211
- const claudeProjectsDir = join7(homeDir, ".claude", "projects");
5212
- if (existsSync7(claudeProjectsDir)) {
5213
- try {
5214
- const projectDirs = readdirSync3(claudeProjectsDir);
5215
- for (const dir of projectDirs) {
5216
- const projectDir = join7(claudeProjectsDir, dir);
5217
- try {
5218
- const files = readdirSync3(projectDir);
5219
- for (const file of files) {
5220
- if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
5221
- total += importJsonlFile(db, join7(projectDir, file));
5222
- }
5223
- if (file === "errors.log") {
5224
- total += importErrorsLog(db, join7(projectDir, file));
5225
- }
5226
- }
5227
- } catch {}
5228
- }
5229
- } catch {}
5474
+ }
5475
+ const registrationsRemaining = [];
5476
+ if (target === "codewith" || target === "all") {
5477
+ const codewithPath = getSettingsPath("global", "codewith");
5478
+ if (existsSync8(codewithPath)) {
5479
+ const before = readFileSync5(codewithPath, "utf-8");
5480
+ const after = removeCodewithHookEntry(before, shortName);
5481
+ if (after.removed) {
5482
+ writeFileSync4(codewithPath, after.text, "utf-8");
5483
+ }
5484
+ if (codewithHasHookEntryInText(after.removed ? after.text : before, shortName)) {
5485
+ registrationsRemaining.push("codewith");
5486
+ }
5230
5487
  }
5231
- markDone(db);
5232
- if (total > 0) {
5233
- process.stderr.write(`[hooks] Imported ${total} legacy log entries into SQLite.
5234
- `);
5488
+ }
5489
+ if (custom) {
5490
+ try {
5491
+ rmSync(customHookDir(shortName), { recursive: true, force: true });
5492
+ } catch {
5493
+ return {
5494
+ name: shortName,
5495
+ removed: false,
5496
+ source: "custom",
5497
+ settingsScopes: scopes,
5498
+ storeDirRemoved: false,
5499
+ pinRemoved: false,
5500
+ dbRecordRemoved: false,
5501
+ registrationsRemaining,
5502
+ error: `Hook '${shortName}' store directory could not be removed; trust records preserved (no fail-open)`
5503
+ };
5235
5504
  }
5236
- } catch (err) {
5237
- process.stderr.write(`[hooks] Legacy import failed (non-fatal): ${err}
5238
- `);
5239
5505
  }
5506
+ const { removedPin, removedRecord } = removeHookFromStore(shortName);
5507
+ return {
5508
+ name: shortName,
5509
+ removed: true,
5510
+ source: custom ? "custom" : bundledMeta ? "bundled" : "registered-only",
5511
+ settingsScopes: scopes,
5512
+ storeDirRemoved: custom ? true : false,
5513
+ pinRemoved: removedPin,
5514
+ dbRecordRemoved: removedRecord,
5515
+ registrationsRemaining
5516
+ };
5240
5517
  }
5241
- var META_KEY = "legacy_import_done";
5242
- var init_legacy_import = () => {};
5518
+ var __dirname2, HOOKS_DIR, EVENT_MAP, getInstalledHooks;
5519
+ var init_installer = __esm(() => {
5520
+ init_registry();
5521
+ init_resolve();
5522
+ init_manifest();
5523
+ init_store();
5524
+ __dirname2 = dirname3(fileURLToPath2(import.meta.url));
5525
+ HOOKS_DIR = existsSync8(join7(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join7(__dirname2, "..", "..", "hooks") : join7(__dirname2, "..", "hooks");
5526
+ EVENT_MAP = {
5527
+ claude: {
5528
+ PreToolUse: "PreToolUse",
5529
+ PostToolUse: "PostToolUse",
5530
+ Stop: "Stop",
5531
+ Notification: "Notification",
5532
+ SessionStart: "SessionStart",
5533
+ SessionEnd: "SessionEnd",
5534
+ UserPromptSubmit: null,
5535
+ SubagentStart: null
5536
+ },
5537
+ gemini: {
5538
+ PreToolUse: "BeforeTool",
5539
+ PostToolUse: "AfterTool",
5540
+ Stop: "AfterAgent",
5541
+ Notification: "Notification",
5542
+ SessionStart: null,
5543
+ SessionEnd: null,
5544
+ UserPromptSubmit: null,
5545
+ SubagentStart: null
5546
+ },
5547
+ codewith: {
5548
+ PreToolUse: "PreToolUse",
5549
+ PostToolUse: "PostToolUse",
5550
+ Stop: "Stop",
5551
+ Notification: null,
5552
+ SessionStart: "SessionStart",
5553
+ SessionEnd: null,
5554
+ UserPromptSubmit: "UserPromptSubmit",
5555
+ SubagentStart: "SubagentStart"
5556
+ }
5557
+ };
5558
+ getInstalledHooks = getRegisteredHooks;
5559
+ });
5243
5560
 
5244
- // src/db/retention.ts
5245
- function runRetention(db, days) {
5246
- const envDays = parseInt(process.env.HOOKS_RETENTION_DAYS ?? "30");
5247
- const retentionDays = days ?? (isNaN(envDays) || envDays <= 0 ? 30 : envDays);
5248
- const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
5561
+ // src/lib/registration.ts
5562
+ function safeMatcherTest(pattern, value) {
5249
5563
  try {
5250
- db.run("DELETE FROM hook_events WHERE timestamp < ?", [cutoff]);
5251
- const changes = db.query("SELECT changes() as changes").get()?.changes ?? 0;
5252
- return changes;
5564
+ return new RegExp(pattern).test(value);
5253
5565
  } catch {
5254
- return 0;
5566
+ return false;
5255
5567
  }
5256
5568
  }
5569
+ function hookRegisteredInSettings(settings, name, eventSpec, hookMatcher) {
5570
+ const separator = eventSpec.indexOf(":");
5571
+ const eventName = separator === -1 ? eventSpec : eventSpec.slice(0, separator);
5572
+ const specMatcher = separator === -1 ? "" : eventSpec.slice(separator + 1);
5573
+ const matcher = specMatcher || hookMatcher || "";
5574
+ const hooks = settings.hooks?.[eventName];
5575
+ if (!Array.isArray(hooks))
5576
+ return false;
5577
+ return hooks.some((entry) => entry?.hooks?.some((h) => {
5578
+ const match = h?.command?.match(/^hooks run ([\w-]+)/);
5579
+ if (!match || match[1] !== name)
5580
+ return false;
5581
+ if (!matcher)
5582
+ return true;
5583
+ const entryMatcher = typeof entry.matcher === "string" ? entry.matcher : "";
5584
+ if (!entryMatcher)
5585
+ return true;
5586
+ return entryMatcher === matcher || safeMatcherTest(entryMatcher, matcher) || safeMatcherTest(matcher, entryMatcher);
5587
+ }));
5588
+ }
5257
5589
 
5258
- // src/db/index.ts
5259
- var exports_db = {};
5260
- __export(exports_db, {
5261
- getDbPath: () => getDbPath,
5262
- getDb: () => getDb,
5263
- createTestDb: () => createTestDb,
5264
- closeDb: () => closeDb
5265
- });
5266
- import { Database } from "bun:sqlite";
5267
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, cpSync as cpSync2 } from "fs";
5590
+ // src/lib/profiles.ts
5591
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5, readdirSync as readdirSync3, rmSync as rmSync2, cpSync as cpSync2 } from "fs";
5268
5592
  import { join as join8 } from "path";
5269
5593
  import { homedir as homedir6 } from "os";
5270
- function resolveDataDir() {
5271
- const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
5272
- if (explicit)
5273
- return explicit;
5274
- const newDir = join8(homedir6(), ".hasna", "hooks");
5275
- const oldDir = join8(homedir6(), ".hooks");
5276
- if (!existsSync8(newDir) && existsSync8(oldDir)) {
5277
- mkdirSync5(join8(homedir6(), ".hasna"), { recursive: true });
5594
+ function resolveProfilesDir() {
5595
+ const newDir = join8(homedir6(), ".hasna", "hooks", "profiles");
5596
+ const oldDir = join8(homedir6(), ".hooks", "profiles");
5597
+ if (!existsSync9(newDir) && existsSync9(oldDir)) {
5598
+ mkdirSync6(join8(homedir6(), ".hasna", "hooks"), { recursive: true });
5278
5599
  cpSync2(oldDir, newDir, { recursive: true });
5279
5600
  }
5280
5601
  return newDir;
5281
5602
  }
5282
- function getDbPath() {
5283
- const explicitDb = process.env.HASNA_HOOKS_DB_PATH ?? process.env.HOOKS_DB_PATH;
5284
- if (explicitDb)
5285
- return explicitDb;
5286
- const dataDir = resolveDataDir();
5287
- return join8(dataDir, "hooks.db");
5288
- }
5289
- function ensureDir(dbPath) {
5290
- const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
5291
- if (dir && !existsSync8(dir)) {
5292
- mkdirSync5(dir, { recursive: true });
5293
- }
5294
- }
5295
- function getDb() {
5296
- if (instance)
5297
- return instance;
5298
- const dbPath = getDbPath();
5299
- const isNew = dbPath === ":memory:" || !existsSync8(dbPath);
5300
- ensureDir(dbPath);
5301
- instance = new Database(dbPath);
5302
- instance.exec("PRAGMA journal_mode=WAL");
5303
- instance.exec("PRAGMA foreign_keys=ON");
5304
- runMigrations(instance);
5305
- runRetention(instance);
5306
- instance.exec(`CREATE TABLE IF NOT EXISTS feedback (
5307
- id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
5308
- message TEXT NOT NULL,
5309
- email TEXT,
5310
- category TEXT DEFAULT 'general',
5311
- version TEXT,
5312
- machine_id TEXT,
5313
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
5314
- )`);
5315
- if (isNew) {
5316
- runLegacyImport(instance);
5317
- }
5318
- return instance;
5319
- }
5320
- function closeDb() {
5321
- if (instance) {
5322
- instance.close();
5323
- instance = null;
5603
+ function ensureProfilesDir() {
5604
+ if (!existsSync9(PROFILES_DIR)) {
5605
+ mkdirSync6(PROFILES_DIR, { recursive: true });
5324
5606
  }
5325
5607
  }
5326
- function createTestDb() {
5327
- const db = new Database(":memory:");
5328
- db.exec("PRAGMA journal_mode=WAL");
5329
- db.exec("PRAGMA foreign_keys=ON");
5330
- return db;
5331
- }
5332
- var instance = null;
5333
- var init_db = __esm(() => {
5334
- init_migrations();
5335
- init_legacy_import();
5336
- });
5337
-
5338
- // src/lib/store.ts
5339
- var exports_store = {};
5340
- __export(exports_store, {
5341
- writeLock: () => writeLock,
5342
- verifyScriptHash: () => verifyScriptHash,
5343
- upsertHookRecord: () => upsertHookRecord,
5344
- sha256Of: () => sha256Of,
5345
- sha256File: () => sha256File,
5346
- setPinnedHook: () => setPinnedHook,
5347
- retrustHook: () => retrustHook,
5348
- removePinnedHook: () => removePinnedHook,
5349
- removeHookRecord: () => removeHookRecord,
5350
- readLock: () => readLock,
5351
- listHookRecords: () => listHookRecords,
5352
- getPinnedHook: () => getPinnedHook,
5353
- getHookRecord: () => getHookRecord,
5354
- checkScriptHash: () => checkScriptHash
5355
- });
5356
- import { createHash } from "crypto";
5357
- import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
5358
- import { readFile as readFile2 } from "fs/promises";
5359
- function sha256Of(input) {
5360
- return createHash("sha256").update(input).digest("hex");
5361
- }
5362
- async function sha256File(path) {
5363
- const buf = await readFile2(path);
5364
- return sha256Of(buf);
5365
- }
5366
- function getHookRecord(db, name) {
5367
- return db.query("SELECT * FROM hooks WHERE name = ?").get(name);
5368
- }
5369
- function listHookRecords(db) {
5370
- return db.query("SELECT * FROM hooks ORDER BY name").all();
5608
+ function profilePath(id) {
5609
+ return join8(PROFILES_DIR, `${id}.json`);
5371
5610
  }
5372
- function upsertHookRecord(db, record) {
5373
- const existing = getHookRecord(db, record.name);
5374
- const id = existing?.id ?? record.name;
5375
- const installedAt = existing?.installed_at ?? new Date().toISOString();
5376
- const enabled = record.enabled ?? existing?.enabled ?? 1;
5377
- db.run(`INSERT INTO hooks (id, name, version, sha256, source_type, source_ref, installed_at, enabled, last_verified_at)
5378
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
5379
- ON CONFLICT(id) DO UPDATE SET
5380
- version = excluded.version,
5381
- sha256 = excluded.sha256,
5382
- source_type = excluded.source_type,
5383
- source_ref = excluded.source_ref,
5384
- enabled = excluded.enabled,
5385
- last_verified_at = excluded.last_verified_at`, [
5386
- id,
5387
- record.name,
5388
- record.version,
5389
- record.sha256,
5390
- record.source_type,
5391
- record.source_ref ?? existing?.source_ref ?? null,
5392
- installedAt,
5393
- enabled,
5394
- record.last_verified_at ?? null
5395
- ]);
5611
+ function shortUuid() {
5612
+ return crypto.randomUUID().slice(0, 8);
5396
5613
  }
5397
- function removeHookRecord(db, name) {
5398
- const res = db.run("DELETE FROM hooks WHERE name = ?", [name]);
5399
- return res.changes > 0;
5614
+ function createProfile(input) {
5615
+ ensureProfilesDir();
5616
+ const id = shortUuid();
5617
+ const now2 = new Date().toISOString();
5618
+ const profile = {
5619
+ agent_id: id,
5620
+ agent_type: input.agent_type,
5621
+ created_at: now2,
5622
+ last_seen_at: now2,
5623
+ preferences: {}
5624
+ };
5625
+ if (input.name) {
5626
+ profile.name = input.name;
5627
+ }
5628
+ writeFileSync5(profilePath(id), JSON.stringify(profile, null, 2) + `
5629
+ `);
5630
+ return profile;
5400
5631
  }
5401
- function readLock() {
5632
+ function getProfile(id) {
5633
+ const path = profilePath(id);
5402
5634
  try {
5403
- const path = getLockPath();
5404
5635
  if (!existsSync9(path))
5405
- return { hooks: {} };
5406
- const parsed = JSON.parse(readFileSync6(path, "utf-8"));
5407
- return { hooks: parsed.hooks ?? {} };
5636
+ return null;
5637
+ return JSON.parse(readFileSync6(path, "utf-8"));
5408
5638
  } catch {
5409
- return { hooks: {} };
5639
+ return null;
5410
5640
  }
5411
5641
  }
5412
- function writeLock(lock) {
5413
- const path = getLockPath();
5414
- mkdirSync6(path.substring(0, path.lastIndexOf("/")) || ".", { recursive: true });
5415
- const sorted = {};
5416
- for (const name of Object.keys(lock.hooks).sort()) {
5417
- sorted[name] = lock.hooks[name];
5642
+ function listProfiles() {
5643
+ if (!existsSync9(PROFILES_DIR))
5644
+ return [];
5645
+ try {
5646
+ const files = readdirSync3(PROFILES_DIR).filter((f) => f.endsWith(".json"));
5647
+ const profiles = [];
5648
+ for (const file of files) {
5649
+ try {
5650
+ const content = readFileSync6(join8(PROFILES_DIR, file), "utf-8");
5651
+ profiles.push(JSON.parse(content));
5652
+ } catch {}
5653
+ }
5654
+ return profiles.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
5655
+ } catch {
5656
+ return [];
5418
5657
  }
5419
- writeFileSync5(path, JSON.stringify({ hooks: sorted }, null, 2) + `
5420
- `, "utf-8");
5421
- return path;
5422
5658
  }
5423
- function setPinnedHook(name, entry) {
5424
- const lock = readLock();
5425
- lock.hooks[name] = entry;
5426
- return writeLock(lock);
5427
- }
5428
- function getPinnedHook(name) {
5429
- return readLock().hooks[name];
5659
+ function touchProfile(id) {
5660
+ const profile = getProfile(id);
5661
+ if (!profile)
5662
+ return;
5663
+ profile.last_seen_at = new Date().toISOString();
5664
+ writeFileSync5(profilePath(id), JSON.stringify(profile, null, 2) + `
5665
+ `);
5430
5666
  }
5431
- function removePinnedHook(name) {
5432
- const lock = readLock();
5433
- if (!(name in lock.hooks))
5434
- return false;
5435
- delete lock.hooks[name];
5436
- writeLock(lock);
5437
- return true;
5667
+ function exportProfiles() {
5668
+ return listProfiles();
5438
5669
  }
5439
- function checkScriptHash(name, actual) {
5440
- const db = getDb();
5441
- const record = getHookRecord(db, name);
5442
- const pin = getPinnedHook(name);
5443
- const expected = record?.sha256 ?? pin?.sha256 ?? null;
5444
- if (expected !== null && expected !== actual) {
5445
- return { ok: false, pinned: true, expected, actual, name };
5446
- }
5447
- if (expected === null) {
5448
- const now3 = new Date().toISOString();
5449
- const version = pin?.version ?? record?.version ?? "0.0.0";
5450
- const source = pin?.source ?? record?.source_type ?? "local";
5451
- upsertHookRecord(db, {
5452
- name,
5453
- version,
5454
- sha256: actual,
5455
- source_type: source,
5456
- last_verified_at: now3
5457
- });
5458
- setPinnedHook(name, { version, sha256: actual, source });
5459
- return { ok: true, pinned: false, expected: null, actual, name };
5670
+ function importProfiles(profiles) {
5671
+ ensureProfilesDir();
5672
+ let imported = 0;
5673
+ let skipped = 0;
5674
+ for (const profile of profiles) {
5675
+ if (!profile.agent_id || !profile.agent_type) {
5676
+ skipped++;
5677
+ continue;
5678
+ }
5679
+ const path = profilePath(profile.agent_id);
5680
+ if (existsSync9(path)) {
5681
+ skipped++;
5682
+ continue;
5683
+ }
5684
+ writeFileSync5(path, JSON.stringify(profile, null, 2) + `
5685
+ `);
5686
+ imported++;
5460
5687
  }
5461
- const now2 = new Date().toISOString();
5462
- upsertHookRecord(db, {
5463
- name,
5464
- version: record?.version ?? pin?.version ?? "0.0.0",
5465
- sha256: expected,
5466
- source_type: record?.source_type ?? pin?.source ?? "local",
5467
- last_verified_at: now2
5468
- });
5469
- return { ok: true, pinned: true, expected, actual, name };
5688
+ return { imported, skipped };
5689
+ }
5690
+ var PROFILES_DIR;
5691
+ var init_profiles = __esm(() => {
5692
+ PROFILES_DIR = resolveProfilesDir();
5693
+ });
5694
+
5695
+ // src/lib/db-writer.ts
5696
+ var exports_db_writer = {};
5697
+ __export(exports_db_writer, {
5698
+ writeHookEvent: () => writeHookEvent,
5699
+ resolveEventType: () => resolveEventType,
5700
+ recordHookRun: () => recordHookRun,
5701
+ normalizeEventType: () => normalizeEventType
5702
+ });
5703
+ function nanoid2() {
5704
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 21);
5470
5705
  }
5471
- async function verifyScriptHash(name, scriptPath) {
5472
- return checkScriptHash(name, await sha256File(scriptPath));
5706
+ function writeHookEvent(event) {
5707
+ try {
5708
+ const db = getDb();
5709
+ const id = nanoid2();
5710
+ const timestamp = event.timestamp ?? new Date().toISOString();
5711
+ db.run(`INSERT INTO hook_events
5712
+ (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
5713
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
5714
+ id,
5715
+ timestamp,
5716
+ event.session_id,
5717
+ event.hook_name,
5718
+ event.event_type,
5719
+ event.tool_name ?? null,
5720
+ event.tool_input ? event.tool_input.slice(0, 500) : null,
5721
+ event.result ?? null,
5722
+ event.error ?? null,
5723
+ event.duration_ms ?? null,
5724
+ event.project_dir ?? null,
5725
+ event.metadata ?? null
5726
+ ]);
5727
+ } catch (err) {
5728
+ process.stderr.write(`[hooks db-writer] failed to write event: ${err}
5729
+ `);
5730
+ }
5473
5731
  }
5474
- function retrustHook(name, scriptPath, version, source) {
5475
- const actual = sha256Of(readFileSync6(scriptPath));
5476
- const db = getDb();
5477
- upsertHookRecord(db, {
5478
- name,
5479
- version,
5480
- sha256: actual,
5481
- source_type: source,
5482
- last_verified_at: new Date().toISOString()
5732
+ function normalizeEventType(value) {
5733
+ if (typeof value !== "string")
5734
+ return null;
5735
+ if (ALLOWED_EVENT_TYPES.has(value))
5736
+ return value;
5737
+ const bare = value.split(":")[0] ?? "";
5738
+ return ALLOWED_EVENT_TYPES.has(bare) ? bare : null;
5739
+ }
5740
+ function resolveEventType(inputEvent, fallbackEvent) {
5741
+ const fromInput = normalizeEventType(inputEvent);
5742
+ if (fromInput)
5743
+ return fromInput;
5744
+ return normalizeEventType(fallbackEvent ?? null);
5745
+ }
5746
+ function recordHookRun(record) {
5747
+ const eventType = record.eventType ? normalizeEventType(record.eventType) : null;
5748
+ if (!eventType) {
5749
+ process.stderr.write(`[hooks db-writer] skipped event for '${record.hookName}': unsupported event type '${String(record.eventType)}'
5750
+ `);
5751
+ return;
5752
+ }
5753
+ const metadata = {
5754
+ ...record.metadata ?? {},
5755
+ version: record.version ?? null,
5756
+ sha256: record.sha256 ?? null,
5757
+ exit_code: record.exitCode
5758
+ };
5759
+ writeHookEvent({
5760
+ session_id: record.sessionId ?? "cli",
5761
+ hook_name: record.hookName,
5762
+ event_type: eventType,
5763
+ tool_name: record.toolName ?? null,
5764
+ tool_input: record.toolInput !== undefined ? JSON.stringify(record.toolInput) : null,
5765
+ result: record.result ?? null,
5766
+ error: record.error ?? null,
5767
+ duration_ms: record.durationMs,
5768
+ project_dir: record.projectDir ?? null,
5769
+ metadata: JSON.stringify(metadata)
5483
5770
  });
5484
- setPinnedHook(name, { version, sha256: actual, source });
5485
- return { ok: true, pinned: true, expected: actual, actual, name };
5486
5771
  }
5487
- var init_store = __esm(() => {
5772
+ var ALLOWED_EVENT_TYPES;
5773
+ var init_db_writer = __esm(() => {
5488
5774
  init_db();
5489
- init_config();
5775
+ ALLOWED_EVENT_TYPES = new Set([
5776
+ "PreToolUse",
5777
+ "PostToolUse",
5778
+ "Stop",
5779
+ "Notification",
5780
+ "SessionStart",
5781
+ "SessionEnd",
5782
+ "UserPromptSubmit",
5783
+ "SubagentStart"
5784
+ ]);
5490
5785
  });
5491
5786
 
5492
5787
  // src/lib/run.ts
5493
5788
  var exports_run = {};
5494
5789
  __export(exports_run, {
5495
- executeVerifiedScript: () => executeVerifiedScript
5790
+ executeVerifiedScript: () => executeVerifiedScript,
5791
+ HookTimeoutError: () => HookTimeoutError
5496
5792
  });
5497
5793
  import { randomBytes } from "crypto";
5498
- import { rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
5794
+ import { rmSync as rmSync3, writeFileSync as writeFileSync6 } from "fs";
5499
5795
  import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join9 } from "path";
5500
5796
  function extensionOf(scriptPath) {
5501
5797
  const base = basename2(scriptPath);
@@ -5532,33 +5828,96 @@ function interpreterFor(name, scriptPath, content) {
5532
5828
  return { command: ["bun", "run"], tempExt: ext };
5533
5829
  throw new Error(`Refusing to run hook '${name}': unsupported script extension '.${ext}' (supported: .sh .bash .ts .tsx .js .jsx .mjs .cjs .mts .cts, or a recognized shebang)`);
5534
5830
  }
5831
+ async function readPipeWithDeadline(stream, deadlineMs) {
5832
+ const reader = stream.getReader();
5833
+ const chunks = [];
5834
+ const deadline = Date.now() + deadlineMs;
5835
+ try {
5836
+ while (true) {
5837
+ const remaining = deadline - Date.now();
5838
+ if (remaining <= 0)
5839
+ break;
5840
+ let result;
5841
+ try {
5842
+ result = await Promise.race([
5843
+ reader.read(),
5844
+ new Promise((_, reject) => {
5845
+ const t = setTimeout(() => reject(new Error("pipe-drain-deadline")), remaining);
5846
+ t.unref?.();
5847
+ })
5848
+ ]);
5849
+ } catch {
5850
+ break;
5851
+ }
5852
+ if (result.done)
5853
+ break;
5854
+ chunks.push(new TextDecoder().decode(result.value));
5855
+ }
5856
+ } finally {
5857
+ try {
5858
+ await reader.cancel();
5859
+ } catch {}
5860
+ }
5861
+ return chunks.join("");
5862
+ }
5535
5863
  async function executeVerifiedScript(options) {
5536
5864
  const scriptDir = dirname4(options.scriptPath);
5537
5865
  const interpreter = interpreterFor(options.name, options.scriptPath, options.content);
5538
5866
  const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes(12).toString("hex")}.${interpreter.tempExt}`);
5867
+ let proc = null;
5868
+ let timedOut = false;
5869
+ const timeoutTimer = options.timeout ? setTimeout(() => {
5870
+ timedOut = true;
5871
+ killGroup(proc);
5872
+ }, options.timeout) : null;
5539
5873
  try {
5540
5874
  writeFileSync6(tempPath, options.content, { flag: "wx", mode: 384 });
5541
- const proc = Bun.spawn([...interpreter.command, isAbsolute2(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
5875
+ proc = Bun.spawn([...interpreter.command, isAbsolute2(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
5542
5876
  stdin: new Response(options.stdin),
5543
5877
  stdout: "pipe",
5544
5878
  stderr: "pipe",
5545
5879
  env: options.env ?? process.env,
5546
- ...options.timeout ? { timeout: options.timeout } : {}
5880
+ detached: true
5547
5881
  });
5548
- const [stdoutText, stderrText, exitCode] = await Promise.all([
5549
- new Response(proc.stdout).text(),
5550
- new Response(proc.stderr).text(),
5551
- proc.exited
5882
+ const exitCode = await proc.exited;
5883
+ if (timedOut) {
5884
+ throw new HookTimeoutError(options.timeout);
5885
+ }
5886
+ const [stdoutText, stderrText] = await Promise.all([
5887
+ readPipeWithDeadline(proc.stdout, 750),
5888
+ readPipeWithDeadline(proc.stderr, 750)
5552
5889
  ]);
5890
+ killGroup(proc);
5553
5891
  return { stdout: stdoutText, stderr: stderrText, exitCode };
5554
5892
  } finally {
5893
+ if (timeoutTimer)
5894
+ clearTimeout(timeoutTimer);
5895
+ try {
5896
+ rmSync3(tempPath, { force: true });
5897
+ } catch {}
5898
+ }
5899
+ }
5900
+ function killGroup(proc) {
5901
+ if (!proc)
5902
+ return;
5903
+ try {
5904
+ process.kill(-proc.pid, "SIGKILL");
5905
+ } catch {
5555
5906
  try {
5556
- rmSync2(tempPath, { force: true });
5907
+ process.kill(proc.pid, "SIGKILL");
5557
5908
  } catch {}
5558
5909
  }
5559
5910
  }
5560
- var BASH_EXTENSIONS, BUN_EXTENSIONS;
5911
+ var HookTimeoutError, BASH_EXTENSIONS, BUN_EXTENSIONS;
5561
5912
  var init_run = __esm(() => {
5913
+ HookTimeoutError = class HookTimeoutError extends Error {
5914
+ timeoutMs;
5915
+ constructor(timeoutMs) {
5916
+ super(`Hook timed out after ${timeoutMs}ms (process group killed)`);
5917
+ this.name = "HookTimeoutError";
5918
+ this.timeoutMs = timeoutMs;
5919
+ }
5920
+ };
5562
5921
  BASH_EXTENSIONS = new Set(["sh", "bash"]);
5563
5922
  BUN_EXTENSIONS = new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"]);
5564
5923
  });
@@ -5570,7 +5929,7 @@ __export(exports_custom_install, {
5570
5929
  installCustomSource: () => installCustomSource,
5571
5930
  ensureCustomDir: () => ensureCustomDir
5572
5931
  });
5573
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
5932
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync4 } from "fs";
5574
5933
  import { join as join10 } from "path";
5575
5934
  import { tmpdir } from "os";
5576
5935
  function manifestAt(dir) {
@@ -5608,7 +5967,7 @@ async function cloneToTemp(gitUrl) {
5608
5967
  proc.exited
5609
5968
  ]);
5610
5969
  if (exitCode !== 0) {
5611
- rmSync3(tmp, { recursive: true, force: true });
5970
+ rmSync4(tmp, { recursive: true, force: true });
5612
5971
  throw new Error(`git clone failed (${exitCode}): ${stderr.trim()}`);
5613
5972
  }
5614
5973
  return join10(tmp, "repo");
@@ -5623,58 +5982,275 @@ function isCustomSource(value) {
5623
5982
  return true;
5624
5983
  return existsSync10(value);
5625
5984
  }
5626
- async function installCustomSource(source) {
5627
- if (isGitUrl(source)) {
5628
- const repoDir = await cloneToTemp(source);
5629
- try {
5630
- return copyManifestDir(repoDir, source, "git");
5631
- } finally {
5632
- rmSync3(repoDir, { recursive: true, force: true });
5633
- }
5985
+ async function installCustomSource(source) {
5986
+ if (isGitUrl(source)) {
5987
+ const repoDir = await cloneToTemp(source);
5988
+ try {
5989
+ return copyManifestDir(repoDir, source, "git");
5990
+ } finally {
5991
+ rmSync4(repoDir, { recursive: true, force: true });
5992
+ }
5993
+ }
5994
+ if (source.startsWith("http://") || source.startsWith("https://")) {
5995
+ return installManifestUrl(source);
5996
+ }
5997
+ if (!existsSync10(source)) {
5998
+ throw new Error(`No such local path: ${source}`);
5999
+ }
6000
+ if (source.endsWith("manifest.json")) {
6001
+ const manifest = parseManifest(readFileSync7(source, "utf-8"));
6002
+ return writeFromManifest(manifest, source.substring(0, source.lastIndexOf("/")), source, "local");
6003
+ }
6004
+ return copyManifestDir(source, source, "local");
6005
+ }
6006
+ async function installManifestUrl(url) {
6007
+ const res = await fetch(url, { redirect: "follow" });
6008
+ if (!res.ok) {
6009
+ throw new Error(`Failed to fetch manifest (${res.status}): ${url}`);
6010
+ }
6011
+ const manifest = parseManifest(await res.text());
6012
+ let script;
6013
+ if (manifest.script.includes(`
6014
+ `)) {
6015
+ script = { path: "script.ts", content: manifest.script };
6016
+ } else {
6017
+ const base = url.substring(0, url.lastIndexOf("/"));
6018
+ const scriptUrl = `${base}/${manifest.script}`;
6019
+ const scriptRes = await fetch(scriptUrl, { redirect: "follow" });
6020
+ if (!scriptRes.ok) {
6021
+ throw new Error(`Failed to fetch script (${scriptRes.status}): ${scriptUrl}`);
6022
+ }
6023
+ script = { path: manifest.script, content: await scriptRes.text() };
6024
+ }
6025
+ const name = shortManifestName(manifest.name);
6026
+ const { dir, scriptPath } = writeCustomHook(name, manifest, script.content, script.path);
6027
+ return { name, version: manifest.version, kind: "url", source: url, dir, scriptPath };
6028
+ }
6029
+ function ensureCustomDir() {
6030
+ const dir = getCustomHooksDir();
6031
+ mkdirSync7(dir, { recursive: true });
6032
+ return dir;
6033
+ }
6034
+ var init_custom_install = __esm(() => {
6035
+ init_config();
6036
+ init_manifest();
6037
+ });
6038
+
6039
+ // src/lib/sync.ts
6040
+ var exports_sync = {};
6041
+ __export(exports_sync, {
6042
+ syncHooks: () => syncHooks,
6043
+ planSync: () => planSync,
6044
+ fetchPinnedHook: () => fetchPinnedHook
6045
+ });
6046
+ import { createHash as createHash2 } from "crypto";
6047
+ import { readFileSync as readFileSync8 } from "fs";
6048
+ function sha256OfText(text) {
6049
+ return createHash2("sha256").update(text).digest("hex");
6050
+ }
6051
+ function sha256OfPath(path) {
6052
+ return createHash2("sha256").update(readFileSync8(path)).digest("hex");
6053
+ }
6054
+ function collectBundledCatalog() {
6055
+ const out = [];
6056
+ for (const meta of HOOKS) {
6057
+ const scriptPath = resolveScriptPath(meta.name);
6058
+ if (!scriptPath)
6059
+ continue;
6060
+ out.push({
6061
+ name: meta.name,
6062
+ version: meta.version,
6063
+ sha256: sha256OfPath(scriptPath)
6064
+ });
6065
+ }
6066
+ return out;
6067
+ }
6068
+ async function fetchJson(base, path) {
6069
+ const headers = { accept: "application/json" };
6070
+ const apiKey = resolveApiKey();
6071
+ if (apiKey)
6072
+ headers["x-api-key"] = apiKey;
6073
+ const res = await fetch(`${base}${path}`, {
6074
+ headers,
6075
+ redirect: "error",
6076
+ signal: AbortSignal.timeout(15000)
6077
+ });
6078
+ if (res.status === 401) {
6079
+ throw new Error("registry requires API key \u2014 set HASNA_HOOKS_API_KEY or HOOKS_API_KEY (vault-delivered, never stored)");
6080
+ }
6081
+ if (!res.ok) {
6082
+ throw new Error(`GET ${path} failed with status ${res.status}`);
6083
+ }
6084
+ return res.json();
6085
+ }
6086
+ async function fetchRemoteState(apiUrl) {
6087
+ const catalog = await fetchJson(apiUrl, "/api/v1/catalog");
6088
+ const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
6089
+ if (!catalog.hooks || !Array.isArray(catalog.hooks)) {
6090
+ throw new Error("remote catalog is malformed: missing hooks array");
6091
+ }
6092
+ if (!remoteLock.hooks || typeof remoteLock.hooks !== "object") {
6093
+ throw new Error("remote lock is malformed: missing hooks map");
6094
+ }
6095
+ return { catalog: catalog.hooks, remoteLock };
6096
+ }
6097
+ function computeDiff(catalog, remoteLock) {
6098
+ const local = readLock();
6099
+ const diff = { added: [], updated: [], unchanged: [], skipped: [] };
6100
+ for (const entry of catalog) {
6101
+ if (!entry.name || !entry.version || !entry.sha256)
6102
+ continue;
6103
+ const pin = local.hooks[entry.name];
6104
+ const remotePin = remoteLock ? remoteLock.hooks[entry.name] : entry;
6105
+ if (remoteLock && !remotePin) {
6106
+ diff.skipped.push(entry.name);
6107
+ continue;
6108
+ }
6109
+ const pinnedLocally = pin && pin.version === entry.version && pin.sha256 === entry.sha256;
6110
+ if (pinnedLocally) {
6111
+ diff.unchanged.push(entry.name);
6112
+ } else if (pin) {
6113
+ diff.updated.push(entry.name);
6114
+ } else {
6115
+ diff.added.push(entry.name);
6116
+ }
6117
+ }
6118
+ return diff;
6119
+ }
6120
+ async function planSync() {
6121
+ const apiUrl = resolveApiUrl();
6122
+ if (apiUrl) {
6123
+ const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6124
+ return { apiUrl, dryRun: false, diff: computeDiff(catalog, remoteLock) };
6125
+ }
6126
+ const diff = computeDiff(collectBundledCatalog(), null);
6127
+ return { apiUrl: null, dryRun: false, diff };
6128
+ }
6129
+ async function syncHooks(options = {}) {
6130
+ const apiUrl = resolveApiUrl();
6131
+ const plan = await planSync();
6132
+ if (options.dryRun)
6133
+ return { ...plan, dryRun: true };
6134
+ if (!apiUrl) {
6135
+ const db2 = getDb();
6136
+ for (const entry of collectBundledCatalog()) {
6137
+ setPinnedHook(entry.name, { version: entry.version, sha256: entry.sha256, source: "bundled" });
6138
+ upsertHookRecord(db2, {
6139
+ name: entry.name,
6140
+ version: entry.version,
6141
+ sha256: entry.sha256,
6142
+ source_type: "bundled",
6143
+ source_ref: "apps/hooks/hooks",
6144
+ last_verified_at: new Date().toISOString()
6145
+ });
6146
+ }
6147
+ return plan;
6148
+ }
6149
+ const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6150
+ const diff = computeDiff(catalog, remoteLock);
6151
+ const toFetch = [...diff.added, ...diff.updated];
6152
+ const artifacts = new Map;
6153
+ for (const name of toFetch) {
6154
+ const entry = remoteLock.hooks[name];
6155
+ if (!entry)
6156
+ continue;
6157
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${entry.version}`);
6158
+ if (!artifact.manifest || typeof artifact.script !== "string") {
6159
+ throw new Error(`artifact for '${name}@${entry.version}' is malformed`);
6160
+ }
6161
+ const actualSha = sha256OfText(artifact.script);
6162
+ if (actualSha !== entry.sha256) {
6163
+ throw new Error(`sha256 mismatch for '${name}@${entry.version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
6164
+ }
6165
+ artifacts.set(name, artifact);
6166
+ }
6167
+ const db = getDb();
6168
+ for (const name of toFetch) {
6169
+ const artifact = artifacts.get(name);
6170
+ const entry = remoteLock.hooks[name];
6171
+ if (!artifact || !entry)
6172
+ continue;
6173
+ const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6174
+ const scriptRel = artifact.manifest.script.includes(`
6175
+ `) ? "script.ts" : artifact.manifest.script;
6176
+ writeCustomHook(name, manifest, artifact.script, scriptRel);
6177
+ const scriptPath = resolveScriptPath(name);
6178
+ const actual = await sha256File(scriptPath);
6179
+ if (actual !== entry.sha256) {
6180
+ throw new Error(`sha256 mismatch after write for '${name}@${entry.version}': lock says ${entry.sha256}, on disk ${actual}`);
6181
+ }
6182
+ setPinnedHook(name, { version: entry.version, sha256: entry.sha256, source: entry.source ?? "remote" });
6183
+ upsertHookRecord(db, {
6184
+ name,
6185
+ version: entry.version,
6186
+ sha256: entry.sha256,
6187
+ source_type: entry.source ?? "remote",
6188
+ source_ref: apiUrl,
6189
+ last_verified_at: new Date().toISOString()
6190
+ });
6191
+ }
6192
+ return { apiUrl, dryRun: false, diff };
6193
+ }
6194
+ async function fetchPinnedHook(name, version, apiUrl) {
6195
+ const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
6196
+ const entry = remoteLock.hooks?.[name];
6197
+ if (!entry) {
6198
+ throw new Error(`Hook '${name}' is not in the remote registry lock`);
5634
6199
  }
5635
- if (source.startsWith("http://") || source.startsWith("https://")) {
5636
- return installManifestUrl(source);
6200
+ if (entry.version !== version) {
6201
+ throw new Error(`Hook '${name}' version ${version} is not in the remote registry (available: ${entry.version})`);
5637
6202
  }
5638
- if (!existsSync10(source)) {
5639
- throw new Error(`No such local path: ${source}`);
6203
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${version}`);
6204
+ if (!artifact.manifest || typeof artifact.script !== "string") {
6205
+ throw new Error(`artifact for '${name}@${version}' is malformed`);
5640
6206
  }
5641
- if (source.endsWith("manifest.json")) {
5642
- const manifest = parseManifest(readFileSync7(source, "utf-8"));
5643
- return writeFromManifest(manifest, source.substring(0, source.lastIndexOf("/")), source, "local");
6207
+ const manifestName = shortManifestName(artifact.manifest.name ?? name);
6208
+ if (manifestName !== name) {
6209
+ throw new Error(`artifact for '${name}@${version}' declares a different hook name ('${manifestName}')`);
5644
6210
  }
5645
- return copyManifestDir(source, source, "local");
5646
- }
5647
- async function installManifestUrl(url) {
5648
- const res = await fetch(url, { redirect: "follow" });
5649
- if (!res.ok) {
5650
- throw new Error(`Failed to fetch manifest (${res.status}): ${url}`);
6211
+ if (artifact.manifest.version !== version) {
6212
+ throw new Error(`artifact for '${name}@${version}' declares a different version ('${artifact.manifest.version}')`);
5651
6213
  }
5652
- const manifest = parseManifest(await res.text());
5653
- let script;
5654
- if (manifest.script.includes(`
5655
- `)) {
5656
- script = { path: "script.ts", content: manifest.script };
5657
- } else {
5658
- const base = url.substring(0, url.lastIndexOf("/"));
5659
- const scriptUrl = `${base}/${manifest.script}`;
5660
- const scriptRes = await fetch(scriptUrl, { redirect: "follow" });
5661
- if (!scriptRes.ok) {
5662
- throw new Error(`Failed to fetch script (${scriptRes.status}): ${scriptUrl}`);
5663
- }
5664
- script = { path: manifest.script, content: await scriptRes.text() };
6214
+ const actualSha = sha256OfText(artifact.script);
6215
+ if (actualSha !== entry.sha256) {
6216
+ throw new Error(`sha256 mismatch for '${name}@${version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
5665
6217
  }
5666
- const name = shortManifestName(manifest.name);
5667
- const { dir, scriptPath } = writeCustomHook(name, manifest, script.content, script.path);
5668
- return { name, version: manifest.version, kind: "url", source: url, dir, scriptPath };
5669
- }
5670
- function ensureCustomDir() {
5671
- const dir = getCustomHooksDir();
5672
- mkdirSync7(dir, { recursive: true });
5673
- return dir;
6218
+ const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6219
+ const scriptRel = artifact.manifest.script.includes(`
6220
+ `) ? "script.ts" : artifact.manifest.script;
6221
+ writeCustomHook(name, manifest, artifact.script, scriptRel);
6222
+ const scriptPath = resolveScriptPath(name);
6223
+ const actual = await sha256File(scriptPath);
6224
+ if (actual !== entry.sha256) {
6225
+ throw new Error(`sha256 mismatch after write for '${name}@${version}': lock says ${entry.sha256}, on disk ${actual}`);
6226
+ }
6227
+ const db = getDb();
6228
+ setPinnedHook(name, { version, sha256: entry.sha256, source: entry.source ?? "remote" });
6229
+ upsertHookRecord(db, {
6230
+ name,
6231
+ version,
6232
+ sha256: entry.sha256,
6233
+ source_type: entry.source ?? "remote",
6234
+ source_ref: apiUrl,
6235
+ last_verified_at: new Date().toISOString()
6236
+ });
6237
+ return {
6238
+ name,
6239
+ version,
6240
+ sha256: entry.sha256,
6241
+ source: entry.source ?? "remote",
6242
+ source_ref: apiUrl,
6243
+ artifact,
6244
+ scriptPath
6245
+ };
5674
6246
  }
5675
- var init_custom_install = __esm(() => {
6247
+ var init_sync = __esm(() => {
6248
+ init_registry();
5676
6249
  init_config();
6250
+ init_db();
6251
+ init_store();
5677
6252
  init_manifest();
6253
+ init_resolve();
5678
6254
  });
5679
6255
 
5680
6256
  // src/db/pg-migrations.ts
@@ -6184,234 +6760,75 @@ function authorized(req, apiKey) {
6184
6760
  return secureEqual(header.slice("Bearer ".length), apiKey);
6185
6761
  return secureEqual(req.headers.get("x-api-key") ?? "", apiKey);
6186
6762
  }
6187
- function json(body, status = 200, headers = {}) {
6188
- return new Response(JSON.stringify(body), {
6189
- status,
6190
- headers: { "content-type": "application/json", ...headers }
6191
- });
6192
- }
6193
- function handleServeRequest(req, apiKey) {
6194
- const url = new URL(req.url);
6195
- if (url.pathname === "/health" && req.method === "GET") {
6196
- return Promise.resolve(json({ status: "ok", name: SERVE_SERVICE_NAME }));
6197
- }
6198
- if (url.pathname === "/api/v1/catalog" && req.method === "GET") {
6199
- return buildCatalog().then((catalog) => json({ hooks: catalog }));
6200
- }
6201
- if (url.pathname === "/api/v1/lock" && req.method === "GET") {
6202
- return Promise.resolve(json(readLock()));
6203
- }
6204
- const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/(\d+\.\d+\.\d+)$/.exec(url.pathname);
6205
- if (artifactMatch && req.method === "GET") {
6206
- const [, name, version] = artifactMatch;
6207
- return artifactFor(name, version).then(async (artifact) => {
6208
- if (!artifact)
6209
- return json({ error: `Hook '${name}@${version}' not found locally` }, 404);
6210
- const resolved = resolveHook(name);
6211
- const sha = resolved ? await sha256File(resolved.scriptPath) : artifact.manifest.name;
6212
- return json(artifact, 200, { "x-hook-sha256": sha });
6213
- });
6214
- }
6215
- if (url.pathname === "/api/v1/hooks" && req.method === "PUT") {
6216
- if (!authorized(req, apiKey)) {
6217
- return Promise.resolve(json({ error: "unauthorized: valid API key required to publish" }, 401));
6218
- }
6219
- return req.json().then((body) => {
6220
- const name = body.name ?? "";
6221
- const version = body.version ?? "";
6222
- const resolved = resolveHook(name);
6223
- if (!resolved)
6224
- return json({ error: `Hook '${name}' not found in local store` }, 404);
6225
- if (version && version !== resolved.version) {
6226
- return json({ error: `Version mismatch: local '${name}' is ${resolved.version}, requested ${version}` }, 409);
6227
- }
6228
- const check = retrustHook(name, resolved.scriptPath, resolved.version, "serve");
6229
- return json({ ok: true, hook: { name, version: resolved.version, sha256: check.actual } });
6230
- }, () => json({ error: "invalid JSON body" }, 400));
6231
- }
6232
- return Promise.resolve(json({ error: "Not Found" }, 404));
6233
- }
6234
- function startServeServer(options) {
6235
- const port = options.port ?? DEFAULT_SERVE_PORT;
6236
- const host = options.host ?? SERVE_HOST;
6237
- const apiKey = resolveApiKey(options.apiKey);
6238
- const server = Bun.serve({
6239
- hostname: host,
6240
- port,
6241
- async fetch(req) {
6242
- return handleServeRequest(req, apiKey);
6243
- }
6244
- });
6245
- console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);
6246
- return server;
6247
- }
6248
- var DEFAULT_SERVE_PORT = 39428, SERVE_HOST = "127.0.0.1", SERVE_SERVICE_NAME = "hooks-registry";
6249
- var init_serve = __esm(() => {
6250
- init_registry();
6251
- init_manifest();
6252
- init_store();
6253
- init_resolve();
6254
- init_config();
6255
- if (false) {}
6256
- });
6257
-
6258
- // src/lib/sync.ts
6259
- var exports_sync = {};
6260
- __export(exports_sync, {
6261
- syncHooks: () => syncHooks,
6262
- planSync: () => planSync
6263
- });
6264
- import { createHash as createHash2 } from "crypto";
6265
- import { readFileSync as readFileSync8 } from "fs";
6266
- function sha256OfText(text) {
6267
- return createHash2("sha256").update(text).digest("hex");
6268
- }
6269
- function sha256OfPath(path) {
6270
- return createHash2("sha256").update(readFileSync8(path)).digest("hex");
6271
- }
6272
- function collectBundledCatalog() {
6273
- const out = [];
6274
- for (const meta of HOOKS) {
6275
- const scriptPath = resolveScriptPath(meta.name);
6276
- if (!scriptPath)
6277
- continue;
6278
- out.push({
6279
- name: meta.name,
6280
- version: meta.version,
6281
- sha256: sha256OfPath(scriptPath)
6282
- });
6283
- }
6284
- return out;
6285
- }
6286
- async function fetchJson(base, path) {
6287
- const headers = { accept: "application/json" };
6288
- const apiKey = resolveApiKey();
6289
- if (apiKey)
6290
- headers["x-api-key"] = apiKey;
6291
- const res = await fetch(`${base}${path}`, {
6292
- headers,
6293
- signal: AbortSignal.timeout(15000)
6294
- });
6295
- if (res.status === 401) {
6296
- throw new Error("registry requires API key \u2014 set HASNA_HOOKS_API_KEY or HOOKS_API_KEY (vault-delivered, never stored)");
6297
- }
6298
- if (!res.ok) {
6299
- throw new Error(`GET ${path} failed with status ${res.status}`);
6300
- }
6301
- return res.json();
6302
- }
6303
- async function fetchRemoteState(apiUrl) {
6304
- const catalog = await fetchJson(apiUrl, "/api/v1/catalog");
6305
- const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
6306
- if (!catalog.hooks || !Array.isArray(catalog.hooks)) {
6307
- throw new Error("remote catalog is malformed: missing hooks array");
6308
- }
6309
- if (!remoteLock.hooks || typeof remoteLock.hooks !== "object") {
6310
- throw new Error("remote lock is malformed: missing hooks map");
6311
- }
6312
- return { catalog: catalog.hooks, remoteLock };
6313
- }
6314
- function computeDiff(catalog, remoteLock) {
6315
- const local = readLock();
6316
- const diff = { added: [], updated: [], unchanged: [], skipped: [] };
6317
- for (const entry of catalog) {
6318
- if (!entry.name || !entry.version || !entry.sha256)
6319
- continue;
6320
- const pin = local.hooks[entry.name];
6321
- const remotePin = remoteLock ? remoteLock.hooks[entry.name] : entry;
6322
- if (remoteLock && !remotePin) {
6323
- diff.skipped.push(entry.name);
6324
- continue;
6325
- }
6326
- const pinnedLocally = pin && pin.version === entry.version && pin.sha256 === entry.sha256;
6327
- if (pinnedLocally) {
6328
- diff.unchanged.push(entry.name);
6329
- } else if (pin) {
6330
- diff.updated.push(entry.name);
6331
- } else {
6332
- diff.added.push(entry.name);
6333
- }
6334
- }
6335
- return diff;
6336
- }
6337
- async function planSync() {
6338
- const apiUrl = resolveApiUrl();
6339
- if (apiUrl) {
6340
- const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6341
- return { apiUrl, dryRun: false, diff: computeDiff(catalog, remoteLock) };
6342
- }
6343
- const diff = computeDiff(collectBundledCatalog(), null);
6344
- return { apiUrl: null, dryRun: false, diff };
6345
- }
6346
- async function syncHooks(options = {}) {
6347
- const apiUrl = resolveApiUrl();
6348
- const plan = await planSync();
6349
- if (options.dryRun)
6350
- return { ...plan, dryRun: true };
6351
- if (!apiUrl) {
6352
- const db2 = getDb();
6353
- for (const entry of collectBundledCatalog()) {
6354
- setPinnedHook(entry.name, { version: entry.version, sha256: entry.sha256, source: "bundled" });
6355
- upsertHookRecord(db2, {
6356
- name: entry.name,
6357
- version: entry.version,
6358
- sha256: entry.sha256,
6359
- source_type: "bundled",
6360
- source_ref: "apps/hooks/hooks",
6361
- last_verified_at: new Date().toISOString()
6362
- });
6363
- }
6364
- return plan;
6365
- }
6366
- const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6367
- const diff = computeDiff(catalog, remoteLock);
6368
- const toFetch = [...diff.added, ...diff.updated];
6369
- const artifacts = new Map;
6370
- for (const name of toFetch) {
6371
- const entry = remoteLock.hooks[name];
6372
- if (!entry)
6373
- continue;
6374
- const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${entry.version}`);
6375
- if (!artifact.manifest || typeof artifact.script !== "string") {
6376
- throw new Error(`artifact for '${name}@${entry.version}' is malformed`);
6377
- }
6378
- const actualSha = sha256OfText(artifact.script);
6379
- if (actualSha !== entry.sha256) {
6380
- throw new Error(`sha256 mismatch for '${name}@${entry.version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
6381
- }
6382
- artifacts.set(name, artifact);
6763
+ function json(body, status = 200, headers = {}) {
6764
+ return new Response(JSON.stringify(body), {
6765
+ status,
6766
+ headers: { "content-type": "application/json", ...headers }
6767
+ });
6768
+ }
6769
+ function handleServeRequest(req, apiKey) {
6770
+ const url = new URL(req.url);
6771
+ if (url.pathname === "/health" && req.method === "GET") {
6772
+ return Promise.resolve(json({ status: "ok", name: SERVE_SERVICE_NAME }));
6383
6773
  }
6384
- const db = getDb();
6385
- for (const name of toFetch) {
6386
- const artifact = artifacts.get(name);
6387
- const entry = remoteLock.hooks[name];
6388
- if (!artifact || !entry)
6389
- continue;
6390
- const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6391
- const scriptRel = artifact.manifest.script.includes(`
6392
- `) ? "script.ts" : artifact.manifest.script;
6393
- writeCustomHook(name, manifest, artifact.script, scriptRel);
6394
- const scriptPath = resolveScriptPath(name);
6395
- const actual = await sha256File(scriptPath);
6396
- setPinnedHook(name, { version: entry.version, sha256: actual, source: entry.source ?? "remote" });
6397
- upsertHookRecord(db, {
6398
- name,
6399
- version: entry.version,
6400
- sha256: actual,
6401
- source_type: entry.source ?? "remote",
6402
- source_ref: apiUrl,
6403
- last_verified_at: new Date().toISOString()
6774
+ if (url.pathname === "/api/v1/catalog" && req.method === "GET") {
6775
+ return buildCatalog().then((catalog) => json({ hooks: catalog }));
6776
+ }
6777
+ if (url.pathname === "/api/v1/lock" && req.method === "GET") {
6778
+ return Promise.resolve(json(readLock()));
6779
+ }
6780
+ const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/(\d+\.\d+\.\d+)$/.exec(url.pathname);
6781
+ if (artifactMatch && req.method === "GET") {
6782
+ const [, name, version] = artifactMatch;
6783
+ return artifactFor(name, version).then(async (artifact) => {
6784
+ if (!artifact)
6785
+ return json({ error: `Hook '${name}@${version}' not found locally` }, 404);
6786
+ const resolved = resolveHook(name);
6787
+ const sha = resolved ? await sha256File(resolved.scriptPath) : artifact.manifest.name;
6788
+ return json(artifact, 200, { "x-hook-sha256": sha });
6404
6789
  });
6405
6790
  }
6406
- return { apiUrl, dryRun: false, diff };
6791
+ if (url.pathname === "/api/v1/hooks" && req.method === "PUT") {
6792
+ if (!authorized(req, apiKey)) {
6793
+ return Promise.resolve(json({ error: "unauthorized: valid API key required to publish" }, 401));
6794
+ }
6795
+ return req.json().then((body) => {
6796
+ const name = body.name ?? "";
6797
+ const version = body.version ?? "";
6798
+ const resolved = resolveHook(name);
6799
+ if (!resolved)
6800
+ return json({ error: `Hook '${name}' not found in local store` }, 404);
6801
+ if (version && version !== resolved.version) {
6802
+ return json({ error: `Version mismatch: local '${name}' is ${resolved.version}, requested ${version}` }, 409);
6803
+ }
6804
+ const check = retrustHook(name, resolved.scriptPath, resolved.version, "serve");
6805
+ return json({ ok: true, hook: { name, version: resolved.version, sha256: check.actual } });
6806
+ }, () => json({ error: "invalid JSON body" }, 400));
6807
+ }
6808
+ return Promise.resolve(json({ error: "Not Found" }, 404));
6407
6809
  }
6408
- var init_sync = __esm(() => {
6810
+ function startServeServer(options) {
6811
+ const port = options.port ?? DEFAULT_SERVE_PORT;
6812
+ const host = options.host ?? SERVE_HOST;
6813
+ const apiKey = resolveApiKey(options.apiKey);
6814
+ const server = Bun.serve({
6815
+ hostname: host,
6816
+ port,
6817
+ async fetch(req) {
6818
+ return handleServeRequest(req, apiKey);
6819
+ }
6820
+ });
6821
+ console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);
6822
+ return server;
6823
+ }
6824
+ var DEFAULT_SERVE_PORT = 39428, SERVE_HOST = "127.0.0.1", SERVE_SERVICE_NAME = "hooks-registry";
6825
+ var init_serve = __esm(() => {
6409
6826
  init_registry();
6410
- init_config();
6411
- init_db();
6412
- init_store();
6413
6827
  init_manifest();
6828
+ init_store();
6414
6829
  init_resolve();
6830
+ init_config();
6831
+ if (false) {}
6415
6832
  });
6416
6833
 
6417
6834
  // src/cf/provision.ts
@@ -6511,14 +6928,77 @@ import { createServer } from "http";
6511
6928
  import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
6512
6929
  import { join as join11, dirname as dirname5 } from "path";
6513
6930
  import { fileURLToPath as fileURLToPath3 } from "url";
6514
- async function runVerifiedHook(name, scriptPath, stdin, timeoutMs) {
6515
- const content = readFileSync9(scriptPath);
6516
- const check = checkScriptHash(name, sha256Of(content));
6931
+ async function runVerifiedHook(name, stdin, timeoutMs) {
6932
+ const { resolveHook: resolveHook2, resolveScriptPath: resolveScriptPath2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
6933
+ const resolved = resolveHook2(name);
6934
+ const hookScript = resolveScriptPath2(name);
6935
+ if (!resolved || !hookScript || !existsSync11(hookScript)) {
6936
+ throw new Error(`Hook '${name}' not found`);
6937
+ }
6938
+ const content = readFileSync9(hookScript);
6939
+ const sha = sha256Of(content);
6940
+ const check = checkScriptHash(name, sha);
6517
6941
  if (!check.ok) {
6518
6942
  throw new Error(`Hook '${name}' script changed since it was trusted (sha256 ${check.expected} != ${check.actual}). Run 'hooks trust ${name}' to trust the new content.`);
6519
6943
  }
6520
- const { executeVerifiedScript: executeVerifiedScript2 } = await Promise.resolve().then(() => (init_run(), exports_run));
6521
- return executeVerifiedScript2({ name, scriptPath, content, stdin, env: process.env, timeout: timeoutMs });
6944
+ const { executeVerifiedScript: executeVerifiedScript2, HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
6945
+ const started = Date.now();
6946
+ let ran;
6947
+ try {
6948
+ ran = await executeVerifiedScript2({ name, scriptPath: hookScript, content, stdin, env: process.env, timeout: timeoutMs });
6949
+ } catch (err) {
6950
+ try {
6951
+ const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
6952
+ let inputJson = {};
6953
+ try {
6954
+ inputJson = JSON.parse(stdin);
6955
+ } catch {}
6956
+ recordHookRun2({
6957
+ hookName: name,
6958
+ eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
6959
+ version: resolved.version,
6960
+ sha256: sha,
6961
+ sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
6962
+ toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
6963
+ toolInput: inputJson.tool_input,
6964
+ error: err instanceof Error ? err.message.slice(0, 500) : String(err),
6965
+ exitCode: -1,
6966
+ durationMs: Date.now() - started,
6967
+ projectDir: process.cwd()
6968
+ });
6969
+ } catch {}
6970
+ if (err instanceof HookTimeoutError2)
6971
+ throw err;
6972
+ throw err;
6973
+ }
6974
+ const durationMs = Date.now() - started;
6975
+ try {
6976
+ const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
6977
+ let inputJson = {};
6978
+ try {
6979
+ inputJson = JSON.parse(stdin);
6980
+ } catch {}
6981
+ let outputJson = {};
6982
+ try {
6983
+ outputJson = JSON.parse(ran.stdout);
6984
+ } catch {}
6985
+ const blocked = outputJson.decision === "block" || outputJson.continue === false;
6986
+ recordHookRun2({
6987
+ hookName: name,
6988
+ eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
6989
+ version: resolved.version,
6990
+ sha256: sha,
6991
+ sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
6992
+ toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
6993
+ toolInput: inputJson.tool_input,
6994
+ result: blocked ? "block" : "continue",
6995
+ error: ran.exitCode !== 0 ? (ran.stderr || `hook exited with code ${ran.exitCode}`).slice(0, 500) : null,
6996
+ exitCode: ran.exitCode,
6997
+ durationMs,
6998
+ projectDir: process.cwd()
6999
+ });
7000
+ } catch {}
7001
+ return ran;
6522
7002
  }
6523
7003
  function formatInstallResults(results, extra) {
6524
7004
  const installed = results.filter((r) => r.success).map((r) => r.hook);
@@ -6800,17 +7280,14 @@ function createHooksServer() {
6800
7280
  name: z2.string().describe("Hook name (e.g. 'gitguard', 'checkpoint')"),
6801
7281
  input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Hook input as JSON object (HookInput)"),
6802
7282
  profile: z2.string().optional().describe("Agent profile ID to inject into hook input"),
6803
- timeout_ms: z2.number().default(1e4).describe("Timeout in milliseconds (default: 10000)")
7283
+ timeout_ms: z2.number().int().positive().max(600000).optional().describe("Timeout in milliseconds (default: the hook's manifest timeout_ms or 10000)")
6804
7284
  }, async ({ name, input, profile, timeout_ms }) => {
6805
- const meta = getHook(name);
6806
- if (!meta) {
7285
+ const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
7286
+ const resolved = resolveHook2(name);
7287
+ if (!resolved) {
6807
7288
  return { content: [{ type: "text", text: JSON.stringify({ error: `Hook '${name}' not found` }) }] };
6808
7289
  }
6809
- const hookDir = getHookPath(name);
6810
- const hookScript = join11(hookDir, "src", "hook.ts");
6811
- if (!existsSync11(hookScript)) {
6812
- return { content: [{ type: "text", text: JSON.stringify({ error: `Hook script not found: ${hookScript}` }) }] };
6813
- }
7290
+ const effectiveTimeout = timeout_ms ?? resolved.timeoutMs ?? 1e4;
6814
7291
  let hookInput = { ...input };
6815
7292
  if (profile) {
6816
7293
  const p = getProfile(profile);
@@ -6823,13 +7300,38 @@ function createHooksServer() {
6823
7300
  };
6824
7301
  }
6825
7302
  }
6826
- let ran = null;
6827
7303
  try {
6828
- ran = await Promise.race([
6829
- runVerifiedHook(name, hookScript, JSON.stringify(hookInput), timeout_ms),
6830
- new Promise((resolve2) => setTimeout(() => resolve2(null), timeout_ms))
6831
- ]);
7304
+ const ran = await runVerifiedHook(name, JSON.stringify(hookInput), effectiveTimeout);
7305
+ const stdoutText = ran.stdout;
7306
+ const stderrText = ran.stderr;
7307
+ let output = {};
7308
+ try {
7309
+ output = JSON.parse(stdoutText);
7310
+ } catch {
7311
+ output = stdoutText ? { raw: stdoutText } : {};
7312
+ }
7313
+ return {
7314
+ content: [{
7315
+ type: "text",
7316
+ text: JSON.stringify({
7317
+ hook: name,
7318
+ output,
7319
+ stderr: stderrText || undefined,
7320
+ exitCode: ran.exitCode,
7321
+ timeout_ms: effectiveTimeout
7322
+ })
7323
+ }]
7324
+ };
6832
7325
  } catch (err) {
7326
+ const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
7327
+ if (err instanceof HookTimeoutError2) {
7328
+ return {
7329
+ content: [{
7330
+ type: "text",
7331
+ text: JSON.stringify({ hook: name, timedOut: true, timeout_ms: effectiveTimeout, error: err.message })
7332
+ }]
7333
+ };
7334
+ }
6833
7335
  return {
6834
7336
  content: [{
6835
7337
  type: "text",
@@ -6837,27 +7339,6 @@ function createHooksServer() {
6837
7339
  }]
6838
7340
  };
6839
7341
  }
6840
- const timedOut = ran === null;
6841
- const stdoutText = ran?.stdout ?? "";
6842
- const stderrText = ran?.stderr ?? "";
6843
- let output = {};
6844
- try {
6845
- output = JSON.parse(stdoutText);
6846
- } catch {
6847
- output = stdoutText ? { raw: stdoutText } : {};
6848
- }
6849
- return {
6850
- content: [{
6851
- type: "text",
6852
- text: JSON.stringify({
6853
- hook: name,
6854
- output,
6855
- stderr: stderrText || undefined,
6856
- exitCode: ran?.exitCode ?? -1,
6857
- ...timedOut ? { timedOut: true, timeout_ms } : {}
6858
- })
6859
- }]
6860
- };
6861
7342
  });
6862
7343
  defineTool("hooks_update", "Re-register installed hooks to pick up new package version (reinstalls with overwrite)", {
6863
7344
  hooks: z2.array(z2.string()).optional().describe("Hook names to update (omit to update all installed hooks)"),
@@ -6918,11 +7399,11 @@ function createHooksServer() {
6918
7399
  tool_name: z2.string().describe("Tool name to simulate (e.g. 'Bash', 'Write', 'Edit')"),
6919
7400
  tool_input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Tool input to pass to matching hooks"),
6920
7401
  scope: z2.enum(["global", "project"]).default("global").describe("Scope to check"),
6921
- timeout_ms: z2.number().default(5000).describe("Per-hook timeout in milliseconds")
7402
+ timeout_ms: z2.number().int().positive().max(600000).optional().describe("Per-hook timeout in milliseconds (default: the hook's manifest timeout_ms or 5000)")
6922
7403
  }, async ({ tool_name, tool_input, scope, timeout_ms }) => {
6923
7404
  const registered = getRegisteredHooks(scope);
6924
7405
  const matchingHooks = registered.filter((name) => {
6925
- const meta = getHook(name);
7406
+ const meta = resolveHookMeta(name) ?? getHook(name);
6926
7407
  if (!meta || meta.event !== "PreToolUse")
6927
7408
  return false;
6928
7409
  if (!meta.matcher)
@@ -6938,26 +7419,23 @@ function createHooksServer() {
6938
7419
  }
6939
7420
  const input = { tool_name, tool_input };
6940
7421
  const results = await Promise.all(matchingHooks.map(async (name) => {
6941
- const hookDir = getHookPath(name);
6942
- const hookScript = join11(hookDir, "src", "hook.ts");
6943
- if (!existsSync11(hookScript))
6944
- return { name, decision: "approve", error: "script not found" };
6945
- let ran = null;
7422
+ const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
7423
+ const resolved = resolveHook2(name);
7424
+ const effectiveTimeout = timeout_ms ?? resolved?.timeoutMs ?? 5000;
6946
7425
  try {
6947
- ran = await Promise.race([
6948
- runVerifiedHook(name, hookScript, JSON.stringify(input), timeout_ms),
6949
- new Promise((r) => setTimeout(() => r(null), timeout_ms))
6950
- ]);
7426
+ const ran = await runVerifiedHook(name, JSON.stringify(input), effectiveTimeout);
7427
+ let output = {};
7428
+ try {
7429
+ output = JSON.parse(ran.stdout);
7430
+ } catch {}
7431
+ return { name, decision: output.decision ?? "approve", reason: output.reason, raw: output };
6951
7432
  } catch (err) {
7433
+ const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
7434
+ if (err instanceof HookTimeoutError2) {
7435
+ return { name, decision: "approve", timedOut: true, timeout_ms: effectiveTimeout };
7436
+ }
6952
7437
  return { name, decision: "block", error: err instanceof Error ? err.message : String(err) };
6953
7438
  }
6954
- if (ran === null)
6955
- return { name, decision: "approve", timedOut: true };
6956
- let output = {};
6957
- try {
6958
- output = JSON.parse(ran.stdout);
6959
- } catch {}
6960
- return { name, decision: output.decision ?? "approve", reason: output.reason, raw: output };
6961
7439
  }));
6962
7440
  const blocked = results.find((r) => r.decision === "block");
6963
7441
  return {
@@ -6997,33 +7475,31 @@ function createHooksServer() {
6997
7475
  name: z2.string().describe("Hook name"),
6998
7476
  input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Hook input JSON")
6999
7477
  })).describe("List of hooks to run with their inputs"),
7000
- timeout_ms: z2.number().default(1e4).describe("Per-hook timeout in milliseconds")
7478
+ timeout_ms: z2.number().int().positive().max(600000).optional().describe("Per-hook timeout in milliseconds (default: the hook's manifest timeout_ms or 10000)")
7001
7479
  }, async ({ hooks, timeout_ms }) => {
7002
7480
  const results = await Promise.all(hooks.map(async ({ name, input }) => {
7003
- const meta = getHook(name);
7004
- if (!meta)
7481
+ const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
7482
+ const resolved = resolveHook2(name);
7483
+ if (!resolved)
7005
7484
  return { name, error: `Hook '${name}' not found` };
7006
- const hookScript = join11(getHookPath(name), "src", "hook.ts");
7007
- if (!existsSync11(hookScript))
7008
- return { name, error: "script not found" };
7009
- let ran = null;
7485
+ const effectiveTimeout = timeout_ms ?? resolved.timeoutMs ?? 1e4;
7010
7486
  try {
7011
- ran = await Promise.race([
7012
- runVerifiedHook(name, hookScript, JSON.stringify(input), timeout_ms),
7013
- new Promise((r) => setTimeout(() => r(null), timeout_ms))
7014
- ]);
7487
+ const ran = await runVerifiedHook(name, JSON.stringify(input), effectiveTimeout);
7488
+ const stdoutText = ran.stdout;
7489
+ let output = {};
7490
+ try {
7491
+ output = JSON.parse(stdoutText);
7492
+ } catch {
7493
+ output = stdoutText ? { raw: stdoutText } : {};
7494
+ }
7495
+ return { name, output, exitCode: ran.exitCode, timeout_ms: effectiveTimeout };
7015
7496
  } catch (err) {
7497
+ const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
7498
+ if (err instanceof HookTimeoutError2) {
7499
+ return { name, error: err.message, timedOut: true, timeout_ms: effectiveTimeout };
7500
+ }
7016
7501
  return { name, error: err instanceof Error ? err.message : String(err) };
7017
7502
  }
7018
- const stdoutText = ran?.stdout ?? "";
7019
- const timedOut = ran === null;
7020
- let output = {};
7021
- try {
7022
- output = JSON.parse(stdoutText);
7023
- } catch {
7024
- output = stdoutText ? { raw: stdoutText } : {};
7025
- }
7026
- return { name, output, exitCode: ran?.exitCode ?? -1, ...timedOut ? { timedOut: true } : {} };
7027
7503
  }));
7028
7504
  return { content: [{ type: "text", text: JSON.stringify({ results, count: results.length }) }] };
7029
7505
  });
@@ -8653,7 +9129,7 @@ var {
8653
9129
 
8654
9130
  // src/cli/index.tsx
8655
9131
  import chalk2 from "chalk";
8656
- import { existsSync as existsSync12, readFileSync as readFileSync10 } from "fs";
9132
+ import { existsSync as existsSync12, readFileSync as readFileSync10, rmSync as rmSync5 } from "fs";
8657
9133
  import { join as join12, dirname as dirname6 } from "path";
8658
9134
  import { fileURLToPath as fileURLToPath4 } from "url";
8659
9135
 
@@ -9832,6 +10308,7 @@ init_installer();
9832
10308
  init_profiles();
9833
10309
  init_manifest();
9834
10310
  init_resolve();
10311
+ init_store();
9835
10312
  import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
9836
10313
  var __dirname4 = dirname6(fileURLToPath4(import.meta.url));
9837
10314
  var pkgPath = existsSync12(join12(__dirname4, "..", "package.json")) ? join12(__dirname4, "..", "package.json") : join12(__dirname4, "..", "..", "package.json");
@@ -9884,6 +10361,22 @@ function hookSummaryLine(hook, options = {}) {
9884
10361
  const description = options.verbose ? ` - ${truncateText2(hook.description, 110)}` : "";
9885
10362
  return ` ${chalk2.cyan(hook.name.padEnd(17))} ${chalk2.dim(`[${hook.event}${matcher}]`)} ${chalk2.dim(hook.category)}${description}`;
9886
10363
  }
10364
+ function listStoreHooks() {
10365
+ const out = [];
10366
+ for (const parsed of listCustomHooks()) {
10367
+ const name = parsed.manifest.name;
10368
+ const pin = getPinnedHook(name);
10369
+ const source = pin?.source === "remote" || pin?.source === "registry" ? "registry" : "custom";
10370
+ const meta = resolveHookMeta(name);
10371
+ if (!meta)
10372
+ continue;
10373
+ out.push({
10374
+ meta: { ...meta, version: pin?.version ?? meta.version },
10375
+ source
10376
+ });
10377
+ }
10378
+ return out.sort((a, b) => a.meta.name.localeCompare(b.meta.name));
10379
+ }
9887
10380
  function printDisclosureHint(hidden, detailCommand, options = {}) {
9888
10381
  const rowControls = options.includeAll ? "--limit, --all, --verbose" : "--limit, --verbose";
9889
10382
  if (hidden > 0) {
@@ -9924,24 +10417,23 @@ program2.command("init").description("Register a new agent profile with a unique
9924
10417
  console.log(chalk2.red(message));
9925
10418
  return;
9926
10419
  }
9927
- const config = { api_url: options.apiUrl.replace(/\/+$/, ""), api_key_ref: options.apiKey };
10420
+ const apiKeyRef = options.apiKey ?? "hasna/hooks/live/api-key";
10421
+ const config = { api_url: options.apiUrl.replace(/\/+$/, ""), api_key_ref: apiKeyRef };
9928
10422
  const path = writeConfig2(config);
9929
10423
  if (options.json) {
9930
- console.log(JSON.stringify({ ok: true, api_url: config.api_url, api_key_ref: config.api_key_ref ?? null, config_path: path }));
10424
+ console.log(JSON.stringify({ ok: true, api_url: config.api_url, api_key_ref: config.api_key_ref, config_path: path }));
9931
10425
  return;
9932
10426
  }
9933
10427
  console.log(chalk2.green(`
9934
10428
  \u2713 Remote registry configured
9935
10429
  `));
9936
10430
  console.log(` ${chalk2.dim("API URL:")} ${config.api_url}`);
9937
- console.log(` ${chalk2.dim("API key ref:")} ${config.api_key_ref ?? "(none; serve will refuse publishes)"}`);
10431
+ console.log(` ${chalk2.dim("API key ref:")} ${config.api_key_ref}`);
9938
10432
  console.log(` ${chalk2.dim("Config:")} ${path}`);
9939
10433
  console.log();
9940
10434
  console.log(chalk2.dim(" Presence of an API URL selects the remote registry; absence means local."));
9941
- if (config.api_key_ref) {
9942
- console.log(chalk2.dim(` Run the server with the key resolved from the vault, never the value:`));
9943
- console.log(` secrets exec ${config.api_key_ref} --as HASNA_HOOKS_API_KEY -- hooks serve`);
9944
- }
10435
+ console.log(chalk2.dim(` Run the server with the key resolved from the vault, never the value:`));
10436
+ console.log(` secrets exec ${config.api_key_ref} --as HASNA_HOOKS_API_KEY -- hooks serve`);
9945
10437
  console.log();
9946
10438
  return;
9947
10439
  }
@@ -9977,6 +10469,7 @@ program2.command("init").description("Register a new agent profile with a unique
9977
10469
  program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>", "Agent profile ID").description("Execute a hook (called by AI coding agents)").action(async (hook, options) => {
9978
10470
  const { resolveHook: resolveHook2, resolveScriptPath: resolveScriptPath2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
9979
10471
  const { sha256Of: sha256Of2, checkScriptHash: checkScriptHash2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10472
+ const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
9980
10473
  const resolved = resolveHook2(hook);
9981
10474
  if (!resolved) {
9982
10475
  console.error(JSON.stringify({ error: `Hook '${hook}' not found` }));
@@ -9988,7 +10481,8 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
9988
10481
  process.exit(1);
9989
10482
  }
9990
10483
  const content = readFileSync10(hookScript);
9991
- const check = checkScriptHash2(hook, sha256Of2(content));
10484
+ const sha = sha256Of2(content);
10485
+ const check = checkScriptHash2(hook, sha);
9992
10486
  if (!check.ok) {
9993
10487
  console.error(JSON.stringify({
9994
10488
  error: `Hook '${hook}' script changed since it was trusted (sha256 ${check.expected} != ${check.actual}). Run 'hooks trust ${hook}' to trust the new content.`,
@@ -10017,18 +10511,74 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
10017
10511
  }
10018
10512
  }
10019
10513
  const { readCustomManifest: readCustomManifest2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
10020
- const { executeVerifiedScript: executeVerifiedScript2 } = await Promise.resolve().then(() => (init_run(), exports_run));
10514
+ const { executeVerifiedScript: executeVerifiedScript2, HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
10021
10515
  const custom = readCustomManifest2(hook);
10022
10516
  const args = custom?.manifest.args ?? [];
10023
10517
  const timeout = custom?.manifest.timeout_ms;
10024
- const { stdout, stderr, exitCode } = await executeVerifiedScript2({
10025
- name: hook,
10026
- scriptPath: hookScript,
10027
- content,
10028
- args,
10029
- stdin: hookStdin,
10030
- env: process.env,
10031
- timeout
10518
+ const started = Date.now();
10519
+ let stdout = "";
10520
+ let stderr = "";
10521
+ let exitCode = 0;
10522
+ try {
10523
+ ({ stdout, stderr, exitCode } = await executeVerifiedScript2({
10524
+ name: hook,
10525
+ scriptPath: hookScript,
10526
+ content,
10527
+ args,
10528
+ stdin: hookStdin,
10529
+ env: process.env,
10530
+ timeout
10531
+ }));
10532
+ } catch (err) {
10533
+ if (err instanceof HookTimeoutError2) {
10534
+ try {
10535
+ const { recordHookRun: recordHookRun3, resolveEventType: resolveEventType3 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
10536
+ let inputJson2 = {};
10537
+ try {
10538
+ inputJson2 = JSON.parse(stdin);
10539
+ } catch {}
10540
+ recordHookRun3({
10541
+ hookName: hook,
10542
+ eventType: resolveEventType3(inputJson2.hook_event_name, resolved.events[0] ?? "PostToolUse"),
10543
+ version: resolved.version,
10544
+ sha256: sha,
10545
+ sessionId: typeof inputJson2.session_id === "string" ? inputJson2.session_id : null,
10546
+ toolName: typeof inputJson2.tool_name === "string" ? inputJson2.tool_name : null,
10547
+ toolInput: inputJson2.tool_input,
10548
+ error: err.message.slice(0, 500),
10549
+ exitCode: -1,
10550
+ durationMs: Date.now() - started,
10551
+ projectDir: process.cwd()
10552
+ });
10553
+ } catch {}
10554
+ console.error(JSON.stringify({ error: err.message, hook, timedOut: true, timeout_ms: timeout ?? null }));
10555
+ process.exit(1);
10556
+ }
10557
+ throw err;
10558
+ }
10559
+ const durationMs = Date.now() - started;
10560
+ let inputJson = {};
10561
+ try {
10562
+ inputJson = JSON.parse(stdin);
10563
+ } catch {}
10564
+ let outputJson = {};
10565
+ try {
10566
+ outputJson = JSON.parse(stdout);
10567
+ } catch {}
10568
+ const blocked = outputJson.decision === "block" || outputJson.continue === false;
10569
+ recordHookRun2({
10570
+ hookName: hook,
10571
+ eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
10572
+ version: resolved.version,
10573
+ sha256: sha,
10574
+ sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
10575
+ toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
10576
+ toolInput: inputJson.tool_input,
10577
+ result: blocked ? "block" : "continue",
10578
+ error: exitCode !== 0 ? (stderr || `hook exited with code ${exitCode}`).slice(0, 500) : null,
10579
+ exitCode,
10580
+ durationMs,
10581
+ projectDir: process.cwd()
10032
10582
  });
10033
10583
  if (stdout)
10034
10584
  process.stdout.write(stdout);
@@ -10036,7 +10586,7 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
10036
10586
  process.stderr.write(stderr);
10037
10587
  process.exit(exitCode);
10038
10588
  });
10039
- 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, codewith, 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("--apply-codewith", "Explicitly append Codewith TOML to a config file (prefer open-configs for managed configs)", false).option("--codewith-config <path>", "Explicit Codewith config path required with --apply-codewith").option("-j, --json", "Output as JSON", false).description("Install one or more hooks (registry names, local paths, git URLs, or manifest URLs)").action(async (hooks, options) => {
10589
+ 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, codewith, 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("--apply-codewith", "Explicitly append Codewith TOML to a config file (prefer open-configs for managed configs)", false).option("--codewith-config <path>", "Explicit Codewith config path required with --apply-codewith").option("-j, --json", "Output as JSON", false).description("Install one or more hooks (registry names, local paths, git URLs, manifest URLs, or <name>@<version> pinned installs)").action(async (hooks, options) => {
10040
10590
  const scope = resolveScope(options);
10041
10591
  const target = resolveTarget(options);
10042
10592
  let toInstall = hooks;
@@ -10069,15 +10619,35 @@ program2.command("install").alias("add").argument("[hooks...]", "Hooks to instal
10069
10619
  return;
10070
10620
  }
10071
10621
  const { isCustomSource: isCustomSource2, installCustomSource: installCustomSource2 } = await Promise.resolve().then(() => (init_custom_install(), exports_custom_install));
10622
+ const { readCustomManifest: readCustomManifest2, HOOK_NAME_RE: HOOK_NAME_RE2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
10623
+ const { resolveApiUrl: resolveApiUrl2 } = await Promise.resolve().then(() => (init_config(), exports_config));
10624
+ const { sha256Of: sha256Of2, pinInstalledHook: pinInstalledHook2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10625
+ const { readFileSync: readFileSyncFs } = await import("fs");
10626
+ function parseNameVersion(arg) {
10627
+ if (isCustomSource2(arg))
10628
+ return null;
10629
+ const at = arg.lastIndexOf("@");
10630
+ if (at <= 0 || at === arg.length - 1)
10631
+ return null;
10632
+ const name = arg.slice(0, at);
10633
+ const version = arg.slice(at + 1);
10634
+ if (!HOOK_NAME_RE2.test(name))
10635
+ return null;
10636
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version))
10637
+ return null;
10638
+ return { name, version };
10639
+ }
10640
+ const pinnedRequests = toInstall.map((arg) => ({ arg, pinned: parseNameVersion(arg) })).filter((x) => x.pinned !== null);
10072
10641
  const customSources = toInstall.filter((n) => !getHook(n) && isCustomSource2(n));
10073
10642
  if (options.dryRun) {
10074
10643
  const known = toInstall.filter((n) => getHook(n));
10075
- const unknown = toInstall.filter((n) => !getHook(n) && !isCustomSource2(n));
10644
+ const unknown = toInstall.filter((n) => !getHook(n) && !isCustomSource2(n) && !parseNameVersion(n) && !readCustomManifest2(n));
10076
10645
  if (options.json) {
10077
10646
  console.log(JSON.stringify({
10078
10647
  dryRun: true,
10079
10648
  would_install: known,
10080
10649
  custom_sources: customSources,
10650
+ pinned: pinnedRequests.map((p) => `${p.pinned.name}@${p.pinned.version}`),
10081
10651
  unknown,
10082
10652
  scope,
10083
10653
  target,
@@ -10095,6 +10665,9 @@ Dry run \u2014 would install (${scope}, ${target}):
10095
10665
  for (const source of customSources) {
10096
10666
  console.log(chalk2.cyan(` ${source}`) + chalk2.dim(" [custom source: would fetch and install]"));
10097
10667
  }
10668
+ for (const p of pinnedRequests) {
10669
+ console.log(chalk2.cyan(` ${p.pinned.name}@${p.pinned.version}`) + chalk2.dim(" [pinned registry install]"));
10670
+ }
10098
10671
  if (unknown.length > 0) {
10099
10672
  console.log();
10100
10673
  for (const name of unknown) {
@@ -10109,9 +10682,21 @@ Dry run \u2014 would install (${scope}, ${target}):
10109
10682
  for (const source of customSources) {
10110
10683
  try {
10111
10684
  const custom = await installCustomSource2(source);
10685
+ try {
10686
+ const scriptBytes = readFileSyncFs(custom.scriptPath);
10687
+ pinInstalledHook2(custom.name, custom.version, sha256Of2(scriptBytes), "custom", source);
10688
+ } catch (pinError) {
10689
+ const { customHookDir: customHookDir2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
10690
+ const { removeHookFromStore: removeHookFromStore2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10691
+ try {
10692
+ rmSync5(customHookDir2(custom.name), { recursive: true, force: true });
10693
+ } catch {}
10694
+ removeHookFromStore2(custom.name);
10695
+ throw pinError;
10696
+ }
10112
10697
  installedCustom.push(custom.name);
10113
10698
  if (options.json) {
10114
- console.log(JSON.stringify({ custom: { source, name: custom.name, version: custom.version } }));
10699
+ console.log(JSON.stringify({ custom: { source, name: custom.name, version: custom.version, pinned: true } }));
10115
10700
  } else {
10116
10701
  console.log(chalk2.green(`\u2713 Installed custom hook '${custom.name}' v${custom.version} from ${source}`));
10117
10702
  }
@@ -10125,9 +10710,37 @@ Dry run \u2014 would install (${scope}, ${target}):
10125
10710
  }
10126
10711
  }
10127
10712
  }
10128
- const namesToRegister = [...new Set([...toInstall.filter((n) => !isCustomSource2(n)), ...installedCustom])];
10713
+ const installedPinned = [];
10714
+ for (const { arg, pinned } of pinnedRequests) {
10715
+ try {
10716
+ const apiUrl = resolveApiUrl2();
10717
+ if (!apiUrl)
10718
+ throw new Error(`Cannot install '${arg}': no remote registry configured (set api_url or HASNA_HOOKS_API_URL)`);
10719
+ const { fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
10720
+ const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, apiUrl);
10721
+ installedPinned.push(pinnedInstall.name);
10722
+ if (options.json) {
10723
+ console.log(JSON.stringify({ pinned: { request: arg, name: pinnedInstall.name, version: pinnedInstall.version, sha256: pinnedInstall.sha256, source: pinnedInstall.source } }));
10724
+ } else {
10725
+ console.log(chalk2.green(`\u2713 Installed '${pinnedInstall.name}' v${pinnedInstall.version} from registry (sha256 ${pinnedInstall.sha256.slice(0, 12)}\u2026)`));
10726
+ }
10727
+ } catch (error) {
10728
+ const message = error instanceof Error ? error.message : String(error);
10729
+ results.push({ hook: arg, success: false, error: message });
10730
+ if (options.json) {
10731
+ console.log(JSON.stringify({ error: `Pinned install failed for ${arg}: ${message}` }));
10732
+ } else {
10733
+ console.log(chalk2.red(`\u2717 ${message}`));
10734
+ }
10735
+ }
10736
+ }
10737
+ const namesToRegister = [...new Set([
10738
+ ...toInstall.filter((n) => !isCustomSource2(n) && !parseNameVersion(n)),
10739
+ ...installedCustom,
10740
+ ...installedPinned
10741
+ ])];
10129
10742
  for (const name of namesToRegister) {
10130
- if (!getHook(name) && !installedCustom.includes(name)) {
10743
+ if (!getHook(name) && !installedCustom.includes(name) && !installedPinned.includes(name) && !readCustomManifest2(name)) {
10131
10744
  const suggestions = suggestHooks(name);
10132
10745
  const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
10133
10746
  results.push({ hook: name, success: false, error: `Hook '${name}' not found${hint}` });
@@ -10154,8 +10767,12 @@ Dry run \u2014 would install (${scope}, ${target}):
10154
10767
  target,
10155
10768
  applied: results.some((r) => r.applied)
10156
10769
  }));
10770
+ if (results.length > 0 && results.every((r) => !r.success)) {
10771
+ process.exitCode = 1;
10772
+ }
10157
10773
  return;
10158
10774
  }
10775
+ const successCount = results.filter((r) => r.success).length;
10159
10776
  const settingsFile = target === "codewith" ? options.applyCodewith ? options.codewithConfig : "TOML fragment only (open-configs should apply)" : scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json";
10160
10777
  console.log(chalk2.bold(`
10161
10778
  Installing hooks (${scope}, ${target})...
@@ -10182,38 +10799,59 @@ Installing hooks (${scope}, ${target})...
10182
10799
  console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
10183
10800
  }
10184
10801
  }
10802
+ if (results.length > 0 && successCount === 0) {
10803
+ console.log(chalk2.red(`
10804
+ \u2717 Nothing was registered \u2014 all ${results.length} hook(s) failed.`));
10805
+ process.exitCode = 1;
10806
+ return;
10807
+ }
10185
10808
  console.log(chalk2.dim(`
10186
10809
  Registered in ${settingsFile}`));
10187
10810
  });
10188
- 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 registered hooks", false).option("-g, --global", "Check global settings", false).option("-p, --project", "Check project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith (default: claude)", "claude").option("-n, --limit <n>", "Max rows to show in compact output", "20").option("--verbose", "Show descriptions and full detail columns", false).option("-j, --json", "Output as JSON", false).description("List available or installed hooks").action((options) => {
10811
+ 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 registered hooks", false).option("-g, --global", "Check global settings", false).option("-p, --project", "Check project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith (default: claude)", "claude").option("-n, --limit <n>", "Max rows to show in compact output", "20").option("--verbose", "Show descriptions and full detail columns", false).option("-j, --json", "Output as JSON", false).description("List available or installed hooks (bundled + custom/registry)").action((options) => {
10189
10812
  const scope = resolveScope(options);
10190
10813
  const limit = options.all ? Number.MAX_SAFE_INTEGER : parseLimit(options.limit, 20, 200);
10814
+ const storeHooks = listStoreHooks();
10191
10815
  if (options.registered || options.installed) {
10192
10816
  const target = options.target === "gemini" ? "gemini" : options.target === "codewith" ? "codewith" : "claude";
10193
10817
  const registered = getRegisteredHooksForTarget(scope, target);
10818
+ const installed = [...new Set([...registered, ...storeHooks.map((s) => s.meta.name)])];
10819
+ const shown = options.registered ? registered : installed;
10194
10820
  if (options.json) {
10195
- console.log(JSON.stringify(registered.map((name) => {
10196
- const meta = getHook(name);
10197
- return { name, event: meta?.event, version: meta?.version, description: meta?.description, scope, target };
10821
+ console.log(JSON.stringify(shown.map((name) => {
10822
+ const store = storeHooks.find((s) => s.meta.name === name);
10823
+ const meta = store?.meta ?? getHook(name);
10824
+ return {
10825
+ name,
10826
+ event: meta?.event,
10827
+ version: meta?.version,
10828
+ description: meta?.description,
10829
+ source: store?.source ?? (store ? "custom" : undefined),
10830
+ scope,
10831
+ target
10832
+ };
10198
10833
  })));
10199
10834
  return;
10200
10835
  }
10201
- if (registered.length === 0) {
10202
- console.log(chalk2.dim(`No hooks registered (${scope}, ${target})`));
10836
+ if (shown.length === 0) {
10837
+ console.log(chalk2.dim(`No hooks ${options.registered ? "registered" : "installed"} (${scope}, ${target})`));
10203
10838
  return;
10204
10839
  }
10205
- const visible2 = registered.slice(0, limit);
10840
+ const label = options.registered ? "Registered" : "Installed";
10841
+ const visible2 = shown.slice(0, limit);
10206
10842
  console.log(chalk2.bold(`
10207
- Registered hooks \u2014 ${scope}/${target} (${registered.length}, showing ${visible2.length}):
10843
+ ${label} hooks \u2014 ${scope}/${target} (${shown.length}, showing ${visible2.length}):
10208
10844
  `));
10209
10845
  for (const name of visible2) {
10210
- const meta = getHook(name);
10211
- if (meta)
10212
- console.log(hookSummaryLine(meta, { verbose: options.verbose }));
10213
- else
10846
+ const store = storeHooks.find((s) => s.meta.name === name);
10847
+ const meta = store?.meta ?? getHook(name);
10848
+ if (meta) {
10849
+ console.log(hookSummaryLine(meta, { verbose: options.verbose }) + (store ? chalk2.dim(` (${store.source})`) : ""));
10850
+ } else {
10214
10851
  console.log(` ${chalk2.cyan(name)} ${chalk2.dim("[unknown]")}`);
10852
+ }
10215
10853
  }
10216
- printDisclosureHint(registered.length - visible2.length, "hooks info <name>", { includeAll: true });
10854
+ printDisclosureHint(shown.length - visible2.length, "hooks info <name>", { includeAll: true });
10217
10855
  return;
10218
10856
  }
10219
10857
  if (options.category) {
@@ -10246,6 +10884,18 @@ ${category} (${hooks.length}, showing ${visible2.length}):
10246
10884
  for (const category of CATEGORIES) {
10247
10885
  result[category] = getHooksByCategory(category);
10248
10886
  }
10887
+ result["Custom / Registry"] = storeHooks.map((s) => ({
10888
+ name: s.meta.name,
10889
+ displayName: s.meta.displayName,
10890
+ description: s.meta.description,
10891
+ version: s.meta.version,
10892
+ category: "Custom / Registry",
10893
+ event: s.meta.event,
10894
+ events: s.meta.events,
10895
+ matcher: s.meta.matcher,
10896
+ tags: [...new Set([...s.meta.tags, s.source])],
10897
+ source: s.source
10898
+ }));
10249
10899
  console.log(JSON.stringify(result));
10250
10900
  return;
10251
10901
  }
@@ -10256,6 +10906,16 @@ Available hooks (${HOOKS.length}, showing ${visible.length}):
10256
10906
  for (const h of visible)
10257
10907
  console.log(hookSummaryLine(h, { verbose: options.verbose }));
10258
10908
  printDisclosureHint(HOOKS.length - visible.length, "hooks info <name>", { includeAll: true });
10909
+ if (storeHooks.length > 0) {
10910
+ const storeVisible = storeHooks.slice(0, limit);
10911
+ console.log(chalk2.bold(`
10912
+ Custom / Registry hooks (${storeHooks.length}, showing ${storeVisible.length}):
10913
+ `));
10914
+ for (const s of storeVisible) {
10915
+ console.log(hookSummaryLine(s.meta, { verbose: options.verbose }) + chalk2.dim(` (${s.source})`));
10916
+ }
10917
+ printDisclosureHint(storeHooks.length - storeVisible.length, "hooks info <name>", { includeAll: true });
10918
+ }
10259
10919
  });
10260
10920
  program2.command("search").argument("<query>", "Search term").option("-n, --limit <n>", "Max rows to show in compact output", "10").option("--verbose", "Show descriptions for search results", false).option("-j, --json", "Output as JSON", false).description("Search for hooks").action((query, options) => {
10261
10921
  const results = searchHooks(query);
@@ -10276,28 +10936,40 @@ Found ${results.length} hook(s), showing ${visible.length}:
10276
10936
  console.log(hookSummaryLine(h, { verbose: options.verbose }));
10277
10937
  printDisclosureHint(results.length - visible.length, "hooks info <name>");
10278
10938
  });
10279
- 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, codewith, all (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("Remove an installed hook").action((hook, options) => {
10939
+ 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, codewith, all (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("Remove an installed hook (settings registration + store + lock + DB)").action(async (hook, options) => {
10280
10940
  const scope = resolveScope(options);
10281
10941
  const target = resolveTarget(options);
10282
- if (!getHook(hook)) {
10283
- const suggestions = suggestHooks(hook);
10284
- const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
10285
- if (options.json) {
10286
- console.log(JSON.stringify({ hook, removed: false, scope, target, error: `Hook '${hook}' not found${hint}`, suggestions }));
10287
- } else {
10288
- console.log(chalk2.red(`\u2717 Hook '${hook}' not found${hint}`));
10289
- }
10290
- return;
10942
+ const { uninstallHook: uninstallHook2 } = await Promise.resolve().then(() => (init_installer(), exports_installer));
10943
+ const result = uninstallHook2(hook, scope, target);
10944
+ if (!result.removed) {
10945
+ process.exitCode = 1;
10291
10946
  }
10292
- const removed = removeHook(hook, scope, target);
10293
10947
  if (options.json) {
10294
- console.log(JSON.stringify({ hook, removed, scope, target }));
10295
- return;
10296
- }
10297
- if (removed) {
10298
- console.log(chalk2.green(`\u2713 Removed ${hook} (${scope}, ${target})`));
10948
+ console.log(JSON.stringify({
10949
+ hook: result.name,
10950
+ removed: result.removed,
10951
+ source: result.source,
10952
+ settings_scopes: result.settingsScopes,
10953
+ store_dir_removed: result.storeDirRemoved,
10954
+ pin_removed: result.pinRemoved,
10955
+ db_record_removed: result.dbRecordRemoved,
10956
+ registrations_remaining: result.registrationsRemaining,
10957
+ scope,
10958
+ target,
10959
+ ...result.error ? { error: result.error } : {}
10960
+ }));
10961
+ } else if (result.removed) {
10962
+ console.log(chalk2.green(`\u2713 Removed ${result.name} (${scope}, ${target})`));
10963
+ if (result.source === "custom" && result.storeDirRemoved) {
10964
+ console.log(chalk2.dim(` store dir removed; lock pin ${result.pinRemoved ? "removed" : "absent"}; DB record ${result.dbRecordRemoved ? "removed" : "absent"}`));
10965
+ }
10966
+ if (result.registrationsRemaining && result.registrationsRemaining.length > 0) {
10967
+ console.log(chalk2.yellow(` \u26A0 registrations remaining: ${result.registrationsRemaining.join(", ")} \u2014 remove them in that target's own config`));
10968
+ }
10299
10969
  } else {
10300
- console.log(chalk2.red(`\u2717 ${hook} is not installed (${scope}, ${target})`));
10970
+ const suggestions = suggestHooks(hook);
10971
+ const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
10972
+ console.log(chalk2.red(`\u2717 Hook '${hook}' not found${hint}`));
10301
10973
  }
10302
10974
  });
10303
10975
  program2.command("categories").option("-j, --json", "Output as JSON", false).description("List all categories").action((options) => {
@@ -10441,7 +11113,7 @@ Hook Health Check (${scope})
10441
11113
  }
10442
11114
  console.log();
10443
11115
  });
10444
- program2.command("update").argument("[hooks...]", "Hooks to update (defaults to all installed)").option("-g, --global", "Update global hooks", false).option("-p, --project", "Update project hooks", false).option("-j, --json", "Output as JSON", false).description("Re-register hooks (picks up new package version) and refresh the lock pins").action(async (hooks, options) => {
11116
+ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to all installed)").option("-g, --global", "Update global hooks", false).option("-p, --project", "Update project hooks", false).option("-j, --json", "Output as JSON", false).description("Re-register hooks (picks up new package version), refresh lock pins, or install a pinned registry version (<name>@<version>)").action(async (hooks, options) => {
10445
11117
  const scope = resolveScope(options);
10446
11118
  const installed = getInstalledHooks(scope);
10447
11119
  const toUpdate = hooks.length > 0 ? hooks : installed;
@@ -10451,19 +11123,60 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
10451
11123
  } else {
10452
11124
  console.log(chalk2.dim("No hooks installed to update."));
10453
11125
  }
11126
+ process.exitCode = 1;
10454
11127
  return;
10455
11128
  }
10456
11129
  const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
10457
11130
  const { sha256File: sha256File2, setPinnedHook: setPinnedHook2, upsertHookRecord: upsertHookRecord2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10458
11131
  const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
11132
+ const { resolveApiUrl: resolveApiUrl2 } = await Promise.resolve().then(() => (init_config(), exports_config));
11133
+ const { isCustomSource: isCustomSource2 } = await Promise.resolve().then(() => (init_custom_install(), exports_custom_install));
11134
+ const { HOOK_NAME_RE: HOOK_NAME_RE2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
11135
+ function parseNameVersion(arg) {
11136
+ if (isCustomSource2(arg))
11137
+ return null;
11138
+ const at = arg.lastIndexOf("@");
11139
+ if (at <= 0 || at === arg.length - 1)
11140
+ return null;
11141
+ const name = arg.slice(0, at);
11142
+ const version = arg.slice(at + 1);
11143
+ if (!HOOK_NAME_RE2.test(name))
11144
+ return null;
11145
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version))
11146
+ return null;
11147
+ return { name, version };
11148
+ }
10459
11149
  const results = [];
10460
- for (const name of toUpdate) {
10461
- if (!installed.includes(name)) {
10462
- results.push({ hook: name, success: false, error: "Not installed" });
11150
+ for (const arg of toUpdate) {
11151
+ const pinned = parseNameVersion(arg);
11152
+ if (pinned) {
11153
+ try {
11154
+ const apiUrl = resolveApiUrl2();
11155
+ if (!apiUrl)
11156
+ throw new Error(`Cannot update '${arg}': no remote registry configured (set api_url or HASNA_HOOKS_API_URL)`);
11157
+ const { fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
11158
+ const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, apiUrl);
11159
+ const registered = installHook(pinnedInstall.name, { scope, overwrite: true });
11160
+ if (!registered.success) {
11161
+ results.push({ hook: arg, success: false, error: registered.error ?? "registration failed" });
11162
+ continue;
11163
+ }
11164
+ results.push({
11165
+ hook: pinnedInstall.name,
11166
+ success: true,
11167
+ pinned: { version: pinnedInstall.version, sha256: pinnedInstall.sha256, source: pinnedInstall.source }
11168
+ });
11169
+ } catch (error) {
11170
+ results.push({ hook: arg, success: false, error: error instanceof Error ? error.message : String(error) });
11171
+ }
10463
11172
  continue;
10464
11173
  }
10465
- const result = installHook(name, { scope, overwrite: true });
10466
- const resolved = resolveHook2(name);
11174
+ if (!installed.includes(arg)) {
11175
+ results.push({ hook: arg, success: false, error: "Not installed" });
11176
+ continue;
11177
+ }
11178
+ const result = installHook(arg, { scope, overwrite: true });
11179
+ const resolved = resolveHook2(arg);
10467
11180
  if (result.success && resolved) {
10468
11181
  const hash = await sha256File2(resolved.scriptPath);
10469
11182
  setPinnedHook2(resolved.name, { version: resolved.version, sha256: hash, source: resolved.source });
@@ -10484,6 +11197,9 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
10484
11197
  updated: results.filter((r) => r.success).map((r) => r.hook),
10485
11198
  failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error }))
10486
11199
  }));
11200
+ if (results.some((r) => !r.success)) {
11201
+ process.exitCode = 1;
11202
+ }
10487
11203
  return;
10488
11204
  }
10489
11205
  console.log(chalk2.bold(`
@@ -10496,6 +11212,9 @@ Updating hooks...
10496
11212
  console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
10497
11213
  }
10498
11214
  }
11215
+ if (results.some((r) => !r.success)) {
11216
+ process.exitCode = 1;
11217
+ }
10499
11218
  });
10500
11219
  program2.command("docs").argument("[hook]", "Hook name (shows general docs if omitted)").option("--verbose", "Print full hook README content", false).option("-j, --json", "Output as JSON", false).description("Show documentation for hooks").action((hook, options) => {
10501
11220
  if (hook) {