@indigoai-us/hq-cli 5.47.14 → 5.47.16

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,168 @@
1
+ /**
2
+ * Company people (membership) directory reader + search.
3
+ *
4
+ * The local source of truth for "who belongs to a company" is the per-company
5
+ * people store on disk:
6
+ *
7
+ * companies/<company-slug>/people/<person-slug>/meta.yaml
8
+ *
9
+ * Each `meta.yaml` carries at least `name` and `type` ("internal" | "external")
10
+ * plus optional `email`, `role`, `organization`, `tags`, etc. (see
11
+ * `companies/_template/people/_example/meta.yaml` for the canonical schema).
12
+ *
13
+ * Everything here is scoped to ONE company directory. Callers resolve a single
14
+ * company slug up front; nothing in this module ever enumerates or reads across
15
+ * company boundaries — HQ tenancy rules forbid cross-company member lookups.
16
+ */
17
+
18
+ !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]="aac5adc3-c336-5d9a-8a1a-edaf16ef4c11")}catch(e){}}();
19
+ import * as fs from "fs";
20
+ import * as path from "path";
21
+ import * as yaml from "js-yaml";
22
+ /**
23
+ * Company slugs map directly onto a filesystem path segment, so we validate
24
+ * them before joining to keep a malicious or fat-fingered `--company` value
25
+ * from escaping the `companies/` tree. Mirrors the slug rules used by
26
+ * cloud-provision.
27
+ */
28
+ const COMPANY_SLUG_REGEX = /^[A-Za-z0-9._-]+$/;
29
+ const FORBIDDEN_COMPANY_SLUGS = new Set(["personal", ".", ".."]);
30
+ export function assertSafeCompanySlug(slug) {
31
+ if (!slug || !COMPANY_SLUG_REGEX.test(slug)) {
32
+ throw new Error(`Invalid company slug "${slug}" — must match ${COMPANY_SLUG_REGEX.source}`);
33
+ }
34
+ if (FORBIDDEN_COMPANY_SLUGS.has(slug)) {
35
+ throw new Error(`Company slug "${slug}" is reserved and cannot be used here`);
36
+ }
37
+ }
38
+ /** Absolute path to a company's `people/` directory. */
39
+ export function companyPeopleDir(hqRoot, companySlug) {
40
+ assertSafeCompanySlug(companySlug);
41
+ return path.join(hqRoot, "companies", companySlug, "people");
42
+ }
43
+ /**
44
+ * Parse a single `meta.yaml` body into a `PersonRecord`. Returns `null` when the
45
+ * file is empty, unparseable, or has no usable `name` — a person record without
46
+ * a name can't be listed, searched, or resolved, so it's skipped rather than
47
+ * surfaced as a half-row. Exported for unit testing.
48
+ */
49
+ export function parsePersonMeta(raw, slug, source) {
50
+ let doc;
51
+ try {
52
+ doc = yaml.load(raw);
53
+ }
54
+ catch (err) {
55
+ throw new Error(`Failed to parse ${source}: ${err instanceof Error ? err.message : String(err)}`);
56
+ }
57
+ if (!doc || typeof doc !== "object")
58
+ return null;
59
+ const d = doc;
60
+ const name = typeof d.name === "string" ? d.name.trim() : "";
61
+ if (!name)
62
+ return null;
63
+ const str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined;
64
+ const tags = Array.isArray(d.tags)
65
+ ? d.tags.filter((t) => typeof t === "string")
66
+ : undefined;
67
+ return {
68
+ slug,
69
+ name,
70
+ email: str(d.email),
71
+ type: str(d.type),
72
+ role: str(d.role),
73
+ organization: str(d.organization),
74
+ ...(tags && tags.length > 0 ? { tags } : {}),
75
+ source,
76
+ };
77
+ }
78
+ /**
79
+ * List every person recorded for ONE company. Reads
80
+ * `companies/<companySlug>/people/<personSlug>/meta.yaml` for each person
81
+ * folder.
82
+ *
83
+ * - Folders whose name starts with `_` are skipped (e.g. the `_example`
84
+ * template that ships in `companies/_template/people/`).
85
+ * - Folders without a `meta.yaml`, or whose `meta.yaml` has no `name`, are
86
+ * skipped silently — they aren't members yet.
87
+ * - Returns `[]` when the company has no `people/` directory at all.
88
+ *
89
+ * Results are sorted by name (case-insensitive) for stable output.
90
+ */
91
+ export function listCompanyPeople(hqRoot, companySlug) {
92
+ const dir = companyPeopleDir(hqRoot, companySlug);
93
+ if (!fs.existsSync(dir))
94
+ return [];
95
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
96
+ const people = [];
97
+ for (const entry of entries) {
98
+ if (!entry.isDirectory())
99
+ continue;
100
+ if (entry.name.startsWith("_"))
101
+ continue; // _example and other scaffolding
102
+ const metaPath = path.join(dir, entry.name, "meta.yaml");
103
+ if (!fs.existsSync(metaPath))
104
+ continue;
105
+ const raw = fs.readFileSync(metaPath, "utf-8");
106
+ const record = parsePersonMeta(raw, entry.name, metaPath);
107
+ if (record)
108
+ people.push(record);
109
+ }
110
+ people.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
111
+ return people;
112
+ }
113
+ /**
114
+ * Case-insensitive keyword search over a person's NAME and EMAIL (plus the
115
+ * folder slug, which is a normalized alias of the name). Pure — operates on an
116
+ * already-loaded list so it's trivially testable and reusable.
117
+ *
118
+ * An empty/whitespace keyword matches nothing (callers should treat that as a
119
+ * usage error rather than "return everyone").
120
+ */
121
+ export function searchPeople(people, keyword) {
122
+ const needle = keyword.trim().toLowerCase();
123
+ if (!needle)
124
+ return [];
125
+ return people.filter((p) => {
126
+ const haystacks = [p.name, p.email, p.slug].filter((v) => typeof v === "string");
127
+ return haystacks.some((h) => h.toLowerCase().includes(needle));
128
+ });
129
+ }
130
+ /**
131
+ * Resolve a person NAME to their email, built on top of {@link searchPeople}.
132
+ *
133
+ * Match precedence (narrowest first) so a precise query isn't drowned out by
134
+ * looser substring hits:
135
+ * 1. exact name match (case-insensitive, trimmed)
136
+ * 2. exact folder-slug match
137
+ * 3. substring search over name/email/slug
138
+ *
139
+ * The first tier that yields any match decides the result:
140
+ * - exactly one match with an email → `found`
141
+ * - exactly one match, no email → `no_email`
142
+ * - more than one match → `ambiguous` (caller disambiguates)
143
+ * - no match in any tier → `not_found`
144
+ */
145
+ export function resolveNameToEmail(people, name) {
146
+ const query = name.trim();
147
+ if (!query)
148
+ return { status: "not_found" };
149
+ const lowered = query.toLowerCase();
150
+ const exactName = people.filter((p) => p.name.toLowerCase() === lowered);
151
+ const exactSlug = people.filter((p) => p.slug.toLowerCase() === lowered);
152
+ const substring = searchPeople(people, query);
153
+ const matches = exactName.length > 0
154
+ ? exactName
155
+ : exactSlug.length > 0
156
+ ? exactSlug
157
+ : substring;
158
+ if (matches.length === 0)
159
+ return { status: "not_found" };
160
+ if (matches.length > 1)
161
+ return { status: "ambiguous", matches };
162
+ const person = matches[0];
163
+ if (!person.email)
164
+ return { status: "no_email", person };
165
+ return { status: "found", email: person.email, person };
166
+ }
167
+ //# sourceMappingURL=people.js.map
168
+ //# debugId=aac5adc3-c336-5d9a-8a1a-edaf16ef4c11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.47.14",
3
+ "version": "5.47.16",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -26,6 +26,7 @@ import {
26
26
  runScanPackages,
27
27
  stampInstallSource,
28
28
  validateManifest,
29
+ getStartedLine,
29
30
  toMarketplaceListing,
30
31
  fetchMarketplace,
31
32
  computeArtifactHash,
@@ -356,6 +357,218 @@ describe('pack-install: install path layout', () => {
356
357
  });
357
358
  });
