@indigoai-us/hq-cli 5.59.0 → 5.60.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.
@@ -1,5 +1,5 @@
1
1
 
2
- !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]="f2fc6ee0-c7c4-5012-85ce-aea36030aa92")}catch(e){}}();
2
+ !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]="6bb33e65-134a-5b0a-917b-ca084d4c8366")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import { ensureCognitoToken } from "../utils/cognito-session.js";
5
5
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
@@ -13,6 +13,17 @@ function formatDuration(seconds) {
13
13
  return `${m}m ${s}s`;
14
14
  return `${s}s`;
15
15
  }
16
+ function safeFormatDuration(seconds) {
17
+ return typeof seconds === "number" && Number.isFinite(seconds) ? formatDuration(seconds) : "-";
18
+ }
19
+ function safeFormatDate(iso, options) {
20
+ if (typeof iso !== "string" || iso.length === 0)
21
+ return "-";
22
+ const date = new Date(iso);
23
+ if (Number.isNaN(date.getTime()))
24
+ return "-";
25
+ return options ? date.toLocaleString("en-US", options) : date.toLocaleString();
26
+ }
16
27
  function formatTimestamp(ts) {
17
28
  const m = Math.floor(ts / 60);
18
29
  const s = Math.floor(ts % 60);
@@ -32,6 +43,38 @@ function statusBadge(status) {
32
43
  return chalk.dim(status);
33
44
  }
34
45
  }
