@indigoai-us/hq-cli 5.61.0 → 5.62.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.
Files changed (80) hide show
  1. package/dist/commands/agents.d.ts +109 -0
  2. package/dist/commands/agents.js +385 -0
  3. package/dist/commands/db-migrate.d.ts +6 -0
  4. package/dist/commands/db-migrate.js +42 -0
  5. package/dist/commands/db-provision.d.ts +15 -0
  6. package/dist/commands/db-provision.js +78 -0
  7. package/dist/commands/db-sql.d.ts +9 -0
  8. package/dist/commands/db-sql.js +81 -0
  9. package/dist/commands/db-status.d.ts +7 -0
  10. package/dist/commands/db-status.js +70 -0
  11. package/dist/commands/db.d.ts +9 -0
  12. package/dist/commands/db.js +23 -0
  13. package/dist/commands/integrations.d.ts +78 -0
  14. package/dist/commands/integrations.js +309 -0
  15. package/dist/commands/members.js +4 -4
  16. package/dist/commands/outposts.d.ts +60 -0
  17. package/dist/commands/outposts.js +255 -0
  18. package/dist/commands/secrets.d.ts +8 -0
  19. package/dist/commands/secrets.js +23 -8
  20. package/dist/commands/skill.d.ts +153 -0
  21. package/dist/commands/skill.js +593 -0
  22. package/dist/commands/workers.d.ts +48 -0
  23. package/dist/commands/workers.js +229 -0
  24. package/dist/lib/db/control-plane.d.ts +45 -0
  25. package/dist/lib/db/control-plane.js +81 -0
  26. package/dist/lib/db/local.d.ts +49 -0
  27. package/dist/lib/db/local.js +106 -0
  28. package/dist/lib/db/migrate.d.ts +41 -0
  29. package/dist/lib/db/migrate.js +104 -0
  30. package/dist/lib/db/paths.d.ts +56 -0
  31. package/dist/lib/db/paths.js +103 -0
  32. package/dist/lib/db/remote-engine.d.ts +58 -0
  33. package/dist/lib/db/remote-engine.js +90 -0
  34. package/dist/lib/db/remote-sql.d.ts +22 -0
  35. package/dist/lib/db/remote-sql.js +39 -0
  36. package/dist/lib/db/sql.d.ts +49 -0
  37. package/dist/lib/db/sql.js +132 -0
  38. package/dist/main.js +27 -2
  39. package/dist/utils/cognito-session.js +3 -3
  40. package/dist/utils/sandbox-runner-client.js +3 -3
  41. package/package.json +9 -1
  42. package/pnpm-workspace.yaml +2 -0
  43. package/src/commands/agents.test.ts +297 -0
  44. package/src/commands/agents.ts +561 -0
  45. package/src/commands/db-migrate.ts +55 -0
  46. package/src/commands/db-provision.ts +102 -0
  47. package/src/commands/db-sql.ts +124 -0
  48. package/src/commands/db-status.ts +100 -0
  49. package/src/commands/db.ts +26 -0
  50. package/src/commands/integrations.test.ts +284 -0
  51. package/src/commands/integrations.ts +438 -0
  52. package/src/commands/members.ts +2 -2
  53. package/src/commands/outposts.test.ts +177 -0
  54. package/src/commands/outposts.ts +338 -0
  55. package/src/commands/secrets.parse-destination.test.ts +38 -0
  56. package/src/commands/secrets.test.ts +24 -0
  57. package/src/commands/secrets.ts +30 -10
  58. package/src/commands/skill.test.ts +770 -0
  59. package/src/commands/skill.ts +796 -0
  60. package/src/commands/workers.test.ts +158 -0
  61. package/src/commands/workers.ts +298 -0
  62. package/src/lib/db/control-plane.test.ts +59 -0
  63. package/src/lib/db/control-plane.ts +113 -0
  64. package/src/lib/db/local.test.ts +81 -0
  65. package/src/lib/db/local.ts +148 -0
  66. package/src/lib/db/migrate.test.ts +133 -0
  67. package/src/lib/db/migrate.ts +137 -0
  68. package/src/lib/db/paths.test.ts +112 -0
  69. package/src/lib/db/paths.ts +128 -0
  70. package/src/lib/db/remote-engine.test.ts +44 -0
  71. package/src/lib/db/remote-engine.ts +148 -0
  72. package/src/lib/db/remote-sql.test.ts +32 -0
  73. package/src/lib/db/remote-sql.ts +62 -0
  74. package/src/lib/db/sql.test.ts +106 -0
  75. package/src/lib/db/sql.ts +192 -0
  76. package/src/main.ts +31 -0
  77. package/src/utils/cognito-session.ts +1 -1
  78. package/src/utils/sandbox-runner-client.ts +1 -1
  79. package/test/commands/db-tenant-isolation.test.ts +94 -0
  80. package/test/commands/db.test.ts +85 -0