358
359
 
360
+ // ---- 8. initialization.entrypoint validation (US-004) -------------------
361
+ // A pack may declare an `initialization` block whose `entrypoint` MUST resolve
362
+ // to a declared contributes skill/command. The block is OPTIONAL and
363
+ // backwards-compatible: legacy packs without it still validate. Slash
364
+ // normalization lets `/foo` match a contributes entry named `foo` (and v.v.).
365
+ describe('initialization.entrypoint (US-004)', () => {
366
+ // A valid pack that contributes one skill (`email-assistant`) and one
367
+ // command (`triage`), so an entrypoint can resolve to either. The only
368
+ // variable across these tests is the `initialization` block (extraYaml).
369
+ function writePackWithInit(extraYaml: string): string {
370
+ const dir = mkFakePackPayload({
371
+ 'skills/email-assistant/SKILL.md': '# email assistant skill',
372
+ 'commands/triage.md': '# triage command',
373
+ });
374
+ fs.writeFileSync(
375
+ path.join(dir, 'package.yaml'),
376
+ [
377
+ 'name: hq-pack-test',
378
+ 'version: 1.0.0',
379
+ "publisher: '@indigoai-us'",
380
+ 'access: public',
381
+ 'requires:',
382
+ " hqCore: '>=12.0.0'",
383
+ 'contributes:',
384
+ ' skills:',
385
+ ' - email-assistant',
386
+ ' commands:',
387
+ ' - triage',
388
+ extraYaml,
389
+ '',
390
+ ].join('\n'),
391
+ );
392
+ return dir;
393
+ }
394
+
395
+ it('accepts an entrypoint matching a contributes.skills entry', () => {
396
+ const dir = writePackWithInit(
397
+ ['initialization:', ' entrypoint: email-assistant'].join('\n'),
398
+ );
399
+ const m = validateManifest(dir, '12.0.0');
400
+ expect(m.name).toBe('hq-pack-test');
401
+ fs.rmSync(dir, { recursive: true, force: true });
402
+ });
403
+
404
+ it('accepts a leading-slash entrypoint matching a no-slash contributes entry', () => {
405
+ const dir = writePackWithInit(
406
+ ['initialization:', ' entrypoint: /email-assistant'].join('\n'),
407
+ );
408
+ const m = validateManifest(dir, '12.0.0');
409
+ expect(m.name).toBe('hq-pack-test');
410
+ fs.rmSync(dir, { recursive: true, force: true });
411
+ });
412
+
413
+ it('accepts a no-slash entrypoint matching a slashed contributes entry (vice-versa)', () => {
414
+ // Contributes declares `/onboard` (slashed); the entrypoint is `onboard`
415
+ // (no slash). The payload-file check computes `commands/${i}.md` which
416
+ // path.join normalizes (`commands//onboard.md` → `commands/onboard.md`),
417
+ // so a single `commands/onboard.md` file satisfies it.
418
+ const dir = mkFakePackPayload({
419
+ 'commands/onboard.md': '# onboard command',
420
+ });
421
+ fs.writeFileSync(
422
+ path.join(dir, 'package.yaml'),
423
+ [
424
+ 'name: hq-pack-test',
425
+ 'version: 1.0.0',
426
+ "publisher: '@indigoai-us'",
427
+ 'access: public',
428
+ 'requires:',
429
+ " hqCore: '>=12.0.0'",
430
+ 'contributes:',
431
+ ' commands:',
432
+ ' - /onboard',
433
+ 'initialization:',
434
+ ' entrypoint: onboard',
435
+ '',
436
+ ].join('\n'),
437
+ );
438
+ const m = validateManifest(dir, '12.0.0');
439
+ expect(m.name).toBe('hq-pack-test');
440
+ fs.rmSync(dir, { recursive: true, force: true });
441
+ });
442
+
443
+ it('accepts an entrypoint matching a contributes.commands entry', () => {
444
+ const dir = writePackWithInit(
445
+ ['initialization:', ' entrypoint: triage'].join('\n'),
446
+ );
447
+ const m = validateManifest(dir, '12.0.0');
448
+ expect(m.name).toBe('hq-pack-test');
449
+ fs.rmSync(dir, { recursive: true, force: true });
450
+ });
451
+
452
+ it('rejects an entrypoint that matches no contributes skill/command', () => {
453
+ const dir = writePackWithInit(
454
+ ['initialization:', ' entrypoint: does-not-exist'].join('\n'),
455
+ );
456
+ expect(() => validateManifest(dir, '12.0.0')).toThrow(
457
+ /initialization\.entrypoint.*does not resolve/,
458
+ );
459
+ fs.rmSync(dir, { recursive: true, force: true });
460
+ });
461
+
462
+ it('rejects an initialization block with a missing entrypoint', () => {
463
+ const dir = writePackWithInit(
464
+ ['initialization:', ' prompt: hello'].join('\n'),
465
+ );
466
+ expect(() => validateManifest(dir, '12.0.0')).toThrow(
467
+ /initialization\.entrypoint is required/,
468
+ );
469
+ fs.rmSync(dir, { recursive: true, force: true });
470
+ });
471
+
472
+ it('rejects an initialization block with an empty entrypoint', () => {
473
+ const dir = writePackWithInit(
474
+ ['initialization:', " entrypoint: ''"].join('\n'),
475
+ );
476
+ expect(() => validateManifest(dir, '12.0.0')).toThrow(
477
+ /initialization\.entrypoint is required/,
478
+ );
479
+ fs.rmSync(dir, { recursive: true, force: true });
480
+ });
481
+
482
+ it('accepts an initialization.prompt at the 2000-char limit', () => {
483
+ const dir = writePackWithInit(
484
+ [
485
+ 'initialization:',
486
+ ' entrypoint: email-assistant',
487
+ ` prompt: '${'a'.repeat(2000)}'`,
488
+ ].join('\n'),
489
+ );
490
+ const m = validateManifest(dir, '12.0.0');
491
+ expect(m.name).toBe('hq-pack-test');
492
+ fs.rmSync(dir, { recursive: true, force: true });
493
+ });
494
+
495
+ it('rejects an initialization.prompt longer than 2000 chars', () => {
496
+ const dir = writePackWithInit(
497
+ [
498
+ 'initialization:',
499
+ ' entrypoint: email-assistant',
500
+ ` prompt: '${'a'.repeat(2001)}'`,
501
+ ].join('\n'),
502
+ );
503
+ expect(() => validateManifest(dir, '12.0.0')).toThrow(
504
+ /initialization\.prompt must be ≤ 2000 characters/,
505
+ );
506
+ fs.rmSync(dir, { recursive: true, force: true });
507
+ });
508
+
509
+ it('rejects a non-string initialization.prompt', () => {
510
+ const dir = writePackWithInit(
511
+ ['initialization:', ' entrypoint: email-assistant', ' prompt: 42'].join('\n'),
512
+ );
513
+ expect(() => validateManifest(dir, '12.0.0')).toThrow(
514
+ /initialization\.prompt must be a string/,
515
+ );
516
+ fs.rmSync(dir, { recursive: true, force: true });
517
+ });
518
+
519
+ it('a manifest WITHOUT an initialization block still validates (backwards-compatible)', () => {
520
+ const dir = writePackWithInit('');
521
+ const m = validateManifest(dir, '12.0.0');
522
+ expect(m.name).toBe('hq-pack-test');
523
+ expect((m as unknown as Record<string, unknown>).initialization).toBeUndefined();
524
+ fs.rmSync(dir, { recursive: true, force: true });
525
+ });
526
+ });
527
+
528
+ // ---- 8b. get-started line derivation (US-005) ---------------------------
529
+ // After a successful install, `installPack` prints a safe, auto-generated
530
+ // "get started" line derived from `initialization.entrypoint` ONLY (never the
531
+ // free-text prompt). The line matches the HQ Sync desktop wording and the
532
+ // command is normalized to exactly one leading slash. No initialization block
533
+ // → no extra line (backwards-compatible).
534
+ describe('getStartedLine (US-005)', () => {
535
+ it('renders a get-started line referencing the entrypoint', () => {
536
+ expect(getStartedLine({ entrypoint: 'email-assistant' })).toBe(
537
+ 'Run `/email-assistant` to get started',
538
+ );
539
+ });
540
+
541
+ it('slash-normalizes an entrypoint already stored with a leading slash', () => {
542
+ expect(getStartedLine({ entrypoint: '/email-assistant' })).toBe(
543
+ 'Run `/email-assistant` to get started',
544
+ );
545
+ });
546
+
547
+ it('collapses multiple leading slashes to exactly one', () => {
548
+ expect(getStartedLine({ entrypoint: '///triage' })).toBe(
549
+ 'Run `/triage` to get started',
550
+ );
551
+ });
552
+
553
+ it('ignores the free-text prompt prose (PHASE 1 — entrypoint only)', () => {
554
+ const line = getStartedLine({
555
+ entrypoint: 'email-assistant',
556
+ prompt: 'do not surface this untrusted prose',
557
+ });
558
+ expect(line).toBe('Run `/email-assistant` to get started');
559
+ expect(line).not.toContain('untrusted');
560
+ });
561
+
562
+ it('returns null when there is no initialization block (prints nothing extra)', () => {
563
+ expect(getStartedLine(undefined)).toBeNull();
564
+ });
565
+
566
+ it('returns null for an empty/blank entrypoint', () => {
567
+ expect(getStartedLine({ entrypoint: '' })).toBeNull();
568
+ expect(getStartedLine({ entrypoint: ' ' })).toBeNull();
569
+ });
570
+ });
571
+
359
572
  it('stampInstallSource preserves a leading `---` document marker — no multi-doc YAML stream', () => {
360
573
  // Regression: prepending `source:` before a `---` would split the file
361
574
  // into two YAML documents, and single-doc `yaml.load` callers downstream
@@ -1084,9 +1084,84 @@ export function validateManifest(
1084
1084
  throw new Error('capabilities must be a list of strings');
1085
1085
  }
1086
1086
  }
1087
+ // initialization (US-004) — OPTIONAL and backwards-compatible. Absent → fine
1088
+ // (legacy packs). Present → the `entrypoint` is REQUIRED and MUST resolve to a
1089
+ // declared `contributes.skills` or `contributes.commands` entry (this is the
1090
+ // content-pack system, so entries are named under `contributes.*` — NOT the
1091
+ // registry `exposes.*` system). The post-install initialization prompt is
1092
+ // rendered/moderated in a later story; here we only validate shape so a
1093
+ // malformed block can't slip through to install.
1094
+ if (m.initialization !== undefined) {
1095
+ const init = m.initialization as unknown;
1096
+ if (!init || typeof init !== 'object' || Array.isArray(init)) {
1097
+ throw new Error('initialization must be a mapping with an entrypoint');
1098
+ }
1099
+ const initObj = init as Record<string, unknown>;
1100
+ const entrypoint = initObj.entrypoint;
1101
+ if (typeof entrypoint !== 'string' || entrypoint.trim() === '') {
1102
+ throw new Error(
1103
+ 'initialization.entrypoint is required and must be a non-empty string',
1104
+ );
1105
+ }
1106
+ // Resolve the entrypoint against declared skills/commands. Normalize a
1107
+ // leading slash on BOTH sides so `/email-assistant` matches a contributes
1108
+ // entry named `email-assistant` and vice-versa.
1109
+ const stripSlash = (s: string): string => (s.startsWith('/') ? s.slice(1) : s);
1110
+ const target = stripSlash(entrypoint.trim());
1111
+ const declared = [
1112
+ ...(contributes.skills ?? []),
1113
+ ...(contributes.commands ?? []),
1114
+ ];
1115
+ const resolves = declared.some((d) => stripSlash(d) === target);
1116
+ if (!resolves) {
1117
+ throw new Error(
1118
+ `initialization.entrypoint "${entrypoint}" does not resolve to a declared ` +
1119
+ `contributes.skills or contributes.commands entry. ` +
1120
+ `Valid entries: ${declared.length ? declared.join(', ') : '(none declared)'}`,
1121
+ );
1122
+ }
1123
+ // initialization.prompt — OPTIONAL. When present it must be a string ≤ 2000
1124
+ // chars. (Rendering/moderation is a later story; we only validate type/length.)
1125
+ if (initObj.prompt !== undefined) {
1126
+ if (typeof initObj.prompt !== 'string') {
1127
+ throw new Error('initialization.prompt must be a string');
1128
+ }
1129
+ if (initObj.prompt.length > 2000) {
1130
+ throw new Error(
1131
+ `initialization.prompt must be ≤ 2000 characters (got ${initObj.prompt.length})`,
1132
+ );
1133
+ }
1134
+ }
1135
+ }
1087
1136
  return m as PackManifest;
1088
1137
  }