46
+ function isMarkdownShape(x) {
47
+ if (!x || typeof x !== "object")
48
+ return false;
49
+ const candidate = x;
50
+ return candidate.sourceShape === "markdown" || Boolean(candidate.source?.frontmatter);
51
+ }
52
+ function hasSignals(signals) {
53
+ return Boolean(signals &&
54
+ typeof signals === "object" &&
55
+ !Array.isArray(signals) &&
56
+ Object.keys(signals).length > 0);
57
+ }
58
+ function renderSignals(signals) {
59
+ console.log(chalk.bold("Signals"));
60
+ for (const [key, value] of Object.entries(signals)) {
61
+ if (value === null || value === undefined) {
62
+ console.log(` ${key}: -`);
63
+ }
64
+ else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
65
+ console.log(` ${key}: ${String(value)}`);
66
+ }
67
+ else {
68
+ const rendered = JSON.stringify(value, null, 2)
69
+ .split("\n")
70
+ .map((line) => ` ${line}`)
71
+ .join("\n");
72
+ console.log(` ${key}:`);
73
+ console.log(rendered);
74
+ }
75
+ }
76
+ console.log();
77
+ }
35
78
  async function resolveShortId(token, prefix, query) {
36
79
  if (prefix.includes("-") && prefix.length > 8)
37
80
  return prefix;
@@ -99,25 +142,28 @@ function printMeetingTable(meetings) {
99
142
  const id = m.meetingId.slice(0, 8);
100
143
  const fullTitle = displayTitle(m);
101
144
  const title = fullTitle.length > TITLE_W ? fullTitle.slice(0, TITLE_W - 1) + "…" : fullTitle;
102
- const date = new Date(m.startTime).toLocaleDateString("en-US", {
145
+ const date = safeFormatDate(m.startTime, {
103
146
  month: "short",
104
147
  day: "numeric",
105
148
  hour: "2-digit",
106
149
  minute: "2-digit",
107
150
  });
108
- const dur = formatDuration(m.duration);
151
+ const dur = safeFormatDuration(m.duration);
152
+ const status = m.status ? statusBadge(m.status) : chalk.dim("-");
153
+ const parts = typeof m.participantCount === "number" ? String(m.participantCount) : "-";
109
154
  const flags = [
110
155
  m.hasTranscript ? "T" : "",
111
156
  m.hasNotes ? "N" : "",
157
+ m.hasSignals ? "S" : "",
112
158
  ].filter(Boolean).join("") || "-";
113
159
  console.log([
114
160
  chalk.cyan(id.padEnd(ID_W)),
115
161
  title.padEnd(TITLE_W),
116
162
  chalk.dim(date.padEnd(DATE_W)),
117
163
  dur.padEnd(DUR_W),
118
- statusBadge(m.status).padEnd(STATUS_W + 10), // chalk adds escape chars
119
- String(m.participantCount).padEnd(PARTS_W),
120
- flags,
164
+ status.padEnd(STATUS_W + 10), // chalk adds escape chars
165
+ parts.padEnd(PARTS_W),
166
+ flags.padEnd(FLAGS_W),
121
167
  ].join(" "));
122
168
  }
123
169
  }
@@ -188,16 +234,34 @@ export function registerMeetingsCommand(program) {
188
234
  console.log(JSON.stringify(detail, null, 2));
189
235
  return;
190
236
  }
191
- console.log(chalk.bold(`\n${detail.title}\n`));
237
+ if (isMarkdownShape(detail)) {
238
+ const fm = detail.source.frontmatter ?? {};
239
+ console.log(chalk.bold(`\n${fm.title || "(untitled)"}\n`));
240
+ console.log(` ID: ${chalk.cyan(detail.meetingId)}`);
241
+ console.log(` Status: ${fm.bot_status || "-"}`);
242
+ console.log(` Date: ${safeFormatDate(fm.scheduled_start_time || fm.created_at)}`);
243
+ console.log(` Platform: ${fm.meeting_platform || "-"}`);
244
+ console.log(` Origin: ${fm.origin || "-"}`);
245
+ console.log(` Company: ${fm.company_id || "-"}`);
246
+ if (fm.meeting_url)
247
+ console.log(` Meeting URL: ${fm.meeting_url}`);
248
+ if (hasSignals(detail.signals)) {
249
+ console.log(` Signals: ${Object.keys(detail.signals).length}`);
250
+ }
251
+ console.log(chalk.dim(`\n This meeting is stored as a markdown document. Use \`hq meetings transcript ${detail.meetingId.slice(0, 8)}\` to view the full document.`));
252
+ console.log();
253
+ return;
254
+ }
255
+ console.log(chalk.bold(`\n${detail.title ?? "(untitled)"}\n`));
192
256
  console.log(` ID: ${chalk.cyan(detail.meetingId)}`);
193
- console.log(` Status: ${statusBadge(detail.status)}`);
194
- console.log(` Date: ${new Date(detail.startTime).toLocaleString()}`);
195
- console.log(` Duration: ${formatDuration(detail.duration)}`);
196
- console.log(` Source: ${detail.sourceApp} (${detail.botProvider})`);
257
+ console.log(` Status: ${detail.status ? statusBadge(detail.status) : chalk.dim("-")}`);
258
+ console.log(` Date: ${safeFormatDate(detail.startTime)}`);
259
+ console.log(` Duration: ${safeFormatDuration(detail.duration)}`);
260
+ console.log(` Source: ${detail.sourceApp ?? "-"} (${detail.botProvider ?? "-"})`);
197
261
  console.log(` Shared: ${detail.isShared ? "yes" : "no"}`);
198
- if (detail.participants.length > 0) {
262
+ if ((detail.participants?.length ?? 0) > 0) {
199
263
  console.log(chalk.bold("\n Participants:"));
200
- for (const p of detail.participants) {
264
+ for (const p of detail.participants ?? []) {
201
265
  const name = p.name ?? p.email;
202
266
  const role = p.role === "organizer" ? chalk.yellow(" (organizer)") : "";
203
267
  console.log(` - ${name}${role}`);
@@ -325,6 +389,26 @@ export function registerMeetingsCommand(program) {
325
389
  if (!res.ok)
326
390
  await handleApiError(res);
327
391
  const detail = (await res.json());
392
+ if (isMarkdownShape(detail)) {
393
+ const documentUrl = detail.source.presigned_url;
394
+ if (!documentUrl) {
395
+ console.error(chalk.red("No document available for this meeting."));
396
+ process.exit(1);
397
+ }
398
+ const docRes = await fetch(documentUrl);
399
+ if (!docRes.ok) {
400
+ console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
401
+ process.exit(1);
402
+ }
403
+ const markdown = await docRes.text();
404
+ if (meetings.opts().json) {
405
+ console.log(JSON.stringify({ meetingId: detail.meetingId, sourceShape: "markdown", markdown }, null, 2));
406
+ return;
407
+ }
408
+ console.log(chalk.bold(`\nTranscript: ${detail.source.frontmatter?.title || "(untitled)"}\n`));
409
+ console.log(markdown);
410
+ return;
411
+ }
328
412
  if (!detail.documentUrl) {
329
413
  console.error(chalk.red("No document URL available for this meeting."));
330
414
  process.exit(1);
@@ -375,6 +459,35 @@ export function registerMeetingsCommand(program) {
375
459
  if (!res.ok)
376
460
  await handleApiError(res);
377
461
  const detail = (await res.json());
462
+ if (isMarkdownShape(detail)) {
463
+ if (hasSignals(detail.signals)) {
464
+ if (meetings.opts().json) {
465
+ console.log(JSON.stringify(detail.signals, null, 2));
466
+ return;
467
+ }
468
+ console.log(chalk.bold(`\nMeeting Notes: ${detail.source.frontmatter?.title || "(untitled)"}\n`));
469
+ renderSignals(detail.signals);
470
+ return;
471
+ }
472
+ const documentUrl = detail.source.presigned_url;
473
+ if (!documentUrl) {
474
+ console.log(chalk.yellow("No notes available for this meeting."));
475
+ return;
476
+ }
477
+ const docRes = await fetch(documentUrl);
478
+ if (!docRes.ok) {
479
+ console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
480
+ process.exit(1);
481
+ }
482
+ const markdown = await docRes.text();
483
+ if (meetings.opts().json) {
484
+ console.log(JSON.stringify({ meetingId: detail.meetingId, sourceShape: "markdown", markdown }, null, 2));
485
+ return;
486
+ }
487
+ console.log(chalk.bold(`\nMeeting Notes: ${detail.source.frontmatter?.title || "(untitled)"}\n`));
488
+ console.log(markdown);
489
+ return;
490
+ }
378
491
  if (!detail.documentUrl) {
379
492
  console.error(chalk.red("No document URL available for this meeting."));
380
493
  process.exit(1);
@@ -437,4 +550,4 @@ export function registerMeetingsCommand(program) {
437
550
  });
438
551
  }
439
552
  //# sourceMappingURL=meetings.js.map
440
- //# debugId=f2fc6ee0-c7c4-5012-85ce-aea36030aa92
553
+ //# debugId=6bb33e65-134a-5b0a-917b-ca084d4c8366
@@ -1,5 +1,5 @@
1
1
 
2
- !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]="12b04dfd-a264-56c7-bfbb-a448c35689b3")}catch(e){}}();
2
+ !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]="3b78d543-70d6-52e2-b19f-fa2650534e97")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import * as readline from "node:readline";
5
5
  import { spawn } from "node:child_process";
@@ -218,7 +218,11 @@ export function scrubSandboxOutput(text, secretNames = []) {
218
218
  }
219
219
  function renderSandboxJobResult(job, secretNames) {
220
220
  if (job.output) {
221
- process.stdout.write(scrubSandboxOutput(job.output, secretNames));
221
+ const output = scrubSandboxOutput(job.output, secretNames);
222
+ process.stdout.write(output);
223
+ if (output.length > 0 && !output.endsWith("\n")) {
224
+ process.stdout.write("\n");
225
+ }
222
226
  }
223
227
  }
224
228
  function normalizePolicyRecord(secretPath, data) {
@@ -1254,4 +1258,4 @@ export function registerSecretsCommand(program) {
1254
1258
  });
1255
1259
  }
1256
1260
  //# sourceMappingURL=secrets.js.map
1257
- //# debugId=12b04dfd-a264-56c7-bfbb-a448c35689b3
1261
+ //# debugId=3b78d543-70d6-52e2-b19f-fa2650534e97
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.59.0",
3
+ "version": "5.60.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -45,6 +45,7 @@ beforeEach(() => {
45
45
 
46
46
  afterEach(() => {
47
47
  vi.restoreAllMocks();
48
+ vi.unstubAllGlobals();
48
49
  });
49
50
 
50
51
  function buildProgram(): Command {
@@ -298,7 +299,7 @@ describe("meetings set-company", () => {
298
299
  "acme",
299
300
  ]);
300
301
 
301
- const output = logSpy.mock.calls.map(([line]) => String(line)).join("\n");
302
+ const output = logSpy.mock.calls.map(([line]) => (line === undefined ? "" : String(line))).join("\n");
302
303
  expect(output).not.toContain("Applied to 1 meetings");
303
304
  expect(output).not.toContain("Refiled 0 transcripts");
304
305
  expect(output).not.toContain("Future occurrences of this recurring series will inherit this attribution.");
@@ -345,8 +346,121 @@ describe("meetings list — null-safe rendering", () => {
345
346
  program.parseAsync(["node", "hq", "meetings", "list", "--company", "indigo"]),
346
347
  ).resolves.toBeDefined();
347
348
 
348
- const printed = logSpy.mock.calls.map(([line]) => String(line)).join("\n");
349
+ const printed = logSpy.mock.calls.map(([line]) => (line === undefined ? "" : String(line))).join("\n");
349
350
  expect(printed).toContain("Meetings (2)");
350
351
  expect(printed).toContain("(untitled)");
351
352
  });
352
353
  });
