@indigoai-us/hq-cli 5.18.3 → 5.20.0

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.
@@ -387,6 +387,151 @@ describe("runBrowse", () => {
387
387
  }),
388
388
  ).rejects.toThrow(/No company found for slug 'notmine'/);
389
389
  });
390
+
391
+ // ── personalMode (hq-cli#26) ──────────────────────────────────────────────
392
+ //
393
+ // Personal mode resolves the entity via `entity.get(personalUid)` (skipping
394
+ // the company-namespace lookup), omits the explicit-grants fetch, and tags
395
+ // every row with `aclSource: "personal-vault"`. The path arg is bucket-
396
+ // relative — companies/<slug>/ prefix is NOT required (and would be
397
+ // incorrect, since the person bucket is owner-only with no companies/
398
+ // subtree).
399
+
400
+ it("personalMode: resolves entity via entity.get(personalUid), skips namespace lookup", async () => {
401
+ const { client, spies } = makeStubVaultClient({
402
+ entity: {
403
+ uid: "prs_test",
404
+ slug: "personal",
405
+ bucketName: "hq-vault-prs-test",
406
+ },
407
+ });
408
+ const { factory } = makeStubS3Factory({
409
+ listResponses: [
410
+ {
411
+ Contents: [{ Key: ".claude/CLAUDE.md", Size: 100, LastModified: new Date() }],
412
+ },
413
+ ],
414
+ });
415
+
416
+ const result = await runBrowse({
417
+ pathPrefix: ".claude/",
418
+ personalMode: true,
419
+ personalUid: "prs_test",
420
+ vaultClient: client,
421
+ s3Factory: factory,
422
+ region: "us-east-1",
423
+ });
424
+
425
+ expect(result.rows).toHaveLength(1);
426
+ expect(result.rows[0].key).toBe(".claude/CLAUDE.md");
427
+ // findInMyNamespace is the company-mode lookup — must not be touched.
428
+ expect(spies.findInMyNamespace).not.toHaveBeenCalled();
429
+ // Grants graph is a company concept — must not be fetched.
430
+ expect(spies.listMyExplicitGrants).not.toHaveBeenCalled();
431
+ // Vend still issued for browse purpose, no policy difference.
432
+ expect(spies.vend).toHaveBeenCalledWith(
433
+ expect.objectContaining({ purpose: "browse", operations: "read-only" }),
434
+ );
435
+ });
436
+
437
+ it("personalMode: empty pathPrefix lists the bucket root", async () => {
438
+ const { client } = makeStubVaultClient({
439
+ entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
440
+ });
441
+ const { factory, sendSpy } = makeStubS3Factory({
442
+ listResponses: [
443
+ {
444
+ Contents: [
445
+ { Key: ".claude/CLAUDE.md", Size: 100, LastModified: new Date() },
446
+ { Key: "core/policies/foo.md", Size: 50, LastModified: new Date() },
447
+ ],
448
+ },
449
+ ],
450
+ });
451
+
452
+ const result = await runBrowse({
453
+ pathPrefix: "",
454
+ personalMode: true,
455
+ personalUid: "prs_test",
456
+ vaultClient: client,
457
+ s3Factory: factory,
458
+ region: "us-east-1",
459
+ });
460
+
461
+ expect(result.rows).toHaveLength(2);
462
+ // ListObjectsV2 issued with an empty Prefix means "list everything".
463
+ const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
464
+ expect(listCmd.input.Prefix).toBe("");
465
+ });
466
+
467
+ it("personalMode: every row is tagged aclSource='personal-vault'", async () => {
468
+ const { client } = makeStubVaultClient({
469
+ entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
470
+ // Note: even if grants WERE present, personalMode would skip the
471
+ // lookup entirely — no chance of accidentally tagging a personal
472
+ // key as `shared-with-you`.
473
+ grants: [fakeGrant("companies/indigo/")],
474
+ });
475
+ const { factory } = makeStubS3Factory({
476
+ listResponses: [
477
+ {
478
+ Contents: [
479
+ { Key: ".claude/CLAUDE.md", Size: 1, LastModified: new Date() },
480
+ { Key: "core/policies/_digest.md", Size: 2, LastModified: new Date() },
481
+ { Key: "personal/notes.md", Size: 3, LastModified: new Date() },
482
+ ],
483
+ },
484
+ ],
485
+ });
486
+
487
+ const result = await runBrowse({
488
+ pathPrefix: "",
489
+ personalMode: true,
490
+ personalUid: "prs_test",
491
+ vaultClient: client,
492
+ s3Factory: factory,
493
+ region: "us-east-1",
494
+ });
495
+
496
+ for (const row of result.rows) {
497
+ expect(row.aclSource).toBe("personal-vault");
498
+ }
499
+ });
500
+
501
+ it("personalMode: throws when personalUid is missing", async () => {
502
+ const { client } = makeStubVaultClient({});
503
+ const { factory } = makeStubS3Factory({});
504
+
505
+ await expect(
506
+ runBrowse({
507
+ pathPrefix: "",
508
+ personalMode: true,
509
+ // personalUid intentionally omitted
510
+ vaultClient: client,
511
+ s3Factory: factory,
512
+ region: "us-east-1",
513
+ }),
514
+ ).rejects.toThrow(/personalMode requires personalUid/);
515
+ });
516
+
517
+ it("personalMode: throws when entity has no provisioned bucket", async () => {
518
+ // Force entity.get to return a bucket-less entity.
519
+ const { client } = makeStubVaultClient({
520
+ entity: { uid: "prs_test", slug: "personal" },
521
+ });
522
+ const { factory } = makeStubS3Factory({});
523
+
524
+ await expect(
525
+ runBrowse({
526
+ pathPrefix: "",
527
+ personalMode: true,
528
+ personalUid: "prs_test",
529
+ vaultClient: client,
530
+ s3Factory: factory,
531
+ region: "us-east-1",
532
+ }),
533
+ ).rejects.toThrow(/no provisioned bucket/);
534
+ });
390
535
  });
