@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
package/src/index.ts CHANGED
@@ -44,7 +44,9 @@ import { registerSourcesCommand } from "./commands/sources.js";
44
44
  import { registerSignalsCommand } from "./commands/signals.js";
45
45
  import { registerReindexCommand } from "./commands/reindex.js";
46
46
  import { registerRescueCommand } from "./commands/rescue.js";
47
+ import { registerMcpCommand } from "./commands/mcp-status.js";
47
48
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
49
+ import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
48
50
  import {
49
51
  maybeWarnNewVersion,
50
52
  refreshVersionCache,
@@ -198,6 +200,11 @@ registerReindexCommand(program);
198
200
  // shipped from @indigoai-us/hq-cloud.
199
201
  registerRescueCommand(program);
200
202
 
203
+ // MCP pack observability (subcommand group — `hq mcp status`). Read-only
204
+ // provenance-based status across BOTH Claude + Codex runtimes (reads `_hqPack`
205
+ // off the configs, NOT linkStatus), with secret-redacted output + `--json`.
206
+ registerMcpCommand(program);
207
+
201
208
  (async () => {
202
209
  try {
203
210
  Sentry.addBreadcrumb({
@@ -215,7 +222,17 @@ registerRescueCommand(program);
215
222
  }
216
223
  await program.parseAsync();
217
224
  } catch (err) {
218
- Sentry.captureException(err);
225
+ // A full disk / exhausted quota / read-only filesystem is the user's
226
+ // machine, not an HQ code defect. Surface a clear, actionable message and
227
+ // skip Sentry capture so one full disk doesn't flood the tracker with
228
+ // identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
229
+ // to Sentry and still exit 1.
230
+ const envMsg = environmentalFsErrorMessage(err);
231
+ if (envMsg) {
232
+ process.stderr.write(`hq: ${envMsg}\n`);
233
+ } else {
234
+ Sentry.captureException(err);
235
+ }
219
236
  process.exitCode = 1;
220
237
  } finally {
221
238
  // Release health: finalize the per-run session before the flush.
package/src/types.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  * knowledge/public/hq-core/package-yaml-spec.md)
9
9
  */
10
10
 
11
+ import type { ContributionKey } from './utils/contribution-table.js';
12
+
11
13
  export type LegacyStrategy = 'link' | 'merge' | 'copy';
12
14
  export type SyncStrategy = LegacyStrategy | 'package';
13
15
  export type AccessLevel = 'public' | 'team' | `role:${string}`;
@@ -73,14 +75,13 @@ export interface SyncResult {
73
75
  // covers the entitlement-gated registry flow (`hq packages install <slug>`).
74
76
  // ---------------------------------------------------------------------------
75
77
 
76
- export type PackContributeKey =
77
- | 'workers'
78
- | 'knowledge'
79
- | 'skills'
80
- | 'commands'
81
- | 'hooks'
82
- | 'policies'
83
- | 'scripts';
78
+ /**
79
+ * The `contributes.*` keys a pack may declare. DERIVED (US-003) from the single
80
+ * declarative contribution registry in `utils/contribution-table.ts` -- adding
81
+ * a contribution type is one row there, and this union updates automatically.
82
+ * Do NOT restate the keys here.
83
+ */
84
+ export type PackContributeKey = ContributionKey;
84
85
 
85
86
  /**
86
87
  * Pack authorship attribution (US-001). OPTIONAL and backwards-compatible —
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The contribution registry -- the SINGLE declarative source of truth (US-003)
3
+ * for the `contributes.* -> host` mapping. Every other surface DERIVES from
4
+ * this table:
5
+ *
6
+ * - `PackContributeKey` (types.ts) = `keyof typeof CONTRIBUTION_TABLE`
7
+ * - `linkFor` / `contributionLinks` read `payload` + `host`
8
+ * - `validateManifest`'s payload check reads `payload`
9
+ * - `core/scripts/scan-packages.sh` reads a data block GENERATED
10
+ * from this table
11
+ * (scripts/generate-scan-packages-table.mjs)
12
+ *
13
+ * Adding a contribution type is ONE row here, not a five-site edit. The parity
14
+ * test (`pack-contributions.test.ts`) asserts every surface agrees on the full
15
+ * key-set, the payload suffix, and the wire mode.
16
+ *
17
+ * Row fields:
18
+ * - `payload`: path INSIDE the pack, with the literal token `{item}` for the
19
+ * declared name. The suffix after `{item}` (e.g. `.md`, `.json`, or none)
20
+ * IS the load-bearing per-key shape.
21
+ * - `host`: for `wire: 'symlink'`, the host DIRECTORY (relative to the HQ
22
+ * root) the symlink is created under -- the symlink dst is
23
+ * `<host>/<expanded-item-basename>`. For `wire: 'merge'`, a non-path
24
+ * SENTINEL describing the merge target (e.g. `merge:claude+codex`); it is
25
+ * NEVER used as a filesystem path.
26
+ * - `wire`: `symlink` (a single `ln -s` into `host`) or `merge` (merged into
27
+ * a shared host config; see US-004/US-005). Symlink-only readers MUST skip
28
+ * `merge` rows.
29
+ *
30
+ * NOTE: the `mcp` row is DECLARED here as data only. US-003 does NOT wire any
31
+ * MCP behavior -- the merge engine lands in US-004/US-005. Declaring it now
32
+ * makes the table the single source so adding the merge wiring is a code change
33
+ * against an already-present row, and the parity test guards it from day one.
34
+ */
35
+
36
+ export type WireMode = 'symlink' | 'merge';
37
+
38
+ export interface ContributionRow {
39
+ /** Path inside the pack, with `{item}` substituted for the declared name. */
40
+ payload: string;
41
+ /**
42
+ * `wire: 'symlink'` -> host directory the symlink lives under (HQ-root
43
+ * relative). `wire: 'merge'` -> a non-path sentinel for the merge target.
44
+ */
45
+ host: string;
46
+ wire: WireMode;
47
+ }
48
+
49
+ /**
50
+ * The 8-key contribution registry. `as const` so `keyof typeof` yields the
51
+ * exact literal key union consumed by `PackContributeKey`.
52
+ */
53
+ export const CONTRIBUTION_TABLE = {
54
+ workers: { payload: 'workers/{item}', host: 'core/workers/public', wire: 'symlink' },
55
+ knowledge: { payload: 'knowledge/{item}', host: 'core/knowledge/public', wire: 'symlink' },
56
+ skills: { payload: 'skills/{item}', host: '.claude/skills', wire: 'symlink' },
57
+ commands: { payload: 'commands/{item}.md', host: '.claude/commands', wire: 'symlink' },
58
+ hooks: { payload: 'hooks/{item}.sh', host: '.claude/hooks', wire: 'symlink' },
59
+ policies: { payload: 'policies/{item}.md', host: 'core/policies', wire: 'symlink' },
60
+ scripts: { payload: 'scripts/{item}', host: 'core/scripts', wire: 'symlink' },
61
+ // wire:merge -- DECLARED as data (US-003); the merge engine ships in
62
+ // US-004/US-005. Symlink readers skip this row.
63
+ mcp: { payload: 'mcp/{item}.json', host: 'merge:claude+codex', wire: 'merge' },
64
+ } as const satisfies Record<string, ContributionRow>;
65
+
66
+ /** The declared contribution keys, derived once from the table. */
67
+ export type ContributionKey = keyof typeof CONTRIBUTION_TABLE;
68
+
69
+ /** All contribution keys, in declaration order. */
70
+ export const CONTRIBUTION_KEYS = Object.keys(CONTRIBUTION_TABLE) as ContributionKey[];
71
+
72
+ /**
73
+ * Expand a row's `payload` template for a concrete item name. Returns the
74
+ * pack-relative path of the contribution's payload (e.g. `commands/foo.md`).
75
+ */
76
+ export function payloadFor(key: ContributionKey, item: string): string {
77
+ return CONTRIBUTION_TABLE[key].payload.replace('{item}', item);
78
+ }
79
+
80
+ /** Keys whose contributions are wired by a host symlink (skip `merge` rows). */
81
+ export const SYMLINK_KEYS = CONTRIBUTION_KEYS.filter(
82
+ (k) => CONTRIBUTION_TABLE[k].wire === 'symlink',
83
+ );
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { environmentalFsErrorMessage } from "./environmental-error.js";
3
+
4
+ /** Build a Node-style errno error with a `.code`, as fs.*Sync throws. */
5
+ function errnoError(code: string, message: string): NodeJS.ErrnoException {
6
+ const err = new Error(message) as NodeJS.ErrnoException;
7
+ err.code = code;
8
+ return err;
9
+ }
10
+
11
+ describe("environmentalFsErrorMessage", () => {
12
+ // HQ-CLI-2: the exact error that crashed `hq reindex` and flooded Sentry.
13
+ it("classifies ENOSPC (no space left on device) as environmental", () => {
14
+ const err = errnoError("ENOSPC", "ENOSPC: no space left on device, open '/x'");
15
+ const msg = environmentalFsErrorMessage(err);
16
+ expect(msg).not.toBeNull();
17
+ expect(msg).toMatch(/no space left on device/i);
18
+ });
19
+
20
+ it("classifies EDQUOT (quota exceeded) as environmental", () => {
21
+ const msg = environmentalFsErrorMessage(errnoError("EDQUOT", "EDQUOT: disk quota exceeded"));
22
+ expect(msg).toMatch(/quota/i);
23
+ });
24
+
25
+ it("classifies EROFS (read-only filesystem) as environmental", () => {
26
+ const msg = environmentalFsErrorMessage(errnoError("EROFS", "EROFS: read-only file system"));
27
+ expect(msg).toMatch(/read-only/i);
28
+ });
29
+
30
+ // A genuine code bug must still reach Sentry — only the disk-class codes are
31
+ // diverted, so we never silently swallow real defects.
32
+ it("returns null for a code-bug error (ENOENT) so it is still captured", () => {
33
+ expect(environmentalFsErrorMessage(errnoError("ENOENT", "ENOENT: not found"))).toBeNull();
34
+ });
35
+
36
+ it("returns null for a plain Error with no code", () => {
37
+ expect(environmentalFsErrorMessage(new Error("boom"))).toBeNull();
38
+ });
39
+
40
+ it("returns null for non-error values (null/undefined/string)", () => {
41
+ expect(environmentalFsErrorMessage(null)).toBeNull();
42
+ expect(environmentalFsErrorMessage(undefined)).toBeNull();
43
+ expect(environmentalFsErrorMessage("ENOSPC")).toBeNull();
44
+ });
45
+ });
@@ -0,0 +1,39 @@
1
+ // src/utils/environmental-error.ts
2
+ //
3
+ // Classify errors that stem from the user's machine/filesystem state rather
4
+ // than an HQ code defect. These are unactionable from our side: a full disk, an
5
+ // exhausted quota, or a read-only filesystem. The CLI surfaces a clear,
6
+ // actionable message and does NOT report them to Sentry — otherwise a single
7
+ // full disk floods the issue tracker with identical, unfixable crash reports.
8
+ //
9
+ // HQ-CLI-2: `hq reindex` hit ENOSPC in the operation-lock temp-file write
10
+ // (`fs.openSync` → "ENOSPC: no space left on device, open") and the raw error
11
+ // propagated uncaught to the CLI's top-level handler, which captured it to
12
+ // Sentry and exited silently — 5 stack-trace crashes in 6 seconds, with no
13
+ // message telling the user their disk was full.
14
+
15
+ /**
16
+ * Node errno codes for "the filesystem cannot accept this write" — purely
17
+ * environmental, never a code bug. Mapped to the message shown to the user.
18
+ */
19
+ const ENVIRONMENTAL_FS_CODES: Record<string, string> = {
20
+ ENOSPC: "No space left on device. Free up disk space and try again.",
21
+ EDQUOT:
22
+ "Disk quota exceeded. Free up space (or raise your quota) and try again.",
23
+ EROFS:
24
+ "The filesystem is read-only, so HQ can't write here. Check the mount/permissions and try again.",
25
+ };
26
+
27
+ /**
28
+ * If `err` is an environmental disk/quota/read-only filesystem error, return a
29
+ * short, user-facing message explaining it; otherwise return `null`.
30
+ *
31
+ * A non-null result means the caller should print the message and SKIP Sentry
32
+ * capture — the condition is the user's machine, not a bug HQ can fix. A null
33
+ * result means "this is a normal error; handle it as usual (capture to Sentry)".
34
+ */
35
+ export function environmentalFsErrorMessage(err: unknown): string | null {
36
+ const code = (err as NodeJS.ErrnoException | null | undefined)?.code;
37
+ if (typeof code !== "string") return null;
38
+ return ENVIRONMENTAL_FS_CODES[code] ?? null;
39
+ }
@@ -17,23 +17,39 @@ import {
17
17
  linkStatus,
18
18
  listInstalledPacks,
19
19
  unwirePack,
20
+ unwirePackMcp,
20
21
  packagesDir,
21
22
  readHqVersion,
22
23
  type WiredLink,
23
24
  } from './pack-contributions.js';
25
+ import { parse as parseToml } from 'smol-toml';
26
+ import {
27
+ registerClaudeServer,
28
+ registerServer,
29
+ claudeConfigPath,
30
+ codexConfigPath,
31
+ codexHome,
32
+ } from '../commands/mcp-registration.js';
33
+ import {
34
+ CONTRIBUTION_TABLE,
35
+ CONTRIBUTION_KEYS,
36
+ SYMLINK_KEYS,
37
+ payloadFor,
38
+ type ContributionKey,
39
+ } from './contribution-table.js';
24
40
  import type { PackContributeKey } from '../types.js';
25
41
 
26
- // The canonical mapping, encoded once here as the test oracle. Host paths are
27
- // relative to hqRoot; src paths relative to the pack dir.
28
- const EXPECTED: Record<PackContributeKey, { src: string; dst: string }> = {
29
- workers: { src: 'workers/X', dst: 'core/workers/public/X' },
30
- knowledge: { src: 'knowledge/X', dst: 'core/knowledge/public/X' },
31
- skills: { src: 'skills/X', dst: '.claude/skills/X' },
32
- commands: { src: 'commands/X.md', dst: '.claude/commands/X.md' },
33
- hooks: { src: 'hooks/X.sh', dst: '.claude/hooks/X.sh' },
34
- policies: { src: 'policies/X.md', dst: 'core/policies/X.md' },
35
- scripts: { src: 'scripts/X', dst: 'core/scripts/X' },
36
- };
42
+ // The canonical mapping is DERIVED from the single-source CONTRIBUTION_TABLE
43
+ // (US-003) -- the oracle is no longer a hand-restated copy. For each SYMLINK
44
+ // key, src = the payload expanded for item "X"; dst = <host-dir>/<basename>.
45
+ const EXPECTED: Record<ContributionKey, { src: string; dst: string }> =
46
+ Object.fromEntries(
47
+ SYMLINK_KEYS.map((k) => {
48
+ const src = payloadFor(k, 'X');
49
+ const dst = `${CONTRIBUTION_TABLE[k].host}/${src.split('/').pop()}`;
50
+ return [k, { src, dst }];
51
+ }),
52
+ ) as Record<ContributionKey, { src: string; dst: string }>;
37
53
 
38
54
  function mkTmp(prefix: string): string {
39
55
  // realpathSync so macOS /tmp -> /private/tmp doesn't break path equality.
@@ -41,10 +57,10 @@ function mkTmp(prefix: string): string {
41
57
  }
42
58
 
43
59
  describe('contributionLinks: mapping', () => {
44
- it('maps every contributes key to the canonical src/dst', () => {
60
+ it('maps every SYMLINK contributes key to the canonical src/dst', () => {
45
61
  const hqRoot = '/hq';
46
62
  const packDir = '/hq/core/packages/hq-pack-x';
47
- for (const key of Object.keys(EXPECTED) as PackContributeKey[]) {
63
+ for (const key of SYMLINK_KEYS) {
48
64
  const [link] = contributionLinks(hqRoot, packDir, { [key]: ['X'] });
49
65
  expect(link.key).toBe(key);
50
66
  expect(link.src).toBe(path.join(packDir, EXPECTED[key].src));
@@ -52,6 +68,19 @@ describe('contributionLinks: mapping', () => {
52
68
  }
53
69
  });
54
70
 
71
+ it('produces NO symlink for wire:merge keys (mcp)', () => {
72
+ // The mcp row is wire:merge — declared as data (US-003) but never wired as a
73
+ // symlink here; its host effect is a config merge (US-004/US-005).
74
+ const mergeKeys = CONTRIBUTION_KEYS.filter(
75
+ (k) => CONTRIBUTION_TABLE[k].wire === 'merge',
76
+ );
77
+ expect(mergeKeys).toContain('mcp'); // guards the row exists
78
+ for (const key of mergeKeys) {
79
+ const links = contributionLinks('/hq', '/p', { [key]: ['X'] });
80
+ expect(links).toEqual([]);
81
+ }
82
+ });
83
+
55
84
  it('ignores empty / non-array contributes subfields', () => {
56
85
  const links = contributionLinks('/hq', '/p', {
57
86
  skills: [],
@@ -63,21 +92,118 @@ describe('contributionLinks: mapping', () => {
63
92
  });
64
93
  });
65
94
 
66
- describe('contributionLinks: parity with scan-packages.sh', () => {
67
- // Best-effort drift guard: if the bash wirer is reachable, confirm each
68
- // case arm's dst suffix matches our TS mapping. Skips cleanly off-tree.
69
- const candidates = [
95
+ // ---------------------------------------------------------------------------
96
+ // US-003 single-source parity: FULL key-set + payload-suffix + wire-mode
97
+ // equivalence across every surface (table, derived union, validateManifest's
98
+ // payload reader, and the generated scan-packages.sh block). Replaces the old
99
+ // substring-only `toContain` check, which passed even if a key was MISSING, had
100
+ // the WRONG payload suffix, or the WRONG wire mode.
101
+ // ---------------------------------------------------------------------------
102
+ describe('US-003 parity: contribution table is the single source', () => {
103
+ // Resolve the HQ root so we can read the GENERATED scan-packages.sh block.
104
+ const scanCandidates = [
70
105
  path.resolve(process.cwd(), '../../../core/scripts/scan-packages.sh'),
71
106
  path.resolve(process.cwd(), '../../../../core/scripts/scan-packages.sh'),
72
107
  ];
73
- const scanPath = candidates.find((p) => fs.existsSync(p));
74
-
75
- (scanPath ? it : it.skip)('dst suffixes match the bash case arms', () => {
76
- const bash = fs.readFileSync(scanPath as string, 'utf-8');
77
- for (const key of Object.keys(EXPECTED) as PackContributeKey[]) {
78
- // dst host-path suffix, with the trailing /$item or /$item.md stripped.
79
- const suffix = EXPECTED[key].dst.replace(/\/X(\.\w+)?$/, '');
80
- expect(bash).toContain(suffix); // e.g. core/workers/public, .claude/skills
108
+ const scanPath = scanCandidates.find((p) => fs.existsSync(p));
109
+
110
+ // Parse the generated `contrib_row` case arms back into {key:{payload,host,wire}}.
111
+ function parseBashTable(bash: string): Record<
112
+ string,
113
+ { payload: string; host: string; wire: string }
114
+ > {
115
+ const out: Record<string, { payload: string; host: string; wire: string }> = {};
116
+ const re =
117
+ /^\s*([a-z]+)\)\s*printf\s+'%s'\s+'([^|]+)\|([^|]+)\|([^']+)'\s*;;/gm;
118
+ let m;
119
+ while ((m = re.exec(bash)) !== null) {
120
+ out[m[1]] = { payload: m[2], host: m[3], wire: m[4] };
121
+ }
122
+ return out;
123
+ }
124
+
125
+ it('the derived PackContributeKey union == the table keys (8 keys incl. mcp)', () => {
126
+ // CONTRIBUTION_KEYS IS PackContributeKey's source (types.ts derives the
127
+ // union from it). Assert the full expected set is present, no more, no less.
128
+ expect([...CONTRIBUTION_KEYS].sort()).toEqual(
129
+ [
130
+ 'commands',
131
+ 'hooks',
132
+ 'knowledge',
133
+ 'mcp',
134
+ 'policies',
135
+ 'scripts',
136
+ 'skills',
137
+ 'workers',
138
+ ].sort(),
139
+ );
140
+ // Type-level: every key is assignable to PackContributeKey.
141
+ const asUnion: PackContributeKey[] = [...CONTRIBUTION_KEYS];
142
+ expect(asUnion.length).toBe(8);
143
+ });
144
+
145
+ it('every key has a wire mode of exactly symlink|merge, mcp is the merge row', () => {
146
+ for (const k of CONTRIBUTION_KEYS) {
147
+ expect(['symlink', 'merge']).toContain(CONTRIBUTION_TABLE[k].wire);
148
+ }
149
+ expect(CONTRIBUTION_TABLE.mcp.wire).toBe('merge');
150
+ expect(CONTRIBUTION_TABLE.mcp.payload).toBe('mcp/{item}.json');
151
+ expect(CONTRIBUTION_TABLE.mcp.host).toBe('merge:claude+codex');
152
+ // All non-mcp rows are symlink in this milestone.
153
+ for (const k of CONTRIBUTION_KEYS) {
154
+ if (k !== 'mcp') expect(CONTRIBUTION_TABLE[k].wire).toBe('symlink');
155
+ }
156
+ });
157
+
158
+ it('payload suffixes are pinned per key (suffix is load-bearing)', () => {
159
+ const suffix = (p: string) => {
160
+ const base = p.split('/').pop() as string;
161
+ const dot = base.indexOf('.', base.indexOf('}'));
162
+ return dot >= 0 ? base.slice(dot) : '';
163
+ };
164
+ expect(suffix(CONTRIBUTION_TABLE.commands.payload)).toBe('.md');
165
+ expect(suffix(CONTRIBUTION_TABLE.policies.payload)).toBe('.md');
166
+ expect(suffix(CONTRIBUTION_TABLE.hooks.payload)).toBe('.sh');
167
+ expect(suffix(CONTRIBUTION_TABLE.mcp.payload)).toBe('.json');
168
+ expect(suffix(CONTRIBUTION_TABLE.workers.payload)).toBe('');
169
+ expect(suffix(CONTRIBUTION_TABLE.knowledge.payload)).toBe('');
170
+ expect(suffix(CONTRIBUTION_TABLE.skills.payload)).toBe('');
171
+ expect(suffix(CONTRIBUTION_TABLE.scripts.payload)).toBe('');
172
+ });
173
+
174
+ (scanPath ? it : it.skip)(
175
+ 'scan-packages.sh generated block matches the table on key-set + payload + host + wire',
176
+ () => {
177
+ const bash = fs.readFileSync(scanPath as string, 'utf-8');
178
+ const bashTable = parseBashTable(bash);
179
+
180
+ // FULL key-set equivalence (fails if a surface is MISSING a key or has an
181
+ // EXTRA one — not just a substring presence check).
182
+ expect(Object.keys(bashTable).sort()).toEqual([...CONTRIBUTION_KEYS].sort());
183
+
184
+ // Per-key payload + host + wire-mode equivalence.
185
+ for (const k of CONTRIBUTION_KEYS) {
186
+ expect(bashTable[k], `scan-packages.sh missing row for "${k}"`).toBeDefined();
187
+ expect(bashTable[k].payload).toBe(CONTRIBUTION_TABLE[k].payload);
188
+ expect(bashTable[k].host).toBe(CONTRIBUTION_TABLE[k].host);
189
+ expect(bashTable[k].wire).toBe(CONTRIBUTION_TABLE[k].wire);
190
+ }
191
+
192
+ // The CONTRIB_KEYS array in the script also lists every key.
193
+ const keysLine = bash.match(/CONTRIB_KEYS=\(([^)]*)\)/);
194
+ expect(keysLine).not.toBeNull();
195
+ const bashKeys = (keysLine as RegExpMatchArray)[1].trim().split(/\s+/).sort();
196
+ expect(bashKeys).toEqual([...CONTRIBUTION_KEYS].sort());
197
+ },
198
+ );
199
+
200
+ it('validateManifest payload reader (payloadFor) agrees with the table for every key', () => {
201
+ for (const k of CONTRIBUTION_KEYS) {
202
+ // payloadFor is exactly what validateManifest now uses for the
203
+ // payload-existence check, so this pins that surface to the table.
204
+ expect(payloadFor(k, 'demo')).toBe(
205
+ CONTRIBUTION_TABLE[k].payload.replace('{item}', 'demo'),
206
+ );
81
207
  }
82
208
  });
83
209
  });
@@ -257,3 +383,109 @@ describe('readHqVersion: v15 layout awareness (feedback_57d7edcf, symptom b)', (
257
383
  expect(readHqVersion(hqRoot)).toBeNull();
258
384
  });
259
385
  });
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // US-009: unwirePackMcp — the wire:merge (MCP) un-registration parallel to the
389
+ // symlink unwirePack. Runs against an ISOLATED tmpdir home (env.home) so the
390
+ // developer's real ~/.claude.json / ~/.codex are NEVER touched.
391
+ // ---------------------------------------------------------------------------
392
+
393
+ describe('US-009: unwirePackMcp (mcp un-registration parallel to symlink unwire)', () => {
394
+ let mcpHome: string;
395
+
396
+ beforeEach(() => {
397
+ mcpHome = mkTmp('hq-unwire-mcp-');
398
+ });
399
+ afterEach(() => {
400
+ fs.rmSync(mcpHome, { recursive: true, force: true });
401
+ });
402
+
403
+ it('un-registers ONLY this pack\'s provenance-stamped mcp servers from ~/.claude.json', () => {
404
+ const env = { home: mcpHome };
405
+ // The user owns `figma`; our pack installs `vyg`.
406
+ fs.writeFileSync(
407
+ claudeConfigPath(env),
408
+ JSON.stringify({ mcpServers: { figma: { type: 'http', url: 'https://figma' } } }, null, 2) + '\n',
409
+ );
410
+ registerClaudeServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
411
+
412
+ const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
413
+ expect(result.servers).toHaveLength(1);
414
+ expect(result.servers[0]!.claude.outcome).toBe('removed');
415
+
416
+ const after = JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8'));
417
+ expect(after.mcpServers.vyg).toBeUndefined(); // ours removed
418
+ expect(after.mcpServers.figma.url).toBe('https://figma'); // user sibling preserved
419
+ });
420
+
421
+ it('no-ops cleanly when the pack declares no mcp (merge) keys', () => {
422
+ const result = unwirePackMcp('hq-pack-x', { commands: ['foo'], skills: ['bar'] }, { env: { home: mcpHome } });
423
+ expect(result.servers).toEqual([]);
424
+ });
425
+
426
+ it('skip-and-warns (does not delete) a foreign/unstamped same-named entry', () => {
427
+ const env = { home: mcpHome };
428
+ // The user has their OWN `vyg` (no _hqPack stamp).
429
+ fs.writeFileSync(
430
+ claudeConfigPath(env),
431
+ JSON.stringify({ mcpServers: { vyg: { type: 'http', url: 'https://user-vyg' } } }, null, 2) + '\n',
432
+ );
433
+ const before = fs.readFileSync(claudeConfigPath(env), 'utf-8');
434
+
435
+ const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
436
+ expect(result.servers[0]!.claude.outcome).toBe('skipped-foreign');
437
+ // The user's server is left byte-for-byte intact.
438
+ expect(fs.readFileSync(claudeConfigPath(env), 'utf-8')).toBe(before);
439
+ });
440
+
441
+ it('is a clean no-op on a Codex-less host with no ~/.claude.json (never fabricates)', () => {
442
+ const env = { home: mcpHome };
443
+ const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
444
+ expect(result.servers[0]!.claude.outcome).toBe('absent');
445
+ expect('skipped' in result.servers[0]!.codex && (result.servers[0]!.codex as any).skipped).toBe(true);
446
+ expect(fs.existsSync(path.join(mcpHome, '.claude.json'))).toBe(false);
447
+ expect(fs.existsSync(path.join(mcpHome, '.codex'))).toBe(false);
448
+ });
449
+
450
+ it('delegates to unregisterMcpServers across BOTH surfaces when ~/.codex exists', () => {
451
+ // The unwire-hook must un-register from Claude AND Codex (not just Claude) — i.e.
452
+ // it really delegates to unregisterMcpServers, which fans out over both surfaces.
453
+ const env = { home: mcpHome };
454
+ fs.mkdirSync(codexHome(env), { recursive: true }); // make Codex a real runtime
455
+ // Install our vyg into BOTH surfaces, alongside a user-owned figma on Claude.
456
+ fs.writeFileSync(
457
+ claudeConfigPath(env),
458
+ JSON.stringify({ mcpServers: { figma: { type: 'http', url: 'https://figma' } } }, null, 2) + '\n',
459
+ );
460
+ registerServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
461
+ // Both surfaces carry vyg before uninstall.
462
+ expect(JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8')).mcpServers.vyg).toBeDefined();
463
+ expect((parseToml(fs.readFileSync(codexConfigPath(env), 'utf-8')) as any).mcp_servers.vyg).toBeDefined();
464
+
465
+ const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
466
+ expect(result.servers).toHaveLength(1);
467
+ expect(result.servers[0]!.claude.outcome).toBe('removed');
468
+ expect('skipped' in result.servers[0]!.codex).toBe(false);
469
+ expect((result.servers[0]!.codex as any).outcome).toBe('removed');
470
+
471
+ // Both surfaces no longer carry vyg; the user's Claude figma sibling survives.
472
+ expect(JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8')).mcpServers.vyg).toBeUndefined();
473
+ expect(JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8')).mcpServers.figma).toBeDefined();
474
+ expect((parseToml(fs.readFileSync(codexConfigPath(env), 'utf-8')) as any).mcp_servers.vyg).toBeUndefined();
475
+ });
476
+
477
+ it('skip-and-warns a foreign Codex table too (does not delete another pack\'s server)', () => {
478
+ // The Codex parallel of the foreign skip: a vyg table stamped by ANOTHER pack must
479
+ // be left in place when uninstalling OUR pack — provenance scope, not name match.
480
+ const env = { home: mcpHome };
481
+ fs.mkdirSync(codexHome(env), { recursive: true });
482
+ const original =
483
+ '# keep me\n[mcp_servers.vyg]\ntype = "http"\nurl = "https://vyg"\n_hqPack = "hq-pack-OTHER"\n';
484
+ fs.writeFileSync(codexConfigPath(env), original);
485
+
486
+ const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
487
+ expect((result.servers[0]!.codex as any).outcome).toBe('skipped-foreign');
488
+ // Byte-for-byte preserved (comment intact — no write on a foreign skip).
489
+ expect(fs.readFileSync(codexConfigPath(env), 'utf-8')).toBe(original);
490
+ });
491
+ });