@indigoai-us/hq-cli 5.49.0 → 5.50.1

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 (40) hide show
  1. package/dist/commands/mcp-registration.d.ts +905 -0
  2. package/dist/commands/mcp-registration.js +2001 -0
  3. package/dist/commands/mcp-status.d.ts +130 -0
  4. package/dist/commands/mcp-status.js +406 -0
  5. package/dist/commands/pack-install.d.ts +62 -0
  6. package/dist/commands/pack-install.js +422 -14
  7. package/dist/commands/packs.js +28 -4
  8. package/dist/commands/pkg-install.js +5 -2
  9. package/dist/index.js +20 -3
  10. package/dist/types.d.ts +8 -1
  11. package/dist/utils/contribution-table.d.ts +103 -0
  12. package/dist/utils/contribution-table.js +65 -0
  13. package/dist/utils/environmental-error.d.ts +10 -0
  14. package/dist/utils/environmental-error.js +40 -0
  15. package/dist/utils/pack-contributions.d.ts +86 -10
  16. package/dist/utils/pack-contributions.js +130 -48
  17. package/dist/utils/secrets-cache.d.ts +9 -0
  18. package/dist/utils/secrets-cache.js +24 -2
  19. package/package.json +3 -2
  20. package/scripts/generate-scan-packages-table.mjs +113 -0
  21. package/src/commands/mcp-registration.test.ts +2787 -0
  22. package/src/commands/mcp-registration.ts +2612 -0
  23. package/src/commands/mcp-status.test.ts +483 -0
  24. package/src/commands/mcp-status.ts +575 -0
  25. package/src/commands/mcp-status.us011.test.ts +243 -0
  26. package/src/commands/pack-install.test.ts +589 -0
  27. package/src/commands/pack-install.ts +497 -13
  28. package/src/commands/packs.ts +26 -1
  29. package/src/commands/pkg-install.ts +4 -1
  30. package/src/index.ts +18 -1
  31. package/src/types.ts +9 -8
  32. package/src/utils/contribution-table.ts +83 -0
  33. package/src/utils/environmental-error.test.ts +45 -0
  34. package/src/utils/environmental-error.ts +39 -0
  35. package/src/utils/pack-contributions.test.ts +257 -25
  36. package/src/utils/pack-contributions.ts +177 -47
  37. package/src/utils/secrets-cache.ts +22 -0
  38. package/test/e2e/smoke-install-mcp.sh +113 -0
  39. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +1 -0
  40. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +11 -0
@@ -53,9 +53,19 @@ import semverValid from 'semver/functions/valid.js';
53
53
  import semverValidRange from 'semver/ranges/valid.js';
54
54
  import semverGt from 'semver/functions/gt.js';
55
55
  import { findHqRoot } from '../utils/manifest.js';
56
- import { readHqVersion } from '../utils/pack-contributions.js';
56
+ import { readHqVersion, routeContribution } from '../utils/pack-contributions.js';
57
+ import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
57
58
  import { safeExtractTarball } from './safe-extract.js';
58
59
  import { vaultApiFetchPublic } from '../utils/vault-api.js';
60
+ import {
61
+ redactSecrets,
62
+ SECRET_REDACTION,
63
+ registerMcpServers,
64
+ McpManifestError,
65
+ type McpManifest,
66
+ type SecretResolver,
67
+ } from './mcp-registration.js';
68
+ import { readCache, listSecretCacheScopes } from '../utils/secrets-cache.js';
59
69
  import type { PackManifest, PackContributeKey } from '../types.js';
60
70
 
61
71
  // ---------------------------------------------------------------------------
@@ -978,6 +988,165 @@ export function verifyArtifact(input: VerifyArtifactInput): void {
978
988
  }
979
989
  }
980
990
 
