@hasna/hooks 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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";
4382
- 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}`;
4386
- }
4387
- function shortHookName(name) {
4388
- return name.startsWith("hook-") ? name.slice("hook-".length) : name;
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
+ }
4389
4414
  }
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
- }));
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
+ }
4395
4443
  }
4396
- function getTargetSettingsDir(target) {
4397
- if (target === "codewith")
4398
- return ".codewith";
4399
- if (target === "gemini")
4400
- return ".gemini";
4401
- return ".claude";
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
+ }
4402
4472
  }
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;
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
+ }
4409
4483
  }
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;
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;
4415
4526
  }
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");
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
+ ]);
4422
4556
  }
4423
- return target === "codewith" ? join5(homedir3(), dir, "config.toml") : join5(homedir3(), dir, "settings.json");
4424
4557
  }
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));
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";
4577
+ import { homedir as homedir3 } from "os";
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
+ `);
4431
4585
  }
4432
- function hookExists(name) {
4433
- return existsSync5(getHookPath(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";
4434
4590
  }
4435
- function readSettings(scope = "global", target = "claude") {
4436
- const path = getSettingsPath(scope, target);
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;
4437
4625
  try {
4438
- if (existsSync5(path)) {
4439
- return JSON.parse(readFileSync3(path, "utf-8"));
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 {}
4440
4647
  }
4441
- } catch (error) {
4442
- console.warn(`[hooks] Failed to read settings at ${path}: ${error instanceof Error ? error.message : String(error)}`);
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
+ `);
4443
4684
  }
4444
- return {};
4445
4685
  }
4446
- function writeSettings(settings, scope = "global", target = "claude") {
4447
- const path = getSettingsPath(scope, target);
4448
- const dir = dirname3(path);
4449
- if (!existsSync5(dir)) {
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)) {
4450
4737
  mkdirSync3(dir, { recursive: true });
4451
4738
  }
4452
- writeFileSync3(path, JSON.stringify(settings, null, 2) + `
4453
- `);
4454
4739
  }
4455
- function getTargetEventName(internalEvent, target) {
4456
- return EVENT_MAP[target]?.[internalEvent] ?? null;
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;
4457
4777
  }
4458
- function isEventSupported(internalEvent, target) {
4459
- return getTargetEventName(internalEvent, target) !== null;
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");
4460
4809
  }
4461
- function tomlString(value) {
4462
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4810
+ async function sha256File(path) {
4811
+ const buf = await readFile2(path);
4812
+ return sha256Of(buf);
4463
4813
  }
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}$`;
4814
+ function getHookRecord(db, name) {
4815
+ return db.query("SELECT * FROM hooks WHERE name = ?").get(name);
4472
4816
  }
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
- }
4817
+ function listHookRecords(db) {
4818
+ return db.query("SELECT * FROM hooks ORDER BY name").all();
4489
4819
  }
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}`;
4506
- }
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
+ ]);
4507
4844
  }
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
- `));
4529
- }
4530
- return `${fragments.join(`
4531
-
4532
- `)}
4533
- `;
4845
+ function removeHookRecord(db, name) {
4846
+ const res = db.run("DELETE FROM hooks WHERE name = ?", [name]);
4847
+ return res.changes > 0;
4534
4848
  }
4535
- function readCodewithConfig(scope, configPath) {
4536
- const path = getSettingsPath(scope, "codewith", configPath);
4849
+ function readLock() {
4537
4850
  try {
4538
- return existsSync5(path) ? readFileSync3(path, "utf-8") : "";
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 ?? {} };
4539
4856
  } catch {
4540
- return "";
4857
+ return { hooks: {} };
4541
4858
  }
4542
4859
  }
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}`);
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];
4866
+ }
4867
+ writeFileSync3(path, JSON.stringify({ hooks: sorted }, null, 2) + `
4868
+ `, "utf-8");
4553
4869
  return path;
4554
4870
  }
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
- }
4575
- return;
4871
+ function setPinnedHook(name, entry) {
4872
+ const lock = readLock();
4873
+ lock.hooks[name] = entry;
4874
+ return writeLock(lock);
4576
4875
  }
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 };
4876
+ function getPinnedHook(name) {
4877
+ return readLock().hooks[name];
4878
+ }
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;
4886
+ }
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 });
4899
+ }
4900
+ function removeHookFromStore(name) {
4901
+ const removedPin = removePinnedHook(name);
4902
+ const db = getDb();
4903
+ const removedRecord = removeHookRecord(db, name);
4904
+ return { removedPin, removedRecord };
4905
+ }
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 };
4634
4913
  }
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
- };
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 };
4646
4927
  }
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 };
4647
4937
  }
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);
4938
+ async function verifyScriptHash(name, scriptPath) {
4939
+ return checkScriptHash(name, await sha256File(scriptPath));
4683
4940
  }
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;
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()
4694
4950
  });
4695
- const uniqueEventKeys = [...new Set(eventKeys)];
4696
- if (uniqueEventKeys.length === 0) {
4697
- throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
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
+ });
4958
+
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}`;
4982
+ }
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
+ }));
4991
+ }
4992
+ function getTargetSettingsDir(target) {
4993
+ if (target === "codewith")
4994
+ return ".codewith";
4995
+ if (target === "gemini")
4996
+ return ".gemini";
4997
+ return ".claude";
4998
+ }
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;
5004
+ return;
5005
+ }
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;
4698
5011
  }
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);
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");
4714
5018
  }
4715
- writeSettings(settings, scope, target);
5019
+ return target === "codewith" ? join7(homedir5(), dir, "config.toml") : join7(homedir5(), dir, "settings.json");
4716
5020
  }
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];
4724
- }
4725
- }
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));
4726
5027
  }
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;
4737
- }
4738
- writeSettings(settings, scope, target);
5028
+ function hookExists(name) {
5029
+ return existsSync8(getHookPath(name));
4739
5030
  }
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]);
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"));
4748
5036
  }
4749
- return [...new Set(registered2)];
5037
+ } catch (error) {
5038
+ console.warn(`[hooks] Failed to read settings at ${path}: ${error instanceof Error ? error.message : String(error)}`);
4750
5039
  }
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
- }
5040
+ return {};
5041
+ }
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 });
4766
5047
  }
4767
- return [...new Set(registered)];
5048
+ writeFileSync4(path, JSON.stringify(settings, null, 2) + `
5049
+ `);
4768
5050
  }
