@indigoai-us/hq-cli 5.69.0 → 5.71.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.
@@ -21,7 +21,7 @@
21
21
  * and status routes that exist. Renaming an Outpost is not a backend capability.
22
22
  */
23
23
 
24
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="ff8d1d83-feab-5f6f-bf48-e4fde1a8d449")}catch(e){}}();
24
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="839f8f76-584d-5ea8-b86e-a92a55c32a16")}catch(e){}}();
25
25
  import chalk from "chalk";
26
26
  import { spawnSync } from "node:child_process";
27
27
  import * as fs from "node:fs";
@@ -139,6 +139,63 @@ export async function execOutpost(token, command, outpostId) {
139
139
  query: outpostId ? { outpostId } : undefined,
140
140
  });
141
141
  }
142
+ export async function stageExecInput(token, outpostId) {
143
+ return outpostRequest({
144
+ token,
145
+ path: "/outpost/exec",
146
+ method: "POST",
147
+ body: { mode: "stage" },
148
+ query: outpostId ? { outpostId } : undefined,
149
+ });
150
+ }
151
+ export async function submitExec(token, command, outpostId) {
152
+ return outpostRequest({
153
+ token,
154
+ path: "/outpost/exec",
155
+ method: "POST",
156
+ body: { mode: "submit", command },
157
+ query: outpostId ? { outpostId } : undefined,
158
+ });
159
+ }
160
+ export async function fetchExecResult(token, commandId, outpostId) {
161
+ return outpostRequest({
162
+ token,
163
+ path: "/outpost/exec",
164
+ method: "POST",
165
+ body: { mode: "result", commandId },
166
+ query: outpostId ? { outpostId } : undefined,
167
+ });
168
+ }
169
+ function shellQuote(part) {
170
+ return `'${part.replace(/'/g, `'\\''`)}'`;
171
+ }
172
+ /** Preserve a single command string; safely join argv when Commander split it. */
173
+ export function joinCommandParts(commandParts) {
174
+ if (commandParts.length === 1)
175
+ return commandParts[0];
176
+ return commandParts.map(shellQuote).join(" ");
177
+ }
178
+ const EXEC_RESULT_INITIAL_POLL_MS = 500;
179
+ const EXEC_RESULT_MAX_POLL_MS = 5_000;
180
+ function sleep(ms) {
181
+ return new Promise((resolve) => setTimeout(resolve, ms));
182
+ }
183
+ async function waitForExecResult(token, commandId, outpostId) {
184
+ let delayMs = EXEC_RESULT_INITIAL_POLL_MS;
185
+ while (true) {
186
+ try {
187
+ const result = await fetchExecResult(token, commandId, outpostId);
188
+ if (result.done)
189
+ return result;
190
+ }
191
+ catch (err) {
192
+ if (!(err instanceof OutpostHttpError) || err.status !== 429)
193
+ throw err;
194
+ }
195
+ await sleep(delayMs);
196
+ delayMs = Math.min(delayMs * 2, EXEC_RESULT_MAX_POLL_MS);
197
+ }
198
+ }
142
199
  /**
143
200
  * Prefix that best-effort `cd`s into the box's HQ checkout before running the
144
201
  * caller's command. `exec` runs over two transports with two different default
@@ -602,8 +659,8 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
602
659
  .option("--json", "Emit raw JSON")
603
660
  .action(async function (commandParts, opts) {
604
661
  try {
605
- const command = commandParts.join(" ").trim();
606
- if (!command) {
662
+ const command = joinCommandParts(commandParts);
663
+ if (!command.trim()) {
607
664
  console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
608
665
  process.exit(1);
609
666
  }
@@ -669,6 +726,94 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
669
726
  fail(err);
670
727
  }
671
728
  });
729
+ outposts
730
+ .command("exec-stage")
731
+ .description("Stage a file for an asynchronous Outpost command")
732
+ .requiredOption("--file <path>", "File to upload")
733
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
734
+ .option("--json", "Emit raw JSON")
735
+ .action(async function (opts) {
736
+ try {
737
+ const bytes = fs.readFileSync(opts.file);
738
+ const token = await ensureCognitoToken();
739
+ const staged = await stageExecInput(token, opts.id);
740
+ const upload = await fetch(staged.putUrl, {
741
+ method: "PUT",
742
+ headers: { "Content-Length": String(bytes.byteLength) },
743
+ body: bytes,
744
+ });
745
+ if (!upload.ok) {
746
+ throw new Error(`Could not upload exec input: HTTP ${upload.status} ${upload.statusText}`);
747
+ }
748
+ const output = { key: staged.key, getUrl: staged.getUrl };
749
+ if (opts.json) {
750
+ process.stdout.write(JSON.stringify(output) + "\n");
751
+ }
752
+ else {
753
+ printKeyValues(output);
754
+ }
755
+ }
756
+ catch (err) {
757
+ fail(err);
758
+ }
759
+ });
760
+ outposts
761
+ .command("exec-submit <command...>")
762
+ .description("Submit an asynchronous shell command to an Outpost")
763
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
764
+ .option("--json", "Emit raw JSON")
765
+ .action(async function (commandParts, opts) {
766
+ try {
767
+ const command = joinCommandParts(commandParts);
768
+ if (!command.trim()) {
769
+ console.error(chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"));
770
+ process.exit(1);
771
+ }
772
+ const token = await ensureCognitoToken();
773
+ const submitted = await submitExec(token, command, opts.id);
774
+ const output = { commandId: submitted.commandId };
775
+ if (opts.json) {
776
+ process.stdout.write(JSON.stringify(output) + "\n");
777
+ }
778
+ else {
779
+ printKeyValues(output);
780
+ }
781
+ }
782
+ catch (err) {
783
+ fail(err);
784
+ }
785
+ });
786
+ outposts
787
+ .command("exec-result")
788
+ .description("Fetch the result of an asynchronous Outpost command")
789
+ .requiredOption("--command-id <commandId>", "Command id returned by exec-submit")
790
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
791
+ .option("--wait", "Poll until the command reaches a terminal state")
792
+ .option("--json", "Emit raw JSON")
793
+ .action(async function (opts) {
794
+ try {
795
+ const token = await ensureCognitoToken();
796
+ const result = opts.wait
797
+ ? await waitForExecResult(token, opts.commandId, opts.id)
798
+ : await fetchExecResult(token, opts.commandId, opts.id);
799
+ const output = {
800
+ done: result.done,
801
+ status: result.status,
802
+ exitCode: result.exitCode ?? null,
803
+ stdout: result.stdout ?? "",
804
+ stderr: result.stderr ?? "",
805
+ };
806
+ if (opts.json) {
807
+ process.stdout.write(JSON.stringify(output) + "\n");
808
+ }
809
+ else {
810
+ printKeyValues(output);
811
+ }
812
+ }
813
+ catch (err) {
814
+ fail(err);
815
+ }
816
+ });
672
817
  outposts
673
818
  .command("codex-enable")
674
819
  .description("Enable (or retry) Codex on an Outpost")
@@ -741,4 +886,4 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
741
886
  });
742
887
  }
743
888
  //# sourceMappingURL=outposts.js.map
744
- //# debugId=ff8d1d83-feab-5f6f-bf48-e4fde1a8d449
889
+ //# debugId=839f8f76-584d-5ea8-b86e-a92a55c32a16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.69.0",
3
+ "version": "5.71.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -19,7 +19,10 @@ export function describeChannel(c: ChannelSummary): string {
19
19
  names.length > 0
20
20
  ? names.join(", ")
21
21
  : `${c.memberCount ?? "?"}-person group`;
22
- return `${who} ${chalk.dim("(group DM)")}`;
22
+ // Group DMs are unnamed, so their channel id is the only way to address them
23
+ // for reading (`hq dm channel <id>`). Surface it so it can be copied.
24
+ const readHint = chalk.dim(`— hq dm channel ${c.channelId}`);
25
+ return `${who} ${chalk.dim("(group DM)")} ${readHint}`;
23
26
  }
24
27
  const slug = c.slug ?? (c.name ? channelSlug(c.name) : "");
25
28
  const scopeTag = c.scope ? chalk.dim(`(${c.scope})`) : "";
@@ -18,6 +18,7 @@ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
18
18
  });
19
19
 
20
20
  import { Command } from "commander";
21
+ import chalk from "chalk";
21
22
  import {
22
23
  detectRecipient,
23
24
  parseDuration,
@@ -29,8 +30,18 @@ import {
29
30
  channelSlug,
30
31
  parseChannelName,
31
32
  matchChannelsByName,
33
+ senderLabel,
34
+ formatRelativeTime,
35
+ isUnread,
36
+ unreadEventIds,
37
+ filterUnread,
38
+ firstLine,
39
+ buildThreadQuery,
40
+ formatInboxEvent,
41
+ formatThreadMessage,
32
42
  type ConnectionRequest,
33
43
  type ChannelSummary,
44
+ type DmInboxEvent,
34
45
  } from "./dm.js";
35
46
 
36
47
  describe("parseGroupRecipients", () => {
@@ -262,6 +273,127 @@ describe("buildConnectionActionBody", () => {
262
273
  // Action handlers (HTTP/auth mocked)
263
274
  // ---------------------------------------------------------------------------
264
275
 
276
+ describe("senderLabel", () => {
277
+ it("prefers display name, then email, then uid", () => {
278
+ expect(
279
+ senderLabel({ fromDisplayName: "Jonathan", fromEmail: "j@x.com", fromPersonUid: "prs_1" }),
280
+ ).toBe("Jonathan");
281
+ expect(senderLabel({ fromEmail: "j@x.com", fromPersonUid: "prs_1" })).toBe("j@x.com");
282
+ expect(senderLabel({ fromPersonUid: "prs_1" })).toBe("prs_1");
283
+ expect(senderLabel({})).toBe("unknown");
284
+ });
285
+ });
286
+
287
+ describe("formatRelativeTime", () => {
288
+ const now = Date.parse("2026-07-16T12:00:00.000Z");
289
+ it("renders sub-minute as 'just now'", () => {
290
+ expect(formatRelativeTime("2026-07-16T11:59:30.000Z", now)).toBe("just now");
291
+ });
292
+ it("renders minutes, hours, and days", () => {
293
+ expect(formatRelativeTime("2026-07-16T11:57:00.000Z", now)).toBe("3m ago");
294
+ expect(formatRelativeTime("2026-07-16T10:00:00.000Z", now)).toBe("2h ago");
295
+ expect(formatRelativeTime("2026-07-11T12:00:00.000Z", now)).toBe("5d ago");
296
+ });
297
+ it("falls back to a date for anything older than a week", () => {
298
+ expect(formatRelativeTime("2026-06-01T09:00:00.000Z", now)).toBe("2026-06-01");
299
+ });
300
+ it("echoes an unparseable input", () => {
301
+ expect(formatRelativeTime("not-a-date", now)).toBe("not-a-date");
302
+ });
303
+ });
304
+
305
+ describe("isUnread / unreadEventIds / filterUnread", () => {
306
+ const events: DmInboxEvent[] = [
307
+ { eventId: "a", body: "x", createdAt: "2026-07-16T11:00:00.000Z" },
308
+ {
309
+ eventId: "b",
310
+ body: "y",
311
+ createdAt: "2026-07-16T11:30:00.000Z",
312
+ acknowledgedAt: "2026-07-16T11:31:00.000Z",
313
+ },
314
+ ];
315
+ it("treats a message with no acknowledgedAt as unread", () => {
316
+ expect(isUnread(events[0])).toBe(true);
317
+ expect(isUnread(events[1])).toBe(false);
318
+ });
319
+ it("collects unread event ids", () => {
320
+ expect(unreadEventIds(events)).toEqual(["a"]);
321
+ });
322
+ it("filters to unread only", () => {
323
+ expect(filterUnread(events).map((e) => e.eventId)).toEqual(["a"]);
324
+ });
325
+ });
326
+
327
+ describe("firstLine", () => {
328
+ it("collapses whitespace to a single trimmed line", () => {
329
+ expect(firstLine(" hello\n world \t there ")).toBe("hello world there");
330
+ });
331
+ it("truncates with an ellipsis past the cap", () => {
332
+ const out = firstLine("abcdefghij", 5);
333
+ expect(out).toBe("abcd…");
334
+ expect(out.length).toBe(5);
335
+ });
336
+ });
337
+
338
+ describe("buildThreadQuery", () => {
339
+ it("routes an email through withEmail (lowercased)", () => {
340
+ expect(buildThreadQuery("Jonathan@GetIndigo.ai")).toEqual({
341
+ withEmail: "jonathan@getindigo.ai",
342
+ });
343
+ });
344
+ it("routes a personUid / agentUid through withPersonUid", () => {
345
+ expect(buildThreadQuery("prs_123")).toEqual({ withPersonUid: "prs_123" });
346
+ expect(buildThreadQuery("agt_123")).toEqual({ withPersonUid: "agt_123" });
347
+ });
348
+ it("rejects a bare name", () => {
349
+ expect(() => buildThreadQuery("Jonathan")).toThrow(/Resolve a name first/);
350
+ });
351
+ });
352
+
353
+ describe("format helpers (rendering)", () => {
354
+ beforeEach(() => {
355
+ chalk.level = 0; // plain text so assertions read the rendered content
356
+ });
357
+ const now = Date.parse("2026-07-16T12:00:00.000Z");
358
+ it("marks an unread inbox row with a bullet and shows the sender + body", () => {
359
+ const row = formatInboxEvent(
360
+ {
361
+ eventId: "e1",
362
+ fromDisplayName: "Jonathan Bach",
363
+ fromEmail: "jonathan@getindigo.ai",
364
+ body: "grp_jb_agent is created",
365
+ createdAt: "2026-07-16T11:57:00.000Z",
366
+ },
367
+ now,
368
+ );
369
+ expect(row).toContain("●");
370
+ expect(row).toContain("Jonathan Bach");
371
+ expect(row).toContain("<jonathan@getindigo.ai>");
372
+ expect(row).toContain("3m ago");
373
+ expect(row).toContain("grp_jb_agent is created");
374
+ });
375
+ it("tags thread direction: outgoing as 'you', incoming by sender", () => {
376
+ const out = formatThreadMessage(
377
+ { eventId: "o", body: "hey", createdAt: "2026-07-16T11:00:00.000Z", direction: "out" },
378
+ now,
379
+ );
380
+ expect(out).toContain("→");
381
+ expect(out).toContain("you");
382
+ const inbound = formatThreadMessage(
383
+ {
384
+ eventId: "i",
385
+ fromDisplayName: "Jonathan",
386
+ body: "thanks",
387
+ createdAt: "2026-07-16T11:30:00.000Z",
388
+ direction: "in",
389
+ },
390
+ now,
391
+ );
392
+ expect(inbound).toContain("←");
393
+ expect(inbound).toContain("Jonathan");
394
+ });
395
+ });
396
+
265
397
  function jsonResponse(status: number, body: unknown): Response {
266
398
  return new Response(JSON.stringify(body), {
267
399
  status,
@@ -480,4 +612,137 @@ describe("dm command actions", () => {
480
612
  expect(body).toEqual({ withEmail: "spammer@example.com" });
481
613
  expect(logged()).toMatch(/Blocked — spammer@example\.com\./);
482
614
  });
615
+
616
+ // ---- receive side: inbox / thread / channel ----
617
+
618
+ it("inbox: lists incoming messages from GET /v1/notify/inbox", async () => {
619
+ fetchSpy.mockResolvedValueOnce(
620
+ jsonResponse(200, {
621
+ events: [
622
+ {
623
+ eventId: "e1",
624
+ fromDisplayName: "Jonathan Bach",
625
+ fromEmail: "jonathan@getindigo.ai",
626
+ body: "grp_jb_agent is created",
627
+ createdAt: "2026-07-16T11:57:00.000Z",
628
+ },
629
+ ],
630
+ }),
631
+ );
632
+ await program.parseAsync(["dm", "inbox"], { from: "user" });
633
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/inbox");
634
+ expect(logged()).toContain("Jonathan Bach");
635
+ expect(logged()).toContain("grp_jb_agent is created");
636
+ expect(errSpy).not.toHaveBeenCalled();
637
+ });
638
+
639
+ it("inbox --unread --mark-read: acks only the unread ids", async () => {
640
+ fetchSpy
641
+ .mockResolvedValueOnce(
642
+ jsonResponse(200, {
643
+ events: [
644
+ { eventId: "u1", body: "new", createdAt: "2026-07-16T11:00:00.000Z" },
645
+ {
646
+ eventId: "r1",
647
+ body: "old",
648
+ createdAt: "2026-07-16T10:00:00.000Z",
649
+ acknowledgedAt: "2026-07-16T10:01:00.000Z",
650
+ },
651
+ ],
652
+ }),
653
+ )
654
+ .mockResolvedValueOnce(jsonResponse(200, { acknowledged: 1 }));
655
+ await program.parseAsync(["dm", "inbox", "--unread", "--mark-read"], {
656
+ from: "user",
657
+ });
658
+ const ackCall = fetchSpy.mock.calls.find((c) =>
659
+ String(c[0]).includes("/v1/notify/inbox/ack"),
660
+ );
661
+ expect(ackCall).toBeTruthy();
662
+ expect(JSON.parse((ackCall![1]?.body as string) ?? "{}")).toEqual({
663
+ eventIds: ["u1"],
664
+ });
665
+ });
666
+
667
+ it("thread <email>: reads via withEmail and acks incoming messages", async () => {
668
+ fetchSpy
669
+ .mockResolvedValueOnce(
670
+ jsonResponse(200, {
671
+ messages: [
672
+ {
673
+ eventId: "in1",
674
+ fromDisplayName: "Jonathan",
675
+ body: "thanks!",
676
+ createdAt: "2026-07-16T11:30:00.000Z",
677
+ direction: "in",
678
+ },
679
+ {
680
+ eventId: "out1",
681
+ body: "done",
682
+ createdAt: "2026-07-16T11:00:00.000Z",
683
+ direction: "out",
684
+ },
685
+ ],
686
+ }),
687
+ )
688
+ .mockResolvedValueOnce(jsonResponse(200, { acknowledged: 1 }));
689
+ await program.parseAsync(["dm", "thread", "jonathan@getindigo.ai"], {
690
+ from: "user",
691
+ });
692
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/thread");
693
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("withEmail=");
694
+ const ackCall = fetchSpy.mock.calls.find((c) =>
695
+ String(c[0]).includes("/v1/notify/inbox/ack"),
696
+ );
697
+ expect(ackCall).toBeTruthy();
698
+ expect(JSON.parse((ackCall![1]?.body as string) ?? "{}")).toEqual({
699
+ eventIds: ["in1"],
700
+ });
701
+ expect(logged()).toContain("thanks!");
702
+ });
703
+
704
+ it("thread --no-ack: reads without marking anything read", async () => {
705
+ fetchSpy.mockResolvedValueOnce(
706
+ jsonResponse(200, {
707
+ messages: [
708
+ {
709
+ eventId: "in1",
710
+ body: "hi",
711
+ createdAt: "2026-07-16T11:30:00.000Z",
712
+ direction: "in",
713
+ },
714
+ ],
715
+ }),
716
+ );
717
+ await program.parseAsync(["dm", "thread", "prs_x", "--no-ack"], {
718
+ from: "user",
719
+ });
720
+ expect(
721
+ fetchSpy.mock.calls.some((c) => String(c[0]).includes("/inbox/ack")),
722
+ ).toBe(false);
723
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("withPersonUid=prs_x");
724
+ });
725
+
726
+ it("channel <name>: resolves the channel then reads its messages", async () => {
727
+ fetchSpy
728
+ .mockResolvedValueOnce(jsonResponse(200, channelsPayload))
729
+ .mockResolvedValueOnce(
730
+ jsonResponse(200, {
731
+ messages: [
732
+ {
733
+ eventId: "m1",
734
+ fromDisplayName: "Stefan",
735
+ body: "ship it",
736
+ createdAt: "2026-07-16T11:00:00.000Z",
737
+ },
738
+ ],
739
+ }),
740
+ );
741
+ await program.parseAsync(["dm", "channel", "vyg-dev"], { from: "user" });
742
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/channels");
743
+ expect(String(fetchSpy.mock.calls[1][0])).toContain(
744
+ "/v1/notify/channels/chn_v/messages",
745
+ );
746
+ expect(logged()).toContain("ship it");
747
+ });
483
748
  });