@indigoai-us/hq-cli 5.47.14 → 5.47.15

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.
@@ -238,6 +238,20 @@ export declare function computeArtifactHash(tarballBytes: Uint8Array): string;
238
238
  */
239
239
  export declare function verifyArtifact(input: VerifyArtifactInput): void;
240
240
  export declare function validateManifest(payloadDir: string, hqVersion: string | null): PackManifest;
241
+ /**
242
+ * Derive the safe, auto-generated "get started" line for a freshly installed
243
+ * pack from its `initialization.entrypoint` ONLY. PHASE 1 deliberately ignores
244
+ * the free-text `initialization.prompt` prose (rendering/moderation is a later
245
+ * story) so untrusted prose can't reach the operator's terminal.
246
+ *
247
+ * The command is slash-normalized to exactly one leading slash regardless of
248
+ * whether `entrypoint` was stored with or without one, matching the HQ Sync
249
+ * desktop render: ``Run `/email-assistant` to get started``.
250
+ *
251
+ * Returns `null` when there is no initialization block (backwards-compatible —
252
+ * the caller prints nothing extra).
253
+ */
254
+ export declare function getStartedLine(initialization?: PackManifest['initialization']): string | null;
241
255
  /**
242
256
  * Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
243
257
  * v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
@@ -34,7 +34,7 @@
34
34
  * from each pack's package.yaml; rationale lives in the layout-fix PR.)
35
35
  */
36
36
 
37
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="cbb11285-034d-50c4-917d-797ab226db66")}catch(e){}}();
37
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="cb8b5583-b60f-52a5-802d-befc33f2a07b")}catch(e){}}();
38
38
  import * as fs from 'fs';
39
39
  import * as os from 'os';
40
40
  import * as path from 'path';
@@ -807,9 +807,75 @@ export function validateManifest(payloadDir, hqVersion) {
807
807
  throw new Error('capabilities must be a list of strings');
808
808
  }
809
809
  }
810
+ // initialization (US-004) — OPTIONAL and backwards-compatible. Absent → fine
811
+ // (legacy packs). Present → the `entrypoint` is REQUIRED and MUST resolve to a
812
+ // declared `contributes.skills` or `contributes.commands` entry (this is the
813
+ // content-pack system, so entries are named under `contributes.*` — NOT the
814
+ // registry `exposes.*` system). The post-install initialization prompt is
815
+ // rendered/moderated in a later story; here we only validate shape so a
816
+ // malformed block can't slip through to install.
817
+ if (m.initialization !== undefined) {
818
+ const init = m.initialization;
819
+ if (!init || typeof init !== 'object' || Array.isArray(init)) {
820
+ throw new Error('initialization must be a mapping with an entrypoint');
821
+ }
822
+ const initObj = init;
823
+ const entrypoint = initObj.entrypoint;
824
+ if (typeof entrypoint !== 'string' || entrypoint.trim() === '') {
825
+ throw new Error('initialization.entrypoint is required and must be a non-empty string');
826
+ }
827
+ // Resolve the entrypoint against declared skills/commands. Normalize a
828
+ // leading slash on BOTH sides so `/email-assistant` matches a contributes
829
+ // entry named `email-assistant` and vice-versa.
830
+ const stripSlash = (s) => (s.startsWith('/') ? s.slice(1) : s);
831
+ const target = stripSlash(entrypoint.trim());
832
+ const declared = [
833
+ ...(contributes.skills ?? []),
834
+ ...(contributes.commands ?? []),
835
+ ];
836
+ const resolves = declared.some((d) => stripSlash(d) === target);
837
+ if (!resolves) {
838
+ throw new Error(`initialization.entrypoint "${entrypoint}" does not resolve to a declared ` +
839
+ `contributes.skills or contributes.commands entry. ` +
840
+ `Valid entries: ${declared.length ? declared.join(', ') : '(none declared)'}`);
841
+ }
842
+ // initialization.prompt — OPTIONAL. When present it must be a string ≤ 2000
843
+ // chars. (Rendering/moderation is a later story; we only validate type/length.)
844
+ if (initObj.prompt !== undefined) {
845
+ if (typeof initObj.prompt !== 'string') {
846
+ throw new Error('initialization.prompt must be a string');
847
+ }
848
+ if (initObj.prompt.length > 2000) {
849
+ throw new Error(`initialization.prompt must be ≤ 2000 characters (got ${initObj.prompt.length})`);
850
+ }
851
+ }
852
+ }
810
853
  return m;
811
854
  }
812
855
  // ---------------------------------------------------------------------------