4769
- function getRegisteredHooks(scope = "global") {
4770
- return getRegisteredHooksForTarget(scope, "claude");
5051
+ function getTargetEventName(internalEvent, target) {
5052
+ return EVENT_MAP[target]?.[internalEvent] ?? null;
5053
+ }
5054
+ function isEventSupported(internalEvent, target) {
5055
+ return getTargetEventName(internalEvent, target) !== null;
5056
+ }
5057
+ function tomlString(value) {
5058
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
5059
+ }
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}$`;
4771
5068
  }
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;
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 };
4899
5177
  }
4900
- writeFileSync4(profilePath(id), JSON.stringify(profile, null, 2) + `
4901
- `);
4902
- return profile;
4903
- }
4904
- function getProfile(id) {
4905
- const path = profilePath(id);
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 };
5230
+ }
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]);
5344
+ }
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
+ }
5052
5361
  }
5053
- db.exec("COMMIT");
5054
- } catch (err) {
5055
- db.exec("ROLLBACK");
5056
- throw err;
5057
5362
  }
5363
+ return [...new Set(registered)];
5058
5364
  }
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);
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;
5068
5374
  }
5375
+ if (target === "codewith") {
5376
+ return false;
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 };
5470
5689
  }
5471
- async function verifyScriptHash(name, scriptPath) {
5472
- return checkScriptHash(name, await sha256File(scriptPath));
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);
5705
+ }
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,120 @@ 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, { leaderAlive: proc !== null && proc.exitCode === null });
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, { leaderAlive: false });
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, options) {
5901
+ if (!proc)
5902
+ return;
5903
+ const pgid = proc.pid;
5904
+ for (let pass = 0;pass < 5; pass++) {
5905
+ let members = [];
5906
+ try {
5907
+ const res = Bun.spawnSync(["ps", "-eo", "pid=,pgid="], { stdout: "pipe", stderr: "pipe" });
5908
+ if (res.exitCode === 0) {
5909
+ members = res.stdout.toString().split(`
5910
+ `).map((line) => line.trim().split(/\s+/)).filter((cols) => cols.length === 2 && cols[1] === String(pgid)).map((cols) => Number(cols[0])).filter((pid) => Number.isInteger(pid) && pid > 1).filter((pid) => options.leaderAlive || pid !== proc.pid);
5911
+ }
5912
+ } catch {}
5913
+ if (members.length === 0)
5914
+ break;
5915
+ let killed = 0;
5916
+ for (const pid of members) {
5917
+ try {
5918
+ const probe = Bun.spawnSync(["ps", "-o", "pid=,pgid=", "-p", String(pid)], { stdout: "pipe", stderr: "pipe" });
5919
+ const cols = probe.stdout.toString().trim().split(/\s+/);
5920
+ if (cols.length === 2 && cols[1] === String(pgid)) {
5921
+ process.kill(pid, "SIGKILL");
5922
+ killed++;
5923
+ }
5924
+ } catch {}
5925
+ }
5926
+ if (killed === 0)
5927
+ break;
5928
+ }
5929
+ if (options.leaderAlive) {
5555
5930
  try {
5556
- rmSync2(tempPath, { force: true });
5931
+ process.kill(proc.pid, "SIGKILL");
5557
5932
  } catch {}
5558
5933
  }
5559
5934
  }
5560
- var BASH_EXTENSIONS, BUN_EXTENSIONS;
5935
+ var HookTimeoutError, BASH_EXTENSIONS, BUN_EXTENSIONS;
5561
5936
  var init_run = __esm(() => {
5937
+ HookTimeoutError = class HookTimeoutError extends Error {
5938
+ timeoutMs;
5939
+ constructor(timeoutMs) {
5940
+ super(`Hook timed out after ${timeoutMs}ms (process group killed)`);
5941
+ this.name = "HookTimeoutError";
5942
+ this.timeoutMs = timeoutMs;
5943
+ }
5944
+ };
5562
5945
  BASH_EXTENSIONS = new Set(["sh", "bash"]);
5563
5946
  BUN_EXTENSIONS = new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"]);
5564
5947
  });
@@ -5570,7 +5953,7 @@ __export(exports_custom_install, {
5570
5953
  installCustomSource: () => installCustomSource,
5571
5954
  ensureCustomDir: () => ensureCustomDir
5572
5955
  });
5573
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
5956
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync4 } from "fs";
5574
5957
  import { join as join10 } from "path";
5575
5958
  import { tmpdir } from "os";
5576
5959
  function manifestAt(dir) {
@@ -5608,7 +5991,7 @@ async function cloneToTemp(gitUrl) {
5608
5991
  proc.exited
5609
5992
  ]);
5610
5993
  if (exitCode !== 0) {
5611
- rmSync3(tmp, { recursive: true, force: true });
5994
+ rmSync4(tmp, { recursive: true, force: true });
5612
5995
  throw new Error(`git clone failed (${exitCode}): ${stderr.trim()}`);
5613
5996
  }
5614
5997
  return join10(tmp, "repo");
@@ -5623,58 +6006,275 @@ function isCustomSource(value) {
5623
6006
  return true;
5624
6007
  return existsSync10(value);
5625
6008
  }
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
- }
6009
+ async function installCustomSource(source) {
6010
+ if (isGitUrl(source)) {
6011
+ const repoDir = await cloneToTemp(source);
6012
+ try {
6013
+ return copyManifestDir(repoDir, source, "git");
6014
+ } finally {
6015
+ rmSync4(repoDir, { recursive: true, force: true });
6016
+ }
6017
+ }
6018
+ if (source.startsWith("http://") || source.startsWith("https://")) {
6019
+ return installManifestUrl(source);
6020
+ }
6021
+ if (!existsSync10(source)) {
6022
+ throw new Error(`No such local path: ${source}`);
6023
+ }
6024
+ if (source.endsWith("manifest.json")) {
6025
+ const manifest = parseManifest(readFileSync7(source, "utf-8"));
6026
+ return writeFromManifest(manifest, source.substring(0, source.lastIndexOf("/")), source, "local");
6027
+ }
6028
+ return copyManifestDir(source, source, "local");
6029
+ }
6030
+ async function installManifestUrl(url) {
6031
+ const res = await fetch(url, { redirect: "follow" });
6032
+ if (!res.ok) {
6033
+ throw new Error(`Failed to fetch manifest (${res.status}): ${url}`);
6034
+ }
6035
+ const manifest = parseManifest(await res.text());
6036
+ let script;
6037
+ if (manifest.script.includes(`
6038
+ `)) {
6039
+ script = { path: "script.ts", content: manifest.script };
6040
+ } else {
6041
+ const base = url.substring(0, url.lastIndexOf("/"));
6042
+ const scriptUrl = `${base}/${manifest.script}`;
6043
+ const scriptRes = await fetch(scriptUrl, { redirect: "follow" });
6044
+ if (!scriptRes.ok) {
6045
+ throw new Error(`Failed to fetch script (${scriptRes.status}): ${scriptUrl}`);
6046
+ }
6047
+ script = { path: manifest.script, content: await scriptRes.text() };
6048
+ }
6049
+ const name = shortManifestName(manifest.name);
6050
+ const { dir, scriptPath } = writeCustomHook(name, manifest, script.content, script.path);
6051
+ return { name, version: manifest.version, kind: "url", source: url, dir, scriptPath };
6052
+ }
6053
+ function ensureCustomDir() {
6054
+ const dir = getCustomHooksDir();
6055
+ mkdirSync7(dir, { recursive: true });
6056
+ return dir;
6057
+ }
6058
+ var init_custom_install = __esm(() => {
6059
+ init_config();
6060
+ init_manifest();
6061
+ });
6062
+
6063
+ // src/lib/sync.ts
6064
+ var exports_sync = {};
6065
+ __export(exports_sync, {
6066
+ syncHooks: () => syncHooks,
6067
+ planSync: () => planSync,
6068
+ fetchPinnedHook: () => fetchPinnedHook
6069
+ });
6070
+ import { createHash as createHash2 } from "crypto";
6071
+ import { readFileSync as readFileSync8 } from "fs";
6072
+ function sha256OfText(text) {
6073
+ return createHash2("sha256").update(text).digest("hex");
6074
+ }
6075
+ function sha256OfPath(path) {
6076
+ return createHash2("sha256").update(readFileSync8(path)).digest("hex");
6077
+ }
6078
+ function collectBundledCatalog() {
6079
+ const out = [];
6080
+ for (const meta of HOOKS) {
6081
+ const scriptPath = resolveScriptPath(meta.name);
6082
+ if (!scriptPath)
6083
+ continue;
6084
+ out.push({
6085
+ name: meta.name,
6086
+ version: meta.version,
6087
+ sha256: sha256OfPath(scriptPath)
6088
+ });
6089
+ }
6090
+ return out;
6091
+ }
6092
+ async function fetchJson(base, path) {
6093
+ const headers = { accept: "application/json" };
6094
+ const apiKey = resolveApiKey();
6095
+ if (apiKey)
6096
+ headers["x-api-key"] = apiKey;
6097
+ const res = await fetch(`${base}${path}`, {
6098
+ headers,
6099
+ redirect: "error",
6100
+ signal: AbortSignal.timeout(15000)
6101
+ });
6102
+ if (res.status === 401) {
6103
+ throw new Error("registry requires API key \u2014 set HASNA_HOOKS_API_KEY or HOOKS_API_KEY (vault-delivered, never stored)");
6104
+ }
6105
+ if (!res.ok) {
6106
+ throw new Error(`GET ${path} failed with status ${res.status}`);
6107
+ }
6108
+ return res.json();
6109
+ }
6110
+ async function fetchRemoteState(apiUrl) {
6111
+ const catalog = await fetchJson(apiUrl, "/api/v1/catalog");
6112
+ const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
6113
+ if (!catalog.hooks || !Array.isArray(catalog.hooks)) {
6114
+ throw new Error("remote catalog is malformed: missing hooks array");
6115
+ }
6116
+ if (!remoteLock.hooks || typeof remoteLock.hooks !== "object") {
6117
+ throw new Error("remote lock is malformed: missing hooks map");
6118
+ }
6119
+ return { catalog: catalog.hooks, remoteLock };
6120
+ }
6121
+ function computeDiff(catalog, remoteLock) {
6122
+ const local = readLock();
6123
+ const diff = { added: [], updated: [], unchanged: [], skipped: [] };
6124
+ for (const entry of catalog) {
6125
+ if (!entry.name || !entry.version || !entry.sha256)
6126
+ continue;
6127
+ const pin = local.hooks[entry.name];
6128
+ const remotePin = remoteLock ? remoteLock.hooks[entry.name] : entry;
6129
+ if (remoteLock && !remotePin) {
6130
+ diff.skipped.push(entry.name);
6131
+ continue;
6132
+ }
6133
+ const pinnedLocally = pin && pin.version === entry.version && pin.sha256 === entry.sha256;
6134
+ if (pinnedLocally) {
6135
+ diff.unchanged.push(entry.name);
6136
+ } else if (pin) {
6137
+ diff.updated.push(entry.name);
6138
+ } else {
6139
+ diff.added.push(entry.name);
6140
+ }
6141
+ }
6142
+ return diff;
6143
+ }
6144
+ async function planSync() {
6145
+ const apiUrl = resolveApiUrl();
6146
+ if (apiUrl) {
6147
+ const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6148
+ return { apiUrl, dryRun: false, diff: computeDiff(catalog, remoteLock) };
6149
+ }
6150
+ const diff = computeDiff(collectBundledCatalog(), null);
6151
+ return { apiUrl: null, dryRun: false, diff };
6152
+ }
6153
+ async function syncHooks(options = {}) {
6154
+ const apiUrl = resolveApiUrl();
6155
+ const plan = await planSync();
6156
+ if (options.dryRun)
6157
+ return { ...plan, dryRun: true };
6158
+ if (!apiUrl) {
6159
+ const db2 = getDb();
6160
+ for (const entry of collectBundledCatalog()) {
6161
+ setPinnedHook(entry.name, { version: entry.version, sha256: entry.sha256, source: "bundled" });
6162
+ upsertHookRecord(db2, {
6163
+ name: entry.name,
6164
+ version: entry.version,
6165
+ sha256: entry.sha256,
6166
+ source_type: "bundled",
6167
+ source_ref: "apps/hooks/hooks",
6168
+ last_verified_at: new Date().toISOString()
6169
+ });
6170
+ }
6171
+ return plan;
6172
+ }
6173
+ const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6174
+ const diff = computeDiff(catalog, remoteLock);
6175
+ const toFetch = [...diff.added, ...diff.updated];
6176
+ const artifacts = new Map;
6177
+ for (const name of toFetch) {
6178
+ const entry = remoteLock.hooks[name];
6179
+ if (!entry)
6180
+ continue;
6181
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${entry.version}`);
6182
+ if (!artifact.manifest || typeof artifact.script !== "string") {
6183
+ throw new Error(`artifact for '${name}@${entry.version}' is malformed`);
6184
+ }
6185
+ const actualSha = sha256OfText(artifact.script);
6186
+ if (actualSha !== entry.sha256) {
6187
+ throw new Error(`sha256 mismatch for '${name}@${entry.version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
6188
+ }
6189
+ artifacts.set(name, artifact);
6190
+ }
6191
+ const db = getDb();
6192
+ for (const name of toFetch) {
6193
+ const artifact = artifacts.get(name);
6194
+ const entry = remoteLock.hooks[name];
6195
+ if (!artifact || !entry)
6196
+ continue;
6197
+ const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6198
+ const scriptRel = artifact.manifest.script.includes(`
6199
+ `) ? "script.ts" : artifact.manifest.script;
6200
+ writeCustomHook(name, manifest, artifact.script, scriptRel);
6201
+ const scriptPath = resolveScriptPath(name);
6202
+ const actual = await sha256File(scriptPath);
6203
+ if (actual !== entry.sha256) {
6204
+ throw new Error(`sha256 mismatch after write for '${name}@${entry.version}': lock says ${entry.sha256}, on disk ${actual}`);
6205
+ }
6206
+ setPinnedHook(name, { version: entry.version, sha256: entry.sha256, source: entry.source ?? "remote" });
6207
+ upsertHookRecord(db, {
6208
+ name,
6209
+ version: entry.version,
6210
+ sha256: entry.sha256,
6211
+ source_type: entry.source ?? "remote",
6212
+ source_ref: apiUrl,
6213
+ last_verified_at: new Date().toISOString()
6214
+ });
6215
+ }
6216
+ return { apiUrl, dryRun: false, diff };
6217
+ }
6218
+ async function fetchPinnedHook(name, version, apiUrl) {
6219
+ const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
6220
+ const entry = remoteLock.hooks?.[name];
6221
+ if (!entry) {
6222
+ throw new Error(`Hook '${name}' is not in the remote registry lock`);
5634
6223
  }
5635
- if (source.startsWith("http://") || source.startsWith("https://")) {
5636
- return installManifestUrl(source);
6224
+ if (entry.version !== version) {
6225
+ throw new Error(`Hook '${name}' version ${version} is not in the remote registry (available: ${entry.version})`);
5637
6226
  }
5638
- if (!existsSync10(source)) {
5639
- throw new Error(`No such local path: ${source}`);
6227
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${version}`);
6228
+ if (!artifact.manifest || typeof artifact.script !== "string") {
6229
+ throw new Error(`artifact for '${name}@${version}' is malformed`);
5640
6230
  }
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");
6231
+ const manifestName = shortManifestName(artifact.manifest.name ?? name);
6232
+ if (manifestName !== name) {
6233
+ throw new Error(`artifact for '${name}@${version}' declares a different hook name ('${manifestName}')`);
5644
6234
  }
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}`);
6235
+ if (artifact.manifest.version !== version) {
6236
+ throw new Error(`artifact for '${name}@${version}' declares a different version ('${artifact.manifest.version}')`);
5651
6237
  }
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() };
6238
+ const actualSha = sha256OfText(artifact.script);
6239
+ if (actualSha !== entry.sha256) {
6240
+ throw new Error(`sha256 mismatch for '${name}@${version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
5665
6241
  }
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;
6242
+ const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6243
+ const scriptRel = artifact.manifest.script.includes(`
6244
+ `) ? "script.ts" : artifact.manifest.script;
6245
+ writeCustomHook(name, manifest, artifact.script, scriptRel);
6246
+ const scriptPath = resolveScriptPath(name);
6247
+ const actual = await sha256File(scriptPath);
6248
+ if (actual !== entry.sha256) {
6249
+ throw new Error(`sha256 mismatch after write for '${name}@${version}': lock says ${entry.sha256}, on disk ${actual}`);
6250
+ }
6251
+ const db = getDb();
6252
+ setPinnedHook(name, { version, sha256: entry.sha256, source: entry.source ?? "remote" });
6253
+ upsertHookRecord(db, {
6254
+ name,
6255
+ version,
6256
+ sha256: entry.sha256,
6257
+ source_type: entry.source ?? "remote",
6258
+ source_ref: apiUrl,
6259
+ last_verified_at: new Date().toISOString()
6260
+ });
6261
+ return {
6262
+ name,
6263
+ version,
6264
+ sha256: entry.sha256,
6265
+ source: entry.source ?? "remote",
6266
+ source_ref: apiUrl,
6267
+ artifact,
6268
+ scriptPath
6269
+ };
5674
6270
  }