391
536
 
392
537
  // ── runCat ──────────────────────────────────────────────────────────────────
@@ -472,4 +617,71 @@ describe("runCat", () => {
472
617
  }),
473
618
  ).rejects.toThrow(/Expected a path starting with 'companies\//);
474
619
  });
620
+
621
+ // ── personalMode (hq-cli#26) ──────────────────────────────────────────────
622
+
623
+ it("personalMode: streams from the person bucket, no slug parse on the key", async () => {
624
+ const { client, spies } = makeStubVaultClient({
625
+ entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
626
+ });
627
+ const body = new Readable({
628
+ read() {
629
+ this.push("hello personal");
630
+ this.push(null);
631
+ },
632
+ });
633
+ const { factory, sendSpy } = makeStubS3Factory({
634
+ getResponse: { Body: body as unknown as GetObjectCommandOutput["Body"] },
635
+ });
636
+
637
+ // The key has no `companies/<slug>/` prefix — would normally fail
638
+ // parseCompanySlugFromPath. Under personalMode, that parse is skipped.
639
+ const sink = new Writable({
640
+ write(_chunk, _enc, cb) {
641
+ cb();
642
+ },
643
+ });
644
+ const result = await runCat({
645
+ key: ".claude/CLAUDE.md",
646
+ personalMode: true,
647
+ personalUid: "prs_test",
648
+ vaultClient: client,
649
+ s3Factory: factory,
650
+ region: "us-east-1",
651
+ hqRoot: tmpRoot,
652
+ stdout: sink,
653
+ });
654
+
655
+ expect(result.destination.kind).toBe("stdout");
656
+ expect(spies.findInMyNamespace).not.toHaveBeenCalled();
657
+ // Vend issued against the bucket-relative key, browse purpose.
658
+ expect(spies.vend).toHaveBeenCalledWith(
659
+ expect.objectContaining({
660
+ paths: [".claude/CLAUDE.md"],
661
+ purpose: "browse",
662
+ }),
663
+ );
664
+ // GetObject targeted the person bucket.
665
+ const getCmd = sendSpy.mock.calls.find(
666
+ (c) => c[0] instanceof GetObjectCommand,
667
+ )?.[0] as GetObjectCommand;
668
+ expect(getCmd.input.Bucket).toBe("hq-vault-prs-test");
669
+ expect(getCmd.input.Key).toBe(".claude/CLAUDE.md");
670
+ });
671
+
672
+ it("personalMode: throws when personalUid is missing", async () => {
673
+ const { client } = makeStubVaultClient({});
674
+ const { factory } = makeStubS3Factory({});
675
+ await expect(
676
+ runCat({
677
+ key: ".claude/CLAUDE.md",
678
+ personalMode: true,
679
+ // personalUid omitted
680
+ vaultClient: client,
681
+ s3Factory: factory,
682
+ region: "us-east-1",
683
+ hqRoot: tmpRoot,
684
+ }),
685
+ ).rejects.toThrow(/personalMode requires personalUid/);
686
+ });
475
687
  });
