@hasna/hooks 0.7.10 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +32 -6
  2. package/bin/index.js +1872 -1300
  3. package/bin/serve.js +730 -551
  4. package/dist/config.d.ts +25 -19
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +1198 -712
  7. package/dist/lib/app-home.d.ts +9 -0
  8. package/dist/lib/installer.d.ts +0 -9
  9. package/dist/lib/local-opt-in.d.ts +80 -0
  10. package/dist/lib/profiles.d.ts +10 -0
  11. package/dist/lib/resolver-types.d.ts +45 -0
  12. package/dist/lib/sync.d.ts +13 -8
  13. package/dist/lib/transport.d.ts +87 -0
  14. package/dist/openapi.d.ts +1 -1
  15. package/dist/storage.js +54 -63
  16. package/hooks/hook-affected-tests/package.json +1 -1
  17. package/hooks/hook-agent-rules-version-check/package.json +1 -1
  18. package/hooks/hook-agentmessages/package.json +1 -1
  19. package/hooks/hook-announce-start/package.json +1 -1
  20. package/hooks/hook-announce-stop/package.json +1 -1
  21. package/hooks/hook-autoformat/package.json +1 -1
  22. package/hooks/hook-branchprotect/package.json +1 -1
  23. package/hooks/hook-checkbugs/package.json +1 -1
  24. package/hooks/hook-checkdocs/package.json +1 -1
  25. package/hooks/hook-checkfiles/package.json +1 -1
  26. package/hooks/hook-checklint/package.json +1 -1
  27. package/hooks/hook-checkpoint/package.json +1 -1
  28. package/hooks/hook-checksecurity/package.json +1 -1
  29. package/hooks/hook-checktasks/package.json +1 -1
  30. package/hooks/hook-checktests/package.json +1 -1
  31. package/hooks/hook-conflict-detect/package.json +1 -1
  32. package/hooks/hook-contextrefresh/package.json +1 -1
  33. package/hooks/hook-desktopnotify/package.json +1 -1
  34. package/hooks/hook-dm-inject/package.json +1 -1
  35. package/hooks/hook-envsetup/package.json +1 -1
  36. package/hooks/hook-failure-to-task/package.json +1 -1
  37. package/hooks/hook-filelock/package.json +1 -1
  38. package/hooks/hook-fleet-blockers-gate/package.json +1 -1
  39. package/hooks/hook-fleet-catchup/package.json +1 -1
  40. package/hooks/hook-gitguard/package.json +1 -1
  41. package/hooks/hook-packageage/package.json +1 -1
  42. package/hooks/hook-permissionguard/package.json +1 -1
  43. package/hooks/hook-phonenotify/package.json +1 -1
  44. package/hooks/hook-precompact/package.json +1 -1
  45. package/hooks/hook-protectfiles/package.json +1 -1
  46. package/hooks/hook-stylescheck/package.json +1 -1
  47. package/hooks/hook-typecheck-gate/package.json +1 -1
  48. package/package.json +4 -9
  49. package/scripts/ensure-profiles-dir.mjs +52 -1
package/bin/index.js CHANGED
@@ -1120,8 +1120,8 @@ Expecting one of '${allowedValues.join("', '")}'`);
1120
1120
  args = args.slice();
1121
1121
  let launchWithNode = false;
1122
1122
  const sourceExt = [".js", ".ts", ".tsx", ".mjs", ".cjs"];
1123
- function findFile(baseDir2, baseName) {
1124
- const localBin = path.resolve(baseDir2, baseName);
1123
+ function findFile(baseDir, baseName) {
1124
+ const localBin = path.resolve(baseDir, baseName);
1125
1125
  if (fs.existsSync(localBin))
1126
1126
  return localBin;
1127
1127
  if (sourceExt.includes(path.extname(baseName)))
@@ -1886,92 +1886,77 @@ var require_commander = __commonJS((exports) => {
1886
1886
  exports.InvalidOptionArgumentError = InvalidArgumentError;
1887
1887
  });
1888
1888
 
1889
- // ../../node_modules/.bun/@hasna+paths@0.1.0/node_modules/@hasna/paths/dist/index.js
1890
- import { homedir as homedir3 } from "os";
1891
- import { join as join4 } from "path";
1892
- function assertApp2(app) {
1889
+ // src/lib/app-home.ts
1890
+ import { existsSync as existsSync3 } from "fs";
1891
+ import { homedir as homedir2 } from "os";
1892
+ import { join as join3, resolve as resolve2 } from "path";
1893
+ import { homedir as pathsResolverHomedir2 } from "os";
1894
+ import { join as pathsResolverJoin2 } from "path";
1895
+ function pathsResolverAssertApp2(app) {
1893
1896
  if (typeof app !== "string" || app.length === 0) {
1894
1897
  throw new TypeError("paths: app must be a non-empty string");
1895
1898
  }
1896
- if (!APP_SLUG_RE2.test(app)) {
1899
+ if (!PATHS_RESOLVER_APP_SLUG_RE2.test(app)) {
1897
1900
  throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
1898
1901
  }
1899
1902
  }
1900
- function envOf2(options) {
1901
- return options.env ?? process.env;
1902
- }
1903
- function envValue2(options, kind) {
1904
- const value = envOf2(options)[KIND_ENV2[kind]];
1905
- return typeof value === "string" && value.length > 0 ? value : undefined;
1906
- }
1907
- function isMacOS2(platform) {
1908
- return platform === "darwin";
1903
+ function pathsResolverAssertKind2(kind) {
1904
+ if (!Object.keys(PATHS_RESOLVER_KIND_ENV2).includes(kind)) {
1905
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV2).join(", ")}`);
1906
+ }
1909
1907
  }
