@indigoai-us/hq-cli 5.50.0 → 5.50.2

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 (46) 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/onboard-warning.d.ts +7 -0
  6. package/dist/commands/onboard-warning.js +14 -0
  7. package/dist/commands/onboard.js +5 -5
  8. package/dist/commands/pack-install.d.ts +74 -0
  9. package/dist/commands/pack-install.js +493 -14
  10. package/dist/commands/packs.js +42 -4
  11. package/dist/commands/pkg-install.js +5 -2
  12. package/dist/index.js +7 -2
  13. package/dist/types.d.ts +26 -1
  14. package/dist/utils/contribution-table.d.ts +103 -0
  15. package/dist/utils/contribution-table.js +65 -0
  16. package/dist/utils/pack-contributions.d.ts +93 -10
  17. package/dist/utils/pack-contributions.js +140 -48
  18. package/dist/utils/secrets-cache.d.ts +9 -0
  19. package/dist/utils/secrets-cache.js +24 -2
  20. package/dist/utils/version-gate.d.ts +40 -1
  21. package/dist/utils/version-gate.js +91 -20
  22. package/package.json +3 -2
  23. package/scripts/generate-scan-packages-table.mjs +113 -0
  24. package/src/commands/mcp-registration.test.ts +2787 -0
  25. package/src/commands/mcp-registration.ts +2612 -0
  26. package/src/commands/mcp-status.test.ts +483 -0
  27. package/src/commands/mcp-status.ts +575 -0
  28. package/src/commands/mcp-status.us011.test.ts +243 -0
  29. package/src/commands/onboard-warning.test.ts +26 -0
  30. package/src/commands/onboard-warning.ts +12 -0
  31. package/src/commands/onboard.ts +4 -7
  32. package/src/commands/pack-install.test.ts +733 -0
  33. package/src/commands/pack-install.ts +582 -13
  34. package/src/commands/packs.ts +45 -1
  35. package/src/commands/pkg-install.ts +4 -1
  36. package/src/index.ts +6 -0
  37. package/src/types.ts +28 -9
  38. package/src/utils/contribution-table.ts +83 -0
  39. package/src/utils/pack-contributions.test.ts +310 -25
  40. package/src/utils/pack-contributions.ts +194 -47
  41. package/src/utils/secrets-cache.ts +22 -0
  42. package/src/utils/version-gate.test.ts +122 -0
  43. package/src/utils/version-gate.ts +109 -13
  44. package/test/e2e/smoke-install-mcp.sh +113 -0
  45. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +1 -0
  46. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +11 -0
@@ -34,7 +34,7 @@
34
34
  * from each pack's package.yaml; rationale lives in the layout-fix PR.)
35
35
  */
36
36
 
37
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="cb8b5583-b60f-52a5-802d-befc33f2a07b")}catch(e){}}();
37
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="0ec07d3a-ee86-51fd-adbd-2608c620ae99")}catch(e){}}();
38
38
  import * as fs from 'fs';
39
39
  import * as os from 'os';
40
40
  import * as path from 'path';
@@ -48,9 +48,12 @@ import semverValid from 'semver/functions/valid.js';
48
48
  import semverValidRange from 'semver/ranges/valid.js';
49
49
  import semverGt from 'semver/functions/gt.js';
50
50
  import { findHqRoot } from '../utils/manifest.js';
51
- import { readHqVersion } from '../utils/pack-contributions.js';
51
+ import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
52
+ import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
52
53
  import { safeExtractTarball } from './safe-extract.js';
53
54
  import { vaultApiFetchPublic } from '../utils/vault-api.js';
55
+ import { redactSecrets, SECRET_REDACTION, registerMcpServers, McpManifestError, } from './mcp-registration.js';
56
+ import { readCache, listSecretCacheScopes } from '../utils/secrets-cache.js';
54
57
  /** Prefix that routes a source through the HQ marketplace transport (US-006). */
55
58
  export const MARKETPLACE_PREFIX = 'marketplace:';
56
59
  export function classify(source) {
@@ -712,6 +715,150 @@ export function verifyArtifact(input) {
712
715
  }
713
716
  }
714
717
  // ---------------------------------------------------------------------------
