@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.
- 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/commands/people.d.ts +22 -0
- package/dist/commands/people.js +187 -0
- package/dist/index.js +6 -2
- package/dist/types.d.ts +10 -0
- package/dist/utils/people.d.ts +99 -0
- package/dist/utils/people.js +168 -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/commands/people.test.ts +426 -0
- package/src/commands/people.ts +240 -0
- package/src/index.ts +4 -0
- package/src/schemas/hq-package.schema.json +18 -0
- package/src/types.ts +7 -0
- package/src/utils/people.ts +222 -0
|
@@ -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
|
@@ -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
|
|