@@ -0,0 +1,338 @@
1
+ /**
2
+ * `hq outposts` — manage your personal HQ Outposts (EC2 boxes) from the
3
+ * terminal instead of the web console. Targets the hq-pro `/outpost/*`
4
+ * control plane on `DEFAULT_VAULT_API_URL` via the shared `vaultApiFetch`
5
+ * helper — the same routes the console's outpost panel calls.
6
+ *
7
+ * Outposts are PERSONAL / caller-scoped: hq-pro keys every `/outpost/*` route
8
+ * on the caller's Cognito sub, so there is no `--company`. `--id <outpostId>`
9
+ * selects a specific box (passed as the `outpostId` query param); when omitted
10
+ * hq-pro targets the caller's primary slot.
11
+ *
12
+ * Subcommands:
13
+ * hq outposts list — every Outpost you own (row summaries)
14
+ * hq outposts status [--id] — live detail for one box
15
+ * hq outposts codex-enable [--id] — enable / retry Codex on the box
16
+ * hq outposts login [--id] — request a fresh login URL
17
+ * hq outposts destroy [--id] --yes — tear the box down (destructive; flag-guarded)
18
+ *
19
+ * NOTE: hq-pro exposes no rename or settings-mutation route for Outposts (the
20
+ * web console can't rename them either), so this CLI wraps only the lifecycle
21
+ * and status routes that exist. Renaming an Outpost is not a backend capability.
22
+ */
23
+
24
+ import { Command } from "commander";
25
+ import chalk from "chalk";
26
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
27
+ import { vaultApiFetch } from "../utils/vault-api.js";
28
+
29
+ /** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
30
+ export class OutpostHttpError extends Error {
31
+ status: number;
32
+ step?: string;
33
+ constructor(status: number, message: string, step?: string) {
34
+ super(message);
35
+ this.name = "OutpostHttpError";
36
+ this.status = status;
37
+ this.step = step;
38
+ }
39
+ }
40
+
41
+ /** Row summary from `GET /outpost/list`. */
42
+ export interface OutpostSummary {
43
+ outpostId: string;
44
+ state: string;
45
+ instanceName: string;
46
+ region: string;
47
+ agentRuntime: string;
48
+ platform: string;
49
+ createdAt: string;
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ /**
54
+ * Authenticated JSON round-trip against the outpost control plane. Throws
55
+ * `OutpostHttpError` on any non-2xx (never swallows — hq-never-swallow-errors),
56
+ * decoding hq-pro's `{ error | message, step }` envelope for the reason. The
57
+ * `step` is preserved so callers can recognise the `destroy` route's
58
+ * `teardown-incomplete` 409 (which means "retry", not "failed").
59
+ */
60
+ export async function outpostRequest<T>(opts: {
61
+ token: string;
62
+ path: string;
63
+ method?: string;
64
+ query?: Record<string, string>;
65
+ }): Promise<T> {
66
+ const res = await vaultApiFetch(opts);
67
+ if (!res.ok) {
68
+ const body = (await res.json().catch(() => ({}))) as {
69
+ error?: unknown;
70
+ message?: string;
71
+ step?: string;
72
+ };
73
+ const message =
74
+ typeof body.message === "string"
75
+ ? body.message
76
+ : typeof body.error === "string"
77
+ ? body.error
78
+ : res.statusText;
79
+ throw new OutpostHttpError(res.status, message, body.step);
80
+ }
81
+ return (await res.json()) as T;
82
+ }
83
+
84
+ export async function listOutposts(token: string): Promise<OutpostSummary[]> {
85
+ const data = await outpostRequest<{ outposts: OutpostSummary[] }>({
86
+ token,
87
+ path: "/outpost/list",
88
+ });
89
+ return data.outposts ?? [];
90
+ }
91
+
92
+ export async function getOutpostStatus(
93
+ token: string,
94
+ outpostId?: string,
95
+ ): Promise<Record<string, unknown>> {
96
+ return outpostRequest({
97
+ token,
98
+ path: "/outpost/status",
99
+ query: outpostId ? { outpostId } : undefined,
100
+ });
101
+ }
102
+
103
+ export async function enableCodex(
104
+ token: string,
105
+ outpostId?: string,
106
+ ): Promise<Record<string, unknown>> {
107
+ return outpostRequest({
108
+ token,
109
+ path: "/outpost/codex/enable",
110
+ method: "POST",
111
+ query: outpostId ? { outpostId } : undefined,
112
+ });
113
+ }
114
+
115
+ export async function regenerateLoginUrl(
116
+ token: string,
117
+ outpostId?: string,
118
+ ): Promise<Record<string, unknown>> {
119
+ return outpostRequest({
120
+ token,
121
+ path: "/outpost/regenerate-login-url",
122
+ method: "POST",
123
+ query: outpostId ? { outpostId } : undefined,
124
+ });
125
+ }
126
+
127
+ export async function destroyOutpost(
128
+ token: string,
129
+ outpostId?: string,
130
+ ): Promise<Record<string, unknown>> {
131
+ return outpostRequest({
132
+ token,
133
+ path: "/outpost/destroy",
134
+ method: "POST",
135
+ query: outpostId ? { outpostId } : undefined,
136
+ });
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Command registration
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function fail(err: unknown): never {
144
+ if (err instanceof OutpostHttpError) {
145
+ console.error(chalk.red(err.message));
146
+ } else {
147
+ console.error(
148
+ chalk.red("Error:"),
149
+ err instanceof Error ? err.message : String(err),
150
+ );
151
+ }
152
+ process.exit(1);
153
+ }
154
+
155
+ /** Print a top-level object as `key: value`, JSON-ifying nested values. */
156
+ function printKeyValues(obj: Record<string, unknown>): void {
157
+ for (const [k, v] of Object.entries(obj)) {
158
+ const rendered = v && typeof v === "object" ? JSON.stringify(v) : String(v);
159
+ console.log(`${chalk.bold(k)}: ${rendered}`);
160
+ }
161
+ }
162
+
163
+ export function registerOutpostsCommand(program: Command): void {
164
+ const outposts = program
165
+ .command("outposts")
166
+ .description("Manage your personal HQ Outposts (EC2 boxes)");
167
+
168
+ outposts
169
+ .command("list")
170
+ .description("List every Outpost you own")
171
+ .option("--json", "Emit raw JSON")
172
+ .action(async function (this: Command, opts: { json?: boolean }) {
173
+ try {
174
+ const token = await ensureCognitoToken();
175
+ const rows = await listOutposts(token);
176
+ if (opts.json) {
177
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
178
+ return;
179
+ }
180
+ if (rows.length === 0) {
181
+ console.log(chalk.gray("You don't own any Outposts yet."));
182
+ return;
183
+ }
184
+ const idW = Math.max(2, ...rows.map((r) => (r.outpostId ?? "").length));
185
+ const stateW = Math.max(5, ...rows.map((r) => (r.state ?? "").length));
186
+ const nameW = Math.max(
187
+ 4,
188
+ ...rows.map((r) => (r.instanceName ?? "").length),
189
+ );
190
+ const regionW = Math.max(6, ...rows.map((r) => (r.region ?? "").length));
191
+ const rtW = Math.max(
192
+ 7,
193
+ ...rows.map((r) => (r.agentRuntime ?? "").length),
194
+ );
195
+ console.log(
196
+ chalk.bold(
197
+ [
198
+ "ID".padEnd(idW),
199
+ "STATE".padEnd(stateW),
200
+ "INSTANCE".padEnd(nameW),
201
+ "REGION".padEnd(regionW),
202
+ "RUNTIME".padEnd(rtW),
203
+ "PLATFORM",
204
+ ].join(" "),
205
+ ),
206
+ );
207
+ for (const r of rows) {
208
+ console.log(
209
+ [
210
+ (r.outpostId ?? "").padEnd(idW),
211
+ (r.state ?? "").padEnd(stateW),
212
+ (r.instanceName ?? "").padEnd(nameW),
213
+ (r.region ?? "").padEnd(regionW),
214
+ (r.agentRuntime ?? "").padEnd(rtW),
215
+ r.platform ?? "",
216
+ ].join(" "),
217
+ );
218
+ }
219
+ } catch (err) {
220
+ fail(err);
221
+ }
222
+ });
223
+
224
+ outposts
225
+ .command("status")
226
+ .description("Show live detail for one Outpost")
227
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
228
+ .option("--json", "Emit raw JSON")
229
+ .action(async function (
230
+ this: Command,
231
+ opts: { id?: string; json?: boolean },
232
+ ) {
233
+ try {
234
+ const token = await ensureCognitoToken();
235
+ const status = await getOutpostStatus(token, opts.id);
236
+ if (opts.json) {
237
+ process.stdout.write(JSON.stringify(status, null, 2) + "\n");
238
+ return;
239
+ }
240
+ printKeyValues(status);
241
+ } catch (err) {
242
+ fail(err);
243
+ }
244
+ });
245
+
246
+ outposts
247
+ .command("codex-enable")
248
+ .description("Enable (or retry) Codex on an Outpost")
249
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
250
+ .option("--json", "Emit raw JSON")
251
+ .action(async function (
252
+ this: Command,
253
+ opts: { id?: string; json?: boolean },
254
+ ) {
255
+ try {
256
+ const token = await ensureCognitoToken();
257
+ const result = await enableCodex(token, opts.id);
258
+ if (opts.json) {
259
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
260
+ return;
261
+ }
262
+ console.log(chalk.green("Codex enablement requested for the Outpost."));
263
+ } catch (err) {
264
+ fail(err);
265
+ }
266
+ });
267
+
268
+ outposts
269
+ .command("login")
270
+ .description("Request a fresh login URL for an Outpost")
271
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
272
+ .option("--json", "Emit raw JSON")
273
+ .action(async function (
274
+ this: Command,
275
+ opts: { id?: string; json?: boolean },
276
+ ) {
277
+ try {
278
+ const token = await ensureCognitoToken();
279
+ const result = await regenerateLoginUrl(token, opts.id);
280
+ if (opts.json) {
281
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
282
+ return;
283
+ }
284
+ console.log(
285
+ chalk.green(
286
+ "Login-URL regeneration requested. The box mints a fresh URL shortly — " +
287
+ "check `hq outposts status` to pick it up.",
288
+ ),
289
+ );
290
+ } catch (err) {
291
+ fail(err);
292
+ }
293
+ });
294
+
295
+ outposts
296
+ .command("destroy")
297
+ .description("Tear down (permanently destroy) an Outpost")
298
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
299
+ .option("--yes", "Confirm the irreversible teardown (required)")
300
+ .action(async function (
301
+ this: Command,
302
+ opts: { id?: string; yes?: boolean },
303
+ ) {
304
+ const target = opts.id ?? "your primary Outpost";
305
+ if (!opts.yes) {
306
+ console.error(
307
+ chalk.yellow(
308
+ `This will permanently destroy ${target} and delete its cloud resources. ` +
309
+ `This cannot be undone.\n` +
310
+ `Re-run with --yes to confirm: hq outposts destroy${opts.id ? ` --id ${opts.id}` : ""} --yes`,
311
+ ),
312
+ );
313
+ process.exit(1);
314
+ }
315
+ try {
316
+ const token = await ensureCognitoToken();
317
+ await destroyOutpost(token, opts.id);
318
+ console.log(chalk.green(`Destroyed ${target}.`));
319
+ } catch (err) {
320
+ // A 409 teardown-incomplete is not a failure — the gateway's 30s cap
321
+ // fired while the Lambda keeps working. The row is preserved and the
322
+ // operation is idempotent, so tell the caller to retry.
323
+ if (
324
+ err instanceof OutpostHttpError &&
325
+ (err.status === 409 || err.step === "teardown-incomplete")
326
+ ) {
327
+ console.log(
328
+ chalk.yellow(
329
+ `Teardown still in progress for ${target} — this is expected for large boxes. ` +
330
+ `Re-run the same destroy command to finish (it's idempotent).`,
331
+ ),
332
+ );
333
+ return;
334
+ }
335
+ fail(err);
336
+ }
337
+ });
338
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pre-landing hardening (secrets-proxy-per-secret-destination): `--destination`
3
+ * must reject an embedded `user:pass@host` userinfo segment EXPLICITLY rather
4
+ * than letting `new URL()` silently drop it and normalize to the bare host.
5
+ * Mirrors hq-pro's authoritative `validateDestinations` server-side check — the
6
+ * server re-validates and remains authoritative, but the CLI must not present a
7
+ * userinfo URL as an accepted destination.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { parseDestinationUrl } from "./secrets.js";
11
+
12
+ describe("parseDestinationUrl — userinfo hardening", () => {
13
+ beforeEach(() => {
14
+ // parseDestinationUrl logs the rejection reason to stderr; silence it so the
15
+ // test output stays clean while still exercising the real code path.
16
+ vi.spyOn(console, "error").mockImplementation(() => {});
17
+ });
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+
22
+ it.each([
23
+ "https://user:pass@api.openai.com",
24
+ "https://user@api.openai.com",
25
+ "https://:pass@api.openai.com",
26
+ ])("rejects a destination with embedded userinfo (%s)", (raw) => {
27
+ expect(parseDestinationUrl(raw).ok).toBe(false);
28
+ });
29
+
30
+ it("still accepts a bare scheme+host HTTPS destination (guards against over-rejection)", () => {
31
+ const result = parseDestinationUrl("https://api.openai.com");
32
+ expect(result.ok).toBe(true);
33
+ if (result.ok) {
34
+ expect(result.url).toBe("https://api.openai.com");
35
+ expect(result.hostname).toBe("api.openai.com");
36
+ }
37
+ });
38
+ });
@@ -162,6 +162,30 @@ describe("secrets sandbox", () => {
162
162
  expect(stdoutSpy).toHaveBeenCalledWith("done\n");
163
163
  });
164
164
 
165
+ it("accumulates repeated --only flags instead of silently keeping only the last", async () => {
166
+ const program = buildProgram();
167
+ await program.parseAsync([
168
+ "node",
169
+ "hq",
170
+ "secrets",
171
+ "sandbox",
172
+ "--company",
173
+ "acme",
174
+ "--only",
175
+ "API_KEY,OTHER_KEY",
176
+ "--only",
177
+ "THIRD_KEY",
178
+ "--",
179
+ "env",
180
+ ]);
181
+
182
+ expect(startJobSpy).toHaveBeenCalledWith("test-token", {
183
+ companyUid: "prs_alice",
184
+ secretNames: ["API_KEY", "OTHER_KEY", "THIRD_KEY"],
185
+ command: "env",
186
+ });
187
+ });
188
+
165
189
  it("requires --only", async () => {
166
190
  const program = buildProgram();
167
191
 
@@ -338,7 +338,7 @@ function parseAuthStyle(authStyle: string): SecretInjectionRecipe | null {
338
338
  // (src/vault-service/handlers/secrets.ts) so a malformed URL is caught
339
339
  // locally with an actionable message rather than a round trip — the server
340
340
  // re-validates and remains authoritative.
341
- function parseDestinationUrl(
341
+ export function parseDestinationUrl(
342
342
  raw: string,
343
343
  ): { ok: true; url: string; hostname: string } | { ok: false } {
344
344
  let parsed: URL;
@@ -362,6 +362,17 @@ function parseDestinationUrl(
362
362
  );
363
363
  return { ok: false };
364
364
  }
365
+ // Reject an embedded `user:pass@host` credential segment explicitly rather
366
+ // than letting `new URL()` silently drop it (mirrors hq-pro's authoritative
367
+ // `validateDestinations`; the server re-validates and remains authoritative).
368
+ if (parsed.username !== "" || parsed.password !== "") {
369
+ console.error(
370
+ chalk.red(
371
+ `Invalid --destination '${raw}': must not contain embedded userinfo (user:pass@) credentials`,
372
+ ),
373
+ );
374
+ return { ok: false };
375
+ }
365
376
  if (
366
377
  (parsed.pathname !== "" && parsed.pathname !== "/") ||
367
378
  parsed.search !== "" ||
@@ -427,8 +438,17 @@ async function buildSecretUsage(
427
438
  };
428
439
  }
429
440
 
430
- function parseSecretNameList(input: string): string[] {
431
- const keys = input.split(",").map((k) => k.trim()).filter(Boolean);
441
+ // Commander collector for `--only`: accumulates across REPEATED flags instead
442
+ // of the last one silently winning, and each value may still be
443
+ // comma-separated. So `--only A,B`, `--only A --only B`, and
444
+ // `--only A,B --only C` all resolve to the full list.
445
+ export function collectSecretNames(value: string, previous?: string[]): string[] {
446
+ const parsed = value.split(",").map((k) => k.trim()).filter(Boolean);
447
+ return (previous ?? []).concat(parsed);
448
+ }
449
+
450
+ function parseSecretNameList(input: string[] | undefined): string[] {
451
+ const keys = input ?? [];
432
452
  if (keys.length === 0) {
433
453
  console.error(chalk.red("Error: --only requires at least one secret name."));
434
454
  process.exit(1);
@@ -1447,11 +1467,11 @@ export function registerSecretsCommand(program: Command): void {
1447
1467
  "--personal",
1448
1468
  "Operate on the caller's personal vault (no sharing)",
1449
1469
  )
1450
- .option("--only <keys>", "Comma-separated list of secret names to inject (required)")
1470
+ .option("--only <keys>", "Secret names to inject (comma-separated; may be repeated) (required)", collectSecretNames)
1451
1471
  .allowUnknownOption(true)
1452
- .action(async (opts: { company?: string; personal?: boolean; only?: string }, cmd: Command) => {
1472
+ .action(async (opts: { company?: string; personal?: boolean; only?: string[] }, cmd: Command) => {
1453
1473
  try {
1454
- if (!opts.only || opts.only.trim().length === 0) {
1474
+ if (!opts.only || opts.only.length === 0) {
1455
1475
  console.error(chalk.red("Error: --only is required and must name at least one secret."));
1456
1476
  process.exit(1);
1457
1477
  }
@@ -1501,10 +1521,10 @@ export function registerSecretsCommand(program: Command): void {
1501
1521
  secrets
1502
1522
  .command("exec")
1503
1523
  .description("Run a command with secrets injected as env vars")
1504
- .requiredOption("--only <keys>", "Comma-separated list of secret names to inject (required)")
1524
+ .requiredOption("--only <keys>", "Secret names to inject (comma-separated; may be repeated) (required)", collectSecretNames)
1505
1525
  .option("--script <path>", "Attach local script identity for script-locked secrets")
1506
1526
  .allowUnknownOption(true)
1507
- .action(async (_opts: { only: string; script?: string }, cmd: Command) => {
1527
+ .action(async (_opts: { only: string[]; script?: string }, cmd: Command) => {
1508
1528
  try {
1509
1529
  const rawArgs = cmd.args;
1510
1530
  const dashIndex = process.argv.indexOf("--");
@@ -1575,9 +1595,9 @@ export function registerSecretsCommand(program: Command): void {
1575
1595
  secrets
1576
1596
  .command("env")
1577
1597
  .description("Print 'export KEY=VALUE' lines suitable for: source <(hq secrets env --only K1,K2)")
1578
- .requiredOption("--only <keys>", "Comma-separated list of secret names to print (required)")
1598
+ .requiredOption("--only <keys>", "Secret names to print (comma-separated; may be repeated) (required)", collectSecretNames)
1579
1599
  .option("--script <path>", "Attach local script identity for script-locked secrets")
1580
- .action(async (opts: { only: string; script?: string }) => {
1600
+ .action(async (opts: { only: string[]; script?: string }) => {
1581
1601
  try {
1582
1602
  const redact = process.stdout.isTTY;
1583
1603
  if (redact) {