@indigoai-us/hq-cli 5.28.0 → 5.30.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.
@@ -17,16 +17,21 @@
17
17
  * `hq sync` owns, and writing a peeked object there would
18
18
  * silently re-import it into the sync envelope.
19
19
  *
20
- * Both subcommands vend via the new `purpose: 'browse'` path
21
- * (`VaultClient.vend`) shipped in hq-cloud US-009. The server treats that
22
- * purpose as the role-bypass-allowed surface sync vends NEVER widen, so
23
- * keeping browse on its own vend call is the acceptance-criteria-1
24
- * separation we need.
20
+ * Both subcommands vend through the multi-tenant STS routes
21
+ * `VaultClient.sts.vend` (`/sts/vend`, company) and `.sts.vendSelf`
22
+ * (`/sts/vend-self`, personal). These resolve the caller's per-entity bucket
23
+ * and apply role/ACL scoping server-side (owner/admin full access; member/
24
+ * guest per-prefix). The legacy `POST /vend` is deliberately NOT used: it
25
+ * assumes a single static `BUCKET_ARN` that is unset in multi-tenant prod, so
26
+ * it builds an invalid policy and STS rejects it (`MalformedPolicyDocument`).
25
27
  *
26
- * Cross-package note: depends on `VendInput`/`VendResult` + the
27
- * `VaultClient.vend` method from hq-cloud US-009 (commit 2f790c5).
28
- * hq-cli pins `@indigoai-us/hq-cloud` to `file:../hq-cloud` via
29
- * `pnpm.overrides` until that release ships to npm.
28
+ * Namespace note: company vault keys are company-relative (no
29
+ * `companies/<slug>/` prefix). The CLI speaks the anchored form for user
30
+ * familiarity and translates at the S3 boundary via `toBucketRelative` /
31
+ * `toCompanyAnchored`.
32
+ *
33
+ * Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
34
+ * methods and `grantPathToPrefix` from hq-cloud.
30
35
  */
31
36
 
32
37
  import { Command } from "commander";