@@ -57,6 +57,7 @@ import {
57
57
  buildVaultConfig,
58
58
  } from "../utils/cognito-session.js";
59
59
  import { getCompanyUid } from "../utils/vault-api.js";
60
+ import { resolveCanonicalPersonUid } from "./cloud.js";
60
61
 
61
62
  // ── Types ───────────────────────────────────────────────────────────────────
62
63
 
@@ -99,8 +100,17 @@ export type S3ClientFactory = (input: {
99
100
  };
100
101
  }) => FilesBrowseS3Client;
101
102
 
102
- /** ACL provenance for a single listed key. */
103
- export type AclSource = "shared-with-you" | "role-bypass";
103
+ /**
104
+ * ACL provenance for a single listed key.
105
+ * - `shared-with-you`: an explicit grant the caller holds covers the key.
106
+ * - `role-bypass`: the caller has no covering explicit grant, but
107
+ * owner/admin role widened the browse-vend policy to include it.
108
+ * - `personal-vault`: the key lives in the caller's own person-entity
109
+ * vault, where no grants graph applies — the caller is the only
110
+ * principal with access by construction. Emitted only when
111
+ * `runBrowse({ personalMode: true })`.
112
+ */
113
+ export type AclSource = "shared-with-you" | "role-bypass" | "personal-vault";
104
114
 
