@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
@@ -1,26 +1,97 @@
1
1
  /**
2
- * Content-pack contribution helpers -- the single source of truth (in TS) for
3
- * the `contributes.* -> host-path` symlink mapping that `hq install` wires via
2
+ * Content-pack contribution helpers -- the SINGLE SOURCE OF TRUTH (US-003) for
3
+ * the `contributes.* -> host` mapping that `hq install` wires via
4
4
  * `core/scripts/scan-packages.sh`.
5
5
  *
6
6
  * `pack-install.ts` only INSTALLS content packs (into `core/packages/<name>/`,
7
7
  * tracked by filesystem presence -- there is no registry file). The list /
8
8
  * update / uninstall lifecycle in `commands/packs.ts` needs to reason about the
9
9
  * SAME mapping so it can report link health and cleanly un-wire a pack without
10
- * leaving dangling symlinks. That mapping is duplicated today in two places:
10
+ * leaving dangling symlinks.
11
11
  *
12
- * - core/scripts/scan-packages.sh (bash `case`, the wiring authority)
13
- * - pack-install.ts validateManifest's `subpaths` record (payload validation)
12
+ * Historically that mapping was RESTATED in five places (the scan-packages.sh
13
+ * bash `case`, pack-install.ts:validateManifest's `subpaths` record,
14
+ * `linkFor`'s switch, `contributionLinks`, and the `PackContributeKey` union),
15
+ * drift-guarded only by a substring check. US-003 collapses it to ONE
16
+ * declarative table -- `CONTRIBUTION_TABLE` below -- with a row per key
17
+ * `{ payload, host, wire }`:
14
18
  *
15
- * This module re-encodes it once for TS callers. A parity test
16
- * (`pack-contributions.test.ts`) asserts it matches scan-packages.sh's `case`
17
- * arms so the three copies cannot drift.
19
+ * - `PackContributeKey` is DERIVED from the table keys (see types.ts).
20
+ * - `linkFor` / `contributionLinks` / the `subpaths` validator READ the table.
21
+ * - `core/scripts/scan-packages.sh` reads a generated data block emitted from
22
+ * this same table (regenerate via `scripts/generate-scan-packages-table.mjs`).
23
+ *
24
+ * A parity test (`pack-contributions.test.ts`) asserts FULL key-set +
25
+ * payload-suffix + wire-mode equivalence across every surface, so the copies
26
+ * cannot drift.
27
+ *
28
+ * `wire` discriminates HOW a contribution reaches the host:
29
+ * - `symlink`: a single `ln -s` into a well-known host directory (every
30
+ * contribution type that shipped before MCP).
31
+ * - `merge`: the contribution is MERGED into a shared host config rather than
32
+ * symlinked (the `mcp` row -- registered into the Claude + Codex agent
33
+ * configs). The merge WIRING is delivered in US-004/US-005; US-003 only
34
+ * DECLARES the row as data so the table is the single source and the parity
35
+ * test guards the real invariant. `symlink`-only readers (linkFor,
36
+ * contributionLinks, the symlink validator, scan-packages) must SKIP
37
+ * `merge` rows -- they never produce a symlink.
18
38
  */
19
39
 
20
40
  import * as fs from 'fs';
21
41
  import * as path from 'path';
22
42
  import * as yaml from 'js-yaml';
23
43
  import type { PackManifest, PackContributeKey } from '../types.js';
