@hasna/hooks 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/index.js CHANGED
@@ -4354,7 +4354,8 @@ function resolveHook(name) {
4354
4354
  events: custom.manifest.events,
4355
4355
  description: custom.manifest.description ?? "Custom hook",
4356
4356
  scriptPath: custom.scriptPath,
4357
- meta: meta2
4357
+ meta: meta2,
4358
+ timeoutMs: custom.manifest.timeout_ms ?? null
4358
4359
  };
4359
4360
  }
4360
4361
  const meta = getHook(shortManifestName(name));
@@ -4368,7 +4369,8 @@ function resolveHook(name) {
4368
4369
  events: meta.events && meta.events.length > 0 ? meta.events : [meta.event],
4369
4370
  description: meta.description,
4370
4371
  scriptPath,
4371
- meta
4372
+ meta,
4373
+ timeoutMs: null
4372
4374
  };
4373
4375
  }
4374
4376
  var init_resolve = __esm(() => {
@@ -4376,1157 +4378,1549 @@ 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]);
5052
5344
  }
5053
- db.exec("COMMIT");
5054
- } catch (err) {
5055
- db.exec("ROLLBACK");
5056
- throw err;
5345
+ return [...new Set(registered2)];
5346
+ }
5347
+ const settings = readSettings(scope, target);
5348
+ if (!settings.hooks)
5349
+ return [];
5350
+ const registered = [];
5351
+ for (const eventKey of Object.keys(settings.hooks)) {
5352
+ for (const entry of settings.hooks[eventKey]) {
5353
+ for (const hook of entry.hooks || []) {
5354
+ const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
5355
+ const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
5356
+ const match = newMatch || oldMatch;
5357
+ if (match) {
5358
+ registered.push(match[1]);
5359
+ }
5360
+ }
5361
+ }
5362
+ }
5363
+ return [...new Set(registered)];
5364
+ }
5365
+ function getRegisteredHooks(scope = "global") {
5366
+ return getRegisteredHooksForTarget(scope, "claude");
5367
+ }
5368
+ function removeHook(name, scope = "global", target = "claude") {
5369
+ const shortName = shortHookName(name);
5370
+ if (target === "all") {
5371
+ const claudeRemoved = removeHookForTarget(shortName, scope, "claude");
5372
+ const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
5373
+ return claudeRemoved || geminiRemoved;
5374
+ }
5375
+ if (target === "codewith") {
5376
+ return false;
5057
5377
  }
5378
+ return removeHookForTarget(shortName, scope, target);
5058
5379
  }
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);
5380
+ function removeHookForTarget(name, scope, target) {
5381
+ const registered = getRegisteredHooksForTarget(scope, target);
5382
+ if (!registered.includes(name)) {
5383
+ return false;
5068
5384
  }
5385
+ unregisterHook(name, scope, target);
5386
+ return true;
5069
5387
  }
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));
5101
- }
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
- }
5423
- function setPinnedHook(name, entry) {
5424
- const lock = readLock();
5425
- lock.hooks[name] = entry;
5426
- return writeLock(lock);
5427
5658
  }