718
+ // MCP per-server manifest validation (US-005, the TS counterpart of the bash
719
+ // `validate_mcp_manifest` arm in core/scripts/scan-packages.sh).
720
+ //
721
+ // The `mcp` contributes key is `wire: 'merge'` — its per-server manifests are
722
+ // NEVER symlinked; they are validated here and (US-006) merged into the agent
723
+ // configs via registerMcpServers. This function MIRRORS the bash transport
724
+ // rules hand-coded against the draft-07 schema
725
+ // (repos/public/knowledge-hq-core/mcp-manifest.schema.json). There is no `ajv`
726
+ // dependency in hq-cli; the schema file is single-source documentation, so the
727
+ // rules are replicated here (dependency-free, robust if the schema is absent).
728
+ // ---------------------------------------------------------------------------
729
+ /** Allowed top-level keys (schema `additionalProperties: false`). */
730
+ const MCP_ALLOWED_KEYS = ['type', 'url', 'headers', 'command', 'args', 'env', 'tools'];
731
+ /** Schema `$id` for an at-a-glance error reference (kept inline; no file read). */
732
+ const MCP_SCHEMA_ID = 'mcp-manifest.schema.json';
733
+ /** True iff a header/env value carries a `${secret:NAME}` reference. */
734
+ function hasSecretRef(value) {
735
+ return /\$\{secret:/.test(value);
736
+ }
737
+ /**
738
+ * Validate that a record (`headers` or `env`) is an object of STRING values,
739
+ * pushing one error per offending entry into `errs`. Mirrors the schema's
740
+ * `additionalProperties: { type: 'string' }`.
741
+ */
742
+ function checkStringMap(errs, label, value) {
743
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
744
+ errs.push(`${label} must be an object`);
745
+ return;
746
+ }
747
+ for (const [k, v] of Object.entries(value)) {
748
+ if (typeof v !== 'string') {
749
+ errs.push(`${label} "${k}" value must be a string`);
750
+ }
751
+ }
752
+ }
753
+ /**
754
+ * Parse + shape-validate one pack's per-server MCP manifest (`mcp/{item}.json`),
755
+ * mirroring the bash `validate_mcp_manifest` arm. EXPORTED so the acceptance
756
+ * test (and US-006's registration engine) can call it directly.
757
+ *
758
+ * Throws an `Error` (whose message names the pack-relative payload path, e.g.
759
+ * `mcp/foo.json`) when the file does not parse as JSON or violates a transport
760
+ * rule:
761
+ * - `type` required, one of `http|stdio|sse`;
762
+ * - no unknown top-level keys (allowed: {@link MCP_ALLOWED_KEYS});
763
+ * - http/sse: `url` required + `^https?://`; `command`/`args`/`env` forbidden;
764
+ * - stdio: non-empty `command` required; `url`/`headers` forbidden;
765
+ * - `headers`/`env` (if present) are objects of string values;
766
+ * - NO inline literal `Bearer ` secret — such a value MUST carry a
767
+ * `${secret:NAME}` reference, never a literal token.
768
+ *
769
+ * @param payloadDir the pack payload root (holds `mcp/{item}.json`)
770
+ * @param item the bare server name declared under `contributes.mcp`
771
+ */
772
+ export function validateMcpManifest(payloadDir, item) {
773
+ const rel = payloadFor('mcp', item); // mcp/{item}.json
774
+ const abs = path.join(payloadDir, rel);
775
+ // EXISTS — the existsSync gate in validateManifest already covers this, but
776
+ // re-check so the helper is safe to call standalone (tests, US-006).
777
+ if (!fs.existsSync(abs)) {
778
+ throw new Error(`MCP manifest missing: ${rel} (declared under contributes.mcp but no such file)`);
779
+ }
780
+ // PARSE — a non-JSON file is a hard error that names the file.
781
+ let doc;
782
+ try {
783
+ doc = JSON.parse(fs.readFileSync(abs, 'utf-8'));
784
+ }
785
+ catch (e) {
786
+ throw new Error(`MCP manifest malformed: ${rel} is not valid JSON (failed to parse: ${e.message})`);
787
+ }
788
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
789
+ throw new Error(`MCP manifest malformed: ${rel} must be a JSON object`);
790
+ }
791
+ const m = doc;
792
+ const errs = [];
793
+ // type: required + enum.
794
+ const type = m.type;
795
+ if (!('type' in m)) {
796
+ errs.push('missing required field: type');
797
+ }
798
+ else if (type !== 'http' && type !== 'stdio' && type !== 'sse') {
799
+ errs.push(`type must be one of http|stdio|sse (got: ${JSON.stringify(type)})`);
800
+ }
801
+ // No unknown top-level keys (schema additionalProperties: false).
802
+ for (const k of Object.keys(m)) {
803
+ if (!MCP_ALLOWED_KEYS.includes(k)) {
804
+ errs.push(`unknown top-level key: ${k}`);
805
+ }
806
+ }
807
+ // http / sse transport: url required (^https?://); command/args/env forbidden.
808
+ if (type === 'http' || type === 'sse') {
809
+ if (!('url' in m)) {
810
+ errs.push(`${type} transport requires a url`);
811
+ }
812
+ else if (typeof m.url !== 'string' || !/^https?:\/\//.test(m.url)) {
813
+ errs.push('url must be an absolute http(s) URL');
814
+ }
815
+ for (const forbidden of ['command', 'args', 'env']) {
816
+ if (forbidden in m)
817
+ errs.push(`${type} transport forbids: ${forbidden}`);
818
+ }
819
+ }
820
+ // stdio transport: non-empty command required; url/headers forbidden.
821
+ if (type === 'stdio') {
822
+ if (typeof m.command !== 'string' || m.command.length === 0) {
823
+ errs.push('stdio transport requires a non-empty command');
824
+ }
825
+ for (const forbidden of ['url', 'headers']) {
826
+ if (forbidden in m)
827
+ errs.push(`stdio transport forbids: ${forbidden}`);
828
+ }
829
+ }
830
+ // headers / env values must be strings.
831
+ if ('headers' in m)
832
+ checkStringMap(errs, 'headers', m.headers);
833
+ if ('env' in m)
834
+ checkStringMap(errs, 'env', m.env);
835
+ if (errs.length > 0) {
836
+ throw new Error(`MCP manifest fails shape validation: ${rel} (schema: ${MCP_SCHEMA_ID})\n` +
837
+ errs.map((e) => ` - ${e}`).join('\n'));
838
+ }
839
+ // Reject INLINE LITERAL Bearer secrets. A header/env value containing
840
+ // 'Bearer ' MUST use a ${secret:NAME} reference, never a literal token baked
841
+ // into a synced/committed artifact.
842
+ const bad = [];
843
+ for (const [label, mapVal] of [
844
+ ['header', m.headers],
845
+ ['env', m.env],
846
+ ]) {
847
+ if (mapVal === null || typeof mapVal !== 'object' || Array.isArray(mapVal))
848
+ continue;
849
+ for (const [k, v] of Object.entries(mapVal)) {
850
+ if (typeof v === 'string' && v.includes('Bearer ') && !hasSecretRef(v)) {
851
+ bad.push(`${label} "${k}"`);
852
+ }
853
+ }
854
+ }
855
+ if (bad.length > 0) {
856
+ throw new Error(`MCP manifest contains an inline literal Bearer secret: ${rel} (${bad.join(', ')}) — ` +
857
+ 'a \'Bearer \' header/env value MUST use a ${secret:NAME} reference, never a literal token ' +
858
+ '(a resolved secret is never written to a synced/committed artifact)');
859
+ }
860
+ }
861
+ // ---------------------------------------------------------------------------
715
862
  // Manifest validation (spec §Validation, 10 checks)