1910
- function baseDir2(kind, options) {
1911
- const override = envValue2(options, kind);
1912
- if (override)
1908
+ function pathsResolverBaseDir2(kind, options) {
1909
+ pathsResolverAssertKind2(kind);
1910
+ const env = options.env ?? process.env;
1911
+ const override = env[PATHS_RESOLVER_KIND_ENV2[kind]];
1912
+ if (typeof override === "string" && override.length > 0)
1913
1913
  return override;
1914
- const home = options.home ?? homedir3();
1914
+ const home = options.home ?? pathsResolverHomedir2();
1915
1915
  const platform = options.platform ?? process.platform;
1916
- if (isMacOS2(platform)) {
1916
+ if (platform === "darwin") {
1917
1917
  switch (kind) {
1918
1918
  case "config":
1919
1919
  case "data":
1920
- return join4(home, "Library", "Application Support", "Hasna");
1920
+ return pathsResolverJoin2(home, "Library", "Application Support", "Hasna");
1921
1921
  case "cache":
1922
- return join4(home, "Library", "Caches", "Hasna");
1922
+ return pathsResolverJoin2(home, "Library", "Caches", "Hasna");
1923
1923
  case "state":
1924
- return join4(home, "Library", "Logs", "Hasna");
1924
+ return pathsResolverJoin2(home, "Library", "Logs", "Hasna");
1925
1925
  }
1926
1926
  }
1927
1927
  switch (kind) {
1928
1928
  case "config":
1929
- return join4(home, ".config", "hasna");
1929
+ return pathsResolverJoin2(home, ".config", "hasna");
1930
1930
  case "data":
1931
- return join4(home, ".local", "share", "hasna");
1931
+ return pathsResolverJoin2(home, ".local", "share", "hasna");
1932
1932
  case "state":
1933
- return join4(home, ".local", "state", "hasna");
1933
+ return pathsResolverJoin2(home, ".local", "state", "hasna");
1934
1934
  case "cache":
1935
- return join4(home, ".cache", "hasna");
1935
+ return pathsResolverJoin2(home, ".cache", "hasna");
1936
1936
  }
1937
1937
  }
1938
- function resolvePath2(kind, options) {
1939
- assertApp2(options.app);
1940
- const appSegment = options.internal === true ? join4("internal", options.app) : options.app;
1941
- return join4(baseDir2(kind, options), appSegment);
1938
+ function pathsResolverResolve2(kind, options) {
1939
+ pathsResolverAssertApp2(options.app);
1940
+ const appSegment = options.internal === true ? pathsResolverJoin2("internal", options.app) : options.app;
1941
+ return pathsResolverJoin2(pathsResolverBaseDir2(kind, options), appSegment);
1942
1942
  }
1943
1943
  function dataDir2(options) {
1944
- return resolvePath2("data", options);
1944
+ return pathsResolverResolve2("data", options);
1945
1945
  }
1946
- var KIND_ENV2, APP_SLUG_RE2;
1947
- var init_dist = __esm(() => {
1948
- KIND_ENV2 = {
1949
- config: "HASNA_CONFIG_HOME",
1950
- data: "HASNA_DATA_HOME",
1951
- state: "HASNA_STATE_HOME",
1952
- cache: "HASNA_CACHE_HOME"
1953
- };
1954
- APP_SLUG_RE2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1955
- });
1956
-
1957
- // src/lib/app-home.ts
1958
- import { existsSync as existsSync3 } from "fs";
1959
- import { homedir as homedir5 } from "os";
1960
- import { join as join6, resolve as resolve2 } from "path";
1961
1946
  function getHomeDir(env = process.env) {
1962
- return env.HOME || env.USERPROFILE || homedir5();
1947
+ return env.HOME || env.USERPROFILE || homedir2();
1963
1948
  }
1964
1949
  function getResolverDataRoot(env = process.env) {
1965
1950
  return dataDir2({ app: "hooks", home: getHomeDir(env), env });
1966
1951
  }
1967
1952
  function getLegacyDataRoot(env = process.env) {
1968
- return join6(getHomeDir(env), ".hasna", "hooks");
1953
+ return join3(getHomeDir(env), ".hasna", "hooks");
1969
1954
  }
1970
1955
  function adoptResolverDataRoot(resolved, env = process.env) {
1971
1956
  const dataOverride = env.HASNA_DATA_HOME;
1972
1957
  if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
1973
1958
  return true;
1974
- return existsSync3(join6(resolved, STORE_MARKER));
1959
+ return existsSync3(join3(resolved, STORE_MARKER));
1975
1960
  }
1976
1961
  function getExplicitDataDir(env = process.env) {
1977
1962
  const dir = env.HASNA_HOOKS_DATA_DIR ?? env.HOOKS_DATA_DIR;
@@ -1999,7 +1984,7 @@ function getReportedDbPath(env = process.env) {
1999
1984
  const explicit = getExplicitDbPath(env);
2000
1985
  if (explicit)
2001
1986
  return explicit;
2002
- return join6(getEffectiveDataRoot(env), "hooks.db");
1987
+ return join3(getEffectiveDataRoot(env), "hooks.db");
2003
1988
  }
2004
1989
  function getExplicitDbPath(env = process.env) {
2005
1990
  const db = env.HASNA_HOOKS_DB_PATH ?? env.HOOKS_DB_PATH;
@@ -2007,9 +1992,15 @@ function getExplicitDbPath(env = process.env) {
2007
1992
  return db.trim();
2008
1993
  return;
2009
1994
  }
2010
- var STORE_MARKER = "hooks.db";
1995
+ var PATHS_RESOLVER_KIND_ENV2, PATHS_RESOLVER_APP_SLUG_RE2, STORE_MARKER = "hooks.db";
2011
1996
  var init_app_home = __esm(() => {
2012
- init_dist();
1997
+ PATHS_RESOLVER_KIND_ENV2 = {
1998
+ config: "HASNA_CONFIG_HOME",
1999
+ data: "HASNA_DATA_HOME",
2000
+ state: "HASNA_STATE_HOME",
2001
+ cache: "HASNA_CACHE_HOME"
2002
+ };
2003
+ PATHS_RESOLVER_APP_SLUG_RE2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2013
2004
  });
2014
2005
 
2015
2006
  // src/lib/registry.ts
@@ -4196,690 +4187,294 @@ var require_cli_spinners = __commonJS((exports, module) => {
4196
4187
  module.exports = spinners;
4197
4188
  });
4198
4189
 
4199
- // ../contracts/dist/client/transport.js
4200
- import { readFileSync, statSync } from "fs";
4201
- import { createRequire } from "module";
4202
- import { join as join7 } from "path";
4203
- function envToken(name) {
4204
- return name.toUpperCase().replace(/-/g, "_");
4205
- }
4206
- function clientTransportEnvKeys(name) {
4207
- const envSegment = envToken(name);
4208
- return {
4209
- apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
4210
- apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
4211
- };
4190
+ // src/config.ts
4191
+ import { join as join5 } from "path";
4192
+ function getHooksDataDir() {
4193
+ return getEffectiveDataRoot();
4212
4194
  }
4213
- function credentialOverrideEnvKey(name) {
4214
- return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
4195
+ function getCustomHooksDir() {
4196
+ return join5(getHooksDataDir(), "hooks");
4215
4197
  }
4216
- function credentialPointerEnvKey(name) {
4217
- return `HASNA_${envToken(name)}_API_KEY_REF`;
4198
+ function getLockPath() {
4199
+ const explicit = process.env.HASNA_HOOKS_LOCK_PATH ?? process.env.HOOKS_LOCK_PATH;
4200
+ if (explicit)
4201
+ return explicit;
4202
+ return join5(getHooksDataDir(), "hooks.lock");
4218
4203
  }
4219
- function homeDir(env) {
4220
- const home = env.HOME?.trim();
4221
- return home ? home : null;
4204
+ var init_config = __esm(() => {
4205
+ init_app_home();
4206
+ });
4207
+
4208
+ // src/lib/semver.ts
4209
+ var NUMERIC_ID = "(0|[1-9]\\d{0,15})", ALNUM_ID = "\\d*[A-Za-z-][0-9A-Za-z-]*", SEMVER_PATTERN;
4210
+ var init_semver = __esm(() => {
4211
+ SEMVER_PATTERN = new RegExp(`^${NUMERIC_ID}\\.${NUMERIC_ID}\\.${NUMERIC_ID}` + `(?:-(?:${NUMERIC_ID}|${ALNUM_ID})(?:\\.(?:${NUMERIC_ID}|${ALNUM_ID}))*)?` + `(?:\\+[0-9A-Za-z.-]+)?$`);
4212
+ });
4213
+
4214
+ // src/lib/manifest.ts
4215
+ var exports_manifest = {};
4216
+ __export(exports_manifest, {
4217
+ writeCustomHook: () => writeCustomHook,
4218
+ shortManifestName: () => shortManifestName,
4219
+ scriptRelFor: () => scriptRelFor,
4220
+ resolveScript: () => resolveScript,
4221
+ readCustomManifest: () => readCustomManifest,
4222
+ parseManifest: () => parseManifest,
4223
+ normalizeManifestName: () => normalizeManifestName,
4224
+ manifestSchema: () => manifestSchema,
4225
+ listCustomHooks: () => listCustomHooks,
4226
+ isInlineScript: () => isInlineScript,
4227
+ customHookDir: () => customHookDir,
4228
+ assertContained: () => assertContained,
4229
+ ScriptContainmentError: () => ScriptContainmentError,
4230
+ HOOK_NAME_RE: () => HOOK_NAME_RE
4231
+ });
4232
+ import { existsSync as existsSync5, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
4233
+ import { basename, dirname, isAbsolute, join as join6, normalize, resolve as resolve3, sep } from "path";
4234
+ import { z } from "zod";
4235
+ function parseManifest(json) {
4236
+ const parsed = JSON.parse(json);
4237
+ return manifestSchema.parse(parsed);
4222
4238
  }
4223
- function credentialDiskSourceList(name, env, profile = null) {
4224
- const home = homeDir(env);
4225
- if (!home || !SAFE_APP_SLUG.test(name))
4226
- return [];
4227
- const stem = profile ? `${name}.${profile}` : name;
4228
- const configStem = profile ? `${name}-${profile}` : name;
4229
- return [
4230
- {
4231
- path: join7(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
4232
- tier: "fleet-env",
4233
- deprecated: false
4234
- },
4235
- {
4236
- path: join7(home, HASNA_STATE_DIR, LEGACY_CLOUD_DIR, `${stem}.env`),
4237
- tier: "legacy-cloud",
4238
- deprecated: true
4239
- },
4240
- {
4241
- path: join7(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}.env`),
4242
- tier: "config",
4243
- deprecated: false
4244
- },
4245
- {
4246
- path: join7(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`),
4247
- tier: "config-legacy",
4248
- deprecated: true
4249
- }
4250
- ];
4239
+ function normalizeManifestName(name) {
4240
+ return name.startsWith("hook-") ? name : `hook-${name}`;
4251
4241
  }
4252
- function credentialDiskSources(name, env) {
4253
- return credentialDiskSourceList(name, env, null).map((s) => s.path);
4242
+ function shortManifestName(name) {
4243
+ return name.startsWith("hook-") ? name.slice("hook-".length) : name;
4254
4244
  }
4255
- function profileDiskSources(name, env, profile) {
4256
- return credentialDiskSourceList(name, env, profile).map((s) => s.path);
4245
+ function isWithin(child, parent) {
4246
+ return child === parent || child.startsWith(parent + sep);
4257
4247
  }
4258
- function parseEnvFile(text) {
4259
- const values = new Map;
4260
- for (const rawLine of text.split(/\r?\n/)) {
4261
- const line = rawLine.trim();
4262
- if (line.length === 0 || line.startsWith("#"))
4263
- continue;
4264
- const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
4265
- const equals = withoutExport.indexOf("=");
4266
- if (equals <= 0)
4267
- continue;
4268
- const key = withoutExport.slice(0, equals).trim();
4269
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
4270
- continue;
4271
- let value = withoutExport.slice(equals + 1).trim();
4272
- const quote = value[0];
4273
- if (quote === '"' || quote === "'") {
4274
- if (value.length < 2 || !value.endsWith(quote))
4275
- continue;
4276
- value = value.slice(1, -1);
4277
- }
4278
- if (value.length === 0)
4279
- continue;
4280
- values.set(key, value);
4248
+ function assertContainedLexical(target, root, raw) {
4249
+ const lexical = resolve3(target);
4250
+ const rootLexical = resolve3(root);
4251
+ if (!isWithin(lexical, rootLexical)) {
4252
+ throw new ScriptContainmentError(target, root, raw);
4281
4253
  }
4282
- return values;
4254
+ return lexical;
4283
4255
  }
4284
- function readAppConfigFile(path) {
4285
- let text;
4286
- try {
4287
- const stats = statSync(path);
4288
- if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
4289
- return null;
4290
- text = readFileSync(path, "utf8");
4291
- } catch {
4292
- return null;
4256
+ function realpathNearest(path) {
4257
+ const tail = [];
4258
+ let cur = path;
4259
+ while (!existsSync5(cur)) {
4260
+ const parent = dirname(cur);
4261
+ if (parent === cur)
4262
+ break;
4263
+ tail.unshift(basename(cur));
4264
+ cur = parent;
4293
4265
  }
4294
- return parseEnvFile(text);
4266
+ return join6(realpathSync(cur), ...tail);
4295
4267
  }
4296
- function readCredentialFile(path, apiKeyKeys) {
4297
- const values = readAppConfigFile(path);
4298
- if (!values)
4299
- return null;
4300
- for (const key of apiKeyKeys) {
4301
- const value = values.get(key)?.trim();
4302
- if (value)
4303
- return value;
4268
+ function assertContainedReal(root, target, raw) {
4269
+ const rootReal = realpathSync(root);
4270
+ const targetReal = realpathNearest(target);
4271
+ if (!isWithin(targetReal, rootReal)) {
4272
+ throw new ScriptContainmentError(target, root, raw);
4304
4273
  }
4305
- return null;
4306
4274
  }
4307
- function assertUsableCredential(appName, source, value) {
4308
- if (VAULT_POINTER_SHAPE.test(value)) {
4309
- throw new CredentialResolutionError(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
4275
+ function assertContained(target, root, raw) {
4276
+ const lexical = assertContainedLexical(target, root, raw);
4277
+ assertContainedReal(root, lexical, raw);
4278
+ return lexical;
4279
+ }
4280
+ function isInlineScript(manifest) {
4281
+ return manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
4282
+ `);
4283
+ }
4284
+ function scriptRelFor(manifest) {
4285
+ if (isInlineScript(manifest)) {
4286
+ const ext = manifest.script.trim().startsWith("#!") ? ".sh" : ".ts";
4287
+ return `script${ext}`;
4310
4288
  }
4311
- if (!ILLEGAL_IN_HEADER_VALUE.test(value))
4312
- return;
4313
- throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
4289
+ const rel = normalize(manifest.script);
4290
+ if (isAbsolute(rel)) {
4291
+ throw new Error(`manifest script must be relative or inline, got absolute path '${manifest.script}'`);
4292
+ }
4293
+ return rel;
4314
4294
  }
4315
- function sealCredential(fields) {
4316
- const { apiKey } = fields;
4317
- const visible = {
4318
- tier: fields.tier,
4319
- source: fields.source,
4320
- deliberate: fields.deliberate,
4321
- deprecated: fields.deprecated,
4322
- diskCandidates: Object.freeze([...fields.diskCandidates]),
4323
- warning: fields.warning
4324
- };
4325
- const sealed = { ...visible };
4326
- Object.defineProperty(sealed, "apiKey", {
4327
- value: apiKey,
4328
- enumerable: false,
4329
- writable: false,
4330
- configurable: false
4331
- });
4332
- if (fields.pointerVaultKey !== undefined) {
4333
- Object.defineProperty(sealed, "pointerVaultKey", {
4334
- value: fields.pointerVaultKey,
4335
- enumerable: false,
4336
- writable: false,
4337
- configurable: false
4338
- });
4295
+ function resolveScript(manifest, manifestDir) {
4296
+ if (isInlineScript(manifest))
4297
+ return { path: scriptRelFor(manifest), content: manifest.script };
4298
+ const rel = scriptRelFor(manifest);
4299
+ const resolved = assertContained(join6(manifestDir, rel), manifestDir, manifest.script);
4300
+ if (!existsSync5(resolved)) {
4301
+ throw new Error(`manifest script file not found: ${resolved}`);
4339
4302
  }
4340
- Object.defineProperty(sealed, INSPECT_CUSTOM, {
4341
- value: () => ({ ...visible, apiKey: "[redacted]" }),
4342
- enumerable: false,
4343
- writable: false,
4344
- configurable: false
4345
- });
4346
- Object.defineProperty(sealed, CREDENTIAL_SEAL, {
4347
- value: true,
4348
- enumerable: false,
4349
- writable: false,
4350
- configurable: false
4351
- });
4352
- return Object.freeze(sealed);
4303
+ return { path: rel, content: readFileSync(resolved, "utf-8") };
4353
4304
  }
4354
- function firstEnvValue(env, keys) {
4355
- for (const key of keys) {
4356
- const value = env[key]?.trim();
4357
- if (value)
4358
- return { key, value };
4305
+ function customHookDir(name) {
4306
+ return join6(getCustomHooksDir(), shortManifestName(name));
4307
+ }
4308
+ function readCustomManifest(name) {
4309
+ const dir = customHookDir(name);
4310
+ const manifestPath = join6(dir, "manifest.json");
4311
+ if (!existsSync5(manifestPath))
4312
+ return;
4313
+ try {
4314
+ const manifest = parseManifest(readFileSync(manifestPath, "utf-8"));
4315
+ const script = resolveScript(manifest, dir);
4316
+ const scriptIsInline = isInlineScript(manifest);
4317
+ return { manifest, scriptPath: join6(dir, script.path), scriptContent: script.content, scriptIsInline };
4318
+ } catch (err) {
4319
+ if (err instanceof ScriptContainmentError)
4320
+ throw err;
4321
+ return;
4359
4322
  }
4360
- return null;
4361
4323
  }
4362
- function deprecationNotified() {
4363
- const host = globalThis;
4364
- const existing = host[DEPRECATION_REGISTRY];
4365
- if (existing instanceof Set)
4366
- return existing;
4367
- const created = new Set;
4368
- host[DEPRECATION_REGISTRY] = created;
4369
- return created;
4370
- }
4371
- function defaultDeprecationSink(message) {
4372
- if (typeof process !== "undefined" && process.stderr) {
4373
- process.stderr.write(`${message}
4374
- `);
4324
+ function listCustomHooks() {
4325
+ const dir = getCustomHooksDir();
4326
+ if (!existsSync5(dir))
4327
+ return [];
4328
+ const results = [];
4329
+ for (const entry of readdirSafe(dir)) {
4330
+ const parsed = readCustomManifest(entry);
4331
+ if (parsed)
4332
+ results.push(parsed);
4375
4333
  }
4334
+ return results.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
4376
4335
  }
4377
- function resolveCredential(name, env, options = {}) {
4378
- const { apiKeyKeys } = clientTransportEnvKeys(name);
4379
- const diskPaths = credentialDiskSources(name, env);
4380
- const explicitKey = options.apiKey?.trim();
4381
- if (explicitKey) {
4382
- assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
4383
- return sealCredential({
4384
- apiKey: explicitKey,
4385
- tier: "argument",
4386
- source: "explicit apiKey argument",
4387
- deliberate: true,
4388
- deprecated: false,
4389
- diskCandidates: diskPaths,
4390
- warning: null
4391
- });
4392
- }
4393
- const overrideKeyName = credentialOverrideEnvKey(name);
4394
- const overrideRaw = env[overrideKeyName];
4395
- if (overrideRaw !== undefined) {
4396
- const override = overrideRaw.trim();
4397
- if (!override) {
4398
- throw new CredentialResolutionError(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
4399
- }
4400
- assertUsableCredential(name, overrideKeyName, override);
4401
- return sealCredential({
4402
- apiKey: override,
4403
- tier: "override",
4404
- source: overrideKeyName,
4405
- deliberate: true,
4406
- deprecated: false,
4407
- diskCandidates: diskPaths,
4408
- warning: null
4409
- });
4410
- }
4411
- const pointerKeyName = credentialPointerEnvKey(name);
4412
- const pointerRaw = env[pointerKeyName];
4413
- if (pointerRaw !== undefined) {
4414
- const pointer = pointerRaw.trim();
4415
- if (!pointer) {
4416
- throw new CredentialResolutionError(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
4417
- }
4418
- if (!VAULT_POINTER_SHAPE.test(pointer)) {
4419
- throw new CredentialResolutionError(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
4420
- }
4421
- return sealCredential({
4422
- apiKey: "",
4423
- pointerVaultKey: pointer,
4424
- tier: "pointer",
4425
- source: pointerKeyName,
4426
- deliberate: true,
4427
- deprecated: false,
4428
- diskCandidates: diskPaths,
4429
- warning: null
4430
- });
4431
- }
4432
- const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
4433
- if (profile) {
4434
- const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
4435
- if (!SAFE_PROFILE.test(profile)) {
4436
- throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
4437
- }
4438
- const paths = profileDiskSources(name, env, profile);
4439
- for (const path of paths) {
4440
- const value = readCredentialFile(path, apiKeyKeys);
4441
- if (value) {
4442
- assertUsableCredential(name, path, value);
4443
- return sealCredential({
4444
- apiKey: value,
4445
- tier: "profile",
4446
- source: path,
4447
- deliberate: true,
4448
- deprecated: false,
4449
- diskCandidates: paths,
4450
- warning: null
4451
- });
4452
- }
4453
- }
4454
- throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
4455
- }
4456
- const diskSourceList = credentialDiskSourceList(name, env, null);
4457
- const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
4458
- if (diskHits.length > 0) {
4459
- const winner = diskHits[0];
4460
- assertUsableCredential(name, winner.src.path, winner.value);
4461
- const divergentSources = [
4462
- ...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
4463
- ...(() => {
4464
- const legacyHit = firstEnvValue(env, apiKeyKeys);
4465
- return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
4466
- })()
4467
- ];
4468
- const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
4469
- let deprecated = winner.src.deprecated;
4470
- let finalWarning = warning;
4471
- if (winner.src.deprecated) {
4472
- deprecated = true;
4473
- const sink = options.onDeprecation ?? defaultDeprecationSink;
4474
- const notified = deprecationNotified();
4475
- const noticeKey = `${name}:${winner.src.path}`;
4476
- if (!notified.has(noticeKey)) {
4477
- notified.add(noticeKey);
4478
- const target = diskSourceList[0]?.path ?? "<none>";
4479
- const message = `[${name}] DEPRECATED: the API key came from ${winner.src.path} \u2014 a legacy credential location. ` + `The primary location is ${target} (~/.hasna/fleet-env/<app>.env). The legacy 'cloud' tiers are ` + `removed after ${LEGACY_CLOUD_REMOVAL_DEADLINE}. Migrate the key to the primary location.`;
4480
- sink(message);
4481
- }
4482
- finalWarning = [warning, `Legacy credential source: ${winner.src.path}. Removed after ${LEGACY_CLOUD_REMOVAL_DEADLINE}.`].filter(Boolean).join(" ") || null;
4483
- }
4484
- return sealCredential({
4485
- apiKey: winner.value,
4486
- tier: winner.src.tier,
4487
- source: winner.src.path,
4488
- deliberate: false,
4489
- deprecated,
4490
- diskCandidates: diskPaths,
4491
- warning: finalWarning
4492
- });
4336
+ function writeCustomHook(name, manifest, scriptContent, scriptRelPath) {
4337
+ const dir = customHookDir(name);
4338
+ if (isAbsolute(scriptRelPath)) {
4339
+ throw new ScriptContainmentError(scriptRelPath, dir);
4493
4340
  }
4494
- const legacy = firstEnvValue(env, apiKeyKeys);
4495
- if (legacy) {
4496
- assertUsableCredential(name, legacy.key, legacy.value);
4497
- const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
4498
- const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
4499
- const sink = options.onDeprecation ?? defaultDeprecationSink;
4500
- const notified = deprecationNotified();
4501
- if (!notified.has(name)) {
4502
- notified.add(name);
4503
- sink(message);
4504
- }
4505
- return sealCredential({
4506
- apiKey: legacy.value,
4507
- tier: "legacy-env",
4508
- source: legacy.key,
4509
- deliberate: false,
4510
- deprecated: true,
4511
- diskCandidates: diskPaths,
4512
- warning: message
4513
- });
4341
+ const scriptPath = scriptRelPath.includes("/") ? join6(dir, scriptRelPath) : join6(dir, scriptRelPath);
4342
+ assertContainedLexical(scriptPath, dir, scriptRelPath);
4343
+ mkdirSync(dir, { recursive: true });
4344
+ assertContainedReal(dir, scriptPath, scriptRelPath);
4345
+ const scriptDir = dirname(scriptPath);
4346
+ if (scriptDir !== dir)
4347
+ mkdirSync(scriptDir, { recursive: true });
4348
+ writeFileSync(scriptPath, scriptContent, "utf-8");
4349
+ writeFileSync(join6(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + `
4350
+ `, "utf-8");
4351
+ return { dir, scriptPath };
4352
+ }
4353
+ function readdirSafe(dir) {
4354
+ try {
4355
+ return readdirSync(dir);
4356
+ } catch {
4357
+ return [];
4514
4358
  }
4515
- return null;
4516
4359
  }
4517
- var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE", CredentialResolutionError, HASNA_STATE_DIR = ".hasna", FLEET_CREDENTIAL_DIR = "fleet-env", LEGACY_CLOUD_DIR = "cloud", CONFIG_DIR = ".config", CONFIG_NAMESPACE = "hasna", LEGACY_CLOUD_REMOVAL_DEADLINE = "2026-10-01", MAX_CREDENTIAL_FILE_BYTES, SAFE_APP_SLUG, SAFE_PROFILE, ILLEGAL_IN_HEADER_VALUE, VAULT_POINTER_SHAPE, INSPECT_CUSTOM, CREDENTIAL_SEAL, DEPRECATION_REGISTRY, SECRETS_PACKAGE_SPECIFIER, requireSecretsSdk, IDEMPOTENT_METHODS, AUTHORITY_OVERRIDE_HEADERS;
4518
- var init_transport = __esm(() => {
4519
- CredentialResolutionError = class CredentialResolutionError extends Error {
4520
- appName;
4521
- attempted;
4522
- constructor(appName, message, attempted) {
4523
- super(message);
4524
- this.name = "CredentialResolutionError";
4525
- this.appName = appName;
4526
- this.attempted = attempted;
4360
+ var HOOK_NAME_RE, manifestSchema, ScriptContainmentError;
4361
+ var init_manifest = __esm(() => {
4362
+ init_config();
4363
+ init_semver();
4364
+ HOOK_NAME_RE = /^[\w-]+$/;
4365
+ manifestSchema = z.object({
4366
+ name: z.string().regex(HOOK_NAME_RE, "hook name must be /^[\\w-]+$/"),
4367
+ version: z.string().regex(SEMVER_PATTERN, "version must be full semver (e.g. 1.2.3 or 1.2.3-beta.1)"),
4368
+ description: z.string().optional(),
4369
+ events: z.array(z.string()).min(1, "at least one event is required"),
4370
+ script: z.string().min(1, "script must name a relative path or inline content"),
4371
+ script_kind: z.enum(["inline", "file"]).optional(),
4372
+ args: z.array(z.string()).optional(),
4373
+ timeout_ms: z.number().int().positive().optional(),
4374
+ env: z.record(z.string(), z.string()).optional()
4375
+ });
4376
+ ScriptContainmentError = class ScriptContainmentError extends Error {
4377
+ constructor(path, hookDir, raw) {
4378
+ super(raw && raw !== path ? `manifest script escapes the hook directory: '${path}' (from '${raw}') resolves outside '${hookDir}'` : `manifest script escapes the hook directory: '${path}' resolves outside '${hookDir}'`);
4379
+ this.name = "ScriptContainmentError";
4527
4380
  }
4528
4381
  };
4529
- MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
4530
- SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
4531
- SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
4532
- ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
4533
- VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
4534
- INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
4535
- CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
4536
- DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
4537
- SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
4538
- requireSecretsSdk = createRequire(import.meta.url);
4539
- IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
4540
- AUTHORITY_OVERRIDE_HEADERS = new Set([
4541
- "host",
4542
- ":authority",
4543
- "forwarded",
4544
- "x-forwarded-host",
4545
- "x-original-host"
4546
- ]);
4547
4382
  });
4548
4383
 
4549
- // src/config.ts
4550
- var exports_config = {};
4551
- __export(exports_config, {
4552
- writeConfig: () => writeConfig,
4553
- resolveApiUrl: () => resolveApiUrl,
4554
- resolveApiKey: () => resolveApiKey,
4555
- readConfig: () => readConfig,
4556
- getLockPath: () => getLockPath,
4557
- getHooksDataDir: () => getHooksDataDir,
4558
- getCustomHooksDir: () => getCustomHooksDir,
4559
- getConfigPath: () => getConfigPath
4384
+ // src/lib/resolve.ts
4385
+ var exports_resolve = {};
4386
+ __export(exports_resolve, {
4387
+ resolveScriptPath: () => resolveScriptPath,
4388
+ resolveHookMeta: () => resolveHookMeta,
4389
+ resolveHookDir: () => resolveHookDir,
4390
+ resolveHook: () => resolveHook,
4391
+ resolveBundledHooksDir: () => resolveBundledHooksDir
4560
4392
  });
4561
- import { existsSync as existsSync5, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
4562
- import { join as join8 } from "path";
4563
- function getHooksDataDir() {
4564
- return getEffectiveDataRoot();
4565
- }
4566
- function getCustomHooksDir() {
4567
- return join8(getHooksDataDir(), "hooks");
4393
+ import { dirname as dirname2, join as join7 } from "path";
4394
+ import { existsSync as existsSync6 } from "fs";
4395
+ import { fileURLToPath } from "url";
4396
+ function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
4397
+ const sourceLayout = join7(moduleDir, "..", "..", "hooks");
4398
+ if (existsSync6(join7(sourceLayout, "hook-gitguard")))
4399
+ return sourceLayout;
4400
+ return join7(moduleDir, "..", "hooks");
4568
4401
  }
4569
- function getLockPath() {
4570
- const explicit = process.env.HASNA_HOOKS_LOCK_PATH ?? process.env.HOOKS_LOCK_PATH;
4571
- if (explicit)
4572
- return explicit;
4573
- return join8(getHooksDataDir(), "hooks.lock");
4402
+ function resolveHookDir(name) {
4403
+ const custom = readCustomManifest(name);
4404
+ if (custom)
4405
+ return customHookDir(name);
4406
+ const meta = getHook(shortManifestName(name));
4407
+ if (!meta)
4408
+ return;
4409
+ const short = shortManifestName(name);
4410
+ const bundledBase = resolveBundledHooksDir();
4411
+ const direct = join7(bundledBase, short);
4412
+ if (existsSync6(direct))
4413
+ return direct;
4414
+ const prefixed = join7(bundledBase, `hook-${short}`);
4415
+ return existsSync6(prefixed) ? prefixed : undefined;
4574
4416
  }
4575
- function getConfigPath() {
4576
- const explicit = process.env.HASNA_HOOKS_CONFIG_PATH ?? process.env.HOOKS_CONFIG_PATH;
4577
- if (explicit)
4578
- return explicit;
4579
- return join8(getHooksDataDir(), "config.json");
4417
+ function resolveScriptPath(name) {
4418
+ const custom = readCustomManifest(name);
4419
+ if (custom)
4420
+ return custom.scriptPath;
4421
+ const dir = resolveHookDir(name);
4422
+ if (!dir)
4423
+ return;
4424
+ const script = join7(dir, "src", "hook.ts");
4425
+ return existsSync6(script) ? script : undefined;
4580
4426
  }
4581
- function readConfig() {
4582
- try {
4583
- const path = getConfigPath();
4584
- if (!existsSync5(path))
4585
- return {};
4586
- return JSON.parse(readFileSync2(path, "utf-8"));
4587
- } catch {
4588
- return {};
4427
+ function resolveHookMeta(name) {
4428
+ const custom = readCustomManifest(name);
4429
+ if (custom) {
4430
+ const { manifest } = custom;
4431
+ return {
4432
+ name: shortManifestName(manifest.name),
4433
+ displayName: manifest.description ? manifest.name : manifest.name,
4434
+ description: manifest.description ?? "Custom hook",
4435
+ version: manifest.version,
4436
+ category: "Workflow Automation",
4437
+ event: manifest.events[0] ?? "PostToolUse",
4438
+ events: manifest.events,
4439
+ matcher: "",
4440
+ tags: ["custom"]
4441
+ };
4589
4442
  }
4443
+ return getHook(shortManifestName(name));
4590
4444
  }
4591
- function writeConfig(config) {
4592
- const path = getConfigPath();
4593
- mkdirSync(join8(path, ".."), { recursive: true });
4594
- writeFileSync(path, JSON.stringify(config, null, 2) + `
4595
- `, "utf-8");
4596
- return path;
4597
- }
4598
- function resolveApiUrl() {
4599
- const envUrl = process.env.HASNA_HOOKS_API_URL ?? process.env.HOOKS_API_URL ?? process.env.HASNA_HOOKS_REGISTRY_URL ?? process.env.HOOKS_REGISTRY_URL;
4600
- if (envUrl)
4601
- return envUrl.replace(/\/+$/, "");
4602
- const cfg = readConfig();
4603
- return cfg.api_url ? cfg.api_url.replace(/\/+$/, "") : undefined;
4604
- }
4605
- function resolveApiKey() {
4606
- return resolveCredential("hooks", process.env)?.apiKey;
4445
+ function resolveHook(name) {
4446
+ const custom = readCustomManifest(name);
4447
+ if (custom) {
4448
+ const meta2 = resolveHookMeta(name);
4449
+ return {
4450
+ name: shortManifestName(custom.manifest.name),
4451
+ source: "custom",
4452
+ version: custom.manifest.version,
4453
+ events: custom.manifest.events,
4454
+ description: custom.manifest.description ?? "Custom hook",
4455
+ scriptPath: custom.scriptPath,
4456
+ meta: meta2,
4457
+ timeoutMs: custom.manifest.timeout_ms ?? null
4458
+ };
4459
+ }
4460
+ const meta = getHook(shortManifestName(name));
4461
+ const scriptPath = resolveScriptPath(name);
4462
+ if (!meta || !scriptPath)
4463
+ return;
4464
+ return {
4465
+ name: shortManifestName(name),
4466
+ source: "bundled",
4467
+ version: meta.version,
4468
+ events: meta.events && meta.events.length > 0 ? meta.events : [meta.event],
4469
+ description: meta.description,
4470
+ scriptPath,
4471
+ meta,
4472
+ timeoutMs: null
4473
+ };
4607
4474
  }
4608
- var init_config = __esm(() => {
4609
- init_transport();
4610
- init_app_home();
4611
- });
4612
-
4613
- // src/lib/semver.ts
4614
- var NUMERIC_ID = "(0|[1-9]\\d{0,15})", ALNUM_ID = "\\d*[A-Za-z-][0-9A-Za-z-]*", SEMVER_PATTERN;
4615
- var init_semver = __esm(() => {
4616
- SEMVER_PATTERN = new RegExp(`^${NUMERIC_ID}\\.${NUMERIC_ID}\\.${NUMERIC_ID}` + `(?:-(?:${NUMERIC_ID}|${ALNUM_ID})(?:\\.(?:${NUMERIC_ID}|${ALNUM_ID}))*)?` + `(?:\\+[0-9A-Za-z.-]+)?$`);
4617
- });
4618
-
4619
- // src/lib/manifest.ts
4620
- var exports_manifest = {};
4621
- __export(exports_manifest, {
4622
- writeCustomHook: () => writeCustomHook,
4623
- shortManifestName: () => shortManifestName,
4624
- scriptRelFor: () => scriptRelFor,
4625
- resolveScript: () => resolveScript,
4626
- readCustomManifest: () => readCustomManifest,
4627
- parseManifest: () => parseManifest,
4628
- normalizeManifestName: () => normalizeManifestName,
4629
- manifestSchema: () => manifestSchema,
4630
- listCustomHooks: () => listCustomHooks,
4631
- isInlineScript: () => isInlineScript,
4632
- customHookDir: () => customHookDir,
4633
- assertContained: () => assertContained,
4634
- ScriptContainmentError: () => ScriptContainmentError,
4635
- HOOK_NAME_RE: () => HOOK_NAME_RE
4636
- });
4637
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync, realpathSync, writeFileSync as writeFileSync2 } from "fs";
4638
- import { basename, dirname, isAbsolute, join as join9, normalize, resolve as resolve3, sep } from "path";
4639
- import { z } from "zod";
4640
- function parseManifest(json) {
4641
- const parsed = JSON.parse(json);
4642
- return manifestSchema.parse(parsed);
4643
- }
4644
- function normalizeManifestName(name) {
4645
- return name.startsWith("hook-") ? name : `hook-${name}`;
4646
- }
4647
- function shortManifestName(name) {
4648
- return name.startsWith("hook-") ? name.slice("hook-".length) : name;
4649
- }
4650
- function isWithin(child, parent) {
4651
- return child === parent || child.startsWith(parent + sep);
4652
- }
4653
- function assertContainedLexical(target, root, raw) {
4654
- const lexical = resolve3(target);
4655
- const rootLexical = resolve3(root);
4656
- if (!isWithin(lexical, rootLexical)) {
4657
- throw new ScriptContainmentError(target, root, raw);
4658
- }
4659
- return lexical;
4660
- }
4661
- function realpathNearest(path) {
4662
- const tail = [];
4663
- let cur = path;
4664
- while (!existsSync6(cur)) {
4665
- const parent = dirname(cur);
4666
- if (parent === cur)
4667
- break;
4668
- tail.unshift(basename(cur));
4669
- cur = parent;
4670
- }
4671
- return join9(realpathSync(cur), ...tail);
4672
- }
4673
- function assertContainedReal(root, target, raw) {
4674
- const rootReal = realpathSync(root);
4675
- const targetReal = realpathNearest(target);
4676
- if (!isWithin(targetReal, rootReal)) {
4677
- throw new ScriptContainmentError(target, root, raw);
4678
- }
4679
- }
4680
- function assertContained(target, root, raw) {
4681
- const lexical = assertContainedLexical(target, root, raw);
4682
- assertContainedReal(root, lexical, raw);
4683
- return lexical;
4684
- }
4685
- function isInlineScript(manifest) {
4686
- return manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
4687
- `);
4688
- }
4689
- function scriptRelFor(manifest) {
4690
- if (isInlineScript(manifest)) {
4691
- const ext = manifest.script.trim().startsWith("#!") ? ".sh" : ".ts";
4692
- return `script${ext}`;
4693
- }
4694
- const rel = normalize(manifest.script);
4695
- if (isAbsolute(rel)) {
4696
- throw new Error(`manifest script must be relative or inline, got absolute path '${manifest.script}'`);
4697
- }
4698
- return rel;
4699
- }
4700
- function resolveScript(manifest, manifestDir) {
4701
- if (isInlineScript(manifest))
4702
- return { path: scriptRelFor(manifest), content: manifest.script };
4703
- const rel = scriptRelFor(manifest);
4704
- const resolved = assertContained(join9(manifestDir, rel), manifestDir, manifest.script);
4705
- if (!existsSync6(resolved)) {
4706
- throw new Error(`manifest script file not found: ${resolved}`);
4707
- }
4708
- return { path: rel, content: readFileSync3(resolved, "utf-8") };
4709
- }
4710
- function customHookDir(name) {
4711
- return join9(getCustomHooksDir(), shortManifestName(name));
4712
- }
4713
- function readCustomManifest(name) {
4714
- const dir = customHookDir(name);
4715
- const manifestPath = join9(dir, "manifest.json");
4716
- if (!existsSync6(manifestPath))
4717
- return;
4718
- try {
4719
- const manifest = parseManifest(readFileSync3(manifestPath, "utf-8"));
4720
- const script = resolveScript(manifest, dir);
4721
- const scriptIsInline = isInlineScript(manifest);
4722
- return { manifest, scriptPath: join9(dir, script.path), scriptContent: script.content, scriptIsInline };
4723
- } catch (err) {
4724
- if (err instanceof ScriptContainmentError)
4725
- throw err;
4726
- return;
4727
- }
4728
- }
4729
- function listCustomHooks() {
4730
- const dir = getCustomHooksDir();
4731
- if (!existsSync6(dir))
4732
- return [];
4733
- const results = [];
4734
- for (const entry of readdirSafe(dir)) {
4735
- const parsed = readCustomManifest(entry);
4736
- if (parsed)
4737
- results.push(parsed);
4738
- }
4739
- return results.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
4740
- }
4741
- function writeCustomHook(name, manifest, scriptContent, scriptRelPath) {
4742
- const dir = customHookDir(name);
4743
- if (isAbsolute(scriptRelPath)) {
4744
- throw new ScriptContainmentError(scriptRelPath, dir);
4745
- }
4746
- const scriptPath = scriptRelPath.includes("/") ? join9(dir, scriptRelPath) : join9(dir, scriptRelPath);
4747
- assertContainedLexical(scriptPath, dir, scriptRelPath);
4748
- mkdirSync2(dir, { recursive: true });
4749
- assertContainedReal(dir, scriptPath, scriptRelPath);
4750
- const scriptDir = dirname(scriptPath);
4751
- if (scriptDir !== dir)
4752
- mkdirSync2(scriptDir, { recursive: true });
4753
- writeFileSync2(scriptPath, scriptContent, "utf-8");
4754
- writeFileSync2(join9(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + `
4755
- `, "utf-8");
4756
- return { dir, scriptPath };
4757
- }
4758
- function readdirSafe(dir) {
4759
- try {
4760
- return readdirSync(dir);
4761
- } catch {
4762
- return [];
4763
- }
4764
- }
4765
- var HOOK_NAME_RE, manifestSchema, ScriptContainmentError;
4766
- var init_manifest = __esm(() => {
4767
- init_config();
4768
- init_semver();
4769
- HOOK_NAME_RE = /^[\w-]+$/;
4770
- manifestSchema = z.object({
4771
- name: z.string().regex(HOOK_NAME_RE, "hook name must be /^[\\w-]+$/"),
4772
- version: z.string().regex(SEMVER_PATTERN, "version must be full semver (e.g. 1.2.3 or 1.2.3-beta.1)"),
4773
- description: z.string().optional(),
4774
- events: z.array(z.string()).min(1, "at least one event is required"),
4775
- script: z.string().min(1, "script must name a relative path or inline content"),
4776
- script_kind: z.enum(["inline", "file"]).optional(),
4777
- args: z.array(z.string()).optional(),
4778
- timeout_ms: z.number().int().positive().optional(),
4779
- env: z.record(z.string(), z.string()).optional()
4780
- });
4781
- ScriptContainmentError = class ScriptContainmentError extends Error {
4782
- constructor(path, hookDir, raw) {
4783
- super(raw && raw !== path ? `manifest script escapes the hook directory: '${path}' (from '${raw}') resolves outside '${hookDir}'` : `manifest script escapes the hook directory: '${path}' resolves outside '${hookDir}'`);
4784
- this.name = "ScriptContainmentError";
4785
- }
4786
- };
4787
- });
4788
-
4789
- // src/lib/resolve.ts
4790
- var exports_resolve = {};
4791
- __export(exports_resolve, {
4792
- resolveScriptPath: () => resolveScriptPath,
4793
- resolveHookMeta: () => resolveHookMeta,
4794
- resolveHookDir: () => resolveHookDir,
4795
- resolveHook: () => resolveHook,
4796
- resolveBundledHooksDir: () => resolveBundledHooksDir
4797
- });
4798
- import { dirname as dirname2, join as join10 } from "path";
4799
- import { existsSync as existsSync7 } from "fs";
4800
- import { fileURLToPath } from "url";
4801
- function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
4802
- const sourceLayout = join10(moduleDir, "..", "..", "hooks");
4803
- if (existsSync7(join10(sourceLayout, "hook-gitguard")))
4804
- return sourceLayout;
4805
- return join10(moduleDir, "..", "hooks");
4806
- }
4807
- function resolveHookDir(name) {
4808
- const custom = readCustomManifest(name);
4809
- if (custom)
4810
- return customHookDir(name);
4811
- const meta = getHook(shortManifestName(name));
4812
- if (!meta)
4813
- return;
4814
- const short = shortManifestName(name);
4815
- const bundledBase = resolveBundledHooksDir();
4816
- const direct = join10(bundledBase, short);
4817
- if (existsSync7(direct))
4818
- return direct;
4819
- const prefixed = join10(bundledBase, `hook-${short}`);
4820
- return existsSync7(prefixed) ? prefixed : undefined;
4821
- }
4822
- function resolveScriptPath(name) {
4823
- const custom = readCustomManifest(name);
4824
- if (custom)
4825
- return custom.scriptPath;
4826
- const dir = resolveHookDir(name);
4827
- if (!dir)
4828
- return;
4829
- const script = join10(dir, "src", "hook.ts");
4830
- return existsSync7(script) ? script : undefined;
4831
- }
4832
- function resolveHookMeta(name) {
4833
- const custom = readCustomManifest(name);
4834
- if (custom) {
4835
- const { manifest } = custom;
4836
- return {
4837
- name: shortManifestName(manifest.name),
4838
- displayName: manifest.description ? manifest.name : manifest.name,
4839
- description: manifest.description ?? "Custom hook",
4840
- version: manifest.version,
4841
- category: "Workflow Automation",
4842
- event: manifest.events[0] ?? "PostToolUse",
4843
- events: manifest.events,
4844
- matcher: "",
4845
- tags: ["custom"]
4846
- };
4847
- }
4848
- return getHook(shortManifestName(name));
4849
- }
4850
- function resolveHook(name) {
4851
- const custom = readCustomManifest(name);
4852
- if (custom) {
4853
- const meta2 = resolveHookMeta(name);
4854
- return {
4855
- name: shortManifestName(custom.manifest.name),
4856
- source: "custom",
4857
- version: custom.manifest.version,
4858
- events: custom.manifest.events,
4859
- description: custom.manifest.description ?? "Custom hook",
4860
- scriptPath: custom.scriptPath,
4861
- meta: meta2,
4862
- timeoutMs: custom.manifest.timeout_ms ?? null
4863
- };
4864
- }
4865
- const meta = getHook(shortManifestName(name));
4866
- const scriptPath = resolveScriptPath(name);
4867
- if (!meta || !scriptPath)
4868
- return;
4869
- return {
4870
- name: shortManifestName(name),
4871
- source: "bundled",
4872
- version: meta.version,
4873
- events: meta.events && meta.events.length > 0 ? meta.events : [meta.event],
4874
- description: meta.description,
4875
- scriptPath,
4876
- meta,
4877
- timeoutMs: null
4878
- };
4879
- }
4880
- var init_resolve = __esm(() => {
4881
- init_registry();
4882
- init_manifest();
4475
+ var init_resolve = __esm(() => {
4476
+ init_registry();
4477
+ init_manifest();
4883
4478
  });
4884
4479
 
4885
4480
  // src/db/schema.ts
@@ -5076,9 +4671,9 @@ var init_migrations = __esm(() => {
5076
4671
  });
5077
4672
 
5078
4673
  // src/db/legacy-import.ts
5079
- import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "fs";
5080
- import { join as join11 } from "path";
5081
- import { homedir as homedir6 } from "os";
4674
+ import { existsSync as existsSync7, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
4675
+ import { join as join8 } from "path";
4676
+ import { homedir as homedir3 } from "os";
5082
4677
  function ensureMetaTable(db) {
5083
4678
  db.exec(`
5084
4679
  CREATE TABLE IF NOT EXISTS _meta (
@@ -5101,7 +4696,7 @@ function nanoid() {
5101
4696
  function importJsonlFile(db, filePath) {
5102
4697
  let count = 0;
5103
4698
  try {
5104
- const lines = readFileSync4(filePath, "utf-8").split(`
4699
+ const lines = readFileSync2(filePath, "utf-8").split(`
5105
4700
  `).filter(Boolean);
5106
4701
  for (const line of lines) {
5107
4702
  try {
@@ -5127,7 +4722,7 @@ function importJsonlFile(db, filePath) {
5127
4722
  function importErrorsLog(db, filePath) {
5128
4723
  let count = 0;
5129
4724
  try {
5130
- const lines = readFileSync4(filePath, "utf-8").split(`
4725
+ const lines = readFileSync2(filePath, "utf-8").split(`
5131
4726
  `).filter(Boolean);
5132
4727
  const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
5133
4728
  for (const line of lines) {
@@ -5152,25 +4747,25 @@ function importErrorsLog(db, filePath) {
5152
4747
  } catch {}
5153
4748
  return count;
5154
4749
  }
5155
- function runLegacyImport(db, homeDir2 = homedir6()) {
4750
+ function runLegacyImport(db, homeDir = homedir3()) {
5156
4751
  try {
5157
4752
  if (isAlreadyDone(db))
5158
4753
  return;
5159
4754
  let total = 0;
5160
- const claudeProjectsDir = join11(homeDir2, ".claude", "projects");
5161
- if (existsSync8(claudeProjectsDir)) {
4755
+ const claudeProjectsDir = join8(homeDir, ".claude", "projects");
4756
+ if (existsSync7(claudeProjectsDir)) {
5162
4757
  try {
5163
4758
  const projectDirs = readdirSync2(claudeProjectsDir);
5164
4759
  for (const dir of projectDirs) {
5165
- const projectDir = join11(claudeProjectsDir, dir);
4760
+ const projectDir = join8(claudeProjectsDir, dir);
5166
4761
  try {
5167
4762
  const files = readdirSync2(projectDir);
5168
4763
  for (const file of files) {
5169
4764
  if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
5170
- total += importJsonlFile(db, join11(projectDir, file));
4765
+ total += importJsonlFile(db, join8(projectDir, file));
5171
4766
  }
5172
4767
  if (file === "errors.log") {
5173
- total += importErrorsLog(db, join11(projectDir, file));
4768
+ total += importErrorsLog(db, join8(projectDir, file));
5174
4769
  }
5175
4770
  }
5176
4771
  } catch {}
@@ -5213,13 +4808,13 @@ __export(exports_db, {
5213
4808
  closeDb: () => closeDb
5214
4809
  });
5215
4810
  import { Database } from "bun:sqlite";
5216
- import { existsSync as existsSync9, mkdirSync as mkdirSync3, cpSync } from "fs";
5217
- import { join as join12 } from "path";
4811
+ import { existsSync as existsSync8, mkdirSync as mkdirSync2, cpSync } from "fs";
4812
+ import { join as join9 } from "path";
5218
4813
  function resolveDataDir() {
5219
4814
  const effective = getEffectiveDataRoot();
5220
- const oldDir = join12(getHomeDir(), ".hooks");
5221
- if (!existsSync9(effective) && existsSync9(oldDir)) {
5222
- mkdirSync3(effective, { recursive: true });
4815
+ const oldDir = join9(getHomeDir(), ".hooks");
4816
+ if (!existsSync8(effective) && existsSync8(oldDir)) {
4817
+ mkdirSync2(effective, { recursive: true });
5223
4818
  cpSync(oldDir, effective, { recursive: true });
5224
4819
  }
5225
4820
  return effective;
@@ -5229,19 +4824,19 @@ function getDbPath() {
5229
4824
  if (explicitDb)
5230
4825
  return explicitDb;
5231
4826
  const dataDir3 = resolveDataDir();
5232
- return join12(dataDir3, "hooks.db");
4827
+ return join9(dataDir3, "hooks.db");
5233
4828
  }
5234
4829
  function ensureDir(dbPath) {
5235
4830
  const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
5236
- if (dir && !existsSync9(dir)) {
5237
- mkdirSync3(dir, { recursive: true });
4831
+ if (dir && !existsSync8(dir)) {
4832
+ mkdirSync2(dir, { recursive: true });
5238
4833
  }
5239
4834
  }
5240
4835
  function getDb() {
5241
4836
  if (instance)
5242
4837
  return instance;
5243
4838
  const dbPath = getDbPath();
5244
- const isNew = dbPath === ":memory:" || !existsSync9(dbPath);
4839
+ const isNew = dbPath === ":memory:" || !existsSync8(dbPath);
5245
4840
  ensureDir(dbPath);
5246
4841
  instance = new Database(dbPath);
5247
4842
  instance.exec("PRAGMA busy_timeout=5000");
@@ -5304,7 +4899,7 @@ __export(exports_store, {
5304
4899
  LockFileError: () => LockFileError
5305
4900
  });
5306
4901
  import { createHash } from "crypto";
5307
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3, renameSync } from "fs";
4902
+ import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, renameSync } from "fs";
5308
4903
  import { readFile as readFile2 } from "fs/promises";
5309
4904
  import { randomBytes } from "crypto";
5310
4905
  function sha256Of(input) {
@@ -5366,11 +4961,11 @@ function assertLockShape(parsed, path) {
5366
4961
  }
5367
4962
  function readLock() {
5368
4963
  const path = getLockPath();
5369
- if (!existsSync10(path))
4964
+ if (!existsSync9(path))
5370
4965
  return { hooks: {} };
5371
4966
  let raw;
5372
4967
  try {
5373
- raw = readFileSync5(path, "utf-8");
4968
+ raw = readFileSync3(path, "utf-8");
5374
4969
  } catch {
5375
4970
  throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
5376
4971
  }
@@ -5385,13 +4980,13 @@ function readLock() {
5385
4980
  }
5386
4981
  function writeLock(lock) {
5387
4982
  const path = getLockPath();
5388
- mkdirSync4(path.substring(0, path.lastIndexOf("/")) || ".", { recursive: true });
4983
+ mkdirSync3(path.substring(0, path.lastIndexOf("/")) || ".", { recursive: true });
5389
4984
  const sorted = {};
5390
4985
  for (const name of Object.keys(lock.hooks).sort()) {
5391
4986
  sorted[name] = lock.hooks[name];
5392
4987
  }
5393
4988
  const tmp = `${path}.tmp-${randomBytes(8).toString("hex")}`;
5394
- writeFileSync3(tmp, JSON.stringify({ hooks: sorted }, null, 2) + `
4989
+ writeFileSync2(tmp, JSON.stringify({ hooks: sorted }, null, 2) + `
5395
4990
  `, "utf-8");
5396
4991
  renameSync(tmp, path);
5397
4992
  return path;
@@ -5467,7 +5062,7 @@ async function verifyScriptHash(name, scriptPath) {
5467
5062
  return checkScriptHash(name, await sha256File(scriptPath));
5468
5063
  }
5469
5064
  function retrustHook(name, scriptPath, version, source) {
5470
- const actual = sha256Of(readFileSync5(scriptPath));
5065
+ const actual = sha256Of(readFileSync3(scriptPath));
5471
5066
  const db = getDb();
5472
5067
  upsertHookRecord(db, {
5473
5068
  name,
@@ -5508,9 +5103,9 @@ __export(exports_installer, {
5508
5103
  getHookPath: () => getHookPath,
5509
5104
  buildCodewithTomlFragment: () => buildCodewithTomlFragment
5510
5105
  });
5511
- import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "fs";
5512
- import { join as join13, dirname as dirname3 } from "path";
5513
- import { homedir as homedir7 } from "os";
5106
+ import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3, rmdirSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
5107
+ import { join as join10, dirname as dirname3 } from "path";
5108
+ import { homedir as homedir4 } from "os";
5514
5109
  import { fileURLToPath as fileURLToPath2 } from "url";
5515
5110
  function normalizeHookName(name) {
5516
5111
  return name.startsWith("hook-") ? name : `hook-${name}`;
@@ -5549,25 +5144,25 @@ function getSettingsPath(scope = "global", target = "claude", codewithConfigPath
5549
5144
  return globalOverride;
5550
5145
  const dir = getTargetSettingsDir(target);
5551
5146
  if (scope === "project") {
5552
- return target === "codewith" ? join13(process.cwd(), dir, "config.toml") : join13(process.cwd(), dir, "settings.json");
5147
+ return target === "codewith" ? join10(process.cwd(), dir, "config.toml") : join10(process.cwd(), dir, "settings.json");
5553
5148
  }
5554
- return target === "codewith" ? join13(homedir7(), dir, "config.toml") : join13(homedir7(), dir, "settings.json");
5149
+ return target === "codewith" ? join10(homedir4(), dir, "config.toml") : join10(homedir4(), dir, "settings.json");
5555
5150
  }
5556
5151
  function getHookPath(name) {
5557
5152
  const shortName = shortHookName(name);
5558
5153
  const resolved = resolveHookDir(shortName);
5559
5154
  if (resolved)
5560
5155
  return resolved;
5561
- return join13(HOOKS_DIR, normalizeHookName(shortName));
5156
+ return join10(HOOKS_DIR, normalizeHookName(shortName));
5562
5157
  }
5563
5158
  function hookExists(name) {
5564
- return existsSync11(getHookPath(name));
5159
+ return existsSync10(getHookPath(name));
5565
5160
  }
5566
5161
  function readSettings(scope = "global", target = "claude") {
5567
5162
  const path = getSettingsPath(scope, target);
5568
5163
  try {
5569
- if (existsSync11(path)) {
5570
- return JSON.parse(readFileSync6(path, "utf-8"));
5164
+ if (existsSync10(path)) {
5165
+ return JSON.parse(readFileSync4(path, "utf-8"));
5571
5166
  }
5572
5167
  } catch (error) {
5573
5168
  console.warn(`[hooks] Failed to read settings at ${path}: ${error instanceof Error ? error.message : String(error)}`);
@@ -5577,10 +5172,10 @@ function readSettings(scope = "global", target = "claude") {
5577
5172
  function writeSettings(settings, scope = "global", target = "claude") {
5578
5173
  const path = getSettingsPath(scope, target);
5579
5174
  const dir = dirname3(path);
5580
- if (!existsSync11(dir)) {
5581
- mkdirSync5(dir, { recursive: true });
5175
+ if (!existsSync10(dir)) {
5176
+ mkdirSync4(dir, { recursive: true });
5582
5177
  }
5583
- writeFileSync4(path, JSON.stringify(settings, null, 2) + `
5178
+ writeFileSync3(path, JSON.stringify(settings, null, 2) + `
5584
5179
  `);
5585
5180
  }
5586
5181
  function getTargetEventName(internalEvent, target) {
@@ -5666,7 +5261,7 @@ function buildCodewithTomlFragment(name, profile) {
5666
5261
  function readCodewithConfig(scope, configPath) {
5667
5262
  const path = getSettingsPath(scope, "codewith", configPath);
5668
5263
  try {
5669
- return existsSync11(path) ? readFileSync6(path, "utf-8") : "";
5264
+ return existsSync10(path) ? readFileSync4(path, "utf-8") : "";
5670
5265
  } catch {
5671
5266
  return "";
5672
5267
  }
@@ -5674,13 +5269,13 @@ function readCodewithConfig(scope, configPath) {
5674
5269
  function appendCodewithFragment(fragment, scope, configPath) {
5675
5270
  const path = getSettingsPath(scope, "codewith", configPath);
5676
5271
  const dir = dirname3(path);
5677
- if (!existsSync11(dir))
5678
- mkdirSync5(dir, { recursive: true });
5679
- const existing = existsSync11(path) ? readFileSync6(path, "utf-8") : "";
5272
+ if (!existsSync10(dir))
5273
+ mkdirSync4(dir, { recursive: true });
5274
+ const existing = existsSync10(path) ? readFileSync4(path, "utf-8") : "";
5680
5275
  const sep2 = existing.trim() ? `
5681
5276
 
5682
5277
  ` : "";
5683
- writeFileSync4(path, `${existing.replace(/\s*$/, "")}${sep2}${fragment}`);
5278
+ writeFileSync3(path, `${existing.replace(/\s*$/, "")}${sep2}${fragment}`);
5684
5279
  return path;
5685
5280
  }
5686
5281
  function detectConflict(name, scope, target) {
@@ -5855,460 +5450,1347 @@ function removeHookFromAllEvents(settings, name) {
5855
5450
  }
5856
5451
  }
5857
5452
  }
5858
- function unregisterHook(name, scope = "global", target = "claude") {
5859
- const settings = readSettings(scope, target);
5860
- if (!settings.hooks)
5453
+ function unregisterHook(name, scope = "global", target = "claude") {
5454
+ const settings = readSettings(scope, target);
5455
+ if (!settings.hooks)
5456
+ return;
5457
+ removeHookFromAllEvents(settings, name);
5458
+ if (Object.keys(settings.hooks).length === 0) {
5459
+ delete settings.hooks;
5460
+ }
5461
+ writeSettings(settings, scope, target);
5462
+ }
5463
+ function installHooks(names, options = {}) {
5464
+ return names.map((name) => installHook(name, options));
5465
+ }
5466
+ function getRegisteredHooksForTarget(scope = "global", target = "claude") {
5467
+ if (target === "codewith") {
5468
+ const config = readCodewithConfig(scope);
5469
+ const registered2 = [];
5470
+ const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
5471
+ let match;
5472
+ while (match = re.exec(config)) {
5473
+ registered2.push(match[1]);
5474
+ }
5475
+ return [...new Set(registered2)];
5476
+ }
5477
+ const settings = readSettings(scope, target);
5478
+ if (!settings.hooks)
5479
+ return [];
5480
+ const registered = [];
5481
+ for (const eventKey of Object.keys(settings.hooks)) {
5482
+ for (const entry of settings.hooks[eventKey]) {
5483
+ for (const hook of entry.hooks || []) {
5484
+ const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
5485
+ const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
5486
+ const match = newMatch || oldMatch;
5487
+ if (match) {
5488
+ registered.push(match[1]);
5489
+ }
5490
+ }
5491
+ }
5492
+ }
5493
+ return [...new Set(registered)];
5494
+ }
5495
+ function getRegisteredHooks(scope = "global") {
5496
+ return getRegisteredHooksForTarget(scope, "claude");
5497
+ }
5498
+ function removeHook(name, scope = "global", target = "claude") {
5499
+ const shortName = shortHookName(name);
5500
+ if (target === "all") {
5501
+ const claudeRemoved = removeHookForTarget(shortName, scope, "claude");
5502
+ const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
5503
+ return claudeRemoved || geminiRemoved;
5504
+ }
5505
+ if (target === "codewith") {
5506
+ return false;
5507
+ }
5508
+ return removeHookForTarget(shortName, scope, target);
5509
+ }
5510
+ function removeHookForTarget(name, scope, target) {
5511
+ const registered = getRegisteredHooksForTarget(scope, target);
5512
+ if (!registered.includes(name)) {
5513
+ return false;
5514
+ }
5515
+ unregisterHook(name, scope, target);
5516
+ return true;
5517
+ }
5518
+ function removeCodewithHookEntry(configText, name) {
5519
+ const sections = configText.split(/\n\s*\n/);
5520
+ const nameRe = new RegExp(`^command\\s*=\\s*"hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?"$`);
5521
+ const entryHeaderRe = /^\[\[hooks\.([\w]+)\.hooks\]\]$/;
5522
+ const eventHeaderRe = /^\[\[hooks\.([\w]+)\]\]$/;
5523
+ const eventsWithEntries = new Set;
5524
+ const kept = [];
5525
+ let removed = false;
5526
+ for (const section of sections) {
5527
+ const lines = section.split(`
5528
+ `);
5529
+ const entryMatch = entryHeaderRe.exec(lines[0] ?? "");
5530
+ if (entryMatch) {
5531
+ const event = entryMatch[1];
5532
+ eventsWithEntries.add(event);
5533
+ if (lines.some((line) => nameRe.test(line.trim()))) {
5534
+ removed = true;
5535
+ continue;
5536
+ }
5537
+ kept.push({ text: section });
5538
+ continue;
5539
+ }
5540
+ const eventMatch = eventHeaderRe.exec(lines[0] ?? "");
5541
+ kept.push({ text: section, eventHeaderFor: eventMatch ? eventMatch[1] : undefined });
5542
+ }
5543
+ const keptEntriesByEvent = new Set;
5544
+ for (const item of kept) {
5545
+ const m = entryHeaderRe.exec(item.text.split(`
5546
+ `)[0] ?? "");
5547
+ if (m)
5548
+ keptEntriesByEvent.add(m[1]);
5549
+ }
5550
+ const finalSections = kept.filter((item) => {
5551
+ if (item.eventHeaderFor === undefined)
5552
+ return true;
5553
+ if (!eventsWithEntries.has(item.eventHeaderFor))
5554
+ return true;
5555
+ return keptEntriesByEvent.has(item.eventHeaderFor);
5556
+ }).map((item) => item.text);
5557
+ return {
5558
+ text: finalSections.join(`
5559
+
5560
+ `) + (configText.endsWith(`
5561
+ `) ? `
5562
+ ` : ""),
5563
+ removed
5564
+ };
5565
+ }
5566
+ function escapeRegExp(value) {
5567
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5568
+ }
5569
+ function codewithHasHookEntryInText(configText, name) {
5570
+ return new RegExp(`hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?(?:$|\\s|")`).test(configText);
5571
+ }
5572
+ function codewithHasHookEntry(name, scope = "global") {
5573
+ const path = getSettingsPath(scope, "codewith");
5574
+ if (!existsSync10(path))
5575
+ return false;
5576
+ try {
5577
+ return codewithHasHookEntryInText(readFileSync4(path, "utf-8"), name);
5578
+ } catch {
5579
+ return false;
5580
+ }
5581
+ }
5582
+ function removeStoreDirFailClosed(dir) {
5583
+ const entries = readdirSync3(dir, { withFileTypes: true });
5584
+ for (const entry of entries) {
5585
+ if (entry.isDirectory()) {
5586
+ removeStoreDirFailClosed(join10(dir, entry.name));
5587
+ }
5588
+ }
5589
+ for (const entry of entries) {
5590
+ if (!entry.isDirectory()) {
5591
+ unlinkSync(join10(dir, entry.name));
5592
+ }
5593
+ }
5594
+ rmdirSync(dir);
5595
+ }
5596
+ function uninstallHook(name, scope = "global", target = "claude") {
5597
+ const shortName = shortHookName(name);
5598
+ const custom = readCustomManifest(shortName);
5599
+ const bundledMeta = custom ? undefined : getHook(shortName);
5600
+ const existsInTarget = (t) => getRegisteredHooksForTarget("global", t).includes(shortName) || getRegisteredHooksForTarget("project", t).includes(shortName);
5601
+ const registeredClaudeOrGemini = target === "all" ? existsInTarget("claude") || existsInTarget("gemini") : false;
5602
+ const registeredGlobal = registeredClaudeOrGemini || getRegisteredHooksForTarget("global", target === "all" ? "claude" : target).includes(shortName);
5603
+ const registeredProject = registeredClaudeOrGemini || getRegisteredHooksForTarget("project", target === "all" ? "claude" : target).includes(shortName);
5604
+ const codewithOnly = (target === "codewith" || target === "all") && codewithHasHookEntry(shortName, scope);
5605
+ if (!custom && !bundledMeta && !registeredGlobal && !registeredProject && !codewithOnly) {
5606
+ return { name: shortName, removed: false, source: null, settingsScopes: [], storeDirRemoved: false, pinRemoved: false, dbRecordRemoved: false, registrationsRemaining: [], error: `Hook '${shortName}' not found` };
5607
+ }
5608
+ const writableTargets = target === "all" ? ["claude", "gemini"] : target === "codewith" ? [] : [target];
5609
+ const scopes = [];
5610
+ for (const s of ["global", "project"]) {
5611
+ for (const t of writableTargets) {
5612
+ if (getRegisteredHooksForTarget(s, t).includes(shortName)) {
5613
+ unregisterHook(shortName, s, t);
5614
+ if (!scopes.includes(s))
5615
+ scopes.push(s);
5616
+ }
5617
+ }
5618
+ }
5619
+ const registrationsRemaining = [];
5620
+ if (target === "codewith" || target === "all") {
5621
+ const codewithPath = getSettingsPath(scope, "codewith");
5622
+ if (existsSync10(codewithPath)) {
5623
+ const before = readFileSync4(codewithPath, "utf-8");
5624
+ const after = removeCodewithHookEntry(before, shortName);
5625
+ if (after.removed) {
5626
+ writeFileSync3(codewithPath, after.text, "utf-8");
5627
+ }
5628
+ if (codewithHasHookEntryInText(after.removed ? after.text : before, shortName)) {
5629
+ registrationsRemaining.push("codewith");
5630
+ }
5631
+ }
5632
+ }
5633
+ if (custom) {
5634
+ try {
5635
+ removeStoreDirFailClosed(customHookDir(shortName));
5636
+ } catch {
5637
+ return {
5638
+ name: shortName,
5639
+ removed: false,
5640
+ source: "custom",
5641
+ settingsScopes: scopes,
5642
+ storeDirRemoved: false,
5643
+ pinRemoved: false,
5644
+ dbRecordRemoved: false,
5645
+ registrationsRemaining,
5646
+ error: `Hook '${shortName}' store directory could not be removed; trust records preserved (no fail-open)`
5647
+ };
5648
+ }
5649
+ }
5650
+ const { removedPin, removedRecord } = removeHookFromStore(shortName);
5651
+ return {
5652
+ name: shortName,
5653
+ removed: true,
5654
+ source: custom ? "custom" : bundledMeta ? "bundled" : "registered-only",
5655
+ settingsScopes: scopes,
5656
+ storeDirRemoved: custom ? true : false,
5657
+ pinRemoved: removedPin,
5658
+ dbRecordRemoved: removedRecord,
5659
+ registrationsRemaining
5660
+ };
5661
+ }
5662
+ var __dirname2, HOOKS_DIR, EVENT_MAP, getInstalledHooks;
5663
+ var init_installer = __esm(() => {
5664
+ init_registry();
5665
+ init_resolve();
5666
+ init_manifest();
5667
+ init_store();
5668
+ __dirname2 = dirname3(fileURLToPath2(import.meta.url));
5669
+ HOOKS_DIR = existsSync10(join10(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join10(__dirname2, "..", "..", "hooks") : join10(__dirname2, "..", "hooks");
5670
+ EVENT_MAP = {
5671
+ claude: {
5672
+ PreToolUse: "PreToolUse",
5673
+ PostToolUse: "PostToolUse",
5674
+ Stop: "Stop",
5675
+ Notification: "Notification",
5676
+ SessionStart: "SessionStart",
5677
+ SessionEnd: "SessionEnd",
5678
+ UserPromptSubmit: null,
5679
+ SubagentStart: null
5680
+ },
5681
+ gemini: {
5682
+ PreToolUse: "BeforeTool",
5683
+ PostToolUse: "AfterTool",
5684
+ Stop: "AfterAgent",
5685
+ Notification: "Notification",
5686
+ SessionStart: null,
5687
+ SessionEnd: null,
5688
+ UserPromptSubmit: null,
5689
+ SubagentStart: null
5690
+ },
5691
+ codewith: {
5692
+ PreToolUse: "PreToolUse",
5693
+ PostToolUse: "PostToolUse",
5694
+ Stop: "Stop",
5695
+ Notification: null,
5696
+ SessionStart: "SessionStart",
5697
+ SessionEnd: null,
5698
+ UserPromptSubmit: "UserPromptSubmit",
5699
+ SubagentStart: "SubagentStart"
5700
+ }
5701
+ };
5702
+ getInstalledHooks = getRegisteredHooks;
5703
+ });
5704
+
5705
+ // src/lib/registration.ts
5706
+ function safeMatcherTest(pattern, value) {
5707
+ try {
5708
+ return new RegExp(pattern).test(value);
5709
+ } catch {
5710
+ return false;
5711
+ }
5712
+ }
5713
+ function countSettingsWiring(settings) {
5714
+ const hooks = settings.hooks;
5715
+ if (!hooks || typeof hooks !== "object")
5716
+ return 0;
5717
+ let count = 0;
5718
+ for (const eventKey of Object.keys(hooks)) {
5719
+ const entries = hooks[eventKey];
5720
+ if (!Array.isArray(entries))
5721
+ continue;
5722
+ for (const entry of entries) {
5723
+ if (Array.isArray(entry?.hooks))
5724
+ count += entry.hooks.length;
5725
+ }
5726
+ }
5727
+ return count;
5728
+ }
5729
+ function hookRegisteredInSettings(settings, name, eventSpec, hookMatcher) {
5730
+ const separator = eventSpec.indexOf(":");
5731
+ const eventName = separator === -1 ? eventSpec : eventSpec.slice(0, separator);
5732
+ const specMatcher = separator === -1 ? "" : eventSpec.slice(separator + 1);
5733
+ const matcher = specMatcher || hookMatcher || "";
5734
+ const hooks = settings.hooks?.[eventName];
5735
+ if (!Array.isArray(hooks))
5736
+ return false;
5737
+ return hooks.some((entry) => entry?.hooks?.some((h) => {
5738
+ const match = h?.command?.match(/^hooks run ([\w-]+)/);
5739
+ if (!match || match[1] !== name)
5740
+ return false;
5741
+ if (!matcher)
5742
+ return true;
5743
+ const entryMatcher = typeof entry.matcher === "string" ? entry.matcher : "";
5744
+ if (!entryMatcher)
5745
+ return true;
5746
+ return entryMatcher === matcher || safeMatcherTest(entryMatcher, matcher) || safeMatcherTest(matcher, entryMatcher);
5747
+ }));
5748
+ }
5749
+
5750
+ // src/lib/redact.ts
5751
+ function redactText(value) {
5752
+ let out = value;
5753
+ for (const pattern of SECRET_SHAPES) {
5754
+ out = out.replace(pattern, REDACTED);
5755
+ }
5756
+ return out;
5757
+ }
5758
+ function redactValue2(value) {
5759
+ if (typeof value === "string")
5760
+ return redactText(value);
5761
+ if (Array.isArray(value))
5762
+ return value.map(redactValue2);
5763
+ if (value !== null && typeof value === "object") {
5764
+ const out = {};
5765
+ for (const [key, child] of Object.entries(value)) {
5766
+ out[key] = SECRET_KEY_NAMES.test(key) ? REDACTED : redactValue2(child);
5767
+ }
5768
+ return out;
5769
+ }
5770
+ return value;
5771
+ }
5772
+ function redactEventPayload(text) {
5773
+ if (text === null || text === undefined)
5774
+ return null;
5775
+ try {
5776
+ const parsed = JSON.parse(text);
5777
+ return redactText(JSON.stringify(redactValue2(parsed)));
5778
+ } catch {
5779
+ return redactText(text);
5780
+ }
5781
+ }
5782
+ function projectEventRowForRead(row) {
5783
+ const out = { ...row };
5784
+ for (const field of ["tool_input", "error", "metadata"]) {
5785
+ const value = out[field];
5786
+ if (typeof value === "string")
5787
+ out[field] = redactEventPayload(value);
5788
+ else if (value === null)
5789
+ out[field] = null;
5790
+ }
5791
+ return out;
5792
+ }
5793
+ var REDACTED = "[REDACTED]", SECRET_KEY_NAMES, SECRET_SHAPES;
5794
+ var init_redact = __esm(() => {
5795
+ SECRET_KEY_NAMES = /^(key|keys|token|tokens|secret|secrets|password|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|refresh[_-]?token|authorization|auth|cookie|credential|credentials|session[_-]?id|otp|mfa[_-]?code|two[_-]?fa[_-]?code|verification[_-]?code|database_url|db_url|connection_string|dsn)$/i;
5796
+ SECRET_SHAPES = [
5797
+ /\bsk-(?:proj|svc|ant)-[A-Za-z0-9_-]{10,}/g,
5798
+ /\bsk-[A-Za-z0-9]{16,}/g,
5799
+ /\btpe_[A-Za-z0-9]{8,}/g,
5800
+ /\b(?:rk|sk)_(?:live|test)_[A-Za-z0-9]{8,}/g,
5801
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
5802
+ /gh[pousr]_[A-Za-z0-9]{20,}/g,
5803
+ /xox[baprs]-[A-Za-z0-9-]{20,}/g,
5804
+ /AKIA[0-9A-Z]{16}/g,
5805
+ /ASIA[0-9A-Z]{16}/g,
5806
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
5807
+ /-----BEGIN [A-Z0-9 ]*PRIVAT[E] KEY-----/g,
5808
+ /-----BEGIN OPENSSH PRIVAT[E] KEY-----/g,
5809
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{6,}/g,
5810
+ /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/gi,
5811
+ /\b(?:password|passwd|pwd|token|api[_-]?key|secret|access[_-]?key|client[_-]?secret|authorization|auth|credential|database_url|db_url|connection_string|dsn)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']{6,})/gi
5812
+ ];
5813
+ });
5814
+
5815
+ // src/lib/profiles.ts
5816
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4, readdirSync as readdirSync4, rmSync, cpSync as cpSync2 } from "fs";
5817
+ import { join as join11 } from "path";
5818
+ function getProfilesDir() {
5819
+ const newDir = join11(getEffectiveDataRoot(), "profiles");
5820
+ const oldDir = join11(getHomeDir(), ".hooks", "profiles");
5821
+ if (!existsSync11(newDir) && existsSync11(oldDir)) {
5822
+ mkdirSync5(newDir, { recursive: true });
5823
+ cpSync2(oldDir, newDir, { recursive: true });
5824
+ }
5825
+ return newDir;
5826
+ }
5827
+ function ensureProfilesDir() {
5828
+ const dir = getProfilesDir();
5829
+ if (!existsSync11(dir)) {
5830
+ mkdirSync5(dir, { recursive: true });
5831
+ }
5832
+ }
5833
+ function profilePath(id) {
5834
+ return join11(getProfilesDir(), `${id}.json`);
5835
+ }
5836
+ function shortUuid() {
5837
+ return crypto.randomUUID().slice(0, 8);
5838
+ }
5839
+ function createProfile(input) {
5840
+ ensureProfilesDir();
5841
+ const id = shortUuid();
5842
+ const now2 = new Date().toISOString();
5843
+ const profile = {
5844
+ agent_id: id,
5845
+ agent_type: input.agent_type,
5846
+ created_at: now2,
5847
+ last_seen_at: now2,
5848
+ preferences: {}
5849
+ };
5850
+ if (input.name) {
5851
+ profile.name = input.name;
5852
+ }
5853
+ writeFileSync4(profilePath(id), JSON.stringify(profile, null, 2) + `
5854
+ `);
5855
+ return profile;
5856
+ }
5857
+ function getProfile(id) {
5858
+ const path = profilePath(id);
5859
+ try {
5860
+ if (!existsSync11(path))
5861
+ return null;
5862
+ return JSON.parse(readFileSync5(path, "utf-8"));
5863
+ } catch {
5864
+ return null;
5865
+ }
5866
+ }
5867
+ function listProfiles() {
5868
+ const dir = getProfilesDir();
5869
+ if (!existsSync11(dir))
5870
+ return [];
5871
+ try {
5872
+ const files = readdirSync4(dir).filter((f) => f.endsWith(".json"));
5873
+ const profiles = [];
5874
+ for (const file of files) {
5875
+ try {
5876
+ const content = readFileSync5(join11(dir, file), "utf-8");
5877
+ profiles.push(JSON.parse(content));
5878
+ } catch {}
5879
+ }
5880
+ return profiles.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
5881
+ } catch {
5882
+ return [];
5883
+ }
5884
+ }
5885
+ function touchProfile(id) {
5886
+ const profile = getProfile(id);
5887
+ if (!profile)
5861
5888
  return;
5862
- removeHookFromAllEvents(settings, name);
5863
- if (Object.keys(settings.hooks).length === 0) {
5864
- delete settings.hooks;
5865
- }
5866
- writeSettings(settings, scope, target);
5889
+ profile.last_seen_at = new Date().toISOString();
5890
+ writeFileSync4(profilePath(id), JSON.stringify(profile, null, 2) + `
5891
+ `);
5867
5892
  }
5868
- function installHooks(names, options = {}) {
5869
- return names.map((name) => installHook(name, options));
5893
+ function exportProfiles() {
5894
+ return listProfiles();
5870
5895
  }
5871
- function getRegisteredHooksForTarget(scope = "global", target = "claude") {
5872
- if (target === "codewith") {
5873
- const config = readCodewithConfig(scope);
5874
- const registered2 = [];
5875
- const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
5876
- let match;
5877
- while (match = re.exec(config)) {
5878
- registered2.push(match[1]);
5896
+ function importProfiles(profiles) {
5897
+ ensureProfilesDir();
5898
+ let imported = 0;
5899
+ let skipped = 0;
5900
+ for (const profile of profiles) {
5901
+ if (!profile.agent_id || !profile.agent_type) {
5902
+ skipped++;
5903
+ continue;
5879
5904
  }
5880
- return [...new Set(registered2)];
5905
+ const path = profilePath(profile.agent_id);
5906
+ if (existsSync11(path)) {
5907
+ skipped++;
5908
+ continue;
5909
+ }
5910
+ writeFileSync4(path, JSON.stringify(profile, null, 2) + `
5911
+ `);
5912
+ imported++;
5881
5913
  }
5882
- const settings = readSettings(scope, target);
5883
- if (!settings.hooks)
5914
+ return { imported, skipped };
5915
+ }
5916
+ var init_profiles = __esm(() => {
5917
+ init_app_home();
5918
+ });
5919
+
5920
+ // ../contracts/dist/client/transport.js
5921
+ import { isIP as isIP2 } from "net";
5922
+ import { spawnSync } from "child_process";
5923
+ import { closeSync, fstatSync, openSync, readFileSync as readFileSync6 } from "fs";
5924
+ import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
5925
+ import { createRequire } from "module";
5926
+ import { hostname as osHostname } from "os";
5927
+ import { isAbsolute as isAbsolute2, join as join12 } from "path";
5928
+ function envToken(name) {
5929
+ return name.toUpperCase().replace(/-/g, "_");
5930
+ }
5931
+ function clientTransportEnvKeys(name) {
5932
+ const envSegment = envToken(name);
5933
+ return {
5934
+ apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
5935
+ apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
5936
+ };
5937
+ }
5938
+ function credentialOverrideEnvKey(name) {
5939
+ return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
5940
+ }
5941
+ function credentialPointerEnvKey(name) {
5942
+ return `HASNA_${envToken(name)}_API_KEY_REF`;
5943
+ }
5944
+ function homeDir(env) {
5945
+ const home = env.HOME?.trim();
5946
+ return home ? home : null;
5947
+ }
5948
+ function absoluteOverride(env, key) {
5949
+ const value = env[key]?.trim();
5950
+ return value && isAbsolute2(value) ? value : null;
5951
+ }
5952
+ function hasnaHomeDir(env) {
5953
+ const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
5954
+ if (override)
5955
+ return override;
5956
+ const home = homeDir(env);
5957
+ return home ? join12(home, HASNA_HOME_DIR) : null;
5958
+ }
5959
+ function appConfigDir(name, env) {
5960
+ const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
5961
+ if (configRoot)
5962
+ return join12(configRoot, name);
5963
+ const root = hasnaHomeDir(env);
5964
+ return root ? join12(root, name, CONFIG_SUBDIR) : null;
5965
+ }
5966
+ function credentialDiskSourceList(name, env, profile = null) {
5967
+ if (!SAFE_APP_SLUG.test(name))
5884
5968
  return [];
5885
- const registered = [];
5886
- for (const eventKey of Object.keys(settings.hooks)) {
5887
- for (const entry of settings.hooks[eventKey]) {
5888
- for (const hook of entry.hooks || []) {
5889
- const newMatch = hook.command?.match(/^hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?$/);
5890
- const oldMatch = hook.command?.match(/^hook-([\w-]+)$/);
5891
- const match = newMatch || oldMatch;
5892
- if (match) {
5893
- registered.push(match[1]);
5894
- }
5969
+ const directory = appConfigDir(name, env);
5970
+ if (!directory)
5971
+ return [];
5972
+ const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
5973
+ return [{ path: join12(directory, file), tier: "disk" }];
5974
+ }
5975
+ function credentialDiskSources(name, env) {
5976
+ return credentialDiskSourceList(name, env, null).map((s) => s.path);
5977
+ }
5978
+ function profileDiskSources(name, env, profile) {
5979
+ return credentialDiskSourceList(name, env, profile).map((s) => s.path);
5980
+ }
5981
+ function parseEnvFile(text) {
5982
+ const values = new Map;
5983
+ const unusable = new Set;
5984
+ for (const rawLine of text.split(/\r?\n/)) {
5985
+ const line = rawLine.trim();
5986
+ if (line.length === 0 || line.startsWith("#"))
5987
+ continue;
5988
+ const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
5989
+ const equals = withoutExport.indexOf("=");
5990
+ if (equals <= 0)
5991
+ continue;
5992
+ const key = withoutExport.slice(0, equals).trim();
5993
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
5994
+ continue;
5995
+ let value = withoutExport.slice(equals + 1).trim();
5996
+ const quote = value[0];
5997
+ if (quote === '"' || quote === "'") {
5998
+ if (value.length < 2 || !value.endsWith(quote)) {
5999
+ unusable.add(key);
6000
+ continue;
5895
6001
  }
6002
+ value = value.slice(1, -1);
5896
6003
  }
6004
+ if (value.trim().length === 0) {
6005
+ unusable.add(key);
6006
+ continue;
6007
+ }
6008
+ if (values.has(key) && values.get(key) !== value)
6009
+ unusable.add(key);
6010
+ values.set(key, value);
5897
6011
  }
5898
- return [...new Set(registered)];
6012
+ return { values, unusable };
5899
6013
  }
5900
- function getRegisteredHooks(scope = "global") {
5901
- return getRegisteredHooksForTarget(scope, "claude");
6014
+ function configFileModeAllowed(mode) {
6015
+ const permissions = mode & 4095;
6016
+ return permissions === 256 || permissions === 384;
5902
6017
  }
5903
- function removeHook(name, scope = "global", target = "claude") {
5904
- const shortName = shortHookName(name);
5905
- if (target === "all") {
5906
- const claudeRemoved = removeHookForTarget(shortName, scope, "claude");
5907
- const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
5908
- return claudeRemoved || geminiRemoved;
6018
+ function configFileReadsCoherent(before, after) {
6019
+ return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
6020
+ }
6021
+ function readAppConfigFile(path) {
6022
+ const unsafe = (reason) => {
6023
+ throw new CredentialFileUnsafeError(path, reason);
6024
+ };
6025
+ let fd = -1;
6026
+ try {
6027
+ fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
6028
+ } catch (error) {
6029
+ const code = error.code;
6030
+ if (code === "ENOENT" || code === "ENOTDIR")
6031
+ return null;
6032
+ if (code === "ELOOP")
6033
+ unsafe("the path is a symlink");
6034
+ unsafe(`the path could not be opened (${code ?? "unknown error"})`);
5909
6035
  }
5910
- if (target === "codewith") {
5911
- return false;
6036
+ try {
6037
+ const before = fstatSync(fd);
6038
+ if (!before.isFile())
6039
+ unsafe("the path is not a regular file");
6040
+ if (!configFileModeAllowed(before.mode)) {
6041
+ unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
6042
+ }
6043
+ const uid = process.getuid?.() ?? process.geteuid?.();
6044
+ if (uid !== undefined && before.uid !== uid)
6045
+ unsafe("the file is not owned by the current user");
6046
+ if (before.size > MAX_CREDENTIAL_FILE_BYTES)
6047
+ unsafe("the file exceeds the size limit");
6048
+ const bytes = readFileSync6(fd);
6049
+ const after = fstatSync(fd);
6050
+ if (!configFileReadsCoherent(before, after)) {
6051
+ unsafe("the file changed while being read");
6052
+ }
6053
+ return parseEnvFile(bytes.toString("utf8"));
6054
+ } finally {
6055
+ if (fd !== -1)
6056
+ closeSync(fd);
5912
6057
  }
5913
- return removeHookForTarget(shortName, scope, target);
5914
6058
  }
5915
- function removeHookForTarget(name, scope, target) {
5916
- const registered = getRegisteredHooksForTarget(scope, target);
5917
- if (!registered.includes(name)) {
5918
- return false;
6059
+ function readCredentialFile(path, apiKeyKeys) {
6060
+ const parsed = readAppConfigFile(path);
6061
+ if (!parsed)
6062
+ return null;
6063
+ for (const key of apiKeyKeys) {
6064
+ if (parsed.unusable.has(key)) {
6065
+ throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
6066
+ }
5919
6067
  }
5920
- unregisterHook(name, scope, target);
5921
- return true;
6068
+ const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
6069
+ if (new Set(values).size > 1) {
6070
+ throw new CredentialFileUnsafeError(path, "credential aliases disagree");
6071
+ }
6072
+ return values[0] ?? null;
5922
6073
  }
5923
- function removeCodewithHookEntry(configText, name) {
5924
- const sections = configText.split(/\n\s*\n/);
5925
- const nameRe = new RegExp(`^command\\s*=\\s*"hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?"$`);
5926
- const entryHeaderRe = /^\[\[hooks\.([\w]+)\.hooks\]\]$/;
5927
- const eventHeaderRe = /^\[\[hooks\.([\w]+)\]\]$/;
5928
- const eventsWithEntries = new Set;
5929
- const kept = [];
5930
- let removed = false;
5931
- for (const section of sections) {
5932
- const lines = section.split(`
5933
- `);
5934
- const entryMatch = entryHeaderRe.exec(lines[0] ?? "");
5935
- if (entryMatch) {
5936
- const event = entryMatch[1];
5937
- eventsWithEntries.add(event);
5938
- if (lines.some((line) => nameRe.test(line.trim()))) {
5939
- removed = true;
5940
- continue;
5941
- }
5942
- kept.push({ text: section });
6074
+ function appConfigDiskValue(name, env, keys) {
6075
+ const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY.test(key));
6076
+ if (wanted.length === 0)
6077
+ return null;
6078
+ for (const path of credentialDiskSources(name, env)) {
6079
+ const parsed = readAppConfigFile(path);
6080
+ if (!parsed)
5943
6081
  continue;
6082
+ if (wanted.some((key) => parsed.unusable.has(key))) {
6083
+ return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
6084
+ }
6085
+ const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
6086
+ if (new Set(values).size > 1)
6087
+ throw new CredentialFileUnsafeError(path, "configuration aliases disagree");
6088
+ for (const key of wanted) {
6089
+ if (parsed.unusable.has(key))
6090
+ return { key, value: "", path, unusable: true };
6091
+ const value = parsed.values.get(key)?.trim();
6092
+ if (value)
6093
+ return { key, value, path };
5944
6094
  }
5945
- const eventMatch = eventHeaderRe.exec(lines[0] ?? "");
5946
- kept.push({ text: section, eventHeaderFor: eventMatch ? eventMatch[1] : undefined });
5947
6095
  }
5948
- const keptEntriesByEvent = new Set;
5949
- for (const item of kept) {
5950
- const m = entryHeaderRe.exec(item.text.split(`
5951
- `)[0] ?? "");
5952
- if (m)
5953
- keptEntriesByEvent.add(m[1]);
6096
+ return null;
6097
+ }
6098
+ function assertUsableCredential(appName, source, value) {
6099
+ if (VAULT_POINTER_SHAPE.test(value)) {
6100
+ throw new CredentialResolutionError(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
5954
6101
  }
5955
- const finalSections = kept.filter((item) => {
5956
- if (item.eventHeaderFor === undefined)
5957
- return true;
5958
- if (!eventsWithEntries.has(item.eventHeaderFor))
5959
- return true;
5960
- return keptEntriesByEvent.has(item.eventHeaderFor);
5961
- }).map((item) => item.text);
5962
- return {
5963
- text: finalSections.join(`
5964
-
5965
- `) + (configText.endsWith(`
5966
- `) ? `
5967
- ` : ""),
5968
- removed
6102
+ if (!ILLEGAL_IN_HEADER_VALUE.test(value))
6103
+ return;
6104
+ throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
6105
+ }
6106
+ function sealCredential(fields) {
6107
+ const { apiKey } = fields;
6108
+ const visible = {
6109
+ tier: fields.tier,
6110
+ source: fields.source,
6111
+ deliberate: fields.deliberate,
6112
+ diskCandidates: Object.freeze([...fields.diskCandidates]),
6113
+ warning: fields.warning
5969
6114
  };
6115
+ const sealed = { ...visible };
6116
+ Object.defineProperty(sealed, "apiKey", {
6117
+ value: apiKey,
6118
+ enumerable: false,
6119
+ writable: false,
6120
+ configurable: false
6121
+ });
6122
+ if (fields.pointerVaultKey !== undefined) {
6123
+ Object.defineProperty(sealed, "pointerVaultKey", {
6124
+ value: fields.pointerVaultKey,
6125
+ enumerable: false,
6126
+ writable: false,
6127
+ configurable: false
6128
+ });
6129
+ }
6130
+ Object.defineProperty(sealed, INSPECT_CUSTOM, {
6131
+ value: () => ({ ...visible, apiKey: "[redacted]" }),
6132
+ enumerable: false,
6133
+ writable: false,
6134
+ configurable: false
6135
+ });
6136
+ Object.defineProperty(sealed, CREDENTIAL_SEAL, {
6137
+ value: true,
6138
+ enumerable: false,
6139
+ writable: false,
6140
+ configurable: false
6141
+ });
6142
+ return Object.freeze(sealed);
6143
+ }
6144
+ function firstEnvValue(env, keys) {
6145
+ for (const key of keys) {
6146
+ if (!Object.prototype.hasOwnProperty.call(env, key))
6147
+ continue;
6148
+ const value = env[key]?.trim();
6149
+ if (value)
6150
+ return { key, value };
6151
+ }
6152
+ return null;
5970
6153
  }
5971
- function escapeRegExp(value) {
5972
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6154
+ function isAmbientEnvironment(env) {
6155
+ return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
5973
6156
  }
5974
- function codewithHasHookEntryInText(configText, name) {
5975
- return new RegExp(`hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?(?:$|\\s|")`).test(configText);
6157
+ function defaultKeychainRunner(argv) {
6158
+ const result = spawnSync(KEYCHAIN_SECURITY_BIN, [...argv], {
6159
+ encoding: "utf8",
6160
+ stdio: ["ignore", "pipe", "pipe"],
6161
+ timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
6162
+ });
6163
+ return {
6164
+ status: result.status,
6165
+ stdout: result.stdout ?? "",
6166
+ stderr: result.error ? result.error.message : result.stderr ?? ""
6167
+ };
5976
6168
  }
5977
- function codewithHasHookEntry(name, scope = "global") {
5978
- const path = getSettingsPath(scope, "codewith");
5979
- if (!existsSync11(path))
6169
+ function keychainTierEnabled(env, options) {
6170
+ if ((options.platform ?? process.platform) !== "darwin")
5980
6171
  return false;
6172
+ if (options.enabled !== undefined)
6173
+ return options.enabled;
6174
+ return options.run !== undefined || isAmbientEnvironment(env);
6175
+ }
6176
+ function keychainAccount(env, options) {
6177
+ const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
6178
+ if (station)
6179
+ return station;
6180
+ const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
6181
+ if (host)
6182
+ return host;
6183
+ const user = env.USER?.trim();
6184
+ return user || null;
6185
+ }
6186
+ function keychainFailureHint(text) {
6187
+ const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
6188
+ const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
6189
+ return clean ? `: ${clean}` : "";
6190
+ }
6191
+ function readKeychainItem(name, env, kind, options) {
6192
+ if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
6193
+ return null;
6194
+ const account = keychainAccount(env, options);
6195
+ if (!account)
6196
+ return null;
6197
+ const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
6198
+ const source = `keychain:${service}@${account}`;
6199
+ const run = options.run ?? defaultKeychainRunner;
6200
+ let result;
5981
6201
  try {
5982
- return codewithHasHookEntryInText(readFileSync6(path, "utf-8"), name);
5983
- } catch {
5984
- return false;
6202
+ result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
6203
+ } catch (error) {
6204
+ const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
6205
+ throw new CredentialResolutionError(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
6206
+ }
6207
+ if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
6208
+ return null;
6209
+ if (result.status !== 0) {
6210
+ throw new CredentialResolutionError(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
6211
+ }
6212
+ const value = result.stdout.trim();
6213
+ if (!value) {
6214
+ throw new CredentialResolutionError(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
6215
+ }
6216
+ return { value, source };
6217
+ }
6218
+ function keychainConfigValue(name, env, options = {}) {
6219
+ return readKeychainItem(name, env, "api-url", options);
6220
+ }
6221
+ function snapshotClientEnvironment(name, env) {
6222
+ const keys = clientTransportEnvKeys(name);
6223
+ const ambient = isAmbientEnvironment(env);
6224
+ const snapshot = Object.create(null);
6225
+ for (const key of [
6226
+ ...keys.apiUrlKeys,
6227
+ ...keys.apiKeyKeys,
6228
+ credentialOverrideEnvKey(name),
6229
+ credentialPointerEnvKey(name),
6230
+ CREDENTIAL_PROFILE_ENV_KEY,
6231
+ "HOME",
6232
+ HASNA_HOME_ENV_KEY,
6233
+ HASNA_CONFIG_HOME_ENV_KEY,
6234
+ KEYCHAIN_STATION_ENV_KEY,
6235
+ "USER"
6236
+ ]) {
6237
+ const descriptor = Object.getOwnPropertyDescriptor(env, key);
6238
+ if (!descriptor)
6239
+ continue;
6240
+ if (!("value" in descriptor)) {
6241
+ throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
6242
+ }
6243
+ if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
6244
+ throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
6245
+ }
6246
+ snapshot[key] = descriptor.value;
6247
+ }
6248
+ if (ambient) {
6249
+ Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
6250
+ value: true,
6251
+ enumerable: false,
6252
+ writable: false,
6253
+ configurable: false
6254
+ });
5985
6255
  }
6256
+ return Object.freeze(snapshot);
5986
6257
  }
5987
- function uninstallHook(name, scope = "global", target = "claude") {
5988
- const shortName = shortHookName(name);
5989
- const custom = readCustomManifest(shortName);
5990
- const bundledMeta = custom ? undefined : getHook(shortName);
5991
- const existsInTarget = (t) => getRegisteredHooksForTarget("global", t).includes(shortName) || getRegisteredHooksForTarget("project", t).includes(shortName);
5992
- const registeredClaudeOrGemini = target === "all" ? existsInTarget("claude") || existsInTarget("gemini") : false;
5993
- const registeredGlobal = registeredClaudeOrGemini || getRegisteredHooksForTarget("global", target === "all" ? "claude" : target).includes(shortName);
5994
- const registeredProject = registeredClaudeOrGemini || getRegisteredHooksForTarget("project", target === "all" ? "claude" : target).includes(shortName);
5995
- const codewithOnly = (target === "codewith" || target === "all") && codewithHasHookEntry(shortName, scope);
5996
- if (!custom && !bundledMeta && !registeredGlobal && !registeredProject && !codewithOnly) {
5997
- return { name: shortName, removed: false, source: null, settingsScopes: [], storeDirRemoved: false, pinRemoved: false, dbRecordRemoved: false, registrationsRemaining: [], error: `Hook '${shortName}' not found` };
6258
+ function resolveCredential(name, env, options = {}) {
6259
+ env = snapshotClientEnvironment(name, env);
6260
+ const { apiKeyKeys } = clientTransportEnvKeys(name);
6261
+ const diskPaths = credentialDiskSources(name, env);
6262
+ if (options.apiKey !== undefined) {
6263
+ const explicitKey = options.apiKey.trim();
6264
+ if (!explicitKey) {
6265
+ throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
6266
+ }
6267
+ assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
6268
+ return sealCredential({
6269
+ apiKey: explicitKey,
6270
+ tier: "argument",
6271
+ source: "explicit apiKey argument",
6272
+ deliberate: true,
6273
+ diskCandidates: diskPaths,
6274
+ warning: null
6275
+ });
5998
6276
  }
5999
- const writableTargets = target === "all" ? ["claude", "gemini"] : target === "codewith" ? [] : [target];
6000
- const scopes = [];
6001
- for (const s of ["global", "project"]) {
6002
- for (const t of writableTargets) {
6003
- if (getRegisteredHooksForTarget(s, t).includes(shortName)) {
6004
- unregisterHook(shortName, s, t);
6005
- if (!scopes.includes(s))
6006
- scopes.push(s);
6007
- }
6277
+ const overrideKeyName = credentialOverrideEnvKey(name);
6278
+ const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
6279
+ if (overrideRaw !== undefined) {
6280
+ const override = overrideRaw.trim();
6281
+ if (!override) {
6282
+ throw new CredentialResolutionError(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
6008
6283
  }
6284
+ assertUsableCredential(name, overrideKeyName, override);
6285
+ return sealCredential({
6286
+ apiKey: override,
6287
+ tier: "override",
6288
+ source: overrideKeyName,
6289
+ deliberate: true,
6290
+ diskCandidates: diskPaths,
6291
+ warning: null
6292
+ });
6009
6293
  }
6010
- const registrationsRemaining = [];
6011
- if (target === "codewith" || target === "all") {
6012
- const codewithPath = getSettingsPath(scope, "codewith");
6013
- if (existsSync11(codewithPath)) {
6014
- const before = readFileSync6(codewithPath, "utf-8");
6015
- const after = removeCodewithHookEntry(before, shortName);
6016
- if (after.removed) {
6017
- writeFileSync4(codewithPath, after.text, "utf-8");
6018
- }
6019
- if (codewithHasHookEntryInText(after.removed ? after.text : before, shortName)) {
6020
- registrationsRemaining.push("codewith");
6021
- }
6294
+ const pointerKeyName = credentialPointerEnvKey(name);
6295
+ const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
6296
+ if (pointerRaw !== undefined) {
6297
+ const pointer = pointerRaw.trim();
6298
+ if (!pointer) {
6299
+ throw new CredentialResolutionError(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
6300
+ }
6301
+ if (!VAULT_POINTER_SHAPE.test(pointer)) {
6302
+ throw new CredentialResolutionError(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
6022
6303
  }
6304
+ return sealCredential({
6305
+ apiKey: "",
6306
+ pointerVaultKey: pointer,
6307
+ tier: "pointer",
6308
+ source: pointerKeyName,
6309
+ deliberate: true,
6310
+ diskCandidates: diskPaths,
6311
+ warning: null
6312
+ });
6023
6313
  }
6024
- if (custom) {
6025
- try {
6026
- rmSync(customHookDir(shortName), { recursive: true, force: true });
6027
- } catch {
6028
- return {
6029
- name: shortName,
6030
- removed: false,
6031
- source: "custom",
6032
- settingsScopes: scopes,
6033
- storeDirRemoved: false,
6034
- pinRemoved: false,
6035
- dbRecordRemoved: false,
6036
- registrationsRemaining,
6037
- error: `Hook '${shortName}' store directory could not be removed; trust records preserved (no fail-open)`
6038
- };
6314
+ if (options.profile !== undefined && !options.profile.trim()) {
6315
+ throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
6316
+ }
6317
+ const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
6318
+ if (profileRaw !== undefined && !profileRaw.trim()) {
6319
+ throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
6320
+ }
6321
+ const profile = options.profile?.trim() || profileRaw?.trim();
6322
+ if (profile) {
6323
+ const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
6324
+ if (!SAFE_PROFILE.test(profile)) {
6325
+ throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
6326
+ }
6327
+ const paths = profileDiskSources(name, env, profile);
6328
+ for (const path of paths) {
6329
+ const value = readCredentialFile(path, apiKeyKeys);
6330
+ if (value) {
6331
+ assertUsableCredential(name, path, value);
6332
+ return sealCredential({
6333
+ apiKey: value,
6334
+ tier: "profile",
6335
+ source: path,
6336
+ deliberate: true,
6337
+ diskCandidates: paths,
6338
+ warning: null
6339
+ });
6340
+ }
6039
6341
  }
6342
+ throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
6040
6343
  }
6041
- const { removedPin, removedRecord } = removeHookFromStore(shortName);
6042
- return {
6043
- name: shortName,
6044
- removed: true,
6045
- source: custom ? "custom" : bundledMeta ? "bundled" : "registered-only",
6046
- settingsScopes: scopes,
6047
- storeDirRemoved: custom ? true : false,
6048
- pinRemoved: removedPin,
6049
- dbRecordRemoved: removedRecord,
6050
- registrationsRemaining
6051
- };
6344
+ const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
6345
+ const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
6346
+ if (blankEnv) {
6347
+ throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
6348
+ }
6349
+ if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
6350
+ throw new CredentialResolutionError(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
6351
+ }
6352
+ const envHit = firstEnvValue(env, apiKeyKeys);
6353
+ const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
6354
+ if (keychainHit) {
6355
+ assertUsableCredential(name, keychainHit.source, keychainHit.value);
6356
+ const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain is re-read on every call while ` + `an environment variable is a snapshot. Reconcile them \u2014 a rotation that updated only one leaves ` + `the other to fail 401 wherever it is loaded first.` : null;
6357
+ return sealCredential({
6358
+ apiKey: keychainHit.value,
6359
+ tier: "keychain",
6360
+ source: keychainHit.source,
6361
+ deliberate: false,
6362
+ diskCandidates: diskPaths,
6363
+ warning
6364
+ });
6365
+ }
6366
+ const diskSourceList = credentialDiskSourceList(name, env, null);
6367
+ const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
6368
+ if (diskHits.length > 0) {
6369
+ const winner = diskHits[0];
6370
+ assertUsableCredential(name, winner.src.path, winner.value);
6371
+ const divergentSources = [
6372
+ ...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
6373
+ ...envHit && envHit.value !== winner.value ? [envHit.key] : []
6374
+ ];
6375
+ const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
6376
+ return sealCredential({
6377
+ apiKey: winner.value,
6378
+ tier: winner.src.tier,
6379
+ source: winner.src.path,
6380
+ deliberate: false,
6381
+ diskCandidates: diskPaths,
6382
+ warning
6383
+ });
6384
+ }
6385
+ if (envHit) {
6386
+ assertUsableCredential(name, envHit.key, envHit.value);
6387
+ return sealCredential({
6388
+ apiKey: envHit.value,
6389
+ tier: "env",
6390
+ source: envHit.key,
6391
+ deliberate: false,
6392
+ diskCandidates: diskPaths,
6393
+ warning: null
6394
+ });
6395
+ }
6396
+ return null;
6052
6397
  }
6053
- var __dirname2, HOOKS_DIR, EVENT_MAP, getInstalledHooks;
6054
- var init_installer = __esm(() => {
6055
- init_registry();
6056
- init_resolve();
6057
- init_manifest();
6058
- init_store();
6059
- __dirname2 = dirname3(fileURLToPath2(import.meta.url));
6060
- HOOKS_DIR = existsSync11(join13(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join13(__dirname2, "..", "..", "hooks") : join13(__dirname2, "..", "hooks");
6061
- EVENT_MAP = {
6062
- claude: {
6063
- PreToolUse: "PreToolUse",
6064
- PostToolUse: "PostToolUse",
6065
- Stop: "Stop",
6066
- Notification: "Notification",
6067
- SessionStart: "SessionStart",
6068
- SessionEnd: "SessionEnd",
6069
- UserPromptSubmit: null,
6070
- SubagentStart: null
6071
- },
6072
- gemini: {
6073
- PreToolUse: "BeforeTool",
6074
- PostToolUse: "AfterTool",
6075
- Stop: "AfterAgent",
6076
- Notification: "Notification",
6077
- SessionStart: null,
6078
- SessionEnd: null,
6079
- UserPromptSubmit: null,
6080
- SubagentStart: null
6081
- },
6082
- codewith: {
6083
- PreToolUse: "PreToolUse",
6084
- PostToolUse: "PostToolUse",
6085
- Stop: "Stop",
6086
- Notification: null,
6087
- SessionStart: "SessionStart",
6088
- SessionEnd: null,
6089
- UserPromptSubmit: "UserPromptSubmit",
6090
- SubagentStart: "SubagentStart"
6091
- }
6092
- };
6093
- getInstalledHooks = getRegisteredHooks;
6094
- });
6095
-
6096
- // src/lib/registration.ts
6097
- function safeMatcherTest(pattern, value) {
6098
- try {
6099
- return new RegExp(pattern).test(value);
6100
- } catch {
6398
+ function defaultFleetGatewayBaseUrl(name) {
6399
+ return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
6400
+ }
6401
+ function isValidDnsDomain(value) {
6402
+ if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
6101
6403
  return false;
6102
6404
  }
6405
+ return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
6103
6406
  }
6104
- function countSettingsWiring(settings) {
6105
- const hooks = settings.hooks;
6106
- if (!hooks || typeof hooks !== "object")
6107
- return 0;
6108
- let count = 0;
6109
- for (const eventKey of Object.keys(hooks)) {
6110
- const entries = hooks[eventKey];
6111
- if (!Array.isArray(entries))
6112
- continue;
6113
- for (const entry of entries) {
6114
- if (Array.isArray(entry?.hooks))
6115
- count += entry.hooks.length;
6116
- }
6407
+ function validateAppSlug(name) {
6408
+ if (name.length > 63 || !DNS_LABEL_PATTERN.test(name)) {
6409
+ throw new Error("App name must be one lowercase DNS label.");
6117
6410
  }
6118
- return count;
6411
+ return name;
6119
6412
  }
6120
- function hookRegisteredInSettings(settings, name, eventSpec, hookMatcher) {
6121
- const separator = eventSpec.indexOf(":");
6122
- const eventName = separator === -1 ? eventSpec : eventSpec.slice(0, separator);
6123
- const specMatcher = separator === -1 ? "" : eventSpec.slice(separator + 1);
6124
- const matcher = specMatcher || hookMatcher || "";
6125
- const hooks = settings.hooks?.[eventName];
6126
- if (!Array.isArray(hooks))
6127
- return false;
6128
- return hooks.some((entry) => entry?.hooks?.some((h) => {
6129
- const match = h?.command?.match(/^hooks run ([\w-]+)/);
6130
- if (!match || match[1] !== name)
6131
- return false;
6132
- if (!matcher)
6133
- return true;
6134
- const entryMatcher = typeof entry.matcher === "string" ? entry.matcher : "";
6135
- if (!entryMatcher)
6136
- return true;
6137
- return entryMatcher === matcher || safeMatcherTest(entryMatcher, matcher) || safeMatcherTest(matcher, entryMatcher);
6138
- }));
6413
+ function rawAuthority(value) {
6414
+ const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
6415
+ if (!match)
6416
+ throw new Error("API URL must be absolute.");
6417
+ const afterScheme = value.slice(match[0].length);
6418
+ const boundary = afterScheme.search(/[/?#]/);
6419
+ const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
6420
+ if (!authority)
6421
+ throw new Error("API URL must include a hostname.");
6422
+ return authority;
6139
6423
  }
6140
-
6141
- // src/lib/redact.ts
6142
- function redactText(value) {
6143
- let out = value;
6144
- for (const pattern of SECRET_SHAPES) {
6145
- out = out.replace(pattern, REDACTED);
6424
+ function assertCanonicalPort(port) {
6425
+ if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
6426
+ throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
6427
+ }
6428
+ const numericPort = Number(port);
6429
+ if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
6430
+ throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
6146
6431
  }
6147
- return out;
6148
6432
  }
6149
- function redactValue2(value) {
6150
- if (typeof value === "string")
6151
- return redactText(value);
6152
- if (Array.isArray(value))
6153
- return value.map(redactValue2);
6154
- if (value !== null && typeof value === "object") {
6155
- const out = {};
6156
- for (const [key, child] of Object.entries(value)) {
6157
- out[key] = SECRET_KEY_NAMES.test(key) ? REDACTED : redactValue2(child);
6433
+ function canonicalAuthorityHostname(authority) {
6434
+ let rawHostname;
6435
+ if (authority.startsWith("[")) {
6436
+ const closingBracket = authority.indexOf("]");
6437
+ if (closingBracket === -1) {
6438
+ throw new Error("API URL authority must contain a canonical hostname.");
6439
+ }
6440
+ rawHostname = authority.slice(0, closingBracket + 1);
6441
+ const portSuffix = authority.slice(closingBracket + 1);
6442
+ if (portSuffix) {
6443
+ if (!portSuffix.startsWith(":")) {
6444
+ throw new Error("API URL authority must contain a canonical hostname and port.");
6445
+ }
6446
+ assertCanonicalPort(portSuffix.slice(1));
6447
+ }
6448
+ if (isIP2(rawHostname.slice(1, -1)) !== 6) {
6449
+ throw new Error("API URL authority must contain a canonical IPv6 literal.");
6450
+ }
6451
+ } else {
6452
+ const firstColon = authority.indexOf(":");
6453
+ const lastColon = authority.lastIndexOf(":");
6454
+ if (firstColon !== lastColon) {
6455
+ throw new Error("IPv6 API URL authorities must use brackets.");
6456
+ }
6457
+ if (lastColon !== -1) {
6458
+ const port = authority.slice(lastColon + 1);
6459
+ assertCanonicalPort(port);
6460
+ rawHostname = authority.slice(0, lastColon);
6461
+ } else {
6462
+ rawHostname = authority;
6463
+ }
6464
+ const ipVersion = isIP2(rawHostname);
6465
+ const numericAddressParts = rawHostname.split(".");
6466
+ const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
6467
+ if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
6468
+ throw new Error("API URL authority must contain a canonical ASCII hostname.");
6158
6469
  }
6159
- return out;
6160
6470
  }
6161
- return value;
6471
+ return rawHostname.toLowerCase();
6162
6472
  }
6163
- function redactEventPayload(text) {
6164
- if (text === null || text === undefined)
6165
- return null;
6166
- try {
6167
- const parsed = JSON.parse(text);
6168
- return redactText(JSON.stringify(redactValue2(parsed)));
6169
- } catch {
6170
- return redactText(text);
6171
- }
6473
+ function isDeliberateLoopbackHttpAuthority(authority) {
6474
+ return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
6172
6475
  }
6173
- function projectEventRowForRead(row) {
6174
- const out = { ...row };
6175
- for (const field of ["tool_input", "error", "metadata"]) {
6176
- const value = out[field];
6177
- if (typeof value === "string")
6178
- out[field] = redactEventPayload(value);
6179
- else if (value === null)
6180
- out[field] = null;
6476
+ function toV1BaseUrl(apiUrl) {
6477
+ if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
6478
+ throw new Error("API URL must not contain ASCII control characters.");
6181
6479
  }
6182
- return out;
6480
+ const input = apiUrl.trim();
6481
+ const authority = rawAuthority(input);
6482
+ if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
6483
+ throw new Error("API URL authority must be canonical ASCII without credentials.");
6484
+ }
6485
+ const canonicalHostname = canonicalAuthorityHostname(authority);
6486
+ const url = new URL(input);
6487
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
6488
+ throw new Error("API URL must use http or https.");
6489
+ }
6490
+ if (url.username || url.password) {
6491
+ throw new Error("API URL must not include credentials.");
6492
+ }
6493
+ if (!url.hostname || url.hostname.endsWith(".")) {
6494
+ throw new Error("API URL must include a canonical hostname.");
6495
+ }
6496
+ if (url.hostname.toLowerCase() !== canonicalHostname) {
6497
+ throw new Error("API URL authority must not rely on parser hostname normalization.");
6498
+ }
6499
+ if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
6500
+ throw new Error("API URL must not use IDN or punycode hostnames.");
6501
+ }
6502
+ if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
6503
+ throw new Error("API URL may use http only for an exact loopback authority.");
6504
+ }
6505
+ if (url.search || url.hash) {
6506
+ throw new Error("API URL must not include a query string or fragment.");
6507
+ }
6508
+ let path = url.pathname.replace(/\/+$/, "");
6509
+ if (path.endsWith("/v1"))
6510
+ path = path.slice(0, -"/v1".length);
6511
+ url.pathname = `${path}/v1`;
6512
+ return url.toString().replace(/\/+$/, "");
6183
6513
  }
6184
- var REDACTED = "[REDACTED]", SECRET_KEY_NAMES, SECRET_SHAPES;
6185
- var init_redact = __esm(() => {
6186
- SECRET_KEY_NAMES = /^(key|keys|token|tokens|secret|secrets|password|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|refresh[_-]?token|authorization|auth|cookie|credential|credentials|session[_-]?id|otp|mfa[_-]?code|two[_-]?fa[_-]?code|verification[_-]?code|database_url|db_url|connection_string|dsn)$/i;
6187
- SECRET_SHAPES = [
6188
- /\bsk-(?:proj|svc|ant)-[A-Za-z0-9_-]{10,}/g,
6189
- /\bsk-[A-Za-z0-9]{16,}/g,
6190
- /\btpe_[A-Za-z0-9]{8,}/g,
6191
- /\b(?:rk|sk)_(?:live|test)_[A-Za-z0-9]{8,}/g,
6192
- /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
6193
- /gh[pousr]_[A-Za-z0-9]{20,}/g,
6194
- /xox[baprs]-[A-Za-z0-9-]{20,}/g,
6195
- /AKIA[0-9A-Z]{16}/g,
6196
- /ASIA[0-9A-Z]{16}/g,
6197
- /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
6198
- /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g,
6199
- /-----BEGIN OPENSSH PRIVATE KEY-----/g,
6200
- /\bBearer\s+[A-Za-z0-9._~+/=-]{6,}/g,
6201
- /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/gi,
6202
- /\b(?:password|passwd|pwd|token|api[_-]?key|secret|access[_-]?key|client[_-]?secret|authorization|auth|credential|database_url|db_url|connection_string|dsn)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']{6,})/gi
6514
+ function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
6515
+ env = snapshotClientEnvironment(name, env);
6516
+ const keys = clientTransportEnvKeys(name);
6517
+ const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
6518
+ const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
6519
+ if (blankUrl) {
6520
+ throw new ClientTransportConfigurationError(name, `${blankUrl.key} is set but blank; public clients require an explicit HTTPS API URL and never select local storage.`, [blankUrl.key]);
6521
+ }
6522
+ const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
6523
+ if (controlledUrl) {
6524
+ throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
6525
+ }
6526
+ const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
6527
+ if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
6528
+ throw new ClientTransportConfigurationError(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must be identical or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
6529
+ }
6530
+ const envUrlHit = usableUrlEntries[0] ?? null;
6531
+ const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
6532
+ const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys);
6533
+ if (diskConfigUrlHit?.unusable) {
6534
+ throw new ClientTransportConfigurationError(name, `${diskConfigUrlHit.key} in ${diskConfigUrlHit.path} is declared but blank or malformed; public clients require a valid HTTPS service authority.`, [diskConfigUrlHit.path]);
6535
+ }
6536
+ const urlCandidates = [
6537
+ ...envUrlHit ? [envUrlHit] : [],
6538
+ ...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
6539
+ ...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
6203
6540
  ];
6541
+ const configuredUrl = urlCandidates[0] ?? null;
6542
+ const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
6543
+ if (configuredUrl && divergentUrls.length > 0) {
6544
+ throw new ClientTransportConfigurationError(name, `${configuredUrl.key} and ${divergentUrls.map((candidate) => candidate.key).join(" and ")} select different service authorities; refusing to send a credential written for one authority to the other.`, urlCandidates.map((candidate) => candidate.key));
6545
+ }
6546
+ const warnings = [];
6547
+ if (configuredUrl && !envUrlHit) {
6548
+ warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${configuredUrl.key} was used, so this client connects to the server. ` + `Keep that entry aligned with the intended service authority.`);
6549
+ }
6550
+ const credential = resolveCredential(name, env, options.credentials);
6551
+ if (!credential) {
6552
+ const diskHint = credentialDiskSourcesForMessage(name, env);
6553
+ const lead = configuredUrl ? `${configuredUrl.key} selects the HTTP server for '${name}', but no API key could be resolved` : `${keys.apiUrlKeys[0]} is not set and no API key could be resolved for '${name}'; a credential is required before the default fleet gateway authority applies`;
6554
+ warnings.push(`${lead}; refusing to create an unauthenticated client \u2014 public clients never fall back to SQLite or another local store. ` + `Looked in the Keychain (macOS only), then for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
6555
+ throw new ClientTransportConfigurationError(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
6556
+ }
6557
+ if (credential.warning)
6558
+ warnings.push(credential.warning);
6559
+ let urlHit;
6560
+ if (configuredUrl) {
6561
+ urlHit = configuredUrl;
6562
+ } else {
6563
+ try {
6564
+ urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
6565
+ } catch (error) {
6566
+ const message = error instanceof Error ? error.message : String(error);
6567
+ throw new ClientTransportConfigurationError(name, `No ${keys.apiUrlKeys[0]} is configured and the default fleet gateway authority cannot be composed for '${name}': ${message}`, [keys.apiUrlKeys[0]]);
6568
+ }
6569
+ }
6570
+ const apiUrlSource = urlHit.key;
6571
+ let baseUrl;
6572
+ try {
6573
+ baseUrl = toV1BaseUrl(urlHit.value);
6574
+ } catch (error) {
6575
+ const message = error instanceof Error ? error.message : String(error);
6576
+ throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
6577
+ }
6578
+ return {
6579
+ resolution: {
6580
+ transport: "http",
6581
+ transportSource: urlHit.key,
6582
+ baseUrl,
6583
+ apiUrlSource,
6584
+ apiKeyPresent: true,
6585
+ apiKeySource: credential.source,
6586
+ apiKeyTier: credential.tier,
6587
+ misconfigured: false,
6588
+ warning: warnings.length > 0 ? warnings.join(" ") : null
6589
+ },
6590
+ credential
6591
+ };
6592
+ }
6593
+ function resolveClientTransport(name, env = process.env, options = {}) {
6594
+ return resolveClientTransportSnapshot(name, env, options).resolution;
6595
+ }
6596
+ function credentialDiskSourcesForMessage(name, env) {
6597
+ const paths = credentialDiskSources(name, env);
6598
+ return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
6599
+ }
6600
+ var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE", CredentialResolutionError, CredentialFileUnsafeError, HASNA_HOME_ENV_KEY = "HASNA_HOME", HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME", KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION", HASNA_HOME_DIR = ".hasna", CONFIG_SUBDIR = "config", CREDENTIALS_FILE = "credentials", KEYCHAIN_SECURITY_BIN = "/usr/bin/security", KEYCHAIN_SERVICE_PREFIX = "hasna.credentials", KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44, KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4, MAX_CREDENTIAL_FILE_BYTES, SAFE_APP_SLUG, SAFE_PROFILE, ILLEGAL_IN_HEADER_VALUE, VAULT_POINTER_SHAPE, CREDENTIAL_SHAPED_KEY, INSPECT_CUSTOM, CREDENTIAL_SEAL, AMBIENT_ENVIRONMENT, SECRETS_PACKAGE_SPECIFIER, requireSecretsSdk, DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com", DEFAULT_AUTHORITY_SOURCE = "default", ASCII_CONTROL_PATTERN, DNS_LABEL_PATTERN, ClientTransportConfigurationError, IDEMPOTENT_METHODS, AUTHORITY_OVERRIDE_HEADERS;
6601
+ var init_transport = __esm(() => {
6602
+ CredentialResolutionError = class CredentialResolutionError extends Error {
6603
+ appName;
6604
+ attempted;
6605
+ constructor(appName, message, attempted) {
6606
+ super(message);
6607
+ this.name = "CredentialResolutionError";
6608
+ this.appName = appName;
6609
+ this.attempted = attempted;
6610
+ }
6611
+ };
6612
+ CredentialFileUnsafeError = class CredentialFileUnsafeError extends Error {
6613
+ path;
6614
+ constructor(path, reason) {
6615
+ super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
6616
+ this.name = "CredentialFileUnsafeError";
6617
+ this.path = path;
6618
+ }
6619
+ };
6620
+ MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
6621
+ SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
6622
+ SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
6623
+ ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
6624
+ VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
6625
+ CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
6626
+ INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
6627
+ CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
6628
+ AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
6629
+ SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
6630
+ requireSecretsSdk = createRequire(import.meta.url);
6631
+ ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
6632
+ DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
6633
+ ClientTransportConfigurationError = class ClientTransportConfigurationError extends Error {
6634
+ appName;
6635
+ sources;
6636
+ constructor(appName, message, sources = []) {
6637
+ super(message);
6638
+ this.name = "ClientTransportConfigurationError";
6639
+ this.appName = appName;
6640
+ this.sources = Object.freeze([...sources]);
6641
+ }
6642
+ };
6643
+ IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
6644
+ AUTHORITY_OVERRIDE_HEADERS = new Set([
6645
+ "host",
6646
+ ":authority",
6647
+ "forwarded",
6648
+ "x-forwarded-host",
6649
+ "x-original-host"
6650
+ ]);
6204
6651
  });
6205
6652
 
6206
- // src/lib/profiles.ts
6207
- import { existsSync as existsSync12, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5, readdirSync as readdirSync3, rmSync as rmSync2, cpSync as cpSync2 } from "fs";
6208
- import { join as join14 } from "path";
6209
- function resolveProfilesDir() {
6210
- const newDir = join14(getEffectiveDataRoot(), "profiles");
6211
- const oldDir = join14(getHomeDir(), ".hooks", "profiles");
6212
- if (!existsSync12(newDir) && existsSync12(oldDir)) {
6213
- mkdirSync6(newDir, { recursive: true });
6214
- cpSync2(oldDir, newDir, { recursive: true });
6215
- }
6216
- return newDir;
6653
+ // src/lib/local-opt-in.ts
6654
+ function isHooksLocalOptIn(env = process.env) {
6655
+ return HOOKS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
6217
6656
  }
6218
- function ensureProfilesDir() {
6219
- if (!existsSync12(PROFILES_DIR)) {
6220
- mkdirSync6(PROFILES_DIR, { recursive: true });
6221
- }
6657
+ function hooksAuthorityEnvKeys() {
6658
+ const keys = clientTransportEnvKeys("hooks");
6659
+ return [
6660
+ ...keys.apiUrlKeys,
6661
+ ...keys.apiKeyKeys,
6662
+ credentialOverrideEnvKey("hooks"),
6663
+ credentialPointerEnvKey("hooks"),
6664
+ CREDENTIAL_PROFILE_ENV_KEY
6665
+ ];
6222
6666
  }
6223
- function profilePath(id) {
6224
- return join14(PROFILES_DIR, `${id}.json`);
6667
+ function hasHooksEnvAuthorityIntent(env = process.env) {
6668
+ return hooksAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
6225
6669
  }
6226
- function shortUuid() {
6227
- return crypto.randomUUID().slice(0, 8);
6670
+ function selectsHooksLocalStore(env = process.env) {
6671
+ return !hasHooksEnvAuthorityIntent(env) && isHooksLocalOptIn(env);
6228
6672
  }
6229
- function getProfilesDir() {
6230
- return PROFILES_DIR;
6673
+ function hooksResolverEnv(env) {
6674
+ const blanks = hooksAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
6675
+ if (blanks.length === 0)
6676
+ return env;
6677
+ const next = { ...env };
6678
+ for (const key of blanks)
6679
+ delete next[key];
6680
+ return next;
6231
6681
  }
6232
- function createProfile(input) {
6233
- ensureProfilesDir();
6234
- const id = shortUuid();
6235
- const now2 = new Date().toISOString();
6236
- const profile = {
6237
- agent_id: id,
6238
- agent_type: input.agent_type,
6239
- created_at: now2,
6240
- last_seen_at: now2,
6241
- preferences: {}
6242
- };
6243
- if (input.name) {
6244
- profile.name = input.name;
6245
- }
6246
- writeFileSync5(profilePath(id), JSON.stringify(profile, null, 2) + `
6247
- `);
6248
- return profile;
6682
+ function isAmbientHooksEnv(env) {
6683
+ if (typeof process !== "undefined" && env === process.env)
6684
+ return true;
6685
+ return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
6249
6686
  }
6250
- function getProfile(id) {
6251
- const path = profilePath(id);
6252
- try {
6253
- if (!existsSync12(path))
6254
- return null;
6255
- return JSON.parse(readFileSync7(path, "utf-8"));
6256
- } catch {
6257
- return null;
6687
+ function hooksResolverInputs(env, credentials = {}) {
6688
+ const normalised = hooksResolverEnv(env);
6689
+ if (normalised === env)
6690
+ return { env: normalised, credentials };
6691
+ const keychain = { ...credentials.keychain };
6692
+ if (keychain.enabled === undefined && keychain.run === undefined) {
6693
+ keychain.enabled = isAmbientHooksEnv(env);
6258
6694
  }
6695
+ return { env: normalised, credentials: { ...credentials, keychain } };
6259
6696
  }
6260
- function listProfiles() {
6261
- if (!existsSync12(PROFILES_DIR))
6262
- return [];
6263
- try {
6264
- const files = readdirSync3(PROFILES_DIR).filter((f) => f.endsWith(".json"));
6265
- const profiles = [];
6266
- for (const file of files) {
6267
- try {
6268
- const content = readFileSync7(join14(PROFILES_DIR, file), "utf-8");
6269
- profiles.push(JSON.parse(content));
6270
- } catch {}
6271
- }
6272
- return profiles.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
6273
- } catch {
6274
- return [];
6275
- }
6697
+ var HOOKS_LOCAL_OPT_IN_ENV_KEYS, CONTRACTS_AMBIENT_ENVIRONMENT;
6698
+ var init_local_opt_in = __esm(() => {
6699
+ init_transport();
6700
+ HOOKS_LOCAL_OPT_IN_ENV_KEYS = ["HASNA_HOOKS_LOCAL", "HOOKS_LOCAL"];
6701
+ CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
6702
+ });
6703
+
6704
+ // src/lib/transport.ts
6705
+ var exports_transport = {};
6706
+ __export(exports_transport, {
6707
+ rethrowHooksAuthorityFailure: () => rethrowHooksAuthorityFailure,
6708
+ resolveHooksTransport: () => resolveHooksTransport,
6709
+ resolveHooksServePublishKey: () => resolveHooksServePublishKey,
6710
+ hooksRegistryOrigin: () => hooksRegistryOrigin,
6711
+ announceHooksLocalMode: () => announceHooksLocalMode,
6712
+ __resetHooksLocalNotice: () => __resetHooksLocalNotice
6713
+ });
6714
+ function __resetHooksLocalNotice() {
6715
+ localNoticePrinted = false;
6276
6716
  }
6277
- function touchProfile(id) {
6278
- const profile = getProfile(id);
6279
- if (!profile)
6717
+ function announceLocal(notice, reason) {
6718
+ if (localNoticePrinted)
6280
6719
  return;
6281
- profile.last_seen_at = new Date().toISOString();
6282
- writeFileSync5(profilePath(id), JSON.stringify(profile, null, 2) + `
6720
+ localNoticePrinted = true;
6721
+ const line = `hooks: LOCAL mode \u2014 ${reason}; using the bundled registry and the local store at ` + `${getHooksDataDir()}, not a remote registry. Set HASNA_HOOKS_API_KEY (or the Keychain item ` + `hasna.credentials.hooks.api-key / ~/.hasna/hooks/config/credentials) to go remote.`;
6722
+ if (notice)
6723
+ notice(line);
6724
+ else if (typeof process !== "undefined")
6725
+ process.stderr.write(`${line}
6283
6726
  `);
6284
6727
  }
6285
- function exportProfiles() {
6286
- return listProfiles();
6728
+ function announceHooksLocalMode(reason = "HASNA_HOOKS_LOCAL is set and nothing configures a registry authority", notice) {
6729
+ announceLocal(notice, reason);
6287
6730
  }
6288
- function importProfiles(profiles) {
6289
- ensureProfilesDir();
6290
- let imported = 0;
6291
- let skipped = 0;
6292
- for (const profile of profiles) {
6293
- if (!profile.agent_id || !profile.agent_type) {
6294
- skipped++;
6295
- continue;
6296
- }
6297
- const path = profilePath(profile.agent_id);
6298
- if (existsSync12(path)) {
6299
- skipped++;
6300
- continue;
6731
+ function hooksRegistryOrigin(v1BaseUrl) {
6732
+ return v1BaseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
6733
+ }
6734
+ function rethrowHooksAuthorityFailure(error) {
6735
+ const message = error instanceof Error ? error.message : String(error);
6736
+ const name = error instanceof Error ? error.name : "";
6737
+ if (name === "CredentialResolutionError" || name === "CredentialFileUnsafeError") {
6738
+ throw new Error(`REMOTE_API_CREDENTIAL_INVALID: ${message} There is no local fallback: ` + "local mode is opt-in only (HASNA_HOOKS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error });
6739
+ }
6740
+ if (/no API key could be resolved/.test(message)) {
6741
+ if (/is not set and no API key could be resolved/.test(message)) {
6742
+ throw new Error("REMOTE_API_CONFIG_MISSING: no hooks registry credential resolved from the Keychain item " + `hasna.credentials.hooks.api-key, ~/.hasna/hooks/config/credentials, or HASNA_HOOKS_API_KEY. ${message} ` + "There is no local fallback: local mode is opt-in only (HASNA_HOOKS_LOCAL=1, alias HOOKS_LOCAL=1) " + "and is disabled by default \u2014 failing closed instead of serving the local store", { cause: error });
6301
6743
  }
6302
- writeFileSync5(path, JSON.stringify(profile, null, 2) + `
6303
- `);
6304
- imported++;
6744
+ throw new Error("REMOTE_API_KEY_MISSING: the remote hooks registry requires HASNA_HOOKS_API_KEY, the Keychain item " + `hasna.credentials.hooks.api-key, or ~/.hasna/hooks/config/credentials. ${message} ` + "There is no local fallback: local mode is opt-in only (HASNA_HOOKS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error });
6305
6745
  }
6306
- return { imported, skipped };
6746
+ throw new Error(`REMOTE_API_URL_INVALID: ${message} local mode fallback is disabled`, { cause: error });
6307
6747
  }
6308
- var PROFILES_DIR;
6309
- var init_profiles = __esm(() => {
6310
- init_app_home();
6311
- PROFILES_DIR = resolveProfilesDir();
6748
+ function resolveHooksTransport(env = process.env, options = {}) {
6749
+ if (selectsHooksLocalStore(env)) {
6750
+ announceLocal(options.notice, "HASNA_HOOKS_LOCAL is set and nothing configures a registry authority");
6751
+ return { mode: "local", source: "local-opt-in", authority: null };
6752
+ }
6753
+ const { env: resolverEnv, credentials } = hooksResolverInputs(env, options.credentials);
6754
+ const tier1 = options.credentials?.apiKey;
6755
+ const credentialOptions = tier1 !== undefined ? { ...credentials, apiKey: tier1 } : credentials;
6756
+ let resolution;
6757
+ let credential;
6758
+ try {
6759
+ credential = resolveCredential("hooks", resolverEnv, credentialOptions);
6760
+ resolution = resolveClientTransport("hooks", resolverEnv, {
6761
+ credentials: credential ? { ...credentialOptions, apiKey: credential.apiKey } : credentialOptions
6762
+ });
6763
+ } catch (error) {
6764
+ rethrowHooksAuthorityFailure(error);
6765
+ }
6766
+ if (!credential) {
6767
+ throw new Error("REMOTE_API_CONFIG_MISSING: no hooks registry credential resolved; " + "local mode fallback is disabled");
6768
+ }
6769
+ return {
6770
+ mode: "remote",
6771
+ source: `${credential.source}+${resolution.apiUrlSource ?? "default"}`,
6772
+ authority: {
6773
+ origin: hooksRegistryOrigin(resolution.baseUrl),
6774
+ apiKey: credential.apiKey,
6775
+ apiUrlSource: resolution.apiUrlSource,
6776
+ apiKeySource: credential.source,
6777
+ apiKeyTier: credential.tier,
6778
+ v1BaseUrl: resolution.baseUrl,
6779
+ warning: resolution.warning
6780
+ }
6781
+ };
6782
+ }
6783
+ function resolveHooksServePublishKey(env = process.env, options = {}) {
6784
+ const { env: resolverEnv, credentials } = hooksResolverInputs(env, options.credentials);
6785
+ const tier1 = options.credentials?.apiKey;
6786
+ const credentialOptions = tier1 !== undefined ? { ...credentials, apiKey: tier1 } : credentials;
6787
+ return resolveCredential("hooks", resolverEnv, credentialOptions)?.apiKey;
6788
+ }
6789
+ var localNoticePrinted = false;
6790
+ var init_transport2 = __esm(() => {
6791
+ init_transport();
6792
+ init_config();
6793
+ init_local_opt_in();
6312
6794
  });
6313
6795
 
6314
6796
  // src/lib/db-writer.ts
@@ -6408,8 +6890,8 @@ var init_db_writer = __esm(() => {
6408
6890
  });
6409
6891
 
6410
6892
  // src/lib/hook-env.ts
6411
- import { dirname as dirname4, isAbsolute as isAbsolute2 } from "path";
6412
- import { existsSync as existsSync13, statSync as statSync2 } from "fs";
6893
+ import { dirname as dirname4, isAbsolute as isAbsolute3 } from "path";
6894
+ import { existsSync as existsSync12, statSync } from "fs";
6413
6895
  function isDeniedEnvName(name) {
6414
6896
  const upper = name.toUpperCase();
6415
6897
  if (DENY_PREFIXES.some((prefix) => upper.startsWith(prefix)))
@@ -6427,7 +6909,7 @@ function isBashFunctionEnvName(name) {
6427
6909
  return name.startsWith("BASH_FUNC_");
6428
6910
  }
6429
6911
  function isUnsafePathEntry(entry, home) {
6430
- if (entry === "" || !isAbsolute2(entry))
6912
+ if (entry === "" || !isAbsolute3(entry))
6431
6913
  return true;
6432
6914
  if (home && (entry === home || entry.startsWith(home + "/")))
6433
6915
  return true;
@@ -6436,14 +6918,14 @@ function isUnsafePathEntry(entry, home) {
6436
6918
  if (entry === "/var/tmp" || entry.startsWith("/var/tmp/"))
6437
6919
  return true;
6438
6920
  try {
6439
- if ((statSync2(entry).mode & 2) !== 0)
6921
+ if ((statSync(entry).mode & 2) !== 0)
6440
6922
  return true;
6441
6923
  } catch {}
6442
6924
  return false;
6443
6925
  }
6444
6926
  function sanitizeHookPath(pathValue, home) {
6445
6927
  const baseline = PATH_BASELINE.slice();
6446
- if (process.platform === "darwin" && existsSync13("/opt/homebrew/bin"))
6928
+ if (process.platform === "darwin" && existsSync12("/opt/homebrew/bin"))
6447
6929
  baseline.push("/opt/homebrew/bin");
6448
6930
  const bunDir = dirname4(process.execPath);
6449
6931
  const seen = new Set;
@@ -6568,8 +7050,8 @@ __export(exports_run, {
6568
7050
  HookTimeoutError: () => HookTimeoutError
6569
7051
  });
6570
7052
  import { randomBytes as randomBytes2 } from "crypto";
6571
- import { rmSync as rmSync3, writeFileSync as writeFileSync6 } from "fs";
6572
- import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute3, join as join15 } from "path";
7053
+ import { rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
7054
+ import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute4, join as join13 } from "path";
6573
7055
  function extensionOf(scriptPath) {
6574
7056
  const base = basename2(scriptPath);
6575
7057
  const dot = base.lastIndexOf(".");
@@ -6640,7 +7122,7 @@ async function readPipeWithDeadline(stream, deadlineMs) {
6640
7122
  async function executeVerifiedScript(options) {
6641
7123
  const scriptDir = dirname5(options.scriptPath);
6642
7124
  const interpreter = interpreterFor(options.name, options.scriptPath, options.content);
6643
- const tempPath = join15(scriptDir, `.hooks-verified-${randomBytes2(12).toString("hex")}.${interpreter.tempExt}`);
7125
+ const tempPath = join13(scriptDir, `.hooks-verified-${randomBytes2(12).toString("hex")}.${interpreter.tempExt}`);
6644
7126
  let proc = null;
6645
7127
  let timedOut = false;
6646
7128
  const timeoutTimer = options.timeout ? setTimeout(() => {
@@ -6648,8 +7130,8 @@ async function executeVerifiedScript(options) {
6648
7130
  killGroup(proc, { leaderAlive: proc !== null && proc.exitCode === null });
6649
7131
  }, options.timeout) : null;
6650
7132
  try {
6651
- writeFileSync6(tempPath, options.content, { flag: "wx", mode: 384 });
6652
- proc = Bun.spawn([...interpreter.command, isAbsolute3(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
7133
+ writeFileSync5(tempPath, options.content, { flag: "wx", mode: 384 });
7134
+ proc = Bun.spawn([...interpreter.command, isAbsolute4(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
6653
7135
  stdin: new Response(options.stdin),
6654
7136
  stdout: "pipe",
6655
7137
  stderr: "pipe",
@@ -6670,7 +7152,7 @@ async function executeVerifiedScript(options) {
6670
7152
  if (timeoutTimer)
6671
7153
  clearTimeout(timeoutTimer);
6672
7154
  try {
6673
- rmSync3(tempPath, { force: true });
7155
+ rmSync2(tempPath, { force: true });
6674
7156
  } catch {}
6675
7157
  }
6676
7158
  }
@@ -6731,15 +7213,15 @@ __export(exports_custom_install, {
6731
7213
  installCustomSource: () => installCustomSource,
6732
7214
  ensureCustomDir: () => ensureCustomDir
6733
7215
  });
6734
- import { existsSync as existsSync14, mkdirSync as mkdirSync7, mkdtempSync, readFileSync as readFileSync8, rmSync as rmSync4 } from "fs";
6735
- import { join as join16 } from "path";
7216
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
7217
+ import { join as join14 } from "path";
6736
7218
  import { tmpdir } from "os";
6737
7219
  function manifestAt(dir) {
6738
- const path = join16(dir, "manifest.json");
6739
- if (!existsSync14(path))
7220
+ const path = join14(dir, "manifest.json");
7221
+ if (!existsSync13(path))
6740
7222
  return;
6741
7223
  try {
6742
- return parseManifest(readFileSync8(path, "utf-8"));
7224
+ return parseManifest(readFileSync7(path, "utf-8"));
6743
7225
  } catch {
6744
7226
  return;
6745
7227
  }
@@ -6758,8 +7240,8 @@ function writeFromManifest(manifest, srcDir, source, kind) {
6758
7240
  return { name, version: manifest.version, kind, source, dir, scriptPath };
6759
7241
  }
6760
7242
  async function cloneToTemp(gitUrl) {
6761
- const tmp = mkdtempSync(join16(tmpdir(), "hooks-install-"));
6762
- const proc = Bun.spawn(["git", "clone", "--depth", "1", "--quiet", gitUrl, join16(tmp, "repo")], {
7243
+ const tmp = mkdtempSync(join14(tmpdir(), "hooks-install-"));
7244
+ const proc = Bun.spawn(["git", "clone", "--depth", "1", "--quiet", gitUrl, join14(tmp, "repo")], {
6763
7245
  stdout: "pipe",
6764
7246
  stderr: "pipe"
6765
7247
  });
@@ -6769,10 +7251,10 @@ async function cloneToTemp(gitUrl) {
6769
7251
  proc.exited
6770
7252
  ]);
6771
7253
  if (exitCode !== 0) {
6772
- rmSync4(tmp, { recursive: true, force: true });
7254
+ rmSync3(tmp, { recursive: true, force: true });
6773
7255
  throw new Error(`git clone failed (${exitCode}): ${stderr.trim()}`);
6774
7256
  }
6775
- return join16(tmp, "repo");
7257
+ return join14(tmp, "repo");
6776
7258
  }
6777
7259
  function isGitUrl(value) {
6778
7260
  return value.startsWith("git@") || value.startsWith("ssh://") || value.startsWith("file://") || value.endsWith(".git");
@@ -6782,7 +7264,7 @@ function isCustomSource(value) {
6782
7264
  return true;
6783
7265
  if (value.startsWith("http://") || value.startsWith("https://"))
6784
7266
  return true;
6785
- return existsSync14(value);
7267
+ return existsSync13(value);
6786
7268
  }
6787
7269
  async function installCustomSource(source) {
6788
7270
  if (isGitUrl(source)) {
@@ -6790,17 +7272,17 @@ async function installCustomSource(source) {
6790
7272
  try {
6791
7273
  return copyManifestDir(repoDir, source, "git");
6792
7274
  } finally {
6793
- rmSync4(repoDir, { recursive: true, force: true });
7275
+ rmSync3(repoDir, { recursive: true, force: true });
6794
7276
  }
6795
7277
  }
6796
7278
  if (source.startsWith("http://") || source.startsWith("https://")) {
6797
7279
  return installManifestUrl(source);
6798
7280
  }
6799
- if (!existsSync14(source)) {
7281
+ if (!existsSync13(source)) {
6800
7282
  throw new Error(`No such local path: ${source}`);
6801
7283
  }
6802
7284
  if (source.endsWith("manifest.json")) {
6803
- const manifest = parseManifest(readFileSync8(source, "utf-8"));
7285
+ const manifest = parseManifest(readFileSync7(source, "utf-8"));
6804
7286
  return writeFromManifest(manifest, source.substring(0, source.lastIndexOf("/")), source, "local");
6805
7287
  }
6806
7288
  return copyManifestDir(source, source, "local");
@@ -6831,7 +7313,7 @@ async function installManifestUrl(url) {
6831
7313
  }
6832
7314
  function ensureCustomDir() {
6833
7315
  const dir = getCustomHooksDir();
6834
- mkdirSync7(dir, { recursive: true });
7316
+ mkdirSync6(dir, { recursive: true });
6835
7317
  return dir;
6836
7318
  }
6837
7319
  var init_custom_install = __esm(() => {
@@ -6849,12 +7331,12 @@ __export(exports_sync, {
6849
7331
  commitSyncArtifacts: () => commitSyncArtifacts
6850
7332
  });
6851
7333
  import { createHash as createHash2 } from "crypto";
6852
- import { readFileSync as readFileSync9 } from "fs";
7334
+ import { readFileSync as readFileSync8 } from "fs";
6853
7335
  function sha256OfText(text) {
6854
7336
  return createHash2("sha256").update(text).digest("hex");
6855
7337
  }
6856
7338
  function sha256OfPath(path) {
6857
- return createHash2("sha256").update(readFileSync9(path)).digest("hex");
7339
+ return createHash2("sha256").update(readFileSync8(path)).digest("hex");
6858
7340
  }
6859
7341
  function collectBundledCatalog() {
6860
7342
  const out = [];
@@ -6870,9 +7352,8 @@ function collectBundledCatalog() {
6870
7352
  }
6871
7353
  return out;
6872
7354
  }
6873
- async function fetchJson(base, path, opts) {
7355
+ async function fetchJson(base, path, apiKey, opts) {
6874
7356
  const headers = { accept: "application/json" };
6875
- const apiKey = resolveApiKey();
6876
7357
  if (apiKey)
6877
7358
  headers["x-api-key"] = apiKey;
6878
7359
  const res = await fetch(`${base}${path}`, {
@@ -6881,7 +7362,7 @@ async function fetchJson(base, path, opts) {
6881
7362
  signal: AbortSignal.timeout(15000)
6882
7363
  });
6883
7364
  if (res.status === 401) {
6884
- throw new Error("registry requires API key \u2014 set HASNA_HOOKS_API_KEY or HOOKS_API_KEY (vault-delivered, never stored)");
7365
+ throw new Error("registry requires API key \u2014 set HASNA_HOOKS_API_KEY, the Keychain item hasna.credentials.hooks.api-key, " + "or ~/.hasna/hooks/config/credentials (resolved per call, never stored)");
6885
7366
  }
6886
7367
  if (!res.ok) {
6887
7368
  throw new Error(`GET ${path} failed with status ${res.status}`);
@@ -6892,9 +7373,9 @@ async function fetchJson(base, path, opts) {
6892
7373
  }
6893
7374
  return body;
6894
7375
  }
6895
- async function fetchRemoteState(apiUrl) {
6896
- const catalog = await fetchJson(apiUrl, "/api/v1/catalog");
6897
- const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
7376
+ async function fetchRemoteState(apiUrl, apiKey) {
7377
+ const catalog = await fetchJson(apiUrl, "/api/v1/catalog", apiKey);
7378
+ const remoteLock = await fetchJson(apiUrl, "/api/v1/lock", apiKey);
6898
7379
  if (!catalog.hooks || !Array.isArray(catalog.hooks)) {
6899
7380
  throw new Error("remote catalog is malformed: missing hooks array");
6900
7381
  }
@@ -6937,20 +7418,25 @@ function computeDiff(catalog, remoteLock) {
6937
7418
  return diff;
6938
7419
  }
6939
7420
  async function planSync(options = {}) {
6940
- const apiUrl = resolveApiUrl();
6941
- if (apiUrl) {
6942
- const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6943
- return { apiUrl, dryRun: options.dryRun ?? false, diff: computeDiff(catalog, remoteLock) };
7421
+ const transport = resolveHooksTransport(options.env ?? process.env, {
7422
+ credentials: options.credentials
7423
+ });
7424
+ if (transport.mode === "remote" && transport.authority) {
7425
+ const { origin, apiKey } = transport.authority;
7426
+ const { catalog, remoteLock } = await fetchRemoteState(origin, apiKey);
7427
+ return { apiUrl: origin, dryRun: options.dryRun ?? false, diff: computeDiff(catalog, remoteLock) };
6944
7428
  }
6945
7429
  const diff = computeDiff(collectBundledCatalog(), null);
6946
7430
  return { apiUrl: null, dryRun: options.dryRun ?? false, diff };
6947
7431
  }
6948
7432
  async function syncHooks(options = {}) {
6949
- const apiUrl = resolveApiUrl();
6950
- const plan = await planSync();
7433
+ const transport = resolveHooksTransport(options.env ?? process.env, {
7434
+ credentials: options.credentials
7435
+ });
7436
+ const plan = await planSync(options);
6951
7437
  if (options.dryRun)
6952
7438
  return { ...plan, dryRun: true };
6953
- if (!apiUrl) {
7439
+ if (transport.mode !== "remote" || !transport.authority) {
6954
7440
  const db = getDb();
6955
7441
  for (const entry of collectBundledCatalog()) {
6956
7442
  setPinnedHook(entry.name, { version: entry.version, sha256: entry.sha256, source: "bundled" });
@@ -6965,19 +7451,20 @@ async function syncHooks(options = {}) {
6965
7451
  }
6966
7452
  return plan;
6967
7453
  }
6968
- const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
7454
+ const { origin, apiKey } = transport.authority;
7455
+ const { catalog, remoteLock } = await fetchRemoteState(origin, apiKey);
6969
7456
  const diff = computeDiff(catalog, remoteLock);
6970
- const staged = await stageSyncArtifacts(apiUrl, [...diff.added, ...diff.updated], remoteLock);
6971
- await commitSyncArtifacts(staged, apiUrl, remoteLock);
6972
- return { apiUrl, dryRun: false, diff };
7457
+ const staged = await stageSyncArtifacts(origin, apiKey, [...diff.added, ...diff.updated], remoteLock);
7458
+ await commitSyncArtifacts(staged, origin, remoteLock);
7459
+ return { apiUrl: origin, dryRun: false, diff };
6973
7460
  }
6974
- async function stageSyncArtifacts(apiUrl, names, remoteLock) {
7461
+ async function stageSyncArtifacts(apiUrl, apiKey, names, remoteLock) {
6975
7462
  const staged = [];
6976
7463
  for (const name of names) {
6977
7464
  const entry = remoteLock.hooks[name];
6978
7465
  if (!entry)
6979
7466
  continue;
6980
- const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${encodeURIComponent(name)}/${encodeURIComponent(entry.version)}`);
7467
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${encodeURIComponent(name)}/${encodeURIComponent(entry.version)}`, apiKey);
6981
7468
  if (!artifact.manifest || typeof artifact.script !== "string") {
6982
7469
  throw new Error(`artifact for '${name}@${entry.version}' is malformed`);
6983
7470
  }
@@ -7044,8 +7531,8 @@ async function commitSyncArtifacts(staged, apiUrl, remoteLock) {
7044
7531
  throw err;
7045
7532
  }
7046
7533
  }
7047
- async function fetchPinnedHook(name, version, apiUrl) {
7048
- const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
7534
+ async function fetchPinnedHook(name, version, apiUrl, apiKey) {
7535
+ const remoteLock = await fetchJson(apiUrl, "/api/v1/lock", apiKey);
7049
7536
  const entry = remoteLock.hooks?.[name];
7050
7537
  if (!entry) {
7051
7538
  throw new Error(`Hook '${name}' is not in the remote registry lock`);
@@ -7054,7 +7541,7 @@ async function fetchPinnedHook(name, version, apiUrl) {
7054
7541
  if (!isLatest && !(entry.versions ?? []).includes(version)) {
7055
7542
  throw new Error(`Hook '${name}' version ${version} is not in the remote registry (available: ${[entry.version, ...entry.versions ?? []].join(", ")})`);
7056
7543
  }
7057
- const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { withHeader: "_headerSha" });
7544
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, apiKey, { withHeader: "_headerSha" });
7058
7545
  if (!artifact.manifest || typeof artifact.script !== "string") {
7059
7546
  throw new Error(`artifact for '${name}@${version}' is malformed`);
7060
7547
  }
@@ -7107,7 +7594,7 @@ async function fetchPinnedHook(name, version, apiUrl) {
7107
7594
  }
7108
7595
  var init_sync = __esm(() => {
7109
7596
  init_registry();
7110
- init_config();
7597
+ init_transport2();
7111
7598
  init_db();
7112
7599
  init_store();
7113
7600
  init_manifest();
@@ -7747,7 +8234,7 @@ var init_openapi = __esm(() => {
7747
8234
  apiKey: {
7748
8235
  type: "http",
7749
8236
  scheme: "bearer",
7750
- description: "The registry API key (HASNA_HOOKS_API_KEY / HOOKS_API_KEY)."
8237
+ description: "The registry API key \u2014 resolved by the client through the @hasna/contracts chain (HASNA_HOOKS_API_KEY, the Keychain item hasna.credentials.hooks.api-key, or ~/.hasna/hooks/config/credentials); the server compares the inbound bearer/x-api-key value."
7751
8238
  }
7752
8239
  }
7753
8240
  }
@@ -7764,13 +8251,13 @@ __export(exports_serve, {
7764
8251
  SERVE_HOST: () => SERVE_HOST,
7765
8252
  DEFAULT_SERVE_PORT: () => DEFAULT_SERVE_PORT
7766
8253
  });
7767
- import { readFileSync as readFileSync10 } from "fs";
7768
- import { dirname as dirname6, join as join17 } from "path";
8254
+ import { readFileSync as readFileSync9 } from "fs";
8255
+ import { dirname as dirname6, join as join15 } from "path";
7769
8256
  import { fileURLToPath as fileURLToPath3 } from "url";
7770
8257
  function packageVersion() {
7771
8258
  try {
7772
- const pkgPath = join17(dirname6(fileURLToPath3(import.meta.url)), "..", "package.json");
7773
- return JSON.parse(readFileSync10(pkgPath, "utf-8")).version || "0.0.0";
8259
+ const pkgPath = join15(dirname6(fileURLToPath3(import.meta.url)), "..", "package.json");
8260
+ return JSON.parse(readFileSync9(pkgPath, "utf-8")).version || "0.0.0";
7774
8261
  } catch {
7775
8262
  return "0.0.0";
7776
8263
  }
@@ -7927,12 +8414,11 @@ function resolveServeOptions(options) {
7927
8414
  }
7928
8415
  function startServeServer(options) {
7929
8416
  const { port, host } = resolveServeOptions(options);
7930
- const apiKey = resolveApiKey();
7931
8417
  const server = Bun.serve({
7932
8418
  hostname: host,
7933
8419
  port,
7934
8420
  async fetch(req) {
7935
- return handleServeRequest(req, apiKey);
8421
+ return handleServeRequest(req, resolveHooksServePublishKey());
7936
8422
  }
7937
8423
  });
7938
8424
  console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);
@@ -7944,7 +8430,7 @@ var init_serve = __esm(() => {
7944
8430
  init_manifest();
7945
8431
  init_store();
7946
8432
  init_resolve();
7947
- init_config();
8433
+ init_transport2();
7948
8434
  init_openapi();
7949
8435
  init_semver();
7950
8436
  if (false) {}
@@ -8046,17 +8532,17 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
8046
8532
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8047
8533
  import { z as z2 } from "zod";
8048
8534
  import { createServer } from "http";
8049
- import { existsSync as existsSync15, readFileSync as readFileSync11 } from "fs";
8050
- import { join as join18, dirname as dirname7 } from "path";
8535
+ import { existsSync as existsSync14, readFileSync as readFileSync10 } from "fs";
8536
+ import { join as join16, dirname as dirname7 } from "path";
8051
8537
  import { fileURLToPath as fileURLToPath4 } from "url";
8052
8538
  async function runVerifiedHook(name, stdin, timeoutMs) {
8053
8539
  const { resolveHook: resolveHook2, resolveScriptPath: resolveScriptPath2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
8054
8540
  const resolved = resolveHook2(name);
8055
8541
  const hookScript = resolveScriptPath2(name);
8056
- if (!resolved || !hookScript || !existsSync15(hookScript)) {
8542
+ if (!resolved || !hookScript || !existsSync14(hookScript)) {
8057
8543
  throw new Error(`Hook '${name}' not found`);
8058
8544
  }
8059
- const content = readFileSync11(hookScript);
8545
+ const content = readFileSync10(hookScript);
8060
8546
  const sha = sha256Of(content);
8061
8547
  const check = checkScriptHash(name, sha);
8062
8548
  if (!check.ok) {
@@ -8261,7 +8747,7 @@ function createHooksServer() {
8261
8747
  const settingsPath = getSettingsPath(scope);
8262
8748
  const issues = [];
8263
8749
  const healthy = [];
8264
- const settingsExist = existsSync15(settingsPath);
8750
+ const settingsExist = existsSync14(settingsPath);
8265
8751
  if (!settingsExist) {
8266
8752
  issues.push({ hook: "(settings)", issue: `${settingsPath} not found`, severity: "warning" });
8267
8753
  }
@@ -8271,7 +8757,7 @@ function createHooksServer() {
8271
8757
  const meta = custom ? resolveHookMeta(name) : getHook(name);
8272
8758
  let hookHealthy = true;
8273
8759
  if (custom) {
8274
- if (!existsSync15(custom.scriptPath)) {
8760
+ if (!existsSync14(custom.scriptPath)) {
8275
8761
  issues.push({ hook: name, issue: `Custom hook script missing: ${custom.scriptPath}`, severity: "error" });
8276
8762
  hookHealthy = false;
8277
8763
  }
@@ -8281,14 +8767,14 @@ function createHooksServer() {
8281
8767
  continue;
8282
8768
  }
8283
8769
  const hookDir = getHookPath(name);
8284
- if (!existsSync15(join18(hookDir, "src", "hook.ts"))) {
8770
+ if (!existsSync14(join16(hookDir, "src", "hook.ts"))) {
8285
8771
  issues.push({ hook: name, issue: "Missing src/hook.ts in package", severity: "error" });
8286
8772
  hookHealthy = false;
8287
8773
  }
8288
8774
  }
8289
8775
  if (meta && settingsExist) {
8290
8776
  try {
8291
- const settings = JSON.parse(readFileSync11(settingsPath, "utf-8"));
8777
+ const settings = JSON.parse(readFileSync10(settingsPath, "utf-8"));
8292
8778
  if (!hookRegisteredInSettings(settings, name, meta.event, meta.matcher)) {
8293
8779
  const eventName = meta.event.split(":")[0];
8294
8780
  issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
@@ -8318,10 +8804,10 @@ function createHooksServer() {
8318
8804
  return { content: [{ type: "text", text: JSON.stringify({ error: `Hook '${name}' not found` }) }] };
8319
8805
  }
8320
8806
  const hookPath = getHookPath(name);
8321
- const readmePath = join18(hookPath, "README.md");
8807
+ const readmePath = join16(hookPath, "README.md");
8322
8808
  let readme = "";
8323
- if (existsSync15(readmePath)) {
8324
- readme = readFileSync11(readmePath, "utf-8");
8809
+ if (existsSync14(readmePath)) {
8810
+ readme = readFileSync10(readmePath, "utf-8");
8325
8811
  }
8326
8812
  if (verbose) {
8327
8813
  return { content: [{ type: "text", text: JSON.stringify({ ...meta, readme }) }] };
@@ -8504,7 +8990,7 @@ function createHooksServer() {
8504
8990
  const ctx = {
8505
8991
  scope,
8506
8992
  settings_path: settingsPath,
8507
- settings_exists: existsSync15(settingsPath),
8993
+ settings_exists: existsSync14(settingsPath),
8508
8994
  registered_hooks: hooks,
8509
8995
  hook_count: hooks.length,
8510
8996
  healthy,
@@ -8650,8 +9136,8 @@ function createHooksServer() {
8650
9136
  const settingsPath = getSettingsPath(scope);
8651
9137
  let settings = {};
8652
9138
  try {
8653
- if (existsSync15(settingsPath))
8654
- settings = JSON.parse(readFileSync11(settingsPath, "utf-8"));
9139
+ if (existsSync14(settingsPath))
9140
+ settings = JSON.parse(readFileSync10(settingsPath, "utf-8"));
8655
9141
  } catch {}
8656
9142
  if (!settings.hooks)
8657
9143
  settings.hooks = {};
@@ -8659,10 +9145,10 @@ function createHooksServer() {
8659
9145
  if (!disabled.includes(name))
8660
9146
  disabled.push(name);
8661
9147
  settings.hooks.__disabled = disabled;
8662
- const { writeFileSync: writeFileSync7, mkdirSync: mkdirSync8 } = await import("fs");
9148
+ const { writeFileSync: writeFileSync6, mkdirSync: mkdirSync7 } = await import("fs");
8663
9149
  const { dirname: dirname8 } = await import("path");
8664
- mkdirSync8(dirname8(settingsPath), { recursive: true });
8665
- writeFileSync7(settingsPath, JSON.stringify(settings, null, 2) + `
9150
+ mkdirSync7(dirname8(settingsPath), { recursive: true });
9151
+ writeFileSync6(settingsPath, JSON.stringify(settings, null, 2) + `
8666
9152
  `);
8667
9153
  return { content: [{ type: "text", text: JSON.stringify({ hook: name, disabled: true, scope }) }] };
8668
9154
  });
@@ -8673,15 +9159,15 @@ function createHooksServer() {
8673
9159
  const settingsPath = getSettingsPath(scope);
8674
9160
  let settings = {};
8675
9161
  try {
8676
- if (existsSync15(settingsPath))
8677
- settings = JSON.parse(readFileSync11(settingsPath, "utf-8"));
9162
+ if (existsSync14(settingsPath))
9163
+ settings = JSON.parse(readFileSync10(settingsPath, "utf-8"));
8678
9164
  } catch {}
8679
9165
  if (settings.hooks?.__disabled) {
8680
9166
  settings.hooks.__disabled = settings.hooks.__disabled.filter((n) => n !== name);
8681
9167
  if (settings.hooks.__disabled.length === 0)
8682
9168
  delete settings.hooks.__disabled;
8683
- const { writeFileSync: writeFileSync7 } = await import("fs");
8684
- writeFileSync7(settingsPath, JSON.stringify(settings, null, 2) + `
9169
+ const { writeFileSync: writeFileSync6 } = await import("fs");
9170
+ writeFileSync6(settingsPath, JSON.stringify(settings, null, 2) + `
8685
9171
  `);
8686
9172
  }
8687
9173
  return { content: [{ type: "text", text: JSON.stringify({ hook: name, disabled: false, scope }) }] };
@@ -9038,9 +9524,9 @@ var init_server = __esm(() => {
9038
9524
  pkg = { name: "@hasna/hooks", version: "0.0.0" };
9039
9525
  try {
9040
9526
  for (const rel of ["../../package.json", "../package.json", "../../../package.json"]) {
9041
- const p = join18(__dirname3, rel);
9042
- if (existsSync15(p)) {
9043
- pkg = JSON.parse(readFileSync11(p, "utf-8"));
9527
+ const p = join16(__dirname3, rel);
9528
+ if (existsSync14(p)) {
9529
+ pkg = JSON.parse(readFileSync10(p, "utf-8"));
9044
9530
  break;
9045
9531
  }
9046
9532
  }
@@ -9112,12 +9598,12 @@ var init_http = () => {};
9112
9598
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
9113
9599
  import { Buffer as Buffer2 } from "buffer";
9114
9600
  import { existsSync as existsSync2 } from "fs";
9115
- import { join as join3 } from "path";
9601
+ import { join as join2 } from "path";
9116
9602
  import { existsSync } from "fs";
9117
- import { homedir as homedir2 } from "os";
9118
- import { join as join2, resolve } from "path";
9119
9603
  import { homedir } from "os";
9120
- import { join } from "path";
9604
+ import { join, resolve } from "path";
9605
+ import { homedir as pathsResolverHomedir } from "os";
9606
+ import { join as pathsResolverJoin } from "path";
9121
9607
  import { createHmac, timingSafeEqual } from "crypto";
9122
9608
  import { lookup as dnsLookup } from "dns/promises";
9123
9609
  import { isIP } from "net";
@@ -9221,75 +9707,72 @@ function channelMatchesEvent(channel, event) {
9221
9707
  return true;
9222
9708
  return channel.filters.some((filter) => eventMatchesFilter(event, filter));
9223
9709
  }
9224
- var KIND_ENV = {
9710
+ var PATHS_RESOLVER_KIND_ENV = {
9225
9711
  config: "HASNA_CONFIG_HOME",
9226
9712
  data: "HASNA_DATA_HOME",
9227
9713
  state: "HASNA_STATE_HOME",
9228
9714
  cache: "HASNA_CACHE_HOME"
9229
9715
  };
9230
- var APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
9231
- function assertApp(app) {
9716
+ var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
9717
+ function pathsResolverAssertApp(app) {
9232
9718
  if (typeof app !== "string" || app.length === 0) {
9233
9719
  throw new TypeError("paths: app must be a non-empty string");
9234
9720
  }
9235
- if (!APP_SLUG_RE.test(app)) {
9721
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
9236
9722
  throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
9237
9723
  }
9238
9724
  }
9239
- function envOf(options) {
9240
- return options.env ?? process.env;
9241
- }
9242
- function envValue(options, kind) {
9243
- const value = envOf(options)[KIND_ENV[kind]];
9244
- return typeof value === "string" && value.length > 0 ? value : undefined;
9245
- }
9246
- function isMacOS(platform) {
9247
- return platform === "darwin";
9725
+ function pathsResolverAssertKind(kind) {
9726
+ if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
9727
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
9728
+ }
9248
9729
  }
9249
- function baseDir(kind, options) {
9250
- const override = envValue(options, kind);
9251
- if (override)
9730
+ function pathsResolverBaseDir(kind, options) {
9731
+ pathsResolverAssertKind(kind);
9732
+ const env = options.env ?? process.env;
9733
+ const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
9734
+ if (typeof override === "string" && override.length > 0)
9252
9735
  return override;
9253
- const home = options.home ?? homedir();
9736
+ const home = options.home ?? pathsResolverHomedir();
9254
9737
  const platform = options.platform ?? process.platform;
9255
- if (isMacOS(platform)) {
9738
+ if (platform === "darwin") {
9256
9739
  switch (kind) {
9257
9740
  case "config":
9258
9741
  case "data":
9259
- return join(home, "Library", "Application Support", "Hasna");
9742
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
9260
9743
  case "cache":
9261
- return join(home, "Library", "Caches", "Hasna");
9744
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
9262
9745
  case "state":
9263
- return join(home, "Library", "Logs", "Hasna");
9746
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
9264
9747
  }
9265
9748
  }
9266
9749
  switch (kind) {
9267
9750
  case "config":
9268
- return join(home, ".config", "hasna");
9751
+ return pathsResolverJoin(home, ".config", "hasna");
9269
9752
  case "data":
9270
- return join(home, ".local", "share", "hasna");
9753
+ return pathsResolverJoin(home, ".local", "share", "hasna");
9271
9754
  case "state":
9272
- return join(home, ".local", "state", "hasna");
9755
+ return pathsResolverJoin(home, ".local", "state", "hasna");
9273
9756
  case "cache":
9274
- return join(home, ".cache", "hasna");
9757
+ return pathsResolverJoin(home, ".cache", "hasna");
9275
9758
  }
9276
9759
  }
9277
- function resolvePath(kind, options) {
9278
- assertApp(options.app);
9279
- const appSegment = options.internal === true ? join("internal", options.app) : options.app;
9280
- return join(baseDir(kind, options), appSegment);
9760
+ function pathsResolverResolve(kind, options) {
9761
+ pathsResolverAssertApp(options.app);
9762
+ const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
9763
+ return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
9281
9764
  }
9282
9765
  function dataDir(options) {
9283
- return resolvePath("data", options);
9766
+ return pathsResolverResolve("data", options);
9284
9767
  }
9285
9768
  var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
9286
9769
  var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
9287
9770
  var EVENTS_STORE_SENTINEL_FILE = "events.json";
9288
9771
  function effectiveHome() {
9289
- return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
9772
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir();
9290
9773
  }
9291
9774
  function legacyHomeDir() {
9292
- return join2(effectiveHome(), ".hasna", "events");
9775
+ return join(effectiveHome(), ".hasna", "events");
9293
9776
  }
9294
9777
  function resolverHome() {
9295
9778
  return dataDir({ app: "events", home: effectiveHome() || undefined });
@@ -9298,7 +9781,7 @@ function adoptResolverHome(resolved, env = process.env) {
9298
9781
  const dataOverride = env.HASNA_DATA_HOME;
9299
9782
  if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
9300
9783
  return true;
9301
- return existsSync(join2(resolved, EVENTS_STORE_SENTINEL_FILE));
9784
+ return existsSync(join(resolved, EVENTS_STORE_SENTINEL_FILE));
9302
9785
  }
9303
9786
  function exactEventsHome() {
9304
9787
  const dir = process.env[HASNA_EVENTS_DIR_ENV];
@@ -9339,9 +9822,9 @@ class JsonEventsStore {
9339
9822
  constructor(dataDir2 = getEventsDataDir()) {
9340
9823
  this.dataDir = dataDir2;
9341
9824
  this.runtime = localJsonRuntime(dataDir2);
9342
- this.channelsPath = join3(dataDir2, "channels.json");
9343
- this.eventsPath = join3(dataDir2, "events.json");
9344
- this.deliveriesPath = join3(dataDir2, "deliveries.json");
9825
+ this.channelsPath = join2(dataDir2, "channels.json");
9826
+ this.eventsPath = join2(dataDir2, "events.json");
9827
+ this.deliveriesPath = join2(dataDir2, "deliveries.json");
9345
9828
  }
9346
9829
  async init() {
9347
9830
  await mkdir(this.dataDir, { recursive: true, mode: 448 });
@@ -9609,7 +10092,7 @@ async function getEventsStatus(dataDir2) {
9609
10092
  };
9610
10093
  }
9611
10094
  function statusFile(dataDir2, fileName, records) {
9612
- const path = join3(dataDir2, fileName);
10095
+ const path = join2(dataDir2, fileName);
9613
10096
  return { path, exists: existsSync2(path), records };
9614
10097
  }
9615
10098
  var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
@@ -10543,6 +11026,13 @@ function parseMatcherExpression(value, label) {
10543
11026
  negated: false
10544
11027
  };
10545
11028
  }
11029
+ function webhookTargetPolicyFromEnv() {
11030
+ const value = process.env.HASNA_EVENTS_ALLOW_PRIVATE_WEBHOOK_TARGETS;
11031
+ if (!value)
11032
+ return;
11033
+ const hosts = value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
11034
+ return hosts.length > 0 ? { allowPrivateHosts: hosts } : undefined;
11035
+ }
10546
11036
  var DEFAULT_EVENT_LIST_LIMIT = 100;
10547
11037
  function parseJsonObject(value, fallback) {
10548
11038
  if (!value)
@@ -10568,7 +11058,7 @@ function parseHeaders(values) {
10568
11058
  function createClient(options) {
10569
11059
  if (options.createClient)
10570
11060
  return options.createClient();
10571
- return new EventsClient({ store: new JsonEventsStore(options.dataDir) });
11061
+ return new EventsClient({ store: new JsonEventsStore(options.dataDir), webhookTargetPolicy: webhookTargetPolicyFromEnv() });
10572
11062
  }
10573
11063
  function print(value, json, text) {
10574
11064
  if (json)
@@ -10649,6 +11139,8 @@ function registerChannelCommands(program, options) {
10649
11139
  metadata: parseJsonObject(actionOptions.metadata, {})
10650
11140
  }, { honorFilters: actionOptions.honorFilters });
10651
11141
  print(result, json, `${result.status}: ${result.channelId}`);
11142
+ if (result.status === "failed")
11143
+ process.exitCode = 1;
10652
11144
  } catch (error) {
10653
11145
  fail(error, json);
10654
11146
  }
@@ -10759,8 +11251,8 @@ var {
10759
11251
 
10760
11252
  // src/cli/index.tsx
10761
11253
  import chalk2 from "chalk";
10762
- import { existsSync as existsSync16, readFileSync as readFileSync12, rmSync as rmSync5 } from "fs";
10763
- import { join as join19, dirname as dirname8 } from "path";
11254
+ import { existsSync as existsSync15, readFileSync as readFileSync11, rmSync as rmSync4 } from "fs";
11255
+ import { join as join17, dirname as dirname8 } from "path";
10764
11256
  import { fileURLToPath as fileURLToPath5 } from "url";
10765
11257
 
10766
11258
  // src/cli/components/App.tsx
@@ -11943,10 +12435,12 @@ init_store();
11943
12435
  init_semver();
11944
12436
  init_app_home();
11945
12437
  init_config();
12438
+ init_local_opt_in();
12439
+ init_transport2();
11946
12440
  import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
11947
12441
  var __dirname4 = dirname8(fileURLToPath5(import.meta.url));
11948
- var pkgPath = existsSync16(join19(__dirname4, "..", "package.json")) ? join19(__dirname4, "..", "package.json") : join19(__dirname4, "..", "..", "package.json");
11949
- var pkg2 = JSON.parse(readFileSync12(pkgPath, "utf-8"));
12442
+ var pkgPath = existsSync15(join17(__dirname4, "..", "package.json")) ? join17(__dirname4, "..", "package.json") : join17(__dirname4, "..", "..", "package.json");
12443
+ var pkg2 = JSON.parse(readFileSync11(pkgPath, "utf-8"));
11950
12444
  var program2 = new Command;
11951
12445
  function resolveScope(options) {
11952
12446
  if (options.project)
@@ -11980,7 +12474,7 @@ function projectLogRows2(rows) {
11980
12474
  function readToken(tokenFile) {
11981
12475
  if (tokenFile) {
11982
12476
  try {
11983
- return readFileSync12(tokenFile, "utf-8").trim();
12477
+ return readFileSync11(tokenFile, "utf-8").trim();
11984
12478
  } catch {
11985
12479
  return;
11986
12480
  }
@@ -12045,7 +12539,6 @@ program2.command("interactive", { isDefault: true }).alias("i").description("Int
12045
12539
  });
12046
12540
  program2.command("init").description("Register a new agent profile with a unique ID").option("-a, --agent <type>", "Agent type: claude, gemini, custom", "claude").option("-n, --name <name>", "Optional display name for the agent").option("-j, --json", "Output as JSON", false).option("--cloudflare", "Configure a remote registry (Cloudflare worker)", false).option("--api-url <url>", "Remote registry API URL (with --cloudflare)").option("--api-key <ref>", "Vault key NAME for the registry API key (never the value; with --cloudflare)").action(async (options) => {
12047
12541
  if (options.cloudflare) {
12048
- const { writeConfig: writeConfig2 } = await Promise.resolve().then(() => (init_config(), exports_config));
12049
12542
  if (!options.apiUrl) {
12050
12543
  const message = "--cloudflare requires --api-url <url>";
12051
12544
  if (options.json)
@@ -12055,22 +12548,39 @@ program2.command("init").description("Register a new agent profile with a unique
12055
12548
  return;
12056
12549
  }
12057
12550
  const apiKeyRef = options.apiKey ?? "hasna/hooks/live/api-key";
12058
- const config = { api_url: options.apiUrl.replace(/\/+$/, ""), api_key_ref: apiKeyRef };
12059
- const path = writeConfig2(config);
12551
+ const apiUrl = options.apiUrl.replace(/\/+$/, "");
12060
12552
  if (options.json) {
12061
- console.log(JSON.stringify({ ok: true, api_url: config.api_url, api_key_ref: config.api_key_ref, config_path: path }));
12553
+ console.log(JSON.stringify({
12554
+ ok: true,
12555
+ api_url: apiUrl,
12556
+ api_key_ref: apiKeyRef,
12557
+ env: {
12558
+ HASNA_HOOKS_API_URL: apiUrl,
12559
+ HASNA_HOOKS_API_KEY_REF: apiKeyRef
12560
+ },
12561
+ note: "config.json is retired; no file was written"
12562
+ }));
12062
12563
  return;
12063
12564
  }
12064
12565
  console.log(chalk2.green(`
12065
- \u2713 Remote registry configured
12566
+ \u2713 Remote registry configuration for ${apiUrl}
12066
12567
  `));
12067
- console.log(` ${chalk2.dim("API URL:")} ${config.api_url}`);
12068
- console.log(` ${chalk2.dim("API key ref:")} ${config.api_key_ref}`);
12069
- console.log(` ${chalk2.dim("Config:")} ${path}`);
12568
+ console.log(` ${chalk2.dim("API URL:")} ${apiUrl}`);
12569
+ console.log(` ${chalk2.dim("API key ref:")} ${apiKeyRef}`);
12570
+ console.log();
12571
+ console.log(chalk2.dim(" ~/.hasna/hooks/config.json is retired (hasna/apps#1720); the registry"));
12572
+ console.log(chalk2.dim(" URL and key now resolve through @hasna/contracts, per call, in this order:"));
12573
+ console.log(chalk2.dim(" HASNA_HOOKS_API_URL / HASNA_HOOKS_API_KEY (env)"));
12574
+ console.log(chalk2.dim(" hasna.credentials.hooks.api-url / .api-key (macOS Keychain)"));
12575
+ console.log(chalk2.dim(" ~/.hasna/hooks/config/credentials (disk, owner-only)"));
12576
+ console.log();
12577
+ console.log(chalk2.dim(" Configure the machine for this registry:"));
12578
+ console.log(` export HASNA_HOOKS_API_URL=${apiUrl}`);
12579
+ console.log(` export HASNA_HOOKS_API_KEY_REF=${apiKeyRef} # resolved through the vault, never a value`);
12070
12580
  console.log();
12071
- console.log(chalk2.dim(" Presence of an API URL selects the remote registry; absence means local."));
12072
- console.log(chalk2.dim(` Run the server with the key resolved from the vault, never the value:`));
12073
- console.log(` secrets exec ${config.api_key_ref} --as HASNA_HOOKS_API_KEY -- hooks serve`);
12581
+ console.log(chalk2.dim(" Run the server with the key resolved from the vault, never the value:"));
12582
+ console.log(` secrets exec ${apiKeyRef} --as HASNA_HOOKS_API_KEY -- hooks serve`);
12583
+ console.log(chalk2.dim(" (or store the Keychain item hasna.credentials.hooks.api-key)"));
12074
12584
  console.log();
12075
12585
  return;
12076
12586
  }
@@ -12097,7 +12607,7 @@ program2.command("init").description("Register a new agent profile with a unique
12097
12607
  if (profile.name) {
12098
12608
  console.log(` ${chalk2.dim("Name:")} ${profile.name}`);
12099
12609
  }
12100
- console.log(` ${chalk2.dim("Profile:")} ${join19(getProfilesDir(), `${profile.agent_id}.json`)}`);
12610
+ console.log(` ${chalk2.dim("Profile:")} ${join17(getProfilesDir(), `${profile.agent_id}.json`)}`);
12101
12611
  console.log();
12102
12612
  console.log(chalk2.dim(" Install hooks with this profile:"));
12103
12613
  console.log(` hooks install gitguard --profile ${profile.agent_id}`);
@@ -12113,11 +12623,11 @@ program2.command("run").argument("<hook>", "Hook to run").option("--profile <id>
12113
12623
  process.exit(1);
12114
12624
  }
12115
12625
  const hookScript = resolveScriptPath2(hook);
12116
- if (!hookScript || !existsSync16(hookScript)) {
12626
+ if (!hookScript || !existsSync15(hookScript)) {
12117
12627
  console.error(JSON.stringify({ error: `Hook script not found: ${hookScript ?? hook}` }));
12118
12628
  process.exit(1);
12119
12629
  }
12120
- const content = readFileSync12(hookScript);
12630
+ const content = readFileSync11(hookScript);
12121
12631
  const sha = sha256Of2(content);
12122
12632
  const check = checkScriptHash2(hook, sha);
12123
12633
  if (!check.ok) {
@@ -12258,7 +12768,7 @@ program2.command("install").alias("add").argument("[hooks...]", "Hooks to instal
12258
12768
  }
12259
12769
  const { isCustomSource: isCustomSource2, installCustomSource: installCustomSource2 } = await Promise.resolve().then(() => (init_custom_install(), exports_custom_install));
12260
12770
  const { readCustomManifest: readCustomManifest2, HOOK_NAME_RE: HOOK_NAME_RE2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
12261
- const { resolveApiUrl: resolveApiUrl2 } = await Promise.resolve().then(() => (init_config(), exports_config));
12771
+ const { resolveHooksTransport: resolveHooksTransport2 } = await Promise.resolve().then(() => (init_transport2(), exports_transport));
12262
12772
  const { sha256Of: sha256Of2, pinInstalledHook: pinInstalledHook2 } = await Promise.resolve().then(() => (init_store(), exports_store));
12263
12773
  const { readFileSync: readFileSyncFs } = await import("fs");
12264
12774
  function parseNameVersion(arg) {
@@ -12327,7 +12837,7 @@ Dry run \u2014 would install (${scope}, ${target}):
12327
12837
  const { customHookDir: customHookDir2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
12328
12838
  const { removeHookFromStore: removeHookFromStore2 } = await Promise.resolve().then(() => (init_store(), exports_store));
12329
12839
  try {
12330
- rmSync5(customHookDir2(custom.name), { recursive: true, force: true });
12840
+ rmSync4(customHookDir2(custom.name), { recursive: true, force: true });
12331
12841
  } catch {}
12332
12842
  removeHookFromStore2(custom.name);
12333
12843
  throw pinError;
@@ -12351,11 +12861,13 @@ Dry run \u2014 would install (${scope}, ${target}):
12351
12861
  const installedPinned = [];
12352
12862
  for (const { arg, pinned } of pinnedRequests) {
12353
12863
  try {
12354
- const apiUrl = resolveApiUrl2();
12355
- if (!apiUrl)
12356
- throw new Error(`Cannot install '${arg}': no remote registry configured (set api_url or HASNA_HOOKS_API_URL)`);
12864
+ const transport = resolveHooksTransport2();
12865
+ if (transport.mode !== "remote" || !transport.authority) {
12866
+ throw new Error(`Cannot install '${arg}': no remote registry configured (set HASNA_HOOKS_API_URL and HASNA_HOOKS_API_KEY, or opt into local mode with HASNA_HOOKS_LOCAL=1)`);
12867
+ }
12868
+ const { origin, apiKey } = transport.authority;
12357
12869
  const { fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
12358
- const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, apiUrl);
12870
+ const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, origin, apiKey);
12359
12871
  installedPinned.push(pinnedInstall.name);
12360
12872
  if (options.json) {
12361
12873
  console.log(JSON.stringify({ pinned: { request: arg, name: pinnedInstall.name, version: pinnedInstall.version, sha256: pinnedInstall.sha256, source: pinnedInstall.source } }));
@@ -12685,7 +13197,7 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
12685
13197
  const settingsPath = getSettingsPath(scope);
12686
13198
  const issues = [];
12687
13199
  const healthy = [];
12688
- const settingsExist = existsSync16(settingsPath);
13200
+ const settingsExist = existsSync15(settingsPath);
12689
13201
  if (!settingsExist) {
12690
13202
  issues.push({ hook: "(settings)", issue: `${settingsPath} not found`, severity: "warning" });
12691
13203
  }
@@ -12693,7 +13205,7 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
12693
13205
  let wiringCount = 0;
12694
13206
  if (settingsExist) {
12695
13207
  try {
12696
- const settings = JSON.parse(readFileSync12(settingsPath, "utf-8"));
13208
+ const settings = JSON.parse(readFileSync11(settingsPath, "utf-8"));
12697
13209
  wiringCount = countSettingsWiring(settings);
12698
13210
  } catch {}
12699
13211
  }
@@ -12702,7 +13214,7 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
12702
13214
  const meta = custom ? resolveHookMeta(name) : getHook(name);
12703
13215
  let hookHealthy = true;
12704
13216
  if (custom) {
12705
- if (!existsSync16(custom.scriptPath)) {
13217
+ if (!existsSync15(custom.scriptPath)) {
12706
13218
  issues.push({ hook: name, issue: `Custom hook script missing: ${custom.scriptPath}`, severity: "error" });
12707
13219
  hookHealthy = false;
12708
13220
  }
@@ -12713,15 +13225,15 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
12713
13225
  continue;
12714
13226
  }
12715
13227
  const hookDir = getHookPath(name);
12716
- const hookScript = join19(hookDir, "src", "hook.ts");
12717
- if (!existsSync16(hookScript)) {
13228
+ const hookScript = join17(hookDir, "src", "hook.ts");
13229
+ if (!existsSync15(hookScript)) {
12718
13230
  issues.push({ hook: name, issue: "Missing src/hook.ts in package", severity: "error" });
12719
13231
  hookHealthy = false;
12720
13232
  }
12721
13233
  }
12722
13234
  if (meta && settingsExist) {
12723
13235
  try {
12724
- const settings = JSON.parse(readFileSync12(settingsPath, "utf-8"));
13236
+ const settings = JSON.parse(readFileSync11(settingsPath, "utf-8"));
12725
13237
  if (!hookRegisteredInSettings(settings, name, meta.event, meta.matcher)) {
12726
13238
  const eventName = meta.event.split(":")[0];
12727
13239
  issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
@@ -12788,7 +13300,7 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
12788
13300
  const { resolveHook: resolveHook2 } = await Promise.resolve().then(() => (init_resolve(), exports_resolve));
12789
13301
  const { sha256File: sha256File2, setPinnedHook: setPinnedHook2, upsertHookRecord: upsertHookRecord2 } = await Promise.resolve().then(() => (init_store(), exports_store));
12790
13302
  const { getDb: getDb2 } = await Promise.resolve().then(() => (init_db(), exports_db));
12791
- const { resolveApiUrl: resolveApiUrl2 } = await Promise.resolve().then(() => (init_config(), exports_config));
13303
+ const { resolveHooksTransport: resolveHooksTransport2 } = await Promise.resolve().then(() => (init_transport2(), exports_transport));
12792
13304
  const { isCustomSource: isCustomSource2 } = await Promise.resolve().then(() => (init_custom_install(), exports_custom_install));
12793
13305
  const { HOOK_NAME_RE: HOOK_NAME_RE2 } = await Promise.resolve().then(() => (init_manifest(), exports_manifest));
12794
13306
  function parseNameVersion(arg) {
@@ -12810,11 +13322,13 @@ program2.command("update").argument("[hooks...]", "Hooks to update (defaults to
12810
13322
  const pinned = parseNameVersion(arg);
12811
13323
  if (pinned) {
12812
13324
  try {
12813
- const apiUrl = resolveApiUrl2();
12814
- if (!apiUrl)
12815
- throw new Error(`Cannot update '${arg}': no remote registry configured (set api_url or HASNA_HOOKS_API_URL)`);
13325
+ const transport = resolveHooksTransport2();
13326
+ if (transport.mode !== "remote" || !transport.authority) {
13327
+ throw new Error(`Cannot update '${arg}': no remote registry configured (set HASNA_HOOKS_API_URL and HASNA_HOOKS_API_KEY, or opt into local mode with HASNA_HOOKS_LOCAL=1)`);
13328
+ }
13329
+ const { origin, apiKey } = transport.authority;
12816
13330
  const { fetchPinnedHook: fetchPinnedHook2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
12817
- const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, apiUrl);
13331
+ const pinnedInstall = await fetchPinnedHook2(pinned.name, pinned.version, origin, apiKey);
12818
13332
  const registered = installHook(pinnedInstall.name, { scope, overwrite: true });
12819
13333
  if (!registered.success) {
12820
13334
  results.push({ hook: arg, success: false, error: registered.error ?? "registration failed" });
@@ -12888,10 +13402,10 @@ program2.command("docs").argument("[hook]", "Hook name (shows general docs if om
12888
13402
  return;
12889
13403
  }
12890
13404
  const hookPath = getHookPath(hook);
12891
- const readmePath = join19(hookPath, "README.md");
13405
+ const readmePath = join17(hookPath, "README.md");
12892
13406
  let readme = "";
12893
- if (existsSync16(readmePath)) {
12894
- readme = readFileSync12(readmePath, "utf-8");
13407
+ if (existsSync15(readmePath)) {
13408
+ readme = readFileSync11(readmePath, "utf-8");
12895
13409
  }
12896
13410
  if (options.json) {
12897
13411
  console.log(JSON.stringify({ ...meta, readme }));
@@ -13075,8 +13589,8 @@ program2.command("profile-export").description("Export all agent profiles as JSO
13075
13589
  const profiles = exportProfiles();
13076
13590
  const json2 = JSON.stringify(profiles, null, 2);
13077
13591
  if (options.output) {
13078
- const { writeFileSync: writeFileSync7 } = await import("fs");
13079
- writeFileSync7(options.output, json2 + `
13592
+ const { writeFileSync: writeFileSync6 } = await import("fs");
13593
+ writeFileSync6(options.output, json2 + `
13080
13594
  `);
13081
13595
  console.log(chalk2.green(`\u2713 Exported ${profiles.length} profile(s) to ${options.output}`));
13082
13596
  } else {
@@ -13088,9 +13602,9 @@ program2.command("profile-import").argument("<file>", "JSON file to import profi
13088
13602
  if (file === "-") {
13089
13603
  raw = await new Response(Bun.stdin.stream()).text();
13090
13604
  } else {
13091
- const { readFileSync: readFileSync13 } = await import("fs");
13605
+ const { readFileSync: readFileSync12 } = await import("fs");
13092
13606
  try {
13093
- raw = readFileSync13(file, "utf-8");
13607
+ raw = readFileSync12(file, "utf-8");
13094
13608
  } catch {
13095
13609
  if (options.json) {
13096
13610
  console.log(JSON.stringify({ error: `Cannot read file: ${file}` }));
@@ -13370,14 +13884,14 @@ program2.command("trust").argument("<hook>", "Hook to trust").option("-j, --json
13370
13884
  console.log(chalk2.green(`\u2713 Trusted '${resolved.name}' v${resolved.version} (${resolved.source})`));
13371
13885
  console.log(chalk2.dim(` sha256 ${result.actual}`));
13372
13886
  });
13373
- program2.command("serve").option("-p, --port <port>", "Port (default 39428)").option("--host <host>", "Host to bind (default 127.0.0.1)").description("Serve the local hook registry over HTTP (catalog, artifacts, lock). The publish API key resolves from HASNA_HOOKS_API_KEY / HOOKS_API_KEY only \u2014 there is deliberately no --api-key flag (P1-8).").action(async (options) => {
13887
+ program2.command("serve").option("-p, --port <port>", "Port (default 39428)").option("--host <host>", "Host to bind (default 127.0.0.1)").description("Serve the local hook registry over HTTP (catalog, artifacts, lock). The publish API key resolves from the @hasna/contracts chain per request (HASNA_HOOKS_API_KEY, the Keychain item hasna.credentials.hooks.api-key, or ~/.hasna/hooks/config/credentials) \u2014 there is deliberately no --api-key flag (P1-8).").action(async (options) => {
13374
13888
  const { startServeServer: startServeServer2 } = await Promise.resolve().then(() => (init_serve(), exports_serve));
13375
13889
  startServeServer2({
13376
13890
  port: options.port ? parseInt(options.port, 10) : undefined,
13377
13891
  host: options.host
13378
13892
  });
13379
13893
  });
13380
- program2.command("sync").option("--dry-run", "Print the plan without changing anything", false).option("-j, --json", "Output as JSON", false).description("Sync local hooks with the remote registry (or bundled registry when no API URL is configured)").action(async (options) => {
13894
+ program2.command("sync").option("--dry-run", "Print the plan without changing anything", false).option("-j, --json", "Output as JSON", false).description("Sync local hooks with the remote registry (or bundled registry when no registry credential resolves and local mode is opted into)").action(async (options) => {
13381
13895
  const { syncHooks: syncHooks2, planSync: planSync2 } = await Promise.resolve().then(() => (init_sync(), exports_sync));
13382
13896
  try {
13383
13897
  const plan = options.dryRun ? await syncHooks2({ dryRun: true }) : await syncHooks2();
@@ -13508,4 +14022,62 @@ program2.command("mcp").option("-s, --stdio", "Use stdio transport (one process
13508
14022
  }
13509
14023
  });
13510
14024
  registerEventsCommands(program2, { source: "hooks" });
14025
+ var API_INDEPENDENT_COMMANDS = new Set([
14026
+ "run",
14027
+ "serve",
14028
+ "mcp",
14029
+ "cf",
14030
+ "migrate",
14031
+ "init",
14032
+ "profile-export",
14033
+ "profile-import",
14034
+ "channels",
14035
+ "events"
14036
+ ]);
14037
+ function failClosedForMissingApiEnv(detail) {
14038
+ console.error(chalk2.red(`hooks: no registry credential resolved and local mode is not explicitly enabled.
14039
+ ` + `The remote registry requires a STRICT pair \u2014 set HASNA_HOOKS_API_URL and HASNA_HOOKS_API_KEY, or the
14040
+ ` + `Keychain items hasna.credentials.hooks.api-url / .api-key, or ~/.hasna/hooks/config/credentials.
14041
+ ` + `config.json (api_url / api_key_ref) is RETIRED and no longer read. Or set
14042
+ ` + `HASNA_HOOKS_LOCAL=1 to explicitly opt into local mode (bundled registry + local store).
14043
+ ` + (detail ? `${detail}
14044
+ ` : "") + "Refusing to silently fall back to local storage."));
14045
+ process.exit(1);
14046
+ }
14047
+ function firstCommandToken(argv) {
14048
+ for (const arg of argv) {
14049
+ if (arg === "--")
14050
+ continue;
14051
+ if (arg.startsWith("-"))
14052
+ continue;
14053
+ return arg;
14054
+ }
14055
+ return null;
14056
+ }
14057
+ function enforceTransportGate() {
14058
+ const argv = process.argv.slice(2);
14059
+ if (argv.some((arg) => arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version"))
14060
+ return;
14061
+ const token = firstCommandToken(argv);
14062
+ if (token === "help")
14063
+ return;
14064
+ if (token !== null && API_INDEPENDENT_COMMANDS.has(token))
14065
+ return;
14066
+ if (hasHooksEnvAuthorityIntent(process.env))
14067
+ return;
14068
+ if (isHooksLocalOptIn(process.env)) {
14069
+ announceHooksLocalMode();
14070
+ return;
14071
+ }
14072
+ try {
14073
+ const transport = resolveHooksTransport(process.env);
14074
+ if (transport.mode !== "remote" || !transport.authority) {
14075
+ failClosedForMissingApiEnv();
14076
+ }
14077
+ } catch (error) {
14078
+ const message = error instanceof Error ? error.message : String(error);
14079
+ failClosedForMissingApiEnv(message);
14080
+ }
14081
+ }
14082
+ enforceTransportGate();
13511
14083
  program2.parse();