44
+ import { CONTRIBUTION_TABLE, payloadFor, type WireMode } from './contribution-table.js';
45
+ import {
46
+ unregisterMcpServers,
47
+ type UnregisterServerResult,
48
+ type SafeWriteEnv,
49
+ type AcquireLockOptions,
50
+ } from '../commands/mcp-registration.js';
51
+
52
+ // NOTE on dependency direction: this module imports from ../commands/mcp-registration.js
53
+ // to un-register `wire:merge` (mcp) servers on uninstall. mcp-registration.ts imports
54
+ // ONLY fs/os/path/smol-toml (it does NOT import from pack-contributions.ts), so there
55
+ // is NO import cycle. If that ever changes, invert via a callback injected by packs.ts.
56
+
57
+ export { CONTRIBUTION_TABLE };
58
+ export type { WireMode };
59
+
60
+ /**
61
+ * Where a contributes key is ROUTED (US-005). Reads the single-source table's
62
+ * `wire` field — `'symlink'` keys go through `linkFor`/`contributionLinks` (a
63
+ * host `ln -s`); `'merge'` keys (e.g. `mcp`) go through the `registerMcpServers`
64
+ * seam in `commands/mcp-registration.ts` and are NEVER symlinked.
65
+ *
66
+ * This is the explicit, code-enforced discriminator behind the invariant that
67
+ * merge keys never reach the symlink path: callers fan out on the return value
68
+ * rather than relying on `linkFor` happening to return `null`.
69
+ */
70
+ export function routeContribution(key: PackContributeKey): WireMode {
71
+ return CONTRIBUTION_TABLE[key].wire;
72
+ }
73
+
74
+ /**
75
+ * The subset of a pack's declared `contributes` keys that are `wire: 'merge'`
76
+ * (e.g. `mcp`) — the keys that MUST be routed to `registerMcpServers` (US-006)
77
+ * and MUST NOT be symlinked. Unknown keys and non-array/empty values are
78
+ * ignored, mirroring `contributionLinks`. Returned in `contributes` iteration
79
+ * order, de-duplicated.
80
+ */
81
+ export function mergeKeys(
82
+ contributes: Partial<Record<PackContributeKey, string[]>>,
83
+ ): PackContributeKey[] {
84
+ const out: PackContributeKey[] = [];
85
+ for (const [key, items] of Object.entries(contributes) as [
86
+ PackContributeKey,
87
+ unknown,
88
+ ][]) {
89
+ if (!(key in CONTRIBUTION_TABLE)) continue; // unknown key -> ignore
90
+ if (!Array.isArray(items) || items.length === 0) continue;
91
+ if (routeContribution(key) === 'merge' && !out.includes(key)) out.push(key);
92
+ }
93
+ return out;
94
+ }
24
95
 
25
96
  /** A single symlink a pack contributes: dst (host path) -> src (inside pack). */
