@indigoai-us/hq-cli 5.47.15 → 5.47.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Unit tests for the `hq people` capability:
3
+ * - utils/people.ts — parse, list, search, resolve (pure + filesystem)
4
+ * - people.ts — active-company resolution + wired list/search/resolve
5
+ */
6
+
7
+ import * as fs from "fs";
8
+ import * as os from "os";
9
+ import * as path from "path";
10
+ import { Command } from "commander";
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import {
14
+ assertSafeCompanySlug,
15
+ companyPeopleDir,
16
+ parsePersonMeta,
17
+ listCompanyPeople,
18
+ searchPeople,
19
+ resolveNameToEmail,
20
+ type PersonRecord,
21
+ } from "../utils/people.js";
22
+ import { registerPeopleCommand, resolveCompanySlug } from "./people.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Filesystem fixture helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ let tmpRoot: string;
29
+
30
+ function makePerson(
31
+ company: string,
32
+ slug: string,
33
+ meta: Record<string, unknown> | string,
34
+ ): void {
35
+ const dir = path.join(tmpRoot, "companies", company, "people", slug);
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ const body =
38
+ typeof meta === "string"
39
+ ? meta
40
+ : Object.entries(meta)
41
+ .map(([k, v]) =>
42
+ Array.isArray(v)
43
+ ? `${k}: [${v.join(", ")}]`
44
+ : `${k}: ${JSON.stringify(v)}`,
45
+ )
46
+ .join("\n");
47
+ fs.writeFileSync(path.join(dir, "meta.yaml"), body + "\n");
48
+ }
49
+
50
+ function writeCompaniesManifest(
51
+ companies: Record<string, Record<string, unknown> | null>,
52
+ ): void {
53
+ const dir = path.join(tmpRoot, "companies");
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ const yamlBody =
56
+ "companies:\n" +
57
+ Object.entries(companies)
58
+ .map(([slug, entry]) => {
59
+ if (!entry) return ` ${slug}:`;
60
+ const fields = Object.entries(entry)
61
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
62
+ .join("\n");
63
+ return ` ${slug}:\n${fields}`;
64
+ })
65
+ .join("\n") +
66
+ "\n";
67
+ fs.writeFileSync(path.join(dir, "manifest.yaml"), yamlBody);
68
+ }
69
+
70
+ beforeEach(() => {
71
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-people-"));
72
+ });
73
+
74
+ afterEach(() => {
75
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // assertSafeCompanySlug
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe("assertSafeCompanySlug", () => {
84
+ it("accepts ordinary slugs", () => {
85
+ expect(() => assertSafeCompanySlug("indigo")).not.toThrow();
86
+ expect(() => assertSafeCompanySlug("acme-co_1.2")).not.toThrow();
87
+ });
88
+
89
+ it("rejects path-traversal and separators", () => {
90
+ expect(() => assertSafeCompanySlug("..")).toThrow();
91
+ expect(() => assertSafeCompanySlug("a/b")).toThrow();
92
+ expect(() => assertSafeCompanySlug("../etc")).toThrow();
93
+ expect(() => assertSafeCompanySlug("")).toThrow();
94
+ });
95
+
96
+ it("rejects reserved slugs", () => {
97
+ expect(() => assertSafeCompanySlug("personal")).toThrow(/reserved/);
98
+ });
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // parsePersonMeta
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("parsePersonMeta", () => {
106
+ it("parses a full record and trims fields", () => {
107
+ const rec = parsePersonMeta(
108
+ "name: Jane Smith\nemail: jane@example.com\ntype: internal\nrole: Head of Eng\ntags: [eng, lead]\n",
109
+ "jane-smith",
110
+ "/x/meta.yaml",
111
+ );
112
+ expect(rec).toEqual({
113
+ slug: "jane-smith",
114
+ name: "Jane Smith",
115
+ email: "jane@example.com",
116
+ type: "internal",
117
+ role: "Head of Eng",
118
+ tags: ["eng", "lead"],
119
+ source: "/x/meta.yaml",
120
+ });
121
+ });
122
+
123
+ it("returns null when name is missing or blank", () => {
124
+ expect(parsePersonMeta("email: a@b.com\n", "x", "/x")).toBeNull();
125
+ expect(parsePersonMeta("name: ' '\n", "x", "/x")).toBeNull();
126
+ });
127
+
128
+ it("returns null for empty / non-object docs", () => {
129
+ expect(parsePersonMeta("", "x", "/x")).toBeNull();
130
+ expect(parsePersonMeta("- a\n- b\n", "x", "/x")).toBeNull();
131
+ });
132
+
133
+ it("throws on unparseable yaml", () => {
134
+ expect(() => parsePersonMeta("name: [unterminated\n", "x", "/x")).toThrow(
135
+ /Failed to parse/,
136
+ );
137
+ });
138
+ });
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // listCompanyPeople
142
+ // ---------------------------------------------------------------------------
143
+
144
+ describe("listCompanyPeople", () => {
145
+ it("returns [] when the company has no people directory", () => {
146
+ writeCompaniesManifest({ acme: { status: "active" } });
147
+ expect(listCompanyPeople(tmpRoot, "acme")).toEqual([]);
148
+ });
149
+
150
+ it("reads people, skips _scaffolding, nameless, and meta-less folders, sorted by name", () => {
151
+ makePerson("acme", "zed", { name: "Zed Zebra", email: "zed@acme.com" });
152
+ makePerson("acme", "ann", { name: "Ann Apple", email: "ann@acme.com" });
153
+ makePerson("acme", "_example", { name: "Template Person" });
154
+ makePerson("acme", "nameless", { email: "noone@acme.com" });
155
+ // folder with no meta.yaml
156
+ fs.mkdirSync(path.join(tmpRoot, "companies", "acme", "people", "empty"), {
157
+ recursive: true,
158
+ });
159
+
160
+ const people = listCompanyPeople(tmpRoot, "acme");
161
+ expect(people.map((p) => p.name)).toEqual(["Ann Apple", "Zed Zebra"]);
162
+ });
163
+
164
+ it("never reads outside the named company (tenancy)", () => {
165
+ makePerson("acme", "ann", { name: "Ann Apple", email: "ann@acme.com" });
166
+ makePerson("other", "bob", { name: "Bob Other", email: "bob@other.com" });
167
+ const people = listCompanyPeople(tmpRoot, "acme");
168
+ expect(people.map((p) => p.name)).toEqual(["Ann Apple"]);
169
+ });
170
+
171
+ it("rejects an unsafe company slug", () => {
172
+ expect(() => listCompanyPeople(tmpRoot, "../other")).toThrow();
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // searchPeople
178
+ // ---------------------------------------------------------------------------
179
+
180
+ const SAMPLE: PersonRecord[] = [
181
+ { slug: "jane-smith", name: "Jane Smith", email: "jane@example.com", source: "a" },
182
+ { slug: "john-doe", name: "John Doe", email: "john@acme.com", source: "b" },
183
+ { slug: "janet-lee", name: "Janet Lee", source: "c" },
184
+ ];
185
+
186
+ describe("searchPeople", () => {
187
+ it("matches on name (case-insensitive)", () => {
188
+ // "jane" is a substring of both "Jane Smith" and "Janet Lee".
189
+ expect(searchPeople(SAMPLE, "jane").map((p) => p.slug)).toEqual([
190
+ "jane-smith",
191
+ "janet-lee",
192
+ ]);
193
+ expect(searchPeople(SAMPLE, "smith").map((p) => p.slug)).toEqual([
194
+ "jane-smith",
195
+ ]);
196
+ expect(searchPeople(SAMPLE, "JAN").map((p) => p.slug)).toEqual([
197
+ "jane-smith",
198
+ "janet-lee",
199
+ ]);
200
+ });
201
+
202
+ it("matches on email", () => {
203
+ expect(searchPeople(SAMPLE, "acme.com").map((p) => p.slug)).toEqual([
204
+ "john-doe",
205
+ ]);
206
+ });
207
+
208
+ it("matches on slug", () => {
209
+ expect(searchPeople(SAMPLE, "janet-lee").map((p) => p.slug)).toEqual([
210
+ "janet-lee",
211
+ ]);
212
+ });
213
+
214
+ it("returns nothing for an empty/whitespace keyword", () => {
215
+ expect(searchPeople(SAMPLE, "")).toEqual([]);
216
+ expect(searchPeople(SAMPLE, " ")).toEqual([]);
217
+ });
218
+
219
+ it("returns nothing when no one matches", () => {
220
+ expect(searchPeople(SAMPLE, "zzz")).toEqual([]);
221
+ });
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // resolveNameToEmail
226
+ // ---------------------------------------------------------------------------
227
+
228
+ describe("resolveNameToEmail", () => {
229
+ it("resolves an exact name to its email", () => {
230
+ expect(resolveNameToEmail(SAMPLE, "Jane Smith")).toEqual({
231
+ status: "found",
232
+ email: "jane@example.com",
233
+ person: SAMPLE[0],
234
+ });
235
+ });
236
+
237
+ it("is case-insensitive on exact name", () => {
238
+ const r = resolveNameToEmail(SAMPLE, "jane smith");
239
+ expect(r.status).toBe("found");
240
+ });
241
+
242
+ it("prefers an exact name over looser substring matches", () => {
243
+ const people: PersonRecord[] = [
244
+ { slug: "jan", name: "Jan", email: "jan@x.com", source: "a" },
245
+ { slug: "jana", name: "Janabc", email: "jana@x.com", source: "b" },
246
+ ];
247
+ expect(resolveNameToEmail(people, "Jan")).toMatchObject({
248
+ status: "found",
249
+ email: "jan@x.com",
250
+ });
251
+ });
252
+
253
+ it("resolves via slug when name doesn't match exactly", () => {
254
+ expect(resolveNameToEmail(SAMPLE, "john-doe")).toMatchObject({
255
+ status: "found",
256
+ email: "john@acme.com",
257
+ });
258
+ });
259
+
260
+ it("reports ambiguity when a substring matches several", () => {
261
+ const r = resolveNameToEmail(SAMPLE, "jan");
262
+ expect(r.status).toBe("ambiguous");
263
+ if (r.status === "ambiguous") {
264
+ expect(r.matches.map((m) => m.slug)).toEqual(["jane-smith", "janet-lee"]);
265
+ }
266
+ });
267
+
268
+ it("reports no_email when the sole match has no email", () => {
269
+ expect(resolveNameToEmail(SAMPLE, "Janet Lee")).toMatchObject({
270
+ status: "no_email",
271
+ });
272
+ });
273
+
274
+ it("reports not_found when nothing matches", () => {
275
+ expect(resolveNameToEmail(SAMPLE, "Nobody")).toEqual({
276
+ status: "not_found",
277
+ });
278
+ expect(resolveNameToEmail(SAMPLE, " ")).toEqual({ status: "not_found" });
279
+ });
280
+ });
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // resolveCompanySlug
284
+ // ---------------------------------------------------------------------------
285
+
286
+ describe("resolveCompanySlug", () => {
287
+ it("passes through an explicit, valid slug", () => {
288
+ expect(resolveCompanySlug(tmpRoot, "indigo")).toBe("indigo");
289
+ });
290
+
291
+ it("rejects an explicit unsafe slug", () => {
292
+ expect(() => resolveCompanySlug(tmpRoot, "../escape")).toThrow();
293
+ });
294
+
295
+ it("defaults to the sole company in the manifest", () => {
296
+ writeCompaniesManifest({ indigo: { status: "active" } });
297
+ expect(resolveCompanySlug(tmpRoot, undefined)).toBe("indigo");
298
+ });
299
+
300
+ it("ignores archived companies when defaulting", () => {
301
+ writeCompaniesManifest({
302
+ indigo: { status: "active" },
303
+ old: { status: "archived" },
304
+ });
305
+ expect(resolveCompanySlug(tmpRoot, undefined)).toBe("indigo");
306
+ });
307
+
308
+ it("requires --company when several companies exist", () => {
309
+ writeCompaniesManifest({ a: { status: "active" }, b: { status: "active" } });
310
+ expect(() => resolveCompanySlug(tmpRoot, undefined)).toThrow(
311
+ /Multiple companies/,
312
+ );
313
+ });
314
+
315
+ it("errors clearly when the manifest is absent", () => {
316
+ expect(() => resolveCompanySlug(tmpRoot, undefined)).toThrow(
317
+ /manifest\.yaml not found/,
318
+ );
319
+ });
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // companyPeopleDir
324
+ // ---------------------------------------------------------------------------
325
+
326
+ describe("companyPeopleDir", () => {
327
+ it("builds the scoped path", () => {
328
+ expect(companyPeopleDir("/hq", "indigo")).toBe(
329
+ path.join("/hq", "companies", "indigo", "people"),
330
+ );
331
+ });
332
+ });
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Wired CLI surface (list / search / resolve)
336
+ // ---------------------------------------------------------------------------
337
+
338
+ function buildProgram(): Command {
339
+ const program = new Command();
340
+ program.exitOverride(); // throw instead of process.exit on commander errors
341
+ registerPeopleCommand(program);
342
+ return program;
343
+ }
344
+
345
+ async function run(args: string[]): Promise<void> {
346
+ await buildProgram().parseAsync(["node", "hq", ...args]);
347
+ }
348
+
349
+ describe("hq people (wired)", () => {
350
+ let logSpy: ReturnType<typeof vi.spyOn>;
351
+ let errSpy: ReturnType<typeof vi.spyOn>;
352
+ let exitSpy: ReturnType<typeof vi.spyOn>;
353
+
354
+ beforeEach(() => {
355
+ writeCompaniesManifest({ acme: { status: "active" } });
356
+ makePerson("acme", "jane-smith", {
357
+ name: "Jane Smith",
358
+ email: "jane@acme.com",
359
+ role: "CEO",
360
+ type: "internal",
361
+ });
362
+ makePerson("acme", "john-doe", {
363
+ name: "John Doe",
364
+ email: "john@acme.com",
365
+ type: "internal",
366
+ });
367
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
368
+ errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
369
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((): never => {
370
+ throw new Error("__exit__");
371
+ }) as never);
372
+ });
373
+
374
+ it("list --json emits both people", async () => {
375
+ await run(["people", "list", "--company", "acme", "--hq-root", tmpRoot, "--json"]);
376
+ const out = JSON.parse(logSpy.mock.calls[0][0] as string);
377
+ expect(out.map((p: PersonRecord) => p.name).sort()).toEqual([
378
+ "Jane Smith",
379
+ "John Doe",
380
+ ]);
381
+ });
382
+
383
+ it("search narrows to the matching person", async () => {
384
+ await run([
385
+ "people",
386
+ "search",
387
+ "jane",
388
+ "--company",
389
+ "acme",
390
+ "--hq-root",
391
+ tmpRoot,
392
+ "--json",
393
+ ]);
394
+ const out = JSON.parse(logSpy.mock.calls[0][0] as string);
395
+ expect(out).toHaveLength(1);
396
+ expect(out[0].email).toBe("jane@acme.com");
397
+ });
398
+
399
+ it("resolve prints the bare email on stdout", async () => {
400
+ await run([
401
+ "people",
402
+ "resolve",
403
+ "Jane Smith",
404
+ "--company",
405
+ "acme",
406
+ "--hq-root",
407
+ tmpRoot,
408
+ ]);
409
+ expect(logSpy).toHaveBeenCalledWith("jane@acme.com");
410
+ });
411
+
412
+ it("resolve exits non-zero when not found", async () => {
413
+ await expect(
414
+ run([
415
+ "people",
416
+ "resolve",
417
+ "Nobody",
418
+ "--company",
419
+ "acme",
420
+ "--hq-root",
421
+ tmpRoot,
422
+ ]),
423
+ ).rejects.toThrow("__exit__");
424
+ expect(exitSpy).toHaveBeenCalledWith(1);
425
+ });
426
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * `hq people` — read a company's people (membership) records from the local HQ
3
+ * tree and search/resolve over them.
4
+ *
5
+ * hq people list [--company <slug>] [--json]
6
+ * hq people search <keyword> [--company <slug>] [--json]
7
+ * hq people resolve <name> [--company <slug>] [--json]
8
+ *
9
+ * Source of truth is `companies/<company>/people/<person>/meta.yaml`. Every
10
+ * subcommand operates on exactly ONE company (the active company, or the one
11
+ * named by `--company`); nothing reads across company boundaries.
12
+ */
13
+
14
+ import * as fs from "fs";
15
+ import { Command, Option } from "commander";
16
+ import chalk from "chalk";
17
+ import * as yaml from "js-yaml";
18
+ import { findHqRoot } from "../utils/manifest.js";
19
+ import { manifestPath, type ManifestDoc } from "./cloud-provision.js";
20
+ import {
21
+ assertSafeCompanySlug,
22
+ listCompanyPeople,
23
+ searchPeople,
24
+ resolveNameToEmail,
25
+ companyPeopleDir,
26
+ type PersonRecord,
27
+ } from "../utils/people.js";
28
+
29
+ interface PeopleScopeOpts {
30
+ company?: string;
31
+ hqRoot?: string;
32
+ }
33
+
34
+ /** Companies that still exist (anything not explicitly `status: archived`). */
35
+ function activeCompanySlugs(manifest: ManifestDoc): string[] {
36
+ const companies = manifest.companies ?? {};
37
+ return Object.entries(companies)
38
+ .filter(([, entry]) => (entry?.status ?? "active") !== "archived")
39
+ .map(([slug]) => slug)
40
+ .sort();
41
+ }
42
+
43
+ /**
44
+ * Resolve the single company to operate on. Explicit `--company` always wins
45
+ * (after a path-safety check). Otherwise the active company is inferred from
46
+ * `companies/manifest.yaml`: if exactly one company exists it's used; if several
47
+ * do, the caller must disambiguate with `--company`.
48
+ */
49
+ export function resolveCompanySlug(
50
+ hqRoot: string,
51
+ explicit: string | undefined,
52
+ ): string {
53
+ if (explicit) {
54
+ assertSafeCompanySlug(explicit);
55
+ return explicit;
56
+ }
57
+ const mPath = manifestPath(hqRoot);
58
+ if (!fs.existsSync(mPath)) {
59
+ throw new Error(
60
+ "Could not determine the active company — companies/manifest.yaml not found. " +
61
+ "Re-run with --company <slug>.",
62
+ );
63
+ }
64
+ let manifest: ManifestDoc;
65
+ try {
66
+ manifest = yaml.load(fs.readFileSync(mPath, "utf-8")) as ManifestDoc;
67
+ } catch (err) {
68
+ throw new Error(
69
+ `companies/manifest.yaml is malformed: ${err instanceof Error ? err.message : String(err)}`,
70
+ );
71
+ }
72
+ const slugs = activeCompanySlugs(manifest ?? {});
73
+ if (slugs.length === 1) return slugs[0];
74
+ if (slugs.length === 0) {
75
+ throw new Error(
76
+ "No companies found in companies/manifest.yaml — re-run with --company <slug>.",
77
+ );
78
+ }
79
+ throw new Error(
80
+ "Multiple companies found — re-run with --company <slug> to pick one:\n" +
81
+ slugs.map((s) => ` --company ${s}`).join("\n"),
82
+ );
83
+ }
84
+
85
+ /** Resolve the HQ root: explicit override (tests) → cwd walk-up. */
86
+ function resolveHqRoot(opts: PeopleScopeOpts): string {
87
+ return opts.hqRoot ?? findHqRoot();
88
+ }
89
+
90
+ function printPeopleTable(people: PersonRecord[]): void {
91
+ const nameW = Math.max(4, ...people.map((p) => p.name.length));
92
+ const emailW = Math.max(5, ...people.map((p) => (p.email ?? "—").length));
93
+ const roleW = Math.max(4, ...people.map((p) => (p.role ?? "—").length));
94
+ console.log(
95
+ chalk.bold(
96
+ [
97
+ "NAME".padEnd(nameW),
98
+ "EMAIL".padEnd(emailW),
99
+ "ROLE".padEnd(roleW),
100
+ "TYPE",
101
+ ].join(" "),
102
+ ),
103
+ );
104
+ for (const p of people) {
105
+ console.log(
106
+ [
107
+ p.name.padEnd(nameW),
108
+ (p.email ?? "—").padEnd(emailW),
109
+ (p.role ?? "—").padEnd(roleW),
110
+ p.type ?? "—",
111
+ ].join(" "),
112
+ );
113
+ }
114
+ }
115
+
116
+ function fail(message: string): never {
117
+ console.error(chalk.red(message));
118
+ process.exit(1);
119
+ }
120
+
121
+ export function registerPeopleCommand(program: Command): void {
122
+ const people = program
123
+ .command("people")
124
+ .description(
125
+ "List, search, and resolve a company's people (from companies/<co>/people)",
126
+ )
127
+ .option(
128
+ "--company <slug>",
129
+ "Company slug to scope to (defaults to the active company)",
130
+ )
131
+ // Hidden escape hatch for tests / non-standard layouts — point the reader at
132
+ // an explicit HQ tree root instead of walking up from cwd.
133
+ .addOption(
134
+ new Option(
135
+ "--hq-root <path>",
136
+ "Override the HQ tree root (advanced)",
137
+ ).hideHelp(),
138
+ );
139
+
140
+ people
141
+ .command("list")
142
+ .description("List all people recorded for the company")
143
+ .option("--json", "Output JSON instead of a table")
144
+ .action((opts: { json?: boolean }) => {
145
+ try {
146
+ const scope = people.opts() as PeopleScopeOpts;
147
+ const hqRoot = resolveHqRoot(scope);
148
+ const slug = resolveCompanySlug(hqRoot, scope.company);
149
+ const records = listCompanyPeople(hqRoot, slug);
150
+
151
+ if (opts.json) {
152
+ console.log(JSON.stringify(records, null, 2));
153
+ return;
154
+ }
155
+ if (records.length === 0) {
156
+ console.log(
157
+ chalk.gray(
158
+ `No people recorded for '${slug}' (looked in ${companyPeopleDir(hqRoot, slug)}).`,
159
+ ),
160
+ );
161
+ return;
162
+ }
163
+ printPeopleTable(records);
164
+ } catch (err) {
165
+ fail(err instanceof Error ? err.message : String(err));
166
+ }
167
+ });
168
+
169
+ people
170
+ .command("search <keyword>")
171
+ .description("Keyword search over people names and emails")
172
+ .option("--json", "Output JSON instead of a table")
173
+ .action((keyword: string, opts: { json?: boolean }) => {
174
+ try {
175
+ const scope = people.opts() as PeopleScopeOpts;
176
+ const hqRoot = resolveHqRoot(scope);
177
+ const slug = resolveCompanySlug(hqRoot, scope.company);
178
+ const matches = searchPeople(listCompanyPeople(hqRoot, slug), keyword);
179
+
180
+ if (opts.json) {
181
+ console.log(JSON.stringify(matches, null, 2));
182
+ return;
183
+ }
184
+ if (matches.length === 0) {
185
+ console.log(chalk.gray(`No people in '${slug}' match "${keyword}".`));
186
+ return;
187
+ }
188
+ printPeopleTable(matches);
189
+ } catch (err) {
190
+ fail(err instanceof Error ? err.message : String(err));
191
+ }
192
+ });
193
+
194
+ people
195
+ .command("resolve <name>")
196
+ .description("Resolve a person name to their email address")
197
+ .option("--json", "Output JSON instead of plain text")
198
+ .action((name: string, opts: { json?: boolean }) => {
199
+ try {
200
+ const scope = people.opts() as PeopleScopeOpts;
201
+ const hqRoot = resolveHqRoot(scope);
202
+ const slug = resolveCompanySlug(hqRoot, scope.company);
203
+ const result = resolveNameToEmail(listCompanyPeople(hqRoot, slug), name);
204
+
205
+ if (opts.json) {
206
+ console.log(JSON.stringify(result, null, 2));
207
+ if (result.status === "found") return;
208
+ process.exit(1);
209
+ }
210
+
211
+ switch (result.status) {
212
+ case "found":
213
+ // Bare email on stdout so callers can capture it directly.
214
+ console.log(result.email);
215
+ return;
216
+ case "no_email":
217
+ fail(
218
+ `Found '${result.person.name}' in '${slug}' but no email is recorded for them.`,
219
+ );
220
+ break;
221
+ case "ambiguous":
222
+ console.error(
223
+ chalk.yellow(
224
+ `"${name}" matches ${result.matches.length} people in '${slug}' — be more specific:`,
225
+ ),
226
+ );
227
+ for (const m of result.matches) {
228
+ console.error(` ${m.name}${m.email ? ` <${m.email}>` : ""}`);
229
+ }
230
+ process.exit(1);
231
+ break;
232
+ case "not_found":
233
+ fail(`No person matching "${name}" found in '${slug}'.`);
234
+ break;
235
+ }
236
+ } catch (err) {
237
+ fail(err instanceof Error ? err.message : String(err));
238
+ }
239
+ });
240
+ }
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ import { registerGroupGrantsCommand } from "./commands/group-grants.js";
36
36
  import { registerFilesCommand } from "./commands/files.js";
37
37
  import { registerFilesBrowseCommands } from "./commands/files-browse.js";
38
38
  import { registerMembersCommand } from "./commands/members.js";
39
+ import { registerPeopleCommand } from "./commands/people.js";
39
40
  import { registerDmCommand } from "./commands/dm.js";
40
41
  import { registerFeedbackCommand } from "./commands/feedback.js";
41
42
  import { registerMeetingsCommand } from "./commands/meetings.js";
@@ -166,6 +167,9 @@ registerFilesBrowseCommands(filesCmd);
166
167
 
167
168
  // Membership management (subcommand group — hq members invite|list|revoke)
168
169
  registerMembersCommand(program);
170
+ // People directory (subcommand group — hq people list|search|resolve), reading
171
+ // the local companies/<co>/people store scoped to one company.
172
+ registerPeopleCommand(program);
169
173
  registerDmCommand(program);
170
174
 
171
175
  // Onboarding (top-level — Cognito + vault-service provisioning)