@indigoai-us/hq-cli 5.77.14 → 5.79.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 (309) hide show
  1. package/CHANGELOG.md +31 -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/meetings.js +16 -9
  31. package/dist/commands/members.js +16 -5
  32. package/dist/commands/pack-install.d.ts +14 -0
  33. package/dist/commands/pack-install.js +42 -2
  34. package/dist/commands/pkg-install.js +39 -2
  35. package/dist/commands/run.js +6 -2
  36. package/dist/commands/secrets.js +120 -65
  37. package/dist/main.js +6 -0
  38. package/dist/utils/api-key-command-gate.d.ts +45 -0
  39. package/dist/utils/api-key-command-gate.js +51 -0
  40. package/dist/utils/hq-roots.d.ts +91 -0
  41. package/dist/utils/hq-roots.js +212 -0
  42. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  43. package/dist/utils/resolve-vault-credential.js +152 -6
  44. package/dist/utils/run-bundled-script.d.ts +66 -0
  45. package/dist/utils/run-bundled-script.js +80 -0
  46. package/dist/utils/vault-api.d.ts +21 -0
  47. package/dist/utils/vault-api.js +74 -3
  48. package/package.json +7 -1
  49. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  50. package/.github/workflows/ci.yml +0 -46
  51. package/.github/workflows/publish.yml +0 -36
  52. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  53. package/e2e/cli.test.ts +0 -128
  54. package/eslint.config.js +0 -37
  55. package/pnpm-workspace.yaml +0 -4
  56. package/scripts/chmod-bins.mjs +0 -49
  57. package/scripts/generate-dsn.mjs +0 -26
  58. package/scripts/generate-scan-packages-table.mjs +0 -113
  59. package/scripts/smoke-sources-signals.sh +0 -103
  60. package/src/bin/hq-auth-refresh.ts +0 -43
  61. package/src/cli-version.ts +0 -13
  62. package/src/commands/__fixtures__/make-tar.ts +0 -126
  63. package/src/commands/_patterns.ts +0 -10
  64. package/src/commands/add.ts +0 -75
  65. package/src/commands/agents.test.ts +0 -527
  66. package/src/commands/agents.ts +0 -923
  67. package/src/commands/api-keys.test.ts +0 -291
  68. package/src/commands/api-keys.ts +0 -382
  69. package/src/commands/artifact-verify.test.ts +0 -177
  70. package/src/commands/auth.test.ts +0 -82
  71. package/src/commands/auth.ts +0 -213
  72. package/src/commands/billing.test.ts +0 -158
  73. package/src/commands/billing.ts +0 -146
  74. package/src/commands/channels.test.ts +0 -74
  75. package/src/commands/channels.ts +0 -91
  76. package/src/commands/cloud-demote.test.ts +0 -401
  77. package/src/commands/cloud-demote.ts +0 -278
  78. package/src/commands/cloud-provision.test.ts +0 -1117
  79. package/src/commands/cloud-provision.ts +0 -942
  80. package/src/commands/cloud.pull-all.test.ts +0 -711
  81. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  82. package/src/commands/cloud.push-all.test.ts +0 -485
  83. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  84. package/src/commands/cloud.selectors.test.ts +0 -208
  85. package/src/commands/cloud.status.test.ts +0 -160
  86. package/src/commands/cloud.test.ts +0 -41
  87. package/src/commands/cloud.ts +0 -2130
  88. package/src/commands/company.test.ts +0 -202
  89. package/src/commands/company.ts +0 -148
  90. package/src/commands/creators.test.ts +0 -60
  91. package/src/commands/creators.ts +0 -117
  92. package/src/commands/crm.test.ts +0 -179
  93. package/src/commands/crm.ts +0 -236
  94. package/src/commands/db-migrate.ts +0 -55
  95. package/src/commands/db-provision.ts +0 -114
  96. package/src/commands/db-sql.ts +0 -124
  97. package/src/commands/db-status.ts +0 -108
  98. package/src/commands/db.ts +0 -26
  99. package/src/commands/dm.test.ts +0 -748
  100. package/src/commands/dm.ts +0 -1226
  101. package/src/commands/feedback.test.ts +0 -413
  102. package/src/commands/feedback.ts +0 -169
  103. package/src/commands/files-browse.test.ts +0 -1249
  104. package/src/commands/files-browse.ts +0 -1506
  105. package/src/commands/files-delete.test.ts +0 -484
  106. package/src/commands/files-recovery.test.ts +0 -370
  107. package/src/commands/files.test.ts +0 -722
  108. package/src/commands/files.ts +0 -1446
  109. package/src/commands/group-grants.test.ts +0 -330
  110. package/src/commands/group-grants.ts +0 -455
  111. package/src/commands/groups.test.ts +0 -44
  112. package/src/commands/groups.ts +0 -368
  113. package/src/commands/integrations.test.ts +0 -633
  114. package/src/commands/integrations.ts +0 -507
  115. package/src/commands/list.ts +0 -85
  116. package/src/commands/login.ts +0 -52
  117. package/src/commands/logout.ts +0 -30
  118. package/src/commands/marketplace-install.test.ts +0 -543
  119. package/src/commands/marketplace-security.test.ts +0 -646
  120. package/src/commands/mcp-registration.test.ts +0 -2787
  121. package/src/commands/mcp-registration.ts +0 -2612
  122. package/src/commands/mcp-status.test.ts +0 -483
  123. package/src/commands/mcp-status.ts +0 -575
  124. package/src/commands/mcp-status.us011.test.ts +0 -243
  125. package/src/commands/meetings.test.ts +0 -522
  126. package/src/commands/meetings.ts +0 -789
  127. package/src/commands/members.test.ts +0 -1474
  128. package/src/commands/members.ts +0 -1181
  129. package/src/commands/onboard-identity-guard.test.ts +0 -99
  130. package/src/commands/onboard-identity-guard.ts +0 -103
  131. package/src/commands/onboard-join.test.ts +0 -96
  132. package/src/commands/onboard-join.ts +0 -101
  133. package/src/commands/onboard-warning.test.ts +0 -26
  134. package/src/commands/onboard-warning.ts +0 -12
  135. package/src/commands/onboard.ts +0 -267
  136. package/src/commands/outposts-heartbeat.test.ts +0 -299
  137. package/src/commands/outposts-heartbeat.ts +0 -310
  138. package/src/commands/outposts-replica-sync.test.ts +0 -272
  139. package/src/commands/outposts-self-deploy.test.ts +0 -243
  140. package/src/commands/outposts.test.ts +0 -1039
  141. package/src/commands/outposts.ts +0 -1935
  142. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  143. package/src/commands/pack-install.test.ts +0 -1469
  144. package/src/commands/pack-install.ts +0 -2256
  145. package/src/commands/pack-update-cache.test.ts +0 -149
  146. package/src/commands/packs-update-api-key.test.ts +0 -105
  147. package/src/commands/packs.test.ts +0 -88
  148. package/src/commands/packs.ts +0 -622
  149. package/src/commands/people.test.ts +0 -807
  150. package/src/commands/people.ts +0 -422
  151. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  152. package/src/commands/pkg-install.ts +0 -221
  153. package/src/commands/pkg-list.test.ts +0 -79
  154. package/src/commands/pkg-list.ts +0 -13
  155. package/src/commands/pkg-remove.ts +0 -71
  156. package/src/commands/pkg-update.ts +0 -189
  157. package/src/commands/publish.test.ts +0 -650
  158. package/src/commands/publish.ts +0 -580
  159. package/src/commands/reindex.test.ts +0 -259
  160. package/src/commands/reindex.ts +0 -274
  161. package/src/commands/rescue.test.ts +0 -39
  162. package/src/commands/rescue.ts +0 -210
  163. package/src/commands/run.env-local.test.ts +0 -88
  164. package/src/commands/run.test.ts +0 -45
  165. package/src/commands/run.ts +0 -190
  166. package/src/commands/safe-extract.test.ts +0 -459
  167. package/src/commands/safe-extract.ts +0 -444
  168. package/src/commands/secrets-scope.test.ts +0 -56
  169. package/src/commands/secrets-scope.ts +0 -32
  170. package/src/commands/secrets.parse-destination.test.ts +0 -38
  171. package/src/commands/secrets.test.ts +0 -2050
  172. package/src/commands/secrets.ts +0 -2249
  173. package/src/commands/signals.ts +0 -351
  174. package/src/commands/skill.test.ts +0 -770
  175. package/src/commands/skill.ts +0 -796
  176. package/src/commands/sources.ts +0 -362
  177. package/src/commands/sync-mode.test.ts +0 -366
  178. package/src/commands/sync-mode.ts +0 -387
  179. package/src/commands/sync-narrow.test.ts +0 -633
  180. package/src/commands/sync-narrow.ts +0 -555
  181. package/src/commands/sync.ts +0 -165
  182. package/src/commands/team-sync.ts +0 -628
  183. package/src/commands/update.ts +0 -71
  184. package/src/commands/whoami.ts +0 -83
  185. package/src/commands/workers.test.ts +0 -158
  186. package/src/commands/workers.ts +0 -301
  187. package/src/index.test.ts +0 -32
  188. package/src/index.ts +0 -20
  189. package/src/lib/db/control-plane.test.ts +0 -59
  190. package/src/lib/db/control-plane.ts +0 -113
  191. package/src/lib/db/local.test.ts +0 -81
  192. package/src/lib/db/local.ts +0 -148
  193. package/src/lib/db/migrate.test.ts +0 -133
  194. package/src/lib/db/migrate.ts +0 -137
  195. package/src/lib/db/paths.test.ts +0 -112
  196. package/src/lib/db/paths.ts +0 -128
  197. package/src/lib/db/remote-engine.test.ts +0 -44
  198. package/src/lib/db/remote-engine.ts +0 -148
  199. package/src/lib/db/remote-sql.test.ts +0 -32
  200. package/src/lib/db/remote-sql.ts +0 -62
  201. package/src/lib/db/sql.test.ts +0 -106
  202. package/src/lib/db/sql.ts +0 -192
  203. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  204. package/src/lib/local-tree-diff.test.ts +0 -301
  205. package/src/lib/local-tree-diff.ts +0 -362
  206. package/src/lib/narrow-hint-banner.test.ts +0 -235
  207. package/src/lib/narrow-hint-banner.ts +0 -212
  208. package/src/lib/plan-limit-nag.test.ts +0 -317
  209. package/src/lib/plan-limit-nag.ts +0 -264
  210. package/src/main.ts +0 -371
  211. package/src/node-preflight.test.ts +0 -60
  212. package/src/node-preflight.ts +0 -67
  213. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  214. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  215. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  216. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  217. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  218. package/src/outpost/session-heartbeat.test.ts +0 -459
  219. package/src/outpost/session-heartbeat.ts +0 -877
  220. package/src/packaging.test.ts +0 -109
  221. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  222. package/src/run/discover-schemas.test.ts +0 -153
  223. package/src/run/discover-schemas.ts +0 -79
  224. package/src/run/hq-plugin.test.ts +0 -371
  225. package/src/run/hq-plugin.ts +0 -277
  226. package/src/run/varlock-shape.test.ts +0 -57
  227. package/src/schemas/hq-package.schema.json +0 -155
  228. package/src/sentry-before-send.test.ts +0 -359
  229. package/src/sentry-before-send.ts +0 -221
  230. package/src/sentry-epipe.test.ts +0 -37
  231. package/src/sentry-release.test.ts +0 -54
  232. package/src/sentry.test.ts +0 -24
  233. package/src/sentry.ts +0 -54
  234. package/src/strategies/link.ts +0 -62
  235. package/src/strategies/merge.ts +0 -142
  236. package/src/types.ts +0 -146
  237. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  238. package/src/utils/auth-error.test.ts +0 -40
  239. package/src/utils/auth-error.ts +0 -42
  240. package/src/utils/billing-gate.test.ts +0 -95
  241. package/src/utils/billing-gate.ts +0 -228
  242. package/src/utils/breadcrumb-buffer.ts +0 -18
  243. package/src/utils/cli-telemetry.test.ts +0 -153
  244. package/src/utils/cli-telemetry.ts +0 -61
  245. package/src/utils/cognito-session.machine.test.ts +0 -109
  246. package/src/utils/cognito-session.refresh.test.ts +0 -84
  247. package/src/utils/cognito-session.test.ts +0 -328
  248. package/src/utils/cognito-session.ts +0 -448
  249. package/src/utils/company-selection-error.test.ts +0 -42
  250. package/src/utils/company-selection-error.ts +0 -45
  251. package/src/utils/contribution-table.ts +0 -83
  252. package/src/utils/environmental-error.test.ts +0 -45
  253. package/src/utils/environmental-error.ts +0 -39
  254. package/src/utils/epipe.test.ts +0 -28
  255. package/src/utils/epipe.ts +0 -29
  256. package/src/utils/expected-cli-error.test.ts +0 -28
  257. package/src/utils/expected-cli-error.ts +0 -39
  258. package/src/utils/feedback-diagnostics.test.ts +0 -183
  259. package/src/utils/feedback-diagnostics.ts +0 -123
  260. package/src/utils/feedback-screenshots.test.ts +0 -134
  261. package/src/utils/feedback-screenshots.ts +0 -124
  262. package/src/utils/feedback-versions.test.ts +0 -98
  263. package/src/utils/feedback-versions.ts +0 -68
  264. package/src/utils/git.ts +0 -74
  265. package/src/utils/integrity.ts +0 -59
  266. package/src/utils/intercepted-process-exit.test.ts +0 -37
  267. package/src/utils/intercepted-process-exit.ts +0 -36
  268. package/src/utils/login-provider.test.ts +0 -42
  269. package/src/utils/login-provider.ts +0 -30
  270. package/src/utils/manifest.test.ts +0 -167
  271. package/src/utils/manifest.ts +0 -131
  272. package/src/utils/pack-contributions.test.ts +0 -603
  273. package/src/utils/pack-contributions.ts +0 -450
  274. package/src/utils/people.ts +0 -215
  275. package/src/utils/registry-client.ts +0 -204
  276. package/src/utils/registry.ts +0 -90
  277. package/src/utils/resolve-vault-credential.test.ts +0 -69
  278. package/src/utils/resolve-vault-credential.ts +0 -60
  279. package/src/utils/sandbox-runner-client.test.ts +0 -279
  280. package/src/utils/sandbox-runner-client.ts +0 -260
  281. package/src/utils/secrets-cache.ts +0 -183
  282. package/src/utils/sentry-identity.ts +0 -45
  283. package/src/utils/settle-with-timeout.test.ts +0 -21
  284. package/src/utils/settle-with-timeout.ts +0 -22
  285. package/src/utils/user-yaml-error.test.ts +0 -24
  286. package/src/utils/user-yaml-error.ts +0 -30
  287. package/src/utils/vault-api.test.ts +0 -536
  288. package/src/utils/vault-api.ts +0 -382
  289. package/src/utils/version-check.test.ts +0 -176
  290. package/src/utils/version-check.ts +0 -155
  291. package/src/utils/version-gate.test.ts +0 -1024
  292. package/src/utils/version-gate.ts +0 -702
  293. package/test/commands/db-tenant-isolation.test.ts +0 -94
  294. package/test/commands/db.test.ts +0 -85
  295. package/test/commands/signals.test.ts +0 -207
  296. package/test/commands/sources.test.ts +0 -231
  297. package/test/e2e/smoke-install-mcp.sh +0 -113
  298. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  299. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  300. package/test/fixtures/signals/action_item/sample.md +0 -16
  301. package/test/fixtures/signals/summary/sample.md +0 -12
  302. package/test/fixtures/sources/meetings/sample.md +0 -25
  303. package/test/helpers/cli-runner.ts +0 -150
  304. package/test/helpers/s3-list-mock.ts +0 -79
  305. package/test/helpers/vault-service-mock.ts +0 -295
  306. package/test/sources-signals/smoke.test.ts +0 -228
  307. package/tsconfig.json +0 -20
  308. package/vitest.config.ts +0 -11
  309. 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
- }