@indigoai-us/hq-cli 5.77.14 → 5.78.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 (308) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +42 -2
  33. package/dist/commands/pkg-install.js +39 -2
  34. package/dist/commands/run.js +6 -2
  35. package/dist/commands/secrets.js +120 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2256
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs-update-api-key.test.ts +0 -105
  146. package/src/commands/packs.test.ts +0 -88
  147. package/src/commands/packs.ts +0 -622
  148. package/src/commands/people.test.ts +0 -807
  149. package/src/commands/people.ts +0 -422
  150. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  151. package/src/commands/pkg-install.ts +0 -221
  152. package/src/commands/pkg-list.test.ts +0 -79
  153. package/src/commands/pkg-list.ts +0 -13
  154. package/src/commands/pkg-remove.ts +0 -71
  155. package/src/commands/pkg-update.ts +0 -189
  156. package/src/commands/publish.test.ts +0 -650
  157. package/src/commands/publish.ts +0 -580
  158. package/src/commands/reindex.test.ts +0 -259
  159. package/src/commands/reindex.ts +0 -274
  160. package/src/commands/rescue.test.ts +0 -39
  161. package/src/commands/rescue.ts +0 -210
  162. package/src/commands/run.env-local.test.ts +0 -88
  163. package/src/commands/run.test.ts +0 -45
  164. package/src/commands/run.ts +0 -190
  165. package/src/commands/safe-extract.test.ts +0 -459
  166. package/src/commands/safe-extract.ts +0 -444
  167. package/src/commands/secrets-scope.test.ts +0 -56
  168. package/src/commands/secrets-scope.ts +0 -32
  169. package/src/commands/secrets.parse-destination.test.ts +0 -38
  170. package/src/commands/secrets.test.ts +0 -2050
  171. package/src/commands/secrets.ts +0 -2249
  172. package/src/commands/signals.ts +0 -351
  173. package/src/commands/skill.test.ts +0 -770
  174. package/src/commands/skill.ts +0 -796
  175. package/src/commands/sources.ts +0 -362
  176. package/src/commands/sync-mode.test.ts +0 -366
  177. package/src/commands/sync-mode.ts +0 -387
  178. package/src/commands/sync-narrow.test.ts +0 -633
  179. package/src/commands/sync-narrow.ts +0 -555
  180. package/src/commands/sync.ts +0 -165
  181. package/src/commands/team-sync.ts +0 -628
  182. package/src/commands/update.ts +0 -71
  183. package/src/commands/whoami.ts +0 -83
  184. package/src/commands/workers.test.ts +0 -158
  185. package/src/commands/workers.ts +0 -301
  186. package/src/index.test.ts +0 -32
  187. package/src/index.ts +0 -20
  188. package/src/lib/db/control-plane.test.ts +0 -59
  189. package/src/lib/db/control-plane.ts +0 -113
  190. package/src/lib/db/local.test.ts +0 -81
  191. package/src/lib/db/local.ts +0 -148
  192. package/src/lib/db/migrate.test.ts +0 -133
  193. package/src/lib/db/migrate.ts +0 -137
  194. package/src/lib/db/paths.test.ts +0 -112
  195. package/src/lib/db/paths.ts +0 -128
  196. package/src/lib/db/remote-engine.test.ts +0 -44
  197. package/src/lib/db/remote-engine.ts +0 -148
  198. package/src/lib/db/remote-sql.test.ts +0 -32
  199. package/src/lib/db/remote-sql.ts +0 -62
  200. package/src/lib/db/sql.test.ts +0 -106
  201. package/src/lib/db/sql.ts +0 -192
  202. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  203. package/src/lib/local-tree-diff.test.ts +0 -301
  204. package/src/lib/local-tree-diff.ts +0 -362
  205. package/src/lib/narrow-hint-banner.test.ts +0 -235
  206. package/src/lib/narrow-hint-banner.ts +0 -212
  207. package/src/lib/plan-limit-nag.test.ts +0 -317
  208. package/src/lib/plan-limit-nag.ts +0 -264
  209. package/src/main.ts +0 -371
  210. package/src/node-preflight.test.ts +0 -60
  211. package/src/node-preflight.ts +0 -67
  212. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  213. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  214. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  215. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  216. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  217. package/src/outpost/session-heartbeat.test.ts +0 -459
  218. package/src/outpost/session-heartbeat.ts +0 -877
  219. package/src/packaging.test.ts +0 -109
  220. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  221. package/src/run/discover-schemas.test.ts +0 -153
  222. package/src/run/discover-schemas.ts +0 -79
  223. package/src/run/hq-plugin.test.ts +0 -371
  224. package/src/run/hq-plugin.ts +0 -277
  225. package/src/run/varlock-shape.test.ts +0 -57
  226. package/src/schemas/hq-package.schema.json +0 -155
  227. package/src/sentry-before-send.test.ts +0 -359
  228. package/src/sentry-before-send.ts +0 -221
  229. package/src/sentry-epipe.test.ts +0 -37
  230. package/src/sentry-release.test.ts +0 -54
  231. package/src/sentry.test.ts +0 -24
  232. package/src/sentry.ts +0 -54
  233. package/src/strategies/link.ts +0 -62
  234. package/src/strategies/merge.ts +0 -142
  235. package/src/types.ts +0 -146
  236. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  237. package/src/utils/auth-error.test.ts +0 -40
  238. package/src/utils/auth-error.ts +0 -42
  239. package/src/utils/billing-gate.test.ts +0 -95
  240. package/src/utils/billing-gate.ts +0 -228
  241. package/src/utils/breadcrumb-buffer.ts +0 -18
  242. package/src/utils/cli-telemetry.test.ts +0 -153
  243. package/src/utils/cli-telemetry.ts +0 -61
  244. package/src/utils/cognito-session.machine.test.ts +0 -109
  245. package/src/utils/cognito-session.refresh.test.ts +0 -84
  246. package/src/utils/cognito-session.test.ts +0 -328
  247. package/src/utils/cognito-session.ts +0 -448
  248. package/src/utils/company-selection-error.test.ts +0 -42
  249. package/src/utils/company-selection-error.ts +0 -45
  250. package/src/utils/contribution-table.ts +0 -83
  251. package/src/utils/environmental-error.test.ts +0 -45
  252. package/src/utils/environmental-error.ts +0 -39
  253. package/src/utils/epipe.test.ts +0 -28
  254. package/src/utils/epipe.ts +0 -29
  255. package/src/utils/expected-cli-error.test.ts +0 -28
  256. package/src/utils/expected-cli-error.ts +0 -39
  257. package/src/utils/feedback-diagnostics.test.ts +0 -183
  258. package/src/utils/feedback-diagnostics.ts +0 -123
  259. package/src/utils/feedback-screenshots.test.ts +0 -134
  260. package/src/utils/feedback-screenshots.ts +0 -124
  261. package/src/utils/feedback-versions.test.ts +0 -98
  262. package/src/utils/feedback-versions.ts +0 -68
  263. package/src/utils/git.ts +0 -74
  264. package/src/utils/integrity.ts +0 -59
  265. package/src/utils/intercepted-process-exit.test.ts +0 -37
  266. package/src/utils/intercepted-process-exit.ts +0 -36
  267. package/src/utils/login-provider.test.ts +0 -42
  268. package/src/utils/login-provider.ts +0 -30
  269. package/src/utils/manifest.test.ts +0 -167
  270. package/src/utils/manifest.ts +0 -131
  271. package/src/utils/pack-contributions.test.ts +0 -603
  272. package/src/utils/pack-contributions.ts +0 -450
  273. package/src/utils/people.ts +0 -215
  274. package/src/utils/registry-client.ts +0 -204
  275. package/src/utils/registry.ts +0 -90
  276. package/src/utils/resolve-vault-credential.test.ts +0 -69
  277. package/src/utils/resolve-vault-credential.ts +0 -60
  278. package/src/utils/sandbox-runner-client.test.ts +0 -279
  279. package/src/utils/sandbox-runner-client.ts +0 -260
  280. package/src/utils/secrets-cache.ts +0 -183
  281. package/src/utils/sentry-identity.ts +0 -45
  282. package/src/utils/settle-with-timeout.test.ts +0 -21
  283. package/src/utils/settle-with-timeout.ts +0 -22
  284. package/src/utils/user-yaml-error.test.ts +0 -24
  285. package/src/utils/user-yaml-error.ts +0 -30
  286. package/src/utils/vault-api.test.ts +0 -536
  287. package/src/utils/vault-api.ts +0 -382
  288. package/src/utils/version-check.test.ts +0 -176
  289. package/src/utils/version-check.ts +0 -155
  290. package/src/utils/version-gate.test.ts +0 -1024
  291. package/src/utils/version-gate.ts +0 -702
  292. package/test/commands/db-tenant-isolation.test.ts +0 -94
  293. package/test/commands/db.test.ts +0 -85
  294. package/test/commands/signals.test.ts +0 -207
  295. package/test/commands/sources.test.ts +0 -231
  296. package/test/e2e/smoke-install-mcp.sh +0 -113
  297. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  298. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  299. package/test/fixtures/signals/action_item/sample.md +0 -16
  300. package/test/fixtures/signals/summary/sample.md +0 -12
  301. package/test/fixtures/sources/meetings/sample.md +0 -25
  302. package/test/helpers/cli-runner.ts +0 -150
  303. package/test/helpers/s3-list-mock.ts +0 -79
  304. package/test/helpers/vault-service-mock.ts +0 -295
  305. package/test/sources-signals/smoke.test.ts +0 -228
  306. package/tsconfig.json +0 -20
  307. package/vitest.config.ts +0 -11
  308. package/vitest.e2e.config.ts +0 -7