5675
- var init_custom_install = __esm(() => {
6271
+ var init_sync = __esm(() => {
6272
+ init_registry();
5676
6273
  init_config();
6274
+ init_db();
6275
+ init_store();
5677
6276
  init_manifest();
6277
+ init_resolve();
5678
6278
  });
5679
6279
 
5680
6280
  // src/db/pg-migrations.ts
@@ -6184,234 +6784,75 @@ function authorized(req, apiKey) {
6184
6784
  return secureEqual(header.slice("Bearer ".length), apiKey);
6185
6785
  return secureEqual(req.headers.get("x-api-key") ?? "", apiKey);
6186
6786
  }
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);
6787
+ function json(body, status = 200, headers = {}) {
6788
+ return new Response(JSON.stringify(body), {
6789
+ status,
6790
+ headers: { "content-type": "application/json", ...headers }
6791
+ });
6792
+ }
6793
+ function handleServeRequest(req, apiKey) {
6794
+ const url = new URL(req.url);
6795
+ if (url.pathname === "/health" && req.method === "GET") {
6796
+ return Promise.resolve(json({ status: "ok", name: SERVE_SERVICE_NAME }));
6383
6797
  }
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()
6798
+ if (url.pathname === "/api/v1/catalog" && req.method === "GET") {
6799
+ return buildCatalog().then((catalog) => json({ hooks: catalog }));
6800
+ }
6801
+ if (url.pathname === "/api/v1/lock" && req.method === "GET") {
6802
+ return Promise.resolve(json(readLock()));
6803
+ }
6804
+ const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/(\d+\.\d+\.\d+)$/.exec(url.pathname);
6805
+ if (artifactMatch && req.method === "GET") {
6806
+ const [, name, version] = artifactMatch;
6807
+ return artifactFor(name, version).then(async (artifact) => {
6808
+ if (!artifact)
6809
+ return json({ error: `Hook '${name}@${version}' not found locally` }, 404);
6810
+ const resolved = resolveHook(name);
6811
+ const sha = resolved ? await sha256File(resolved.scriptPath) : artifact.manifest.name;
6812
+ return json(artifact, 200, { "x-hook-sha256": sha });
6404
6813
  });
6405
6814
  }
6406
- return { apiUrl, dryRun: false, diff };
6815
+ if (url.pathname === "/api/v1/hooks" && req.method === "PUT") {
6816
+ if (!authorized(req, apiKey)) {
6817
+ return Promise.resolve(json({ error: "unauthorized: valid API key required to publish" }, 401));
6818
+ }
6819
+ return req.json().then((body) => {
6820
+ const name = body.name ?? "";
6821
+ const version = body.version ?? "";
6822
+ const resolved = resolveHook(name);
6823
+ if (!resolved)
6824
+ return json({ error: `Hook '${name}' not found in local store` }, 404);
6825
+ if (version && version !== resolved.version) {
6826
+ return json({ error: `Version mismatch: local '${name}' is ${resolved.version}, requested ${version}` }, 409);
6827
+ }
6828
+ const check = retrustHook(name, resolved.scriptPath, resolved.version, "serve");
6829
+ return json({ ok: true, hook: { name, version: resolved.version, sha256: check.actual } });
6830
+ }, () => json({ error: "invalid JSON body" }, 400));
6831
+ }
6832
+ return Promise.resolve(json({ error: "Not Found" }, 404));
6407
6833
  }
6408
- var init_sync = __esm(() => {
6834
+ function startServeServer(options) {
6835
+ const port = options.port ?? DEFAULT_SERVE_PORT;
6836
+ const host = options.host ?? SERVE_HOST;
6837
+ const apiKey = resolveApiKey(options.apiKey);
6838
+ const server = Bun.serve({
6839
+ hostname: host,
6840
+ port,
6841
+ async fetch(req) {
6842
+ return handleServeRequest(req, apiKey);
6843
+ }
6844
+ });
6845
+ console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);
6846
+ return server;
6847
+ }
6848
+ var DEFAULT_SERVE_PORT = 39428, SERVE_HOST = "127.0.0.1", SERVE_SERVICE_NAME = "hooks-registry";
6849
+ var init_serve = __esm(() => {
6409
6850
  init_registry();
6410
- init_config();
6411
- init_db();
6412
- init_store();
6413
6851
  init_manifest();
6852
+ init_store();
6414
6853
  init_resolve();
6854
+ init_config();
6855
+ if (false) {}
6415
6856
  });
6416
6857
 
6417
6858
  // src/cf/provision.ts
@@ -6511,14 +6952,77 @@ import { createServer } from "http";
6511
6952
  import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
6512
6953
  import { join as join11, dirname as dirname5 } from "path";
6513
6954
  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));
