@indigoai-us/hq-cli 5.51.0 → 5.53.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.
@@ -0,0 +1,236 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { readFileSync } from "node:fs";
4
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
5
+ import { vaultApiFetch, getEntityUid } from "../utils/vault-api.js";
6
+
7
+ /**
8
+ * `hq crm entity upsert` — agent-facing controlled entity upsert
9
+ * (hq-native-crm US-002). Wraps `POST /crm/entities`, which fronts the ontology
10
+ * write gate so an authenticated company MEMBER (or higher) can create/update
11
+ * canonical CRM entities in their company's vault.
12
+ *
13
+ * Conventions mirror `secrets.ts`: ensureCognitoToken() → getEntityUid() →
14
+ * vaultApiFetch() → error-check → chalk output.
15
+ */
16
+
17
+ /** The CRM/ontology entity types the upsert route accepts via `--type`. */
18
+ const CRM_ENTITY_TYPES = [
19
+ "contact",
20
+ "deal",
21
+ "contract",
22
+ "invoice",
23
+ "company",
24
+ ] as const;
25
+ type CrmEntityType = (typeof CRM_ENTITY_TYPES)[number];
26
+
27
+ /** A minimal entity payload in the write-gate ExtractedEntity shape. */
28
+ interface CrmEntityInput {
29
+ type: string;
30
+ canonical_name: string;
31
+ aliases?: string[];
32
+ confidence?: number;
33
+ domain?: string[];
34
+ relationships?: unknown[];
35
+ external_ids?: Record<string, string>;
36
+ web_domains?: string[];
37
+ }
38
+
39
+ interface UpsertFlags {
40
+ type?: string;
41
+ name?: string;
42
+ attioId?: string;
43
+ stripeId?: string;
44
+ pandadocId?: string;
45
+ neonId?: string;
46
+ json?: string;
47
+ }
48
+
49
+ /**
50
+ * Read the `--json <file|->` payload: a single entity object or an array of
51
+ * entities. `-` reads from stdin. Returns the entities array (always
52
+ * normalized to an array) or throws a descriptive Error.
53
+ */
54
+ function readJsonEntities(source: string): CrmEntityInput[] {
55
+ const raw =
56
+ source === "-"
57
+ ? readFileSync(0, "utf8") // fd 0 = stdin
58
+ : readFileSync(source, "utf8");
59
+ let parsed: unknown;
60
+ try {
61
+ parsed = JSON.parse(raw);
62
+ } catch (err) {
63
+ throw new Error(
64
+ `--json payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
65
+ );
66
+ }
67
+ const entities = Array.isArray(parsed) ? parsed : [parsed];
68
+ if (entities.length === 0) {
69
+ throw new Error("--json payload contained no entities");
70
+ }
71
+ for (const e of entities) {
72
+ if (!e || typeof e !== "object") {
73
+ throw new Error("--json entities must be objects");
74
+ }
75
+ const obj = e as Record<string, unknown>;
76
+ if (typeof obj.type !== "string" || typeof obj.canonical_name !== "string") {
77
+ throw new Error(
78
+ "each --json entity needs a string `type` and `canonical_name`",
79
+ );
80
+ }
81
+ }
82
+ return entities as CrmEntityInput[];
83
+ }
84
+
85
+ /**
86
+ * Build a single entity from the flag form (`--type` + `--name` + optional
87
+ * external-id flags). The route fills the additive defaults (aliases/domain/
88
+ * relationships/confidence) when omitted, but we send a complete minimal shape.
89
+ */
90
+ function buildEntityFromFlags(opts: UpsertFlags): CrmEntityInput {
91
+ const externalIds: Record<string, string> = {};
92
+ if (opts.attioId) externalIds.attio = opts.attioId;
93
+ if (opts.stripeId) externalIds.stripe = opts.stripeId;
94
+ if (opts.pandadocId) externalIds.pandadoc = opts.pandadocId;
95
+ if (opts.neonId) externalIds.neon = opts.neonId;
96
+
97
+ const entity: CrmEntityInput = {
98
+ type: opts.type as CrmEntityType,
99
+ canonical_name: opts.name as string,
100
+ aliases: [],
101
+ confidence: 1,
102
+ domain: [],
103
+ relationships: [],
104
+ };
105
+ if (Object.keys(externalIds).length > 0) {
106
+ entity.external_ids = externalIds;
107
+ }
108
+ return entity;
109
+ }
110
+
111
+ export function registerCrmCommand(program: Command): void {
112
+ const crm = program
113
+ .command("crm")
114
+ .description("Native CRM — upsert canonical entities into a company vault")
115
+ .option("--company <slug>", "Company slug (resolves to companyUid)");
116
+
117
+ const entity = crm
118
+ .command("entity")
119
+ .description("CRM entity operations");
120
+
121
+ entity
122
+ .command("upsert")
123
+ .description(
124
+ "Create or update a canonical CRM entity (POST /crm/entities). " +
125
+ "Use --type/--name (+ optional external-id flags) or --json <file|-> " +
126
+ "to pass a full entity/array.",
127
+ )
128
+ .option(
129
+ "--type <type>",
130
+ `Entity type: ${CRM_ENTITY_TYPES.join(" | ")}`,
131
+ )
132
+ .option("--name <canonical_name>", "Canonical entity name")
133
+ .option("--attio-id <id>", "Attio record id → external_ids.attio")
134
+ .option("--stripe-id <id>", "Stripe object id → external_ids.stripe")
135
+ .option("--pandadoc-id <id>", "PandaDoc document id → external_ids.pandadoc")
136
+ .option("--neon-id <id>", "Neon row id → external_ids.neon")
137
+ .option(
138
+ "--json <file|->",
139
+ "Path to a JSON file (or '-' for stdin) with a full entity object or array",
140
+ )
141
+ .action(async (opts: UpsertFlags) => {
142
+ try {
143
+ const companySlug = crm.opts().company as string | undefined;
144
+ if (!companySlug) {
145
+ console.error(
146
+ chalk.red("Error: --company <slug> is required."),
147
+ );
148
+ process.exit(1);
149
+ }
150
+
151
+ // Resolve the entity payload: --json is the alternative to flags.
152
+ let entities: CrmEntityInput[];
153
+ if (opts.json) {
154
+ entities = readJsonEntities(opts.json);
155
+ } else {
156
+ if (!opts.type) {
157
+ console.error(
158
+ chalk.red(
159
+ `Error: --type <${CRM_ENTITY_TYPES.join("|")}> is required (or pass --json).`,
160
+ ),
161
+ );
162
+ process.exit(1);
163
+ }
164
+ if (!CRM_ENTITY_TYPES.includes(opts.type as CrmEntityType)) {
165
+ console.error(
166
+ chalk.red(
167
+ `Error: --type must be one of ${CRM_ENTITY_TYPES.join(", ")}.`,
168
+ ),
169
+ );
170
+ process.exit(1);
171
+ }
172
+ if (!opts.name) {
173
+ console.error(
174
+ chalk.red("Error: --name <canonical_name> is required (or pass --json)."),
175
+ );
176
+ process.exit(1);
177
+ }
178
+ entities = [buildEntityFromFlags(opts)];
179
+ }
180
+
181
+ const token = await ensureCognitoToken();
182
+ const companyUid = await getEntityUid(token, { companySlug });
183
+
184
+ const res = await vaultApiFetch({
185
+ token,
186
+ path: "/crm/entities",
187
+ method: "POST",
188
+ body: { companyUid, entities },
189
+ });
190
+
191
+ if (!res.ok) {
192
+ const body = (await res.json().catch(() => ({}))) as Record<
193
+ string,
194
+ unknown
195
+ >;
196
+ // CrmDisabledError surfaces as a 403 with a helpful message.
197
+ if (res.status === 403 && body.code === "CRM_DISABLED") {
198
+ console.error(
199
+ chalk.red(
200
+ "CRM is not enabled for this company. Enable it first: " +
201
+ `hq company settings set --company ${companySlug} --crm-enabled true`,
202
+ ),
203
+ );
204
+ process.exit(1);
205
+ }
206
+ console.error(
207
+ chalk.red(
208
+ `Failed to upsert entities: ${(body.error as string) ?? res.statusText}`,
209
+ ),
210
+ );
211
+ process.exit(1);
212
+ }
213
+
214
+ const result = (await res.json().catch(() => ({}))) as {
215
+ created?: number;
216
+ updated?: number;
217
+ skipped?: number;
218
+ status?: string;
219
+ };
220
+ console.log(
221
+ chalk.green(
222
+ `Upsert ${result.status ?? "ok"}: ` +
223
+ `${result.created ?? 0} created, ` +
224
+ `${result.updated ?? 0} updated, ` +
225
+ `${result.skipped ?? 0} skipped.`,
226
+ ),
227
+ );
228
+ } catch (err) {
229
+ console.error(
230
+ chalk.red("Error:"),
231
+ err instanceof Error ? err.message : String(err),
232
+ );
233
+ process.exit(1);
234
+ }
235
+ });
236
+ }
@@ -398,3 +398,87 @@ describe("hq files delete — server error mapping", () => {
398
398
  expect(formatFilesDeleteError(new FilesDeleteHttpError(500, "boom"), "p")).toMatch(/Server error: boom/);
399
399
  });
400
400
  });
401
+
402
+ describe("hq files delete — --personal (personal vault scope)", () => {
403
+ // Body parser tolerant of both scope shapes (company vs personal).
404
+ function bodies(): Array<Record<string, unknown>> {
405
+ return deleteCalls().map((c) => JSON.parse((c[1]?.body as string) ?? "{}"));
406
+ }
407
+
408
+ it("--dry-run sends { personal: true } with NO company membership lookup", async () => {
409
+ // First (and only) fetch is the delete preview — getCompanyUid is skipped.
410
+ fetchSpy.mockResolvedValueOnce(
411
+ deleteResponse({
412
+ dryRun: true,
413
+ matched: 2,
414
+ keys: ["installer/a.txt", "installer/b.txt"],
415
+ }),
416
+ );
417
+
418
+ const program = buildProgram();
419
+ await program.parseAsync(
420
+ ["files", "delete", "installer/", "--personal", "--dry-run"],
421
+ { from: "user" },
422
+ );
423
+
424
+ const b = bodies();
425
+ expect(b).toHaveLength(1);
426
+ expect(b[0]).toEqual({ personal: true, prefix: "installer/*", dryRun: true });
427
+ expect(b[0]).not.toHaveProperty("company");
428
+ expect(printed()).toContain("[dry-run]");
429
+ expect(exitSpy).not.toHaveBeenCalled();
430
+ });
431
+
432
+ it("real delete with --yes previews then deletes, both bodies personal-scoped", async () => {
433
+ fetchSpy.mockResolvedValueOnce(
434
+ deleteResponse({ dryRun: true, matched: 2, keys: ["installer/a.txt", "installer/b.txt"] }),
435
+ );
436
+ fetchSpy.mockResolvedValueOnce(
437
+ deleteResponse({ dryRun: false, matched: 2, deleted: 2, tombstoned: 2 }),
438
+ );
439
+
440
+ const program = buildProgram();
441
+ await program.parseAsync(
442
+ ["files", "delete", "installer/*", "--personal", "--yes"],
443
+ { from: "user" },
444
+ );
445
+
446
+ const b = bodies();
447
+ expect(b).toHaveLength(2);
448
+ expect(b[0]).toEqual({ personal: true, prefix: "installer/*", dryRun: true });
449
+ expect(b[1]).toEqual({ personal: true, prefix: "installer/*", dryRun: false });
450
+ expect(printed()).toContain("Deleted 2 objects");
451
+ });
452
+
453
+ it("confirmation copy says 'your personal vault' (not the shared-vault wording)", async () => {
454
+ fetchSpy.mockResolvedValueOnce(
455
+ deleteResponse({ dryRun: true, matched: 1, keys: ["installer/a.txt"] }),
456
+ );
457
+ let prompt = "";
458
+ await runFilesDelete(
459
+ { prefix: "installer/*", dryRun: false, yes: false, personal: true, companySlug: undefined },
460
+ {
461
+ confirm: async (m) => {
462
+ prompt = m;
463
+ return false;
464
+ },
465
+ },
466
+ );
467
+ expect(prompt).toContain("your personal vault");
468
+ expect(prompt).not.toContain("shared vault");
469
+ // Declined → no real delete (only the preview fetch happened).
470
+ expect(bodies()).toHaveLength(1);
471
+ });
472
+
473
+ it("rejects --personal combined with --company before any network call", async () => {
474
+ const program = buildProgram();
475
+ await expect(
476
+ program.parseAsync(
477
+ ["files", "--company", "acme", "delete", "installer/*", "--personal"],
478
+ { from: "user" },
479
+ ),
480
+ ).rejects.toThrow(/__EXIT__:1/);
481
+ expect(printedErr()).toContain("Pass either --personal or --company");
482
+ expect(deleteCalls()).toHaveLength(0);
483
+ });
484
+ });
@@ -501,6 +501,136 @@ describe("hq files share — direct-grant fork (with --with)", () => {
501
501
  expect(errs).toMatch(/--permission is required/);
502
502
  expect(fetchSpy).not.toHaveBeenCalled();
503
503
  });
504
+
505
+ it("--full --with email grants the '*' wildcard at the default write permission (no glob to quote)", async () => {
506
+ // 1) /membership/me for company resolution
507
+ fetchSpy.mockResolvedValueOnce(
508
+ jsonResponse(200, {
509
+ memberships: [
510
+ {
511
+ membershipKey: "k1",
512
+ companyUid: "cmp_acme",
513
+ role: "member",
514
+ status: "active",
515
+ },
516
+ ],
517
+ }),
518
+ );
519
+ // 2) POST /files/cmp_acme/acl/grant
520
+ fetchSpy.mockResolvedValueOnce(
521
+ jsonResponse(200, { acl: { path: "*" } }),
522
+ );
523
+
524
+ const program = buildProgram();
525
+ // No positional path, no --permission — --full supplies prefix '*' and
526
+ // defaults permission to write.
527
+ await program.parseAsync(
528
+ ["files", "share", "--full", "--with", "user@example.com"],
529
+ { from: "user" },
530
+ );
531
+
532
+ const mintCalls = fetchSpy.mock.calls.filter((c) =>
533
+ String(c[0]).includes("/share-session"),
534
+ );
535
+ expect(mintCalls).toHaveLength(0);
536
+
537
+ const grantCall = fetchSpy.mock.calls.find((c) =>
538
+ String(c[0]).includes("/acl/grant"),
539
+ );
540
+ expect(grantCall).toBeDefined();
541
+ const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
542
+ expect(body).toEqual({
543
+ prefix: "*",
544
+ granteeType: "email",
545
+ granteeId: "user@example.com",
546
+ permission: "write",
547
+ });
548
+
549
+ const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
550
+ expect(printed).toMatch(/ENTIRE vault/);
551
+ expect(open).not.toHaveBeenCalled();
552
+ });
553
+
554
+ it("--full honors an explicit --permission read (read-only full vault)", async () => {
555
+ fetchSpy.mockResolvedValueOnce(
556
+ jsonResponse(200, {
557
+ memberships: [
558
+ { membershipKey: "k1", companyUid: "cmp_acme", role: "member", status: "active" },
559
+ ],
560
+ }),
561
+ );
562
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { acl: { path: "*" } }));
563
+
564
+ const program = buildProgram();
565
+ await program.parseAsync(
566
+ ["files", "share", "--full", "--with", "user@example.com", "--permission", "read"],
567
+ { from: "user" },
568
+ );
569
+
570
+ const grantCall = fetchSpy.mock.calls.find((c) =>
571
+ String(c[0]).includes("/acl/grant"),
572
+ );
573
+ const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
574
+ expect(body).toEqual({
575
+ prefix: "*",
576
+ granteeType: "email",
577
+ granteeId: "user@example.com",
578
+ permission: "read",
579
+ });
580
+ });
581
+
582
+ it("--full composes with @all (whole vault for the whole company → company-wide '*')", async () => {
583
+ fetchSpy.mockResolvedValueOnce(
584
+ jsonResponse(200, {
585
+ memberships: [
586
+ { membershipKey: "k1", companyUid: "cmp_acme", role: "member", status: "active" },
587
+ ],
588
+ }),
589
+ );
590
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { acl: { path: "*" } }));
591
+
592
+ const program = buildProgram();
593
+ await program.parseAsync(
594
+ ["files", "share", "--full", "--with", "@all"],
595
+ { from: "user" },
596
+ );
597
+
598
+ const grantCall = fetchSpy.mock.calls.find((c) =>
599
+ String(c[0]).includes("/acl/grant"),
600
+ );
601
+ const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
602
+ expect(body).toEqual({
603
+ prefix: "*",
604
+ granteeType: "company-wide",
605
+ granteeId: "",
606
+ permission: "write",
607
+ });
608
+ });
609
+
610
+ it("rejects --full without --with (and never calls the network)", async () => {
611
+ const program = buildProgram();
612
+ await expect(
613
+ program.parseAsync(["files", "share", "--full"], { from: "user" }),
614
+ ).rejects.toThrow(/__EXIT__:1/);
615
+
616
+ const errs = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
617
+ expect(errs).toMatch(/--full.*requires --with/);
618
+ expect(fetchSpy).not.toHaveBeenCalled();
619
+ });
620
+
621
+ it("rejects --full when file paths are also passed", async () => {
622
+ const program = buildProgram();
623
+ await expect(
624
+ program.parseAsync(
625
+ ["files", "share", "somePath", "--full", "--with", "user@example.com"],
626
+ { from: "user" },
627
+ ),
628
+ ).rejects.toThrow(/__EXIT__:1/);
629
+
630
+ const errs = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
631
+ expect(errs).toMatch(/entire vault; do not also pass file paths/);
632
+ expect(fetchSpy).not.toHaveBeenCalled();
633
+ });
504
634
  });
505
635
 
506
636
  // ---------------------------------------------------------------------------
@@ -135,6 +135,10 @@ export function registerFilesCommand(program: Command): Command {
135
135
  "Email address, group id, or '@all' to share with every active company member",
136
136
  )
137
137
  .option("--permission <level>", "Permission level (only with --with): read | write")
138
+ .option(
139
+ "--full",
140
+ "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.",
141
+ )
138
142
  .option(
139
143
  "--expires <duration>",
140
144
  "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.",
@@ -146,11 +150,45 @@ export function registerFilesCommand(program: Command): Command {
146
150
  opts: {
147
151
  with?: string;
148
152
  permission?: string;
153
+ full?: boolean;
149
154
  expires?: string;
150
155
  open: boolean;
151
156
  },
152
157
  ) => {
153
158
  try {
159
+ // Full-vault grant: a glob-safe affordance for "give this person the
160
+ // whole vault" so admins never have to quote a `*` (an unquoted glob
161
+ // expands to local filenames and instantly fails the one-prefix
162
+ // check). Maps to the single `*` wildcard grant, which the server
163
+ // coalesces to one policy entry — sidestepping the per-prefix STS
164
+ // session-policy budget. Defaults to write permission.
165
+ if (opts.full) {
166
+ if (opts.with === undefined) {
167
+ console.error(
168
+ chalk.red(
169
+ "--full grants whole-vault access to a principal and requires --with <principal>.",
170
+ ),
171
+ );
172
+ process.exit(1);
173
+ }
174
+ if (paths && paths.length > 0) {
175
+ console.error(
176
+ chalk.red(
177
+ "--full grants the entire vault; do not also pass file paths.",
178
+ ),
179
+ );
180
+ process.exit(1);
181
+ }
182
+ await runDirectGrant({
183
+ prefix: "*",
184
+ principal: opts.with,
185
+ permission: opts.permission ?? "write",
186
+ companySlug: files.opts().company as string | undefined,
187
+ fullVault: true,
188
+ });
189
+ return;
190
+ }
191
+
154
192
  if (!paths || paths.length === 0) {
155
193
  console.error(
156
194
  chalk.red("usage: hq files share <paths...> [--with <principal>]"),
@@ -430,21 +468,39 @@ export function registerFilesCommand(program: Command): Command {
430
468
  files
431
469
  .command("delete <prefix>")
432
470
  .description(
433
- "Delete vault objects under a prefix (bounded + scoped). Always previews the exact count first; prompts for confirmation unless --yes.",
471
+ "Delete vault objects under a prefix (bounded + scoped). Always previews the exact count first; prompts for confirmation unless --yes. Use --personal to target your own personal vault instead of a company.",
434
472
  )
435
473
  .option(
436
474
  "--dry-run",
437
475
  "List what WOULD be deleted without deleting anything",
438
476
  )
477
+ .option(
478
+ "--personal",
479
+ "Target your own personal vault instead of a company vault (mutually exclusive with --company)",
480
+ )
439
481
  .option("-y, --yes", "Skip the confirmation prompt (for scripts)")
440
482
  .action(
441
- async (prefix: string, opts: { dryRun?: boolean; yes?: boolean }) => {
483
+ async (
484
+ prefix: string,
485
+ opts: { dryRun?: boolean; yes?: boolean; personal?: boolean },
486
+ ) => {
442
487
  try {
488
+ const companySlug = files.opts().company as string | undefined;
489
+ const personal = opts.personal === true;
490
+ if (personal && companySlug) {
491
+ console.error(
492
+ chalk.red(
493
+ "Pass either --personal or --company, not both.",
494
+ ),
495
+ );
496
+ process.exit(1);
497
+ }
443
498
  await runFilesDelete({
444
499
  prefix,
445
500
  dryRun: opts.dryRun === true,
446
501
  yes: opts.yes === true,
447
- companySlug: files.opts().company as string | undefined,
502
+ personal,
503
+ companySlug,
448
504
  });
449
505
  } catch (err) {
450
506
  console.error(
@@ -472,6 +528,12 @@ interface DirectGrantParams {
472
528
  principal: string;
473
529
  permission: string | undefined;
474
530
  companySlug: string | undefined;
531
+ /**
532
+ * Set by the `--full` affordance: the grant targets the whole vault (the
533
+ * `*` wildcard prefix). Only affects the success message wording — the
534
+ * request shape is identical to any other prefix grant.
535
+ */
536
+ fullVault?: boolean;
475
537
  }
476
538
 
477
539
  async function runDirectGrant(params: DirectGrantParams): Promise<void> {
@@ -572,9 +634,17 @@ async function runDirectGrant(params: DirectGrantParams): Promise<void> {
572
634
  };
573
635
  const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
574
636
  const verb = autoCreated ? "Created ACL and granted" : "Granted";
575
- console.log(
576
- chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`),
577
- );
637
+ if (params.fullVault) {
638
+ console.log(
639
+ chalk.green(
640
+ `${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`,
641
+ ),
642
+ );
643
+ } else {
644
+ console.log(
645
+ chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`),
646
+ );
647
+ }
578
648
  }
579
649
 
580
650
  // ---------------------------------------------------------------------------
@@ -731,10 +801,16 @@ function realConfirm(message: string): Promise<boolean> {
731
801
  /**
732
802
  * POST /v1/files/delete. Throws FilesDeleteHttpError on any non-2xx so the one
733
803
  * caller renders a single consistent error path.
804
+ *
805
+ * Scope is EITHER a company vault (`companyUid` set) OR the caller's personal
806
+ * vault (`personal: true`). For the personal case the server resolves the target
807
+ * person + bucket from the authenticated caller — we send NO uid, just the
808
+ * `personal` flag — so there is nothing for the client to get wrong or spoof.
734
809
  */
735
810
  async function callDeleteEndpoint(params: {
736
811
  token: string;
737
- companyUid: string;
812
+ companyUid?: string;
813
+ personal?: boolean;
738
814
  prefix: string;
739
815
  dryRun: boolean;
740
816
  }): Promise<FilesDeleteResponse> {
@@ -742,11 +818,17 @@ async function callDeleteEndpoint(params: {
742
818
  token: params.token,
743
819
  path: "/v1/files/delete",
744
820
  method: "POST",
745
- body: {
746
- company: params.companyUid,
747
- prefix: params.prefix,
748
- dryRun: params.dryRun,
749
- },
821
+ body: params.personal
822
+ ? {
823
+ personal: true,
824
+ prefix: params.prefix,
825
+ dryRun: params.dryRun,
826
+ }
827
+ : {
828
+ company: params.companyUid,
829
+ prefix: params.prefix,
830
+ dryRun: params.dryRun,
831
+ },
750
832
  });
751
833
  if (!res.ok) {
752
834
  const body = (await res.json().catch(() => ({}))) as Record<string, string>;
@@ -763,6 +845,8 @@ interface RunFilesDeleteParams {
763
845
  prefix: string;
764
846
  dryRun: boolean;
765
847
  yes: boolean;
848
+ /** Target the caller's personal vault instead of a company vault. */
849
+ personal?: boolean;
766
850
  companySlug: string | undefined;
767
851
  }
768
852
 
@@ -844,7 +928,14 @@ export async function runFilesDelete(
844
928
  }
845
929
 
846
930
  const token = await ensureCognitoToken();
847
- const companyUid = await getCompanyUid(token, params.companySlug);
931
+ // Personal scope resolves the vault server-side from the caller's identity —
932
+ // no company to look up. Company scope resolves the companyUid as before.
933
+ const companyUid = params.personal
934
+ ? undefined
935
+ : await getCompanyUid(token, params.companySlug);
936
+ const scopeArgs = params.personal
937
+ ? { personal: true as const }
938
+ : { companyUid };
848
939
 
849
940
  // 1. Always preview first — this is how we print the EXACT key count before
850
941
  // deleting anything (and the whole behavior of --dry-run).
@@ -852,7 +943,7 @@ export async function runFilesDelete(
852
943
  try {
853
944
  preview = await callDeleteEndpoint({
854
945
  token,
855
- companyUid,
946
+ ...scopeArgs,
856
947
  prefix: normalized,
857
948
  dryRun: true,
858
949
  });
@@ -904,7 +995,9 @@ export async function runFilesDelete(
904
995
 
905
996
  if (!params.yes) {
906
997
  const ok = await confirm(
907
- `Delete ${preview.matched} ${noun}? This removes them from the shared vault for everyone.`,
998
+ params.personal
999
+ ? `Delete ${preview.matched} ${noun}? This removes them from your personal vault.`
1000
+ : `Delete ${preview.matched} ${noun}? This removes them from the shared vault for everyone.`,
908
1001
  );
909
1002
  if (!ok) {
910
1003
  console.log(chalk.dim("Aborted — nothing was deleted."));
@@ -917,7 +1010,7 @@ export async function runFilesDelete(
917
1010
  try {
918
1011
  result = await callDeleteEndpoint({
919
1012
  token,
920
- companyUid,
1013
+ ...scopeArgs,
921
1014
  prefix: normalized,
922
1015
  dryRun: false,
923
1016
  });