@hashicorp/kits 0.1.5 → 0.1.8

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 (114) hide show
  1. package/README.md +51 -682
  2. package/dist/adapters/claude-code/detection.d.ts +1 -1
  3. package/dist/adapters/claude-code/detection.d.ts.map +1 -1
  4. package/dist/adapters/claude-code/detection.js +3 -2
  5. package/dist/adapters/claude-code/detection.js.map +1 -1
  6. package/dist/adapters/claude-code/index.d.ts +1 -1
  7. package/dist/adapters/claude-code/index.d.ts.map +1 -1
  8. package/dist/adapters/claude-code/index.js +25 -9
  9. package/dist/adapters/claude-code/index.js.map +1 -1
  10. package/dist/adapters/claude-code/installer.d.ts +1 -4
  11. package/dist/adapters/claude-code/installer.d.ts.map +1 -1
  12. package/dist/adapters/claude-code/installer.js +21 -24
  13. package/dist/adapters/claude-code/installer.js.map +1 -1
  14. package/dist/adapters/gemini-cli/detection.d.ts +1 -1
  15. package/dist/adapters/gemini-cli/detection.d.ts.map +1 -1
  16. package/dist/adapters/gemini-cli/detection.js +3 -2
  17. package/dist/adapters/gemini-cli/detection.js.map +1 -1
  18. package/dist/adapters/gemini-cli/index.d.ts +1 -1
  19. package/dist/adapters/gemini-cli/index.d.ts.map +1 -1
  20. package/dist/adapters/gemini-cli/index.js +3 -1
  21. package/dist/adapters/gemini-cli/index.js.map +1 -1
  22. package/dist/adapters/gemini-cli/installer.d.ts +1 -4
  23. package/dist/adapters/gemini-cli/installer.d.ts.map +1 -1
  24. package/dist/adapters/gemini-cli/installer.js +20 -24
  25. package/dist/adapters/gemini-cli/installer.js.map +1 -1
  26. package/dist/adapters/github-copilot/detection.d.ts +1 -1
  27. package/dist/adapters/github-copilot/detection.d.ts.map +1 -1
  28. package/dist/adapters/github-copilot/detection.js +3 -2
  29. package/dist/adapters/github-copilot/detection.js.map +1 -1
  30. package/dist/adapters/github-copilot/index.d.ts +1 -1
  31. package/dist/adapters/github-copilot/index.d.ts.map +1 -1
  32. package/dist/adapters/github-copilot/index.js +2 -1
  33. package/dist/adapters/github-copilot/index.js.map +1 -1
  34. package/dist/adapters/github-copilot/installer.d.ts.map +1 -1
  35. package/dist/adapters/github-copilot/installer.js +6 -3
  36. package/dist/adapters/github-copilot/installer.js.map +1 -1
  37. package/dist/adapters/opencode/detection.d.ts.map +1 -1
  38. package/dist/adapters/opencode/detection.js +3 -2
  39. package/dist/adapters/opencode/detection.js.map +1 -1
  40. package/dist/adapters/opencode/index.d.ts.map +1 -1
  41. package/dist/adapters/opencode/index.js +43 -20
  42. package/dist/adapters/opencode/index.js.map +1 -1
  43. package/dist/adapters/opencode/installer.d.ts.map +1 -1
  44. package/dist/adapters/opencode/installer.js +57 -32
  45. package/dist/adapters/opencode/installer.js.map +1 -1
  46. package/dist/adapters/types.d.ts +8 -0
  47. package/dist/adapters/types.d.ts.map +1 -1
  48. package/dist/adapters/types.js.map +1 -1
  49. package/dist/cli/index.js +1 -1
  50. package/dist/cli/index.js.map +1 -1
  51. package/dist/cli/install.d.ts.map +1 -1
  52. package/dist/cli/install.js +238 -176
  53. package/dist/cli/install.js.map +1 -1
  54. package/dist/cli/list.d.ts.map +1 -1
  55. package/dist/cli/list.js +18 -10
  56. package/dist/cli/list.js.map +1 -1
  57. package/dist/cli/uninstall.d.ts.map +1 -1
  58. package/dist/cli/uninstall.js +93 -88
  59. package/dist/cli/uninstall.js.map +1 -1
  60. package/dist/cli/upgrade.d.ts.map +1 -1
  61. package/dist/cli/upgrade.js +180 -31
  62. package/dist/cli/upgrade.js.map +1 -1
  63. package/dist/core/debug.d.ts +1 -1
  64. package/dist/core/debug.js +2 -2
  65. package/dist/core/debug.js.map +1 -1
  66. package/dist/core/hook-arguments.d.ts +4 -0
  67. package/dist/core/hook-arguments.d.ts.map +1 -0
  68. package/dist/core/hook-arguments.js +56 -0
  69. package/dist/core/hook-arguments.js.map +1 -0
  70. package/dist/core/hook-instance.d.ts +2 -2
  71. package/dist/core/hook-instance.d.ts.map +1 -1
  72. package/dist/core/hook-instance.js +7 -11
  73. package/dist/core/hook-instance.js.map +1 -1
  74. package/dist/core/hook-store.d.ts +5 -0
  75. package/dist/core/hook-store.d.ts.map +1 -0
  76. package/dist/core/hook-store.js +6 -0
  77. package/dist/core/hook-store.js.map +1 -0
  78. package/dist/core/types.d.ts +12 -11
  79. package/dist/core/types.d.ts.map +1 -1
  80. package/dist/core/upgrade-executor.d.ts +3 -1
  81. package/dist/core/upgrade-executor.d.ts.map +1 -1
  82. package/dist/core/upgrade-executor.js +292 -25
  83. package/dist/core/upgrade-executor.js.map +1 -1
  84. package/dist/index.d.ts +1 -1
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/index.js.map +1 -1
  88. package/dist/lockfile/index.d.ts +5 -5
  89. package/dist/lockfile/index.d.ts.map +1 -1
  90. package/dist/lockfile/index.js +35 -57
  91. package/dist/lockfile/index.js.map +1 -1
  92. package/dist/lockfile/read.js +1 -1
  93. package/dist/lockfile/read.js.map +1 -1
  94. package/dist/lockfile/types.d.ts +39 -39
  95. package/dist/lockfile/types.d.ts.map +1 -1
  96. package/dist/lockfile/types.js +1 -1
  97. package/dist/lockfile/types.js.map +1 -1
  98. package/dist/lockfile/upgrade-check.d.ts.map +1 -1
  99. package/dist/lockfile/upgrade-check.js +16 -19
  100. package/dist/lockfile/upgrade-check.js.map +1 -1
  101. package/dist/tui/types.d.ts +2 -0
  102. package/dist/tui/types.d.ts.map +1 -1
  103. package/dist/tui/upgrade-select.d.ts.map +1 -1
  104. package/dist/tui/upgrade-select.js +92 -14
  105. package/dist/tui/upgrade-select.js.map +1 -1
  106. package/dist/validation/validate-hooks.d.ts.map +1 -1
  107. package/dist/validation/validate-hooks.js +2 -1
  108. package/dist/validation/validate-hooks.js.map +1 -1
  109. package/package.json +2 -1
  110. package/schemas/examples/hook-binding-valid.json +1 -2
  111. package/schemas/hook-binding.schema.json +15 -15
  112. package/schemas/hook-program.schema.json +39 -7
  113. package/schemas/kit.schema.json +5 -4
  114. package/schemas/kits-lock.schema.json +161 -105