5428
- function getPinnedHook(name) {
5429
- return readLock().hooks[name];
5659
+ function touchProfile(id) {
5660
+ const profile = getProfile(id);
5661
+ if (!profile)
5662
+ return;
5663
+ profile.last_seen_at = new Date().toISOString();
5664
+ writeFileSync5(profilePath(id), JSON.stringify(profile, null, 2) + `
5665
+ `);
5430
5666
  }
5431
- function removePinnedHook(name) {
5432
- const lock = readLock();
5433
- if (!(name in lock.hooks))
5434
- return false;
5435
- delete lock.hooks[name];
5436
- writeLock(lock);
5437
- return true;
5667
+ function exportProfiles() {
5668
+ return listProfiles();
5438
5669
  }
5439
- function checkScriptHash(name, actual) {
5440
- const db = getDb();
5441
- const record = getHookRecord(db, name);
5442
- const pin = getPinnedHook(name);
5443
- const expected = record?.sha256 ?? pin?.sha256 ?? null;
5444
- if (expected !== null && expected !== actual) {
5445
- return { ok: false, pinned: true, expected, actual, name };
5446
- }
5447
- if (expected === null) {
5448
- const now3 = new Date().toISOString();
5449
- const version = pin?.version ?? record?.version ?? "0.0.0";
5450
- const source = pin?.source ?? record?.source_type ?? "local";
5451
- upsertHookRecord(db, {
5452
- name,
5453
- version,
5454
- sha256: actual,
5455
- source_type: source,
5456
- last_verified_at: now3
5457
- });
5458
- setPinnedHook(name, { version, sha256: actual, source });
5459
- return { ok: true, pinned: false, expected: null, actual, name };
5670
+ function importProfiles(profiles) {
5671
+ ensureProfilesDir();
5672
+ let imported = 0;
5673
+ let skipped = 0;
5674
+ for (const profile of profiles) {
5675
+ if (!profile.agent_id || !profile.agent_type) {
5676
+ skipped++;
5677
+ continue;
5678
+ }
5679
+ const path = profilePath(profile.agent_id);
5680
+ if (existsSync9(path)) {
5681
+ skipped++;
5682
+ continue;
5683
+ }
5684
+ writeFileSync5(path, JSON.stringify(profile, null, 2) + `
5685
+ `);
5686
+ imported++;
5460
5687
  }
5461
- const now2 = new Date().toISOString();
5462
- upsertHookRecord(db, {
5463
- name,
5464
- version: record?.version ?? pin?.version ?? "0.0.0",
5465
- sha256: expected,
5466
- source_type: record?.source_type ?? pin?.source ?? "local",
5467
- last_verified_at: now2
5468
- });
5469
- return { ok: true, pinned: true, expected, actual, name };
5688
+ return { imported, skipped };
5689
+ }
5690
+ var PROFILES_DIR;
5691
+ var init_profiles = __esm(() => {
5692
+ PROFILES_DIR = resolveProfilesDir();
5693
+ });
5694
+
5695
+ // src/lib/db-writer.ts
5696
+ var exports_db_writer = {};
5697
+ __export(exports_db_writer, {
5698
+ writeHookEvent: () => writeHookEvent,
5699
+ resolveEventType: () => resolveEventType,
5700
+ recordHookRun: () => recordHookRun,
5701
+ normalizeEventType: () => normalizeEventType
5702
+ });
5703
+ function nanoid2() {
5704
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 21);
5470
5705
  }
5471
- async function verifyScriptHash(name, scriptPath) {
5472
- return checkScriptHash(name, await sha256File(scriptPath));
5706
+ function writeHookEvent(event) {
5707
+ try {
5708
+ const db = getDb();
5709
+ const id = nanoid2();
5710
+ const timestamp = event.timestamp ?? new Date().toISOString();
5711
+ db.run(`INSERT INTO hook_events
5712
+ (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
5713
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
5714
+ id,
5715
+ timestamp,
5716
+ event.session_id,
5717
+ event.hook_name,
5718
+ event.event_type,
5719
+ event.tool_name ?? null,
5720
+ event.tool_input ? event.tool_input.slice(0, 500) : null,
5721
+ event.result ?? null,
5722
+ event.error ?? null,
5723
+ event.duration_ms ?? null,
5724
+ event.project_dir ?? null,
5725
+ event.metadata ?? null
5726
+ ]);
5727
+ } catch (err) {
5728
+ process.stderr.write(`[hooks db-writer] failed to write event: ${err}
5729
+ `);
5730
+ }
5473
5731
  }
5474
- function retrustHook(name, scriptPath, version, source) {
5475
- const actual = sha256Of(readFileSync6(scriptPath));
5476
- const db = getDb();
5477
- upsertHookRecord(db, {
5478
- name,
5479
- version,
5480
- sha256: actual,
5481
- source_type: source,
5482
- last_verified_at: new Date().toISOString()
5732
+ function normalizeEventType(value) {
5733
+ if (typeof value !== "string")
5734
+ return null;
5735
+ if (ALLOWED_EVENT_TYPES.has(value))
5736
+ return value;
5737
+ const bare = value.split(":")[0] ?? "";
5738
+ return ALLOWED_EVENT_TYPES.has(bare) ? bare : null;
5739
+ }
5740
+ function resolveEventType(inputEvent, fallbackEvent) {
5741
+ const fromInput = normalizeEventType(inputEvent);
5742
+ if (fromInput)
5743
+ return fromInput;
5744
+ return normalizeEventType(fallbackEvent ?? null);
5745
+ }
5746
+ function recordHookRun(record) {
5747
+ const eventType = record.eventType ? normalizeEventType(record.eventType) : null;
5748
+ if (!eventType) {
5749
+ process.stderr.write(`[hooks db-writer] skipped event for '${record.hookName}': unsupported event type '${String(record.eventType)}'
5750
+ `);
5751
+ return;
5752
+ }
5753
+ const metadata = {
5754
+ ...record.metadata ?? {},
5755
+ version: record.version ?? null,
5756
+ sha256: record.sha256 ?? null,
5757
+ exit_code: record.exitCode
5758
+ };
5759
+ writeHookEvent({
5760
+ session_id: record.sessionId ?? "cli",
5761
+ hook_name: record.hookName,
5762
+ event_type: eventType,
5763
+ tool_name: record.toolName ?? null,
5764
+ tool_input: record.toolInput !== undefined ? JSON.stringify(record.toolInput) : null,
5765
+ result: record.result ?? null,
5766
+ error: record.error ?? null,
5767
+ duration_ms: record.durationMs,
5768
+ project_dir: record.projectDir ?? null,
5769
+ metadata: JSON.stringify(metadata)
5483
5770
  });
5484
- setPinnedHook(name, { version, sha256: actual, source });
5485
- return { ok: true, pinned: true, expected: actual, actual, name };
5486
5771
  }
5487
- var init_store = __esm(() => {
5772
+ var ALLOWED_EVENT_TYPES;
5773
+ var init_db_writer = __esm(() => {
5488
5774
  init_db();
5489
- init_config();
5775
+ ALLOWED_EVENT_TYPES = new Set([
5776
+ "PreToolUse",
5777
+ "PostToolUse",
5778
+ "Stop",
5779
+ "Notification",
5780
+ "SessionStart",
5781
+ "SessionEnd",
5782
+ "UserPromptSubmit",
5783
+ "SubagentStart"
5784
+ ]);
5490
5785
  });
5491
5786
 
5492
5787
  // src/lib/run.ts
5493
5788
  var exports_run = {};
5494
5789
  __export(exports_run, {
5495
- executeVerifiedScript: () => executeVerifiedScript
5790
+ executeVerifiedScript: () => executeVerifiedScript,
5791
+ HookTimeoutError: () => HookTimeoutError
5496
5792
  });
5497
5793
  import { randomBytes } from "crypto";
5498
- import { rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
5794
+ import { rmSync as rmSync3, writeFileSync as writeFileSync6 } from "fs";
5499
5795
  import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join9 } from "path";
5500
5796
  function extensionOf(scriptPath) {
5501
5797
  const base = basename2(scriptPath);
5502
5798
  const dot = base.lastIndexOf(".");
5503
5799
  return dot > 0 ? base.slice(dot + 1) : "ts";
5504
5800
  }
5801
+ function shebangInterpreter(firstLine) {
5802
+ if (!firstLine.startsWith("#!"))
5803
+ return null;
5804
+ const rest = firstLine.slice(2).trim();
5805
+ if (!rest)
5806
+ return null;
5807
+ const parts = rest.split(/\s+/);
5808
+ if (parts[0] === "env" || parts[0] === "/usr/bin/env") {
5809
+ return parts[1] && parts[1] !== "-S" ? basename2(parts[1]) : parts[2] ? basename2(parts[2]) : null;
5810
+ }
5811
+ return basename2(parts[0]);
5812
+ }
5813
+ function interpreterFor(name, scriptPath, content) {
5814
+ const ext = extensionOf(scriptPath);
5815
+ const firstLine = content.subarray(0, 512).toString("utf8").split(`
5816
+ `, 1)[0] ?? "";
5817
+ const interp = shebangInterpreter(firstLine);
5818
+ if (interp) {
5819
+ if (interp === "bash" || interp === "sh")
5820
+ return { command: ["/bin/bash"], tempExt: "sh" };
5821
+ if (interp === "node" || interp === "bun")
5822
+ return { command: ["bun", "run"], tempExt: BUN_EXTENSIONS.has(ext) ? ext : "ts" };
5823
+ throw new Error(`Refusing to run hook '${name}': shebang '${firstLine}' is not a recognized interpreter (supported: bash/sh, node/bun)`);
5824
+ }
5825
+ if (BASH_EXTENSIONS.has(ext))
5826
+ return { command: ["/bin/bash"], tempExt: "sh" };
5827
+ if (BUN_EXTENSIONS.has(ext))
5828
+ return { command: ["bun", "run"], tempExt: ext };
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)`);
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
+ }
5505
5863
  async function executeVerifiedScript(options) {
5506
5864
  const scriptDir = dirname4(options.scriptPath);
5507
- const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes(12).toString("hex")}.${extensionOf(options.scriptPath)}`);
5865
+ const interpreter = interpreterFor(options.name, options.scriptPath, options.content);
5866
+ const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes(12).toString("hex")}.${interpreter.tempExt}`);
5867
+ let proc = null;
5868
+ let timedOut = false;
5869
+ const timeoutTimer = options.timeout ? setTimeout(() => {
5870
+ timedOut = true;
5871
+ killGroup(proc);
5872
+ }, options.timeout) : null;
5508
5873
  try {
5509
5874
  writeFileSync6(tempPath, options.content, { flag: "wx", mode: 384 });
5510
- const proc = Bun.spawn(["bun", "run", isAbsolute2(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
5875
+ proc = Bun.spawn([...interpreter.command, isAbsolute2(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
5511
5876
  stdin: new Response(options.stdin),
5512
5877
  stdout: "pipe",
5513
5878
  stderr: "pipe",
5514
5879
  env: options.env ?? process.env,
5515
- ...options.timeout ? { timeout: options.timeout } : {}
5880
+ detached: true
5516
5881
  });
5517
- const [stdoutText, stderrText, exitCode] = await Promise.all([
5518
- new Response(proc.stdout).text(),
5519
- new Response(proc.stderr).text(),
5520
- 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)
5521
5889
  ]);
5890
+ killGroup(proc);
5522
5891
  return { stdout: stdoutText, stderr: stderrText, exitCode };
5523
5892
  } finally {
5893
+ if (timeoutTimer)
5894
+ clearTimeout(timeoutTimer);
5895
+ try {
5896
+ rmSync3(tempPath, { force: true });
5897
+ } catch {}
5898
+ }
5899
+ }
5900
+ function killGroup(proc) {
5901
+ if (!proc)
5902
+ return;
5903
+ try {
5904
+ process.kill(-proc.pid, "SIGKILL");
5905
+ } catch {
5524
5906
  try {
5525
- rmSync2(tempPath, { force: true });
5907
+ process.kill(proc.pid, "SIGKILL");
5526
5908
  } catch {}
5527
5909
  }
5528
5910
  }
5529
- var init_run = () => {};
5911
+ var HookTimeoutError, BASH_EXTENSIONS, BUN_EXTENSIONS;
5912
+ var init_run = __esm(() => {
5913
+ HookTimeoutError = class HookTimeoutError extends Error {
5914
+ timeoutMs;
5915
+ constructor(timeoutMs) {
5916
+ super(`Hook timed out after ${timeoutMs}ms (process group killed)`);
5917
+ this.name = "HookTimeoutError";
5918
+ this.timeoutMs = timeoutMs;
5919
+ }
5920
+ };
5921
+ BASH_EXTENSIONS = new Set(["sh", "bash"]);
5922
+ BUN_EXTENSIONS = new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"]);
5923
+ });
5530
5924
 
5531
5925
  // src/lib/custom-install.ts
5532
5926
  var exports_custom_install = {};
@@ -5535,7 +5929,7 @@ __export(exports_custom_install, {
5535
5929
  installCustomSource: () => installCustomSource,
5536
5930
  ensureCustomDir: () => ensureCustomDir
5537
5931
  });
5538
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
5932
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync4 } from "fs";
5539
5933
  import { join as join10 } from "path";
5540
5934
  import { tmpdir } from "os";
5541
5935
  function manifestAt(dir) {
@@ -5573,7 +5967,7 @@ async function cloneToTemp(gitUrl) {
5573
5967
  proc.exited
5574
5968
  ]);
5575
5969
  if (exitCode !== 0) {
5576
- rmSync3(tmp, { recursive: true, force: true });
5970
+ rmSync4(tmp, { recursive: true, force: true });
5577
5971
  throw new Error(`git clone failed (${exitCode}): ${stderr.trim()}`);
5578
5972
  }
5579
5973
  return join10(tmp, "repo");
@@ -5588,58 +5982,275 @@ function isCustomSource(value) {
5588
5982
  return true;
5589
5983
  return existsSync10(value);
5590
5984
  }
5591
- async function installCustomSource(source) {
5592
- if (isGitUrl(source)) {
5593
- const repoDir = await cloneToTemp(source);
5594
- try {
5595
- return copyManifestDir(repoDir, source, "git");
5596
- } finally {
5597
- rmSync3(repoDir, { recursive: true, force: true });
5598
- }
5985
+ async function installCustomSource(source) {
5986
+ if (isGitUrl(source)) {
5987
+ const repoDir = await cloneToTemp(source);
5988
+ try {
5989
+ return copyManifestDir(repoDir, source, "git");
5990
+ } finally {
5991
+ rmSync4(repoDir, { recursive: true, force: true });
5992
+ }
5993
+ }
5994
+ if (source.startsWith("http://") || source.startsWith("https://")) {
5995
+ return installManifestUrl(source);
5996
+ }
5997
+ if (!existsSync10(source)) {
5998
+ throw new Error(`No such local path: ${source}`);
5999
+ }
6000
+ if (source.endsWith("manifest.json")) {
6001
+ const manifest = parseManifest(readFileSync7(source, "utf-8"));
6002
+ return writeFromManifest(manifest, source.substring(0, source.lastIndexOf("/")), source, "local");
6003
+ }
6004
+ return copyManifestDir(source, source, "local");
6005
+ }
6006
+ async function installManifestUrl(url) {
6007
+ const res = await fetch(url, { redirect: "follow" });
6008
+ if (!res.ok) {
6009
+ throw new Error(`Failed to fetch manifest (${res.status}): ${url}`);
6010
+ }
6011
+ const manifest = parseManifest(await res.text());
6012
+ let script;
6013
+ if (manifest.script.includes(`
6014
+ `)) {
6015
+ script = { path: "script.ts", content: manifest.script };
6016
+ } else {
6017
+ const base = url.substring(0, url.lastIndexOf("/"));
6018
+ const scriptUrl = `${base}/${manifest.script}`;
6019
+ const scriptRes = await fetch(scriptUrl, { redirect: "follow" });
6020
+ if (!scriptRes.ok) {
6021
+ throw new Error(`Failed to fetch script (${scriptRes.status}): ${scriptUrl}`);
6022
+ }
6023
+ script = { path: manifest.script, content: await scriptRes.text() };
6024
+ }
6025
+ const name = shortManifestName(manifest.name);
6026
+ const { dir, scriptPath } = writeCustomHook(name, manifest, script.content, script.path);
6027
+ return { name, version: manifest.version, kind: "url", source: url, dir, scriptPath };
6028
+ }
6029
+ function ensureCustomDir() {
6030
+ const dir = getCustomHooksDir();
6031
+ mkdirSync7(dir, { recursive: true });
6032
+ return dir;
6033
+ }
6034
+ var init_custom_install = __esm(() => {
6035
+ init_config();
6036
+ init_manifest();
6037
+ });
6038
+
6039
+ // src/lib/sync.ts
6040
+ var exports_sync = {};
6041
+ __export(exports_sync, {
6042
+ syncHooks: () => syncHooks,
6043
+ planSync: () => planSync,
6044
+ fetchPinnedHook: () => fetchPinnedHook
6045
+ });
6046
+ import { createHash as createHash2 } from "crypto";
6047
+ import { readFileSync as readFileSync8 } from "fs";
6048
+ function sha256OfText(text) {
6049
+ return createHash2("sha256").update(text).digest("hex");
6050
+ }
6051
+ function sha256OfPath(path) {
6052
+ return createHash2("sha256").update(readFileSync8(path)).digest("hex");
6053
+ }
6054
+ function collectBundledCatalog() {
6055
+ const out = [];
6056
+ for (const meta of HOOKS) {
6057
+ const scriptPath = resolveScriptPath(meta.name);
6058
+ if (!scriptPath)
6059
+ continue;
6060
+ out.push({
6061
+ name: meta.name,
6062
+ version: meta.version,
6063
+ sha256: sha256OfPath(scriptPath)
6064
+ });
6065
+ }
6066
+ return out;
6067
+ }
6068
+ async function fetchJson(base, path) {
6069
+ const headers = { accept: "application/json" };
6070
+ const apiKey = resolveApiKey();
6071
+ if (apiKey)
6072
+ headers["x-api-key"] = apiKey;
6073
+ const res = await fetch(`${base}${path}`, {
6074
+ headers,
6075
+ redirect: "error",
6076
+ signal: AbortSignal.timeout(15000)
6077
+ });
6078
+ if (res.status === 401) {
6079
+ throw new Error("registry requires API key \u2014 set HASNA_HOOKS_API_KEY or HOOKS_API_KEY (vault-delivered, never stored)");
6080
+ }
6081
+ if (!res.ok) {
6082
+ throw new Error(`GET ${path} failed with status ${res.status}`);
6083
+ }
6084
+ return res.json();
6085
+ }
6086
+ async function fetchRemoteState(apiUrl) {
6087
+ const catalog = await fetchJson(apiUrl, "/api/v1/catalog");
6088
+ const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
6089
+ if (!catalog.hooks || !Array.isArray(catalog.hooks)) {
6090
+ throw new Error("remote catalog is malformed: missing hooks array");
6091
+ }
6092
+ if (!remoteLock.hooks || typeof remoteLock.hooks !== "object") {
6093
+ throw new Error("remote lock is malformed: missing hooks map");
6094
+ }
6095
+ return { catalog: catalog.hooks, remoteLock };
6096
+ }
6097
+ function computeDiff(catalog, remoteLock) {
6098
+ const local = readLock();
6099
+ const diff = { added: [], updated: [], unchanged: [], skipped: [] };
6100
+ for (const entry of catalog) {
6101
+ if (!entry.name || !entry.version || !entry.sha256)
6102
+ continue;
6103
+ const pin = local.hooks[entry.name];
6104
+ const remotePin = remoteLock ? remoteLock.hooks[entry.name] : entry;
6105
+ if (remoteLock && !remotePin) {
6106
+ diff.skipped.push(entry.name);
6107
+ continue;
6108
+ }
6109
+ const pinnedLocally = pin && pin.version === entry.version && pin.sha256 === entry.sha256;
6110
+ if (pinnedLocally) {
6111
+ diff.unchanged.push(entry.name);
6112
+ } else if (pin) {
6113
+ diff.updated.push(entry.name);
6114
+ } else {
6115
+ diff.added.push(entry.name);
6116
+ }
6117
+ }
6118
+ return diff;
6119
+ }
6120
+ async function planSync() {
6121
+ const apiUrl = resolveApiUrl();
6122
+ if (apiUrl) {
6123
+ const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6124
+ return { apiUrl, dryRun: false, diff: computeDiff(catalog, remoteLock) };
6125
+ }
6126
+ const diff = computeDiff(collectBundledCatalog(), null);
6127
+ return { apiUrl: null, dryRun: false, diff };
6128
+ }
6129
+ async function syncHooks(options = {}) {
6130
+ const apiUrl = resolveApiUrl();
6131
+ const plan = await planSync();
6132
+ if (options.dryRun)
6133
+ return { ...plan, dryRun: true };
6134
+ if (!apiUrl) {
6135
+ const db2 = getDb();
6136
+ for (const entry of collectBundledCatalog()) {
6137
+ setPinnedHook(entry.name, { version: entry.version, sha256: entry.sha256, source: "bundled" });
6138
+ upsertHookRecord(db2, {
6139
+ name: entry.name,
6140
+ version: entry.version,
6141
+ sha256: entry.sha256,
6142
+ source_type: "bundled",
6143
+ source_ref: "apps/hooks/hooks",
6144
+ last_verified_at: new Date().toISOString()
6145
+ });
6146
+ }
6147
+ return plan;
6148
+ }
6149
+ const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6150
+ const diff = computeDiff(catalog, remoteLock);
6151
+ const toFetch = [...diff.added, ...diff.updated];
6152
+ const artifacts = new Map;
6153
+ for (const name of toFetch) {
6154
+ const entry = remoteLock.hooks[name];
6155
+ if (!entry)
6156
+ continue;
6157
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${entry.version}`);
6158
+ if (!artifact.manifest || typeof artifact.script !== "string") {
6159
+ throw new Error(`artifact for '${name}@${entry.version}' is malformed`);
6160
+ }
6161
+ const actualSha = sha256OfText(artifact.script);
6162
+ if (actualSha !== entry.sha256) {
6163
+ throw new Error(`sha256 mismatch for '${name}@${entry.version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
6164
+ }
6165
+ artifacts.set(name, artifact);
6166
+ }
6167
+ const db = getDb();
6168
+ for (const name of toFetch) {
6169
+ const artifact = artifacts.get(name);
6170
+ const entry = remoteLock.hooks[name];
6171
+ if (!artifact || !entry)
6172
+ continue;
6173
+ const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6174
+ const scriptRel = artifact.manifest.script.includes(`
6175
+ `) ? "script.ts" : artifact.manifest.script;
6176
+ writeCustomHook(name, manifest, artifact.script, scriptRel);
6177
+ const scriptPath = resolveScriptPath(name);
6178
+ const actual = await sha256File(scriptPath);
6179
+ if (actual !== entry.sha256) {
6180
+ throw new Error(`sha256 mismatch after write for '${name}@${entry.version}': lock says ${entry.sha256}, on disk ${actual}`);
6181
+ }
6182
+ setPinnedHook(name, { version: entry.version, sha256: entry.sha256, source: entry.source ?? "remote" });
6183
+ upsertHookRecord(db, {
6184
+ name,
6185
+ version: entry.version,
6186
+ sha256: entry.sha256,
6187
+ source_type: entry.source ?? "remote",
6188
+ source_ref: apiUrl,
6189
+ last_verified_at: new Date().toISOString()
6190
+ });
6191
+ }
6192
+ return { apiUrl, dryRun: false, diff };
6193
+ }
6194
+ async function fetchPinnedHook(name, version, apiUrl) {
6195
+ const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
6196
+ const entry = remoteLock.hooks?.[name];
6197
+ if (!entry) {
6198
+ throw new Error(`Hook '${name}' is not in the remote registry lock`);
5599
6199
  }
5600
- if (source.startsWith("http://") || source.startsWith("https://")) {
5601
- return installManifestUrl(source);
6200
+ if (entry.version !== version) {
6201
+ throw new Error(`Hook '${name}' version ${version} is not in the remote registry (available: ${entry.version})`);
5602
6202
  }
5603
- if (!existsSync10(source)) {
5604
- throw new Error(`No such local path: ${source}`);
6203
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${version}`);
6204
+ if (!artifact.manifest || typeof artifact.script !== "string") {
6205
+ throw new Error(`artifact for '${name}@${version}' is malformed`);
5605
6206
  }
5606
- if (source.endsWith("manifest.json")) {
5607
- const manifest = parseManifest(readFileSync7(source, "utf-8"));
5608
- return writeFromManifest(manifest, source.substring(0, source.lastIndexOf("/")), source, "local");
6207
+ const manifestName = shortManifestName(artifact.manifest.name ?? name);
6208
+ if (manifestName !== name) {
6209
+ throw new Error(`artifact for '${name}@${version}' declares a different hook name ('${manifestName}')`);
5609
6210
  }
5610
- return copyManifestDir(source, source, "local");
5611
- }
5612
- async function installManifestUrl(url) {
5613
- const res = await fetch(url, { redirect: "follow" });
5614
- if (!res.ok) {
5615
- throw new Error(`Failed to fetch manifest (${res.status}): ${url}`);
6211
+ if (artifact.manifest.version !== version) {
6212
+ throw new Error(`artifact for '${name}@${version}' declares a different version ('${artifact.manifest.version}')`);
5616
6213
  }
5617
- const manifest = parseManifest(await res.text());
5618
- let script;
5619
- if (manifest.script.includes(`
5620
- `)) {
5621
- script = { path: "script.ts", content: manifest.script };
5622
- } else {
5623
- const base = url.substring(0, url.lastIndexOf("/"));
5624
- const scriptUrl = `${base}/${manifest.script}`;
5625
- const scriptRes = await fetch(scriptUrl, { redirect: "follow" });
5626
- if (!scriptRes.ok) {
5627
- throw new Error(`Failed to fetch script (${scriptRes.status}): ${scriptUrl}`);
5628
- }
5629
- script = { path: manifest.script, content: await scriptRes.text() };
6214
+ const actualSha = sha256OfText(artifact.script);
6215
+ if (actualSha !== entry.sha256) {
6216
+ throw new Error(`sha256 mismatch for '${name}@${version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
5630
6217
  }
5631
- const name = shortManifestName(manifest.name);
5632
- const { dir, scriptPath } = writeCustomHook(name, manifest, script.content, script.path);
5633
- return { name, version: manifest.version, kind: "url", source: url, dir, scriptPath };
5634
- }
5635
- function ensureCustomDir() {
5636
- const dir = getCustomHooksDir();
5637
- mkdirSync7(dir, { recursive: true });
5638
- return dir;
6218
+ const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6219
+ const scriptRel = artifact.manifest.script.includes(`
6220
+ `) ? "script.ts" : artifact.manifest.script;
6221
+ writeCustomHook(name, manifest, artifact.script, scriptRel);
6222
+ const scriptPath = resolveScriptPath(name);
6223
+ const actual = await sha256File(scriptPath);
6224
+ if (actual !== entry.sha256) {
6225
+ throw new Error(`sha256 mismatch after write for '${name}@${version}': lock says ${entry.sha256}, on disk ${actual}`);
6226
+ }
6227
+ const db = getDb();
6228
+ setPinnedHook(name, { version, sha256: entry.sha256, source: entry.source ?? "remote" });
6229
+ upsertHookRecord(db, {
6230
+ name,
6231
+ version,
6232
+ sha256: entry.sha256,
6233
+ source_type: entry.source ?? "remote",
6234
+ source_ref: apiUrl,
6235
+ last_verified_at: new Date().toISOString()
6236
+ });
6237
+ return {
6238
+ name,
6239
+ version,
6240
+ sha256: entry.sha256,
6241
+ source: entry.source ?? "remote",
6242
+ source_ref: apiUrl,
6243
+ artifact,
6244
+ scriptPath
6245
+ };
5639
6246
  }
5640
- var init_custom_install = __esm(() => {
6247
+ var init_sync = __esm(() => {
6248
+ init_registry();
5641
6249
  init_config();
6250
+ init_db();
6251
+ init_store();
5642
6252
  init_manifest();
6253
+ init_resolve();
5643
6254
  });
5644
6255
 
5645
6256
  // src/db/pg-migrations.ts
@@ -6149,234 +6760,75 @@ function authorized(req, apiKey) {
6149
6760
  return secureEqual(header.slice("Bearer ".length), apiKey);
6150
6761
  return secureEqual(req.headers.get("x-api-key") ?? "", apiKey);
6151
6762
  }
6152
- function json(body, status = 200, headers = {}) {
6153
- return new Response(JSON.stringify(body), {
6154
- status,
6155
- headers: { "content-type": "application/json", ...headers }
6156
- });
6157
- }
6158
- function handleServeRequest(req, apiKey) {
6159
- const url = new URL(req.url);
6160
- if (url.pathname === "/health" && req.method === "GET") {
6161
- return Promise.resolve(json({ status: "ok", name: SERVE_SERVICE_NAME }));
6162
- }
6163
- if (url.pathname === "/api/v1/catalog" && req.method === "GET") {
6164
- return buildCatalog().then((catalog) => json({ hooks: catalog }));
6165
- }
6166
- if (url.pathname === "/api/v1/lock" && req.method === "GET") {
6167
- return Promise.resolve(json(readLock()));
6168
- }
6169
- const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/(\d+\.\d+\.\d+)$/.exec(url.pathname);
6170
- if (artifactMatch && req.method === "GET") {
6171
- const [, name, version] = artifactMatch;
6172
- return artifactFor(name, version).then(async (artifact) => {
6173
- if (!artifact)
6174
- return json({ error: `Hook '${name}@${version}' not found locally` }, 404);
6175
- const resolved = resolveHook(name);
6176
- const sha = resolved ? await sha256File(resolved.scriptPath) : artifact.manifest.name;
6177
- return json(artifact, 200, { "x-hook-sha256": sha });
6178
- });
6179
- }
6180
- if (url.pathname === "/api/v1/hooks" && req.method === "PUT") {
6181
- if (!authorized(req, apiKey)) {
6182
- return Promise.resolve(json({ error: "unauthorized: valid API key required to publish" }, 401));
6183
- }
6184
- return req.json().then((body) => {
6185
- const name = body.name ?? "";
6186
- const version = body.version ?? "";
6187
- const resolved = resolveHook(name);
6188
- if (!resolved)
6189
- return json({ error: `Hook '${name}' not found in local store` }, 404);
6190
- if (version && version !== resolved.version) {
6191
- return json({ error: `Version mismatch: local '${name}' is ${resolved.version}, requested ${version}` }, 409);
6192
- }
6193
- const check = retrustHook(name, resolved.scriptPath, resolved.version, "serve");
6194
- return json({ ok: true, hook: { name, version: resolved.version, sha256: check.actual } });
6195
- }, () => json({ error: "invalid JSON body" }, 400));
6196
- }
6197
- return Promise.resolve(json({ error: "Not Found" }, 404));
6198
- }
6199
- function startServeServer(options) {
6200
- const port = options.port ?? DEFAULT_SERVE_PORT;
6201
- const host = options.host ?? SERVE_HOST;
6202
- const apiKey = resolveApiKey(options.apiKey);
6203
- const server = Bun.serve({
6204
- hostname: host,
6205
- port,
6206
- async fetch(req) {
6207
- return handleServeRequest(req, apiKey);
6208
- }
6209
- });
6210
- console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);
6211
- return server;
6212
- }
6213
- var DEFAULT_SERVE_PORT = 39428, SERVE_HOST = "127.0.0.1", SERVE_SERVICE_NAME = "hooks-registry";
6214
- var init_serve = __esm(() => {
6215
- init_registry();
6216
- init_manifest();
6217
- init_store();
6218
- init_resolve();
6219
- init_config();
6220
- if (false) {}
6221
- });
6222
-
6223
- // src/lib/sync.ts
6224
- var exports_sync = {};
6225
- __export(exports_sync, {
6226
- syncHooks: () => syncHooks,
6227
- planSync: () => planSync
6228
- });
6229
- import { createHash as createHash2 } from "crypto";
6230
- import { readFileSync as readFileSync8 } from "fs";
6231
- function sha256OfText(text) {
6232
- return createHash2("sha256").update(text).digest("hex");
6233
- }
6234
- function sha256OfPath(path) {
6235
- return createHash2("sha256").update(readFileSync8(path)).digest("hex");
6236
- }
6237
- function collectBundledCatalog() {
6238
- const out = [];
6239
- for (const meta of HOOKS) {
6240
- const scriptPath = resolveScriptPath(meta.name);
6241
- if (!scriptPath)
6242
- continue;
6243
- out.push({
6244
- name: meta.name,
6245
- version: meta.version,
6246
- sha256: sha256OfPath(scriptPath)
6247
- });
6248
- }
6249
- return out;
6250
- }
6251
- async function fetchJson(base, path) {
6252
- const headers = { accept: "application/json" };
6253
- const apiKey = resolveApiKey();
6254
- if (apiKey)
6255
- headers["x-api-key"] = apiKey;
6256
- const res = await fetch(`${base}${path}`, {
6257
- headers,
6258
- signal: AbortSignal.timeout(15000)
6259
- });
6260
- if (res.status === 401) {
6261
- throw new Error("registry requires API key \u2014 set HASNA_HOOKS_API_KEY or HOOKS_API_KEY (vault-delivered, never stored)");
6262
- }
6263
- if (!res.ok) {
6264
- throw new Error(`GET ${path} failed with status ${res.status}`);
6265
- }
6266
- return res.json();
6267
- }
6268
- async function fetchRemoteState(apiUrl) {
6269
- const catalog = await fetchJson(apiUrl, "/api/v1/catalog");
6270
- const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
6271
- if (!catalog.hooks || !Array.isArray(catalog.hooks)) {
6272
- throw new Error("remote catalog is malformed: missing hooks array");
6273
- }
6274
- if (!remoteLock.hooks || typeof remoteLock.hooks !== "object") {
6275
- throw new Error("remote lock is malformed: missing hooks map");
6276
- }
6277
- return { catalog: catalog.hooks, remoteLock };
6278
- }
6279
- function computeDiff(catalog, remoteLock) {
6280
- const local = readLock();
6281
- const diff = { added: [], updated: [], unchanged: [], skipped: [] };
6282
- for (const entry of catalog) {
6283
- if (!entry.name || !entry.version || !entry.sha256)
6284
- continue;
6285
- const pin = local.hooks[entry.name];
6286
- const remotePin = remoteLock ? remoteLock.hooks[entry.name] : entry;
6287
- if (remoteLock && !remotePin) {
6288
- diff.skipped.push(entry.name);
6289
- continue;
6290
- }
6291
- const pinnedLocally = pin && pin.version === entry.version && pin.sha256 === entry.sha256;
6292
- if (pinnedLocally) {
6293
- diff.unchanged.push(entry.name);
6294
- } else if (pin) {
6295
- diff.updated.push(entry.name);
6296
- } else {
6297
- diff.added.push(entry.name);
6298
- }
6299
- }
6300
- return diff;
6301
- }
6302
- async function planSync() {
6303
- const apiUrl = resolveApiUrl();
6304
- if (apiUrl) {
6305
- const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6306
- return { apiUrl, dryRun: false, diff: computeDiff(catalog, remoteLock) };
6307
- }
6308
- const diff = computeDiff(collectBundledCatalog(), null);
6309
- return { apiUrl: null, dryRun: false, diff };
6310
- }
6311
- async function syncHooks(options = {}) {
6312
- const apiUrl = resolveApiUrl();
6313
- const plan = await planSync();
6314
- if (options.dryRun)
6315
- return { ...plan, dryRun: true };
6316
- if (!apiUrl) {
6317
- const db2 = getDb();
6318
- for (const entry of collectBundledCatalog()) {
6319
- setPinnedHook(entry.name, { version: entry.version, sha256: entry.sha256, source: "bundled" });
6320
- upsertHookRecord(db2, {
6321
- name: entry.name,
6322
- version: entry.version,
6323
- sha256: entry.sha256,
6324
- source_type: "bundled",
6325
- source_ref: "apps/hooks/hooks",
6326
- last_verified_at: new Date().toISOString()
6327
- });
6328
- }
6329
- return plan;
6330
- }
6331
- const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6332
- const diff = computeDiff(catalog, remoteLock);
6333
- const toFetch = [...diff.added, ...diff.updated];
6334
- const artifacts = new Map;
6335
- for (const name of toFetch) {
6336
- const entry = remoteLock.hooks[name];
6337
- if (!entry)
6338
- continue;
6339
- const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${entry.version}`);
6340
- if (!artifact.manifest || typeof artifact.script !== "string") {
6341
- throw new Error(`artifact for '${name}@${entry.version}' is malformed`);
6342
- }
6343
- const actualSha = sha256OfText(artifact.script);
6344
- if (actualSha !== entry.sha256) {
6345
- throw new Error(`sha256 mismatch for '${name}@${entry.version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
6346
- }
6347
- artifacts.set(name, artifact);
6763
+ function json(body, status = 200, headers = {}) {
6764
+ return new Response(JSON.stringify(body), {
6765
+ status,
6766
+ headers: { "content-type": "application/json", ...headers }
6767
+ });
6768
+ }
6769
+ function handleServeRequest(req, apiKey) {
6770
+ const url = new URL(req.url);
6771
+ if (url.pathname === "/health" && req.method === "GET") {
6772
+ return Promise.resolve(json({ status: "ok", name: SERVE_SERVICE_NAME }));
6348
6773
  }
6349
- const db = getDb();
6350
- for (const name of toFetch) {
6351
- const artifact = artifacts.get(name);
6352
- const entry = remoteLock.hooks[name];
6353
- if (!artifact || !entry)
6354
- continue;
6355
- const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6356
- const scriptRel = artifact.manifest.script.includes(`
6357
- `) ? "script.ts" : artifact.manifest.script;
6358
- writeCustomHook(name, manifest, artifact.script, scriptRel);
6359
- const scriptPath = resolveScriptPath(name);
6360
- const actual = await sha256File(scriptPath);
6361
- setPinnedHook(name, { version: entry.version, sha256: actual, source: entry.source ?? "remote" });
6362
- upsertHookRecord(db, {
6363
- name,
6364
- version: entry.version,
6365
- sha256: actual,
6366
- source_type: entry.source ?? "remote",
6367
- source_ref: apiUrl,
6368
- last_verified_at: new Date().toISOString()
6774
+ if (url.pathname === "/api/v1/catalog" && req.method === "GET") {
6775
+ return buildCatalog().then((catalog) => json({ hooks: catalog }));
6776
+ }
6777
+ if (url.pathname === "/api/v1/lock" && req.method === "GET") {
6778
+ return Promise.resolve(json(readLock()));
6779
+ }
6780
+ const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/(\d+\.\d+\.\d+)$/.exec(url.pathname);
6781
+ if (artifactMatch && req.method === "GET") {
6782
+ const [, name, version] = artifactMatch;
6783
+ return artifactFor(name, version).then(async (artifact) => {
6784
+ if (!artifact)
6785
+ return json({ error: `Hook '${name}@${version}' not found locally` }, 404);
6786
+ const resolved = resolveHook(name);
6787
+ const sha = resolved ? await sha256File(resolved.scriptPath) : artifact.manifest.name;
6788
+ return json(artifact, 200, { "x-hook-sha256": sha });
6369
6789
  });
6370
6790
  }
6371
- return { apiUrl, dryRun: false, diff };
6791
+ if (url.pathname === "/api/v1/hooks" && req.method === "PUT") {
6792
+ if (!authorized(req, apiKey)) {
6793
+ return Promise.resolve(json({ error: "unauthorized: valid API key required to publish" }, 401));
6794
+ }
6795
+ return req.json().then((body) => {
6796
+ const name = body.name ?? "";
6797
+ const version = body.version ?? "";
6798
+ const resolved = resolveHook(name);
6799
+ if (!resolved)
6800
+ return json({ error: `Hook '${name}' not found in local store` }, 404);
6801
+ if (version && version !== resolved.version) {
6802
+ return json({ error: `Version mismatch: local '${name}' is ${resolved.version}, requested ${version}` }, 409);
6803
+ }
6804
+ const check = retrustHook(name, resolved.scriptPath, resolved.version, "serve");
6805
+ return json({ ok: true, hook: { name, version: resolved.version, sha256: check.actual } });
6806
+ }, () => json({ error: "invalid JSON body" }, 400));
6807
+ }
6808
+ return Promise.resolve(json({ error: "Not Found" }, 404));
6372
6809
  }
6373
- var init_sync = __esm(() => {
6810
+ function startServeServer(options) {
6811
+ const port = options.port ?? DEFAULT_SERVE_PORT;
6812
+ const host = options.host ?? SERVE_HOST;
6813
+ const apiKey = resolveApiKey(options.apiKey);
6814
+ const server = Bun.serve({
6815
+ hostname: host,
6816
+ port,
6817
+ async fetch(req) {
6818
+ return handleServeRequest(req, apiKey);
6819
+ }
6820
+ });
6821
+ console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);
6822
+ return server;
6823
+ }
6824
+ var DEFAULT_SERVE_PORT = 39428, SERVE_HOST = "127.0.0.1", SERVE_SERVICE_NAME = "hooks-registry";
6825
+ var init_serve = __esm(() => {
6374
6826
  init_registry();
6375
- init_config();
6376
- init_db();
6377
- init_store();
6378
6827
  init_manifest();
6828
+ init_store();
6379
6829
  init_resolve();
6830
+ init_config();
6831
+ if (false) {}
6380
6832
  });
6381
6833
 
6382
6834
  // src/cf/provision.ts
@@ -6476,14 +6928,77 @@ import { createServer } from "http";
6476
6928
  import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
6477
6929
  import { join as join11, dirname as dirname5 } from "path";
6478
6930
  import { fileURLToPath as fileURLToPath3 } from "url";
6479
- async function runVerifiedHook(name, scriptPath, stdin, timeoutMs) {
6480
- const content = readFileSync9(scriptPath);
6481
- const check = checkScriptHash(name, sha256Of(content));
6931
+ async function runVerifiedHook(name, stdin, timeoutMs) {
6932
+ const { resolveHook: resolveHook2, resolveScriptPath: resolveScriptPath2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
6933
+ const resolved = resolveHook2(name);
6934
+ const hookScript = resolveScriptPath2(name);
6935
+ if (!resolved || !hookScript || !existsSync11(hookScript)) {
6936
+ throw new Error(`Hook '${name}' not found`);
6937
+ }
6938
+ const content = readFileSync9(hookScript);
6939
+ const sha = sha256Of(content);
6940
+ const check = checkScriptHash(name, sha);
6482
6941
  if (!check.ok) {
6483
6942
  throw new Error(`Hook '${name}' script changed since it was trusted (sha256 ${check.expected} != ${check.actual}). Run 'hooks trust ${name}' to trust the new content.`);
6484
6943
  }
6485
- const { executeVerifiedScript: executeVerifiedScript2 } = await Promise.resolve().then(() => (init_run(), exports_run));
6486
- return executeVerifiedScript2({ name, scriptPath, content, stdin, env: process.env, timeout: timeoutMs });
6944
+ const { executeVerifiedScript: executeVerifiedScript2, HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
6945
+ const started = Date.now();
6946
+ let ran;
6947
+ try {
6948
+ ran = await executeVerifiedScript2({ name, scriptPath: hookScript, content, stdin, env: process.env, timeout: timeoutMs });
6949
+ } catch (err) {
6950
+ try {
6951
+ const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
6952
+ let inputJson = {};
6953
+ try {
6954
+ inputJson = JSON.parse(stdin);
6955
+ } catch {}
6956
+ recordHookRun2({
6957
+ hookName: name,
6958
+ eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
6959
+ version: resolved.version,
6960
+ sha256: sha,
6961
+ sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
6962
+ toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
6963
+ toolInput: inputJson.tool_input,
6964
+ error: err instanceof Error ? err.message.slice(0, 500) : String(err),
6965
+ exitCode: -1,
6966
+ durationMs: Date.now() - started,
6967
+ projectDir: process.cwd()
6968
+ });
6969
+ } catch {}
6970
+ if (err instanceof HookTimeoutError2)
6971
+ throw err;
6972
+ throw err;
6973
+ }
6974
+ const durationMs = Date.now() - started;
6975
+ try {
6976
+ const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
6977
+ let inputJson = {};
6978
+ try {
6979
+ inputJson = JSON.parse(stdin);
6980
+ } catch {}
6981
+ let outputJson = {};
6982
+ try {
6983
+ outputJson = JSON.parse(ran.stdout);
6984
+ } catch {}
6985
+ const blocked = outputJson.decision === "block" || outputJson.continue === false;
6986
+ recordHookRun2({
6987
+ hookName: name,
6988
+ eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
6989
+ version: resolved.version,
6990
+ sha256: sha,
6991
+ sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
6992
+ toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
6993
+ toolInput: inputJson.tool_input,
6994
+ result: blocked ? "block" : "continue",
6995
+ error: ran.exitCode !== 0 ? (ran.stderr || `hook exited with code ${ran.exitCode}`).slice(0, 500) : null,
6996
+ exitCode: ran.exitCode,
6997
+ durationMs,
6998
+ projectDir: process.cwd()
6999
+ });
7000
+ } catch {}
7001
+ return ran;
6487
7002
  }
6488
7003
  function formatInstallResults(results, extra) {
6489
7004
  const installed = results.filter((r) => r.success).map((r) => r.hook);
@@ -6765,17 +7280,14 @@ function createHooksServer() {
6765
7280
  name: z2.string().describe("Hook name (e.g. 'gitguard', 'checkpoint')"),
6766
7281
  input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Hook input as JSON object (HookInput)"),
6767
7282
  profile: z2.string().optional().describe("Agent profile ID to inject into hook input"),
6768
- timeout_ms: z2.number().default(1e4).describe("Timeout in milliseconds (default: 10000)")
7283
+ timeout_ms: z2.number().int().positive().max(600000).optional().describe("Timeout in milliseconds (default: the hook's manifest timeout_ms or 10000)")
6769
7284
  }, async ({ name, input, profile, timeout_ms }) => {
6770
- const meta = getHook(name);
6771
- if (!meta) {
7285
+ const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
7286
+ const resolved = resolveHook2(name);
7287
+ if (!resolved) {
6772
7288
  return { content: [{ type: "text", text: JSON.stringify({ error: `Hook '${name}' not found` }) }] };
6773
7289
  }
6774
- const hookDir = getHookPath(name);
6775
- const hookScript = join11(hookDir, "src", "hook.ts");
6776
- if (!existsSync11(hookScript)) {
6777
- return { content: [{ type: "text", text: JSON.stringify({ error: `Hook script not found: ${hookScript}` }) }] };
6778
- }
7290
+ const effectiveTimeout = timeout_ms ?? resolved.timeoutMs ?? 1e4;
6779
7291
  let hookInput = { ...input };
6780
7292
  if (profile) {
6781
7293
  const p = getProfile(profile);
@@ -6788,13 +7300,38 @@ function createHooksServer() {
6788
7300
  };
6789
7301
  }
6790
7302
  }
6791
- let ran = null;
6792
7303
  try {
6793
- ran = await Promise.race([
6794
- runVerifiedHook(name, hookScript, JSON.stringify(hookInput), timeout_ms),
6795
- new Promise((resolve2) => setTimeout(() => resolve2(null), timeout_ms))
6796
- ]);
7304
+ const ran = await runVerifiedHook(name, JSON.stringify(hookInput), effectiveTimeout);
7305
+ const stdoutText = ran.stdout;
7306
+ const stderrText = ran.stderr;
7307
+ let output = {};
7308
+ try {
7309
+ output = JSON.parse(stdoutText);
7310
+ } catch {
7311
+ output = stdoutText ? { raw: stdoutText } : {};
7312
+ }
7313
+ return {
7314
+ content: [{
7315
+ type: "text",
7316
+ text: JSON.stringify({
7317
+ hook: name,
7318
+ output,
7319
+ stderr: stderrText || undefined,
7320
+ exitCode: ran.exitCode,
7321
+ timeout_ms: effectiveTimeout
7322
+ })
7323
+ }]
7324
+ };
6797
7325
  } catch (err) {
7326
+ const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
7327
+ if (err instanceof HookTimeoutError2) {
7328
+ return {
7329
+ content: [{
7330
+ type: "text",
7331
+ text: JSON.stringify({ hook: name, timedOut: true, timeout_ms: effectiveTimeout, error: err.message })
7332
+ }]
7333
+ };
7334
+ }
6798
7335
  return {
6799
7336
  content: [{
6800
7337
  type: "text",
@@ -6802,27 +7339,6 @@ function createHooksServer() {
6802
7339
  }]
6803
7340
  };
6804
7341
  }
6805
- const timedOut = ran === null;
6806
- const stdoutText = ran?.stdout ?? "";
6807
- const stderrText = ran?.stderr ?? "";
6808
- let output = {};
6809
- try {
6810
- output = JSON.parse(stdoutText);
6811
- } catch {
6812
- output = stdoutText ? { raw: stdoutText } : {};
6813
- }
6814
- return {
6815
- content: [{
6816
- type: "text",
6817
- text: JSON.stringify({
6818
- hook: name,
6819
- output,
6820
- stderr: stderrText || undefined,
6821
- exitCode: ran?.exitCode ?? -1,
6822
- ...timedOut ? { timedOut: true, timeout_ms } : {}
6823
- })
6824
- }]
6825
- };
6826
7342
  });
6827
7343
  defineTool("hooks_update", "Re-register installed hooks to pick up new package version (reinstalls with overwrite)", {
6828
7344
  hooks: z2.array(z2.string()).optional().describe("Hook names to update (omit to update all installed hooks)"),
@@ -6883,11 +7399,11 @@ function createHooksServer() {
6883
7399
  tool_name: z2.string().describe("Tool name to simulate (e.g. 'Bash', 'Write', 'Edit')"),
6884
7400
  tool_input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Tool input to pass to matching hooks"),
6885
7401
  scope: z2.enum(["global", "project"]).default("global").describe("Scope to check"),
6886
- timeout_ms: z2.number().default(5000).describe("Per-hook timeout in milliseconds")
7402
+ timeout_ms: z2.number().int().positive().max(600000).optional().describe("Per-hook timeout in milliseconds (default: the hook's manifest timeout_ms or 5000)")
6887
7403
  }, async ({ tool_name, tool_input, scope, timeout_ms }) => {
6888
7404
  const registered = getRegisteredHooks(scope);
6889
7405
  const matchingHooks = registered.filter((name) => {
6890
- const meta = getHook(name);
7406
+ const meta = resolveHookMeta(name) ?? getHook(name);
6891
7407
  if (!meta || meta.event !== "PreToolUse")
6892
7408
  return false;
6893
7409
  if (!meta.matcher)
@@ -6903,26 +7419,23 @@ function createHooksServer() {
6903
7419
  }
6904
7420
  const input = { tool_name, tool_input };
6905
7421
  const results = await Promise.all(matchingHooks.map(async (name) => {
6906
- const hookDir = getHookPath(name);
6907
- const hookScript = join11(hookDir, "src", "hook.ts");
6908
- if (!existsSync11(hookScript))
6909
- return { name, decision: "approve", error: "script not found" };
6910
- let ran = null;
7422
+ const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
7423
+ const resolved = resolveHook2(name);
7424
+ const effectiveTimeout = timeout_ms ?? resolved?.timeoutMs ?? 5000;
6911
7425
  try {
6912
- ran = await Promise.race([
6913
- runVerifiedHook(name, hookScript, JSON.stringify(input), timeout_ms),
6914
- new Promise((r) => setTimeout(() => r(null), timeout_ms))
6915
- ]);
7426
+ const ran = await runVerifiedHook(name, JSON.stringify(input), effectiveTimeout);
7427
+ let output = {};
7428
+ try {
7429
+ output = JSON.parse(ran.stdout);
7430
+ } catch {}
7431
+ return { name, decision: output.decision ?? "approve", reason: output.reason, raw: output };
6916
7432
  } catch (err) {
7433
+ const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
7434
+ if (err instanceof HookTimeoutError2) {
7435
+ return { name, decision: "approve", timedOut: true, timeout_ms: effectiveTimeout };
7436
+ }
6917
7437
  return { name, decision: "block", error: err instanceof Error ? err.message : String(err) };
6918
7438
  }
6919
- if (ran === null)
6920
- return { name, decision: "approve", timedOut: true };
6921
- let output = {};
6922
- try {
6923
- output = JSON.parse(ran.stdout);
6924
- } catch {}
6925
- return { name, decision: output.decision ?? "approve", reason: output.reason, raw: output };
6926
7439
  }));
6927
7440
  const blocked = results.find((r) => r.decision === "block");
6928
7441
  return {
@@ -6962,33 +7475,31 @@ function createHooksServer() {
6962
7475
  name: z2.string().describe("Hook name"),
6963
7476
  input: z2.record(z2.string(), z2.unknown()).default(() => ({})).describe("Hook input JSON")
6964
7477
  })).describe("List of hooks to run with their inputs"),
6965
- timeout_ms: z2.number().default(1e4).describe("Per-hook timeout in milliseconds")
7478
+ timeout_ms: z2.number().int().positive().max(600000).optional().describe("Per-hook timeout in milliseconds (default: the hook's manifest timeout_ms or 10000)")
6966
7479
  }, async ({ hooks, timeout_ms }) => {
6967
7480
  const results = await Promise.all(hooks.map(async ({ name, input }) => {
6968
- const meta = getHook(name);
6969
- if (!meta)
7481
+ const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
7482
+ const resolved = resolveHook2(name);
7483
+ if (!resolved)
6970
7484
  return { name, error: `Hook '${name}' not found` };
6971
- const hookScript = join11(getHookPath(name), "src", "hook.ts");
6972
- if (!existsSync11(hookScript))
6973
- return { name, error: "script not found" };
6974
- let ran = null;
7485
+ const effectiveTimeout = timeout_ms ?? resolved.timeoutMs ?? 1e4;
6975
7486
  try {
6976
- ran = await Promise.race([
6977
- runVerifiedHook(name, hookScript, JSON.stringify(input), timeout_ms),
6978
- new Promise((r) => setTimeout(() => r(null), timeout_ms))
6979
- ]);
7487
+ const ran = await runVerifiedHook(name, JSON.stringify(input), effectiveTimeout);
7488
+ const stdoutText = ran.stdout;
7489
+ let output = {};
7490
+ try {
7491
+ output = JSON.parse(stdoutText);
7492
+ } catch {
7493
+ output = stdoutText ? { raw: stdoutText } : {};
7494
+ }
7495
+ return { name, output, exitCode: ran.exitCode, timeout_ms: effectiveTimeout };
6980
7496
  } catch (err) {
7497
+ const { HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
7498
+ if (err instanceof HookTimeoutError2) {
7499
+ return { name, error: err.message, timedOut: true, timeout_ms: effectiveTimeout };
7500
+ }
6981
7501
  return { name, error: err instanceof Error ? err.message : String(err) };
6982
7502
  }
6983
- const stdoutText = ran?.stdout ?? "";
6984
- const timedOut = ran === null;
6985
- let output = {};
6986
- try {
6987
- output = JSON.parse(stdoutText);
6988
- } catch {
6989
- output = stdoutText ? { raw: stdoutText } : {};
6990
- }
6991
- return { name, output, exitCode: ran?.exitCode ?? -1, ...timedOut ? { timedOut: true } : {} };
6992
7503
  }));
6993
7504
  return { content: [{ type: "text", text: JSON.stringify({ results, count: results.length }) }] };
6994
7505
  });
@@ -8618,7 +9129,7 @@ var {
8618
9129
 
8619
9130
  // src/cli/index.tsx
8620
9131
  import chalk2 from "chalk";
8621
- import { existsSync as existsSync12, readFileSync as readFileSync10 } from "fs";
9132
+ import { existsSync as existsSync12, readFileSync as readFileSync10, rmSync as rmSync5 } from "fs";
8622
9133
  import { join as join12, dirname as dirname6 } from "path";
8623
9134
  import { fileURLToPath as fileURLToPath4 } from "url";
8624
9135
 
@@ -9797,6 +10308,7 @@ init_installer();
9797
10308
  init_profiles();
9798
10309
  init_manifest();
9799
10310
  init_resolve();
10311
+ init_store();
9800
10312
  import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
9801
10313
  var __dirname4 = dirname6(fileURLToPath4(import.meta.url));
9802
10314
  var pkgPath = existsSync12(join12(__dirname4, "..", "package.json")) ? join12(__dirname4, "..", "package.json") : join12(__dirname4, "..", "..", "package.json");
@@ -9849,6 +10361,22 @@ function hookSummaryLine(hook, options = {}) {
9849
10361
  const description = options.verbose ? ` - ${truncateText2(hook.description, 110)}` : "";
9850
10362
  return ` ${chalk2.cyan(hook.name.padEnd(17))} ${chalk2.dim(`[${hook.event}${matcher}]`)} ${chalk2.dim(hook.category)}${description}`;
9851
10363
  }
10364
+ function listStoreHooks() {
10365
+ const out = [];
10366
+ for (const parsed of listCustomHooks()) {
10367
+ const name = parsed.manifest.name;
10368
+ const pin = getPinnedHook(name);
10369
+ const source = pin?.source === "remote" || pin?.source === "registry" ? "registry" : "custom";
10370
+ const meta = resolveHookMeta(name);
10371
+ if (!meta)
10372
+ continue;
10373
+ out.push({
10374
+ meta: { ...meta, version: pin?.version ?? meta.version },
10375
+ source
10376
+ });
10377
+ }
10378
+ return out.sort((a, b) => a.meta.name.localeCompare(b.meta.name));
10379
+ }
9852
10380
  function printDisclosureHint(hidden, detailCommand, options = {}) {
9853
10381
  const rowControls = options.includeAll ? "--limit, --all, --verbose" : "--limit, --verbose";
9854
10382
  if (hidden > 0) {
@@ -9889,24 +10417,23 @@ program2.command("init").description("Register a new agent profile with a unique
9889
10417
  console.log(chalk2.red(message));
9890
10418
  return;
9891
10419
  }
9892
- const config = { api_url: options.apiUrl.replace(/\/+$/, ""), api_key_ref: options.apiKey };
10420
+ const apiKeyRef = options.apiKey ?? "hasna/hooks/live/api-key";
10421
+ const config = { api_url: options.apiUrl.replace(/\/+$/, ""), api_key_ref: apiKeyRef };
9893
10422
  const path = writeConfig2(config);
9894
10423
  if (options.json) {
9895
- console.log(JSON.stringify({ ok: true, api_url: config.api_url, api_key_ref: config.api_key_ref ?? null, config_path: path }));
10424
+ console.log(JSON.stringify({ ok: true, api_url: config.api_url, api_key_ref: config.api_key_ref, config_path: path }));
9896
10425
  return;
9897
10426
  }
9898
10427
  console.log(chalk2.green(`
9899
10428
  \u2713 Remote registry configured
9900
10429
  `));
9901
10430
  console.log(` ${chalk2.dim("API URL:")} ${config.api_url}`);
9902
- console.log(` ${chalk2.dim("API key ref:")} ${config.api_key_ref ?? "(none; serve will refuse publishes)"}`);
10431
+ console.log(` ${chalk2.dim("API key ref:")} ${config.api_key_ref}`);
9903
10432
  console.log(` ${chalk2.dim("Config:")} ${path}`);
9904
10433
  console.log();
9905
10434
  console.log(chalk2.dim(" Presence of an API URL selects the remote registry; absence means local."));
9906
- if (config.api_key_ref) {
9907
- console.log(chalk2.dim(` Run the server with the key resolved from the vault, never the value:`));
9908
- console.log(` secrets exec ${config.api_key_ref} --as HASNA_HOOKS_API_KEY -- hooks serve`);
9909
- }
10435
+ console.log(chalk2.dim(` Run the server with the key resolved from the vault, never the value:`));
10436
+ console.log(` secrets exec ${config.api_key_ref} --as HASNA_HOOKS_API_KEY -- hooks serve`);
9910
10437
  console.log();
9911
10438
  return;
9912
10439
  }
@@ -9942,6 +10469,7 @@ program2.command("init").description("Register a new agent profile with a unique
9942
10469
  program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>", "Agent profile ID").description("Execute a hook (called by AI coding agents)").action(async (hook, options) => {
9943
10470
  const { resolveHook: resolveHook2, resolveScriptPath: resolveScriptPath2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
9944
10471
  const { sha256Of: sha256Of2, checkScriptHash: checkScriptHash2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10472
+ const { recordHookRun: recordHookRun2, resolveEventType: resolveEventType2 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
9945
10473
  const resolved = resolveHook2(hook);
9946
10474
  if (!resolved) {
9947
10475
  console.error(JSON.stringify({ error: `Hook '${hook}' not found` }));
@@ -9953,7 +10481,8 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
9953
10481
  process.exit(1);
9954
10482
  }
9955
10483
  const content = readFileSync10(hookScript);
9956
- const check = checkScriptHash2(hook, sha256Of2(content));
10484
+ const sha = sha256Of2(content);
10485
+ const check = checkScriptHash2(hook, sha);
9957
10486
  if (!check.ok) {
9958
10487
  console.error(JSON.stringify({
9959
10488
  error: `Hook '${hook}' script changed since it was trusted (sha256 ${check.expected} != ${check.actual}). Run 'hooks trust ${hook}' to trust the new content.`,
@@ -9982,18 +10511,74 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
9982
10511
  }
9983
10512
  }
9984
10513
  const { readCustomManifest: readCustomManifest2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
9985
- const { executeVerifiedScript: executeVerifiedScript2 } = await Promise.resolve().then(() => (init_run(), exports_run));
10514
+ const { executeVerifiedScript: executeVerifiedScript2, HookTimeoutError: HookTimeoutError2 } = await Promise.resolve().then(() => (init_run(), exports_run));
9986
10515
  const custom = readCustomManifest2(hook);
9987
10516
  const args = custom?.manifest.args ?? [];
9988
10517
  const timeout = custom?.manifest.timeout_ms;
9989
- const { stdout, stderr, exitCode } = await executeVerifiedScript2({
9990
- name: hook,
9991
- scriptPath: hookScript,
9992
- content,
9993
- args,
9994
- stdin: hookStdin,
9995
- env: process.env,
9996
- timeout
10518
+ const started = Date.now();
10519
+ let stdout = "";
10520
+ let stderr = "";
10521
+ let exitCode = 0;
10522
+ try {
10523
+ ({ stdout, stderr, exitCode } = await executeVerifiedScript2({
10524
+ name: hook,
10525
+ scriptPath: hookScript,
10526
+ content,
10527
+ args,
10528
+ stdin: hookStdin,
10529
+ env: process.env,
10530
+ timeout
10531
+ }));
10532
+ } catch (err) {
10533
+ if (err instanceof HookTimeoutError2) {
10534
+ try {
10535
+ const { recordHookRun: recordHookRun3, resolveEventType: resolveEventType3 } = await Promise.resolve().then(() => (init_db_writer(), exports_db_writer));
10536
+ let inputJson2 = {};
10537
+ try {
10538
+ inputJson2 = JSON.parse(stdin);
10539
+ } catch {}
10540
+ recordHookRun3({
10541
+ hookName: hook,
10542
+ eventType: resolveEventType3(inputJson2.hook_event_name, resolved.events[0] ?? "PostToolUse"),
10543
+ version: resolved.version,
10544
+ sha256: sha,
10545
+ sessionId: typeof inputJson2.session_id === "string" ? inputJson2.session_id : null,
10546
+ toolName: typeof inputJson2.tool_name === "string" ? inputJson2.tool_name : null,
10547
+ toolInput: inputJson2.tool_input,
10548
+ error: err.message.slice(0, 500),
10549
+ exitCode: -1,
10550
+ durationMs: Date.now() - started,
10551
+ projectDir: process.cwd()
10552
+ });
10553
+ } catch {}
10554
+ console.error(JSON.stringify({ error: err.message, hook, timedOut: true, timeout_ms: timeout ?? null }));
10555
+ process.exit(1);
10556
+ }
10557
+ throw err;
10558
+ }
10559
+ const durationMs = Date.now() - started;
10560
+ let inputJson = {};
10561
+ try {
10562
+ inputJson = JSON.parse(stdin);
10563
+ } catch {}
10564
+ let outputJson = {};
10565
+ try {
10566
+ outputJson = JSON.parse(stdout);
10567
+ } catch {}
10568
+ const blocked = outputJson.decision === "block" || outputJson.continue === false;
10569
+ recordHookRun2({
10570
+ hookName: hook,
10571
+ eventType: resolveEventType2(inputJson.hook_event_name, resolved.events[0] ?? "PostToolUse"),
10572
+ version: resolved.version,
10573
+ sha256: sha,
10574
+ sessionId: typeof inputJson.session_id === "string" ? inputJson.session_id : null,
10575
+ toolName: typeof inputJson.tool_name === "string" ? inputJson.tool_name : null,
10576
+ toolInput: inputJson.tool_input,
10577
+ result: blocked ? "block" : "continue",
10578
+ error: exitCode !== 0 ? (stderr || `hook exited with code ${exitCode}`).slice(0, 500) : null,
10579
+ exitCode,
10580
+ durationMs,
10581
+ projectDir: process.cwd()
9997
10582
  });
9998
10583
  if (stdout)
9999
10584
  process.stdout.write(stdout);
@@ -10001,7 +10586,7 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
10001
10586
  process.stderr.write(stderr);
10002
10587
  process.exit(exitCode);
10003
10588
  });
10004
- program2.command("install").alias("add").argument("[hooks...]", "Hooks to install").option("-o, --overwrite", "Overwrite existing hooks", false).option("-a, --all", "Install all available hooks", false).option("-c, --category <category>", "Install all hooks in a category").option("-g, --global", "Install globally (~/.claude/settings.json)", false).option("-p, --project", "Install for current project (.claude/settings.json)", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith, all (default: claude)", "claude").option("--profile <id>", "Agent profile ID to scope hooks to").option("--dry-run", "Preview what would be installed without writing to settings", false).option("--apply-codewith", "Explicitly append Codewith TOML to a config file (prefer open-configs for managed configs)", false).option("--codewith-config <path>", "Explicit Codewith config path required with --apply-codewith").option("-j, --json", "Output as JSON", false).description("Install one or more hooks (registry names, local paths, git URLs, or manifest URLs)").action(async (hooks, options) => {
10589
+ program2.command("install").alias("add").argument("[hooks...]", "Hooks to install").option("-o, --overwrite", "Overwrite existing hooks", false).option("-a, --all", "Install all available hooks", false).option("-c, --category <category>", "Install all hooks in a category").option("-g, --global", "Install globally (~/.claude/settings.json)", false).option("-p, --project", "Install for current project (.claude/settings.json)", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith, all (default: claude)", "claude").option("--profile <id>", "Agent profile ID to scope hooks to").option("--dry-run", "Preview what would be installed without writing to settings", false).option("--apply-codewith", "Explicitly append Codewith TOML to a config file (prefer open-configs for managed configs)", false).option("--codewith-config <path>", "Explicit Codewith config path required with --apply-codewith").option("-j, --json", "Output as JSON", false).description("Install one or more hooks (registry names, local paths, git URLs, manifest URLs, or <name>@<version> pinned installs)").action(async (hooks, options) => {
10005
10590
  const scope = resolveScope(options);
10006
10591
  const target = resolveTarget(options);
10007
10592
  let toInstall = hooks;
@@ -10034,15 +10619,35 @@ program2.command("install").alias("add").argument("[hooks...]", "Hooks to instal
10034
10619
  return;
10035
10620
  }
10036
10621
  const { isCustomSource: isCustomSource2, installCustomSource: installCustomSource2 } = await Promise.resolve().then(() => (init_custom_install(), exports_custom_install));
10622
+ const { readCustomManifest: readCustomManifest2, HOOK_NAME_RE: HOOK_NAME_RE2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
10623
+ const { resolveApiUrl: resolveApiUrl2 } = await Promise.resolve().then(() => (init_config(), exports_config));
10624
+ const { sha256Of: sha256Of2, pinInstalledHook: pinInstalledHook2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10625
+ const { readFileSync: readFileSyncFs } = await import("fs");
10626
+ function parseNameVersion(arg) {
10627
+ if (isCustomSource2(arg))
10628
+ return null;
10629
+ const at = arg.lastIndexOf("@");
10630
+ if (at <= 0 || at === arg.length - 1)
10631
+ return null;
10632
+ const name = arg.slice(0, at);
10633
+ const version = arg.slice(at + 1);
10634
+ if (!HOOK_NAME_RE2.test(name))
10635
+ return null;
10636
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version))
10637
+ return null;
10638
+ return { name, version };
10639
+ }
10640
+ const pinnedRequests = toInstall.map((arg) => ({ arg, pinned: parseNameVersion(arg) })).filter((x) => x.pinned !== null);
10037
10641
  const customSources = toInstall.filter((n) => !getHook(n) && isCustomSource2(n));
10038
10642
  if (options.dryRun) {
10039
10643
  const known = toInstall.filter((n) => getHook(n));
10040
- const unknown = toInstall.filter((n) => !getHook(n) && !isCustomSource2(n));
10644
+ const unknown = toInstall.filter((n) => !getHook(n) && !isCustomSource2(n) && !parseNameVersion(n) && !readCustomManifest2(n));
10041
10645
  if (options.json) {
10042
10646
  console.log(JSON.stringify({
10043
10647
  dryRun: true,
10044
10648
  would_install: known,
10045
10649
  custom_sources: customSources,
10650
+ pinned: pinnedRequests.map((p) => `${p.pinned.name}@${p.pinned.version}`),
10046
10651
  unknown,
10047
10652
  scope,
10048
10653
  target,
@@ -10060,6 +10665,9 @@ Dry run \u2014 would install (${scope}, ${target}):
10060
10665
  for (const source of customSources) {
10061
10666
  console.log(chalk2.cyan(` ${source}`) + chalk2.dim(" [custom source: would fetch and install]"));
10062
10667
  }
10668
+ for (const p of pinnedRequests) {
10669
+ console.log(chalk2.cyan(` ${p.pinned.name}@${p.pinned.version}`) + chalk2.dim(" [pinned registry install]"));
10670
+ }
10063
10671
  if (unknown.length > 0) {
10064
10672
  console.log();
10065
10673
  for (const name of unknown) {
@@ -10074,9 +10682,21 @@ Dry run \u2014 would install (${scope}, ${target}):
10074
10682
  for (const source of customSources) {
10075
10683
  try {
10076
10684
  const custom = await installCustomSource2(source);
10685
+ try {
10686
+ const scriptBytes = readFileSyncFs(custom.scriptPath);
10687
+ pinInstalledHook2(custom.name, custom.version, sha256Of2(scriptBytes), "custom", source);
10688
+ } catch (pinError) {
10689
+ const { customHookDir: customHookDir2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
10690
+ const { removeHookFromStore: removeHookFromStore2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10691
+ try {
10692
+ rmSync5(customHookDir2(custom.name), { recursive: true, force: true });
10693
+ } catch {}
10694
+ removeHookFromStore2(custom.name);
10695
+ throw pinError;
10696
+ }
10077
10697
  installedCustom.push(custom.name);
10078
10698
  if (options.json) {
10079
- console.log(JSON.stringify({ custom: { source, name: custom.name, version: custom.version } }));
10699
+ console.log(JSON.stringify({ custom: { source, name: custom.name, version: custom.version, pinned: true } }));
10080
10700
  } else {
10081
10701
  console.log(chalk2.green(`\u2713 Installed custom hook '${custom.name}' v${custom.version} from ${source}`));
10082
10702
  }
@@ -10090,9 +10710,37 @@ Dry run \u2014 would install (${scope}, ${target}):
10090
10710
  }
10091
10711
  }
10092
10712
  }
10093
- const namesToRegister = [...new Set([...toInstall.filter((n) => !isCustomSource2(n)), ...installedCustom])];
10713
+ const installedPinned = [];
10714
+ for (const { arg, pinned } of pinnedRequests) {
10715
+ try {
10716
+ const apiUrl = resolveApiUrl2();
10717
+ if (!apiUrl)
10718
+ throw new Error(`Cannot install '${arg}': no remote registry configured (set api_url or HASNA_HOOKS_API_URL)`);
10719
+ const { fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
10720
+ const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, apiUrl);
10721
+ installedPinned.push(pinnedInstall.name);
10722
+ if (options.json) {
10723
+ console.log(JSON.stringify({ pinned: { request: arg, name: pinnedInstall.name, version: pinnedInstall.version, sha256: pinnedInstall.sha256, source: pinnedInstall.source } }));
10724
+ } else {
10725
+ console.log(chalk2.green(`\u2713 Installed '${pinnedInstall.name}' v${pinnedInstall.version} from registry (sha256 ${pinnedInstall.sha256.slice(0, 12)}\u2026)`));
10726
+ }
10727
+ } catch (error) {
10728
+ const message = error instanceof Error ? error.message : String(error);
10729
+ results.push({ hook: arg, success: false, error: message });
10730
+ if (options.json) {
10731
+ console.log(JSON.stringify({ error: `Pinned install failed for ${arg}: ${message}` }));
10732
+ } else {
10733
+ console.log(chalk2.red(`\u2717 ${message}`));
10734
+ }
10735
+ }
10736
+ }
10737
+ const namesToRegister = [...new Set([
10738
+ ...toInstall.filter((n) => !isCustomSource2(n) && !parseNameVersion(n)),
10739
+ ...installedCustom,
10740
+ ...installedPinned
10741
+ ])];
10094
10742
  for (const name of namesToRegister) {
10095
- if (!getHook(name) && !installedCustom.includes(name)) {
10743
+ if (!getHook(name) && !installedCustom.includes(name) && !installedPinned.includes(name) && !readCustomManifest2(name)) {
10096
10744
  const suggestions = suggestHooks(name);
10097
10745
  const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
10098
10746
  results.push({ hook: name, success: false, error: `Hook '${name}' not found${hint}` });
@@ -10119,8 +10767,12 @@ Dry run \u2014 would install (${scope}, ${target}):
10119
10767
  target,
10120
10768
  applied: results.some((r) => r.applied)
10121
10769
  }));
10770
+ if (results.length > 0 && results.every((r) => !r.success)) {
10771
+ process.exitCode = 1;
10772
+ }
10122
10773
  return;
10123
10774
  }
10775
+ const successCount = results.filter((r) => r.success).length;
10124
10776
  const settingsFile = target === "codewith" ? options.applyCodewith ? options.codewithConfig : "TOML fragment only (open-configs should apply)" : scope === "project" ? ".claude/settings.json" : "~/.claude/settings.json";
10125
10777
  console.log(chalk2.bold(`
10126
10778
  Installing hooks (${scope}, ${target})...
@@ -10147,38 +10799,59 @@ Installing hooks (${scope}, ${target})...
10147
10799
  console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
10148
10800
  }
10149
10801
  }
10802
+ if (results.length > 0 && successCount === 0) {
10803
+ console.log(chalk2.red(`
10804
+ \u2717 Nothing was registered \u2014 all ${results.length} hook(s) failed.`));
10805
+ process.exitCode = 1;
10806
+ return;
10807
+ }
10150
10808
  console.log(chalk2.dim(`
10151
10809
  Registered in ${settingsFile}`));
10152
10810
  });
10153
- program2.command("list").alias("ls").option("-c, --category <category>", "Filter by category").option("-a, --all", "Show all available hooks", false).option("-i, --installed", "Show only installed hooks", false).option("-r, --registered", "Show registered hooks", false).option("-g, --global", "Check global settings", false).option("-p, --project", "Check project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith (default: claude)", "claude").option("-n, --limit <n>", "Max rows to show in compact output", "20").option("--verbose", "Show descriptions and full detail columns", false).option("-j, --json", "Output as JSON", false).description("List available or installed hooks").action((options) => {
10811
+ program2.command("list").alias("ls").option("-c, --category <category>", "Filter by category").option("-a, --all", "Show all available hooks", false).option("-i, --installed", "Show only installed hooks", false).option("-r, --registered", "Show registered hooks", false).option("-g, --global", "Check global settings", false).option("-p, --project", "Check project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith (default: claude)", "claude").option("-n, --limit <n>", "Max rows to show in compact output", "20").option("--verbose", "Show descriptions and full detail columns", false).option("-j, --json", "Output as JSON", false).description("List available or installed hooks (bundled + custom/registry)").action((options) => {
10154
10812
  const scope = resolveScope(options);
10155
10813
  const limit = options.all ? Number.MAX_SAFE_INTEGER : parseLimit(options.limit, 20, 200);
10814
+ const storeHooks = listStoreHooks();
10156
10815
  if (options.registered || options.installed) {
10157
10816
  const target = options.target === "gemini" ? "gemini" : options.target === "codewith" ? "codewith" : "claude";
10158
10817
  const registered = getRegisteredHooksForTarget(scope, target);
10818
+ const installed = [...new Set([...registered, ...storeHooks.map((s) => s.meta.name)])];
10819
+ const shown = options.registered ? registered : installed;
10159
10820
  if (options.json) {
10160
- console.log(JSON.stringify(registered.map((name) => {
10161
- const meta = getHook(name);
10162
- return { name, event: meta?.event, version: meta?.version, description: meta?.description, scope, target };
10821
+ console.log(JSON.stringify(shown.map((name) => {
10822
+ const store = storeHooks.find((s) => s.meta.name === name);
10823
+ const meta = store?.meta ?? getHook(name);
10824
+ return {
10825
+ name,
10826
+ event: meta?.event,
10827
+ version: meta?.version,
10828
+ description: meta?.description,
10829
+ source: store?.source ?? (store ? "custom" : undefined),
10830
+ scope,
10831
+ target
10832
+ };
10163
10833
  })));
10164
10834
  return;
10165
10835
  }
10166
- if (registered.length === 0) {
10167
- console.log(chalk2.dim(`No hooks registered (${scope}, ${target})`));
10836
+ if (shown.length === 0) {
10837
+ console.log(chalk2.dim(`No hooks ${options.registered ? "registered" : "installed"} (${scope}, ${target})`));
10168
10838
  return;
10169
10839
  }
10170
- const visible2 = registered.slice(0, limit);
10840
+ const label = options.registered ? "Registered" : "Installed";
10841
+ const visible2 = shown.slice(0, limit);
10171
10842
  console.log(chalk2.bold(`
10172
- Registered hooks \u2014 ${scope}/${target} (${registered.length}, showing ${visible2.length}):
10843
+ ${label} hooks \u2014 ${scope}/${target} (${shown.length}, showing ${visible2.length}):
10173
10844
  `));
10174
10845
  for (const name of visible2) {
10175
- const meta = getHook(name);
10176
- if (meta)
10177
- console.log(hookSummaryLine(meta, { verbose: options.verbose }));
10178
- else
10846
+ const store = storeHooks.find((s) => s.meta.name === name);
10847
+ const meta = store?.meta ?? getHook(name);
10848
+ if (meta) {
10849
+ console.log(hookSummaryLine(meta, { verbose: options.verbose }) + (store ? chalk2.dim(` (${store.source})`) : ""));
10850
+ } else {
10179
10851
  console.log(` ${chalk2.cyan(name)} ${chalk2.dim("[unknown]")}`);
10852
+ }
10180
10853
  }
10181
- printDisclosureHint(registered.length - visible2.length, "hooks info <name>", { includeAll: true });
10854
+ printDisclosureHint(shown.length - visible2.length, "hooks info <name>", { includeAll: true });
10182
10855
  return;
10183
10856
  }
10184
10857
  if (options.category) {
@@ -10211,6 +10884,18 @@ ${category} (${hooks.length}, showing ${visible2.length}):
10211
10884
  for (const category of CATEGORIES) {
10212
10885
  result[category] = getHooksByCategory(category);
10213
10886
  }
10887
+ result["Custom / Registry"] = storeHooks.map((s) => ({
10888
+ name: s.meta.name,
10889
+ displayName: s.meta.displayName,
10890
+ description: s.meta.description,
10891
+ version: s.meta.version,
10892
+ category: "Custom / Registry",
10893
+ event: s.meta.event,
10894
+ events: s.meta.events,
10895
+ matcher: s.meta.matcher,
10896
+ tags: [...new Set([...s.meta.tags, s.source])],
10897
+ source: s.source
10898
+ }));
10214
10899
  console.log(JSON.stringify(result));
10215
10900
  return;
10216
10901
  }
@@ -10221,6 +10906,16 @@ Available hooks (${HOOKS.length}, showing ${visible.length}):
10221
10906
  for (const h of visible)
10222
10907
  console.log(hookSummaryLine(h, { verbose: options.verbose }));
10223
10908
  printDisclosureHint(HOOKS.length - visible.length, "hooks info <name>", { includeAll: true });
10909
+ if (storeHooks.length > 0) {
10910
+ const storeVisible = storeHooks.slice(0, limit);
10911
+ console.log(chalk2.bold(`
10912
+ Custom / Registry hooks (${storeHooks.length}, showing ${storeVisible.length}):
10913
+ `));
10914
+ for (const s of storeVisible) {
10915
+ console.log(hookSummaryLine(s.meta, { verbose: options.verbose }) + chalk2.dim(` (${s.source})`));
10916
+ }
10917
+ printDisclosureHint(storeHooks.length - storeVisible.length, "hooks info <name>", { includeAll: true });
10918
+ }
10224
10919
  });
10225
10920
  program2.command("search").argument("<query>", "Search term").option("-n, --limit <n>", "Max rows to show in compact output", "10").option("--verbose", "Show descriptions for search results", false).option("-j, --json", "Output as JSON", false).description("Search for hooks").action((query, options) => {
10226
10921
  const results = searchHooks(query);
@@ -10241,28 +10936,40 @@ Found ${results.length} hook(s), showing ${visible.length}:
10241
10936
  console.log(hookSummaryLine(h, { verbose: options.verbose }));
10242
10937
  printDisclosureHint(results.length - visible.length, "hooks info <name>");
10243
10938
  });
10244
- program2.command("remove").alias("rm").argument("<hook>", "Hook to remove").option("-g, --global", "Remove from global settings", false).option("-p, --project", "Remove from project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith, all (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("Remove an installed hook").action((hook, options) => {
10939
+ program2.command("remove").alias("rm").argument("<hook>", "Hook to remove").option("-g, --global", "Remove from global settings", false).option("-p, --project", "Remove from project settings", false).option("-t, --target <target>", "Agent target: claude, gemini, codewith, all (default: claude)", "claude").option("-j, --json", "Output as JSON", false).description("Remove an installed hook (settings registration + store + lock + DB)").action(async (hook, options) => {
10245
10940
  const scope = resolveScope(options);
10246
10941
  const target = resolveTarget(options);
10247
- if (!getHook(hook)) {
10248
- const suggestions = suggestHooks(hook);
10249
- const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
10250
- if (options.json) {
10251
- console.log(JSON.stringify({ hook, removed: false, scope, target, error: `Hook '${hook}' not found${hint}`, suggestions }));
10252
- } else {
10253
- console.log(chalk2.red(`\u2717 Hook '${hook}' not found${hint}`));
10254
- }
10255
- return;
10942
+ const { uninstallHook: uninstallHook2 } = await Promise.resolve().then(() => (init_installer(), exports_installer));
10943
+ const result = uninstallHook2(hook, scope, target);
10944
+ if (!result.removed) {
10945
+ process.exitCode = 1;
10256
10946
  }
10257
- const removed = removeHook(hook, scope, target);
10258
10947
  if (options.json) {
10259
- console.log(JSON.stringify({ hook, removed, scope, target }));
10260
- return;
10261
- }
10262
- if (removed) {
10263
- console.log(chalk2.green(`\u2713 Removed ${hook} (${scope}, ${target})`));
10948
+ console.log(JSON.stringify({
10949
+ hook: result.name,
10950
+ removed: result.removed,
10951
+ source: result.source,
10952
+ settings_scopes: result.settingsScopes,
10953
+ store_dir_removed: result.storeDirRemoved,
10954
+ pin_removed: result.pinRemoved,
10955
+ db_record_removed: result.dbRecordRemoved,
10956
+ registrations_remaining: result.registrationsRemaining,
10957
+ scope,
10958
+ target,
10959
+ ...result.error ? { error: result.error } : {}
10960
+ }));
10961
+ } else if (result.removed) {
10962
+ console.log(chalk2.green(`\u2713 Removed ${result.name} (${scope}, ${target})`));
10963
+ if (result.source === "custom" && result.storeDirRemoved) {
10964
+ console.log(chalk2.dim(` store dir removed; lock pin ${result.pinRemoved ? "removed" : "absent"}; DB record ${result.dbRecordRemoved ? "removed" : "absent"}`));
10965
+ }
10966
+ if (result.registrationsRemaining && result.registrationsRemaining.length > 0) {
10967
+ console.log(chalk2.yellow(` \u26A0 registrations remaining: ${result.registrationsRemaining.join(", ")} \u2014 remove them in that target's own config`));
10968
+ }
10264
10969
  } else {
10265
- console.log(chalk2.red(`\u2717 ${hook} is not installed (${scope}, ${target})`));
10970
+ const suggestions = suggestHooks(hook);
10971
+ const hint = suggestions.length ? ` \u2014 did you mean: ${suggestions.join(", ")}?` : "";
10972
+ console.log(chalk2.red(`\u2717 Hook '${hook}' not found${hint}`));
10266
10973
  }
10267
10974
  });
10268
10975
  program2.command("categories").option("-j, --json", "Output as JSON", false).description("List all categories").action((options) => {
@@ -10406,7 +11113,7 @@ Hook Health Check (${scope})
10406
11113
  }
10407
11114
  console.log();
10408
11115
  });
10409
- program2.command("update").argument("[hooks...]", "Hooks to update (defaults to all installed)").option("-g, --global", "Update global hooks", false).option("-p, --project", "Update project hooks", false).option("-j, --json", "Output as JSON", false).description("Re-register hooks (picks up new package version) and refresh the lock pins").action(async (hooks, options) => {
11116
+ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to all installed)").option("-g, --global", "Update global hooks", false).option("-p, --project", "Update project hooks", false).option("-j, --json", "Output as JSON", false).description("Re-register hooks (picks up new package version), refresh lock pins, or install a pinned registry version (<name>@<version>)").action(async (hooks, options) => {
10410
11117
  const scope = resolveScope(options);
10411
11118
  const installed = getInstalledHooks(scope);
10412
11119
  const toUpdate = hooks.length > 0 ? hooks : installed;
@@ -10416,19 +11123,60 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
10416
11123
  } else {
10417
11124
  console.log(chalk2.dim("No hooks installed to update."));
10418
11125
  }
11126
+ process.exitCode = 1;
10419
11127
  return;
10420
11128
  }
10421
11129
  const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
10422
11130
  const { sha256File: sha256File2, setPinnedHook: setPinnedHook2, upsertHookRecord: upsertHookRecord2 } = await Promise.resolve().then(() => (init_store(), exports_store));
10423
11131
  const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
11132
+ const { resolveApiUrl: resolveApiUrl2 } = await Promise.resolve().then(() => (init_config(), exports_config));
11133
+ const { isCustomSource: isCustomSource2 } = await Promise.resolve().then(() => (init_custom_install(), exports_custom_install));
11134
+ const { HOOK_NAME_RE: HOOK_NAME_RE2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
11135
+ function parseNameVersion(arg) {
11136
+ if (isCustomSource2(arg))
11137
+ return null;
11138
+ const at = arg.lastIndexOf("@");
11139
+ if (at <= 0 || at === arg.length - 1)
11140
+ return null;
11141
+ const name = arg.slice(0, at);
11142
+ const version = arg.slice(at + 1);
11143
+ if (!HOOK_NAME_RE2.test(name))
11144
+ return null;
11145
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version))
11146
+ return null;
11147
+ return { name, version };
11148
+ }
10424
11149
  const results = [];
10425
- for (const name of toUpdate) {
10426
- if (!installed.includes(name)) {
10427
- results.push({ hook: name, success: false, error: "Not installed" });
11150
+ for (const arg of toUpdate) {
11151
+ const pinned = parseNameVersion(arg);
11152
+ if (pinned) {
11153
+ try {
11154
+ const apiUrl = resolveApiUrl2();
11155
+ if (!apiUrl)
11156
+ throw new Error(`Cannot update '${arg}': no remote registry configured (set api_url or HASNA_HOOKS_API_URL)`);
11157
+ const { fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
11158
+ const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, apiUrl);
11159
+ const registered = installHook(pinnedInstall.name, { scope, overwrite: true });
11160
+ if (!registered.success) {
11161
+ results.push({ hook: arg, success: false, error: registered.error ?? "registration failed" });
11162
+ continue;
11163
+ }
11164
+ results.push({
11165
+ hook: pinnedInstall.name,
11166
+ success: true,
11167
+ pinned: { version: pinnedInstall.version, sha256: pinnedInstall.sha256, source: pinnedInstall.source }
11168
+ });
11169
+ } catch (error) {
11170
+ results.push({ hook: arg, success: false, error: error instanceof Error ? error.message : String(error) });
11171
+ }
10428
11172
  continue;
10429
11173
  }
10430
- const result = installHook(name, { scope, overwrite: true });
10431
- const resolved = resolveHook2(name);
11174
+ if (!installed.includes(arg)) {
11175
+ results.push({ hook: arg, success: false, error: "Not installed" });
11176
+ continue;
11177
+ }
11178
+ const result = installHook(arg, { scope, overwrite: true });
11179
+ const resolved = resolveHook2(arg);
10432
11180
  if (result.success && resolved) {
10433
11181
  const hash = await sha256File2(resolved.scriptPath);
10434
11182
  setPinnedHook2(resolved.name, { version: resolved.version, sha256: hash, source: resolved.source });
@@ -10449,6 +11197,9 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
10449
11197
  updated: results.filter((r) => r.success).map((r) => r.hook),
10450
11198
  failed: results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error }))
10451
11199
  }));
11200
+ if (results.some((r) => !r.success)) {
11201
+ process.exitCode = 1;
11202
+ }
10452
11203
  return;
10453
11204
  }
10454
11205
  console.log(chalk2.bold(`
@@ -10461,6 +11212,9 @@ Updating hooks...
10461
11212
  console.log(chalk2.red(`\u2717 ${result.hook}: ${result.error}`));
10462
11213
  }
10463
11214
  }
11215
+ if (results.some((r) => !r.success)) {
11216
+ process.exitCode = 1;
11217
+ }
10464
11218
  });
10465
11219
  program2.command("docs").argument("[hook]", "Hook name (shows general docs if omitted)").option("--verbose", "Print full hook README content", false).option("-j, --json", "Output as JSON", false).description("Show documentation for hooks").action((hook, options) => {
10466
11220
  if (hook) {