@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.
- package/dist/commands/pack-install.d.ts +14 -0
- package/dist/commands/pack-install.js +76 -2
- package/dist/commands/packs.d.ts +32 -0
- package/dist/commands/packs.js +20 -3
- package/dist/types.d.ts +10 -0
- package/package.json +1 -1
- package/src/commands/pack-install.test.ts +213 -0
- package/src/commands/pack-install.ts +82 -0
- package/src/commands/packs.test.ts +88 -0
- package/src/commands/packs.ts +24 -1
- package/src/schemas/hq-package.schema.json +18 -0
- package/src/types.ts +7 -0
|
@@ -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]="
|
|
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=
|
|
1153
|
+
//# debugId=cb8b5583-b60f-52a5-802d-befc33f2a07b
|
package/dist/commands/packs.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/packs.js
CHANGED
|
@@ -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]="
|
|
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=
|
|
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
|
@@ -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
|
+
});
|
package/src/commands/packs.ts
CHANGED
|
@@ -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
|
}
|