6955
+ async function runVerifiedHook(name, stdin, timeoutMs) {
6956
+ const { resolveHook: resolveHook2, resolveScriptPath: resolveScriptPath2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
6957
+ const resolved = resolveHook2(name);
6958
+ const hookScript = resolveScriptPath2(name);
6959
+ if (!resolved || !hookScript || !existsSync11(hookScript)) {
6960
+ throw new Error(`Hook '${name}' not found`);
6961
+ }
6962
+ const content = readFileSync9(hookScript);
6963
+ const sha = sha256Of(content);
6964
+ const check = checkScriptHash(name, sha);
6517
6965
  if (!check.ok) {
6518
6966
  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
6967
  }
6520
- const { executeVerifiedScript: executeVerifiedScript2 } = await Promise.resolve().then(() => (init_run(), exports_run));
6521
- return executeVerifiedScript2({ name, scriptPath, content, stdin, env: process.env, timeout: timeoutMs });
6968
+ const { executeVerifiedScript: executeVerifiedScript2, HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
6969
+ const started = Date.now();
6970
+ let ran;
6971
+ try {
6972
+ ran = await executeVerifiedScript2({ name, scriptPath: hookScript, content, stdin, env: process.env, timeout: timeoutMs });
6973
+ } catch (err) {
6974
+ try {
6975
+ const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
6976
+ let inputJson = {};
6977
+ try {
6978
+ inputJson = JSON.parse(stdin);
6979
+ } catch {}
6980
+ recordHookRun2({
6981
+ hookName: name,
6982
+ eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
6983
+ version: resolved.version,
6984
+ sha256: sha,
6985
+ sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
6986
+ toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
6987
+ toolInput: inputJson.tool_input,
6988
+ error: err instanceof Error ? err.message.slice(0, 500) : String(err),
6989
+ exitCode: -1,
6990
+ durationMs: Date.now() - started,
6991
+ projectDir: process.cwd()
6992
+ });
6993
+ } catch {}
6994
+ if (err instanceof HookTimeoutError2)
6995
+ throw err;
6996
+ throw err;
6997
+ }
6998
+ const durationMs = Date.now() - started;
6999
+ try {
7000
+ const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
7001
+ let inputJson = {};
7002
+ try {
7003
+ inputJson = JSON.parse(stdin);
7004
+ } catch {}
7005
+ let outputJson = {};
7006
+ try {
7007
+ outputJson = JSON.parse(ran.stdout);
7008
+ } catch {}
7009
+ const blocked = outputJson.decision === "block" || outputJson.continue === false;
7010
+ recordHookRun2({
7011
+ hookName: name,
7012
+ eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
7013
+ version: resolved.version,
7014
+ sha256: sha,
7015
+ sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
7016
+ toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
7017
+ toolInput: inputJson.tool_input,
7018
+ result: blocked ? "block" : "continue",
7019
+ error: ran.exitCode !== 0 ? (ran.stderr || `hook exited with code ${ran.exitCode}`).slice(0, 500) : null,
7020
+ exitCode: ran.exitCode,
7021
+ durationMs,
7022
+ projectDir: process.cwd()
7023
+ });
7024
+ } catch {}
7025
+ return ran;
6522
7026
  }
6523
7027
  function formatInstallResults(results, extra) {
6524
7028
  const installed = results.filter((r) => r.success).map((r) => r.hook);
@@ -6800,17 +7304,14 @@ function createHooksServer() {
6800
7304
  name: z2.string().describe("Hook name (e.g. 'gitguard', 'checkpoint')"),
6801
7305
  input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Hook input as JSON object (HookInput)"),
6802
7306
  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)")
7307
+ timeout_ms: z2.number().int().positive().max(600000).optional().describe("Timeout in milliseconds (default: the hook's manifest timeout_ms or 10000)")
6804
7308
  }, async ({ name, input, profile, timeout_ms }) => {
6805
- const meta = getHook(name);
6806
- if (!meta) {
7309
+ const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
7310
+ const resolved = resolveHook2(name);
7311
+ if (!resolved) {
6807
7312
  return { content: [{ type: "text", text: JSON.stringify({ error: `Hook '${name}' not found` }) }] };
6808
7313
  }
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
- }
7314
+ const effectiveTimeout = timeout_ms ?? resolved.timeoutMs ?? 1e4;
6814
7315
  let hookInput = { ...input };
6815
7316
  if (profile) {
6816
7317
  const p = getProfile(profile);
@@ -6823,13 +7324,38 @@ function createHooksServer() {
6823
7324
  };
6824
7325
  }
6825
7326
  }
6826
- let ran = null;
6827
7327
  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
- ]);
7328
+ const ran = await runVerifiedHook(name, JSON.stringify(hookInput), effectiveTimeout);
7329
+ const stdoutText = ran.stdout;
7330
+ const stderrText = ran.stderr;
7331
+ let output = {};
7332
+ try {
7333
+ output = JSON.parse(stdoutText);
7334
+ } catch {
7335
+ output = stdoutText ? { raw: stdoutText } : {};
7336
+ }
7337
+ return {
7338
+ content: [{
7339
+ type: "text",
7340
+ text: JSON.stringify({
7341
+ hook: name,
7342
+ output,
7343
+ stderr: stderrText || undefined,
7344
+ exitCode: ran.exitCode,
7345
+ timeout_ms: effectiveTimeout
7346
+ })
7347
+ }]
7348
+ };
6832
7349
  } catch (err) {
7350
+ const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
7351
+ if (err instanceof HookTimeoutError2) {
7352
+ return {
7353
+ content: [{
7354
+ type: "text",
7355
+ text: JSON.stringify({ hook: name, timedOut: true, timeout_ms: effectiveTimeout, error: err.message })
7356
+ }]
7357
+ };
7358
+ }
6833
7359
  return {
6834
7360
  content: [{
6835
7361
  type: "text",
@@ -6837,27 +7363,6 @@ function createHooksServer() {
6837
7363
  }]
6838
7364
  };
6839
7365
  }
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
7366
  });