105
115
  export interface BrowseRow {
106
116
  key: string;
@@ -201,10 +211,30 @@ export function formatBrowseTable(rows: BrowseRow[]): string {
201
211
  // ── Orchestrators ───────────────────────────────────────────────────────────
202
212
 
203
213
  export interface RunBrowseInput {
204
- /** Vault path prefix, e.g. `companies/indigo/scratch/`. */
214
+ /**
215
+ * Vault path prefix.
216
+ * - Company mode (`personalMode: false | undefined`): must start with
217
+ * `companies/<slug>/`, e.g. `companies/indigo/scratch/`.
218
+ * - Personal mode (`personalMode: true`): bucket-relative; empty string
219
+ * lists the whole personal vault root.
220
+ */
205
221
  pathPrefix: string;
206
- /** Caller-overridden company slug (defaults to slug parsed from path). */
222
+ /** Caller-overridden company slug (defaults to slug parsed from path). Ignored under `personalMode`. */
207
223
  companySlug?: string;
224
+ /**
225
+ * Personal-vault mode. Skips the `companies/<slug>/` path requirement,
226
+ * resolves the entity via `entity.get(personalUid)` instead of the
227
+ * company namespace, omits the explicit-grants fetch (no grants graph
228
+ * on a person bucket), and marks every row's `aclSource` as
229
+ * `"personal-vault"`. Closes hq-cli#26 (audit gap for personal vault).
230
+ */
231
+ personalMode?: boolean;
232
+ /**
233
+ * Canonical person-entity UID (e.g. `prs_…`). Required when
234
+ * `personalMode: true`; ignored otherwise. Caller resolves via
235
+ * `resolveCanonicalPersonUid` to keep this orchestrator pure.
236
+ */
237
+ personalUid?: string;
208
238
  vaultClient: FilesBrowseVaultClient;
209
239
  s3Factory: S3ClientFactory;
210
240
  region: string;
@@ -227,25 +257,52 @@ export interface RunBrowseResult {
227
257
  * Pure-ish: no console output, no process.exit — caller renders + exits.
228
258
  */
229
259
  export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult> {
230
- const { pathPrefix, vaultClient, s3Factory, region } = input;
231
- const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
232
-
233
- const entity = await vaultClient.entity.findInMyNamespace("company", slug);
234
- if (!entity) {
235
- throw new Error(
236
- `No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
237
- );
238
- }
239
- if (!entity.bucketName) {
240
- throw new Error(
241
- `Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
242
- );
260
+ const { pathPrefix, vaultClient, s3Factory, region, personalMode } = input;
261
+
262
+ // Branch by mode. Company mode parses slug from path and looks up by
263
+ // namespace; personal mode resolves the entity directly by the supplied
264
+ // person UID and skips the slug + grants machinery (a person bucket has
265
+ // no grants graph — the owner is the only principal). The vend call is
266
+ // identical for both modes once we have the entity in hand.
267
+ let bucket: string;
268
+ let entityUid: string;
269
+ if (personalMode) {
270
+ if (!input.personalUid) {
271
+ throw new Error(
272
+ "runBrowse: personalMode requires personalUid. Resolve via " +
273
+ "resolveCanonicalPersonUid() before calling.",
274
+ );
275
+ }
276
+ const entity = await vaultClient.entity.get(input.personalUid);
277
+ if (!entity.bucketName) {
278
+ throw new Error(
279
+ `Personal entity '${input.personalUid}' has no provisioned bucket.`,
280
+ );
281
+ }
282
+ entityUid = entity.uid;
283
+ bucket = entity.bucketName;
284
+ } else {
285
+ const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
286
+ const entity = await vaultClient.entity.findInMyNamespace("company", slug);
287
+ if (!entity) {
288
+ throw new Error(
289
+ `No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
290
+ );
291
+ }
292
+ if (!entity.bucketName) {
293
+ throw new Error(
294
+ `Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
295
+ );
296
+ }
297
+ entityUid = entity.uid;
298
+ bucket = entity.bucketName;
243
299
  }
244
- const companyUid = entity.uid;
245
- const bucket = entity.bucketName;
246
300
 
247
301
  // Distinct vend call from sync — `purpose: 'browse'` opts the request
248
- // into the role-bypass-allowed code path on the server (US-009).
302
+ // into the role-bypass-allowed code path on the server (US-009). The
303
+ // personal mode vends against the person entity which is owner-only by
304
+ // construction; the vend response shape is identical so downstream
305
+ // S3Client construction doesn't branch.
249
306
  const vend = await vaultClient.vend({
250
307
  paths: [pathPrefix],
251
308
  operations: "read-only",
@@ -262,8 +319,12 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
262
319
  });
263
320
 
264
321
  // Pull the caller's explicit-grant graph once so per-key classification
265
- // is O(grants) without N round-trips.
266
- const grants = await vaultClient.listMyExplicitGrants(companyUid);
322
+ // is O(grants) without N round-trips. Skipped in personal mode — the
323
+ // grants graph is a company concept; a person bucket marks every row
324
+ // as `"personal-vault"` directly.
325
+ const grants = personalMode
326
+ ? ([] as ExplicitGrant[])
327
+ : await vaultClient.listMyExplicitGrants(entityUid);
267
328
 
268
329
  const rows: BrowseRow[] = [];
269
330
  let continuationToken: string | undefined;
@@ -285,7 +346,7 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
285
346
  key: obj.Key,
286
347
  size: obj.Size ?? 0,
287
348
  lastModified: obj.LastModified,
288
- aclSource: classifyAclSource(obj.Key, grants),
349
+ aclSource: personalMode ? "personal-vault" : classifyAclSource(obj.Key, grants),
289
350
  });
290
351
  }
291
352
 
@@ -296,7 +357,11 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
296
357
  }
297
358
 
298
359
  export interface RunCatInput {
299
- /** Single vault key, e.g. `companies/indigo/scratch/foo.txt`. */
360
+ /**
361
+ * Single vault key.
362
+ * - Company mode: must be a `companies/<slug>/...` path.
363
+ * - Personal mode: bucket-relative, e.g. `.claude/CLAUDE.md`.
364
+ */
300
365
  key: string;
301
366
  /**
302
367
  * Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
@@ -305,6 +370,10 @@ export interface RunCatInput {
305
370
  out?: string;
306
371
  hqRoot: string;
307
372
  companySlug?: string;
373
+ /** Personal-vault mode — see `RunBrowseInput.personalMode`. */
374
+ personalMode?: boolean;
375
+ /** Canonical person-entity UID; required when `personalMode: true`. */
376
+ personalUid?: string;
308
377
  vaultClient: FilesBrowseVaultClient;
309
378
  s3Factory: S3ClientFactory;
310
379
  region: string;
@@ -324,8 +393,7 @@ export interface RunCatResult {
324
393
  * containment guard). Refuses ahead of any I/O when `--out` is unsafe.
325
394
  */
326
395
  export async function runCat(input: RunCatInput): Promise<RunCatResult> {
327
- const { key, vaultClient, s3Factory, region, hqRoot } = input;
328
- const slug = input.companySlug ?? parseCompanySlugFromPath(key);
396
+ const { key, vaultClient, s3Factory, region, hqRoot, personalMode } = input;
329
397
 
330
398
  // Acceptance 5: refuse BEFORE vending — no point pulling credentials
331
399
  // for a request we're already going to abort.
@@ -334,16 +402,37 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
334
402
  absOut = assertOutPathOutsideCompanies(input.out, hqRoot);
335
403
  }
336
404
 
337
- const entity = await vaultClient.entity.findInMyNamespace("company", slug);
338
- if (!entity) {
339
- throw new Error(
340
- `No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
341
- );
342
- }
343
- if (!entity.bucketName) {
344
- throw new Error(
345
- `Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
346
- );
405
+ // Same branch logic as runBrowse — see that function's doc-block for
406
+ // the personal-vs-company rationale.
407
+ let bucket: string;
408
+ if (personalMode) {
409
+ if (!input.personalUid) {
410
+ throw new Error(
411
+ "runCat: personalMode requires personalUid. Resolve via " +
412
+ "resolveCanonicalPersonUid() before calling.",
413
+ );
414
+ }
415
+ const entity = await vaultClient.entity.get(input.personalUid);
416
+ if (!entity.bucketName) {
417
+ throw new Error(
418
+ `Personal entity '${input.personalUid}' has no provisioned bucket.`,
419
+ );
420
+ }
421
+ bucket = entity.bucketName;
422
+ } else {
423
+ const slug = input.companySlug ?? parseCompanySlugFromPath(key);
424
+ const entity = await vaultClient.entity.findInMyNamespace("company", slug);
425
+ if (!entity) {
426
+ throw new Error(
427
+ `No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
428
+ );
429
+ }
430
+ if (!entity.bucketName) {
431
+ throw new Error(
432
+ `Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
433
+ );
434
+ }
435
+ bucket = entity.bucketName;
347
436
  }
348
437
 
349
438
  const vend = await vaultClient.vend({
@@ -362,7 +451,7 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
362
451
  });
363
452
 
364
453
  const resp = (await s3.send(
365
- new GetObjectCommand({ Bucket: entity.bucketName, Key: key }),
454
+ new GetObjectCommand({ Bucket: bucket, Key: key }),
366
455
  )) as GetObjectCommandOutput;
367
456
 
368
457
  if (!resp.Body) {
@@ -403,6 +492,13 @@ const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
403
492
  interface FilesBrowseCliOptions {
404
493
  company?: string;
405
494
  hqRoot: string;
495
+ /**
496
+ * Personal-vault mode (hq-cli#26). Skips the `companies/<slug>/`
497
+ * requirement on the path arg, resolves the entity from the caller's
498
+ * canonical person UID, and emits rows tagged `personal-vault`.
499
+ * Mutually exclusive with `--company`.
500
+ */
501
+ personal?: boolean;
406
502
  }
407
503
 
408
504
  interface FilesCatCliOptions extends FilesBrowseCliOptions {
@@ -417,25 +513,69 @@ interface FilesCatCliOptions extends FilesBrowseCliOptions {
417
513
  */
418
514
  export function registerFilesBrowseCommands(filesCmd: Command): void {
419
515
  filesCmd
420
- .command("browse <path>")
516
+ .command("browse [path]")
421
517
  .description(
422
- "List vault objects under <path> without syncing them locally. Uses the browse-vend path (role-bypass allowed).",
518
+ "List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.",
423
519
  )
424
520
  .option(
425
521
  "--company <slug>",
426
522
  "Company slug (defaults to the slug parsed from <path>)",
427
523
  )
524
+ .option(
525
+ "--personal",
526
+ "Browse the caller's canonical personal vault. [path] is treated as " +
527
+ "bucket-relative (omit it to list the vault root). Mutually exclusive " +
528
+ "with --company.",
529
+ )
428
530
  .option(
429
531
  "--hq-root <path>",
430
532
  `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
431
533
  DEFAULT_HQ_ROOT,
432
534
  )
433
- .action(async (pathArg: string, options: FilesBrowseCliOptions) => {
535
+ .action(async (pathArg: string | undefined, options: FilesBrowseCliOptions) => {
434
536
  try {
537
+ if (options.personal && options.company) {
538
+ throw new Error(
539
+ "--personal and --company are mutually exclusive. Pick one.",
540
+ );
541
+ }
542
+
435
543
  const accessToken = await ensureCognitoToken();
436
544
  const vaultConfig = buildVaultConfig(accessToken);
437
545
  const client = new VaultClient(vaultConfig);
438
546
 
547
+ if (options.personal) {
548
+ // Personal-vault path. Resolve the caller's canonical person
549
+ // entity once; the orchestrator does the bucket lookup + vend.
550
+ // Empty [path] → list bucket root.
551
+ const personalUid = await resolveCanonicalPersonUid({
552
+ listMyMemberships: () => client.listMyMemberships(),
553
+ listPersonEntities: () => client.entity.listByType("person"),
554
+ getEntity: async () => null,
555
+ });
556
+
557
+ const result = await runBrowse({
558
+ pathPrefix: pathArg ?? "",
559
+ personalMode: true,
560
+ personalUid,
561
+ vaultClient: client,
562
+ s3Factory: defaultS3Factory,
563
+ region: DEFAULT_COGNITO.region,
564
+ });
565
+
566
+ console.log(formatBrowseTable(result.rows));
567
+ return;
568
+ }
569
+
570
+ // Company path. [path] is required here — the slug parse needs it.
571
+ if (!pathArg) {
572
+ throw new Error(
573
+ "browse: [path] is required when --personal is not set. " +
574
+ "Pass a companies/<slug>/... path, or add --personal to " +
575
+ "browse your personal vault.",
576
+ );
577
+ }
578
+
439
579
  // Resolve slug — CLI flag wins, otherwise parse from path arg.
440
580
  const slug = options.company ?? parseCompanySlugFromPath(pathArg);
441
581
 
@@ -496,7 +636,7 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
496
636
  filesCmd
497
637
  .command("cat <path>")
498
638
  .description(
499
- "Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path.",
639
+ "Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path. Pass --personal to read from the caller's personal vault.",
500
640
  )
501
641
  .option(
502
642
  "--out <file>",
@@ -506,6 +646,11 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
506
646
  "--company <slug>",
507
647
  "Company slug (defaults to the slug parsed from <path>)",
508
648
  )
649
+ .option(
650
+ "--personal",
651
+ "Read from the caller's canonical personal vault. <path> is treated as " +
652
+ "bucket-relative. Mutually exclusive with --company.",
653
+ )
509
654
  .option(
510
655
  "--hq-root <path>",
511
656
  `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
@@ -513,10 +658,43 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
513
658
  )
514
659
  .action(async (keyArg: string, options: FilesCatCliOptions) => {
515
660
  try {
661
+ if (options.personal && options.company) {
662
+ throw new Error(
663
+ "--personal and --company are mutually exclusive. Pick one.",
664
+ );
665
+ }
666
+
516
667
  const accessToken = await ensureCognitoToken();
517
668
  const vaultConfig = buildVaultConfig(accessToken);
518
669
  const client = new VaultClient(vaultConfig);
519
670
 
671
+ if (options.personal) {
672
+ const personalUid = await resolveCanonicalPersonUid({
673
+ listMyMemberships: () => client.listMyMemberships(),
674
+ listPersonEntities: () => client.entity.listByType("person"),
675
+ getEntity: async () => null,
676
+ });
677
+
678
+ const result = await runCat({
679
+ key: keyArg,
680
+ out: options.out,
681
+ hqRoot: options.hqRoot,
682
+ personalMode: true,
683
+ personalUid,
684
+ vaultClient: client,
685
+ s3Factory: defaultS3Factory,
686
+ region: DEFAULT_COGNITO.region,
687
+ });
688
+
689
+ if (result.destination.kind === "file") {
690
+ console.error(
691
+ chalk.green("✓"),
692
+ `Wrote ${result.bytesWritten} bytes to ${result.destination.absPath}`,
693
+ );
694
+ }
695
+ return;
696
+ }
697
+
520
698
  const slug = options.company ?? parseCompanySlugFromPath(keyArg);
521
699
  if (options.company !== undefined) {
522
700
  const fromPath = (() => {