26
97
  export interface WiredLink {
@@ -37,58 +108,40 @@ export type LinkStatus =
37
108
  | 'foreign'; // dst exists but points elsewhere or is a real file (collision)
38
109
 
39
110
  /**
40
- * Map one contributes entry to its source/host paths. MUST stay in lockstep
41
- * with scan-packages.sh:wire_one_package and pack-install.ts:validateManifest.
111
+ * Map one SYMLINK contributes entry to its source/host paths, READING the
112
+ * single-source `CONTRIBUTION_TABLE` (US-003). The src is the expanded pack
113
+ * payload; the dst is `<host-dir>/<payload-basename>` so per-key suffixes
114
+ * (`.md`, `.sh`, none) carry through.
115
+ *
116
+ * Returns `null` for a `wire: 'merge'` key (e.g. `mcp`): a merge contribution
117
+ * is not a symlink and has no `WiredLink`. Callers that only handle symlinks
118
+ * (contributionLinks/unwirePack) skip those keys.
42
119
  */
43
120
  function linkFor(
44
121
  hqRoot: string,
45
122
  packDir: string,
46
123
  key: PackContributeKey,
47
124
  item: string,
48
- ): WiredLink {
49
- let srcRel: string;
50
- let dstRel: string;
51
- switch (key) {
52
- case 'workers':
53
- srcRel = path.join('workers', item);
54
- dstRel = path.join('core', 'workers', 'public', item);
55
- break;
56
- case 'knowledge':
57
- srcRel = path.join('knowledge', item);
58
- dstRel = path.join('core', 'knowledge', 'public', item);
59
- break;
60
- case 'skills':
61
- srcRel = path.join('skills', item);
62
- dstRel = path.join('.claude', 'skills', item);
63
- break;
64
- case 'commands':
65
- srcRel = path.join('commands', `${item}.md`);
66
- dstRel = path.join('.claude', 'commands', `${item}.md`);
67
- break;
68
- case 'hooks':
69
- srcRel = path.join('hooks', `${item}.sh`);
70
- dstRel = path.join('.claude', 'hooks', `${item}.sh`);
71
- break;
72
- case 'policies':
73
- srcRel = path.join('policies', `${item}.md`);
74
- dstRel = path.join('core', 'policies', `${item}.md`);
75
- break;
76
- case 'scripts':
77
- srcRel = path.join('scripts', item);
78
- dstRel = path.join('core', 'scripts', item);
79
- break;
80
- }
125
+ ): WiredLink | null {
126
+ const row = CONTRIBUTION_TABLE[key];
127
+ if (row.wire !== 'symlink') return null;
128
+ const payloadRel = payloadFor(key, item); // e.g. commands/foo.md
129
+ // dst keeps the payload's basename (so the suffix carries through) under the
130
+ // host directory declared in the table.
131
+ const dstRel = path.join(row.host, path.basename(payloadRel));
81
132
  return {
82
133
  key,
83
134
  item,
84
- src: path.join(packDir, srcRel),
135
+ src: path.join(packDir, payloadRel),
85
136
  dst: path.join(hqRoot, dstRel),
86
137
  };
87
138
  }
88
139
 
89
140
  /**
90
- * Every symlink a pack's `contributes` block declares. Empty subfields and
91
- * non-array values are ignored, mirroring scan-packages.sh.
141
+ * Every SYMLINK a pack's `contributes` block declares. Empty subfields and
142
+ * non-array values are ignored, mirroring scan-packages.sh. `wire: 'merge'`
143
+ * keys (e.g. `mcp`) produce no symlink and are skipped here -- their host
144
+ * effect (a config merge) is handled separately (US-004/US-005).
92
145
  */
93
146
  export function contributionLinks(
94
147
  hqRoot: string,
@@ -100,10 +153,17 @@ export function contributionLinks(
100
153
  PackContributeKey,
101
154
  unknown,
102
155
  ][]) {
156
+ if (!(key in CONTRIBUTION_TABLE)) continue; // unknown key -> ignore
103
157
  if (!Array.isArray(items)) continue;
158
+ // INVARIANT (US-005): merge keys are NEVER symlinked. They route to
159
+ // registerMcpServers (commands/mcp-registration.ts) instead. Skip them
160
+ // explicitly here -- not just via linkFor returning null -- so the symlink
161
+ // path provably excludes them.
162
+ if (routeContribution(key) === 'merge') continue;
104
163
  for (const item of items) {
105
164
  if (typeof item !== 'string' || item.length === 0) continue;
106
- links.push(linkFor(hqRoot, packDir, key, item));
165
+ const link = linkFor(hqRoot, packDir, key, item);
166
+ if (link) links.push(link); // null for merge keys -- skip
107
167
  }
108
168
  }
109
169
  return links;
@@ -256,6 +316,76 @@ export function unwirePack(
256
316
  return result;
257
317
  }
258
318
 
319
+ // ---------------------------------------------------------------------------
320
+ // MCP un-wiring (US-009) — the `wire: 'merge'` parallel to symlink un-wiring.
321
+ //
322
+ // `unwirePack` above only removes SYMLINKS: it iterates `contributionLinks`, which
323
+ // SKIPS every `wire: 'merge'` key (e.g. `mcp`). So a pack's MCP servers — registered
324
+ // into ~/.claude.json + ~/.codex/config.toml, NOT symlinked — are INVISIBLE to the
325
+ // symlink unwire and would leak after uninstall. `unwirePackMcp` is the parallel
326
+ // path: for each name in `contributes.mcp` it calls `unregisterMcpServers` (US-009),
327
+ // which removes ONLY entries whose `_hqPack` provenance matches THIS pack and
328
+ // skip-and-warns on any foreign/unstamped same-named entry.
329
+ //
330
+ // Kept as a SEPARATE, composable call from `unwirePack` (not folded into it) so the
331
+ // existing `unwirePack` signature + tests stay intact and each path is testable in
332
+ // isolation. The uninstall command composes both (symlink unwire + MCP unwire).
333
+ // ---------------------------------------------------------------------------
334
+
335
+ /** The result of {@link unwirePackMcp}: the per-server un-registration outcomes (empty when no `mcp` keys). */
336
+ export interface UnwireMcpResult {
337
+ /** One {@link UnregisterServerResult} per declared `contributes.mcp` server, in declaration order. */
338
+ servers: UnregisterServerResult[];
339
+ }
340
+
341
+ /** Tuning passthrough for {@link unwirePackMcp} (tests inject a tmpdir `env.home` + tiny lock timeouts). */
342
+ export interface UnwirePackMcpOptions {
343
+ /** Injectable env — tests MUST pass a tmpdir `home` so the real ~/.claude.json / ~/.codex are untouched. */
344
+ env?: Partial<SafeWriteEnv>;
345
+ /** Lock tuning (tests use tiny timeouts). */
346
+ lock?: AcquireLockOptions;
347
+ /** Backup timestamp override (deterministic tests). */
348
+ stamp?: string;
349
+ }
350
+
351
+ /**
352
+ * Un-register the `wire: 'merge'` (MCP) servers a pack declares — the parallel to
353
+ * {@link unwirePack}'s symlink removal. For each name in `mergeKeys(contributes)` →
354
+ * `contributes.<mergeKey>` (today only `mcp`), this delegates to
355
+ * {@link unregisterMcpServers}, which removes ONLY entries stamped with THIS pack's
356
+ * `_hqPack` provenance and SKIPS-AND-WARNS on a foreign/unstamped same-named entry.
357
+ *
358
+ * No-ops cleanly when the pack declares no merge keys (returns `{ servers: [] }`),
359
+ * when the runtime config is absent (Codex-less host → first-class skip; missing
360
+ * ~/.claude.json → `absent`), and on a re-run (already-removed → `absent`). It does
361
+ * NOT throw on a foreign entry — uninstall of one pack must not abort on a server
362
+ * another pack (or the user) owns.
363
+ *
364
+ * @param packName the pack being uninstalled — the provenance to match
365
+ * @param contributes the pack's `contributes` block (its `mcp` list drives removal)
366
+ */
367
+ export function unwirePackMcp(
368
+ packName: string,
369
+ contributes: Partial<Record<PackContributeKey, string[]>>,
370
+ options?: UnwirePackMcpOptions,
371
+ ): UnwireMcpResult {
372
+ const servers: UnregisterServerResult[] = [];
373
+ for (const key of mergeKeys(contributes)) {
374
+ const names = (contributes[key] ?? []).filter(
375
+ (n): n is string => typeof n === 'string' && n.length > 0,
376
+ );
377
+ if (names.length === 0) continue;
378
+ servers.push(
379
+ ...unregisterMcpServers(packName, names, {
380
+ env: options?.env,
381
+ lock: options?.lock,
382
+ stamp: options?.stamp,
383
+ }),
384
+ );
385
+ }
386
+ return { servers };
387
+ }
388
+
259
389
  // ---------------------------------------------------------------------------
260
390
  // Host introspection: hqVersion + recommended_packages catalog
261
391
  // ---------------------------------------------------------------------------
@@ -142,6 +142,28 @@ export function writeCache(
142
142
  }
143
143
  }