1089
1138
 
1139
+ // ---------------------------------------------------------------------------
1140
+ // Post-install get-started line (US-005)
1141
+ // ---------------------------------------------------------------------------
1142
+
1143
+ /**
1144
+ * Derive the safe, auto-generated "get started" line for a freshly installed
1145
+ * pack from its `initialization.entrypoint` ONLY. PHASE 1 deliberately ignores
1146
+ * the free-text `initialization.prompt` prose (rendering/moderation is a later
1147
+ * story) so untrusted prose can't reach the operator's terminal.
1148
+ *
1149
+ * The command is slash-normalized to exactly one leading slash regardless of
1150
+ * whether `entrypoint` was stored with or without one, matching the HQ Sync
1151
+ * desktop render: ``Run `/email-assistant` to get started``.
1152
+ *
1153
+ * Returns `null` when there is no initialization block (backwards-compatible —
1154
+ * the caller prints nothing extra).
1155
+ */
1156
+ export function getStartedLine(
1157
+ initialization?: PackManifest['initialization'],
1158
+ ): string | null {
1159
+ const entrypoint = initialization?.entrypoint;
1160
+ if (typeof entrypoint !== 'string' || entrypoint.trim() === '') return null;
1161
+ const command = '/' + entrypoint.trim().replace(/^\/+/, '');
1162
+ return `Run \`${command}\` to get started`;
1163
+ }
1164
+
1090
1165
  // ---------------------------------------------------------------------------