@@ -7,12 +7,13 @@
7
7
  import { ExitCode } from "./types.js";
8
8
  import * as clack from "@clack/prompts";
9
9
  import fs from "node:fs/promises";
10
+ import os from "node:os";
10
11
  import path from "node:path";
11
12
  import pc from "picocolors";
12
13
  import { fetchSource, scanKits, filterKitsByName as filterDiscoveryKitsByName, getMissingKitNames as getDiscoveryMissingKitNames, NoFetcherError, SourceParseError, } from "../discovery/index.js";
13
14
  import { resolvePrimitiveReferences, PrimitivesRegistryLoader, validateCliEnvFlags, mergeEnvDefs, resolveEnvVarsFromConfig, } from "../resolution/index.js";
14
15
  import { hashMcpConfig } from "../core/mcp-instance.js";
15
- import { hashHookConfig } from "../core/hook-instance.js";
16
+ import { hashHookProgram } from "../core/hook-instance.js";
16
17
  import { readLockfile } from "../lockfile/read.js";
17
18
  import { writeLockfile } from "../lockfile/write.js";
18
19
  import { createEmptyLockfile, } from "../lockfile/types.js";
@@ -131,16 +132,18 @@ async function resolveManifestTarget(source) {
131
132
  }
132
133
  function buildLockedVersionMap(lockfile) {
133
134
  const locked = new Map();
134
- for (const harnessEntry of Object.values(lockfile.harnesses)) {
135
- if (!harnessEntry)
136
- continue;
137
- for (const kit of Object.values(harnessEntry.kits)) {
138
- for (const primitive of kit.primitives) {
139
- if (!primitive.version)
140
- continue;
141
- const key = `${primitive.type}:${primitive.name}`;
142
- if (!locked.has(key)) {
143
- locked.set(key, primitive.version);
135
+ for (const kit of Object.values(lockfile.kits)) {
136
+ for (const harnessEntry of Object.values(kit.harnesses)) {
137
+ if (!harnessEntry)
138
+ continue;
139
+ for (const [type, primitives] of Object.entries(harnessEntry.primitives)) {
140
+ for (const primitive of primitives ?? []) {
141
+ if (!primitive.version)
142
+ continue;
143
+ const key = `${type}:${primitive.name}`;
144
+ if (!locked.has(key)) {
145
+ locked.set(key, primitive.version);
146
+ }
144
147
  }
145
148
  }
146
149
  }
@@ -282,6 +285,20 @@ export async function runInstall(source, options) {
282
285
  }
283
286
  return { success: false, exitCode: ExitCode.ScopeRequired, error: message };
284
287
  }
288
+ if (scope === "project") {
289
+ const resolvedProjectRoot = path.resolve(projectRoot);
290
+ const homeDir = path.resolve(os.homedir());
291
+ if (resolvedProjectRoot === homeDir) {
292
+ const message = "Project scope installs are not allowed in the home directory.";
293
+ if (options.json) {
294
+ console.log(JSON.stringify({ success: false, error: message }));
295
+ }
296
+ else {
297
+ console.error(`Error: ${message}`);
298
+ }
299
+ return { success: false, exitCode: ExitCode.InvalidArguments, error: message };
300
+ }
301
+ }
285
302
  try {
286
303
  // Start interactive UI if applicable
287
304
  if (isInteractive) {
@@ -706,25 +723,19 @@ export async function runInstall(source, options) {
706
723
  : undefined;
707
724
  const outputSource = manifestInstall && manifestPath ? manifestPath : source;
708
725
  const overwriteTargets = new Map();
709
- const existingInstances = new Set();
710
- for (const harness of selectedHarnesses) {
711
- const harnessEntry = lockfile.harnesses[harness.name];
712
- if (!harnessEntry)
713
- continue;
714
- for (const instanceName of Object.keys(harnessEntry.kits)) {
715
- existingInstances.add(instanceName);
716
- }
717
- }
726
+ const existingInstances = new Set(Object.keys(lockfile.kits));
718
727
  const usedInstanceNames = new Set(selectedKits.map((kit) => kit.installAs));
719
728
  for (const kit of selectedKits) {
720
729
  const collidingHarnesses = [];
721
730
  const existingKits = [];
722
- for (const harness of selectedHarnesses) {
723
- const existing = lockfile.harnesses[harness.name]?.kits[kit.installAs];
724
- if (!existing)
725
- continue;
726
- collidingHarnesses.push(harness.name);
727
- existingKits.push(existing);
731
+ const existingKit = lockfile.kits[kit.installAs];
732
+ if (existingKit) {
733
+ for (const harness of selectedHarnesses) {
734
+ if (existingKit.harnesses[harness.name]) {
735
+ collidingHarnesses.push(harness.name);
736
+ existingKits.push(existingKit);
737
+ }
738
+ }
728
739
  }
729
740
  if (collidingHarnesses.length === 0) {
730
741
  continue;
@@ -1297,14 +1308,17 @@ async function buildHookPrimitiveInfoBySourcePath(resolvedByKit) {
1297
1308
  continue;
1298
1309
  if (!primitive.hookProgram || !primitive.hookBinding)
1299
1310
  continue;
1311
+ if (!primitive.hookEntryPath)
1312
+ continue;
1300
1313
  const key = buildPrimitiveKey(kitName, primitive.sourcePath);
1301
1314
  if (infoBySourcePath.has(key))
1302
1315
  continue;
1303
1316
  const instanceName = resolveHookInstanceName(primitive.hookProgram.name ?? primitive.name, primitive.instanceName, kitName);
1304
- const configHash = hashHookConfig(primitive.hookProgram, primitive.hookBinding);
1317
+ const entryContents = await fs.readFile(primitive.hookEntryPath, "utf-8");
1318
+ const checksum = hashHookProgram(primitive.hookProgram, entryContents);
1305
1319
  infoBySourcePath.set(key, {
1306
1320
  instanceName,
1307
- configHash,
1321
+ checksum,
1308
1322
  program: primitive.hookProgram,
1309
1323
  binding: primitive.hookBinding,
1310
1324
  primitiveName: primitive.name,
@@ -1344,8 +1358,8 @@ function getMcpConfigPath(adapter, scope, projectRoot) {
1344
1358
  return expandPath(mcpPath);
1345
1359
  }
1346
1360
  function getHookConfigPath(adapter, scope, projectRoot) {
1347
- const paths = adapter.getInstallationPaths(scope);
1348
- const hookPath = paths.hooks ?? adapter.getConfigLocations(scope).primary;
1361
+ const configLocations = adapter.getConfigLocations(scope);
1362
+ const hookPath = configLocations.hooks ?? configLocations.primary;
1349
1363
  if (scope === "project" && projectRoot) {
1350
1364
  return path.join(projectRoot, hookPath.replace(/^\.\//, ""));
1351
1365
  }
@@ -1386,69 +1400,92 @@ async function promptForMcpInstanceName(instanceName, usedNames) {
1386
1400
  return trimmed;
1387
1401
  }
1388
1402
  }
1403
+ function getChecksumSuffix(checksum) {
1404
+ const delimiterIndex = checksum.indexOf(":");
1405
+ return delimiterIndex === -1 ? checksum : checksum.slice(delimiterIndex + 1);
1406
+ }
1407
+ function buildHookInstanceAlias(instanceName, checksum, existing) {
1408
+ const suffix = getChecksumSuffix(checksum);
1409
+ const lengths = [8, 12, 16, suffix.length];
1410
+ for (const length of lengths) {
1411
+ const alias = `${instanceName}@${suffix.slice(0, length)}`;
1412
+ const entry = existing?.[alias];
1413
+ if (!entry || entry.checksum === checksum) {
1414
+ return alias;
1415
+ }
1416
+ }
1417
+ return `${instanceName}@${suffix}`;
1418
+ }
1389
1419
  async function removeOverwrittenKitsForHarness(harness, kitsToOverwrite, lockfile, scope, projectRoot, options) {
1390
- const harnessEntry = lockfile.harnesses[harness.name];
1391
- if (!harnessEntry || kitsToOverwrite.size === 0) {
1420
+ if (kitsToOverwrite.size === 0) {
1392
1421
  return;
1393
1422
  }
1394
- const mcpInstances = harnessEntry.mcpInstances;
1395
- const hookInstances = harnessEntry.hookInstances;
1423
+ const mcpInstances = lockfile.mcpInstances;
1424
+ const hookInstances = lockfile.hookInstances;
1396
1425
  const mcpCandidates = new Set();
1397
1426
  const hookCandidates = new Set();
1398
1427
  for (const kitName of kitsToOverwrite) {
1399
- const kit = harnessEntry.kits[kitName];
1428
+ const kit = lockfile.kits[kitName];
1400
1429
  if (!kit)
1401
1430
  continue;
1402
- for (const primitive of kit.primitives) {
1403
- if (primitive.type === "mcp") {
1404
- const instanceName = primitive.instanceName ?? primitive.namespacedName;
1405
- if (instanceName) {
1406
- mcpCandidates.add(instanceName);
1407
- }
1408
- continue;
1409
- }
1410
- if (primitive.type === "hooks") {
1411
- const instanceName = primitive.instanceName ?? primitive.namespacedName;
1412
- if (instanceName) {
1413
- hookCandidates.add(instanceName);
1414
- }
1415
- continue;
1416
- }
1417
- const filePath = expandPath(primitive.installedPath);
1418
- try {
1419
- await remove(filePath);
1420
- if (options.verbose) {
1421
- if (options.isInteractive) {
1422
- clack.log.info(` ✓ Removed ${primitive.namespacedName}`);
1431
+ const harnessEntry = kit.harnesses[harness.name];
1432
+ if (!harnessEntry)
1433
+ continue;
1434
+ for (const [type, entries] of Object.entries(harnessEntry.primitives)) {
1435
+ for (const primitive of entries ?? []) {
1436
+ const displayName = `${kitName}.${primitive.name}`;
1437
+ if (type === "mcp") {
1438
+ if ("instanceRef" in primitive && primitive.instanceRef) {
1439
+ mcpCandidates.add(primitive.instanceRef);
1423
1440
  }
1424
- else {
1425
- console.error(` Removed: ${filePath}`);
1441
+ continue;
1442
+ }
1443
+ if (type === "hooks") {
1444
+ if ("instanceRef" in primitive && primitive.instanceRef) {
1445
+ hookCandidates.add(primitive.instanceRef);
1426
1446
  }
1447
+ continue;
1427
1448
  }
1428
- }
1429
- catch (error) {
1430
- if (options.verbose) {
1431
- const msg = error instanceof Error ? error.message : String(error);
1432
- if (options.isInteractive) {
1433
- clack.log.warn(` Could not remove ${primitive.namespacedName}: ${msg}`);
1449
+ const filePath = expandPath(primitive.installedPath);
1450
+ try {
1451
+ await remove(filePath);
1452
+ if (options.verbose) {
1453
+ if (options.isInteractive) {
1454
+ clack.log.info(` Removed ${displayName}`);
1455
+ }
1456
+ else {
1457
+ console.error(` Removed: ${filePath}`);
1458
+ }
1434
1459
  }
1435
- else {
1436
- console.error(` Warning: Could not remove ${filePath}: ${msg}`);
1460
+ }
1461
+ catch (error) {
1462
+ if (options.verbose) {
1463
+ const msg = error instanceof Error ? error.message : String(error);
1464
+ if (options.isInteractive) {
1465
+ clack.log.warn(` ⚠ Could not remove ${displayName}: ${msg}`);
1466
+ }
1467
+ else {
1468
+ console.error(` Warning: Could not remove ${filePath}: ${msg}`);
1469
+ }
1437
1470
  }
1438
1471
  }
1439
1472
  }
1440
1473
  }
1474
+ delete kit.harnesses[harness.name];
1475
+ if (Object.keys(kit.harnesses).length === 0) {
1476
+ delete lockfile.kits[kitName];
1477
+ }
1441
1478
  }
1442
1479
  const mcpInstancesToRemove = [];
1443
1480
  if (mcpInstances) {
1444
- for (const instanceName of mcpCandidates) {
1445
- const entry = mcpInstances[instanceName];
1481
+ for (const instanceRef of mcpCandidates) {
1482
+ const entry = mcpInstances[instanceRef];
1446
1483
  if (!entry)
1447
1484
  continue;
1448
1485
  entry.usedBy = entry.usedBy.filter((name) => !kitsToOverwrite.has(name));
1449
1486
  if (entry.usedBy.length === 0) {
1450
- delete mcpInstances[instanceName];
1451
- mcpInstancesToRemove.push(instanceName);
1487
+ delete mcpInstances[instanceRef];
1488
+ mcpInstancesToRemove.push(entry.instanceName);
1452
1489
  }
1453
1490
  }
1454
1491
  }
@@ -1468,14 +1505,14 @@ async function removeOverwrittenKitsForHarness(harness, kitsToOverwrite, lockfil
1468
1505
  }
1469
1506
  const hookInstancesToRemove = [];
1470
1507
  if (hookInstances) {
1471
- for (const instanceName of hookCandidates) {
1472
- const entry = hookInstances[instanceName];
1508
+ for (const instanceRef of hookCandidates) {
1509
+ const entry = hookInstances[instanceRef];
1473
1510
  if (!entry)
1474
1511
  continue;
1475
1512
  entry.usedBy = entry.usedBy.filter((name) => !kitsToOverwrite.has(name));
1476
1513
  if (entry.usedBy.length === 0) {
1477
- delete hookInstances[instanceName];
1478
- hookInstancesToRemove.push(instanceName);
1514
+ delete hookInstances[instanceRef];
1515
+ hookInstancesToRemove.push(instanceRef);
1479
1516
  }
1480
1517
  }
1481
1518
  }
@@ -1493,9 +1530,7 @@ async function removeOverwrittenKitsForHarness(harness, kitsToOverwrite, lockfil
1493
1530
  }
1494
1531
  }
1495
1532
  }
1496
- for (const kitName of kitsToOverwrite) {
1497
- delete harnessEntry.kits[kitName];
1498
- }
1533
+ // kits are removed above when their harness entries are cleared
1499
1534
  }
1500
1535
  /**
1501
1536
  * Execute the actual installation of kits to harnesses.
@@ -1701,11 +1736,12 @@ async function executeInstallation(kits, harnesses, registryBySource, options) {
1701
1736
  };
1702
1737
  }
1703
1738
  }
1704
- const existingInstances = lockfile.harnesses[harness.name]?.mcpInstances ?? {};
1739
+ const existingInstances = lockfile.mcpInstances ?? {};
1705
1740
  const overwriteKits = filterOverwriteKits(overwriteKitsByHarness.get(harness.name), kitsToCheck);
1706
1741
  const usedInstanceNames = new Set(Object.entries(existingInstances)
1707
- .filter(([, entry]) => entry.usedBy.some((name) => !overwriteKits.has(name)))
1708
- .map(([name]) => name));
1742
+ .filter(([, entry]) => entry.harness === harness.name &&
1743
+ entry.usedBy.some((name) => !overwriteKits.has(name)))
1744
+ .map(([, entry]) => entry.instanceName));
1709
1745
  const assignments = new Map();
1710
1746
  const forkedInstances = new Map();
1711
1747
  const instancesToInstall = new Set();
@@ -1714,7 +1750,8 @@ async function executeInstallation(kits, harnesses, registryBySource, options) {
1714
1750
  let finalName = instanceName;
1715
1751
  let finalHash = candidate.info.configHash;
1716
1752
  let action = "install";
1717
- const existing = existingInstances[instanceName];
1753
+ const instanceKey = `${harness.name}:${instanceName}`;
1754
+ const existing = existingInstances[instanceKey];
1718
1755
  const existingUsers = existing?.usedBy.filter((name) => !overwriteKits.has(name)) ?? [];
1719
1756
  if (existing && existingUsers.length > 0) {
1720
1757
  if (existing.configHash === candidate.info.configHash) {
@@ -1822,60 +1859,67 @@ async function executeInstallation(kits, harnesses, registryBySource, options) {
1822
1859
  const info = hookInfoByPrimitiveKey.get(key);
1823
1860
  if (!info)
1824
1861
  continue;
1825
- const byHash = desiredByName.get(info.instanceName) ?? new Map();
1826
- const entry = byHash.get(info.configHash) ?? {
1862
+ const byChecksum = desiredByName.get(info.instanceName) ?? new Map();
1863
+ const entry = byChecksum.get(info.checksum) ?? {
1827
1864
  primitives: [],
1828
1865
  kits: new Set(),
1829
1866
  info,
1830
1867
  };
1831
1868
  entry.primitives.push({ primitive, kitName: kit.installAs });
1832
1869
  entry.kits.add(kit.installAs);
1833
- byHash.set(info.configHash, entry);
1834
- desiredByName.set(info.instanceName, byHash);
1835
- }
1836
- }
1837
- for (const [instanceName, byHash] of desiredByName) {
1838
- if (byHash.size > 1) {
1839
- const message = `Conflicting hook configs detected for instance "${instanceName}". ` +
1840
- "Resolve the conflict by renaming one of the hook instances.";
1841
- return {
1842
- success: false,
1843
- exitCode: ExitCode.InstallationFailed,
1844
- error: message,
1845
- };
1870
+ byChecksum.set(info.checksum, entry);
1871
+ desiredByName.set(info.instanceName, byChecksum);
1846
1872
  }
1847
1873
  }
1848
- const existingInstances = lockfile.harnesses[harness.name]?.hookInstances ?? {};
1874
+ const existingInstances = lockfile.hookInstances ?? {};
1849
1875
  const overwriteKits = filterOverwriteKits(overwriteKitsByHarness.get(harness.name), kitsToCheck);
1850
1876
  const assignments = new Map();
1851
1877
  const instancesToInstall = new Set();
1852
- for (const [instanceName, byHash] of desiredByName) {
1853
- const candidate = Array.from(byHash.values())[0];
1854
- let action = "install";
1855
- let finalHash = candidate.info.configHash;
1856
- const existing = existingInstances[instanceName];
1857
- const existingUsers = existing?.usedBy.filter((name) => !overwriteKits.has(name)) ?? [];
1858
- if (existing && existingUsers.length > 0) {
1859
- if (existing.configHash !== candidate.info.configHash) {
1860
- return {
1861
- success: false,
1862
- exitCode: ExitCode.InstallationFailed,
1863
- error: `Hook instance "${instanceName}" already exists in ${harness.displayName} with a different configuration.`,
1864
- };
1878
+ const plannedBaseChecksums = new Map();
1879
+ for (const [instanceName, byChecksum] of desiredByName) {
1880
+ for (const candidate of byChecksum.values()) {
1881
+ let instanceRef = instanceName;
1882
+ let action = "install";
1883
+ const checksum = candidate.info.checksum;
1884
+ const existingBase = existingInstances[instanceName];
1885
+ const existingBaseUsers = existingBase?.usedBy.filter((name) => !overwriteKits.has(name)) ?? [];
1886
+ const baseInUse = Boolean(existingBase && existingBaseUsers.length > 0);
1887
+ if (baseInUse && existingBase?.checksum && existingBase.checksum !== checksum) {
1888
+ instanceRef = buildHookInstanceAlias(instanceName, checksum, existingInstances);
1889
+ }
1890
+ const plannedChecksum = plannedBaseChecksums.get(instanceName);
1891
+ if (instanceRef === instanceName && plannedChecksum && plannedChecksum !== checksum) {
1892
+ instanceRef = buildHookInstanceAlias(instanceName, checksum, existingInstances);
1893
+ }
1894
+ if (instanceRef === instanceName) {
1895
+ plannedBaseChecksums.set(instanceName, checksum);
1896
+ }
1897
+ const existing = existingInstances[instanceRef];
1898
+ const existingUsers = existing?.usedBy.filter((name) => !overwriteKits.has(name)) ?? [];
1899
+ if (existing && existingUsers.length > 0) {
1900
+ if (existing.checksum === checksum) {
1901
+ action = "reuse";
1902
+ }
1903
+ else {
1904
+ instanceRef = buildHookInstanceAlias(instanceName, checksum, existingInstances);
1905
+ }
1906
+ }
1907
+ const aliasEntry = existingInstances[instanceRef];
1908
+ const aliasUsers = aliasEntry?.usedBy.filter((name) => !overwriteKits.has(name)) ?? [];
1909
+ if (aliasEntry && aliasUsers.length > 0 && aliasEntry.checksum === checksum) {
1910
+ action = "reuse";
1911
+ }
1912
+ if (action === "install") {
1913
+ instancesToInstall.add(instanceRef);
1914
+ }
1915
+ for (const { primitive, kitName } of candidate.primitives) {
1916
+ const key = buildPrimitiveKey(kitName, primitive.sourcePath);
1917
+ assignments.set(key, {
1918
+ instanceRef,
1919
+ checksum,
1920
+ action,
1921
+ });
1865
1922
  }
1866
- action = "reuse";
1867
- finalHash = existing.configHash;
1868
- }
1869
- if (action === "install") {
1870
- instancesToInstall.add(instanceName);
1871
- }
1872
- for (const { primitive, kitName } of candidate.primitives) {
1873
- const key = buildPrimitiveKey(kitName, primitive.sourcePath);
1874
- assignments.set(key, {
1875
- instanceName,
1876
- configHash: finalHash,
1877
- action,
1878
- });
1879
1923
  }
1880
1924
  }
1881
1925
  if (assignments.size > 0) {
@@ -1961,15 +2005,10 @@ async function executeInstallation(kits, harnesses, registryBySource, options) {
1961
2005
  primitivesForAdapter.push(primitive);
1962
2006
  continue;
1963
2007
  }
1964
- const canInstall = assignment.action === "install";
1965
- const shouldInstall = canInstall &&
1966
- (!hookInstancesToInstall || hookInstancesToInstall.has(assignment.instanceName));
1967
- if (!shouldInstall) {
1968
- skippedHookPrimitives.push(primitive);
1969
- continue;
2008
+ primitivesForAdapter.push({ ...primitive, instanceName: assignment.instanceRef });
2009
+ if (assignment.action === "install") {
2010
+ installedHookInstanceNames.add(assignment.instanceRef);
1970
2011
  }
1971
- primitivesForAdapter.push({ ...primitive, instanceName: assignment.instanceName });
1972
- installedHookInstanceNames.add(assignment.instanceName);
1973
2012
  continue;
1974
2013
  }
1975
2014
  primitivesForAdapter.push(primitive);
@@ -2048,66 +2087,83 @@ async function executeInstallation(kits, harnesses, registryBySource, options) {
2048
2087
  }
2049
2088
  }
2050
2089
  // Record in lockfile
2051
- if (!lockfile.harnesses[harness.name]) {
2052
- lockfile.harnesses[harness.name] = { kits: {} };
2053
- }
2054
2090
  const statusByKey = new Map();
2055
2091
  for (const status of installResult.installedPrimitives) {
2056
2092
  statusByKey.set(`${status.type}:${status.name}`, status);
2057
2093
  }
2058
- const installedPrimitives = resolvedPrimitives.map((primitive) => {
2094
+ const kitEntry = (lockfile.kits[kit.installAs] ??= {
2095
+ name: kit.name,
2096
+ installAs: kit.installAs,
2097
+ version: kit.manifest.version,
2098
+ installedAt: new Date().toISOString(),
2099
+ source: kit.source,
2100
+ harnesses: {},
2101
+ });
2102
+ kitEntry.version = kit.manifest.version;
2103
+ kitEntry.installedAt = new Date().toISOString();
2104
+ kitEntry.source = kit.source;
2105
+ const primitivesByType = {};
2106
+ for (const primitive of resolvedPrimitives) {
2059
2107
  const key = `${primitive.type}:${primitive.name}`;
2060
2108
  const status = statusByKey.get(key);
2061
- const namespacedName = status?.namespacedName
2062
- ?? harness.adapter.getNamespacedName(kit.installAs, primitive.name);
2063
2109
  const installedPath = status?.destination
2064
2110
  ?? (primitive.type === "mcp" ? mcpConfigPath : "");
2065
- const installed = {
2111
+ const entry = {
2066
2112
  name: primitive.name,
2067
- type: primitive.type,
2068
- namespacedName,
2069
- isInline: !primitive.ref,
2113
+ version: primitive.resolvedVersion ?? kit.manifest.version,
2070
2114
  installedPath,
2071
2115
  };
2072
- if (primitive.resolvedVersion) {
2073
- installed.version = primitive.resolvedVersion;
2116
+ if (!primitive.ref) {
2117
+ entry.isInline = true;
2074
2118
  }
2075
- if (primitive.ref) {
2076
- // Extract version spec from ref (e.g., "tf-plan@^1.0.0" -> "^1.0.0")
2119
+ else {
2077
2120
  const atIndex = primitive.ref.indexOf("@");
2078
2121
  if (atIndex !== -1) {
2079
- installed.versionSpec = primitive.ref.substring(atIndex + 1);
2122
+ entry.versionSpec = primitive.ref.substring(atIndex + 1);
2080
2123
  }
2081
2124
  }
2082
2125
  if (primitive.type === "mcp") {
2083
2126
  const assignment = mcpAssignments?.get(buildPrimitiveKey(kit.installAs, primitive.sourcePath));
2084
2127
  if (assignment) {
2085
- installed.instanceName = assignment.instanceName;
2086
- installed.configHash = assignment.configHash;
2128
+ const instanceRef = `${harness.name}:${assignment.instanceName}`;
2129
+ const mcpEntry = {
2130
+ ...entry,
2131
+ configPath: mcpConfigPath,
2132
+ instanceRef,
2133
+ };
2134
+ (primitivesByType.mcp ??= []).push(mcpEntry);
2087
2135
  }
2136
+ continue;
2088
2137
  }
2089
2138
  if (primitive.type === "hooks") {
2090
2139
  const assignment = hookAssignments?.get(buildPrimitiveKey(kit.installAs, primitive.sourcePath));
2091
2140
  if (assignment) {
2092
- installed.instanceName = assignment.instanceName;
2093
- installed.configHash = assignment.configHash;
2141
+ if (!primitive.hookBinding) {
2142
+ continue;
2143
+ }
2144
+ const destinationDir = status?.destination ?? "";
2145
+ const programPath = primitive.hookEntryPath
2146
+ ? path.join(destinationDir, path.basename(primitive.hookEntryPath))
2147
+ : destinationDir;
2148
+ const hookEntry = {
2149
+ ...entry,
2150
+ installedPath: programPath,
2151
+ configPath: hookConfigPath,
2152
+ instanceRef: assignment.instanceRef,
2153
+ binding: primitive.hookBinding,
2154
+ };
2155
+ (primitivesByType.hooks ??= []).push(hookEntry);
2094
2156
  }
2157
+ continue;
2095
2158
  }
2096
- return installed;
2097
- });
2098
- const installedKit = {
2099
- name: kit.name,
2100
- installAs: kit.installAs,
2101
- version: kit.manifest.version,
2102
- installedAt: new Date().toISOString(),
2103
- source: kit.source,
2104
- primitives: installedPrimitives,
2159
+ (primitivesByType[primitive.type] ??= []).push(entry);
2160
+ }
2161
+ kitEntry.harnesses[harness.name] = {
2162
+ primitives: primitivesByType,
2105
2163
  };
2106
- lockfile.harnesses[harness.name].kits[kit.installAs] = installedKit;
2107
2164
  if (mcpAssignments && mcpInfoByPrimitiveKey.size > 0) {
2108
- const harnessEntry = lockfile.harnesses[harness.name];
2109
- if (!harnessEntry.mcpInstances) {
2110
- harnessEntry.mcpInstances = {};
2165
+ if (!lockfile.mcpInstances) {
2166
+ lockfile.mcpInstances = {};
2111
2167
  }
2112
2168
  for (const primitive of resolvedPrimitives) {
2113
2169
  if (primitive.type !== "mcp")
@@ -2115,8 +2171,8 @@ async function executeInstallation(kits, harnesses, registryBySource, options) {
2115
2171
  const assignment = mcpAssignments.get(buildPrimitiveKey(kit.installAs, primitive.sourcePath));
2116
2172
  if (!assignment)
2117
2173
  continue;
2118
- const instanceName = assignment.instanceName;
2119
- const existingInstance = harnessEntry.mcpInstances[instanceName];
2174
+ const instanceKey = `${harness.name}:${assignment.instanceName}`;
2175
+ const existingInstance = lockfile.mcpInstances[instanceKey];
2120
2176
  if (existingInstance) {
2121
2177
  if (!existingInstance.usedBy.includes(kit.installAs)) {
2122
2178
  existingInstance.usedBy.push(kit.installAs);
@@ -2126,9 +2182,12 @@ async function executeInstallation(kits, harnesses, registryBySource, options) {
2126
2182
  const info = mcpInfoByPrimitiveKey.get(buildPrimitiveKey(kit.installAs, primitive.sourcePath));
2127
2183
  if (!info)
2128
2184
  continue;
2129
- harnessEntry.mcpInstances[instanceName] = {
2130
- version: info.version,
2185
+ lockfile.mcpInstances[instanceKey] = {
2186
+ harness: harness.name,
2187
+ instanceName: assignment.instanceName,
2188
+ config: info.config,
2131
2189
  configHash: assignment.configHash,
2190
+ version: info.version,
2132
2191
  usedBy: [kit.installAs],
2133
2192
  sourcePrimitive: info.primitiveName,
2134
2193
  installedAt: new Date().toISOString(),
@@ -2136,9 +2195,8 @@ async function executeInstallation(kits, harnesses, registryBySource, options) {
2136
2195
  }
2137
2196
  }
2138
2197
  if (hookAssignments && hookInfoByPrimitiveKey.size > 0) {
2139
- const harnessEntry = lockfile.harnesses[harness.name];
2140
- if (!harnessEntry.hookInstances) {
2141
- harnessEntry.hookInstances = {};
2198
+ if (!lockfile.hookInstances) {
2199
+ lockfile.hookInstances = {};
2142
2200
  }
2143
2201
  for (const primitive of resolvedPrimitives) {
2144
2202
  if (primitive.type !== "hooks")
@@ -2146,8 +2204,7 @@ async function executeInstallation(kits, harnesses, registryBySource, options) {
2146
2204
  const assignment = hookAssignments.get(buildPrimitiveKey(kit.installAs, primitive.sourcePath));
2147
2205
  if (!assignment)
2148
2206
  continue;
2149
- const instanceName = assignment.instanceName;
2150
- const existingInstance = harnessEntry.hookInstances[instanceName];
2207
+ const existingInstance = lockfile.hookInstances[assignment.instanceRef];
2151
2208
  if (existingInstance) {
2152
2209
  if (!existingInstance.usedBy.includes(kit.installAs)) {
2153
2210
  existingInstance.usedBy.push(kit.installAs);
@@ -2157,12 +2214,17 @@ async function executeInstallation(kits, harnesses, registryBySource, options) {
2157
2214
  const info = hookInfoByPrimitiveKey.get(buildPrimitiveKey(kit.installAs, primitive.sourcePath));
2158
2215
  if (!info)
2159
2216
  continue;
2160
- harnessEntry.hookInstances[instanceName] = {
2217
+ const status = statusByKey.get(`hooks:${primitive.name}`);
2218
+ const destinationDir = status?.destination ?? "";
2219
+ const programPath = primitive.hookEntryPath
2220
+ ? path.join(destinationDir, path.basename(primitive.hookEntryPath))
2221
+ : destinationDir;
2222
+ lockfile.hookInstances[assignment.instanceRef] = {
2223
+ instanceName: assignment.instanceRef,
2224
+ programPath,
2225
+ checksum: assignment.checksum,
2161
2226
  version: info.version,
2162
- configHash: assignment.configHash,
2163
2227
  usedBy: [kit.installAs],
2164
- sourcePrimitive: info.primitiveName,
2165
- installedAt: new Date().toISOString(),
2166
2228
  };
2167
2229
  }
2168
2230
  }