856
+ // Post-install get-started line (US-005)
857
+ // ---------------------------------------------------------------------------
858
+ /**
859
+ * Derive the safe, auto-generated "get started" line for a freshly installed
860
+ * pack from its `initialization.entrypoint` ONLY. PHASE 1 deliberately ignores
861
+ * the free-text `initialization.prompt` prose (rendering/moderation is a later
862
+ * story) so untrusted prose can't reach the operator's terminal.
863
+ *
864
+ * The command is slash-normalized to exactly one leading slash regardless of
865
+ * whether `entrypoint` was stored with or without one, matching the HQ Sync
866
+ * desktop render: ``Run `/email-assistant` to get started``.
867
+ *
868
+ * Returns `null` when there is no initialization block (backwards-compatible —
869
+ * the caller prints nothing extra).
870
+ */
871
+ export function getStartedLine(initialization) {
872
+ const entrypoint = initialization?.entrypoint;
873
+ if (typeof entrypoint !== 'string' || entrypoint.trim() === '')
874
+ return null;
875
+ const command = '/' + entrypoint.trim().replace(/^\/+/, '');
876
+ return `Run \`${command}\` to get started`;
877
+ }
878
+ // ---------------------------------------------------------------------------
813
879
  // Hooks confirmation
814
880
  // ---------------------------------------------------------------------------