991
+ // ---------------------------------------------------------------------------
992
+ // MCP per-server manifest validation (US-005, the TS counterpart of the bash
993
+ // `validate_mcp_manifest` arm in core/scripts/scan-packages.sh).
994
+ //
995
+ // The `mcp` contributes key is `wire: 'merge'` — its per-server manifests are
996
+ // NEVER symlinked; they are validated here and (US-006) merged into the agent
997
+ // configs via registerMcpServers. This function MIRRORS the bash transport
998
+ // rules hand-coded against the draft-07 schema
999
+ // (repos/public/knowledge-hq-core/mcp-manifest.schema.json). There is no `ajv`
1000
+ // dependency in hq-cli; the schema file is single-source documentation, so the
1001
+ // rules are replicated here (dependency-free, robust if the schema is absent).
1002
+ // ---------------------------------------------------------------------------
1003
+
1004
+ /** Allowed top-level keys (schema `additionalProperties: false`). */
1005
+ const MCP_ALLOWED_KEYS = ['type', 'url', 'headers', 'command', 'args', 'env', 'tools'];
1006
+
1007
+ /** Schema `$id` for an at-a-glance error reference (kept inline; no file read). */
1008
+ const MCP_SCHEMA_ID = 'mcp-manifest.schema.json';
1009
+
1010
+ /** True iff a header/env value carries a `${secret:NAME}` reference. */
1011
+ function hasSecretRef(value: string): boolean {
1012
+ return /\$\{secret:/.test(value);
1013
+ }
1014
+
1015
+ /**
1016
+ * Validate that a record (`headers` or `env`) is an object of STRING values,
1017
+ * pushing one error per offending entry into `errs`. Mirrors the schema's
1018
+ * `additionalProperties: { type: 'string' }`.
1019
+ */
1020
+ function checkStringMap(
1021
+ errs: string[],
1022
+ label: 'headers' | 'env',
1023
+ value: unknown,
1024
+ ): void {
1025
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
1026
+ errs.push(`${label} must be an object`);
1027
+ return;
1028
+ }
1029
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
1030
+ if (typeof v !== 'string') {
1031
+ errs.push(`${label} "${k}" value must be a string`);
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ /**
1037
+ * Parse + shape-validate one pack's per-server MCP manifest (`mcp/{item}.json`),
1038
+ * mirroring the bash `validate_mcp_manifest` arm. EXPORTED so the acceptance
1039
+ * test (and US-006's registration engine) can call it directly.
1040
+ *
1041
+ * Throws an `Error` (whose message names the pack-relative payload path, e.g.
1042
+ * `mcp/foo.json`) when the file does not parse as JSON or violates a transport
1043
+ * rule:
1044
+ * - `type` required, one of `http|stdio|sse`;
1045
+ * - no unknown top-level keys (allowed: {@link MCP_ALLOWED_KEYS});
1046
+ * - http/sse: `url` required + `^https?://`; `command`/`args`/`env` forbidden;
1047
+ * - stdio: non-empty `command` required; `url`/`headers` forbidden;
1048
+ * - `headers`/`env` (if present) are objects of string values;
1049
+ * - NO inline literal `Bearer ` secret — such a value MUST carry a
1050
+ * `${secret:NAME}` reference, never a literal token.
1051
+ *
1052
+ * @param payloadDir the pack payload root (holds `mcp/{item}.json`)
1053
+ * @param item the bare server name declared under `contributes.mcp`
1054
+ */
1055
+ export function validateMcpManifest(payloadDir: string, item: string): void {
1056
+ const rel = payloadFor('mcp', item); // mcp/{item}.json
1057
+ const abs = path.join(payloadDir, rel);
1058
+
1059
+ // EXISTS — the existsSync gate in validateManifest already covers this, but
1060
+ // re-check so the helper is safe to call standalone (tests, US-006).
1061
+ if (!fs.existsSync(abs)) {
1062
+ throw new Error(`MCP manifest missing: ${rel} (declared under contributes.mcp but no such file)`);
1063
+ }
1064
+
1065
+ // PARSE — a non-JSON file is a hard error that names the file.
1066
+ let doc: unknown;
1067
+ try {
1068
+ doc = JSON.parse(fs.readFileSync(abs, 'utf-8'));
1069
+ } catch (e) {
1070
+ throw new Error(`MCP manifest malformed: ${rel} is not valid JSON (failed to parse: ${(e as Error).message})`);
1071
+ }
1072
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
1073
+ throw new Error(`MCP manifest malformed: ${rel} must be a JSON object`);
1074
+ }
1075
+ const m = doc as Record<string, unknown>;
1076
+ const errs: string[] = [];
1077
+
1078
+ // type: required + enum.
1079
+ const type = m.type;
1080
+ if (!('type' in m)) {
1081
+ errs.push('missing required field: type');
1082
+ } else if (type !== 'http' && type !== 'stdio' && type !== 'sse') {
1083
+ errs.push(`type must be one of http|stdio|sse (got: ${JSON.stringify(type)})`);
1084
+ }
1085
+
1086
+ // No unknown top-level keys (schema additionalProperties: false).
1087
+ for (const k of Object.keys(m)) {
1088
+ if (!MCP_ALLOWED_KEYS.includes(k)) {
1089
+ errs.push(`unknown top-level key: ${k}`);
1090
+ }
1091
+ }
1092
+
1093
+ // http / sse transport: url required (^https?://); command/args/env forbidden.
1094
+ if (type === 'http' || type === 'sse') {
1095
+ if (!('url' in m)) {
1096
+ errs.push(`${type} transport requires a url`);
1097
+ } else if (typeof m.url !== 'string' || !/^https?:\/\//.test(m.url)) {
1098
+ errs.push('url must be an absolute http(s) URL');
1099
+ }
1100
+ for (const forbidden of ['command', 'args', 'env'] as const) {
1101
+ if (forbidden in m) errs.push(`${type} transport forbids: ${forbidden}`);
1102
+ }
1103
+ }
1104
+
1105
+ // stdio transport: non-empty command required; url/headers forbidden.
1106
+ if (type === 'stdio') {
1107
+ if (typeof m.command !== 'string' || m.command.length === 0) {
1108
+ errs.push('stdio transport requires a non-empty command');
1109
+ }
1110
+ for (const forbidden of ['url', 'headers'] as const) {
1111
+ if (forbidden in m) errs.push(`stdio transport forbids: ${forbidden}`);
1112
+ }
1113
+ }
1114
+
1115
+ // headers / env values must be strings.
1116
+ if ('headers' in m) checkStringMap(errs, 'headers', m.headers);
1117
+ if ('env' in m) checkStringMap(errs, 'env', m.env);
1118
+
1119
+ if (errs.length > 0) {
1120
+ throw new Error(
1121
+ `MCP manifest fails shape validation: ${rel} (schema: ${MCP_SCHEMA_ID})\n` +
1122
+ errs.map((e) => ` - ${e}`).join('\n'),
1123
+ );
1124
+ }
1125
+
1126
+ // Reject INLINE LITERAL Bearer secrets. A header/env value containing
1127
+ // 'Bearer ' MUST use a ${secret:NAME} reference, never a literal token baked
1128
+ // into a synced/committed artifact.
1129
+ const bad: string[] = [];
1130
+ for (const [label, mapVal] of [
1131
+ ['header', m.headers],
1132
+ ['env', m.env],
1133
+ ] as const) {
1134
+ if (mapVal === null || typeof mapVal !== 'object' || Array.isArray(mapVal)) continue;
1135
+ for (const [k, v] of Object.entries(mapVal as Record<string, unknown>)) {
1136
+ if (typeof v === 'string' && v.includes('Bearer ') && !hasSecretRef(v)) {
1137
+ bad.push(`${label} "${k}"`);
1138
+ }
1139
+ }
1140
+ }
1141
+ if (bad.length > 0) {
1142
+ throw new Error(
1143
+ `MCP manifest contains an inline literal Bearer secret: ${rel} (${bad.join(', ')}) — ` +
1144
+ 'a \'Bearer \' header/env value MUST use a ${secret:NAME} reference, never a literal token ' +
1145
+ '(a resolved secret is never written to a synced/committed artifact)',
1146
+ );
1147
+ }
1148
+ }
1149
+
981
1150
  // ---------------------------------------------------------------------------
982
1151
  // Manifest validation (spec §Validation, 10 checks)
983
1152
  // ---------------------------------------------------------------------------
@@ -1040,26 +1209,33 @@ export function validateManifest(
1040
1209
  if (!nonEmpty) {
1041
1210
  throw new Error('contributes must have at least one non-empty subfield');
1042
1211
  }
1043
- // 10. payload files exist (hooks check happens separately in step 8)
1044
- const subpaths: Record<PackContributeKey, (item: string) => string> = {
1045
- workers: (i) => path.join('workers', i),
1046
- knowledge: (i) => path.join('knowledge', i),
1047
- skills: (i) => path.join('skills', i),
1048
- commands: (i) => path.join('commands', `${i}.md`),
1049
- hooks: (i) => path.join('hooks', `${i}.sh`),
1050
- policies: (i) => path.join('policies', `${i}.md`),
1051
- scripts: (i) => path.join('scripts', i),
1052
- };
1053
- for (const [key, items] of Object.entries(contributes) as [PackContributeKey, string[]][]) {
1212
+ // 10. payload files exist (hooks check happens separately in step 8).
1213
+ // Payload paths are READ from the single-source CONTRIBUTION_TABLE (US-003)
1214
+ // via payloadFor -- no restated `subpaths` record. An unknown key (not in the
1215
+ // table) is rejected here so a typo can't silently install unwired.
1216
+ for (const [key, items] of Object.entries(contributes) as [string, unknown][]) {
1217
+ if (!(key in CONTRIBUTION_TABLE)) {
1218
+ throw new Error(`contributes.${key} is not a known contribution type`);
1219
+ }
1054
1220
  if (!Array.isArray(items)) continue;
1055
1221
  for (const item of items) {
1056
- const rel = subpaths[key](item);
1222
+ const rel = payloadFor(key as PackContributeKey, item as string);
1057
1223
  const abs = path.join(payloadDir, rel);
1058
1224
  if (!fs.existsSync(abs)) {
1059
1225
  throw new Error(
1060
1226
  `contributes.${key} declares "${item}" but payload file missing: ${rel}`
1061
1227
  );
1062
1228
  }
1229
+ // The `mcp` key (wire: 'merge') is NOT existsSync-only: after the file is
1230
+ // confirmed present it must PARSE + SHAPE-validate against the per-server
1231
+ // MCP schema (US-005), mirroring the bash validate_mcp_manifest arm. Other
1232
+ // (symlink) keys keep their existsSync-only behavior. We dispatch on the
1233
+ // ROUTE from the single-source table (not a hardcoded key) so the merge
1234
+ // path is table-driven; the merge itself (registering into the agent
1235
+ // configs via registerMcpServers, US-006) is NEVER a symlink.
1236
+ if (routeContribution(key as PackContributeKey) === 'merge') {
1237
+ validateMcpManifest(payloadDir, item as string);
1238
+ }
1063
1239
  }
1064
1240
  }
1065
1241
  // author + capabilities (US-001) — both OPTIONAL and backwards-compatible.
@@ -1200,6 +1376,148 @@ async function confirmHooks(
1200
1376
  return /^(y|yes)$/i.test(answer.trim());
1201
1377
  }
1202
1378
 
1379
+ // ---------------------------------------------------------------------------
1380
+ // MCP confirmation (US-010)
1381
+ //
1382
+ // The MCP equivalent of confirmHooks. MCP servers are a high-trust surface: an
1383
+ // http/sse server points your agent at a REMOTE endpoint (typosquat / phishing
1384
+ // risk — the FULL url is disclosed verbatim so the operator can eyeball it), and
1385
+ // a stdio server runs a LOCAL binary with your shell permissions. We GATE on a
1386
+ // non-empty `contributes.mcp` (NEVER the advisory `capabilities` field, which is
1387
+ // reserved/unenforced — gating on it would let a pack omit a capability to dodge
1388
+ // the prompt) and render per-server transport + url-or-command + a count of ALL
1389
+ // servers being added.
1390
+ //
1391
+ // REDACTION: header/env VALUES are never printed. The manifests use
1392
+ // `${secret:NAME}` references (never literal Bearers), but we STILL run the
1393
+ // rendered prompt through `redactSecrets` against the raw header/env values so
1394
+ // that even a LITERAL secret could not leak. We show header KEY names with a
1395
+ // redacted value marker.
1396
+ //
1397
+ // FAIL-SAFE: a manifest that fails to load/parse here is NOT a bypass — we
1398
+ // surface the server name with a `[manifest unreadable]` note and STILL require
1399
+ // confirmation (a malformed manifest should make the operator MORE cautious).
1400
+ // ---------------------------------------------------------------------------
1401
+
1402
+ /**
1403
+ * Render one declared MCP server's prompt line, redacting every header/env value.
1404
+ * EXPORTED so the acceptance/redaction self-test can assert no secret/Bearer
1405
+ * substring ever appears in the rendered output.
1406
+ */
1407
+ export function renderMcpServerLine(payloadDir: string, item: string): string {
1408
+ let manifest: McpManifest;
1409
+ try {
1410
+ const abs = path.join(payloadDir, payloadFor('mcp', item));
1411
+ manifest = JSON.parse(fs.readFileSync(abs, 'utf-8')) as McpManifest;
1412
+ if (manifest === null || typeof manifest !== 'object') throw new Error('not an object');
1413
+ } catch {
1414
+ // Fail-safe: do not crash the prompt; flag the server and keep requiring
1415
+ // confirmation.
1416
+ return ` - ${item} [manifest unreadable] — could not load mcp/${item}.json; treat with caution`;
1417
+ }
1418
+
1419
+ const transport = manifest.type;
1420
+ // Accumulate every raw header/env value so a final redactSecrets pass scrubs
1421
+ // even a literal token that slipped past `${secret:}` (defense in depth — we
1422
+ // already replace the values with the redaction marker below).
1423
+ const rawValues = new Set<string>();
1424
+ for (const v of Object.values(manifest.headers ?? {})) rawValues.add(v);
1425
+ for (const v of Object.values(manifest.env ?? {})) rawValues.add(v);
1426
+
1427
+ // Header KEYS shown, VALUES always redacted (never the raw value).
1428
+ const headerKeys = Object.keys(manifest.headers ?? {});
1429
+ const headerStr =
1430
+ headerKeys.length > 0
1431
+ ? ` headers: { ${headerKeys.map((k) => `${k}: ${SECRET_REDACTION}`).join(', ')} }`
1432
+ : '';
1433
+
1434
+ let line: string;
1435
+ if (transport === 'http' || transport === 'sse') {
1436
+ // FULL url verbatim — typosquat disclosure is the whole point.
1437
+ line =
1438
+ ` - ${item} [${transport}] url: ${manifest.url ?? '(missing)'}` +
1439
+ headerStr +
1440
+ ` (contacts a remote endpoint)`;
1441
+ } else {
1442
+ // stdio — local binary with shell permissions.
1443
+ const args = manifest.args ?? [];
1444
+ const argsStr = args.length > 0 ? ` args: [${args.join(', ')}]` : '';
1445
+ line =
1446
+ ` - ${item} [stdio] command: ${manifest.command ?? '(missing)'}` +
1447
+ argsStr +
1448
+ headerStr +
1449
+ ` (runs a local binary with your shell permissions)`;
1450
+ }
1451
+
1452
+ // Final defense-in-depth scrub: even though values are already replaced with
1453
+ // the marker above, run the whole line through redactSecrets so a stray
1454
+ // literal token (e.g. inside command/args) can never reach the terminal.
1455
+ return redactSecrets(line, rawValues);
1456
+ }
1457
+
1458
+ /**
1459
+ * Install-time MCP trust prompt. EXPORTED so the acceptance test can drive the
1460
+ * gate / bypass / non-TTY branches directly (capture stdout, assert no secret
1461
+ * substring leaks, assert deny returns false). See {@link confirmHooks} for the
1462
+ * voice/shape this mirrors.
1463
+ */
1464
+ export async function confirmMcp(
1465
+ pkg: PackManifest,
1466
+ payloadDir: string,
1467
+ allowMcp: boolean,
1468
+ ): Promise<boolean> {
1469
+ // GATE on non-empty `contributes.mcp` — mirrors confirmHooks. Never on the
1470
+ // advisory `capabilities` field.
1471
+ const servers = pkg.contributes.mcp ?? [];
1472
+ if (servers.length === 0) return true;
1473
+
1474
+ if (allowMcp) {
1475
+ console.log(
1476
+ chalk.yellow(
1477
+ `--allow-mcp set; registering ${servers.length} MCP server(s) without prompting.`,
1478
+ ),
1479
+ );
1480
+ return true;
1481
+ }
1482
+
1483
+ console.log('');
1484
+ console.log(
1485
+ chalk.yellow(
1486
+ `Pack ${pkg.publisher}/${pkg.name} declares ${servers.length} MCP server(s):`,
1487
+ ),
1488
+ );
1489
+ for (const item of servers) {
1490
+ console.log(chalk.yellow(renderMcpServerLine(payloadDir, item)));
1491
+ }
1492
+ console.log(
1493
+ chalk.yellow(
1494
+ 'These servers will be registered into your Claude and Codex agent configs.',
1495
+ ),
1496
+ );
1497
+
1498
+ // Non-TTY guard (mirrors the secrets.ts pattern): do NOT call rl.question in a
1499
+ // non-interactive shell — it would hang. Instruct the operator to re-run with
1500
+ // --allow-mcp and DENY (return false -> abort, no partial state).
1501
+ if (!process.stdin.isTTY) {
1502
+ console.log(
1503
+ chalk.red(
1504
+ `Refusing to register ${servers.length} MCP server(s) without confirmation ` +
1505
+ 'in a non-interactive shell. Re-run with --allow-mcp to approve.',
1506
+ ),
1507
+ );
1508
+ return false;
1509
+ }
1510
+
1511
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1512
+ const answer: string = await new Promise((resolve) => {
1513
+ rl.question('Install anyway? [y/N] ', (a) => {
1514
+ rl.close();
1515
+ resolve(a);
1516
+ });
1517
+ });
1518
+ return /^(y|yes)$/i.test(answer.trim());
1519
+ }
1520
+
1203
1521
  // ---------------------------------------------------------------------------
1204
1522
  // Conditional predicate
1205
1523
  //
@@ -1246,6 +1564,130 @@ function evalConditional(expr: string): boolean {
1246
1564
  return r.status === 0;
1247
1565
  }
1248
1566
 
1567
+ // ---------------------------------------------------------------------------
1568
+ // MCP registration wiring (US-007) — call registerMcpServers from installPack.
1569
+ //
1570
+ // The registration CORE + Claude/Codex emitters live in mcp-registration.ts and
1571
+ // are fully unit-tested; this is the install-time SEAM that feeds them a real
1572
+ // manifest loader and a production, vault-backed secret resolver, with per-server
1573
+ // secret-deferral so a fresh install (before `/connect-shopify` has minted the
1574
+ // key) still SUCCEEDS, skipping only the server whose secret is not yet present.
1575
+ // ---------------------------------------------------------------------------
1576
+
1577
+ /**
1578
+ * Build the install-time {@link SecretResolver} bound to the HQ vault's local
1579
+ * secrets-cache (`~/.hq/secrets-cache/<scope>/<NAME>`, AES-256-GCM, 0600, TTL'd).
1580
+ *
1581
+ * Active-company resolution at install time is INDIRECT by design: `hq install`
1582
+ * has no `--company` flag and runs OFFLINE (no token), so we cannot resolve a
1583
+ * single active company UID the way `hq run` / `hq secrets` do (via
1584
+ * `getEntityUid` over the network). Instead we probe EVERY cached scope
1585
+ * (`cmp_*`/`prs_*` — whichever has minted secrets locally) for the requested
1586
+ * name and return the first hit. This naturally resolves to whichever company
1587
+ * context just provisioned the secret (e.g. the one `/connect-shopify` minted
1588
+ * `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
1589
+ * key under a company or person entity.
1590
+ *
1591
+ * On MISS across all scopes (no cache, expired TTL, or key never minted) it
1592
+ * returns `null` — which is exactly what {@link registerMcpServers}'s
1593
+ * unresolvable-secret path keys off to defer that server gracefully. With no
1594
+ * cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
1595
+ * `null` for every name, the desired graceful-deferral behavior.
1596
+ */
1597
+ export function makeInstallSecretResolver(): SecretResolver {
1598
+ return (name: string): string | null => {
1599
+ for (const scope of listSecretCacheScopes()) {
1600
+ const value = readCache(scope, name);
1601
+ if (value !== null) return value;
1602
+ }
1603
+ return null;
1604
+ };
1605
+ }
1606
+
1607
+ /**
1608
+ * Load + shape-validate + parse a pack's per-server MCP manifest from disk.
1609
+ * `validateMcpManifest(destDir, name)` is the SAME shape gate `validateManifest`
1610
+ * runs at install; it returns void and THROWS on bad shape, so we call it first
1611
+ * (re-using one source of truth), then parse `mcp/<name>.json` and return it
1612
+ * typed as {@link McpManifest}. `destDir` is the installed pack root
1613
+ * (`core/packages/<pkg>/`), the realpath `registerMcpServers` emits from.
1614
+ */
1615
+ function loadMcpManifestFrom(destDir: string, name: string): McpManifest {
1616
+ validateMcpManifest(destDir, name); // throws on bad shape (void on success)
1617
+ const abs = path.join(destDir, 'mcp', `${name}.json`);
1618
+ return JSON.parse(fs.readFileSync(abs, 'utf-8')) as McpManifest;
1619
+ }
1620
+
1621
+ /**
1622
+ * Derive the per-server "needs secret" remedy command from the pack's
1623
+ * `initialization.entrypoint` (e.g. the vyg pack's `connect-shopify` →
1624
+ * `/connect-shopify`). Falls back to a generic "provision the secret then re-run
1625
+ * `hq install`" when the pack declares no entrypoint.
1626
+ */
1627
+ function mcpSecretRemedy(initialization: PackManifest['initialization']): string {
1628
+ const entrypoint = initialization?.entrypoint;
1629
+ if (typeof entrypoint === 'string' && entrypoint.trim() !== '') {
1630
+ const command = '/' + entrypoint.trim().replace(/^\/+/, '');
1631
+ return `run \`${command}\` to provision it, then re-run \`hq install\``;
1632
+ }
1633
+ return 'provision the secret then re-run `hq install`';
1634
+ }
1635
+
1636
+ /** Pull the `${secret:NAME}` token out of a `cannot resolve ${secret:NAME}` error message. */
1637
+ function extractDeferredSecretName(message: string): string {
1638
+ const m = /\$\{secret:([^}]+)\}/.exec(message);
1639
+ return m ? m[1] : 'a required secret';
1640
+ }
1641
+
1642
+ /**
1643
+ * Register one pack's `contributes.mcp` servers into the shared Claude/Codex
1644
+ * agent configs, PER SERVER, so a single unresolvable `${secret:NAME}` defers
1645
+ * ONLY that server instead of aborting the whole install. Returns the registered
1646
+ * and skipped (deferred) server-name lists for the one-line summary.
1647
+ *
1648
+ * Per-server policy:
1649
+ * - SUCCESS → push to `registered`.
1650
+ * - McpManifestError matching
1651
+ * `/cannot resolve \$\{secret:/` → SKIP (push to `skipped`), warn on stderr,
1652
+ * install still succeeds (the key gets minted later, then a re-install wires it).
1653
+ * - ANY OTHER error → RE-THROW (abort install). Only the
1654
+ * unresolvable-secret case is swallowed.
1655
+ */
1656
+ function wireMcpServers(
1657
+ pkg: PackManifest,
1658
+ destDir: string,
1659
+ ): { registered: string[]; skipped: string[] } {
1660
+ const resolveSecret = makeInstallSecretResolver();
1661
+ const loadManifest = (name: string): McpManifest => loadMcpManifestFrom(destDir, name);
1662
+ const registered: string[] = [];
1663
+ const skipped: string[] = [];
1664
+
1665
+ for (const name of pkg.contributes.mcp ?? []) {
1666
+ try {
1667
+ // Per-server call: registerMcpServers throws on the FIRST unresolvable
1668
+ // secret, so calling it one name at a time lets us catch + continue.
1669
+ registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
1670
+ registered.push(name);
1671
+ } catch (e) {
1672
+ const isUnresolvableSecret =
1673
+ e instanceof McpManifestError && /cannot resolve \$\{secret:/.test(e.message);
1674
+ if (!isUnresolvableSecret) {
1675
+ // ConfigParseError / ConfigPermissionError / McpNameCollisionError / a
1676
+ // malformed-manifest McpManifestError / etc. — propagate, abort install.
1677
+ throw e;
1678
+ }
1679
+ skipped.push(name);
1680
+ const secret = extractDeferredSecretName(e.message);
1681
+ // Always stderr (never suppressed by --quiet): the user must see WHY a
1682
+ // server was deferred and exactly how to finish wiring it.
1683
+ process.stderr.write(
1684
+ `MCP server '${name}' needs secret ${secret} — ${mcpSecretRemedy(pkg.initialization)}.\n`,
1685
+ );
1686
+ }
1687
+ }
1688
+ return { registered, skipped };
1689
+ }
1690
+
1249
1691
  // ---------------------------------------------------------------------------
1250
1692
  // Move into core/packages/ + run core/scripts/scan-packages.sh
1251
1693
  // ---------------------------------------------------------------------------
@@ -1398,6 +1840,13 @@ export function runScanPackages(hqRoot: string, opts: { quiet?: boolean } = {}):
1398
1840
 
1399
1841
  export interface InstallPackOptions {
1400
1842
  allowHooks?: boolean;
1843
+ /**
1844
+ * US-010 install-time MCP trust prompt bypass (CI/ambient-trust). Mirrors
1845
+ * `allowHooks`: when set, `confirmMcp` skips the prompt and prints a yellow
1846
+ * notice. Absent/false → the operator is prompted (or, in a non-TTY shell,
1847
+ * the install is refused with a "re-run with --allow-mcp" hint).
1848
+ */
1849
+ allowMcp?: boolean;
1401
1850
  followBranch?: boolean;
1402
1851
  /**
1403
1852
  * Route this function's human output to stderr (and silence scan-packages
@@ -1497,6 +1946,22 @@ export async function installPack(
1497
1946
  return;
1498
1947
  }
1499
1948
 
1949
+ // US-010 — install-time MCP trust prompt. Positioned AFTER confirmHooks and
1950
+ // BEFORE installToPackages so a deny aborts with NO partial state (no config
1951
+ // written, no pack moved into core/packages, no registerMcpServers call). The
1952
+ // actual registration (US-007) now fires AFTER installToPackages +
1953
+ // runScanPackages below (pack on disk + symlinks wired), once this gate has
1954
+ // confirmed the servers — so a deny here means registration never runs.
1955
+ const mcpConfirmed = await confirmMcp(
1956
+ pkg,
1957
+ fetched.payloadDir,
1958
+ opts.allowMcp ?? false,
1959
+ );
1960
+ if (!mcpConfirmed) {
1961
+ say(chalk.red('Install aborted (MCP servers denied).'));
1962
+ return;
1963
+ }
1964
+
1500
1965
  const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
1501
1966
  // Under the v12+ HQ layout, packs live at `core/packages/<name>/` and
1502
1967
  // are tracked by filesystem presence — no `modules.yaml` write (that
@@ -1516,6 +1981,25 @@ export async function installPack(
1516
1981
  stampInstallSource(destDir, stampedSource);
1517
1982
  runScanPackages(hqRoot, { quiet: opts.quiet });
1518
1983
 
1984
+ // US-007 — register `contributes.mcp` servers into the shared Claude/Codex
1985
+ // agent configs. Fires HERE (after installToPackages + runScanPackages, i.e.
1986
+ // pack on disk + symlinks wired) and only once the confirmMcp gate above
1987
+ // approved them. The `mcp` key is wire:'merge' — NEVER symlinked; this merge
1988
+ // is its only wiring path. registerMcpServers honors the
1989
+ // HQ_DISABLE_MCP_REGISTRATION kill-switch internally, so we never re-check it.
1990
+ // Per-server secret-deferral keeps a fresh install (key not yet minted) at
1991
+ // exit 0, deferring only the unresolvable server (see wireMcpServers).
1992
+ if (Array.isArray(pkg.contributes.mcp) && pkg.contributes.mcp.length > 0) {
1993
+ const { registered, skipped } = wireMcpServers(pkg, destDir);
1994
+ // One-line summary (server NAMES only — never resolved secret VALUES).
1995
+ say(
1996
+ chalk.dim(
1997
+ ` MCP servers: registered [${registered.join(', ')}]; ` +
1998
+ `skipped [${skipped.join(', ')}].`
1999
+ )
2000
+ );
2001
+ }
2002
+
1519
2003
  say(
1520
2004
  chalk.green(
1521
2005
  `\nOK Installed ${pkg.name}@${pkg.version} -> ${path.relative(hqRoot, destDir)}/`
@@ -40,6 +40,7 @@ import {
40
40
  listInstalledPacks,
41
41
  readPackManifest,
42
42
  unwirePack,
43
+ unwirePackMcp,
43
44
  readHqVersion,
44
45
  readRecommendedPackages,
45
46
  packagesDir,
@@ -303,6 +304,7 @@ interface UpdateOpts extends CommonOpts {
303
304
  checkOnly?: boolean;
304
305
  yes?: boolean;
305
306
  allowHooks?: boolean;
307
+ allowMcp?: boolean;
306
308
  branch?: boolean;
307
309
  }
308
310
 
@@ -359,6 +361,7 @@ async function runUpdate(name: string | undefined, opts: UpdateOpts): Promise<Up
359
361
  try {
360
362
  await installPack(source, {
361
363
  allowHooks: opts.yes || opts.allowHooks,
364
+ allowMcp: opts.yes || opts.allowMcp,
362
365
  followBranch: opts.branch,
363
366
  quiet: wantsJson(opts),
364
367
  });
@@ -413,6 +416,27 @@ async function runUninstall(name: string, opts: UninstallOpts): Promise<Uninstal
413
416
  // 1. Un-wire only our symlinks.
414
417
  const { unlinked, skipped } = unwirePack(hqRoot, packDir, contributes);
415
418
 
419
+ // 1b. Un-register the pack's MCP (`wire: 'merge'`) servers — invisible to the
420
+ // symlink unwire above. Provenance-scoped: removes ONLY entries stamped with this
421
+ // pack's `_hqPack`, and skip-and-warns on any foreign/unstamped same-named entry.
422
+ // Tolerant of a Codex-less host / absent config; idempotent on re-run.
423
+ try {
424
+ const mcp = unwirePackMcp(name, contributes);
425
+ for (const server of mcp.servers) {
426
+ if ('skipped' in server.claude) continue; // (claude is always inspected; never skipped)
427
+ if (server.claude.outcome === 'skipped-foreign' && server.claude.reason) {
428
+ warnings.push(server.claude.reason);
429
+ }
430
+ if (!('skipped' in server.codex) && server.codex.outcome === 'skipped-foreign' && server.codex.reason) {
431
+ warnings.push(server.codex.reason);
432
+ }
433
+ }
434
+ } catch (e) {
435
+ // Never let an MCP un-registration failure abort the rest of the uninstall
436
+ // (symlink unwire already ran; the pack dir still gets archived). Surface it.
437
+ warnings.push(`MCP un-registration encountered an error: ${(e as Error).message}`);
438
+ }
439
+
416
440
  // 2. Archive (or delete) the pack dir -- BEFORE re-scan so it isn't re-wired.
417
441
  let archived: string | null = null;
418
442
  if (opts.archive === false) {
@@ -495,8 +519,9 @@ export function registerPacksCommand(parent: Command): void {
495
519
  .option('--json', 'Machine-readable JSON output')
496
520
  .option('--hq-root <path>', 'HQ root (default: auto-detect)')
497
521
  .option('--check-only', 'Report availability without installing')
498
- .option('-y, --yes', 'Non-interactive (implies --allow-hooks)')
522
+ .option('-y, --yes', 'Non-interactive (implies --allow-hooks and --allow-mcp)')
499
523
  .option('--allow-hooks', 'Install pack hooks without prompting')
524
+ .option('--allow-mcp', 'Register pack MCP servers without prompting')
500
525
  .option('--branch', 'Follow the source branch instead of SHA-pinning')
501
526
  .action(async (name: string | undefined, opts: UpdateOpts) => {
502
527
  try {
@@ -43,16 +43,18 @@ export function registerPackageInstallCommand(parent: Command): void {
43
43
  )
44
44
  .option('--company <co>', 'Scope the package to a specific company (registry flow only)')
45
45
  .option('--allow-hooks', 'Skip the hooks confirmation prompt (content-pack flow)')
46
+ .option('--allow-mcp', 'Skip the MCP server confirmation prompt (content-pack flow)')
46
47
  .option('--branch', 'Follow a ref instead of SHA-pinning (git content-pack flow)')
47
48
  .action(
48
49
  async (
49
50
  source: string,
50
- opts: { company?: string; allowHooks?: boolean; branch?: boolean }
51
+ opts: { company?: string; allowHooks?: boolean; allowMcp?: boolean; branch?: boolean }
51
52
  ) => {
52
53
  try {
53
54
  if (sourceMatchesPackPattern(source)) {
54
55
  await installPack(source, {
55
56
  allowHooks: opts.allowHooks,
57
+ allowMcp: opts.allowMcp,
56
58
  followBranch: opts.branch,
57
59
  });
58
60
  } else {
@@ -62,6 +64,7 @@ export function registerPackageInstallCommand(parent: Command): void {
62
64
  // listings transport — the live install path — instead.
63
65
  await installPack(`${MARKETPLACE_PREFIX}${source}`, {
64
66
  allowHooks: opts.allowHooks,
67
+ allowMcp: opts.allowMcp,
65
68
  followBranch: opts.branch,
66
69
  });
67
70
  }