1091
1166
  // Hooks confirmation
1092
1167
  // ---------------------------------------------------------------------------
@@ -1452,6 +1527,13 @@ export async function installPack(
1452
1527
  `contribution(s) into host-side paths.`
1453
1528
  )
1454
1529
  );
1530
+ // US-005 — when the pack declares an `initialization` block, print a safe,
1531
+ // auto-generated "get started" line right after the success output. PHASE 1
1532
+ // derives the line from `initialization.entrypoint` ONLY (never the
1533
+ // free-text `initialization.prompt` prose), and matches the HQ Sync desktop
1534
+ // wording for consistency. Absent block → nothing extra (backwards-compat).
1535
+ const getStarted = getStartedLine(pkg.initialization);
1536
+ if (getStarted) say(chalk.cyan(getStarted));
1455
1537
  } finally {
1456
1538
  fs.rmSync(tmpDir, { recursive: true, force: true });
1457
1539
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * packs list view tests — guards the machine-facing `hq packs list --json`
3
+ * shape consumed by the HQ Sync menubar app.
4
+ *
5
+ * US-005: `buildInstalledView` must surface a pack's `initialization` block so
6
+ * the desktop "Installed" panel can render its get-started affordance. The
7
+ * field is OPTIONAL — absent on legacy packs, in which case it is omitted from
8
+ * the view entirely (no null noise in the JSON).
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { buildInstalledView } from './packs.js';
13
+ import type {
14
+ InstalledPack,
15
+ InstalledPackManifest,
16
+ } from '../utils/pack-contributions.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Fixtures
20
+ // ---------------------------------------------------------------------------
21
+
22
+ function fakeInstalledPack(
23
+ manifestOverrides: Partial<InstalledPackManifest>,
24
+ ): InstalledPack {
25
+ const manifest: InstalledPackManifest = {
26
+ name: 'hq-pack-test',
27
+ version: '1.0.0',
28
+ publisher: '@indigoai-us',
29
+ access: 'public',
30
+ requires: { hqCore: '>=12.0.0' },
31
+ contributes: { skills: ['email-assistant'] },
32
+ ...manifestOverrides,
33
+ } as InstalledPackManifest;
34
+ return {
35
+ name: 'hq-pack-test',
36
+ // A non-existent dir is fine — contributionLinks classifies missing links
37
+ // without throwing, and we never check updates (no network).
38
+ dir: '/tmp/does-not-exist/hq-pack-test',
39
+ manifest,
40
+ };
41
+ }
42
+
43
+ // `checkUpdates: false` keeps the call offline (no resolveLatest network hop).
44
+ function build(pack: InstalledPack) {
45
+ return buildInstalledView('/tmp/does-not-exist', '12.0.0', pack, new Set(), false);
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Tests
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe('buildInstalledView: initialization surfacing (US-005)', () => {
53
+ it('surfaces the initialization block when the pack declares one', () => {
54
+ const view = build(
55
+ fakeInstalledPack({
56
+ initialization: { entrypoint: 'email-assistant', prompt: 'hi there' },
57
+ }),
58
+ );
59
+ expect(view.initialization).toEqual({
60
+ entrypoint: 'email-assistant',
61
+ prompt: 'hi there',
62
+ });
63
+ });
64
+
65
+ it('surfaces initialization with no prompt (entrypoint only)', () => {
66
+ const view = build(
67
+ fakeInstalledPack({ initialization: { entrypoint: '/email-assistant' } }),
68
+ );
69
+ expect(view.initialization).toEqual({ entrypoint: '/email-assistant' });
70
+ expect(view.initialization).not.toHaveProperty('prompt');
71
+ });
72
+
73
+ it('omits the initialization field entirely when the pack declares none', () => {
74
+ const view = build(fakeInstalledPack({}));
75
+ expect(view.initialization).toBeUndefined();
76
+ expect(Object.prototype.hasOwnProperty.call(view, 'initialization')).toBe(false);
77
+ });
78
+
79
+ it('tolerates a malformed initialization block by omitting the field', () => {
80
+ const view = build(
81
+ fakeInstalledPack({
82
+ // An empty entrypoint is not a usable get-started target.
83
+ initialization: { entrypoint: ' ' },
84
+ }),
85
+ );
86
+ expect(view.initialization).toBeUndefined();
87
+ });
88
+ });
@@ -111,6 +111,12 @@ interface InstalledPackView {
111
111
  brokenLinks: Array<{ key: PackContributeKey; item: string; dst: string }>;
112
112
  inCatalog: boolean;
113
113
  updateAvailable: boolean | null;
114
+ /**
115
+ * Post-install initialization (US-005). Present only when the pack's
116
+ * package.yaml declares an `initialization` block — drives the HQ Sync
117
+ * "Installed" panel get-started affordance. Absent → omitted (no null noise).
118
+ */
119
+ initialization?: { entrypoint: string; prompt?: string };
114
120
  error?: string;
115
121
  }
116
122
 
@@ -130,7 +136,7 @@ interface PacksListView {
130
136
  warnings: string[];
131
137
  }
132
138
 
133
- function buildInstalledView(
139
+ export function buildInstalledView(
134
140
  hqRoot: string,
135
141
  hqVersion: string | null,
136
142
  pack: InstalledPack,
@@ -173,6 +179,22 @@ function buildInstalledView(
173
179
  updateAvailable = resolveLatest(m.source, m.version).updateAvailable;
174
180
  }
175
181
 
182
+ // US-005 — surface the pack's `initialization` block so the HQ Sync
183
+ // "Installed" panel can render its get-started affordance. `readPackManifest`
184
+ // already parses the full package.yaml, so `m.initialization` is available;
185
+ // we still shape it defensively (tolerate a malformed/absent block) and omit
186
+ // the field entirely when absent so the JSON carries no null noise.
187
+ let initialization: InstalledPackView['initialization'];
188
+ const rawInit = m.initialization as unknown;
189
+ if (rawInit && typeof rawInit === 'object' && !Array.isArray(rawInit)) {
190
+ const initObj = rawInit as Record<string, unknown>;
191
+ const entrypoint = initObj.entrypoint;
192
+ if (typeof entrypoint === 'string' && entrypoint.trim() !== '') {
193
+ initialization = { entrypoint };
194
+ if (typeof initObj.prompt === 'string') initialization.prompt = initObj.prompt;
195
+ }
196
+ }
197
+
176
198
  return {
177
199
  name: m.name ?? pack.name,
178
200
  version: m.version,
@@ -186,6 +208,7 @@ function buildInstalledView(
186
208
  brokenLinks,
187
209
  inCatalog: m.source ? installedSources.has(m.source) : false,
188
210
  updateAvailable,
211
+ ...(initialization ? { initialization } : {}),
189
212
  };
190
213
  }
191
214