354
+
355
+ describe("meetings — markdown shape rendering", () => {
356
+ const markdownMeetingId = "589bfd78-aaaa-bbbb-cccc-1234567890ab";
357
+ const markdownUrl = "https://example.test/meeting.md";
358
+
359
+ function markdownDetail(signals: Record<string, unknown> = {}) {
360
+ return {
361
+ meetingId: markdownMeetingId,
362
+ sourceShape: "markdown",
363
+ source: {
364
+ path: "meetings/meeting.md",
365
+ presigned_url: markdownUrl,
366
+ frontmatter: {
367
+ title: "Markdown Standup",
368
+ channel: "eng",
369
+ origin: "recall",
370
+ company_id: "cmp_indigo",
371
+ meeting_url: "https://meet.example.test/standup",
372
+ meeting_platform: "google_meet",
373
+ calendar_event_id: null,
374
+ scheduled_start_time: "2026-02-03T15:30:00Z",
375
+ created_at: "2026-02-03T15:00:00Z",
376
+ updated_at: "2026-02-03T16:00:00Z",
377
+ ingested_at: "2026-02-03T16:05:00Z",
378
+ recall_bot_id: null,
379
+ bot_status: "done",
380
+ auto_scheduled: true,
381
+ },
382
+ },
383
+ signals,
384
+ };
385
+ }
386
+
387
+ it("lists markdown meetings without invalid legacy field output", async () => {
388
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
389
+ jsonRes({
390
+ meetings: [
391
+ {
392
+ meetingId: markdownMeetingId,
393
+ sourceShape: "markdown",
394
+ title: "Markdown Standup",
395
+ startTime: "2026-02-03T15:30:00Z",
396
+ channel: "eng",
397
+ ingested_at: "2026-02-03T16:05:00Z",
398
+ hasSignals: true,
399
+ companyId: "cmp_indigo",
400
+ attributed: true,
401
+ },
402
+ ],
403
+ }),
404
+ );
405
+
406
+ const program = buildProgram();
407
+ await program.parseAsync(["node", "hq", "meetings", "list"]);
408
+
409
+ const output = logSpy.mock.calls.map(([line]) => (line === undefined ? "" : String(line))).join("\n");
410
+ expect(output).toContain("Markdown Standup");
411
+ expect(output).toContain(" S ");
412
+ expect(output).not.toContain("NaN");
413
+ expect(output).not.toContain("undefined");
414
+ expect(output).not.toContain("Invalid Date");
415
+ });
416
+
417
+ it("gets markdown details from frontmatter without reading participants", async () => {
418
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes(markdownDetail({ action_items: ["Ship fix"] })));
419
+
420
+ const program = buildProgram();
421
+ await expect(
422
+ program.parseAsync(["node", "hq", "meetings", "get", markdownMeetingId]),
423
+ ).resolves.toBeDefined();
424
+
425
+ const output = logSpy.mock.calls.map(([line]) => (line === undefined ? "" : String(line))).join("\n");
426
+ expect(output).toContain("Markdown Standup");
427
+ expect(output).toContain("Status: done");
428
+ expect(output).toContain("Platform: google_meet");
429
+ expect(output).toContain("Origin: recall");
430
+ expect(output).toContain("Company: cmp_indigo");
431
+ expect(output).toContain("Meeting URL: https://meet.example.test/standup");
432
+ expect(output).toContain("Signals: 1");
433
+ expect(output).toContain("stored as a markdown document");
434
+ });
435
+
436
+ it("prints markdown transcript text from the presigned source URL", async () => {
437
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes(markdownDetail()));
438
+ const fetchMock = vi.fn(async () => new Response("# Markdown transcript\n\nHello team."));
439
+ vi.stubGlobal("fetch", fetchMock);
440
+
441
+ const program = buildProgram();
442
+ await program.parseAsync(["node", "hq", "meetings", "transcript", markdownMeetingId]);
443
+
444
+ expect(fetchMock).toHaveBeenCalledWith(markdownUrl);
445
+ const output = logSpy.mock.calls.map(([line]) => (line === undefined ? "" : String(line))).join("\n");
446
+ expect(output).toContain("Transcript: Markdown Standup");
447
+ expect(output).toContain("# Markdown transcript");
448
+ expect(output).toContain("Hello team.");
449
+ });
450
+
451
+ it("falls back to the markdown document for notes when markdown signals are empty", async () => {
452
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes(markdownDetail({})));
453
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("# Notes\n\nFollow up next week.")));
454
+
455
+ const program = buildProgram();
456
+ await program.parseAsync(["node", "hq", "meetings", "notes", markdownMeetingId]);
457
+
458
+ const output = logSpy.mock.calls.map(([line]) => (line === undefined ? "" : String(line))).join("\n");
459
+ const errors = errSpy.mock.calls.map(([line]) => (line === undefined ? "" : String(line))).join("\n");
460
+ expect(output).toContain("Meeting Notes: Markdown Standup");
461
+ expect(output).toContain("# Notes");
462
+ expect(output).toContain("Follow up next week.");
463
+ expect(output).not.toContain("No document URL available");
464
+ expect(errors).not.toContain("No document URL available");
465
+ });
466
+ });
@@ -5,35 +5,67 @@ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
5
5
 
