@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.
- package/CHANGELOG.md +23 -0
- package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
- package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
- package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
- package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
- package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
- package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
- package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
- package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
- package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
- package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
- package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
- package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
- package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
- package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
- package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
- package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
- package/assets/scaffold/core/scripts/worktree.sh +136 -0
- package/dist/commands/agents.js +28 -12
- package/dist/commands/api-keys.js +100 -20
- package/dist/commands/core.d.ts +90 -0
- package/dist/commands/core.js +279 -0
- package/dist/commands/files-browse.d.ts +21 -0
- package/dist/commands/files-browse.js +145 -24
- package/dist/commands/files.js +33 -7
- package/dist/commands/members.js +16 -5
- package/dist/commands/pack-install.d.ts +14 -0
- package/dist/commands/pack-install.js +42 -2
- package/dist/commands/pkg-install.js +39 -2
- package/dist/commands/run.js +6 -2
- package/dist/commands/secrets.js +120 -65
- package/dist/main.js +6 -0
- package/dist/utils/api-key-command-gate.d.ts +45 -0
- package/dist/utils/api-key-command-gate.js +51 -0
- package/dist/utils/hq-roots.d.ts +91 -0
- package/dist/utils/hq-roots.js +212 -0
- package/dist/utils/resolve-vault-credential.d.ts +63 -3
- package/dist/utils/resolve-vault-credential.js +152 -6
- package/dist/utils/run-bundled-script.d.ts +66 -0
- package/dist/utils/run-bundled-script.js +80 -0
- package/dist/utils/vault-api.d.ts +21 -0
- package/dist/utils/vault-api.js +74 -3
- package/package.json +7 -1
- package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
- package/.github/workflows/ci.yml +0 -46
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
- package/e2e/cli.test.ts +0 -128
- package/eslint.config.js +0 -37
- package/pnpm-workspace.yaml +0 -4
- package/scripts/chmod-bins.mjs +0 -49
- package/scripts/generate-dsn.mjs +0 -26
- package/scripts/generate-scan-packages-table.mjs +0 -113
- package/scripts/smoke-sources-signals.sh +0 -103
- package/src/bin/hq-auth-refresh.ts +0 -43
- package/src/cli-version.ts +0 -13
- package/src/commands/__fixtures__/make-tar.ts +0 -126
- package/src/commands/_patterns.ts +0 -10
- package/src/commands/add.ts +0 -75
- package/src/commands/agents.test.ts +0 -527
- package/src/commands/agents.ts +0 -923
- package/src/commands/api-keys.test.ts +0 -291
- package/src/commands/api-keys.ts +0 -382
- package/src/commands/artifact-verify.test.ts +0 -177
- package/src/commands/auth.test.ts +0 -82
- package/src/commands/auth.ts +0 -213
- package/src/commands/billing.test.ts +0 -158
- package/src/commands/billing.ts +0 -146
- package/src/commands/channels.test.ts +0 -74
- package/src/commands/channels.ts +0 -91
- package/src/commands/cloud-demote.test.ts +0 -401
- package/src/commands/cloud-demote.ts +0 -278
- package/src/commands/cloud-provision.test.ts +0 -1117
- package/src/commands/cloud-provision.ts +0 -942
- package/src/commands/cloud.pull-all.test.ts +0 -711
- package/src/commands/cloud.pull-per-company.test.ts +0 -188
- package/src/commands/cloud.push-all.test.ts +0 -485
- package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
- package/src/commands/cloud.selectors.test.ts +0 -208
- package/src/commands/cloud.status.test.ts +0 -160
- package/src/commands/cloud.test.ts +0 -41
- package/src/commands/cloud.ts +0 -2130
- package/src/commands/company.test.ts +0 -202
- package/src/commands/company.ts +0 -148
- package/src/commands/creators.test.ts +0 -60
- package/src/commands/creators.ts +0 -117
- package/src/commands/crm.test.ts +0 -179
- package/src/commands/crm.ts +0 -236
- package/src/commands/db-migrate.ts +0 -55
- package/src/commands/db-provision.ts +0 -114
- package/src/commands/db-sql.ts +0 -124
- package/src/commands/db-status.ts +0 -108
- package/src/commands/db.ts +0 -26
- package/src/commands/dm.test.ts +0 -748
- package/src/commands/dm.ts +0 -1226
- package/src/commands/feedback.test.ts +0 -413
- package/src/commands/feedback.ts +0 -169
- package/src/commands/files-browse.test.ts +0 -1249
- package/src/commands/files-browse.ts +0 -1506
- package/src/commands/files-delete.test.ts +0 -484
- package/src/commands/files-recovery.test.ts +0 -370
- package/src/commands/files.test.ts +0 -722
- package/src/commands/files.ts +0 -1446
- package/src/commands/group-grants.test.ts +0 -330
- package/src/commands/group-grants.ts +0 -455
- package/src/commands/groups.test.ts +0 -44
- package/src/commands/groups.ts +0 -368
- package/src/commands/integrations.test.ts +0 -633
- package/src/commands/integrations.ts +0 -507
- package/src/commands/list.ts +0 -85
- package/src/commands/login.ts +0 -52
- package/src/commands/logout.ts +0 -30
- package/src/commands/marketplace-install.test.ts +0 -543
- package/src/commands/marketplace-security.test.ts +0 -646
- package/src/commands/mcp-registration.test.ts +0 -2787
- package/src/commands/mcp-registration.ts +0 -2612
- package/src/commands/mcp-status.test.ts +0 -483
- package/src/commands/mcp-status.ts +0 -575
- package/src/commands/mcp-status.us011.test.ts +0 -243
- package/src/commands/meetings.test.ts +0 -522
- package/src/commands/meetings.ts +0 -789
- package/src/commands/members.test.ts +0 -1474
- package/src/commands/members.ts +0 -1181
- package/src/commands/onboard-identity-guard.test.ts +0 -99
- package/src/commands/onboard-identity-guard.ts +0 -103
- package/src/commands/onboard-join.test.ts +0 -96
- package/src/commands/onboard-join.ts +0 -101
- package/src/commands/onboard-warning.test.ts +0 -26
- package/src/commands/onboard-warning.ts +0 -12
- package/src/commands/onboard.ts +0 -267
- package/src/commands/outposts-heartbeat.test.ts +0 -299
- package/src/commands/outposts-heartbeat.ts +0 -310
- package/src/commands/outposts-replica-sync.test.ts +0 -272
- package/src/commands/outposts-self-deploy.test.ts +0 -243
- package/src/commands/outposts.test.ts +0 -1039
- package/src/commands/outposts.ts +0 -1935
- package/src/commands/pack-install-secret-authorization.test.ts +0 -115
- package/src/commands/pack-install.test.ts +0 -1469
- package/src/commands/pack-install.ts +0 -2256
- package/src/commands/pack-update-cache.test.ts +0 -149
- package/src/commands/packs-update-api-key.test.ts +0 -105
- package/src/commands/packs.test.ts +0 -88
- package/src/commands/packs.ts +0 -622
- package/src/commands/people.test.ts +0 -807
- package/src/commands/people.ts +0 -422
- package/src/commands/pkg-install.dispatch.test.ts +0 -103
- package/src/commands/pkg-install.ts +0 -221
- package/src/commands/pkg-list.test.ts +0 -79
- package/src/commands/pkg-list.ts +0 -13
- package/src/commands/pkg-remove.ts +0 -71
- package/src/commands/pkg-update.ts +0 -189
- package/src/commands/publish.test.ts +0 -650
- package/src/commands/publish.ts +0 -580
- package/src/commands/reindex.test.ts +0 -259
- package/src/commands/reindex.ts +0 -274
- package/src/commands/rescue.test.ts +0 -39
- package/src/commands/rescue.ts +0 -210
- package/src/commands/run.env-local.test.ts +0 -88
- package/src/commands/run.test.ts +0 -45
- package/src/commands/run.ts +0 -190
- package/src/commands/safe-extract.test.ts +0 -459
- package/src/commands/safe-extract.ts +0 -444
- package/src/commands/secrets-scope.test.ts +0 -56
- package/src/commands/secrets-scope.ts +0 -32
- package/src/commands/secrets.parse-destination.test.ts +0 -38
- package/src/commands/secrets.test.ts +0 -2050
- package/src/commands/secrets.ts +0 -2249
- package/src/commands/signals.ts +0 -351
- package/src/commands/skill.test.ts +0 -770
- package/src/commands/skill.ts +0 -796
- package/src/commands/sources.ts +0 -362
- package/src/commands/sync-mode.test.ts +0 -366
- package/src/commands/sync-mode.ts +0 -387
- package/src/commands/sync-narrow.test.ts +0 -633
- package/src/commands/sync-narrow.ts +0 -555
- package/src/commands/sync.ts +0 -165
- package/src/commands/team-sync.ts +0 -628
- package/src/commands/update.ts +0 -71
- package/src/commands/whoami.ts +0 -83
- package/src/commands/workers.test.ts +0 -158
- package/src/commands/workers.ts +0 -301
- package/src/index.test.ts +0 -32
- package/src/index.ts +0 -20
- package/src/lib/db/control-plane.test.ts +0 -59
- package/src/lib/db/control-plane.ts +0 -113
- package/src/lib/db/local.test.ts +0 -81
- package/src/lib/db/local.ts +0 -148
- package/src/lib/db/migrate.test.ts +0 -133
- package/src/lib/db/migrate.ts +0 -137
- package/src/lib/db/paths.test.ts +0 -112
- package/src/lib/db/paths.ts +0 -128
- package/src/lib/db/remote-engine.test.ts +0 -44
- package/src/lib/db/remote-engine.ts +0 -148
- package/src/lib/db/remote-sql.test.ts +0 -32
- package/src/lib/db/remote-sql.ts +0 -62
- package/src/lib/db/sql.test.ts +0 -106
- package/src/lib/db/sql.ts +0 -192
- package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
- package/src/lib/local-tree-diff.test.ts +0 -301
- package/src/lib/local-tree-diff.ts +0 -362
- package/src/lib/narrow-hint-banner.test.ts +0 -235
- package/src/lib/narrow-hint-banner.ts +0 -212
- package/src/lib/plan-limit-nag.test.ts +0 -317
- package/src/lib/plan-limit-nag.ts +0 -264
- package/src/main.ts +0 -371
- package/src/node-preflight.test.ts +0 -60
- package/src/node-preflight.ts +0 -67
- package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
- package/src/outpost/session-heartbeat-guard.test.ts +0 -105
- package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
- package/src/outpost/session-heartbeat-publisher.ts +0 -186
- package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
- package/src/outpost/session-heartbeat.test.ts +0 -459
- package/src/outpost/session-heartbeat.ts +0 -877
- package/src/packaging.test.ts +0 -109
- package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
- package/src/run/discover-schemas.test.ts +0 -153
- package/src/run/discover-schemas.ts +0 -79
- package/src/run/hq-plugin.test.ts +0 -371
- package/src/run/hq-plugin.ts +0 -277
- package/src/run/varlock-shape.test.ts +0 -57
- package/src/schemas/hq-package.schema.json +0 -155
- package/src/sentry-before-send.test.ts +0 -359
- package/src/sentry-before-send.ts +0 -221
- package/src/sentry-epipe.test.ts +0 -37
- package/src/sentry-release.test.ts +0 -54
- package/src/sentry.test.ts +0 -24
- package/src/sentry.ts +0 -54
- package/src/strategies/link.ts +0 -62
- package/src/strategies/merge.ts +0 -142
- package/src/types.ts +0 -146
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
- package/src/utils/auth-error.test.ts +0 -40
- package/src/utils/auth-error.ts +0 -42
- package/src/utils/billing-gate.test.ts +0 -95
- package/src/utils/billing-gate.ts +0 -228
- package/src/utils/breadcrumb-buffer.ts +0 -18
- package/src/utils/cli-telemetry.test.ts +0 -153
- package/src/utils/cli-telemetry.ts +0 -61
- package/src/utils/cognito-session.machine.test.ts +0 -109
- package/src/utils/cognito-session.refresh.test.ts +0 -84
- package/src/utils/cognito-session.test.ts +0 -328
- package/src/utils/cognito-session.ts +0 -448
- package/src/utils/company-selection-error.test.ts +0 -42
- package/src/utils/company-selection-error.ts +0 -45
- package/src/utils/contribution-table.ts +0 -83
- package/src/utils/environmental-error.test.ts +0 -45
- package/src/utils/environmental-error.ts +0 -39
- package/src/utils/epipe.test.ts +0 -28
- package/src/utils/epipe.ts +0 -29
- package/src/utils/expected-cli-error.test.ts +0 -28
- package/src/utils/expected-cli-error.ts +0 -39
- package/src/utils/feedback-diagnostics.test.ts +0 -183
- package/src/utils/feedback-diagnostics.ts +0 -123
- package/src/utils/feedback-screenshots.test.ts +0 -134
- package/src/utils/feedback-screenshots.ts +0 -124
- package/src/utils/feedback-versions.test.ts +0 -98
- package/src/utils/feedback-versions.ts +0 -68
- package/src/utils/git.ts +0 -74
- package/src/utils/integrity.ts +0 -59
- package/src/utils/intercepted-process-exit.test.ts +0 -37
- package/src/utils/intercepted-process-exit.ts +0 -36
- package/src/utils/login-provider.test.ts +0 -42
- package/src/utils/login-provider.ts +0 -30
- package/src/utils/manifest.test.ts +0 -167
- package/src/utils/manifest.ts +0 -131
- package/src/utils/pack-contributions.test.ts +0 -603
- package/src/utils/pack-contributions.ts +0 -450
- package/src/utils/people.ts +0 -215
- package/src/utils/registry-client.ts +0 -204
- package/src/utils/registry.ts +0 -90
- package/src/utils/resolve-vault-credential.test.ts +0 -69
- package/src/utils/resolve-vault-credential.ts +0 -60
- package/src/utils/sandbox-runner-client.test.ts +0 -279
- package/src/utils/sandbox-runner-client.ts +0 -260
- package/src/utils/secrets-cache.ts +0 -183
- package/src/utils/sentry-identity.ts +0 -45
- package/src/utils/settle-with-timeout.test.ts +0 -21
- package/src/utils/settle-with-timeout.ts +0 -22
- package/src/utils/user-yaml-error.test.ts +0 -24
- package/src/utils/user-yaml-error.ts +0 -30
- package/src/utils/vault-api.test.ts +0 -536
- package/src/utils/vault-api.ts +0 -382
- package/src/utils/version-check.test.ts +0 -176
- package/src/utils/version-check.ts +0 -155
- package/src/utils/version-gate.test.ts +0 -1024
- package/src/utils/version-gate.ts +0 -702
- package/test/commands/db-tenant-isolation.test.ts +0 -94
- package/test/commands/db.test.ts +0 -85
- package/test/commands/signals.test.ts +0 -207
- package/test/commands/sources.test.ts +0 -231
- package/test/e2e/smoke-install-mcp.sh +0 -113
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
- package/test/fixtures/signals/action_item/sample.md +0 -16
- package/test/fixtures/signals/summary/sample.md +0 -12
- package/test/fixtures/sources/meetings/sample.md +0 -25
- package/test/helpers/cli-runner.ts +0 -150
- package/test/helpers/s3-list-mock.ts +0 -79
- package/test/helpers/vault-service-mock.ts +0 -295
- package/test/sources-signals/smoke.test.ts +0 -228
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -11
- package/vitest.e2e.config.ts +0 -7
package/src/commands/dm.ts
DELETED
|
@@ -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
|
-
}
|