@@ -1,1226 +0,0 @@
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 } from "../utils/vault-api.js";
6
-
7
- const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
8
- // People (prs_) and agents (agt_) are both first-class DM participants; the
9
- // server applies the same membership-overlap gate to either and delivers
10
- // agt_* recipients into the agent's durable box inbox.
11
- const RECIPIENT_UID_PATTERN = /^(prs|agt)_[A-Za-z0-9_-]+$/;
12
-
13
- export interface DmRecipient {
14
- toEmail?: string;
15
- toPersonUid?: string;
16
- }
17
-
18
- /**
19
- * Classify a recipient arg as an email or a person/agent uid. Mirrors the
20
- * email/uid heuristic used by `hq members`. Returns null for neither.
21
- * Agent uids (agt_*) ride the same `toPersonUid` wire field — that is the
22
- * server contract (POST /v1/notify/dm accepts prs_* or agt_*).
23
- */
24
- export function detectRecipient(recipient: string): DmRecipient | null {
25
- const r = recipient.trim();
26
- if (EMAIL_PATTERN.test(r)) return { toEmail: r.toLowerCase() };
27
- if (RECIPIENT_UID_PATTERN.test(r)) return { toPersonUid: r };
28
- return null;
29
- }
30
-
31
- /**
32
- * A comma in the recipient arg signals a GROUP DM. Split into trimmed, de-duped
33
- * recipient tokens (emails or personUids). Returns null when there's no comma
34
- * (the normal 1:1 path). Pure → unit-testable.
35
- */
36
- export function parseGroupRecipients(recipient: string): string[] | null {
37
- if (!recipient.includes(",")) return null;
38
- const parts = recipient
39
- .split(",")
40
- .map((s) => s.trim())
41
- .filter(Boolean);
42
- return [...new Set(parts)];
43
- }
44
-
45
- /**
46
- * Normalize a channel name into a stable slug for name matching. MIRRORS the
47
- * server's `channelSlug` (hq-pro src/vault-service/lib/channels.ts): lowercase,
48
- * collapse runs of non-alphanumerics to single hyphens, trim leading/trailing
49
- * hyphens. Keep in lockstep with the server so `hq dm vyg-dev` matches the
50
- * channel the server stored as slug `vyg-dev`. Pure → unit-testable.
51
- */
52
- export function channelSlug(name: string): string {
53
- return name
54
- .trim()
55
- .toLowerCase()
56
- .replace(/[^a-z0-9]+/g, "-")
57
- .replace(/^-+|-+$/g, "");
58
- }
59
-
60
- /**
61
- * Decide whether a positional recipient token addresses a DM CHANNEL by name
62
- * (rather than a person/agent or a group). Returns the bare channel name to
63
- * resolve, or null when the token is a person path (email / prs_ / agt_ uid) or
64
- * a comma group. Pure → unit-testable.
65
- *
66
- * "#vyg-dev" → "vyg-dev" (explicit hash form)
67
- * "vyg-dev" → "vyg-dev" (bare name — not an email/uid/group)
68
- * "a@b.com" → null (person)
69
- * "prs_…" → null (person) "agt_…" → null (agent)
70
- * "a@x,b@y" → null (group DM)
71
- */
72
- export function parseChannelName(recipient: string): string | null {
73
- const r = recipient.trim();
74
- if (!r) return null;
75
- if (r.startsWith("#")) {
76
- const name = r.slice(1).trim();
77
- return name || null;
78
- }
79
- // A comma is the group-DM signal; emails and prs_/agt_ uids are person paths.
80
- if (r.includes(",")) return null;
81
- if (EMAIL_PATTERN.test(r)) return null;
82
- if (RECIPIENT_UID_PATTERN.test(r)) return null;
83
- return r;
84
- }
85
-
86
- /** Minimal channel shape the CLI reads back from GET /v1/notify/channels. */
87
- export interface ChannelSummary {
88
- channelId: string;
89
- name?: string;
90
- slug?: string;
91
- scope?: string;
92
- memberCount?: number;
93
- members?: { personUid: string; displayName?: string }[];
94
- }
95
-
96
- /**
97
- * Find the caller's channel(s) whose name matches `name`, by slug or
98
- * case-insensitive display name. Group DMs are unnamed (participant-keyed), so
99
- * they never match a name. Returns ALL matches so the caller can detect an
100
- * ambiguous name (same slug across personal + company scope). Pure →
101
- * unit-testable.
102
- */
103
- export function matchChannelsByName(
104
- channels: ChannelSummary[],
105
- name: string,
106
- ): ChannelSummary[] {
107
- const targetSlug = channelSlug(name);
108
- const targetName = name.trim().toLowerCase();
109
- if (!targetSlug && !targetName) return [];
110
- return channels.filter((c) => {
111
- if (c.scope === "group") return false;
112
- const slug = c.slug ?? (c.name ? channelSlug(c.name) : "");
113
- if (slug && slug === targetSlug) return true;
114
- if (c.name && c.name.trim().toLowerCase() === targetName) return true;
115
- return false;
116
- });
117
- }
118
-
119
- /**
120
- * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
121
- * Returns null on anything that doesn't match. Pure → unit-testable.
122
- */
123
- export function parseDuration(input: string): number | null {
124
- const m = /^(\d+)\s*(s|m|h|d)$/.exec(input.trim());
125
- if (!m) return null;
126
- const n = parseInt(m[1], 10);
127
- const mult: Record<string, number> = {
128
- s: 1000,
129
- m: 60_000,
130
- h: 3_600_000,
131
- d: 86_400_000,
132
- };
133
- return n * mult[m[2]];
134
- }
135
-
136
- export interface DmSendBody {
137
- toEmail?: string;
138
- toPersonUid?: string;
139
- body: string;
140
- prompt?: string;
141
- details?: string;
142
- deliverAt?: string;
143
- }
144
-
145
- /**
146
- * Build the POST /v1/notify/dm request body from CLI inputs. Pure (no I/O,
147
- * no clock) so the option-resolution logic is unit-testable; the caller
148
- * supplies `now` for the `--in` relative-delay computation.
149
- *
150
- * Throws Error with a user-facing message on invalid input.
151
- */
152
- export function buildDmBody(args: {
153
- recipient: string;
154
- message: string;
155
- prompt?: string;
156
- details?: string;
157
- at?: string;
158
- inDelay?: string;
159
- now: number;
160
- }): DmSendBody {
161
- const rcpt = detectRecipient(args.recipient);
162
- if (!rcpt) {
163
- throw new Error(
164
- `Invalid recipient '${args.recipient}': must be an email address, a personUid (prs_…), or an agentUid (agt_…).`,
165
- );
166
- }
167
- const body = (args.message ?? "").trim();
168
- if (!body) {
169
- throw new Error("A message body is required: hq dm <recipient> <message>");
170
- }
171
-
172
- if (args.at && args.inDelay) {
173
- throw new Error("Use only one of --at or --in, not both.");
174
- }
175
- let deliverAt: string | undefined;
176
- if (args.at) {
177
- const when = new Date(args.at);
178
- if (isNaN(when.getTime())) {
179
- throw new Error(`Invalid --at '${args.at}': must be an ISO8601 date.`);
180
- }
181
- deliverAt = when.toISOString();
182
- } else if (args.inDelay) {
183
- const ms = parseDuration(args.inDelay);
184
- if (ms === null) {
185
- throw new Error(
186
- `Invalid --in '${args.inDelay}': use a relative delay like 30s, 10m, 2h, 1d.`,
187
- );
188
- }
189
- deliverAt = new Date(args.now + ms).toISOString();
190
- }
191
-
192
- const prompt = args.prompt?.trim();
193
- const details = args.details?.trim();
194
-
195
- return {
196
- ...rcpt,
197
- body,
198
- ...(prompt ? { prompt } : {}),
199
- ...(details ? { details } : {}),
200
- ...(deliverAt ? { deliverAt } : {}),
201
- };
202
- }
203
-
204
- function friendlyDmError(status: number, code: string | undefined, fallback: string): string {
205
- if (status === 401) return "Not authenticated — run `hq login` and try again.";
206
- if (status === 404 || code === "RECIPIENT_NOT_FOUND") {
207
- return "Recipient not found or not reachable — you can only DM someone you share an active company with.";
208
- }
209
- if (status >= 500) return `Server error: ${fallback}`;
210
- return fallback;
211
- }
212
-
213
- /** A pending incoming connection request, as returned by the backend. */
214
- export interface ConnectionRequest {
215
- pairKey: string;
216
- fromPersonUid: string;
217
- fromDisplayName?: string;
218
- fromEmail?: string;
219
- message?: string;
220
- createdAt?: string;
221
- }
222
-
223
- /**
224
- * Resolve a connection request from a pending-requests list by a caller-supplied
225
- * identifier (email, personUid, or pairKey). Pure → unit-testable.
226
- *
227
- * Matching is case-insensitive for emails. Returns the matched request, or null
228
- * if no request matches.
229
- */
230
- export function matchRequest(
231
- requests: ConnectionRequest[],
232
- identifier: string,
233
- ): ConnectionRequest | null {
234
- const id = identifier.trim();
235
- const idLower = id.toLowerCase();
236
- for (const r of requests) {
237
- if (r.pairKey === id) return r;
238
- if (r.fromPersonUid === id) return r;
239
- if (r.fromEmail && r.fromEmail.toLowerCase() === idLower) return r;
240
- }
241
- return null;
242
- }
243
-
244
- /**
245
- * Build the POST body for an accept/decline/block action. Prefers a resolved
246
- * pairKey (from a matched pending request); otherwise falls back to the
247
- * recipient identifier the backend can resolve (`withPersonUid` for a prs_ UID,
248
- * `toEmail`-style is not supported by the connections endpoints so we send
249
- * `withEmail`). Pure → unit-testable.
250
- *
251
- * Throws Error with a user-facing message when the identifier can't be resolved.
252
- */
253
- export function buildConnectionActionBody(
254
- identifier: string,
255
- matched: ConnectionRequest | null,
256
- ): { pairKey: string } | { withPersonUid: string } | { withEmail: string } {
257
- if (matched) {
258
- return { pairKey: matched.pairKey };
259
- }
260
- const r = identifier.trim();
261
- const rcpt = detectRecipient(r);
262
- if (rcpt?.toPersonUid) return { withPersonUid: rcpt.toPersonUid };
263
- if (rcpt?.toEmail) return { withEmail: rcpt.toEmail };
264
- throw new Error(
265
- `Could not resolve '${identifier}' to a pending request — pass the requester's email, personUid, or the pairKey from \`hq dm requests\`.`,
266
- );
267
- }
268
-
269
- const CONNECTION_ACTIONS = {
270
- accept: { path: "accept", done: "Connection accepted" },
271
- decline: { path: "decline", done: "Request declined" },
272
- block: { path: "block", done: "Blocked" },
273
- } as const;
274
-
275
- type ConnectionAction = keyof typeof CONNECTION_ACTIONS;
276
-
277
- /** Fetch the caller's pending incoming connection requests. */
278
- async function fetchRequests(token: string): Promise<ConnectionRequest[]> {
279
- const res = await vaultApiFetch({
280
- token,
281
- path: "/v1/notify/connections/requests",
282
- });
283
- if (!res.ok) {
284
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
285
- throw new Error(
286
- friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
287
- );
288
- }
289
- const data = (await res.json()) as { requests?: ConnectionRequest[] };
290
- return data.requests ?? [];
291
- }
292
-
293
- async function runConnectionAction(
294
- action: ConnectionAction,
295
- identifier: string,
296
- ): Promise<void> {
297
- try {
298
- const token = await ensureCognitoToken();
299
- // Resolve the pairKey from the caller's pending requests when possible so
300
- // accept/decline/block work against a friendly email or personUid.
301
- const requests = await fetchRequests(token).catch(() => [] as ConnectionRequest[]);
302
- const matched = matchRequest(requests, identifier);
303
- const body = buildConnectionActionBody(identifier, matched);
304
-
305
- const res = await vaultApiFetch({
306
- token,
307
- path: `/v1/notify/connections/${CONNECTION_ACTIONS[action].path}`,
308
- method: "POST",
309
- body: body as unknown as Record<string, unknown>,
310
- });
311
- if (!res.ok) {
312
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
313
- console.error(
314
- chalk.red(
315
- friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
316
- ),
317
- );
318
- process.exit(1);
319
- }
320
-
321
- const who = matched?.fromDisplayName ?? matched?.fromEmail ?? identifier;
322
- console.log(chalk.green(`${CONNECTION_ACTIONS[action].done} — ${who}.`));
323
- if (action === "accept" && matched?.message) {
324
- console.log(chalk.dim("Their held message will now be delivered."));
325
- }
326
- } catch (err) {
327
- console.error(
328
- chalk.red("Error:"),
329
- err instanceof Error ? err.message : String(err),
330
- );
331
- process.exit(1);
332
- }
333
- }
334
-
335
- interface DmSendOpts {
336
- prompt?: string;
337
- promptFile?: string;
338
- details?: string;
339
- detailsFile?: string;
340
- at?: string;
341
- in?: string;
342
- channel?: string;
343
- }
344
-
345
- /**
346
- * Group DM path: `hq dm send "a@x.com,b@y.com" "msg"`. Creates (or reopens, via
347
- * the server's idempotent participant-key dedupe) a group channel, then posts
348
- * the message into it. Reuses the same vault API client + auth as the 1:1 path.
349
- */
350
- async function runGroupSend(
351
- recipients: string[],
352
- message: string | undefined,
353
- ): Promise<void> {
354
- try {
355
- const participants: string[] = [];
356
- for (const r of recipients) {
357
- const rc = detectRecipient(r);
358
- if (!rc) {
359
- console.error(
360
- chalk.red(
361
- `Invalid recipient '${r}': each must be an email address or a personUid (prs_…).`,
362
- ),
363
- );
364
- process.exit(1);
365
- }
366
- // Group DMs are channels — agents don't participate in channels (their
367
- // DM surface is 1:1 via the durable box inbox). DM an agent directly.
368
- if (rc.toPersonUid?.startsWith("agt_")) {
369
- console.error(
370
- chalk.red(
371
- `Agents can't join group DMs yet — DM '${r}' directly: hq dm ${r} "<message>".`,
372
- ),
373
- );
374
- process.exit(1);
375
- }
376
- participants.push(rc.toEmail ?? rc.toPersonUid!);
377
- }
378
- if (participants.length < 2) {
379
- console.error(
380
- chalk.red(
381
- 'A group DM needs at least 2 other people — list them comma-separated, e.g. hq dm send "a@x.com,b@y.com" "hi".',
382
- ),
383
- );
384
- process.exit(1);
385
- }
386
- const body = (message ?? "").trim();
387
- if (!body) {
388
- console.error(
389
- chalk.red('A message body is required: hq dm send "a@x,b@y" <message>'),
390
- );
391
- process.exit(1);
392
- }
393
-
394
- const token = await ensureCognitoToken();
395
-
396
- // 1) Create or reopen the group (idempotent on the participant set).
397
- const createRes = await vaultApiFetch({
398
- token,
399
- path: "/v1/notify/channels",
400
- method: "POST",
401
- body: { scope: "group", participants },
402
- });
403
- if (!createRes.ok) {
404
- const err = (await createRes.json().catch(() => ({}))) as Record<string, string>;
405
- console.error(
406
- chalk.red(
407
- friendlyDmError(
408
- createRes.status,
409
- err.code,
410
- err.error ?? err.message ?? createRes.statusText,
411
- ),
412
- ),
413
- );
414
- process.exit(1);
415
- }
416
- const createData = (await createRes.json()) as {
417
- channel?: { channelId?: string };
418
- created?: boolean;
419
- };
420
- const channelId = createData.channel?.channelId;
421
- if (!channelId) {
422
- console.error(chalk.red("Group create returned no channel id."));
423
- process.exit(1);
424
- }
425
-
426
- // 2) Post the message into the group.
427
- const sendRes = await vaultApiFetch({
428
- token,
429
- path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
430
- method: "POST",
431
- body: { body },
432
- });
433
- if (!sendRes.ok) {
434
- const err = (await sendRes.json().catch(() => ({}))) as Record<string, string>;
435
- console.error(
436
- chalk.red(
437
- friendlyDmError(
438
- sendRes.status,
439
- err.code,
440
- err.error ?? err.message ?? sendRes.statusText,
441
- ),
442
- ),
443
- );
444
- process.exit(1);
445
- }
446
- console.log(
447
- chalk.green(
448
- `Group DM sent to ${participants.length} people${
449
- createData.created ? " (new group)" : ""
450
- }.`,
451
- ),
452
- );
453
- } catch (err) {
454
- console.error(
455
- chalk.red("Error:"),
456
- err instanceof Error ? err.message : String(err),
457
- );
458
- process.exit(1);
459
- }
460
- }
461
-
462
- /** Fetch the caller's channels (GET /v1/notify/channels). */
463
- async function fetchChannels(token: string): Promise<ChannelSummary[]> {
464
- const res = await vaultApiFetch({ token, path: "/v1/notify/channels" });
465
- if (!res.ok) {
466
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
467
- throw new Error(
468
- friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
469
- );
470
- }
471
- const data = (await res.json()) as { channels?: ChannelSummary[] };
472
- return data.channels ?? [];
473
- }
474
-
475
- /**
476
- * Channel DM path: `hq dm vyg-dev "msg"`, `hq dm '#vyg-dev' "msg"`, or
477
- * `hq dm --channel vyg-dev "msg"`. Resolves the caller's channel by name via
478
- * GET /v1/notify/channels, then posts the message into it. Scheduling /
479
- * prompt / details are 1:1-DM features and are rejected here rather than
480
- * silently dropped.
481
- */
482
- async function runChannelSend(
483
- channelName: string,
484
- message: string | undefined,
485
- opts: DmSendOpts,
486
- ): Promise<void> {
487
- try {
488
- const body = (message ?? "").trim();
489
- if (!body) {
490
- console.error(
491
- chalk.red(
492
- `A message body is required: hq dm ${channelName} "<message>" (or hq dm --channel ${channelName} "<message>").`,
493
- ),
494
- );
495
- process.exit(1);
496
- }
497
- const unsupported = [
498
- opts.prompt || opts.promptFile ? "--prompt/--prompt-file" : null,
499
- opts.details || opts.detailsFile ? "--details/--details-file" : null,
500
- opts.at ? "--at" : null,
501
- opts.in ? "--in" : null,
502
- ].filter(Boolean);
503
- if (unsupported.length > 0) {
504
- console.error(
505
- chalk.red(
506
- `${unsupported.join(", ")} ${
507
- unsupported.length === 1 ? "is" : "are"
508
- } only supported for 1:1 DMs, not channel messages.`,
509
- ),
510
- );
511
- process.exit(1);
512
- }
513
-
514
- const token = await ensureCognitoToken();
515
- const channels = await fetchChannels(token);
516
- const matches = matchChannelsByName(channels, channelName);
517
-
518
- if (matches.length === 0) {
519
- console.error(
520
- chalk.red(`No channel named '${channelName}' — run \`hq channels\` to see your channels.`),
521
- );
522
- process.exit(1);
523
- }
524
- if (matches.length > 1) {
525
- const scopes = matches.map((m) => m.scope ?? "?").join(", ");
526
- console.error(
527
- chalk.red(
528
- `'${channelName}' matches ${matches.length} channels (${scopes}) — this is ambiguous. Open the channel in HQ Sync to post, or rename one.`,
529
- ),
530
- );
531
- process.exit(1);
532
- }
533
-
534
- const channel = matches[0];
535
- const sendRes = await vaultApiFetch({
536
- token,
537
- path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`,
538
- method: "POST",
539
- body: { body },
540
- });
541
- if (!sendRes.ok) {
542
- const err = (await sendRes.json().catch(() => ({}))) as Record<string, string>;
543
- console.error(
544
- chalk.red(
545
- friendlyDmError(sendRes.status, err.code, err.error ?? err.message ?? sendRes.statusText),
546
- ),
547
- );
548
- process.exit(1);
549
- }
550
- console.log(chalk.green(`Message posted to #${channel.name ?? channelName}.`));
551
- } catch (err) {
552
- console.error(
553
- chalk.red("Error:"),
554
- err instanceof Error ? err.message : String(err),
555
- );
556
- process.exit(1);
557
- }
558
- }
559
-
560
- async function runDmSend(
561
- recipient: string | undefined,
562
- message: string | undefined,
563
- opts: DmSendOpts,
564
- ): Promise<void> {
565
- // --channel <name> is an explicit channel target; the positional carries the
566
- // message (recipient slot), e.g. `hq dm --channel vyg-dev "hello"`.
567
- if (opts.channel !== undefined) {
568
- await runChannelSend(opts.channel, message ?? recipient, opts);
569
- return;
570
- }
571
- if (recipient === undefined) {
572
- console.error(
573
- chalk.red(
574
- 'A recipient is required: hq dm <email|personUid|#channel> "<message>" (or --channel <name>).',
575
- ),
576
- );
577
- process.exit(1);
578
- return;
579
- }
580
- // A comma in the recipient means a group DM — fan into the channel path.
581
- const group = parseGroupRecipients(recipient);
582
- if (group) {
583
- await runGroupSend(group, message);
584
- return;
585
- }
586
- // A bare name or #hash addresses a named DM channel.
587
- const channelName = parseChannelName(recipient);
588
- if (channelName !== null) {
589
- await runChannelSend(channelName, message, opts);
590
- return;
591
- }
592
- try {
593
- // Resolve prompt/details from inline text or a file.
594
- let prompt = opts.prompt;
595
- if (opts.promptFile) prompt = readFileSync(opts.promptFile, "utf8");
596
- let details = opts.details;
597
- if (opts.detailsFile) details = readFileSync(opts.detailsFile, "utf8");
598
-
599
- const reqBody = buildDmBody({
600
- recipient,
601
- message: message ?? "",
602
- prompt,
603
- details,
604
- at: opts.at,
605
- inDelay: opts.in,
606
- now: Date.now(),
607
- });
608
-
609
- const token = await ensureCognitoToken();
610
- const res = await vaultApiFetch({
611
- token,
612
- path: "/v1/notify/dm",
613
- method: "POST",
614
- body: reqBody as unknown as Record<string, unknown>,
615
- });
616
-
617
- if (!res.ok) {
618
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
619
- console.error(
620
- chalk.red(
621
- friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
622
- ),
623
- );
624
- process.exit(1);
625
- }
626
-
627
- // 202 connection_requested: the recipient isn't connected yet, so the DM is
628
- // held as a connection request rather than delivered. This is a success, not
629
- // an error — surface it quietly.
630
- const data = (await res.json()) as {
631
- eventId?: string;
632
- createdAt?: string;
633
- scheduled?: boolean;
634
- deliverAt?: string;
635
- state?: string;
636
- };
637
-
638
- if (res.status === 202 || data.state === "connection_requested") {
639
- console.log(
640
- chalk.green(`Request sent — pending ${recipient}'s acceptance.`),
641
- );
642
- console.log(
643
- chalk.dim("Your message is held and delivers automatically once they accept."),
644
- );
645
- return;
646
- }
647
-
648
- if (data.scheduled) {
649
- console.log(
650
- chalk.green(
651
- `Scheduled DM to ${recipient} for ${data.deliverAt} (eventId ${data.eventId}).`,
652
- ),
653
- );
654
- console.log(
655
- chalk.dim("It delivers within ~60s of that time, even if you're offline."),
656
- );
657
- } else {
658
- console.log(
659
- chalk.green(`DM sent to ${recipient} (eventId ${data.eventId}).`),
660
- );
661
- }
662
- } catch (err) {
663
- console.error(
664
- chalk.red("Error:"),
665
- err instanceof Error ? err.message : String(err),
666
- );
667
- process.exit(1);
668
- }
669
- }
670
-
671
- async function runDmRequests(): Promise<void> {
672
- try {
673
- const token = await ensureCognitoToken();
674
- const requests = await fetchRequests(token);
675
- if (requests.length === 0) {
676
- console.log(chalk.dim("No pending connection requests."));
677
- return;
678
- }
679
- console.log(
680
- chalk.green(
681
- `${requests.length} pending connection request${requests.length === 1 ? "" : "s"}:`,
682
- ),
683
- );
684
- for (const r of requests) {
685
- const name = r.fromDisplayName ?? r.fromEmail ?? r.fromPersonUid;
686
- const emailPart = r.fromEmail ? chalk.dim(` <${r.fromEmail}>`) : "";
687
- console.log(`\n ${chalk.bold(name)}${emailPart}`);
688
- if (r.message) {
689
- console.log(` ${chalk.dim('"' + r.message + '"')}`);
690
- }
691
- const idHint = r.fromEmail ?? r.fromPersonUid ?? r.pairKey;
692
- console.log(
693
- chalk.dim(` Accept: hq dm accept ${idHint} · Decline: hq dm decline ${idHint}`),
694
- );
695
- }
696
- } catch (err) {
697
- console.error(
698
- chalk.red("Error:"),
699
- err instanceof Error ? err.message : String(err),
700
- );
701
- process.exit(1);
702
- }
703
- }
704
-
705
- // ---------------------------------------------------------------------------
706
- // Receive side — reading DMs (inbox, 1:1 thread, channel/group history).
707
- // The backend read endpoints (GET /v1/notify/inbox, GET /v1/notify/thread,
708
- // GET /v1/notify/channels/{id}/messages) already back the HQ Sync menubar; the
709
- // commands below expose the same reads from the CLI.
710
- // ---------------------------------------------------------------------------
711
-
712
- /** One incoming DM as returned by GET /v1/notify/inbox. */
713
- export interface DmInboxEvent {
714
- eventId: string;
715
- fromPersonUid?: string;
716
- fromEmail?: string;
717
- fromDisplayName?: string;
718
- body: string;
719
- createdAt: string;
720
- details?: string;
721
- prompt?: string;
722
- acknowledgedAt?: string;
723
- }
724
-
725
- /** One message in a 1:1 thread as returned by GET /v1/notify/thread. */
726
- export interface DmThreadMessage {
727
- eventId: string;
728
- fromPersonUid?: string;
729
- fromEmail?: string;
730
- fromDisplayName?: string;
731
- body: string;
732
- createdAt: string;
733
- direction: "in" | "out";
734
- details?: string;
735
- prompt?: string;
736
- }
737
-
738
- /** One channel/group message from GET /v1/notify/channels/{id}/messages. */
739
- export interface ChannelMessageItem {
740
- eventId?: string;
741
- messageId?: string;
742
- fromPersonUid?: string;
743
- fromEmail?: string;
744
- fromDisplayName?: string;
745
- body: string;
746
- createdAt: string;
747
- }
748
-
749
- /**
750
- * Human label for a message sender — display name, else email, else uid. Pure →
751
- * unit-testable.
752
- */
753
- export function senderLabel(m: {
754
- fromDisplayName?: string;
755
- fromEmail?: string;
756
- fromPersonUid?: string;
757
- }): string {
758
- return (
759
- m.fromDisplayName?.trim() ||
760
- m.fromEmail?.trim() ||
761
- m.fromPersonUid?.trim() ||
762
- "unknown"
763
- );
764
- }
765
-
766
- /**
767
- * Render an ISO timestamp as a compact relative age ("3m ago", "2h ago",
768
- * "5d ago"), falling back to a YYYY-MM-DD date for anything older than a week or
769
- * an unparseable input. `nowMs` is injected so the formatting is unit-testable.
770
- * Pure.
771
- */
772
- export function formatRelativeTime(iso: string, nowMs: number): string {
773
- const t = new Date(iso).getTime();
774
- if (isNaN(t)) return iso;
775
- const diff = nowMs - t;
776
- if (diff < 60_000) return "just now";
777
- const mins = Math.floor(diff / 60_000);
778
- if (mins < 60) return `${mins}m ago`;
779
- const hours = Math.floor(mins / 60);
780
- if (hours < 24) return `${hours}h ago`;
781
- const days = Math.floor(hours / 24);
782
- if (days < 7) return `${days}d ago`;
783
- return new Date(t).toISOString().slice(0, 10);
784
- }
785
-
786
- /** A DM is unread until the recipient acks it. Pure. */
787
- export function isUnread(e: { acknowledgedAt?: string }): boolean {
788
- return !e.acknowledgedAt;
789
- }
790
-
791
- /** Event ids of the unread messages in a fetched inbox page. Pure. */
792
- export function unreadEventIds(events: DmInboxEvent[]): string[] {
793
- return events
794
- .filter(isUnread)
795
- .map((e) => e.eventId)
796
- .filter((id): id is string => typeof id === "string" && id.length > 0);
797
- }
798
-
799
- /** Keep only the unread messages. Pure. */
800
- export function filterUnread(events: DmInboxEvent[]): DmInboxEvent[] {
801
- return events.filter(isUnread);
802
- }
803
-
804
- /**
805
- * Collapse a message body to a single trimmed line for list rendering, capped
806
- * so one row stays readable. Pure.
807
- */
808
- export function firstLine(body: string, max = 240): string {
809
- const oneLine = (body ?? "").replace(/\s+/g, " ").trim();
810
- if (oneLine.length <= max) return oneLine;
811
- return oneLine.slice(0, max - 1) + "…";
812
- }
813
-
814
- /**
815
- * Turn a person identifier into the query the thread endpoint expects. An email
816
- * rides `withEmail` (server resolves it); a prs_/agt_ uid rides `withPersonUid`.
817
- * A bare name is rejected — resolve it first with `hq people resolve`. Pure →
818
- * unit-testable. Throws with a user-facing message on an invalid identifier.
819
- */
820
- export function buildThreadQuery(identifier: string): Record<string, string> {
821
- const rcpt = detectRecipient(identifier);
822
- if (!rcpt) {
823
- throw new Error(
824
- `Invalid person '${identifier}': pass an email or a personUid/agentUid (prs_… / agt_…). Resolve a name first with \`hq people resolve\`.`,
825
- );
826
- }
827
- if (rcpt.toEmail) return { withEmail: rcpt.toEmail };
828
- return { withPersonUid: rcpt.toPersonUid! };
829
- }
830
-
831
- /** Render one inbox row (marker · age · sender · first line of body). */
832
- export function formatInboxEvent(e: DmInboxEvent, nowMs: number): string {
833
- const marker = isUnread(e) ? chalk.cyan("●") : " ";
834
- const when = chalk.dim(formatRelativeTime(e.createdAt, nowMs));
835
- const who = chalk.bold(senderLabel(e));
836
- const email =
837
- e.fromEmail && e.fromDisplayName ? chalk.dim(` <${e.fromEmail}>`) : "";
838
- return `${marker} ${when} ${who}${email}\n ${firstLine(e.body)}`;
839
- }
840
-
841
- /** Render one 1:1 thread line, tagged by direction. */
842
- export function formatThreadMessage(m: DmThreadMessage, nowMs: number): string {
843
- const arrow = m.direction === "out" ? chalk.dim("→") : chalk.cyan("←");
844
- const who = m.direction === "out" ? "you" : senderLabel(m);
845
- const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
846
- return `${arrow} ${chalk.bold(who)} ${when}\n ${firstLine(m.body)}`;
847
- }
848
-
849
- /** Render one channel/group message line. */
850
- export function formatChannelMessage(
851
- m: ChannelMessageItem,
852
- nowMs: number,
853
- ): string {
854
- const who = chalk.bold(senderLabel(m));
855
- const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
856
- return `${who} ${when}\n ${firstLine(m.body)}`;
857
- }
858
-
859
- /** POST /v1/notify/inbox/ack — idempotently mark messages read. */
860
- async function ackEvents(token: string, eventIds: string[]): Promise<void> {
861
- if (eventIds.length === 0) return;
862
- const res = await vaultApiFetch({
863
- token,
864
- path: "/v1/notify/inbox/ack",
865
- method: "POST",
866
- body: { eventIds },
867
- });
868
- if (!res.ok) {
869
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
870
- throw new Error(
871
- friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
872
- );
873
- }
874
- }
875
-
876
- interface DmInboxOpts {
877
- limit?: string;
878
- unread?: boolean;
879
- markRead?: boolean;
880
- json?: boolean;
881
- }
882
-
883
- async function runDmInbox(opts: DmInboxOpts): Promise<void> {
884
- try {
885
- const token = await ensureCognitoToken();
886
- const query: Record<string, string> = {};
887
- if (opts.limit) query.limit = opts.limit;
888
- const res = await vaultApiFetch({ token, path: "/v1/notify/inbox", query });
889
- if (!res.ok) {
890
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
891
- console.error(
892
- chalk.red(
893
- friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
894
- ),
895
- );
896
- process.exit(1);
897
- }
898
- const data = (await res.json()) as {
899
- events?: DmInboxEvent[];
900
- nextCursor?: string;
901
- };
902
- const all = data.events ?? [];
903
- const shown = opts.unread ? filterUnread(all) : all;
904
-
905
- if (opts.json) {
906
- console.log(JSON.stringify(shown, null, 2));
907
- } else if (shown.length === 0) {
908
- console.log(
909
- chalk.dim(opts.unread ? "No unread messages." : "No messages yet."),
910
- );
911
- } else {
912
- const unreadCount = filterUnread(all).length;
913
- const suffix = unreadCount > 0 ? ` (${unreadCount} unread)` : "";
914
- console.log(
915
- chalk.green(
916
- `${shown.length} message${shown.length === 1 ? "" : "s"}${suffix}:`,
917
- ),
918
- );
919
- const now = Date.now();
920
- for (const e of shown) console.log(`\n${formatInboxEvent(e, now)}`);
921
- if (data.nextCursor) {
922
- console.log(
923
- chalk.dim("\nMore messages available — raise --limit to see them."),
924
- );
925
- }
926
- }
927
-
928
- // Ack every unread message on the fetched page (not just the filtered view).
929
- if (opts.markRead) {
930
- const ids = unreadEventIds(all);
931
- await ackEvents(token, ids);
932
- if (!opts.json && ids.length > 0) {
933
- console.log(chalk.dim(`\nMarked ${ids.length} read.`));
934
- }
935
- }
936
- } catch (err) {
937
- console.error(
938
- chalk.red("Error:"),
939
- err instanceof Error ? err.message : String(err),
940
- );
941
- process.exit(1);
942
- }
943
- }
944
-
945
- interface DmThreadOpts {
946
- limit?: string;
947
- ack?: boolean;
948
- json?: boolean;
949
- }
950
-
951
- async function runDmThread(
952
- identifier: string,
953
- opts: DmThreadOpts,
954
- ): Promise<void> {
955
- try {
956
- const query = buildThreadQuery(identifier);
957
- if (opts.limit) query.limit = opts.limit;
958
- const token = await ensureCognitoToken();
959
- const res = await vaultApiFetch({
960
- token,
961
- path: "/v1/notify/thread",
962
- query,
963
- });
964
- if (!res.ok) {
965
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
966
- console.error(
967
- chalk.red(
968
- friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
969
- ),
970
- );
971
- process.exit(1);
972
- }
973
- const data = (await res.json()) as { messages?: DmThreadMessage[] };
974
- const messages = data.messages ?? [];
975
- // The server returns newest-first; read a conversation oldest-first.
976
- const ordered = [...messages].reverse();
977
-
978
- if (opts.json) {
979
- console.log(JSON.stringify(ordered, null, 2));
980
- } else if (ordered.length === 0) {
981
- console.log(chalk.dim(`No messages with ${identifier} yet.`));
982
- } else {
983
- console.log(
984
- chalk.green(
985
- `${ordered.length} message${ordered.length === 1 ? "" : "s"} with ${identifier}:`,
986
- ),
987
- );
988
- const now = Date.now();
989
- for (const m of ordered) console.log(`\n${formatThreadMessage(m, now)}`);
990
- }
991
-
992
- // Mark the incoming messages read unless the caller opted out. Best-effort:
993
- // a read is a side effect, not the point of the command, so an ack failure
994
- // is surfaced but does not fail the read.
995
- if (opts.ack !== false) {
996
- const inIds = messages
997
- .filter((m) => m.direction === "in")
998
- .map((m) => m.eventId)
999
- .filter((id): id is string => typeof id === "string" && id.length > 0);
1000
- try {
1001
- await ackEvents(token, inIds);
1002
- } catch (ackErr) {
1003
- console.error(
1004
- chalk.dim(
1005
- `(could not mark read: ${
1006
- ackErr instanceof Error ? ackErr.message : String(ackErr)
1007
- })`,
1008
- ),
1009
- );
1010
- }
1011
- }
1012
- } catch (err) {
1013
- console.error(
1014
- chalk.red("Error:"),
1015
- err instanceof Error ? err.message : String(err),
1016
- );
1017
- process.exit(1);
1018
- }
1019
- }
1020
-
1021
- /**
1022
- * Resolve a `hq dm channel <target>` argument to a channelId. Accepts a channel
1023
- * name (bare or `#name`) resolved against the caller's channels, or a raw
1024
- * channelId (the only way to address an unnamed group DM — copy it from
1025
- * `hq channels`). Throws a user-facing message when nothing matches or a name is
1026
- * ambiguous.
1027
- */
1028
- async function resolveChannelId(token: string, target: string): Promise<string> {
1029
- const raw = target.trim().replace(/^#/, "");
1030
- if (!raw) throw new Error("A channel name or id is required.");
1031
- const channels = await fetchChannels(token);
1032
- const named = matchChannelsByName(channels, raw);
1033
- if (named.length === 1) return named[0].channelId;
1034
- if (named.length > 1) {
1035
- throw new Error(
1036
- `'${raw}' matches ${named.length} channels — pass the channel id instead (see \`hq channels\`).`,
1037
- );
1038
- }
1039
- const byId = channels.find((c) => c.channelId === raw);
1040
- if (byId) return byId.channelId;
1041
- throw new Error(
1042
- `No channel named or with id '${raw}' — run \`hq channels\` to see yours.`,
1043
- );
1044
- }
1045
-
1046
- interface DmChannelOpts {
1047
- limit?: string;
1048
- markRead?: boolean;
1049
- json?: boolean;
1050
- }
1051
-
1052
- async function runDmChannel(
1053
- target: string,
1054
- opts: DmChannelOpts,
1055
- ): Promise<void> {
1056
- try {
1057
- const token = await ensureCognitoToken();
1058
- const channelId = await resolveChannelId(token, target);
1059
- const query: Record<string, string> = {};
1060
- if (opts.limit) query.limit = opts.limit;
1061
- const res = await vaultApiFetch({
1062
- token,
1063
- path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
1064
- query,
1065
- });
1066
- if (!res.ok) {
1067
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
1068
- console.error(
1069
- chalk.red(
1070
- friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
1071
- ),
1072
- );
1073
- process.exit(1);
1074
- }
1075
- const data = (await res.json()) as { messages?: ChannelMessageItem[] };
1076
- const messages = data.messages ?? [];
1077
- const ordered = [...messages].reverse(); // oldest-first for reading
1078
-
1079
- if (opts.json) {
1080
- console.log(JSON.stringify(ordered, null, 2));
1081
- } else if (ordered.length === 0) {
1082
- console.log(chalk.dim("No messages in this channel yet."));
1083
- } else {
1084
- console.log(
1085
- chalk.green(
1086
- `${ordered.length} message${ordered.length === 1 ? "" : "s"}:`,
1087
- ),
1088
- );
1089
- const now = Date.now();
1090
- for (const m of ordered) console.log(`\n${formatChannelMessage(m, now)}`);
1091
- }
1092
-
1093
- if (opts.markRead) {
1094
- // messages are newest-first from the server; advance the read cursor to
1095
- // the newest one we saw.
1096
- const newest = messages[0]?.createdAt;
1097
- const readRes = await vaultApiFetch({
1098
- token,
1099
- path: `/v1/notify/channels/${encodeURIComponent(channelId)}/read`,
1100
- method: "POST",
1101
- body: newest ? { lastReadAt: newest } : {},
1102
- });
1103
- if (!opts.json && readRes.ok) {
1104
- console.log(chalk.dim("\nMarked read."));
1105
- }
1106
- }
1107
- } catch (err) {
1108
- console.error(
1109
- chalk.red("Error:"),
1110
- err instanceof Error ? err.message : String(err),
1111
- );
1112
- process.exit(1);
1113
- }
1114
- }
1115
-
1116
- export function registerDmCommand(program: Command): void {
1117
- const dm = program
1118
- .command("dm")
1119
- .description(
1120
- "Send and read direct messages, and manage connection requests.",
1121
- );
1122
-
1123
- dm
1124
- .command("send [recipient] [message]", { isDefault: true, hidden: true })
1125
- .description(
1126
- 'Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.',
1127
- )
1128
- .option(
1129
- "--channel <name>",
1130
- "Post the message to one of your DM channels by name (e.g. --channel vyg-dev)",
1131
- )
1132
- .option(
1133
- "--prompt <text>",
1134
- "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)",
1135
- )
1136
- .option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs only)")
1137
- .option(
1138
- "--details <text>",
1139
- "Longer detail shown in the recipient's DM detail window (1:1 DMs only)",
1140
- )
1141
- .option("--details-file <path>", "Read the details from a file (1:1 DMs only)")
1142
- .option(
1143
- "--at <iso>",
1144
- "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)",
1145
- )
1146
- .option(
1147
- "--in <duration>",
1148
- "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)",
1149
- )
1150
- .action(
1151
- async (
1152
- recipient: string | undefined,
1153
- message: string | undefined,
1154
- opts: DmSendOpts,
1155
- ) => {
1156
- await runDmSend(recipient, message, opts);
1157
- },
1158
- );
1159
-
1160
- dm
1161
- .command("inbox")
1162
- .description("List your recent incoming direct messages.")
1163
- .option("--limit <n>", "Max messages to fetch (server-capped)")
1164
- .option("--unread", "Show only unread messages")
1165
- .option("--mark-read", "Mark the fetched messages as read after listing")
1166
- .option("--json", "Output raw JSON instead of a list")
1167
- .action(async (opts: DmInboxOpts) => {
1168
- await runDmInbox(opts);
1169
- });
1170
-
1171
- dm
1172
- .command("thread <person>")
1173
- .alias("read")
1174
- .description(
1175
- "Show your two-way conversation with a person (email, personUid, or agentUid). Reads oldest-first and marks their messages read unless --no-ack.",
1176
- )
1177
- .option("--limit <n>", "Max messages to fetch (server-capped)")
1178
- .option("--no-ack", "Do not mark the incoming messages as read")
1179
- .option("--json", "Output raw JSON instead of a transcript")
1180
- .action(async (person: string, opts: DmThreadOpts) => {
1181
- await runDmThread(person, opts);
1182
- });
1183
-
1184
- dm
1185
- .command("channel <target>")
1186
- .alias("history")
1187
- .description(
1188
- "Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`.",
1189
- )
1190
- .option("--limit <n>", "Max messages to fetch (server-capped)")
1191
- .option("--mark-read", "Advance your read marker to the newest message")
1192
- .option("--json", "Output raw JSON instead of a transcript")
1193
- .action(async (target: string, opts: DmChannelOpts) => {
1194
- await runDmChannel(target, opts);
1195
- });
1196
-
1197
- dm
1198
- .command("requests")
1199
- .description("List your pending incoming connection requests.")
1200
- .action(async () => {
1201
- await runDmRequests();
1202
- });
1203
-
1204
- dm
1205
- .command("accept <person>")
1206
- .description(
1207
- "Accept a pending connection request (by email, personUid, or pairKey). Their held message is then delivered.",
1208
- )
1209
- .action(async (person: string) => {
1210
- await runConnectionAction("accept", person);
1211
- });
1212
-
1213
- dm
1214
- .command("decline <person>")
1215
- .description("Decline a pending connection request (by email, personUid, or pairKey).")
1216
- .action(async (person: string) => {
1217
- await runConnectionAction("decline", person);
1218
- });
1219
-
1220
- dm
1221
- .command("block <person>")
1222
- .description("Block a person so they can't send you further requests (by email, personUid, or pairKey).")
1223
- .action(async (person: string) => {
1224
- await runConnectionAction("block", person);
1225
- });
1226
- }