@@ -46,7 +51,7 @@ import {
46
51
 
47
52
  import {
48
53
  VaultClient,
49
- type VendResult,
54
+ grantPathToPrefix,
50
55
  type ExplicitGrant,
51
56
  } from "@indigoai-us/hq-cloud";
52
57
 
@@ -61,17 +66,40 @@ import { resolveCanonicalPersonUid } from "./cloud.js";
61
66
 
62
67
  // ── Types ───────────────────────────────────────────────────────────────────
63
68
 
69
+ /** STS-vended credential set the browse/cat path consumes. */
70
+ export interface BrowseCredentials {
71
+ accessKeyId: string;
72
+ secretAccessKey: string;
73
+ sessionToken: string;
74
+ }
75
+
76
+ /** Minimal STS-vend response shape (both `/sts/vend` and `/sts/vend-self`). */
77
+ export interface BrowseVendResult {
78
+ credentials: BrowseCredentials;
79
+ }
80
+
64
81
  /**
65
82
  * Subset of `VaultClient` this command actually uses — exposed so tests
66
83
  * can stub vend + grants without standing up a real `VaultClient`.
84
+ *
85
+ * Browse/cat vend through the multi-tenant `/sts/vend` (company) and
86
+ * `/sts/vend-self` (personal) routes — NOT the legacy `POST /vend`, which
87
+ * assumes a single static bucket and is non-functional in multi-tenant
88
+ * production (it builds a policy against an undefined `BUCKET_ARN`, so STS
89
+ * rejects it with `MalformedPolicyDocument`). The STS routes resolve the
90
+ * caller's per-entity bucket and apply role/ACL scoping server-side.
67
91
  */
68
92
  export interface FilesBrowseVaultClient {
69
- vend(input: {
70
- paths: string[];
71
- operations: "read-only" | "read-write" | "staged-write";
72
- purpose: "sync" | "browse";
73
- duration?: number;
74
- }): Promise<VendResult>;
93
+ sts: {
94
+ vend(input: {
95
+ companyUid: string;
96
+ durationSeconds?: number;
97
+ }): Promise<BrowseVendResult>;
98
+ vendSelf(input: {
99
+ personUid: string;
100
+ durationSeconds?: number;
101
+ }): Promise<BrowseVendResult>;
102
+ };
75
103
  listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
76
104
  entity: {
77
105
  get(uid: string): Promise<{ uid: string; slug: string; name?: string; bucketName?: string }>;
@@ -138,20 +166,48 @@ export function parseCompanySlugFromPath(prefix: string): string {
138
166
  }
139
167
 
140
168
  /**
141
- * Classify a single S3 key against the caller's explicit-grant list. Any
142
- * grant whose `path` is a prefix of the key contributes `shared-with-you`;
143
- * otherwise the key is only visible via role-bypass on the vend call.
169
+ * Translate a CLI `companies/<slug>/…` path into the company-relative S3 key
170
+ * the vault bucket actually stores. Company vault buckets are already
171
+ * company-scoped, so their keys carry NO `companies/<slug>/` prefix (e.g.
172
+ * `knowledge/foo.md`, not `companies/indigo/knowledge/foo.md`). The CLI speaks
173
+ * the anchored form for user familiarity; we strip the anchor at the S3
174
+ * boundary. A path without the anchor (or personal-mode, bucket-relative
175
+ * paths) passes through unchanged.
176
+ */
177
+ export function toBucketRelative(pathOrPrefix: string, slug: string): string {
178
+ const anchor = `companies/${slug}/`;
179
+ const normalized = pathOrPrefix.replace(/^\/+/, "");
180
+ return normalized.startsWith(anchor)
181
+ ? normalized.slice(anchor.length)
182
+ : normalized;
183
+ }
184
+
185
+ /**
186
+ * Re-attach the `companies/<slug>/` anchor to a company-relative bucket key
187
+ * for display + `hq files cat` round-trip, so the CLI surface keeps speaking
188
+ * the anchored form the user passed in.
189
+ */
190
+ export function toCompanyAnchored(bucketRelKey: string, slug: string): string {
191
+ return `companies/${slug}/${bucketRelKey}`;
192
+ }
193
+
194
+ /**
195
+ * Classify a single company-relative S3 key against the caller's
196
+ * (already-normalized) explicit-grant prefixes. Any prefix that covers the
197
+ * key contributes `shared-with-you`; otherwise the key is visible only via
198
+ * the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
199
+ * prefix is a company-wide grant and matches everything.
144
200
  *
145
- * Grant paths and S3 keys live in the same canonical form ("companies/<slug>/…");
146
- * `coalescePrefixes` would shrink the list further but isn't required for
147
- * correctness`startsWith` already short-circuits on the first match.
201
+ * Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
202
+ * to company-relative `startsWith` prefixes via `grantPathToPrefix` before
203
+ * calling this keeping this helper pure and trivially testable.
148
204
  */
149
205
  export function classifyAclSource(
150
- key: string,
151
- grants: ExplicitGrant[],
206
+ bucketRelKey: string,
207
+ grantPrefixes: string[],
152
208
  ): AclSource {
153
- for (const g of grants) {
154
- if (g.path && key.startsWith(g.path)) return "shared-with-you";
209
+ for (const p of grantPrefixes) {
210
+ if (p === "" || bucketRelKey.startsWith(p)) return "shared-with-you";
155
211
  }
156
212
  return "role-bypass";
157
213
  }
@@ -242,7 +298,7 @@ export interface RunBrowseInput {
242
298
 
243
299
  export interface RunBrowseResult {
244
300
  rows: BrowseRow[];
245
- vend: VendResult;
301
+ vend: BrowseVendResult;
246
302
  }
247
303
 
248
304
  /**
@@ -250,8 +306,10 @@ export interface RunBrowseResult {
250
306
  *
251
307
  * 1. Parse slug from prefix (or use override).
252
308
  * 2. Resolve companyUid + bucketName via VaultClient.entity.
253
- * 3. Vend with `purpose: 'browse'`, `operations: 'read-only'`, paths: [prefix].
254
- * 4. Construct S3Client from vended creds, paginate ListObjectsV2.
309
+ * 3. Vend read creds via the multi-tenant STS route (`/sts/vend` company,
310
+ * `/sts/vend-self` personal) resolves the per-entity bucket + role/ACL.
311
+ * 4. Construct S3Client from vended creds, paginate ListObjectsV2 over the
312
+ * company-relative key space.
255
313
  * 5. Fetch explicit grants once, classify each key.
256
314
  *
257
315
  * Pure-ish: no console output, no process.exit — caller renders + exits.
@@ -262,10 +320,11 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
262
320
  // Branch by mode. Company mode parses slug from path and looks up by
263
321
  // namespace; personal mode resolves the entity directly by the supplied
264
322
  // 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.
323
+ // no grants graph — the owner is the only principal).
267
324
  let bucket: string;
268
325
  let entityUid: string;
326
+ let slug: string | undefined;
327
+ let vend: BrowseVendResult;
269
328
  if (personalMode) {
270
329
  if (!input.personalUid) {
271
330
  throw new Error(
@@ -281,8 +340,9 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
281
340
  }
282
341
  entityUid = entity.uid;
283
342
  bucket = entity.bucketName;
343
+ vend = await vaultClient.sts.vendSelf({ personUid: entityUid });
284
344
  } else {
285
- const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
345
+ slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
286
346
  const entity = await vaultClient.entity.findInMyNamespace("company", slug);
287
347
  if (!entity) {
288
348
  throw new Error(
@@ -296,19 +356,12 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
296
356
  }
297
357
  entityUid = entity.uid;
298
358
  bucket = entity.bucketName;
359
+ // Multi-tenant vend: the server resolves this company's bucket + applies
360
+ // owner/admin role-bypass (full access) or member/guest ACL scoping. The
361
+ // legacy `POST /vend` is unused here — see FilesBrowseVaultClient docs.
362
+ vend = await vaultClient.sts.vend({ companyUid: entityUid });
299
363
  }
300
364
 
301
- // Distinct vend call from sync — `purpose: 'browse'` opts the request
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.
306
- const vend = await vaultClient.vend({
307
- paths: [pathPrefix],
308
- operations: "read-only",
309
- purpose: "browse",
310
- });
311
-
312
365
  const s3 = s3Factory({
313
366
  region,
314
367
  credentials: {
@@ -318,13 +371,25 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
318
371
  },
319
372
  });
320
373
 
374
+ // Company vault keys are company-relative (no `companies/<slug>/` prefix), so
375
+ // translate the CLI's anchored prefix into the bucket-relative form before
376
+ // listing. Personal-mode paths are already bucket-relative.
377
+ const listPrefix =
378
+ personalMode || slug === undefined
379
+ ? pathPrefix
380
+ : toBucketRelative(pathPrefix, slug);
381
+
321
382
  // Pull the caller's explicit-grant graph once so per-key classification
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);
383
+ // is O(grants) without N round-trips. Grant `path`s are normalized to
384
+ // company-relative `startsWith` prefixes (matching the bucket key space)
385
+ // via `grantPathToPrefix`. Skipped in personal mode — the grants graph is a
386
+ // company concept; a person bucket marks every row `"personal-vault"`.
387
+ const grantPrefixes =
388
+ personalMode || slug === undefined
389
+ ? []
390
+ : (await vaultClient.listMyExplicitGrants(entityUid)).map((g) =>
391
+ grantPathToPrefix(g.path, slug as string),
392
+ );
328
393
 
329
394
  const rows: BrowseRow[] = [];
330
395
  let continuationToken: string | undefined;
@@ -332,7 +397,7 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
332
397
  const resp = (await s3.send(
333
398
  new ListObjectsV2Command({
334
399
  Bucket: bucket,
335
- Prefix: pathPrefix,
400
+ Prefix: listPrefix,
336
401
  ContinuationToken: continuationToken,
337
402
  }),
338
403
  )) as ListObjectsV2CommandOutput;
@@ -342,11 +407,18 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
342
407
  // Skip S3 "directory marker" objects (0-byte, trailing slash).
343
408
  if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0) continue;
344
409
 
410
+ // `obj.Key` is company-relative. Classify in that space, then re-anchor
411
+ // for display so the CLI keeps speaking `companies/<slug>/...`.
345
412
  rows.push({
346
- key: obj.Key,
413
+ key:
414
+ personalMode || slug === undefined
415
+ ? obj.Key
416
+ : toCompanyAnchored(obj.Key, slug),
347
417
  size: obj.Size ?? 0,
348
418
  lastModified: obj.LastModified,
349
- aclSource: personalMode ? "personal-vault" : classifyAclSource(obj.Key, grants),
419
+ aclSource: personalMode
420
+ ? "personal-vault"
421
+ : classifyAclSource(obj.Key, grantPrefixes),
350
422
  });
351
423
  }
352
424
 
@@ -384,7 +456,7 @@ export interface RunCatInput {
384
456
  export interface RunCatResult {
385
457
  bytesWritten: number;
386
458
  destination: { kind: "stdout" } | { kind: "file"; absPath: string };
387
- vend: VendResult;
459
+ vend: BrowseVendResult;
388
460
  }
389
461
 
390
462
  /**
@@ -405,6 +477,8 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
405
477
  // Same branch logic as runBrowse — see that function's doc-block for
406
478
  // the personal-vs-company rationale.
407
479
  let bucket: string;
480
+ let s3Key: string;
481
+ let vend: BrowseVendResult;
408
482
  if (personalMode) {
409
483
  if (!input.personalUid) {
410
484
  throw new Error(
@@ -419,6 +493,8 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
419
493
  );
420
494
  }
421
495
  bucket = entity.bucketName;
496
+ s3Key = key; // personal-mode keys are already bucket-relative
497
+ vend = await vaultClient.sts.vendSelf({ personUid: entity.uid });
422
498
  } else {
423
499
  const slug = input.companySlug ?? parseCompanySlugFromPath(key);
424
500
  const entity = await vaultClient.entity.findInMyNamespace("company", slug);
@@ -433,14 +509,11 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
433
509
  );
434
510
  }
435
511
  bucket = entity.bucketName;
512
+ // Translate the anchored CLI key into the company-relative bucket key.
513
+ s3Key = toBucketRelative(key, slug);
514
+ vend = await vaultClient.sts.vend({ companyUid: entity.uid });
436
515
  }
437
516
 
438
- const vend = await vaultClient.vend({
439
- paths: [key],
440
- operations: "read-only",
441
- purpose: "browse",
442
- });
443
-
444
517
  const s3 = s3Factory({
445
518
  region,
446
519
  credentials: {
@@ -451,7 +524,7 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
451
524
  });
452
525
 
453
526
  const resp = (await s3.send(
454
- new GetObjectCommand({ Bucket: bucket, Key: key }),
527
+ new GetObjectCommand({ Bucket: bucket, Key: s3Key }),
455
528
  )) as GetObjectCommandOutput;
456
529
 
457
530
  if (!resp.Body) {
@@ -603,6 +676,207 @@ export function formatSharedWithMeTable(rows: SharedWithMeRow[]): string {
603
676
  ].join("\n");
604
677
  }
605
678
 
679
+ // ── search ───────────────────────────────────────────────────────────────
680
+
681
+ export interface RunSearchInput {
682
+ /** Case-insensitive substring matched against each object's full key. */
683
+ query: string;
684
+ /** Company slug to search (ignored under personalMode). */
685
+ companySlug: string;
686
+ personalMode?: boolean;
687
+ personalUid?: string;
688
+ vaultClient: FilesBrowseVaultClient;
689
+ s3Factory: S3ClientFactory;
690
+ region: string;
691
+ }
692
+
693
+ /**
694
+ * `hq files search <query>` orchestrator. Lists the company (or personal)
695
+ * vault under its root via `runBrowse`, then filters to keys containing the
696
+ * query (case-insensitive). v1 is a name/path search over the listing — no
697
+ * content search. Rows carry the same ACL-source classification as browse.
698
+ *
699
+ * Pure-ish: no console output. The caller renders with `formatBrowseTable`.
700
+ */
701
+ export async function runSearch(input: RunSearchInput): Promise<BrowseRow[]> {
702
+ const prefix = input.personalMode ? "" : `companies/${input.companySlug}/`;
703
+ const { rows } = await runBrowse({
704
+ pathPrefix: prefix,
705
+ companySlug: input.companySlug,
706
+ personalMode: input.personalMode,
707
+ personalUid: input.personalUid,
708
+ vaultClient: input.vaultClient,
709
+ s3Factory: input.s3Factory,
710
+ region: input.region,
711
+ });
712
+ const q = input.query.toLowerCase();
713
+ return rows.filter((r) => r.key.toLowerCase().includes(q));
714
+ }
715
+
716
+ // ── get (materialize + pin) ─────────────────────────────────────────────────
717
+
718
+ interface PinFile {
719
+ version: number;
720
+ /** companySlug → sorted list of company-relative pinned prefixes. */
721
+ pins: Record<string, string[]>;
722
+ }
723
+
724
+ /** Per-machine pin set path: `<hqRoot>/.hq/pins.json`. */
725
+ export function pinFilePath(hqRoot: string): string {
726
+ return path.join(hqRoot, ".hq", "pins.json");
727
+ }
728
+
729
+ /** Read the pin set, tolerating a missing or corrupt file (→ fresh). */
730
+ export function readPins(hqRoot: string): PinFile {
731
+ try {
732
+ const parsed = JSON.parse(fs.readFileSync(pinFilePath(hqRoot), "utf-8"));
733
+ if (parsed && typeof parsed === "object" && parsed.pins) {
734
+ return { version: parsed.version ?? 1, pins: parsed.pins };
735
+ }
736
+ } catch {
737
+ /* missing / unreadable / malformed → start fresh */
738
+ }
739
+ return { version: 1, pins: {} };
740
+ }
741
+
742
+ /**
743
+ * Register a company-relative prefix in the per-machine pin set. Pins are what
744
+ * keep an on-demand `hq files get` from being pruned by the next *scoped*
745
+ * sync (`syncMode: shared|custom`): the sync runner unions the company's pins
746
+ * into its pull scope. Idempotent + sorted for stable diffs.
747
+ */
748
+ export function addPin(hqRoot: string, companySlug: string, prefix: string): void {
749
+ const pf = readPins(hqRoot);
750
+ const list = pf.pins[companySlug] ?? [];
751
+ if (!list.includes(prefix)) list.push(prefix);
752
+ list.sort();
753
+ pf.pins[companySlug] = list;
754
+ const f = pinFilePath(hqRoot);
755
+ fs.mkdirSync(path.dirname(f), { recursive: true });
756
+ fs.writeFileSync(f, JSON.stringify(pf, null, 2) + "\n");
757
+ }
758
+
759
+ export interface RunGetInput {
760
+ /** Vault path to materialize: `companies/<slug>/...` (file or prefix). */
761
+ path: string;
762
+ /**
763
+ * Override destination directory. Default (omitted) writes in place under
764
+ * `<hqRoot>/companies/<slug>/...` — the tree `hq sync` manages — and
765
+ * registers a pin. `--into` writes outside that envelope and pins nothing.
766
+ */
767
+ into?: string;
768
+ hqRoot: string;
769
+ companySlug?: string;
770
+ vaultClient: FilesBrowseVaultClient;
771
+ s3Factory: S3ClientFactory;
772
+ region: string;
773
+ }
774
+
775
+ export interface RunGetResult {
776
+ filesWritten: number;
777
+ bytesWritten: number;
778
+ destinations: string[];
779
+ /** Set only for in-place (no `--into`) materialization. */
780
+ pinned?: { companySlug: string; prefix: string };
781
+ }
782
+
783
+ /**
784
+ * `hq files get <path>` orchestrator. Materializes a vault file or prefix into
785
+ * the local HQ tree on demand. Unlike `cat` (which refuses to write under
786
+ * `companies/`), `get` deliberately writes INTO `companies/<slug>/...` by
787
+ * default — that's the point: pull a path you have access to but don't sync.
788
+ * It then registers a pin so the next scoped sync keeps it.
789
+ *
790
+ * Company mode only in v1 — materializing a personal vault would target the
791
+ * HQ root itself, which is too broad to do implicitly.
792
+ */
793
+ export async function runGet(input: RunGetInput): Promise<RunGetResult> {
794
+ const { path: vaultPath, vaultClient, s3Factory, region, hqRoot } = input;
795
+ const slug = input.companySlug ?? parseCompanySlugFromPath(vaultPath);
796
+
797
+ const entity = await vaultClient.entity.findInMyNamespace("company", slug);
798
+ if (!entity) {
799
+ throw new Error(
800
+ `No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
801
+ );
802
+ }
803
+ if (!entity.bucketName) {
804
+ throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
805
+ }
806
+ const bucket = entity.bucketName;
807
+ const vend = await vaultClient.sts.vend({ companyUid: entity.uid });
808
+ const s3 = s3Factory({
809
+ region,
810
+ credentials: {
811
+ accessKeyId: vend.credentials.accessKeyId,
812
+ secretAccessKey: vend.credentials.secretAccessKey,
813
+ sessionToken: vend.credentials.sessionToken,
814
+ },
815
+ });
816
+
817
+ // Company-relative prefix to list/fetch (bucket keys carry no anchor).
818
+ const bucketPrefix = toBucketRelative(vaultPath, slug);
819
+
820
+ const keys: string[] = [];
821
+ let continuationToken: string | undefined;
822
+ do {
823
+ const resp = (await s3.send(
824
+ new ListObjectsV2Command({
825
+ Bucket: bucket,
826
+ Prefix: bucketPrefix,
827
+ ContinuationToken: continuationToken,
828
+ }),
829
+ )) as ListObjectsV2CommandOutput;
830
+ for (const obj of resp.Contents ?? []) {
831
+ if (!obj.Key) continue;
832
+ if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0) continue;
833
+ keys.push(obj.Key);
834
+ }
835
+ continuationToken = resp.NextContinuationToken ?? undefined;
836
+ } while (continuationToken);
837
+
838
+ if (keys.length === 0) {
839
+ throw new Error(`No objects under '${vaultPath}'.`);
840
+ }
841
+
842
+ const destinations: string[] = [];
843
+ let bytesWritten = 0;
844
+ for (const key of keys) {
845
+ // Default: in-place under companies/<slug>/<company-relative key>.
846
+ // --into: write the path relative to the requested prefix under <into>.
847
+ let destAbs: string;
848
+ if (input.into !== undefined) {
849
+ const rel = key.startsWith(bucketPrefix)
850
+ ? key.slice(bucketPrefix.length)
851
+ : key;
852
+ destAbs = path.resolve(input.into, rel || path.basename(key));
853
+ } else {
854
+ destAbs = path.join(hqRoot, "companies", slug, key);
855
+ }
856
+ const resp = (await s3.send(
857
+ new GetObjectCommand({ Bucket: bucket, Key: key }),
858
+ )) as GetObjectCommandOutput;
859
+ if (!resp.Body) {
860
+ throw new Error(`GetObject for '${key}' returned no body.`);
861
+ }
862
+ const body = resp.Body as unknown as Readable;
863
+ fs.mkdirSync(path.dirname(destAbs), { recursive: true });
864
+ await pipeline(body, fs.createWriteStream(destAbs));
865
+ bytesWritten += fs.statSync(destAbs).size;
866
+ destinations.push(destAbs);
867
+ }
868
+
869
+ // Pin only the in-place case — `--into` writes outside the sync envelope, so
870
+ // there's nothing for a scoped sync to prune.
871
+ let pinned: RunGetResult["pinned"];
872
+ if (input.into === undefined) {
873
+ addPin(hqRoot, slug, bucketPrefix);
874
+ pinned = { companySlug: slug, prefix: bucketPrefix };
875
+ }
876
+
877
+ return { filesWritten: keys.length, bytesWritten, destinations, pinned };
878
+ }
879
+
606
880
  // ── CLI registration ────────────────────────────────────────────────────────
607
881
 
608
882
  const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
@@ -624,6 +898,17 @@ interface FilesCatCliOptions extends FilesBrowseCliOptions {
624
898
  out?: string;
625
899
  }
626
900
 
901
+ interface FilesSearchCliOptions {
902
+ company?: string;
903
+ personal?: boolean;
904
+ }
905
+
906
+ interface FilesGetCliOptions {
907
+ company?: string;
908
+ hqRoot: string;
909
+ into?: string;
910
+ }
911
+
627
912
  /**
628
913
  * Wire `hq files browse` + `hq files cat` onto an existing `files`
629
914
  * Commander group. `registerFilesCommand` in files.ts builds the group
@@ -893,4 +1178,138 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
893
1178
  process.exit(1);
894
1179
  }
895
1180
  });
1181
+
1182
+ filesCmd
1183
+ .command("search <query>")
1184
+ .description(
1185
+ "Search vault object keys (case-insensitive path/name match) under a company without downloading. Requires --company (or --personal). Content search is not supported in v1.",
1186
+ )
1187
+ .option("--company <slug>", "Company slug to search.")
1188
+ .option(
1189
+ "--personal",
1190
+ "Search the caller's canonical personal vault. Mutually exclusive with --company.",
1191
+ )
1192
+ .action(async (query: string, options: FilesSearchCliOptions) => {
1193
+ try {
1194
+ if (options.personal && options.company) {
1195
+ throw new Error(
1196
+ "--personal and --company are mutually exclusive. Pick one.",
1197
+ );
1198
+ }
1199
+
1200
+ const accessToken = await ensureCognitoToken();
1201
+ const client = new VaultClient(buildVaultConfig(accessToken));
1202
+
1203
+ if (options.personal) {
1204
+ const personalUid = await resolveCanonicalPersonUid({
1205
+ listMyMemberships: () => client.listMyMemberships(),
1206
+ listPersonEntities: () => client.entity.listByType("person"),
1207
+ getEntity: async () => null,
1208
+ });
1209
+ const rows = await runSearch({
1210
+ query,
1211
+ companySlug: "personal",
1212
+ personalMode: true,
1213
+ personalUid,
1214
+ vaultClient: client,
1215
+ s3Factory: defaultS3Factory,
1216
+ region: DEFAULT_COGNITO.region,
1217
+ });
1218
+ console.log(formatBrowseTable(rows));
1219
+ return;
1220
+ }
1221
+
1222
+ if (!options.company) {
1223
+ throw new Error(
1224
+ "search: --company <slug> is required (or --personal to search your personal vault).",
1225
+ );
1226
+ }
1227
+ await getCompanyUid(accessToken, options.company);
1228
+
1229
+ const rows = await runSearch({
1230
+ query,
1231
+ companySlug: options.company,
1232
+ vaultClient: client,
1233
+ s3Factory: defaultS3Factory,
1234
+ region: DEFAULT_COGNITO.region,
1235
+ });
1236
+ console.log(formatBrowseTable(rows));
1237
+ } catch (err) {
1238
+ console.error(
1239
+ chalk.red("Error:"),
1240
+ err instanceof Error ? err.message : String(err),
1241
+ );
1242
+ process.exit(1);
1243
+ }
1244
+ });
1245
+
1246
+ filesCmd
1247
+ .command("get <path>")
1248
+ .description(
1249
+ "Download (materialize) a vault file or prefix into local HQ on demand. Default writes in place under <hqRoot>/companies/<slug>/<path> and registers a pin so a scoped sync (mode shared|custom) won't prune it. Use --into to write elsewhere (no pin). Company mode only.",
1250
+ )
1251
+ .option(
1252
+ "--into <dir>",
1253
+ "Write into this directory instead of the in-place companies/<slug>/ location. No pin is registered.",
1254
+ )
1255
+ .option(
1256
+ "--company <slug>",
1257
+ "Company slug (defaults to the slug parsed from <path>).",
1258
+ )
1259
+ .option(
1260
+ "--hq-root <path>",
1261
+ `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
1262
+ DEFAULT_HQ_ROOT,
1263
+ )
1264
+ .action(async (pathArg: string, options: FilesGetCliOptions) => {
1265
+ try {
1266
+ const accessToken = await ensureCognitoToken();
1267
+ const client = new VaultClient(buildVaultConfig(accessToken));
1268
+
1269
+ const slug = options.company ?? parseCompanySlugFromPath(pathArg);
1270
+ if (options.company !== undefined) {
1271
+ const fromPath = (() => {
1272
+ try {
1273
+ return parseCompanySlugFromPath(pathArg);
1274
+ } catch {
1275
+ return undefined;
1276
+ }
1277
+ })();
1278
+ if (fromPath && fromPath !== options.company) {
1279
+ throw new Error(
1280
+ `--company '${options.company}' disagrees with path slug '${fromPath}'.`,
1281
+ );
1282
+ }
1283
+ }
1284
+ await getCompanyUid(accessToken, slug);
1285
+
1286
+ const result = await runGet({
1287
+ path: pathArg,
1288
+ into: options.into,
1289
+ hqRoot: options.hqRoot,
1290
+ companySlug: slug,
1291
+ vaultClient: client,
1292
+ s3Factory: defaultS3Factory,
1293
+ region: DEFAULT_COGNITO.region,
1294
+ });
1295
+
1296
+ console.error(
1297
+ chalk.green("✓"),
1298
+ `Materialized ${result.filesWritten} file(s), ${result.bytesWritten} bytes.`,
1299
+ );
1300
+ if (result.pinned) {
1301
+ console.error(
1302
+ chalk.dim(
1303
+ `Pinned ${result.pinned.companySlug}:${result.pinned.prefix} — survives scoped sync.`,
1304
+ ),
1305
+ );
1306
+ }
1307
+ } catch (err) {
1308
+ console.error(
1309
+ chalk.red("Error:"),
1310
+ err instanceof Error ? err.message : String(err),
1311
+ );
1312
+ process.exit(1);
1313
+ }
1314
+ });
896
1315
  }