6
6
  interface MeetingListItem {
7
7
  meetingId: string;
8
- title: string;
9
- startTime: string;
10
- endTime: string;
11
- duration: number;
12
- participantCount: number;
13
- status: string;
14
- hasTranscript: boolean;
15
- hasNotes: boolean;
8
+ sourceShape?: "markdown" | "json";
9
+ title?: string | null;
10
+ startTime?: string;
11
+ endTime?: string;
12
+ duration?: number;
13
+ participantCount?: number;
14
+ status?: string;
15
+ hasTranscript?: boolean;
16
+ hasNotes?: boolean;
17
+ channel?: string;
18
+ ingested_at?: string;
19
+ hasSignals?: boolean;
20
+ companyId?: string;
21
+ attributed?: boolean;
16
22
  }
17
23
 
18
24
  interface MeetingDetail {
19
25
  meetingId: string;
20
- title: string;
21
- startTime: string;
22
- endTime: string;
23
- duration: number;
24
- participants: Array<{ email: string; name: string | null; role: string }>;
25
- calendarEventId: string | null;
26
- botProvider: string;
27
- sourceApp: string;
28
- companyId: string;
29
- status: string;
30
- recallBotId: string | null;
31
- isShared: boolean;
32
- createdAt: string;
33
- updatedAt: string;
34
- documentUrl: string;
35
- hasTranscript: boolean;
36
- hasNotes: boolean;
26
+ sourceShape?: "markdown" | "json";
27
+ title?: string;
28
+ startTime?: string;
29
+ endTime?: string;
30
+ duration?: number;
31
+ participants?: Array<{ email: string; name: string | null; role: string }>;
32
+ calendarEventId?: string | null;
33
+ botProvider?: string;
34
+ sourceApp?: string;
35
+ companyId?: string;
36
+ status?: string;
37
+ recallBotId?: string | null;
38
+ isShared?: boolean;
39
+ createdAt?: string;
40
+ updatedAt?: string;
41
+ documentUrl?: string;
42
+ hasTranscript?: boolean;
43
+ hasNotes?: boolean;
44
+ source?: MarkdownMeetingSource;
45
+ signals?: Record<string, unknown>;
46
+ }
47
+
48
+ interface MarkdownMeetingFrontmatter {
49
+ title?: string;
50
+ channel?: string;
51
+ origin?: string;
52
+ company_id?: string;
53
+ meeting_url?: string;
54
+ meeting_platform?: string;
55
+ calendar_event_id?: string | null;
56
+ scheduled_start_time?: string;
57
+ created_at?: string;
58
+ updated_at?: string;
59
+ ingested_at?: string;
60
+ recall_bot_id?: string | null;
61
+ bot_status?: string;
62
+ auto_scheduled?: boolean;
63
+ }
64
+
65
+ interface MarkdownMeetingSource {
66
+ path?: string;
67
+ presigned_url?: string;
68
+ frontmatter?: MarkdownMeetingFrontmatter;
37
69
  }