6862
7367
  defineTool("hooks_update", "Re-register installed hooks to pick up new package version (reinstalls with overwrite)", {
6863
7368
  hooks: z2.array(z2.string()).optional().describe("Hook names to update (omit to update all installed hooks)"),
@@ -6918,11 +7423,11 @@ function createHooksServer() {
6918
7423
  tool_name: z2.string().describe("Tool name to simulate (e.g. 'Bash', 'Write', 'Edit')"),
6919
7424
  tool_input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Tool input to pass to matching hooks"),
6920
7425
  scope: z2.enum(["global", "project"]).default("global").describe("Scope to check"),
6921
- timeout_ms: z2.number().default(5000).describe("Per-hook timeout in milliseconds")
7426
+ 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
7427
  }, async ({ tool_name, tool_input, scope, timeout_ms }) => {
6923
7428
  const registered = getRegisteredHooks(scope);
6924
7429
  const matchingHooks = registered.filter((name) => {
6925
- const meta = getHook(name);
7430
+ const meta = resolveHookMeta(name) ?? getHook(name);
6926
7431
  if (!meta || meta.event !== "PreToolUse")
6927
7432
  return false;
6928
7433
  if (!meta.matcher)
@@ -6938,26 +7443,23 @@ function createHooksServer() {
6938
7443
  }
6939
7444
  const input = { tool_name, tool_input };
6940
7445
  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;
7446
+ const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
7447
+ const resolved = resolveHook2(name);
7448
+ const effectiveTimeout = timeout_ms ?? resolved?.timeoutMs ?? 5000;
6946
7449
  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
- ]);
7450
+ const ran = await runVerifiedHook(name, JSON.stringify(input), effectiveTimeout);
7451
+ let output = {};
7452
+ try {
7453
+ output = JSON.parse(ran.stdout);
7454
+ } catch {}
7455
+ return { name, decision: output.decision ?? "approve", reason: output.reason, raw: output };
6951
7456
  } catch (err) {
7457
+ const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
7458
+ if (err instanceof HookTimeoutError2) {
7459
+ return { name, decision: "approve", timedOut: true, timeout_ms: effectiveTimeout };
7460
+ }
6952
7461
  return { name, decision: "block", error: err instanceof Error ? err.message : String(err) };
6953
7462
  }
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
7463
  }));
6962
7464
  const blocked = results.find((r) => r.decision === "block");
6963
7465
  return {
@@ -6997,33 +7499,31 @@ function createHooksServer() {
6997
7499
  name: z2.string().describe("Hook name"),
6998
7500
  input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Hook input JSON")
6999
7501
  })).describe("List of hooks to run with their inputs"),
7000
- timeout_ms: z2.number().default(1e4).describe("Per-hook timeout in milliseconds")
7502
+ 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
7503
  }, async ({ hooks, timeout_ms }) => {
7002
7504
  const results = await Promise.all(hooks.map(async ({ name, input }) => {
7003
- const meta = getHook(name);
7004
- if (!meta)
7505
+ const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
7506
+ const resolved = resolveHook2(name);
7507
+ if (!resolved)
7005
7508
  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;
7509
+ const effectiveTimeout = timeout_ms ?? resolved.timeoutMs ?? 1e4;
7010
7510
  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
- ]);
7511
+ const ran = await runVerifiedHook(name, JSON.stringify(input), effectiveTimeout);
7512
+ const stdoutText = ran.stdout;
7513
+ let output = {};
7514
+ try {
7515
+ output = JSON.parse(stdoutText);
7516
+ } catch {
7517
+ output = stdoutText ? { raw: stdoutText } : {};
7518
+ }
7519
+ return { name, output, exitCode: ran.exitCode, timeout_ms: effectiveTimeout };
7015
7520
  } catch (err) {
7521
+ const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
7522
+ if (err instanceof HookTimeoutError2) {
7523
+ return { name, error: err.message, timedOut: true, timeout_ms: effectiveTimeout };
7524
+ }
7016
7525
  return { name, error: err instanceof Error ? err.message : String(err) };
7017
7526
  }
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
7527
  }));
7028
7528
  return { content: [{ type: "text", text: JSON.stringify({ results, count: results.length }) }] };
7029
7529
  });
@@ -8653,7 +9153,7 @@ var {
8653
9153
 
8654
9154
  // src/cli/index.tsx
8655
9155
  import chalk2 from "chalk";
8656
- import { existsSync as existsSync12, readFileSync as readFileSync10 } from "fs";
9156
+ import { existsSync as existsSync12, readFileSync as readFileSync10, rmSync as rmSync5 } from "fs";
8657
9157
  import { join as join12, dirname as dirname6 } from "path";
8658
9158
  import { fileURLToPath as fileURLToPath4 } from "url";
8659
9159
 
@@ -9832,6 +10332,7 @@ init_installer();
9832
10332
  init_profiles();
9833
10333
  init_manifest();
9834
10334
  init_resolve();
10335
+ init_store();
9835
10336
  import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
9836
10337
  var __dirname4 = dirname6(fileURLToPath4(import.meta.url));
9837
10338
  var pkgPath = existsSync12(join12(__dirname4, "..", "package.json")) ? join12(__dirname4, "..", "package.json") : join12(__dirname4, "..", "..", "package.json");
@@ -9884,6 +10385,22 @@ function hookSummaryLine(hook, options = {}) {
9884
10385
  const description = options.verbose ? ` - ${truncateText2(hook.description, 110)}` : "";
9885
10386
  return ` ${chalk2.cyan(hook.name.padEnd(17))} ${chalk2.dim(`[${hook.event}${matcher}]`)} ${chalk2.dim(hook.category)}${description}`;
9886
10387
  }
10388
+ function listStoreHooks() {
10389
+ const out = [];
10390
+ for (const parsed of listCustomHooks()) {
10391
+ const name = parsed.manifest.name;
10392
+ const pin = getPinnedHook(name);
10393
+ const source = pin?.source === "remote" || pin?.source === "registry" ? "registry" : "custom";
10394
+ const meta = resolveHookMeta(name);
10395
+ if (!meta)
10396
+ continue;
10397
+ out.push({
10398
+ meta: { ...meta, version: pin?.version ?? meta.version },
10399
+ source
10400
+ });
10401
+ }
10402
+ return out.sort((a, b) => a.meta.name.localeCompare(b.meta.name));
10403
+ }
9887
10404
  function printDisclosureHint(hidden, detailCommand, options = {}) {
9888
10405
  const rowControls = options.includeAll ? "--limit, --all, --verbose" : "--limit, --verbose";
9889
10406
  if (hidden > 0) {
@@ -9924,24 +10441,23 @@ program2.command("init").description("Register a new agent profile with a unique
9924
10441
  console.log(chalk2.red(message));
9925
10442
  return;
9926
10443
  }
9927
- const config = { api_url: options.apiUrl.replace(/\/+$/, ""), api_key_ref: options.apiKey };
10444
+ const apiKeyRef = options.apiKey ?? "hasna/hooks/live/api-key";
10445
+ const config = { api_url: options.apiUrl.replace(/\/+$/, ""), api_key_ref: apiKeyRef };
9928
10446
  const path = writeConfig2(config);
9929
10447
  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 }));
10448
+ console.log(JSON.stringify({ ok: true, api_url: config.api_url, api_key_ref: config.api_key_ref, config_path: path }));
9931
10449
  return;
9932
10450
  }