144
144
 
145
+ /**
146
+ * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
147
+ * secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
148
+ * registration) that have no `--company` flag and no network token and so cannot
149
+ * resolve a single active company UID up front: they instead probe every cached
150
+ * scope for a given secret name. Returns `[]` when the cache root is absent or
151
+ * unreadable (the desired graceful-deferral behavior — no scopes, no hits).
152
+ */
153
+ export function listSecretCacheScopes(): string[] {
154
+ try {
155
+ return fs
156
+ .readdirSync(CACHE_DIR, { withFileTypes: true })
157
+ .filter((e) => e.isDirectory())
158
+ .map((e) => e.name)
159
+ // Only real entity scopes (cmp_*/prs_*); validateInputs in readCache also
160
+ // rejects anything with `/` or `..`, so this is belt-and-suspenders.
161
+ .filter((name) => !name.startsWith("."));
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
145
167
  export function removeCacheEntry(companyUid: string, name: string): void {
146
168
  if (!validateInputs(companyUid, name)) return;
147
169
  const filePath = path.join(CACHE_DIR, companyUid, name);
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # smoke-install-mcp.sh — TRUE end-to-end smoke test of `hq install` for
4
+ # MCP-bearing packs, in a fully isolated sandbox.
5
+ #
6
+ # Unlike the vitest suite (which unit-tests mcp-registration.ts against an
7
+ # injected $HOME), this drives the REAL built `hq` CLI through a REAL
8
+ # `hq install` against REAL packs, so it catches WIRING gaps unit tests miss —
9
+ # e.g. "registerMcpServers is implemented + tested but installPack never calls
10
+ # it" (the exact bug this harness first caught).
11
+ #
12
+ # It NEVER touches the developer's real ~/.claude.json, ~/.mcp.json, or
13
+ # ~/.codex/config.toml — everything lives under a mktemp sandbox HOME, and the
14
+ # install targets a sandbox HQ root.
15
+ #
16
+ # Two packs, two paths:
17
+ # A) hq-pack-smoke-mcp (no-secret fixture) → MUST register into both configs
18
+ # B) vyg-shopify-hq-pack (needs ${secret:VYG_API_KEY}) → MUST defer gracefully
19
+ # (install succeeds, server NOT registered, "connect first" warning shown)
20
+ #
21
+ # Usage: bash test/e2e/smoke-install-mcp.sh [--keep]
22
+ # Exit 0 = all checks pass. Exit 1 = at least one failed.
23
+ # ---------------------------------------------------------------------------
24
+ set -uo pipefail
25
+
26
+ CLI_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
27
+ HQ_ROOT_REAL="$(cd "$CLI_REPO/../../.." && pwd)" # …/HQ
28
+ CLI="$CLI_REPO/dist/index.js"
29
+ FIXTURE="$CLI_REPO/test/fixtures/hq-pack-smoke-mcp" # no-secret pack
30
+ VYG="$HQ_ROOT_REAL/repos/private/vyg-shopify-hq-pack" # secret-bearing pack
31
+ KEEP=0; [ "${1:-}" = "--keep" ] && KEEP=1
32
+
33
+ pass=0; fail=0
34
+ ok() { echo " ✓ $1"; pass=$((pass+1)); }
35
+ bad() { echo " ✗ $1"; fail=$((fail+1)); }
36
+
37
+ [ -f "$CLI" ] || { echo "FATAL: build first (npm run build) — missing $CLI"; exit 2; }
38
+ [ -d "$FIXTURE" ] || { echo "FATAL: fixture pack missing at $FIXTURE"; exit 2; }
39
+ command -v node >/dev/null || { echo "FATAL: node not on PATH"; exit 2; }
40
+
41
+ SBX="$(mktemp -d -t hq-smoke-mcp.XXXXXX)"
42
+ SBX_HOME="$SBX/home"; SBX_HQ="$SBX/hq"
43
+ cleanup() { [ "$KEEP" = 1 ] && { echo "sandbox kept: $SBX"; return; }; rm -rf "$SBX"; }
44
+ trap cleanup EXIT
45
+
46
+ mkdir -p "$SBX_HOME/.codex" "$SBX_HQ/core/packages" "$SBX_HQ/core/workers/public" \
47
+ "$SBX_HQ/core/knowledge/public/hq-core" "$SBX_HQ/core/policies" \
48
+ "$SBX_HQ/core/hooks" "$SBX_HQ/.claude/skills" "$SBX_HQ/.claude/commands" \
49
+ "$SBX_HQ/companies"
50
+ cp -R "$HQ_ROOT_REAL/core/scripts" "$SBX_HQ/core/scripts"
51
+ # Pin the sandbox host version at the M1 floor (>=15.1.0). Copying the live
52
+ # core.yaml is unreliable — hq-sync can reset it below the floor (e.g. 15.0.24),
53
+ # which makes the floor check CORRECTLY refuse the test packs (they require
54
+ # >=15.1.0). This harness exercises the install path; floor-refusal has its own
55
+ # unit test. Pin a satisfying version so the install path is what's under test.
56
+ printf 'hqVersion: "15.1.0"\n' > "$SBX_HQ/core/core.yaml"
57
+ cp "$HQ_ROOT_REAL/core/knowledge/public/hq-core/mcp-manifest.schema.json" \
58
+ "$SBX_HQ/core/knowledge/public/hq-core/mcp-manifest.schema.json"
59
+ printf 'companies: {}\n' > "$SBX_HQ/companies/manifest.yaml"
60
+
61
+ # pre-existing non-HQ servers that MUST survive every install/uninstall
62
+ echo '{ "mcpServers": { "figma": { "type": "http", "url": "https://figma.example.test/mcp" } } }' > "$SBX_HOME/.claude.json"
63
+ printf '# user existing codex config\n[mcp_servers.node_repl]\ncommand = "node"\nargs = ["--repl"]\n' > "$SBX_HOME/.codex/config.toml"
64
+
65
+ # Always run from inside the sandbox HQ root. The CLI resolves the HQ root by
66
+ # walking up from cwd (utils/manifest.js findHqRoot), NOT from $HQ_ROOT — so the
67
+ # cwd is what actually scopes install/uninstall. (We still export HQ_ROOT for the
68
+ # code paths that DO honor it, e.g. scan-packages.) Without the cd, `packs
69
+ # uninstall` resolves the developer's real HQ root and silently no-ops.
70
+ run_hq() { ( cd "$SBX_HQ" && HOME="$SBX_HOME" HQ_ROOT="$SBX_HQ" node "$CLI" "$@" ); }
71
+ claude_has() { node -e "try{const j=require('$SBX_HOME/.claude.json');process.stdout.write(j.mcpServers&&j.mcpServers['$1']?'1':'0')}catch(e){process.stdout.write('0')}"; }
72
+ codex_has() { c=$(grep -c "\[mcp_servers.$1\]" "$SBX_HOME/.codex/config.toml" 2>/dev/null); echo "${c:-0}"; }
73
+
74
+ echo "=== HQ MCP install smoke test ==="
75
+ echo "sandbox: $SBX"; echo
76
+
77
+ # ---- A) NO-SECRET FIXTURE → must register ---------------------------------
78
+ echo "[A] install no-secret fixture (hq-pack-smoke-mcp) — must REGISTER"
79
+ OUT="$(cd "$SBX_HQ" && run_hq install "$FIXTURE" --allow-mcp --allow-hooks 2>&1)"; rc=$?
80
+ echo "$OUT" | sed 's/^/ /'
81
+ [ $rc -eq 0 ] && ok "install exited 0" || bad "install exited $rc"
82
+ [ "$(claude_has smoke-http)" = "1" ] && ok "smoke-http registered in ~/.claude.json" || bad "smoke-http MISSING from ~/.claude.json"
83
+ [ "$(codex_has smoke-http)" -ge 1 ] && ok "smoke-http registered in ~/.codex/config.toml" || bad "smoke-http MISSING from ~/.codex/config.toml"
84
+ [ "$(claude_has figma)" = "1" ] && ok "figma preserved" || bad "figma LOST"
85
+ [ "$(codex_has node_repl)" -ge 1 ] && ok "node_repl table preserved" || bad "node_repl LOST"
86
+ if grep -rqiE 'X-Smoke.*static-not-a-secret' "$SBX_HOME/.claude.json"; then ok "static header written (http client usable)"; else bad "static header missing from registered server"; fi
87
+
88
+ echo "[A.2] hq mcp status --json reports it"
89
+ ST="$(run_hq mcp status --json 2>&1)"
90
+ echo "$ST" | grep -q 'smoke-http' && ok "mcp status reports smoke-http" || bad "mcp status missing smoke-http"
91
+
92
+ echo "[A.3] uninstall round-trip"
93
+ run_hq packs uninstall hq-pack-smoke-mcp --yes >/dev/null 2>&1 || run_hq packs uninstall hq-pack-smoke-mcp >/dev/null 2>&1
94
+ [ "$(claude_has smoke-http)" = "0" ] && ok "smoke-http removed on uninstall" || bad "smoke-http still present after uninstall"
95
+ [ "$(claude_has figma)" = "1" ] && ok "figma still preserved after uninstall" || bad "figma lost during uninstall"
96
+
97
+ # ---- B) VYG PACK (needs a vault secret) → must DEFER gracefully -----------
98
+ echo
99
+ echo "[B] install vyg pack (needs \${secret:VYG_API_KEY}, not in vault) — must DEFER gracefully"
100
+ if [ -d "$VYG" ]; then
101
+ OUT="$(cd "$SBX_HQ" && run_hq install "$VYG" --allow-mcp --allow-hooks 2>&1)"; rc=$?
102
+ echo "$OUT" | sed 's/^/ /'
103
+ [ $rc -eq 0 ] && ok "install exited 0 (did not abort on the missing secret)" || bad "install aborted (rc=$rc) on missing secret"
104
+ [ "$(claude_has vyg-shopify)" = "0" ] && ok "vyg-shopify correctly DEFERRED (not registered without its key)" || bad "vyg-shopify registered despite missing secret"
105
+ echo "$OUT" | grep -qiE "VYG_API_KEY|connect-shopify|needs secret" && ok "clear 'provision the key' guidance shown" || bad "no deferral guidance shown to the user"
106
+ [ "$(claude_has figma)" = "1" ] && ok "figma still preserved through the deferred install" || bad "figma LOST during deferred install"
107
+ else
108
+ echo " · vyg pack not present — skipping deferral checks"
109
+ fi
110
+
111
+ echo
112
+ echo "=== RESULT: $pass passed · $fail failed ==="
113
+ [ $fail -eq 0 ] && exit 0 || exit 1
@@ -0,0 +1 @@
1
+ { "type": "http", "url": "https://smoke.example.test/mcp", "headers": { "X-Smoke": "static-not-a-secret" } }
@@ -0,0 +1,11 @@
1
+ name: hq-pack-smoke-mcp
2
+ version: 0.0.1
3
+ publisher: '@indigoai-us'
4
+ access: public
5
+ description: 'No-secret MCP pack — e2e smoke fixture for install registration.'
6
+ requires:
7
+ hqCore: '>=15.1.0'
8
+ capabilities: [network]
9
+ contributes:
10
+ mcp:
11
+ - smoke-http