815
881
  async function confirmHooks(pkg, allowHooks) {
@@ -1070,10 +1136,18 @@ export async function installPack(source, opts = {}) {
1070
1136
  say(chalk.green(`\nOK Installed ${pkg.name}@${pkg.version} -> ${path.relative(hqRoot, destDir)}/`));
1071
1137
  say(chalk.dim(` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
1072
1138
  `contribution(s) into host-side paths.`));
1139
+ // US-005 — when the pack declares an `initialization` block, print a safe,
1140
+ // auto-generated "get started" line right after the success output. PHASE 1
1141
+ // derives the line from `initialization.entrypoint` ONLY (never the
1142
+ // free-text `initialization.prompt` prose), and matches the HQ Sync desktop
1143
+ // wording for consistency. Absent block → nothing extra (backwards-compat).
1144
+ const getStarted = getStartedLine(pkg.initialization);
1145
+ if (getStarted)
1146
+ say(chalk.cyan(getStarted));
1073
1147
  }
1074
1148
  finally {
1075
1149
  fs.rmSync(tmpDir, { recursive: true, force: true });
1076
1150
  }
1077
1151
  }
1078
1152
  //# sourceMappingURL=pack-install.js.map
1079
- //# debugId=cbb11285-034d-50c4-917d-797ab226db66
1153
+ //# debugId=cb8b5583-b60f-52a5-802d-befc33f2a07b
@@ -18,5 +18,37 @@
18
18
  * Spec: knowledge/public/hq-core/package-yaml-spec.md.
19
19
  */
20
20
  import { Command } from 'commander';
21
+ import { type InstalledPack, type LinkStatus } from '../utils/pack-contributions.js';
22
+ import type { PackContributeKey } from '../types.js';
23
+ interface InstalledPackView {
24
+ name: string;
25
+ version?: string;
26
+ publisher?: string;
27
+ source?: string;
28
+ transport: string | null;
29
+ requiresHqCore?: string;
30
+ hqCoreSatisfied: boolean | null;
31
+ contributes: Partial<Record<PackContributeKey, number>>;
32
+ links: Record<LinkStatus, number>;
33
+ brokenLinks: Array<{
34
+ key: PackContributeKey;
35
+ item: string;
36
+ dst: string;
37
+ }>;
38
+ inCatalog: boolean;
39
+ updateAvailable: boolean | null;
40
+ /**
41
+ * Post-install initialization (US-005). Present only when the pack's
42
+ * package.yaml declares an `initialization` block — drives the HQ Sync
43
+ * "Installed" panel get-started affordance. Absent → omitted (no null noise).
44
+ */
45
+ initialization?: {
46
+ entrypoint: string;
47
+ prompt?: string;
48
+ };
49
+ error?: string;
50
+ }
51
+ export declare function buildInstalledView(hqRoot: string, hqVersion: string | null, pack: InstalledPack, installedSources: Set<string>, checkUpdates: boolean): InstalledPackView;
21
52
  export declare function registerPacksCommand(parent: Command): void;
53
+ export {};
22
54
  //# sourceMappingURL=packs.d.ts.map
@@ -18,7 +18,7 @@
18
18
  * Spec: knowledge/public/hq-core/package-yaml-spec.md.
19
19
  */
20
20
 
21
- !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]="fa6cf3bf-90fa-5261-a15f-c8810e4b5615")}catch(e){}}();
21
+ !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]="527d0e7d-2101-5438-8251-2579a40453e6")}catch(e){}}();
22
22
  import * as fs from 'fs';
23
23
  import * as path from 'path';
24
24
  import * as readline from 'readline';
@@ -61,7 +61,7 @@ async function confirm(question) {
61
61
  });
62
62
  return /^(y|yes)$/i.test(answer.trim());
63
63
  }
64
- function buildInstalledView(hqRoot, hqVersion, pack, installedSources, checkUpdates) {
64
+ export function buildInstalledView(hqRoot, hqVersion, pack, installedSources, checkUpdates) {
65
65
  if (!pack.manifest) {
66
66
  return {
67
67
  name: pack.name,
@@ -97,6 +97,22 @@ function buildInstalledView(hqRoot, hqVersion, pack, installedSources, checkUpda
97
97
  if (checkUpdates && m.source) {
98
98
  updateAvailable = resolveLatest(m.source, m.version).updateAvailable;
99
99
  }
100
+ // US-005 — surface the pack's `initialization` block so the HQ Sync
101
+ // "Installed" panel can render its get-started affordance. `readPackManifest`
102
+ // already parses the full package.yaml, so `m.initialization` is available;
103
+ // we still shape it defensively (tolerate a malformed/absent block) and omit
104
+ // the field entirely when absent so the JSON carries no null noise.
105
+ let initialization;
106
+ const rawInit = m.initialization;
107
+ if (rawInit && typeof rawInit === 'object' && !Array.isArray(rawInit)) {
108
+ const initObj = rawInit;
109
+ const entrypoint = initObj.entrypoint;
110
+ if (typeof entrypoint === 'string' && entrypoint.trim() !== '') {
111
+ initialization = { entrypoint };
112
+ if (typeof initObj.prompt === 'string')
113
+ initialization.prompt = initObj.prompt;
114
+ }
115
+ }
100
116
  return {
101
117
  name: m.name ?? pack.name,
102
118
  version: m.version,
@@ -110,6 +126,7 @@ function buildInstalledView(hqRoot, hqVersion, pack, installedSources, checkUpda
110
126
  brokenLinks,
111
127
  inCatalog: m.source ? installedSources.has(m.source) : false,
112
128
  updateAvailable,
129
+ ...(initialization ? { initialization } : {}),
113
130
  };
114
131
  }
115
132
  function buildListView(hqRoot, checkUpdates, evalConditionals) {
@@ -408,4 +425,4 @@ export function registerPacksCommand(parent) {
408
425
  });
409
426
  }
410
427
  //# sourceMappingURL=packs.js.map
411
- //# debugId=fa6cf3bf-90fa-5261-a15f-c8810e4b5615
428
+ //# debugId=527d0e7d-2101-5438-8251-2579a40453e6
package/dist/types.d.ts CHANGED
@@ -93,5 +93,15 @@ export interface PackManifest {
93
93
  * yet enforced.
94
94
  */
95
95
  capabilities?: string[];
96
+ /**
97
+ * Post-install initialization (US-004/US-005). Optional — absent on legacy
98
+ * packs. `entrypoint` names a declared `contributes.skills`/`commands` entry
99
+ * (slash-normalized); `prompt` is optional free-text prose (PHASE 1 does NOT
100
+ * render the prose — only an auto-generated get-started line from entrypoint).
101
+ */
102
+ initialization?: {
103
+ entrypoint: string;
104
+ prompt?: string;
105
+ };
96
106
  }
97
107
  //# sourceMappingURL=types.d.ts.map
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.15",
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
 
@@ -132,6 +132,24 @@
132
132
  "minLength": 1
133
133
  }
134
134
  }
135
+ },
136
+ "initialization": {
137
+ "type": "object",
138
+ "description": "Optional post-install onboarding. `entrypoint` is the pack's primary get-started action (a skill or command that must resolve to one of this package's exposes.skills / exposes.commands entries); HQ surfaces a safe auto-generated 'get started' line from it after install. `prompt` is optional free-text the user can copy/paste into their agent to begin setup — treated as UNTRUSTED instruction text, surfaced only after marketplace injection-scan/moderation (suppressed for non-marketplace installs).",
139
+ "additionalProperties": false,
140
+ "properties": {
141
+ "entrypoint": {
142
+ "type": "string",
143
+ "minLength": 1,
144
+ "description": "Primary get-started action — a skill or command name (with or without a leading slash) that must resolve to a declared exposes.skills / exposes.commands entry."
145
+ },
146
+ "prompt": {
147
+ "type": "string",
148
+ "maxLength": 2000,
149
+ "description": "Optional copy/paste setup prompt. Untrusted; surfaced only after moderation for marketplace packs, suppressed for local/git installs."
150
+ }
151
+ },
152
+ "required": ["entrypoint"]
135
153
  }
136
154
  }
137
155
  }
package/src/types.ts CHANGED
@@ -117,4 +117,11 @@ export interface PackManifest {
117
117
  * yet enforced.
118
118
  */
119
119
  capabilities?: string[];
120
+ /**
121
+ * Post-install initialization (US-004/US-005). Optional — absent on legacy
122
+ * packs. `entrypoint` names a declared `contributes.skills`/`commands` entry
123
+ * (slash-normalized); `prompt` is optional free-text prose (PHASE 1 does NOT
124
+ * render the prose — only an auto-generated get-started line from entrypoint).
125
+ */
126
+ initialization?: { entrypoint: string; prompt?: string };
120
127
  }