@indigoai-us/hq-cli 5.31.0 → 5.32.0

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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * pack-contributions tests — pins the content-pack lifecycle helpers that
3
+ * commands/packs.ts (list / update / uninstall) depends on.
4
+ *
5
+ * The load-bearing invariant is the contributes->host-path mapping: it MUST
6
+ * agree byte-for-byte with core/scripts/scan-packages.sh (the wiring
7
+ * authority) and pack-install.ts:validateManifest. A drift there means
8
+ * uninstall would miss symlinks (dangling links) or delete the wrong path.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+ import {
16
+ contributionLinks,
17
+ linkStatus,
18
+ listInstalledPacks,
19
+ unwirePack,
20
+ packagesDir,
21
+ type WiredLink,
22
+ } from './pack-contributions.js';
23
+ import type { PackContributeKey } from '../types.js';
24
+
25
+ // The canonical mapping, encoded once here as the test oracle. Host paths are
26
+ // relative to hqRoot; src paths relative to the pack dir.
27
+ const EXPECTED: Record<PackContributeKey, { src: string; dst: string }> = {
28
+ workers: { src: 'workers/X', dst: 'core/workers/public/X' },
29
+ knowledge: { src: 'knowledge/X', dst: 'core/knowledge/public/X' },
30
+ skills: { src: 'skills/X', dst: '.claude/skills/X' },
31
+ commands: { src: 'commands/X.md', dst: '.claude/commands/X.md' },
32
+ hooks: { src: 'hooks/X.sh', dst: '.claude/hooks/X.sh' },
33
+ policies: { src: 'policies/X.md', dst: 'core/policies/X.md' },
34
+ scripts: { src: 'scripts/X', dst: 'core/scripts/X' },
35
+ };
36
+
37
+ function mkTmp(prefix: string): string {
38
+ // realpathSync so macOS /tmp -> /private/tmp doesn't break path equality.
39
+ return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
40
+ }
41
+
42
+ describe('contributionLinks: mapping', () => {
43
+ it('maps every contributes key to the canonical src/dst', () => {
44
+ const hqRoot = '/hq';
45
+ const packDir = '/hq/core/packages/hq-pack-x';
46
+ for (const key of Object.keys(EXPECTED) as PackContributeKey[]) {
47
+ const [link] = contributionLinks(hqRoot, packDir, { [key]: ['X'] });
48
+ expect(link.key).toBe(key);
49
+ expect(link.src).toBe(path.join(packDir, EXPECTED[key].src));
50
+ expect(link.dst).toBe(path.join(hqRoot, EXPECTED[key].dst));
51
+ }
52
+ });
53
+
54
+ it('ignores empty / non-array contributes subfields', () => {
55
+ const links = contributionLinks('/hq', '/p', {
56
+ skills: [],
57
+ workers: ['a', 'b'],
58
+ // @ts-expect-error — defensive: non-array should be skipped
59
+ hooks: 'nope',
60
+ });
61
+ expect(links.map((l) => l.item)).toEqual(['a', 'b']);
62
+ });
63
+ });
64
+
65
+ describe('contributionLinks: parity with scan-packages.sh', () => {
66
+ // Best-effort drift guard: if the bash wirer is reachable, confirm each
67
+ // case arm's dst suffix matches our TS mapping. Skips cleanly off-tree.
68
+ const candidates = [
69
+ path.resolve(process.cwd(), '../../../core/scripts/scan-packages.sh'),
70
+ path.resolve(process.cwd(), '../../../../core/scripts/scan-packages.sh'),
71
+ ];
72
+ const scanPath = candidates.find((p) => fs.existsSync(p));
73
+
74
+ (scanPath ? it : it.skip)('dst suffixes match the bash case arms', () => {
75
+ const bash = fs.readFileSync(scanPath as string, 'utf-8');
76
+ for (const key of Object.keys(EXPECTED) as PackContributeKey[]) {
77
+ // dst host-path suffix, with the trailing /$item or /$item.md stripped.
78
+ const suffix = EXPECTED[key].dst.replace(/\/X(\.\w+)?$/, '');
79
+ expect(bash).toContain(suffix); // e.g. core/workers/public, .claude/skills
80
+ }
81
+ });
82
+ });
83
+
84
+ describe('linkStatus + unwirePack', () => {
85
+ let hqRoot: string;
86
+ let packDir: string;
87
+
88
+ beforeEach(() => {
89
+ hqRoot = mkTmp('hq-pc-root-');
90
+ packDir = path.join(packagesDir(hqRoot), 'hq-pack-demo');
91
+ // A pack that contributes one skill and one command.
92
+ fs.mkdirSync(path.join(packDir, 'skills', 'demo-skill'), { recursive: true });
93
+ fs.writeFileSync(path.join(packDir, 'skills', 'demo-skill', 'SKILL.md'), '# demo');
94
+ fs.mkdirSync(path.join(packDir, 'commands'), { recursive: true });
95
+ fs.writeFileSync(path.join(packDir, 'commands', 'demo-cmd.md'), '# cmd');
96
+ });
97
+
98
+ afterEach(() => {
99
+ fs.rmSync(hqRoot, { recursive: true, force: true });
100
+ });
101
+
102
+ const contributes = { skills: ['demo-skill'], commands: ['demo-cmd'] };
103
+
104
+ function wire(link: WiredLink): void {
105
+ fs.mkdirSync(path.dirname(link.dst), { recursive: true });
106
+ fs.symlinkSync(link.src, link.dst);
107
+ }
108
+
109
+ it('reports missing before wiring, live after wiring', () => {
110
+ const links = contributionLinks(hqRoot, packDir, contributes);
111
+ for (const l of links) expect(linkStatus(l)).toBe('missing');
112
+ for (const l of links) wire(l);
113
+ for (const l of links) expect(linkStatus(l)).toBe('live');
114
+ });
115
+
116
+ it('reports broken when the pack payload is gone', () => {
117
+ const links = contributionLinks(hqRoot, packDir, contributes);
118
+ links.forEach(wire);
119
+ fs.rmSync(packDir, { recursive: true, force: true });
120
+ for (const l of links) expect(linkStatus(l)).toBe('broken');
121
+ });
122
+
123
+ it('reports foreign when the dst points elsewhere or is a real file', () => {
124
+ const [skillLink, cmdLink] = contributionLinks(hqRoot, packDir, contributes);
125
+ // foreign symlink: points at some other path
126
+ fs.mkdirSync(path.dirname(skillLink.dst), { recursive: true });
127
+ const other = path.join(hqRoot, 'somewhere-else');
128
+ fs.mkdirSync(other, { recursive: true });
129
+ fs.symlinkSync(other, skillLink.dst);
130
+ expect(linkStatus(skillLink)).toBe('foreign');
131
+ // foreign real file
132
+ fs.mkdirSync(path.dirname(cmdLink.dst), { recursive: true });
133
+ fs.writeFileSync(cmdLink.dst, 'host-owned content');
134
+ expect(linkStatus(cmdLink)).toBe('foreign');
135
+ });
136
+
137
+ it('unwirePack removes ONLY our symlinks, leaving foreign ones', () => {
138
+ const [skillLink, cmdLink] = contributionLinks(hqRoot, packDir, contributes);
139
+ wire(skillLink); // our link
140
+ // cmd slot occupied by a foreign symlink (another pack / host content)
141
+ fs.mkdirSync(path.dirname(cmdLink.dst), { recursive: true });
142
+ const other = path.join(hqRoot, 'foreign-target.md');
143
+ fs.writeFileSync(other, 'x');
144
+ fs.symlinkSync(other, cmdLink.dst);
145
+
146
+ const result = unwirePack(hqRoot, packDir, contributes);
147
+
148
+ expect(fs.existsSync(skillLink.dst)).toBe(false); // ours removed
149
+ expect(fs.lstatSync(cmdLink.dst).isSymbolicLink()).toBe(true); // foreign kept
150
+ expect(result.unlinked.map((u) => u.item)).toEqual(['demo-skill']);
151
+ expect(result.skipped.map((s) => s.item)).toEqual(['demo-cmd']);
152
+ });
153
+
154
+ it('unwirePack removes broken (dangling) links that point into our pack', () => {
155
+ const links = contributionLinks(hqRoot, packDir, contributes);
156
+ links.forEach(wire);
157
+ fs.rmSync(packDir, { recursive: true, force: true }); // now all broken
158
+ const result = unwirePack(hqRoot, packDir, contributes);
159
+ expect(result.unlinked).toHaveLength(2);
160
+ for (const l of links) expect(fs.existsSync(l.dst)).toBe(false);
161
+ for (const l of links) expect(() => fs.lstatSync(l.dst)).toThrow(); // gone
162
+ });
163
+ });
164
+
165
+ describe('listInstalledPacks', () => {
166
+ let hqRoot: string;
167
+
168
+ beforeEach(() => {
169
+ hqRoot = mkTmp('hq-pc-list-');
170
+ });
171
+ afterEach(() => {
172
+ fs.rmSync(hqRoot, { recursive: true, force: true });
173
+ });
174
+
175
+ it('returns [] when core/packages is absent', () => {
176
+ expect(listInstalledPacks(hqRoot)).toEqual([]);
177
+ });
178
+
179
+ it('walks packs, skips README + .archive, and parses the stamped source', () => {
180
+ const dir = packagesDir(hqRoot);
181
+ fs.mkdirSync(dir, { recursive: true });
182
+ fs.writeFileSync(path.join(dir, 'README.md'), '# packages');
183
+ fs.mkdirSync(path.join(dir, '.archive', 'old-pack-2026'), { recursive: true });
184
+
185
+ const p = path.join(dir, 'hq-pack-one');
186
+ fs.mkdirSync(p, { recursive: true });
187
+ fs.writeFileSync(
188
+ path.join(p, 'package.yaml'),
189
+ 'source: "github:acme/repo#packages/hq-pack-one"\nname: hq-pack-one\nversion: 2.1.0\ncontributes:\n skills:\n - foo\n',
190
+ );
191
+
192
+ const packs = listInstalledPacks(hqRoot);
193
+ expect(packs.map((x) => x.name)).toEqual(['hq-pack-one']);
194
+ expect(packs[0].manifest?.version).toBe('2.1.0');
195
+ expect(packs[0].manifest?.source).toBe('github:acme/repo#packages/hq-pack-one');
196
+ });
197
+
198
+ it('surfaces an unreadable manifest as error, not a throw', () => {
199
+ const dir = packagesDir(hqRoot);
200
+ const p = path.join(dir, 'hq-pack-bad');
201
+ fs.mkdirSync(p, { recursive: true });
202
+ fs.writeFileSync(path.join(p, 'package.yaml'), 'name: [unclosed\n');
203
+ const packs = listInstalledPacks(hqRoot);
204
+ expect(packs).toHaveLength(1);
205
+ expect(packs[0].manifest).toBeNull();
206
+ expect(packs[0].error).toMatch(/invalid/i);
207
+ });
208
+ });
@@ -0,0 +1,303 @@
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
4
+ * `core/scripts/scan-packages.sh`.
5
+ *
6
+ * `pack-install.ts` only INSTALLS content packs (into `core/packages/<name>/`,
7
+ * tracked by filesystem presence -- there is no registry file). The list /
8
+ * update / uninstall lifecycle in `commands/packs.ts` needs to reason about the
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:
11
+ *
12
+ * - core/scripts/scan-packages.sh (bash `case`, the wiring authority)
13
+ * - pack-install.ts validateManifest's `subpaths` record (payload validation)
14
+ *
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.
18
+ */
19
+
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import * as yaml from 'js-yaml';
23
+ import type { PackManifest, PackContributeKey } from '../types.js';
24
+
25
+ /** A single symlink a pack contributes: dst (host path) -> src (inside pack). */
26
+ export interface WiredLink {
27
+ key: PackContributeKey;
28
+ item: string;
29
+ src: string; // absolute path inside core/packages/<pack>/
30
+ dst: string; // absolute host path the symlink lives at
31
+ }
32
+
33
+ export type LinkStatus =
34
+ | 'live' // dst is a symlink -> this pack's src, and src exists
35
+ | 'broken' // dst is a symlink -> this pack's src, but src is gone
36
+ | 'missing' // dst does not exist (declared but never wired / already removed)
37
+ | 'foreign'; // dst exists but points elsewhere or is a real file (collision)
38
+
39
+ /**
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.
42
+ */
43
+ function linkFor(
44
+ hqRoot: string,
45
+ packDir: string,
46
+ key: PackContributeKey,
47
+ 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
+ }
81
+ return {
82
+ key,
83
+ item,
84
+ src: path.join(packDir, srcRel),
85
+ dst: path.join(hqRoot, dstRel),
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Every symlink a pack's `contributes` block declares. Empty subfields and
91
+ * non-array values are ignored, mirroring scan-packages.sh.
92
+ */
93
+ export function contributionLinks(
94
+ hqRoot: string,
95
+ packDir: string,
96
+ contributes: Partial<Record<PackContributeKey, string[]>>,
97
+ ): WiredLink[] {
98
+ const links: WiredLink[] = [];
99
+ for (const [key, items] of Object.entries(contributes) as [
100
+ PackContributeKey,
101
+ unknown,
102
+ ][]) {
103
+ if (!Array.isArray(items)) continue;
104
+ for (const item of items) {
105
+ if (typeof item !== 'string' || item.length === 0) continue;
106
+ links.push(linkFor(hqRoot, packDir, key, item));
107
+ }
108
+ }
109
+ return links;
110
+ }
111
+
112
+ /** Classify a host path against the link that should own it. */
113
+ export function linkStatus(link: WiredLink): LinkStatus {
114
+ let st: fs.Stats | undefined;
115
+ try {
116
+ st = fs.lstatSync(link.dst);
117
+ } catch {
118
+ return 'missing';
119
+ }
120
+ if (!st.isSymbolicLink()) {
121
+ return 'foreign'; // a real file/dir occupies the slot -- not ours
122
+ }
123
+ let target: string;
124
+ try {
125
+ target = fs.readlinkSync(link.dst);
126
+ } catch {
127
+ return 'foreign';
128
+ }
129
+ // scan-packages.sh writes the symlink target as the absolute `src` path, so a
130
+ // direct compare is correct. Resolve both to be robust to trailing slashes.
131
+ const resolvedTarget = path.resolve(path.dirname(link.dst), target);
132
+ if (path.resolve(link.src) !== resolvedTarget) {
133
+ return 'foreign'; // points at another pack / somewhere else
134
+ }
135
+ return fs.existsSync(link.src) ? 'live' : 'broken';
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Installed-pack discovery
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /** A content pack's manifest plus the install-time stamped source. */
143
+ export interface InstalledPackManifest extends PackManifest {
144
+ source?: string; // stamped by pack-install.ts:stampInstallSource
145
+ }
146
+
147
+ export interface InstalledPack {
148
+ name: string; // directory name (== manifest.name when well-formed)
149
+ dir: string; // absolute path to core/packages/<name>/
150
+ manifest: InstalledPackManifest | null; // null when package.yaml is unreadable
151
+ error?: string; // parse/validation error message when manifest is null
152
+ }
153
+
154
+ /** Absolute path to `<hqRoot>/core/packages`. */
155
+ export function packagesDir(hqRoot: string): string {
156
+ return path.join(hqRoot, 'core', 'packages');
157
+ }
158
+
159
+ /** Read and shallowly validate a pack's package.yaml. */
160
+ export function readPackManifest(packDir: string): {
161
+ manifest: InstalledPackManifest | null;
162
+ error?: string;
163
+ } {
164
+ const manifestPath = path.join(packDir, 'package.yaml');
165
+ if (!fs.existsSync(manifestPath)) {
166
+ return { manifest: null, error: 'package.yaml missing' };
167
+ }
168
+ try {
169
+ const parsed = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
170
+ if (!parsed || typeof parsed !== 'object') {
171
+ return { manifest: null, error: 'package.yaml is not a mapping' };
172
+ }
173
+ return { manifest: parsed as InstalledPackManifest };
174
+ } catch (e) {
175
+ return { manifest: null, error: `package.yaml invalid: ${(e as Error).message}` };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Walk `core/packages/<name>/package.yaml`. Skips the `.archive` dir, the bundled
181
+ * `README.md`, and any non-directory entry. Filesystem presence is the source
182
+ * of truth for installed content packs.
183
+ */
184
+ export function listInstalledPacks(hqRoot: string): InstalledPack[] {
185
+ const dir = packagesDir(hqRoot);
186
+ if (!fs.existsSync(dir)) return [];
187
+ const out: InstalledPack[] = [];
188
+ for (const name of fs.readdirSync(dir).sort()) {
189
+ if (name.startsWith('.')) continue; // .archive, .DS_Store, etc.
190
+ const packDir = path.join(dir, name);
191
+ let st: fs.Stats;
192
+ try {
193
+ st = fs.statSync(packDir);
194
+ } catch {
195
+ continue;
196
+ }
197
+ if (!st.isDirectory()) continue; // README.md and friends
198
+ const { manifest, error } = readPackManifest(packDir);
199
+ if (!manifest && !fs.existsSync(path.join(packDir, 'package.yaml'))) {
200
+ continue; // a plain dir that isn't a pack -- ignore silently
201
+ }
202
+ out.push({ name, dir: packDir, manifest, error });
203
+ }
204
+ return out;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Un-wiring (the uninstall guarantee)
209
+ // ---------------------------------------------------------------------------
210
+
211
+ export interface UnwireResult {
212
+ unlinked: Array<{ key: PackContributeKey; item: string; dst: string }>;
213
+ skipped: Array<{
214
+ key: PackContributeKey;
215
+ item: string;
216
+ dst: string;
217
+ reason: 'foreign' | 'missing';
218
+ }>;
219
+ }
220
+
221
+ /**
222
+ * Remove only the host symlinks that resolve into THIS pack's directory
223
+ * (status `live` or `broken`). Foreign links and real files are left in place
224
+ * -- same collision philosophy as scan-packages.sh. This is what prevents an
225
+ * uninstall from leaving dangling symlinks behind.
226
+ */
227
+ export function unwirePack(
228
+ hqRoot: string,
229
+ packDir: string,
230
+ contributes: Partial<Record<PackContributeKey, string[]>>,
231
+ ): UnwireResult {
232
+ const result: UnwireResult = { unlinked: [], skipped: [] };
233
+ for (const link of contributionLinks(hqRoot, packDir, contributes)) {
234
+ const status = linkStatus(link);
235
+ if (status === 'live' || status === 'broken') {
236
+ try {
237
+ fs.unlinkSync(link.dst);
238
+ result.unlinked.push({ key: link.key, item: link.item, dst: link.dst });
239
+ } catch {
240
+ result.skipped.push({
241
+ key: link.key,
242
+ item: link.item,
243
+ dst: link.dst,
244
+ reason: 'foreign',
245
+ });
246
+ }
247
+ } else {
248
+ result.skipped.push({
249
+ key: link.key,
250
+ item: link.item,
251
+ dst: link.dst,
252
+ reason: status === 'missing' ? 'missing' : 'foreign',
253
+ });
254
+ }
255
+ }
256
+ return result;
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Host introspection: hqVersion + recommended_packages catalog
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /** Locate core.yaml -- v14+ nests it under `core/`, older layouts at the root. */
264
+ function coreYamlPath(hqRoot: string): string | null {
265
+ const nested = path.join(hqRoot, 'core', 'core.yaml');
266
+ if (fs.existsSync(nested)) return nested;
267
+ const flat = path.join(hqRoot, 'core.yaml');
268
+ if (fs.existsSync(flat)) return flat;
269
+ return null;
270
+ }
271
+
272
+ export function readHqVersion(hqRoot: string): string | null {
273
+ const p = coreYamlPath(hqRoot);
274
+ if (!p) return null;
275
+ try {
276
+ const c = yaml.load(fs.readFileSync(p, 'utf-8')) as { hqVersion?: string };
277
+ return c?.hqVersion ?? null;
278
+ } catch {
279
+ return null;
280
+ }
281
+ }
282
+
283
+ export interface CatalogEntry {
284
+ source: string;
285
+ description?: string;
286
+ conditional?: string;
287
+ auto_install?: boolean;
288
+ }
289
+
290
+ /** Read `recommended_packages` from core.yaml (the curated content-pack catalog). */
291
+ export function readRecommendedPackages(hqRoot: string): CatalogEntry[] {
292
+ const p = coreYamlPath(hqRoot);
293
+ if (!p) return [];
294
+ try {
295
+ const c = yaml.load(fs.readFileSync(p, 'utf-8')) as {
296
+ recommended_packages?: CatalogEntry[];
297
+ };
298
+ const list = c?.recommended_packages;
299
+ return Array.isArray(list) ? list : [];
300
+ } catch {
301
+ return [];
302
+ }
303
+ }