716
863
  // ---------------------------------------------------------------------------
717
864
  export function validateManifest(payloadDir, hqVersion) {
@@ -760,31 +907,64 @@ export function validateManifest(payloadDir, hqVersion) {
760
907
  if (hqVersion && !semverSatisfies(hqVersion, range, { includePrerelease: true })) {
761
908
  throw new Error(`Host hqCore ${hqVersion} does not satisfy pack requirement ${range}`);
762
909
  }
910
+ // 6b. requires.packs (M0) — OPTIONAL pack-to-pack dependencies. Absent → legacy
911
+ // behavior (hqCore is the only prerequisite). Present → a list of
912
+ // { name, version? } where `name` is a valid hq-pack name and `version` (if
913
+ // given) is a valid semver RANGE. SHAPE validation only (no filesystem) so a
914
+ // malformed dependency can't masquerade as valid; whether the named packs are
915
+ // actually INSTALLED is enforced at install time by assertPackDependencies
916
+ // (which needs hqRoot — a pure manifest validator doesn't have it).
917
+ const reqPacks = m.requires?.packs;
918
+ if (reqPacks !== undefined) {
919
+ if (!Array.isArray(reqPacks)) {
920
+ throw new Error('requires.packs must be a list of { name, version? } entries');
921
+ }
922
+ for (const dep of reqPacks) {
923
+ if (!dep || typeof dep !== 'object' || Array.isArray(dep)) {
924
+ throw new Error('requires.packs entries must be mappings with a name (and optional version)');
925
+ }
926
+ const d = dep;
927
+ if (typeof d.name !== 'string' || !/^hq-pack-[a-z0-9][a-z0-9-]*$/.test(d.name)) {
928
+ throw new Error(`requires.packs[].name "${d.name}" must match ^hq-pack-[a-z0-9][a-z0-9-]*$`);
929
+ }
930
+ if (d.version !== undefined &&
931
+ (typeof d.version !== 'string' || !semverValidRange(d.version))) {
932
+ throw new Error(`requires.packs entry for "${d.name}" has an invalid version range "${d.version}"`);
933
+ }
934
+ }
935
+ }
763
936
  // 7. contributes has at least one non-empty subfield
764
937
  const contributes = (m.contributes ?? {});
765
938
  const nonEmpty = Object.values(contributes).some((v) => Array.isArray(v) && v.length > 0);
766
939
  if (!nonEmpty) {
767
940
  throw new Error('contributes must have at least one non-empty subfield');
768
941
  }
769
- // 10. payload files exist (hooks check happens separately in step 8)
770
- const subpaths = {
771
- workers: (i) => path.join('workers', i),
772
- knowledge: (i) => path.join('knowledge', i),
773
- skills: (i) => path.join('skills', i),
774
- commands: (i) => path.join('commands', `${i}.md`),
775
- hooks: (i) => path.join('hooks', `${i}.sh`),
776
- policies: (i) => path.join('policies', `${i}.md`),
777
- scripts: (i) => path.join('scripts', i),
778
- };
942
+ // 10. payload files exist (hooks check happens separately in step 8).
943
+ // Payload paths are READ from the single-source CONTRIBUTION_TABLE (US-003)
944
+ // via payloadFor -- no restated `subpaths` record. An unknown key (not in the
945
+ // table) is rejected here so a typo can't silently install unwired.
779
946
  for (const [key, items] of Object.entries(contributes)) {
947
+ if (!(key in CONTRIBUTION_TABLE)) {
948
+ throw new Error(`contributes.${key} is not a known contribution type`);
949
+ }
780
950
  if (!Array.isArray(items))
781
951
  continue;
782
952
  for (const item of items) {
783
- const rel = subpaths[key](item);
953
+ const rel = payloadFor(key, item);
784
954
  const abs = path.join(payloadDir, rel);
785
955
  if (!fs.existsSync(abs)) {
786
956
  throw new Error(`contributes.${key} declares "${item}" but payload file missing: ${rel}`);
787
957
  }
958
+ // The `mcp` key (wire: 'merge') is NOT existsSync-only: after the file is
959
+ // confirmed present it must PARSE + SHAPE-validate against the per-server
960
+ // MCP schema (US-005), mirroring the bash validate_mcp_manifest arm. Other
961
+ // (symlink) keys keep their existsSync-only behavior. We dispatch on the
962
+ // ROUTE from the single-source table (not a hardcoded key) so the merge
963
+ // path is table-driven; the merge itself (registering into the agent
964
+ // configs via registerMcpServers, US-006) is NEVER a symlink.
965
+ if (routeContribution(key) === 'merge') {
966
+ validateMcpManifest(payloadDir, item);
967
+ }
788
968
  }
789
969
  }
790
970
  // author + capabilities (US-001) — both OPTIONAL and backwards-compatible.
@@ -853,6 +1033,46 @@ export function validateManifest(payloadDir, hqVersion) {
853
1033
  return m;
854
1034
  }
855
1035
  // ---------------------------------------------------------------------------
1036
+ // Pack-to-pack dependency pre-flight (M0)
1037
+ // ---------------------------------------------------------------------------
1038
+ /**
1039
+ * Enforce a pack's `requires.packs`: every named dependency MUST already be
1040
+ * installed (and satisfy its optional semver RANGE) before we write anything.
1041
+ *
1042
+ * Installed packs are discovered by FILESYSTEM PRESENCE via `listInstalledPacks`
1043
+ * — deliberately NOT `modules.yaml`, which `installPack` no longer writes under
1044
+ * the v12+ layout (a modules.yaml-based check would silently ignore every modern
1045
+ * pack). Throws on the first unmet dependency so the install aborts with NO
1046
+ * partial state (it is called before installToPackages). No-op when
1047
+ * `requires.packs` is absent/empty, keeping legacy packs unaffected.
1048
+ */
1049
+ export function assertPackDependencies(hqRoot, pkg) {
1050
+ const deps = pkg.requires?.packs ?? [];
1051
+ if (deps.length === 0)
1052
+ return;
1053
+ const installed = new Map();
1054
+ for (const p of listInstalledPacks(hqRoot)) {
1055
+ // Key on the manifest name when readable, else the directory name.
1056
+ installed.set(p.manifest?.name ?? p.name, p.manifest?.version);
1057
+ }
1058
+ for (const dep of deps) {
1059
+ if (dep.name === pkg.name) {
1060
+ throw new Error(`Pack ${pkg.name} cannot list itself in requires.packs.`);
1061
+ }
1062
+ if (!installed.has(dep.name)) {
1063
+ throw new Error(`Pack ${pkg.name} requires ${dep.name}, which is not installed. ` +
1064
+ `Install it first, e.g.: hq install marketplace:${dep.name}`);
1065
+ }
1066
+ if (dep.version) {
1067
+ const have = installed.get(dep.name);
1068
+ if (!have || !semverSatisfies(have, dep.version, { includePrerelease: true })) {
1069
+ throw new Error(`Pack ${pkg.name} requires ${dep.name} ${dep.version}, but ` +
1070
+ `${dep.name}${have ? ` ${have}` : ' (version unknown)'} is installed.`);
1071
+ }
1072
+ }
1073
+ }
1074
+ }
1075
+ // ---------------------------------------------------------------------------
856
1076
  // Post-install get-started line (US-005)
857
1077
  // ---------------------------------------------------------------------------
858
1078
  /**
@@ -901,6 +1121,122 @@ async function confirmHooks(pkg, allowHooks) {
901
1121
  return /^(y|yes)$/i.test(answer.trim());
902
1122
  }
903
1123
  // ---------------------------------------------------------------------------
1124
+ // MCP confirmation (US-010)
1125
+ //
1126
+ // The MCP equivalent of confirmHooks. MCP servers are a high-trust surface: an
1127
+ // http/sse server points your agent at a REMOTE endpoint (typosquat / phishing
1128
+ // risk — the FULL url is disclosed verbatim so the operator can eyeball it), and
1129
+ // a stdio server runs a LOCAL binary with your shell permissions. We GATE on a
1130
+ // non-empty `contributes.mcp` (NEVER the advisory `capabilities` field, which is
1131
+ // reserved/unenforced — gating on it would let a pack omit a capability to dodge
1132
+ // the prompt) and render per-server transport + url-or-command + a count of ALL
1133
+ // servers being added.
1134
+ //
1135
+ // REDACTION: header/env VALUES are never printed. The manifests use
1136
+ // `${secret:NAME}` references (never literal Bearers), but we STILL run the
1137
+ // rendered prompt through `redactSecrets` against the raw header/env values so
1138
+ // that even a LITERAL secret could not leak. We show header KEY names with a
1139
+ // redacted value marker.
1140
+ //
1141
+ // FAIL-SAFE: a manifest that fails to load/parse here is NOT a bypass — we
1142
+ // surface the server name with a `[manifest unreadable]` note and STILL require
1143
+ // confirmation (a malformed manifest should make the operator MORE cautious).
1144
+ // ---------------------------------------------------------------------------
1145
+ /**
1146
+ * Render one declared MCP server's prompt line, redacting every header/env value.
1147
+ * EXPORTED so the acceptance/redaction self-test can assert no secret/Bearer
1148
+ * substring ever appears in the rendered output.
1149
+ */
1150
+ export function renderMcpServerLine(payloadDir, item) {
1151
+ let manifest;
1152
+ try {
1153
+ const abs = path.join(payloadDir, payloadFor('mcp', item));
1154
+ manifest = JSON.parse(fs.readFileSync(abs, 'utf-8'));
1155
+ if (manifest === null || typeof manifest !== 'object')
1156
+ throw new Error('not an object');
1157
+ }
1158
+ catch {
1159
+ // Fail-safe: do not crash the prompt; flag the server and keep requiring
1160
+ // confirmation.
1161
+ return ` - ${item} [manifest unreadable] — could not load mcp/${item}.json; treat with caution`;
1162
+ }
1163
+ const transport = manifest.type;
1164
+ // Accumulate every raw header/env value so a final redactSecrets pass scrubs
1165
+ // even a literal token that slipped past `${secret:}` (defense in depth — we
1166
+ // already replace the values with the redaction marker below).
1167
+ const rawValues = new Set();
1168
+ for (const v of Object.values(manifest.headers ?? {}))
1169
+ rawValues.add(v);
1170
+ for (const v of Object.values(manifest.env ?? {}))
1171
+ rawValues.add(v);
1172
+ // Header KEYS shown, VALUES always redacted (never the raw value).
1173
+ const headerKeys = Object.keys(manifest.headers ?? {});
1174
+ const headerStr = headerKeys.length > 0
1175
+ ? ` headers: { ${headerKeys.map((k) => `${k}: ${SECRET_REDACTION}`).join(', ')} }`
1176
+ : '';
1177
+ let line;
1178
+ if (transport === 'http' || transport === 'sse') {
1179
+ // FULL url verbatim — typosquat disclosure is the whole point.
1180
+ line =
1181
+ ` - ${item} [${transport}] url: ${manifest.url ?? '(missing)'}` +
1182
+ headerStr +
1183
+ ` (contacts a remote endpoint)`;
1184
+ }
1185
+ else {
1186
+ // stdio — local binary with shell permissions.
1187
+ const args = manifest.args ?? [];
1188
+ const argsStr = args.length > 0 ? ` args: [${args.join(', ')}]` : '';
1189
+ line =
1190
+ ` - ${item} [stdio] command: ${manifest.command ?? '(missing)'}` +
1191
+ argsStr +
1192
+ headerStr +
1193
+ ` (runs a local binary with your shell permissions)`;
1194
+ }
1195
+ // Final defense-in-depth scrub: even though values are already replaced with
1196
+ // the marker above, run the whole line through redactSecrets so a stray
1197
+ // literal token (e.g. inside command/args) can never reach the terminal.
1198
+ return redactSecrets(line, rawValues);
1199
+ }
1200
+ /**
1201
+ * Install-time MCP trust prompt. EXPORTED so the acceptance test can drive the
1202
+ * gate / bypass / non-TTY branches directly (capture stdout, assert no secret
1203
+ * substring leaks, assert deny returns false). See {@link confirmHooks} for the
1204
+ * voice/shape this mirrors.
1205
+ */
1206
+ export async function confirmMcp(pkg, payloadDir, allowMcp) {
1207
+ // GATE on non-empty `contributes.mcp` — mirrors confirmHooks. Never on the
1208
+ // advisory `capabilities` field.
1209
+ const servers = pkg.contributes.mcp ?? [];
1210
+ if (servers.length === 0)
1211
+ return true;
1212
+ if (allowMcp) {
1213
+ console.log(chalk.yellow(`--allow-mcp set; registering ${servers.length} MCP server(s) without prompting.`));
1214
+ return true;
1215
+ }
1216
+ console.log('');
1217
+ console.log(chalk.yellow(`Pack ${pkg.publisher}/${pkg.name} declares ${servers.length} MCP server(s):`));
1218
+ for (const item of servers) {
1219
+ console.log(chalk.yellow(renderMcpServerLine(payloadDir, item)));
1220
+ }
1221
+ console.log(chalk.yellow('These servers will be registered into your Claude and Codex agent configs.'));
1222
+ // Non-TTY guard (mirrors the secrets.ts pattern): do NOT call rl.question in a
1223
+ // non-interactive shell — it would hang. Instruct the operator to re-run with
1224
+ // --allow-mcp and DENY (return false -> abort, no partial state).
1225
+ if (!process.stdin.isTTY) {
1226
+ console.log(chalk.red(`Refusing to register ${servers.length} MCP server(s) without confirmation ` +
1227
+ 'in a non-interactive shell. Re-run with --allow-mcp to approve.'));
1228
+ return false;
1229
+ }
1230
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1231
+ const answer = await new Promise((resolve) => {
1232
+ rl.question('Install anyway? [y/N] ', (a) => {
1233
+ rl.close();
1234
+ resolve(a);
1235
+ });
1236
+ });
1237
+ return /^(y|yes)$/i.test(answer.trim());
1238
+ }
1239
+ // ---------------------------------------------------------------------------
904
1240
  // Conditional predicate
905
1241
  //
906
1242
  // `package.yaml:conditional` is arbitrary bash sourced from a remote pack.
@@ -935,6 +1271,119 @@ function evalConditional(expr) {
935
1271
  return r.status === 0;
936
1272
  }
937
1273
  // ---------------------------------------------------------------------------
1274
+ // MCP registration wiring (US-007) — call registerMcpServers from installPack.
1275
+ //
1276
+ // The registration CORE + Claude/Codex emitters live in mcp-registration.ts and
1277
+ // are fully unit-tested; this is the install-time SEAM that feeds them a real
1278
+ // manifest loader and a production, vault-backed secret resolver, with per-server
1279
+ // secret-deferral so a fresh install (before `/connect-shopify` has minted the
1280
+ // key) still SUCCEEDS, skipping only the server whose secret is not yet present.
1281
+ // ---------------------------------------------------------------------------
1282
+ /**
1283
+ * Build the install-time {@link SecretResolver} bound to the HQ vault's local
1284
+ * secrets-cache (`~/.hq/secrets-cache/<scope>/<NAME>`, AES-256-GCM, 0600, TTL'd).
1285
+ *
1286
+ * Active-company resolution at install time is INDIRECT by design: `hq install`
1287
+ * has no `--company` flag and runs OFFLINE (no token), so we cannot resolve a
1288
+ * single active company UID the way `hq run` / `hq secrets` do (via
1289
+ * `getEntityUid` over the network). Instead we probe EVERY cached scope
1290
+ * (`cmp_*`/`prs_*` — whichever has minted secrets locally) for the requested
1291
+ * name and return the first hit. This naturally resolves to whichever company
1292
+ * context just provisioned the secret (e.g. the one `/connect-shopify` minted
1293
+ * `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
1294
+ * key under a company or person entity.
1295
+ *
1296
+ * On MISS across all scopes (no cache, expired TTL, or key never minted) it
1297
+ * returns `null` — which is exactly what {@link registerMcpServers}'s
1298
+ * unresolvable-secret path keys off to defer that server gracefully. With no
1299
+ * cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
1300
+ * `null` for every name, the desired graceful-deferral behavior.
1301
+ */
1302
+ export function makeInstallSecretResolver() {
1303
+ return (name) => {
1304
+ for (const scope of listSecretCacheScopes()) {
1305
+ const value = readCache(scope, name);
1306
+ if (value !== null)
1307
+ return value;
1308
+ }
1309
+ return null;
1310
+ };
1311
+ }
1312
+ /**
1313
+ * Load + shape-validate + parse a pack's per-server MCP manifest from disk.
1314
+ * `validateMcpManifest(destDir, name)` is the SAME shape gate `validateManifest`
1315
+ * runs at install; it returns void and THROWS on bad shape, so we call it first
1316
+ * (re-using one source of truth), then parse `mcp/<name>.json` and return it
1317
+ * typed as {@link McpManifest}. `destDir` is the installed pack root
1318
+ * (`core/packages/<pkg>/`), the realpath `registerMcpServers` emits from.
1319
+ */
1320
+ function loadMcpManifestFrom(destDir, name) {
1321
+ validateMcpManifest(destDir, name); // throws on bad shape (void on success)
1322
+ const abs = path.join(destDir, 'mcp', `${name}.json`);
1323
+ return JSON.parse(fs.readFileSync(abs, 'utf-8'));
1324
+ }
1325
+ /**
1326
+ * Derive the per-server "needs secret" remedy command from the pack's
1327
+ * `initialization.entrypoint` (e.g. the vyg pack's `connect-shopify` →
1328
+ * `/connect-shopify`). Falls back to a generic "provision the secret then re-run
1329
+ * `hq install`" when the pack declares no entrypoint.
1330
+ */
1331
+ function mcpSecretRemedy(initialization) {
1332
+ const entrypoint = initialization?.entrypoint;
1333
+ if (typeof entrypoint === 'string' && entrypoint.trim() !== '') {
1334
+ const command = '/' + entrypoint.trim().replace(/^\/+/, '');
1335
+ return `run \`${command}\` to provision it, then re-run \`hq install\``;
1336
+ }
1337
+ return 'provision the secret then re-run `hq install`';
1338
+ }
1339
+ /** Pull the `${secret:NAME}` token out of a `cannot resolve ${secret:NAME}` error message. */
1340
+ function extractDeferredSecretName(message) {
1341
+ const m = /\$\{secret:([^}]+)\}/.exec(message);
1342
+ return m ? m[1] : 'a required secret';
1343
+ }
1344
+ /**
1345
+ * Register one pack's `contributes.mcp` servers into the shared Claude/Codex
1346
+ * agent configs, PER SERVER, so a single unresolvable `${secret:NAME}` defers
1347
+ * ONLY that server instead of aborting the whole install. Returns the registered
1348
+ * and skipped (deferred) server-name lists for the one-line summary.
1349
+ *
1350
+ * Per-server policy:
1351
+ * - SUCCESS → push to `registered`.
1352
+ * - McpManifestError matching
1353
+ * `/cannot resolve \$\{secret:/` → SKIP (push to `skipped`), warn on stderr,
1354
+ * install still succeeds (the key gets minted later, then a re-install wires it).
1355
+ * - ANY OTHER error → RE-THROW (abort install). Only the
1356
+ * unresolvable-secret case is swallowed.
1357
+ */
1358
+ function wireMcpServers(pkg, destDir) {
1359
+ const resolveSecret = makeInstallSecretResolver();
1360
+ const loadManifest = (name) => loadMcpManifestFrom(destDir, name);
1361
+ const registered = [];
1362
+ const skipped = [];
1363
+ for (const name of pkg.contributes.mcp ?? []) {
1364
+ try {
1365
+ // Per-server call: registerMcpServers throws on the FIRST unresolvable
1366
+ // secret, so calling it one name at a time lets us catch + continue.
1367
+ registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
1368
+ registered.push(name);
1369
+ }
1370
+ catch (e) {
1371
+ const isUnresolvableSecret = e instanceof McpManifestError && /cannot resolve \$\{secret:/.test(e.message);
1372
+ if (!isUnresolvableSecret) {
1373
+ // ConfigParseError / ConfigPermissionError / McpNameCollisionError / a
1374
+ // malformed-manifest McpManifestError / etc. — propagate, abort install.
1375
+ throw e;
1376
+ }
1377
+ skipped.push(name);
1378
+ const secret = extractDeferredSecretName(e.message);
1379
+ // Always stderr (never suppressed by --quiet): the user must see WHY a
1380
+ // server was deferred and exactly how to finish wiring it.
1381
+ process.stderr.write(`MCP server '${name}' needs secret ${secret} — ${mcpSecretRemedy(pkg.initialization)}.\n`);
1382
+ }
1383
+ }
1384
+ return { registered, skipped };
1385
+ }
1386
+ // ---------------------------------------------------------------------------
938
1387
  // Move into core/packages/ + run core/scripts/scan-packages.sh
939
1388
  // ---------------------------------------------------------------------------
940
1389
  /**
@@ -1099,6 +1548,11 @@ export async function installPack(source, opts = {}) {
1099
1548
  break;
1100
1549
  }
1101
1550
  const pkg = validateManifest(fetched.payloadDir, hqVersion);
1551
+ // M0 — pack-to-pack dependency pre-flight. Runs BEFORE any prompts or writes
1552
+ // so a missing/unsatisfied `requires.packs` aborts with no partial state, and
1553
+ // before we bother the operator with hook/MCP trust prompts for a pack that
1554
+ // can't install anyway.
1555
+ assertPackDependencies(hqRoot, pkg);
1102
1556
  if (pkg.conditional) {
1103
1557
  const allowed = await confirmConditional(pkg, opts.allowHooks ?? false);
1104
1558
  if (!allowed) {
@@ -1116,6 +1570,17 @@ export async function installPack(source, opts = {}) {
1116
1570
  say(chalk.red('Install aborted (hooks denied).'));
1117
1571
  return;
1118
1572
  }
1573
+ // US-010 — install-time MCP trust prompt. Positioned AFTER confirmHooks and
1574
+ // BEFORE installToPackages so a deny aborts with NO partial state (no config
1575
+ // written, no pack moved into core/packages, no registerMcpServers call). The
1576
+ // actual registration (US-007) now fires AFTER installToPackages +
1577
+ // runScanPackages below (pack on disk + symlinks wired), once this gate has
1578
+ // confirmed the servers — so a deny here means registration never runs.
1579
+ const mcpConfirmed = await confirmMcp(pkg, fetched.payloadDir, opts.allowMcp ?? false);
1580
+ if (!mcpConfirmed) {
1581
+ say(chalk.red('Install aborted (MCP servers denied).'));
1582
+ return;
1583
+ }
1119
1584
  const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
1120
1585
  // Under the v12+ HQ layout, packs live at `core/packages/<name>/` and
1121
1586
  // are tracked by filesystem presence — no `modules.yaml` write (that
@@ -1133,6 +1598,20 @@ export async function installPack(source, opts = {}) {
1133
1598
  const stampedSource = transport === 'marketplace' ? fetched.resolvedSource : source;
1134
1599
  stampInstallSource(destDir, stampedSource);
1135
1600
  runScanPackages(hqRoot, { quiet: opts.quiet });
1601
+ // US-007 — register `contributes.mcp` servers into the shared Claude/Codex
1602
+ // agent configs. Fires HERE (after installToPackages + runScanPackages, i.e.
1603
+ // pack on disk + symlinks wired) and only once the confirmMcp gate above
1604
+ // approved them. The `mcp` key is wire:'merge' — NEVER symlinked; this merge
1605
+ // is its only wiring path. registerMcpServers honors the
1606
+ // HQ_DISABLE_MCP_REGISTRATION kill-switch internally, so we never re-check it.
1607
+ // Per-server secret-deferral keeps a fresh install (key not yet minted) at
1608
+ // exit 0, deferring only the unresolvable server (see wireMcpServers).
1609
+ if (Array.isArray(pkg.contributes.mcp) && pkg.contributes.mcp.length > 0) {
1610
+ const { registered, skipped } = wireMcpServers(pkg, destDir);
1611
+ // One-line summary (server NAMES only — never resolved secret VALUES).
1612
+ say(chalk.dim(` MCP servers: registered [${registered.join(', ')}]; ` +
1613
+ `skipped [${skipped.join(', ')}].`));
1614
+ }
1136
1615
  say(chalk.green(`\nOK Installed ${pkg.name}@${pkg.version} -> ${path.relative(hqRoot, destDir)}/`));
1137
1616
  say(chalk.dim(` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
1138
1617
  `contribution(s) into host-side paths.`));
@@ -1150,4 +1629,4 @@ export async function installPack(source, opts = {}) {
1150
1629
  }
1151
1630
  }
1152
1631
  //# sourceMappingURL=pack-install.js.map
1153
- //# debugId=cb8b5583-b60f-52a5-802d-befc33f2a07b
1632
+ //# debugId=0ec07d3a-ee86-51fd-adbd-2608c620ae99