9933
10451
  console.log(chalk2.green(`
9934
10452
  \u2713 Remote registry configured
9935
10453
  `));
9936
10454
  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)"}`);
10455
+ console.log(` ${chalk2.dim("API key ref:")} ${config.api_key_ref}`);
9938
10456
  console.log(` ${chalk2.dim("Config:")} ${path}`);
9939
10457
  console.log();
9940
10458
  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
- }
10459
+ console.log(chalk2.dim(` Run the server with the key resolved from the vault, never the value:`));
10460
+ console.log(` secrets exec ${config.api_key_ref} --as HASNA_HOOKS_API_KEY -- hooks serve`);
9945
10461
  console.log();
9946
10462
  return;
9947
10463
  }
@@ -9977,6 +10493,7 @@ program2.command("init").description("Register a new agent profile with a unique
9977
10493
  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
10494
  const { resolveHook: resolveHook2, resolveScriptPath: resolveScriptPath2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
9979
10495
  const { sha256Of: sha256Of2, checkScriptHash: checkScriptHash2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10496
+ const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
9980
10497
  const resolved = resolveHook2(hook);
9981
10498
  if (!resolved) {
9982
10499
  console.error(JSON.stringify({ error: `Hook '${hook}' not found` }));
@@ -9988,7 +10505,8 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
9988
10505
  process.exit(1);
9989
10506
  }
9990
10507
  const content = readFileSync10(hookScript);
9991
- const check = checkScriptHash2(hook, sha256Of2(content));
10508
+ const sha = sha256Of2(content);
10509
+ const check = checkScriptHash2(hook, sha);
9992
10510
  if (!check.ok) {
9993
10511
  console.error(JSON.stringify({
9994
10512
  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 +10535,74 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
10017
10535
  }
10018
10536
  }
10019
10537
  const { readCustomManifest: readCustomManifest2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
10020
- const { executeVerifiedScript: executeVerifiedScript2 } = await Promise.resolve().then(() => (init_run(), exports_run));
10538
+ const { executeVerifiedScript: executeVerifiedScript2, HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
10021
10539
  const custom = readCustomManifest2(hook);
10022
10540
  const args = custom?.manifest.args ?? [];
10023
10541
  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
10542
+ const started = Date.now();
10543
+ let stdout = "";
10544
+ let stderr = "";
10545
+ let exitCode = 0;
10546
+ try {
10547
+ ({ stdout, stderr, exitCode } = await executeVerifiedScript2({
10548
+ name: hook,
10549
+ scriptPath: hookScript,
10550
+ content,
10551
+ args,
10552
+ stdin: hookStdin,
10553
+ env: process.env,
10554
+ timeout
10555
+ }));
10556
+ } catch (err) {
10557
+ if (err instanceof HookTimeoutError2) {
10558
+ try {
10559
+ const { recordHookRun: recordHookRun3, resolveEventType: resolveEventType3 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
10560
+ let inputJson2 = {};
10561
+ try {
10562
+ inputJson2 = JSON.parse(stdin);
10563
+ } catch {}
10564
+ recordHookRun3({
10565
+ hookName: hook,
10566
+ eventType: resolveEventType3(inputJson2.hook_event_name, resolved.events[0] ?? "PostToolUse"),
10567
+ version: resolved.version,
10568
+ sha256: sha,
10569
+ sessionId: typeof inputJson2.session_id === "string" ? inputJson2.session_id : null,
10570
+ toolName: typeof inputJson2.tool_name === "string" ? inputJson2.tool_name : null,
10571
+ toolInput: inputJson2.tool_input,
10572
+ error: err.message.slice(0, 500),
10573
+ exitCode: -1,
10574
+ durationMs: Date.now() - started,
10575
+ projectDir: process.cwd()
10576
+ });
10577
+ } catch {}
10578
+ console.error(JSON.stringify({ error: err.message, hook, timedOut: true, timeout_ms: timeout ?? null }));
10579
+ process.exit(1);
10580
+ }
10581
+ throw err;
10582
+ }
10583
+ const durationMs = Date.now() - started;
10584
+ let inputJson = {};
10585
+ try {
10586
+ inputJson = JSON.parse(stdin);
10587
+ } catch {}
10588
+ let outputJson = {};
10589
+ try {
10590
+ outputJson = JSON.parse(stdout);
10591
+ } catch {}
10592
+ const blocked = outputJson.decision === "block" || outputJson.continue === false;
10593
+ recordHookRun2({
10594
+ hookName: hook,
10595
+ eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
10596
+ version: resolved.version,
10597
+ sha256: sha,
10598
+ sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
10599
+ toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
10600
+ toolInput: inputJson.tool_input,
10601
+ result: blocked ? "block" : "continue",
10602
+ error: exitCode !== 0 ? (stderr || `hook exited with code ${exitCode}`).slice(0, 500) : null,
10603
+ exitCode,
10604
+ durationMs,
10605
+ projectDir: process.cwd()
10032
10606
  });
10033
10607
  if (stdout)
10034
10608
  process.stdout.write(stdout);
@@ -10036,7 +10610,7 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
10036
10610
  process.stderr.write(stderr);
10037
10611
  process.exit(exitCode);
10038
10612
  });
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) => {
10613
+ 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
10614
  const scope = resolveScope(options);
10041
10615
  const target = resolveTarget(options);
10042
10616
  let toInstall = hooks;
@@ -10069,15 +10643,35 @@ program2.command("install").alias("add").argument("[hooks...]", "Hooks to instal
10069
10643
  return;
10070
10644
  }
10071
10645
  const { isCustomSource: isCustomSource2, installCustomSource: installCustomSource2 } = await Promise.resolve().then(() => (init_custom_install(), exports_custom_install));
10646
+ const { readCustomManifest: readCustomManifest2, HOOK_NAME_RE: HOOK_NAME_RE2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
10647
+ const { resolveApiUrl: resolveApiUrl2 } = await Promise.resolve().then(() => (init_config(), exports_config));
10648
+ const { sha256Of: sha256Of2, pinInstalledHook: pinInstalledHook2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10649
+ const { readFileSync: readFileSyncFs } = await import("fs");
10650
+ function parseNameVersion(arg) {
10651
+ if (isCustomSource2(arg))
10652
+ return null;
10653
+ const at = arg.lastIndexOf("@");
10654
+ if (at <= 0 || at === arg.length - 1)
10655
+ return null;
10656
+ const name = arg.slice(0, at);
10657
+ const version = arg.slice(at + 1);
10658
+ if (!HOOK_NAME_RE2.test(name))
10659
+ return null;
10660
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version))
10661
+ return null;
10662
+ return { name, version };
10663
+ }
10664
+ const pinnedRequests = toInstall.map((arg) => ({ arg, pinned: parseNameVersion(arg) })).filter((x) => x.pinned !== null);
10072
10665
  const customSources = toInstall.filter((n) => !getHook(n) && isCustomSource2(n));
10073
10666
  if (options.dryRun) {
10074
10667
  const known = toInstall.filter((n) => getHook(n));
10075
- const unknown = toInstall.filter((n) => !getHook(n) && !isCustomSource2(n));
10668
+ const unknown = toInstall.filter((n) => !getHook(n) && !isCustomSource2(n) && !parseNameVersion(n) && !readCustomManifest2(n));
10076
10669
  if (options.json) {
10077
10670
  console.log(JSON.stringify({
10078
10671
  dryRun: true,
10079
10672
  would_install: known,
10080
10673
  custom_sources: customSources,
10674
+ pinned: pinnedRequests.map((p) => `${p.pinned.name}@${p.pinned.version}`),
10081
10675
  unknown,
10082
10676
  scope,
10083
10677
  target,
@@ -10095,6 +10689,9 @@ Dry run \u2014 would install (${scope}, ${target}):
10095
10689
  for (const source of customSources) {
10096
10690
  console.log(chalk2.cyan(` ${source}`) + chalk2.dim(" [custom source: would fetch and install]"));
10097
10691
  }
10692
+ for (const p of pinnedRequests) {
10693
+ console.log(chalk2.cyan(` ${p.pinned.name}@${p.pinned.version}`) + chalk2.dim(" [pinned registry install]"));
10694
+ }
10098
10695
  if (unknown.length > 0) {
10099
10696
  console.log();
10100
10697
  for (const name of unknown) {
@@ -10109,9 +10706,21 @@ Dry run \u2014 would install (${scope}, ${target}):
10109
10706
  for (const source of customSources) {
10110
10707
  try {
10111
10708
  const custom = await installCustomSource2(source);
10709
+ try {
10710
+ const scriptBytes = readFileSyncFs(custom.scriptPath);
10711
+ pinInstalledHook2(custom.name, custom.version, sha256Of2(scriptBytes), "custom", source);
10712
+ } catch (pinError) {
10713
+ const { customHookDir: customHookDir2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
10714
+ const { removeHookFromStore: removeHookFromStore2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10715
+ try {
10716
+ rmSync5(customHookDir2(custom.name), { recursive: true, force: true });
10717
+ } catch {}
10718
+ removeHookFromStore2(custom.name);
10719
+ throw pinError;
10720
+ }
10112
10721
  installedCustom.push(custom.name);
10113
10722
  if (options.json) {
10114
- console.log(JSON.stringify({ custom: { source, name: custom.name, version: custom.version } }));
10723
+ console.log(JSON.stringify({ custom: { source, name: custom.name, version: custom.version, pinned: true } }));
10115
10724
  } else {
10116
10725
  console.log(chalk2.green(`\u2713 Installed custom hook '${custom.name}' v${custom.version} from ${source}`));
10117
10726
  }
@@ -10125,9 +10734,37 @@ Dry run \u2014 would install (${scope}, ${target}):
10125
10734
  }
10126
10735
  }
10127
10736
  }
10128
- const namesToRegister = [...new Set([...toInstall.filter((n) => !isCustomSource2(n)), ...installedCustom])];
10737
+ const installedPinned = [];
10738
+ for (const { arg, pinned } of pinnedRequests) {
10739
+ try {
10740
+ const apiUrl = resolveApiUrl2();
10741
+ if (!apiUrl)
10742
+ throw new Error(`Cannot install '${arg}': no remote registry configured (set api_url or HASNA_HOOKS_API_URL)`);
10743
+ const { fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
10744
+ const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, apiUrl);
10745
+ installedPinned.push(pinnedInstall.name);
10746
+ if (options.json) {
10747
+ console.log(JSON.stringify({ pinned: { request: arg, name: pinnedInstall.name, version: pinnedInstall.version, sha256: pinnedInstall.sha256, source: pinnedInstall.source } }));
10748
+ } else {
10749
+ console.log(chalk2.green(`\u2713 Installed '${pinnedInstall.name}' v${pinnedInstall.version} from registry (sha256 ${pinnedInstall.sha256.slice(0, 12)}\u2026)`));
10750
+ }
10751
+ } catch (error) {
10752
+ const message = error instanceof Error ? error.message : String(error);
10753
+ results.push({ hook: arg, success: false, error: message });
10754
+ if (options.json) {
10755
+ console.log(JSON.stringify({ error: `Pinned install failed for ${arg}: ${message}` }));
10756
+ } else {
10757
+ console.log(chalk2.red(`\u2717 ${message}`));
10758
+ }
10759
+ }
10760
+ }
10761
+ const namesToRegister = [...new Set([
10762
+ ...toInstall.filter((n) => !isCustomSource2(n) && !parseNameVersion(n)),
10763
+ ...installedCustom,
10764
+ ...installedPinned
10765
+ ])];
10129
10766
  for (const name of namesToRegister) {
10130
- if (!getHook(name) && !installedCustom.includes(name)) {
10767
+ if (!getHook(name) && !installedCustom.includes(name) && !installedPinned.includes(name) && !readCustomManifest2(name)) {
10131
10768
  const suggestions = suggestHooks(name);
10132
10769
  const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
10133
10770
  results.push({ hook: name, success: false, error: `Hook '${name}' not found${hint}` });
@@ -10154,8 +10791,12 @@ Dry run \u2014 would install (${scope}, ${target}):
10154
10791
  target,
10155
10792
  applied: results.some((r) => r.applied)
10156
10793
  }));
10794
+ if (results.length > 0 && results.every((r) => !r.success)) {
10795
+ process.exitCode = 1;
10796
+ }
10157
10797
  return;
10158
10798
  }
10799
+ const successCount = results.filter((r) => r.success).length;
10159
10800
  const settingsFile = target === "codewith" ? options.applyCodewith ? options.codewithConfig : "TOML fragment only (open-configs should apply)" : scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json";
10160
10801
  console.log(chalk2.bold(`
10161
10802
  Installing hooks (${scope}, ${target})...
@@ -10182,38 +10823,59 @@ Installing hooks (${scope}, ${target})...
10182
10823
  console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
10183
10824
  }
10184
10825
  }
10826
+ if (results.length > 0 && successCount === 0) {
10827
+ console.log(chalk2.red(`
10828
+ \u2717 Nothing was registered \u2014 all ${results.length} hook(s) failed.`));
10829
+ process.exitCode = 1;
10830
+ return;
10831
+ }
10185
10832
  console.log(chalk2.dim(`
10186
10833
  Registered in ${settingsFile}`));
10187
10834
  });
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) => {
10835
+ 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
10836
  const scope = resolveScope(options);
10190
10837
  const limit = options.all ? Number.MAX_SAFE_INTEGER : parseLimit(options.limit, 20, 200);
10838
+ const storeHooks = listStoreHooks();
10191
10839
  if (options.registered || options.installed) {
10192
10840
  const target = options.target === "gemini" ? "gemini" : options.target === "codewith" ? "codewith" : "claude";
10193
10841
  const registered = getRegisteredHooksForTarget(scope, target);
10842
+ const installed = [...new Set([...registered, ...storeHooks.map((s) => s.meta.name)])];
10843
+ const shown = options.registered ? registered : installed;
10194
10844
  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 };
10845
+ console.log(JSON.stringify(shown.map((name) => {
10846
+ const store = storeHooks.find((s) => s.meta.name === name);
10847
+ const meta = store?.meta ?? getHook(name);
10848
+ return {
10849
+ name,
10850
+ event: meta?.event,
10851
+ version: meta?.version,
10852
+ description: meta?.description,
10853
+ source: store?.source ?? (store ? "custom" : undefined),
10854
+ scope,
10855
+ target
10856
+ };
10198
10857
  })));
10199
10858
  return;
10200
10859
  }
10201
- if (registered.length === 0) {
10202
- console.log(chalk2.dim(`No hooks registered (${scope}, ${target})`));
10860
+ if (shown.length === 0) {
10861
+ console.log(chalk2.dim(`No hooks ${options.registered ? "registered" : "installed"} (${scope}, ${target})`));
10203
10862
  return;
10204
10863
  }
10205
- const visible2 = registered.slice(0, limit);
10864
+ const label = options.registered ? "Registered" : "Installed";
10865
+ const visible2 = shown.slice(0, limit);
10206
10866
  console.log(chalk2.bold(`
10207
- Registered hooks \u2014 ${scope}/${target} (${registered.length}, showing ${visible2.length}):
10867
+ ${label} hooks \u2014 ${scope}/${target} (${shown.length}, showing ${visible2.length}):
10208
10868
  `));
10209
10869
  for (const name of visible2) {
10210
- const meta = getHook(name);
10211
- if (meta)
10212
- console.log(hookSummaryLine(meta, { verbose: options.verbose }));
10213
- else
10870
+ const store = storeHooks.find((s) => s.meta.name === name);
10871
+ const meta = store?.meta ?? getHook(name);
10872
+ if (meta) {
10873
+ console.log(hookSummaryLine(meta, { verbose: options.verbose }) + (store ? chalk2.dim(` (${store.source})`) : ""));
10874
+ } else {
10214
10875
  console.log(` ${chalk2.cyan(name)} ${chalk2.dim("[unknown]")}`);
10876
+ }
10215
10877
  }
10216
- printDisclosureHint(registered.length - visible2.length, "hooks info <name>", { includeAll: true });
10878
+ printDisclosureHint(shown.length - visible2.length, "hooks info <name>", { includeAll: true });
10217
10879
  return;
10218
10880
  }
10219
10881
  if (options.category) {
@@ -10246,6 +10908,18 @@ ${category} (${hooks.length}, showing ${visible2.length}):
10246
10908
  for (const category of CATEGORIES) {
10247
10909
  result[category] = getHooksByCategory(category);
10248
10910
  }
10911
+ result["Custom / Registry"] = storeHooks.map((s) => ({
10912
+ name: s.meta.name,
10913
+ displayName: s.meta.displayName,
10914
+ description: s.meta.description,
10915
+ version: s.meta.version,
10916
+ category: "Custom / Registry",
10917
+ event: s.meta.event,
10918
+ events: s.meta.events,
10919
+ matcher: s.meta.matcher,
10920
+ tags: [...new Set([...s.meta.tags, s.source])],
10921
+ source: s.source
10922
+ }));
10249
10923
  console.log(JSON.stringify(result));
10250
10924
  return;
10251
10925
  }
@@ -10256,6 +10930,16 @@ Available hooks (${HOOKS.length}, showing ${visible.length}):
10256
10930
  for (const h of visible)
10257
10931
  console.log(hookSummaryLine(h, { verbose: options.verbose }));
10258
10932
  printDisclosureHint(HOOKS.length - visible.length, "hooks info <name>", { includeAll: true });
10933
+ if (storeHooks.length > 0) {
10934
+ const storeVisible = storeHooks.slice(0, limit);
10935
+ console.log(chalk2.bold(`
10936
+ Custom / Registry hooks (${storeHooks.length}, showing ${storeVisible.length}):
10937
+ `));
10938
+ for (const s of storeVisible) {
10939
+ console.log(hookSummaryLine(s.meta, { verbose: options.verbose }) + chalk2.dim(` (${s.source})`));
10940
+ }
10941
+ printDisclosureHint(storeHooks.length - storeVisible.length, "hooks info <name>", { includeAll: true });
10942
+ }
10259
10943
  });
10260
10944
  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
10945
  const results = searchHooks(query);
@@ -10276,28 +10960,40 @@ Found ${results.length} hook(s), showing ${visible.length}:
10276
10960
  console.log(hookSummaryLine(h, { verbose: options.verbose }));
10277
10961
  printDisclosureHint(results.length - visible.length, "hooks info <name>");
10278
10962
  });
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) => {
10963
+ 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
10964
  const scope = resolveScope(options);
10281
10965
  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;
10966
+ const { uninstallHook: uninstallHook2 } = await Promise.resolve().then(() => (init_installer(), exports_installer));
10967
+ const result = uninstallHook2(hook, scope, target);
10968
+ if (!result.removed) {
10969
+ process.exitCode = 1;
10291
10970
  }
10292
- const removed = removeHook(hook, scope, target);
10293
10971
  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})`));
10972
+ console.log(JSON.stringify({
10973
+ hook: result.name,
10974
+ removed: result.removed,
10975
+ source: result.source,
10976
+ settings_scopes: result.settingsScopes,
10977
+ store_dir_removed: result.storeDirRemoved,
10978
+ pin_removed: result.pinRemoved,
10979
+ db_record_removed: result.dbRecordRemoved,
10980
+ registrations_remaining: result.registrationsRemaining,
10981
+ scope,
10982
+ target,
10983
+ ...result.error ? { error: result.error } : {}
10984
+ }));
10985
+ } else if (result.removed) {
10986
+ console.log(chalk2.green(`\u2713 Removed ${result.name} (${scope}, ${target})`));
10987
+ if (result.source === "custom" && result.storeDirRemoved) {
10988
+ console.log(chalk2.dim(` store dir removed; lock pin ${result.pinRemoved ? "removed" : "absent"}; DB record ${result.dbRecordRemoved ? "removed" : "absent"}`));
10989
+ }
10990
+ if (result.registrationsRemaining && result.registrationsRemaining.length > 0) {
10991
+ console.log(chalk2.yellow(` \u26A0 registrations remaining: ${result.registrationsRemaining.join(", ")} \u2014 remove them in that target's own config`));
10992
+ }
10299
10993
  } else {
10300
- console.log(chalk2.red(`\u2717 ${hook} is not installed (${scope}, ${target})`));
10994
+ const suggestions = suggestHooks(hook);
10995
+ const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
10996
+ console.log(chalk2.red(`\u2717 Hook '${hook}' not found${hint}`));
10301
10997
  }
10302
10998
  });
10303
10999
  program2.command("categories").option("-j, --json", "Output as JSON", false).description("List all categories").action((options) => {
@@ -10441,7 +11137,7 @@ Hook Health Check (${scope})
10441
11137
  }
10442
11138
  console.log();
10443
11139
  });
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) => {
11140
+ 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
11141
  const scope = resolveScope(options);
10446
11142
  const installed = getInstalledHooks(scope);
10447
11143
  const toUpdate = hooks.length > 0 ? hooks : installed;
@@ -10451,19 +11147,60 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
10451
11147
  } else {
10452
11148
  console.log(chalk2.dim("No hooks installed to update."));
10453
11149
  }
11150
+ process.exitCode = 1;
10454
11151
  return;
10455
11152
  }
10456
11153
  const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
10457
11154
  const { sha256File: sha256File2, setPinnedHook: setPinnedHook2, upsertHookRecord: upsertHookRecord2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10458
11155
  const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
11156
+ const { resolveApiUrl: resolveApiUrl2 } = await Promise.resolve().then(() => (init_config(), exports_config));
11157
+ const { isCustomSource: isCustomSource2 } = await Promise.resolve().then(() => (init_custom_install(), exports_custom_install));
11158
+ const { HOOK_NAME_RE: HOOK_NAME_RE2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
11159
+ function parseNameVersion(arg) {
11160
+ if (isCustomSource2(arg))
11161
+ return null;
11162
+ const at = arg.lastIndexOf("@");
11163
+ if (at <= 0 || at === arg.length - 1)
11164
+ return null;
11165
+ const name = arg.slice(0, at);
11166
+ const version = arg.slice(at + 1);
11167
+ if (!HOOK_NAME_RE2.test(name))
11168
+ return null;
11169
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version))
11170
+ return null;
11171
+ return { name, version };
11172
+ }
10459
11173
  const results = [];
10460
- for (const name of toUpdate) {
10461
- if (!installed.includes(name)) {
10462
- results.push({ hook: name, success: false, error: "Not installed" });
11174
+ for (const arg of toUpdate) {
11175
+ const pinned = parseNameVersion(arg);
11176
+ if (pinned) {
11177
+ try {
11178
+ const apiUrl = resolveApiUrl2();
11179
+ if (!apiUrl)
11180
+ throw new Error(`Cannot update '${arg}': no remote registry configured (set api_url or HASNA_HOOKS_API_URL)`);
11181
+ const { fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
11182
+ const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, apiUrl);
11183
+ const registered = installHook(pinnedInstall.name, { scope, overwrite: true });
11184
+ if (!registered.success) {
11185
+ results.push({ hook: arg, success: false, error: registered.error ?? "registration failed" });
11186
+ continue;
11187
+ }
11188
+ results.push({
11189
+ hook: pinnedInstall.name,
11190
+ success: true,
11191
+ pinned: { version: pinnedInstall.version, sha256: pinnedInstall.sha256, source: pinnedInstall.source }
11192
+ });
11193
+ } catch (error) {
11194
+ results.push({ hook: arg, success: false, error: error instanceof Error ? error.message : String(error) });
11195
+ }
10463
11196
  continue;
10464
11197
  }
10465
- const result = installHook(name, { scope, overwrite: true });
10466
- const resolved = resolveHook2(name);
11198
+ if (!installed.includes(arg)) {
11199
+ results.push({ hook: arg, success: false, error: "Not installed" });
11200
+ continue;
11201
+ }
11202
+ const result = installHook(arg, { scope, overwrite: true });
11203
+ const resolved = resolveHook2(arg);
10467
11204
  if (result.success && resolved) {
10468
11205
  const hash = await sha256File2(resolved.scriptPath);
10469
11206
  setPinnedHook2(resolved.name, { version: resolved.version, sha256: hash, source: resolved.source });
@@ -10484,6 +11221,9 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
10484
11221
  updated: results.filter((r) => r.success).map((r) => r.hook),
10485
11222
  failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error }))
10486
11223
  }));
11224
+ if (results.some((r) => !r.success)) {
11225
+ process.exitCode = 1;
11226
+ }
10487
11227
  return;
10488
11228
  }
10489
11229
  console.log(chalk2.bold(`
@@ -10496,6 +11236,9 @@ Updating hooks...
10496
11236
  console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
10497
11237
  }
10498
11238
  }
11239
+ if (results.some((r) => !r.success)) {
11240
+ process.exitCode = 1;
11241
+ }
10499
11242
  });
10500
11243
  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
11244
  if (hook) {