38
70
 
39
71
  interface TranscriptSegment {
@@ -78,6 +110,17 @@ function formatDuration(seconds: number): string {
78
110
  return `${s}s`;
79
111
  }
80
112
 
113
+ function safeFormatDuration(seconds: unknown): string {
114
+ return typeof seconds === "number" && Number.isFinite(seconds) ? formatDuration(seconds) : "-";
115
+ }
116
+
117
+ function safeFormatDate(iso: unknown, options?: Intl.DateTimeFormatOptions): string {
118
+ if (typeof iso !== "string" || iso.length === 0) return "-";
119
+ const date = new Date(iso);
120
+ if (Number.isNaN(date.getTime())) return "-";
121
+ return options ? date.toLocaleString("en-US", options) : date.toLocaleString();
122
+ }
123
+
81
124
  function formatTimestamp(ts: number): string {
82
125
  const m = Math.floor(ts / 60);
83
126
  const s = Math.floor(ts % 60);
@@ -99,6 +142,45 @@ function statusBadge(status: string): string {
99
142
  }
100
143
  }
101
144
 
145
+ function isMarkdownShape(x: unknown): x is MeetingDetail & {
146
+ source: MarkdownMeetingSource;
147
+ } {
148
+ if (!x || typeof x !== "object") return false;
149
+ const candidate = x as {
150
+ sourceShape?: string;
151
+ source?: { frontmatter?: unknown };
152
+ };
153
+ return candidate.sourceShape === "markdown" || Boolean(candidate.source?.frontmatter);
154
+ }
155
+
156
+ function hasSignals(signals: unknown): signals is Record<string, unknown> {
157
+ return Boolean(
158
+ signals &&
159
+ typeof signals === "object" &&
160
+ !Array.isArray(signals) &&
161
+ Object.keys(signals).length > 0,
162
+ );
163
+ }
164
+
165
+ function renderSignals(signals: Record<string, unknown>): void {
166
+ console.log(chalk.bold("Signals"));
167
+ for (const [key, value] of Object.entries(signals)) {
168
+ if (value === null || value === undefined) {
169
+ console.log(` ${key}: -`);
170
+ } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
171
+ console.log(` ${key}: ${String(value)}`);
172
+ } else {
173
+ const rendered = JSON.stringify(value, null, 2)
174
+ .split("\n")
175
+ .map((line) => ` ${line}`)
176
+ .join("\n");
177
+ console.log(` ${key}:`);
178
+ console.log(rendered);
179
+ }
180
+ }
181
+ console.log();
182
+ }
183
+
102
184
  async function resolveShortId(
103
185
  token: string,
104
186
  prefix: string,
@@ -181,16 +263,19 @@ function printMeetingTable(meetings: MeetingListItem[]): void {
181
263
  const id = m.meetingId.slice(0, 8);
182
264
  const fullTitle = displayTitle(m);
183
265
  const title = fullTitle.length > TITLE_W ? fullTitle.slice(0, TITLE_W - 1) + "…" : fullTitle;
184
- const date = new Date(m.startTime).toLocaleDateString("en-US", {
266
+ const date = safeFormatDate(m.startTime, {
185
267
  month: "short",
186
268
  day: "numeric",
187
269
  hour: "2-digit",
188
270
  minute: "2-digit",
189
271
  });
190
- const dur = formatDuration(m.duration);
272
+ const dur = safeFormatDuration(m.duration);
273
+ const status = m.status ? statusBadge(m.status) : chalk.dim("-");
274
+ const parts = typeof m.participantCount === "number" ? String(m.participantCount) : "-";
191
275
  const flags = [
192
276
  m.hasTranscript ? "T" : "",
193
277
  m.hasNotes ? "N" : "",
278
+ m.hasSignals ? "S" : "",
194
279
  ].filter(Boolean).join("") || "-";
195
280
 
196
281
  console.log(
@@ -199,9 +284,9 @@ function printMeetingTable(meetings: MeetingListItem[]): void {
199
284
  title.padEnd(TITLE_W),
200
285
  chalk.dim(date.padEnd(DATE_W)),
201
286
  dur.padEnd(DUR_W),
202
- statusBadge(m.status).padEnd(STATUS_W + 10), // chalk adds escape chars
203
- String(m.participantCount).padEnd(PARTS_W),
204
- flags,
287
+ status.padEnd(STATUS_W + 10), // chalk adds escape chars
288
+ parts.padEnd(PARTS_W),
289
+ flags.padEnd(FLAGS_W),
205
290
  ].join(" "),
206
291
  );
207
292
  }
@@ -286,17 +371,39 @@ export function registerMeetingsCommand(program: Command): void {
286
371
  return;
287
372
  }
288
373
 
289
- console.log(chalk.bold(`\n${detail.title}\n`));
374
+ if (isMarkdownShape(detail)) {
375
+ const fm = detail.source.frontmatter ?? {};
376
+ console.log(chalk.bold(`\n${fm.title || "(untitled)"}\n`));
377
+ console.log(` ID: ${chalk.cyan(detail.meetingId)}`);
378
+ console.log(` Status: ${fm.bot_status || "-"}`);
379
+ console.log(` Date: ${safeFormatDate(fm.scheduled_start_time || fm.created_at)}`);
380
+ console.log(` Platform: ${fm.meeting_platform || "-"}`);
381
+ console.log(` Origin: ${fm.origin || "-"}`);
382
+ console.log(` Company: ${fm.company_id || "-"}`);
383
+ if (fm.meeting_url) console.log(` Meeting URL: ${fm.meeting_url}`);
384
+ if (hasSignals(detail.signals)) {
385
+ console.log(` Signals: ${Object.keys(detail.signals).length}`);
386
+ }
387
+ console.log(
388
+ chalk.dim(
389
+ `\n This meeting is stored as a markdown document. Use \`hq meetings transcript ${detail.meetingId.slice(0, 8)}\` to view the full document.`,
390
+ ),
391
+ );
392
+ console.log();
393
+ return;
394
+ }
395
+
396
+ console.log(chalk.bold(`\n${detail.title ?? "(untitled)"}\n`));
290
397
  console.log(` ID: ${chalk.cyan(detail.meetingId)}`);
291
- console.log(` Status: ${statusBadge(detail.status)}`);
292
- console.log(` Date: ${new Date(detail.startTime).toLocaleString()}`);
293
- console.log(` Duration: ${formatDuration(detail.duration)}`);
294
- console.log(` Source: ${detail.sourceApp} (${detail.botProvider})`);
398
+ console.log(` Status: ${detail.status ? statusBadge(detail.status) : chalk.dim("-")}`);
399
+ console.log(` Date: ${safeFormatDate(detail.startTime)}`);
400
+ console.log(` Duration: ${safeFormatDuration(detail.duration)}`);
401
+ console.log(` Source: ${detail.sourceApp ?? "-"} (${detail.botProvider ?? "-"})`);
295
402
  console.log(` Shared: ${detail.isShared ? "yes" : "no"}`);
296
403
 
297
- if (detail.participants.length > 0) {
404
+ if ((detail.participants?.length ?? 0) > 0) {
298
405
  console.log(chalk.bold("\n Participants:"));
299
- for (const p of detail.participants) {
406
+ for (const p of detail.participants ?? []) {
300
407
  const name = p.name ?? p.email;
301
408
  const role = p.role === "organizer" ? chalk.yellow(" (organizer)") : "";
302
409
  console.log(` - ${name}${role}`);
@@ -447,6 +554,32 @@ export function registerMeetingsCommand(program: Command): void {
447
554
  if (!res.ok) await handleApiError(res);
448
555
 
449
556
  const detail = (await res.json()) as MeetingDetail;
557
+
558
+ if (isMarkdownShape(detail)) {
559
+ const documentUrl = detail.source.presigned_url;
560
+ if (!documentUrl) {
561
+ console.error(chalk.red("No document available for this meeting."));
562
+ process.exit(1);
563
+ }
564
+
565
+ const docRes = await fetch(documentUrl);
566
+ if (!docRes.ok) {
567
+ console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
568
+ process.exit(1);
569
+ }
570
+
571
+ const markdown = await docRes.text();
572
+
573
+ if (meetings.opts().json) {
574
+ console.log(JSON.stringify({ meetingId: detail.meetingId, sourceShape: "markdown", markdown }, null, 2));
575
+ return;
576
+ }
577
+
578
+ console.log(chalk.bold(`\nTranscript: ${detail.source.frontmatter?.title || "(untitled)"}\n`));
579
+ console.log(markdown);
580
+ return;
581
+ }
582
+
450
583
  if (!detail.documentUrl) {
451
584
  console.error(chalk.red("No document URL available for this meeting."));
452
585
  process.exit(1);
@@ -503,6 +636,42 @@ export function registerMeetingsCommand(program: Command): void {
503
636
  if (!res.ok) await handleApiError(res);
504
637
 
505
638
  const detail = (await res.json()) as MeetingDetail;
639
+
640
+ if (isMarkdownShape(detail)) {
641
+ if (hasSignals(detail.signals)) {
642
+ if (meetings.opts().json) {
643
+ console.log(JSON.stringify(detail.signals, null, 2));
644
+ return;
645
+ }
646
+ console.log(chalk.bold(`\nMeeting Notes: ${detail.source.frontmatter?.title || "(untitled)"}\n`));
647
+ renderSignals(detail.signals);
648
+ return;
649
+ }
650
+
651
+ const documentUrl = detail.source.presigned_url;
652
+ if (!documentUrl) {
653
+ console.log(chalk.yellow("No notes available for this meeting."));
654
+ return;
655
+ }
656
+
657
+ const docRes = await fetch(documentUrl);
658
+ if (!docRes.ok) {
659
+ console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
660
+ process.exit(1);
661
+ }
662
+
663
+ const markdown = await docRes.text();
664
+
665
+ if (meetings.opts().json) {
666
+ console.log(JSON.stringify({ meetingId: detail.meetingId, sourceShape: "markdown", markdown }, null, 2));
667
+ return;
668
+ }
669
+
670
+ console.log(chalk.bold(`\nMeeting Notes: ${detail.source.frontmatter?.title || "(untitled)"}\n`));
671
+ console.log(markdown);
672
+ return;
673
+ }
674
+
506
675
  if (!detail.documentUrl) {
507
676
  console.error(chalk.red("No document URL available for this meeting."));
508
677
  process.exit(1);
@@ -264,6 +264,95 @@ describe("secrets sandbox", () => {
264
264
  expect(stdoutSpy).toHaveBeenCalledWith("token [REDACTED]\nAPI_KEY=[REDACTED]\n");
265
265
  });
266
266
 
267
+ it("terminates sandbox output with exactly one newline when output is present", async () => {
268
+ pollJobSpy.mockResolvedValueOnce({
269
+ jobId: "job_123",
270
+ status: "succeeded",
271
+ output: "123",
272
+ exitCode: 0,
273
+ success: true,
274
+ });
275
+
276
+ const program = buildProgram();
277
+ await program.parseAsync([
278
+ "node",
279
+ "hq",
280
+ "secrets",
281
+ "sandbox",
282
+ "--only",
283
+ "API_KEY",
284
+ "--",
285
+ "my-skill",
286
+ ]);
287
+
288
+ expect(stdoutSpy.mock.calls.flat().join("")).toBe("123\n");
289
+
290
+ stdoutSpy.mockClear();
291
+ pollJobSpy.mockResolvedValueOnce({
292
+ jobId: "job_123",
293
+ status: "succeeded",
294
+ output: "123\n",
295
+ exitCode: 0,
296
+ success: true,
297
+ });
298
+
299
+ await buildProgram().parseAsync([
300
+ "node",
301
+ "hq",
302
+ "secrets",
303
+ "sandbox",
304
+ "--only",
305
+ "API_KEY",
306
+ "--",
307
+ "my-skill",
308
+ ]);
309
+
310
+ expect(stdoutSpy.mock.calls.flat().join("")).toBe("123\n");
311
+
312
+ stdoutSpy.mockClear();
313
+ pollJobSpy.mockResolvedValueOnce({
314
+ jobId: "job_123",
315
+ status: "succeeded",
316
+ output: "",
317
+ exitCode: 0,
318
+ success: true,
319
+ });
320
+
321
+ await buildProgram().parseAsync([
322
+ "node",
323
+ "hq",
324
+ "secrets",
325
+ "sandbox",
326
+ "--only",
327
+ "API_KEY",
328
+ "--",
329
+ "my-skill",
330
+ ]);
331
+
332
+ expect(stdoutSpy).not.toHaveBeenCalled();
333
+
334
+ stdoutSpy.mockClear();
335
+ pollJobSpy.mockResolvedValueOnce({
336
+ jobId: "job_123",
337
+ status: "succeeded",
338
+ exitCode: 0,
339
+ success: true,
340
+ });
341
+
342
+ await buildProgram().parseAsync([
343
+ "node",
344
+ "hq",
345
+ "secrets",
346
+ "sandbox",
347
+ "--only",
348
+ "API_KEY",
349
+ "--",
350
+ "my-skill",
351
+ ]);
352
+
353
+ expect(stdoutSpy).not.toHaveBeenCalled();
354
+ });
355
+
267
356
  it("exits non-zero when the sandbox command fails", async () => {
268
357
  pollJobSpy.mockResolvedValueOnce({
269
358
  jobId: "job_123",
@@ -369,7 +369,11 @@ export function scrubSandboxOutput(text: string, secretNames: string[] = []): st
369
369
 
370
370
  function renderSandboxJobResult(job: SandboxRunnerJob, secretNames: string[]): void {
371
371
  if (job.output) {
372
- process.stdout.write(scrubSandboxOutput(job.output, secretNames));
372
+ const output = scrubSandboxOutput(job.output, secretNames);
373
+ process.stdout.write(output);
374
+ if (output.length > 0 && !output.endsWith("\n")) {
375
+ process.stdout.write("\n");
376
+ }
373
377
  }
374
378
  }
375
379