@indigoai-us/hq-cli 5.58.1 → 5.59.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/dist/commands/channels.d.ts +16 -0
- package/dist/commands/channels.js +78 -0
- package/dist/commands/dm.d.ts +41 -0
- package/dist/commands/dm.js +159 -10
- package/dist/index.js +4 -2
- package/package.json +1 -1
- package/src/commands/channels.test.ts +74 -0
- package/src/commands/channels.ts +88 -0
- package/src/commands/dm.test.ts +129 -0
- package/src/commands/dm.ts +208 -10
- package/src/index.ts +2 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { type ChannelSummary } from "./dm.js";
|
|
3
|
+
/**
|
|
4
|
+
* Human label for a channel row. Named channels (personal/company) show their
|
|
5
|
+
* name + a `hq dm <slug>` hint; unnamed group DMs are identified by their
|
|
6
|
+
* members (caller excluded), matching how HQ Sync renders them. Pure →
|
|
7
|
+
* unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
export declare function describeChannel(c: ChannelSummary): string;
|
|
10
|
+
/**
|
|
11
|
+
* Render the caller's channels for `hq channels`. Pure (takes the already
|
|
12
|
+
* fetched list) so the formatting is unit-testable without network/auth.
|
|
13
|
+
*/
|
|
14
|
+
export declare function formatChannelsList(channels: ChannelSummary[]): string;
|
|
15
|
+
export declare function registerChannelsCommand(program: Command): void;
|
|
16
|
+
//# sourceMappingURL=channels.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="891a3265-c9ff-53d8-8a3c-2a964f5a74b6")}catch(e){}}();
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
6
|
+
import { channelSlug } from "./dm.js";
|
|
7
|
+
/**
|
|
8
|
+
* Human label for a channel row. Named channels (personal/company) show their
|
|
9
|
+
* name + a `hq dm <slug>` hint; unnamed group DMs are identified by their
|
|
10
|
+
* members (caller excluded), matching how HQ Sync renders them. Pure →
|
|
11
|
+
* unit-testable.
|
|
12
|
+
*/
|
|
13
|
+
export function describeChannel(c) {
|
|
14
|
+
if (c.scope === "group") {
|
|
15
|
+
const names = (c.members ?? [])
|
|
16
|
+
.map((m) => m.displayName)
|
|
17
|
+
.filter((n) => !!n);
|
|
18
|
+
const who = names.length > 0
|
|
19
|
+
? names.join(", ")
|
|
20
|
+
: `${c.memberCount ?? "?"}-person group`;
|
|
21
|
+
return `${who} ${chalk.dim("(group DM)")}`;
|
|
22
|
+
}
|
|
23
|
+
const slug = c.slug ?? (c.name ? channelSlug(c.name) : "");
|
|
24
|
+
const scopeTag = c.scope ? chalk.dim(`(${c.scope})`) : "";
|
|
25
|
+
const hint = slug ? chalk.dim(`— hq dm ${slug} "…"`) : "";
|
|
26
|
+
return `${chalk.bold(c.name ?? slug ?? c.channelId)} ${scopeTag} ${hint}`.trim();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Render the caller's channels for `hq channels`. Pure (takes the already
|
|
30
|
+
* fetched list) so the formatting is unit-testable without network/auth.
|
|
31
|
+
*/
|
|
32
|
+
export function formatChannelsList(channels) {
|
|
33
|
+
if (channels.length === 0) {
|
|
34
|
+
return chalk.dim("No channels yet. Group DMs and named channels you're in will appear here.");
|
|
35
|
+
}
|
|
36
|
+
const lines = [
|
|
37
|
+
chalk.green(`${channels.length} channel${channels.length === 1 ? "" : "s"}:`),
|
|
38
|
+
];
|
|
39
|
+
for (const c of channels) {
|
|
40
|
+
lines.push(` ${describeChannel(c)}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
async function runChannelsList() {
|
|
45
|
+
try {
|
|
46
|
+
const token = await ensureCognitoToken();
|
|
47
|
+
const res = await vaultApiFetch({ token, path: "/v1/notify/channels" });
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
if (res.status === 401) {
|
|
50
|
+
console.error(chalk.red("Not authenticated — run `hq login` and try again."));
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const err = (await res.json().catch(() => ({})));
|
|
54
|
+
console.error(chalk.red(`Could not list channels: ${err.error ?? err.message ?? res.statusText}`));
|
|
55
|
+
}
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
const data = (await res.json());
|
|
59
|
+
console.log(formatChannelsList(data.channels ?? []));
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function registerChannelsCommand(program) {
|
|
67
|
+
const channels = program
|
|
68
|
+
.command("channels")
|
|
69
|
+
.description("List the DM channels you're in (name them with `hq dm <name> \"…\"`).");
|
|
70
|
+
channels
|
|
71
|
+
.command("list", { isDefault: true })
|
|
72
|
+
.description("List your DM channels — named channels and group DMs.")
|
|
73
|
+
.action(async () => {
|
|
74
|
+
await runChannelsList();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=channels.js.map
|
|
78
|
+
//# debugId=891a3265-c9ff-53d8-8a3c-2a964f5a74b6
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -16,6 +16,47 @@ export declare function detectRecipient(recipient: string): DmRecipient | null;
|
|
|
16
16
|
* (the normal 1:1 path). Pure → unit-testable.
|
|
17
17
|
*/
|
|
18
18
|
export declare function parseGroupRecipients(recipient: string): string[] | null;
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a channel name into a stable slug for name matching. MIRRORS the
|
|
21
|
+
* server's `channelSlug` (hq-pro src/vault-service/lib/channels.ts): lowercase,
|
|
22
|
+
* collapse runs of non-alphanumerics to single hyphens, trim leading/trailing
|
|
23
|
+
* hyphens. Keep in lockstep with the server so `hq dm vyg-dev` matches the
|
|
24
|
+
* channel the server stored as slug `vyg-dev`. Pure → unit-testable.
|
|
25
|
+
*/
|
|
26
|
+
export declare function channelSlug(name: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Decide whether a positional recipient token addresses a DM CHANNEL by name
|
|
29
|
+
* (rather than a person/agent or a group). Returns the bare channel name to
|
|
30
|
+
* resolve, or null when the token is a person path (email / prs_ / agt_ uid) or
|
|
31
|
+
* a comma group. Pure → unit-testable.
|
|
32
|
+
*
|
|
33
|
+
* "#vyg-dev" → "vyg-dev" (explicit hash form)
|
|
34
|
+
* "vyg-dev" → "vyg-dev" (bare name — not an email/uid/group)
|
|
35
|
+
* "a@b.com" → null (person)
|
|
36
|
+
* "prs_…" → null (person) "agt_…" → null (agent)
|
|
37
|
+
* "a@x,b@y" → null (group DM)
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseChannelName(recipient: string): string | null;
|
|
40
|
+
/** Minimal channel shape the CLI reads back from GET /v1/notify/channels. */
|
|
41
|
+
export interface ChannelSummary {
|
|
42
|
+
channelId: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
slug?: string;
|
|
45
|
+
scope?: string;
|
|
46
|
+
memberCount?: number;
|
|
47
|
+
members?: {
|
|
48
|
+
personUid: string;
|
|
49
|
+
displayName?: string;
|
|
50
|
+
}[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Find the caller's channel(s) whose name matches `name`, by slug or
|
|
54
|
+
* case-insensitive display name. Group DMs are unnamed (participant-keyed), so
|
|
55
|
+
* they never match a name. Returns ALL matches so the caller can detect an
|
|
56
|
+
* ambiguous name (same slug across personal + company scope). Pure →
|
|
57
|
+
* unit-testable.
|
|
58
|
+
*/
|
|
59
|
+
export declare function matchChannelsByName(channels: ChannelSummary[], name: string): ChannelSummary[];
|
|
19
60
|
/**
|
|
20
61
|
* Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
|
|
21
62
|
* Returns null on anything that doesn't match. Pure → unit-testable.
|
package/dist/commands/dm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="e95f345b-0e15-52b3-a851-fc97da935a99")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
@@ -37,6 +37,72 @@ export function parseGroupRecipients(recipient) {
|
|
|
37
37
|
.filter(Boolean);
|
|
38
38
|
return [...new Set(parts)];
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Normalize a channel name into a stable slug for name matching. MIRRORS the
|
|
42
|
+
* server's `channelSlug` (hq-pro src/vault-service/lib/channels.ts): lowercase,
|
|
43
|
+
* collapse runs of non-alphanumerics to single hyphens, trim leading/trailing
|
|
44
|
+
* hyphens. Keep in lockstep with the server so `hq dm vyg-dev` matches the
|
|
45
|
+
* channel the server stored as slug `vyg-dev`. Pure → unit-testable.
|
|
46
|
+
*/
|
|
47
|
+
export function channelSlug(name) {
|
|
48
|
+
return name
|
|
49
|
+
.trim()
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
52
|
+
.replace(/^-+|-+$/g, "");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Decide whether a positional recipient token addresses a DM CHANNEL by name
|
|
56
|
+
* (rather than a person/agent or a group). Returns the bare channel name to
|
|
57
|
+
* resolve, or null when the token is a person path (email / prs_ / agt_ uid) or
|
|
58
|
+
* a comma group. Pure → unit-testable.
|
|
59
|
+
*
|
|
60
|
+
* "#vyg-dev" → "vyg-dev" (explicit hash form)
|
|
61
|
+
* "vyg-dev" → "vyg-dev" (bare name — not an email/uid/group)
|
|
62
|
+
* "a@b.com" → null (person)
|
|
63
|
+
* "prs_…" → null (person) "agt_…" → null (agent)
|
|
64
|
+
* "a@x,b@y" → null (group DM)
|
|
65
|
+
*/
|
|
66
|
+
export function parseChannelName(recipient) {
|
|
67
|
+
const r = recipient.trim();
|
|
68
|
+
if (!r)
|
|
69
|
+
return null;
|
|
70
|
+
if (r.startsWith("#")) {
|
|
71
|
+
const name = r.slice(1).trim();
|
|
72
|
+
return name || null;
|
|
73
|
+
}
|
|
74
|
+
// A comma is the group-DM signal; emails and prs_/agt_ uids are person paths.
|
|
75
|
+
if (r.includes(","))
|
|
76
|
+
return null;
|
|
77
|
+
if (EMAIL_PATTERN.test(r))
|
|
78
|
+
return null;
|
|
79
|
+
if (RECIPIENT_UID_PATTERN.test(r))
|
|
80
|
+
return null;
|
|
81
|
+
return r;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Find the caller's channel(s) whose name matches `name`, by slug or
|
|
85
|
+
* case-insensitive display name. Group DMs are unnamed (participant-keyed), so
|
|
86
|
+
* they never match a name. Returns ALL matches so the caller can detect an
|
|
87
|
+
* ambiguous name (same slug across personal + company scope). Pure →
|
|
88
|
+
* unit-testable.
|
|
89
|
+
*/
|
|
90
|
+
export function matchChannelsByName(channels, name) {
|
|
91
|
+
const targetSlug = channelSlug(name);
|
|
92
|
+
const targetName = name.trim().toLowerCase();
|
|
93
|
+
if (!targetSlug && !targetName)
|
|
94
|
+
return [];
|
|
95
|
+
return channels.filter((c) => {
|
|
96
|
+
if (c.scope === "group")
|
|
97
|
+
return false;
|
|
98
|
+
const slug = c.slug ?? (c.name ? channelSlug(c.name) : "");
|
|
99
|
+
if (slug && slug === targetSlug)
|
|
100
|
+
return true;
|
|
101
|
+
if (c.name && c.name.trim().toLowerCase() === targetName)
|
|
102
|
+
return true;
|
|
103
|
+
return false;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
40
106
|
/**
|
|
41
107
|
* Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
|
|
42
108
|
* Returns null on anything that doesn't match. Pure → unit-testable.
|
|
@@ -266,13 +332,95 @@ async function runGroupSend(recipients, message) {
|
|
|
266
332
|
process.exit(1);
|
|
267
333
|
}
|
|
268
334
|
}
|
|
335
|
+
/** Fetch the caller's channels (GET /v1/notify/channels). */
|
|
336
|
+
async function fetchChannels(token) {
|
|
337
|
+
const res = await vaultApiFetch({ token, path: "/v1/notify/channels" });
|
|
338
|
+
if (!res.ok) {
|
|
339
|
+
const err = (await res.json().catch(() => ({})));
|
|
340
|
+
throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
|
|
341
|
+
}
|
|
342
|
+
const data = (await res.json());
|
|
343
|
+
return data.channels ?? [];
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Channel DM path: `hq dm vyg-dev "msg"`, `hq dm '#vyg-dev' "msg"`, or
|
|
347
|
+
* `hq dm --channel vyg-dev "msg"`. Resolves the caller's channel by name via
|
|
348
|
+
* GET /v1/notify/channels, then posts the message into it. Scheduling /
|
|
349
|
+
* prompt / details are 1:1-DM features and are rejected here rather than
|
|
350
|
+
* silently dropped.
|
|
351
|
+
*/
|
|
352
|
+
async function runChannelSend(channelName, message, opts) {
|
|
353
|
+
try {
|
|
354
|
+
const body = (message ?? "").trim();
|
|
355
|
+
if (!body) {
|
|
356
|
+
console.error(chalk.red(`A message body is required: hq dm ${channelName} "<message>" (or hq dm --channel ${channelName} "<message>").`));
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
const unsupported = [
|
|
360
|
+
opts.prompt || opts.promptFile ? "--prompt/--prompt-file" : null,
|
|
361
|
+
opts.details || opts.detailsFile ? "--details/--details-file" : null,
|
|
362
|
+
opts.at ? "--at" : null,
|
|
363
|
+
opts.in ? "--in" : null,
|
|
364
|
+
].filter(Boolean);
|
|
365
|
+
if (unsupported.length > 0) {
|
|
366
|
+
console.error(chalk.red(`${unsupported.join(", ")} ${unsupported.length === 1 ? "is" : "are"} only supported for 1:1 DMs, not channel messages.`));
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
const token = await ensureCognitoToken();
|
|
370
|
+
const channels = await fetchChannels(token);
|
|
371
|
+
const matches = matchChannelsByName(channels, channelName);
|
|
372
|
+
if (matches.length === 0) {
|
|
373
|
+
console.error(chalk.red(`No channel named '${channelName}' — run \`hq channels\` to see your channels.`));
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
if (matches.length > 1) {
|
|
377
|
+
const scopes = matches.map((m) => m.scope ?? "?").join(", ");
|
|
378
|
+
console.error(chalk.red(`'${channelName}' matches ${matches.length} channels (${scopes}) — this is ambiguous. Open the channel in HQ Sync to post, or rename one.`));
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
const channel = matches[0];
|
|
382
|
+
const sendRes = await vaultApiFetch({
|
|
383
|
+
token,
|
|
384
|
+
path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`,
|
|
385
|
+
method: "POST",
|
|
386
|
+
body: { body },
|
|
387
|
+
});
|
|
388
|
+
if (!sendRes.ok) {
|
|
389
|
+
const err = (await sendRes.json().catch(() => ({})));
|
|
390
|
+
console.error(chalk.red(friendlyDmError(sendRes.status, err.code, err.error ?? err.message ?? sendRes.statusText)));
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
console.log(chalk.green(`Message posted to #${channel.name ?? channelName}.`));
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
269
400
|
async function runDmSend(recipient, message, opts) {
|
|
401
|
+
// --channel <name> is an explicit channel target; the positional carries the
|
|
402
|
+
// message (recipient slot), e.g. `hq dm --channel vyg-dev "hello"`.
|
|
403
|
+
if (opts.channel !== undefined) {
|
|
404
|
+
await runChannelSend(opts.channel, message ?? recipient, opts);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (recipient === undefined) {
|
|
408
|
+
console.error(chalk.red('A recipient is required: hq dm <email|personUid|#channel> "<message>" (or --channel <name>).'));
|
|
409
|
+
process.exit(1);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
270
412
|
// A comma in the recipient means a group DM — fan into the channel path.
|
|
271
413
|
const group = parseGroupRecipients(recipient);
|
|
272
414
|
if (group) {
|
|
273
415
|
await runGroupSend(group, message);
|
|
274
416
|
return;
|
|
275
417
|
}
|
|
418
|
+
// A bare name or #hash addresses a named DM channel.
|
|
419
|
+
const channelName = parseChannelName(recipient);
|
|
420
|
+
if (channelName !== null) {
|
|
421
|
+
await runChannelSend(channelName, message, opts);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
276
424
|
try {
|
|
277
425
|
// Resolve prompt/details from inline text or a file.
|
|
278
426
|
let prompt = opts.prompt;
|
|
@@ -354,14 +502,15 @@ export function registerDmCommand(program) {
|
|
|
354
502
|
.command("dm")
|
|
355
503
|
.description("Send a direct message and manage connection requests.");
|
|
356
504
|
dm
|
|
357
|
-
.command("send
|
|
358
|
-
.description('Send a direct message
|
|
359
|
-
.option("--
|
|
360
|
-
.option("--prompt
|
|
361
|
-
.option("--
|
|
362
|
-
.option("--details
|
|
363
|
-
.option("--
|
|
364
|
-
.option("--
|
|
505
|
+
.command("send [recipient] [message]", { isDefault: true, hidden: true })
|
|
506
|
+
.description('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`.')
|
|
507
|
+
.option("--channel <name>", "Post the message to one of your DM channels by name (e.g. --channel vyg-dev)")
|
|
508
|
+
.option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)")
|
|
509
|
+
.option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs only)")
|
|
510
|
+
.option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs only)")
|
|
511
|
+
.option("--details-file <path>", "Read the details from a file (1:1 DMs only)")
|
|
512
|
+
.option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)")
|
|
513
|
+
.option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)")
|
|
365
514
|
.action(async (recipient, message, opts) => {
|
|
366
515
|
await runDmSend(recipient, message, opts);
|
|
367
516
|
});
|
|
@@ -391,4 +540,4 @@ export function registerDmCommand(program) {
|
|
|
391
540
|
});
|
|
392
541
|
}
|
|
393
542
|
//# sourceMappingURL=dm.js.map
|
|
394
|
-
//# debugId=
|
|
543
|
+
//# debugId=e95f345b-0e15-52b3-a851-fc97da935a99
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// MUST be first: guard the Node version before any dependency that needs a
|
|
6
6
|
// Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
|
|
7
7
|
|
|
8
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
8
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="8839ee7d-9f6d-54fe-8f90-99ad858e349e")}catch(e){}}();
|
|
9
9
|
import "./node-preflight.js";
|
|
10
10
|
import { Command } from "commander";
|
|
11
11
|
import { initSentry, Sentry } from "./sentry.js";
|
|
@@ -41,6 +41,7 @@ import { registerFilesBrowseCommands } from "./commands/files-browse.js";
|
|
|
41
41
|
import { registerMembersCommand } from "./commands/members.js";
|
|
42
42
|
import { registerPeopleCommand } from "./commands/people.js";
|
|
43
43
|
import { registerDmCommand } from "./commands/dm.js";
|
|
44
|
+
import { registerChannelsCommand } from "./commands/channels.js";
|
|
44
45
|
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
45
46
|
import { registerMeetingsCommand } from "./commands/meetings.js";
|
|
46
47
|
import { registerSourcesCommand } from "./commands/sources.js";
|
|
@@ -152,6 +153,7 @@ registerMembersCommand(program);
|
|
|
152
153
|
// the local companies/<co>/people store scoped to one company.
|
|
153
154
|
registerPeopleCommand(program);
|
|
154
155
|
registerDmCommand(program);
|
|
156
|
+
registerChannelsCommand(program);
|
|
155
157
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|
|
156
158
|
registerOnboardCommand(program);
|
|
157
159
|
// Feedback (subcommand group — hq feedback bug|feature)
|
|
@@ -242,4 +244,4 @@ registerCompanyCommand(program);
|
|
|
242
244
|
}
|
|
243
245
|
})();
|
|
244
246
|
//# sourceMappingURL=index.js.map
|
|
245
|
-
//# debugId=
|
|
247
|
+
//# debugId=8839ee7d-9f6d-54fe-8f90-99ad858e349e
|
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it, beforeAll } from "vitest";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { describeChannel, formatChannelsList } from "./channels.js";
|
|
4
|
+
import type { ChannelSummary } from "./dm.js";
|
|
5
|
+
|
|
6
|
+
// Disable chalk's ANSI colouring so assertions read the plain rendered text
|
|
7
|
+
// (chalk is a singleton, so this also affects the module under test).
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
chalk.level = 0;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("describeChannel", () => {
|
|
13
|
+
it("shows a named channel with a `hq dm <slug>` hint", () => {
|
|
14
|
+
const c: ChannelSummary = {
|
|
15
|
+
channelId: "chn_v",
|
|
16
|
+
name: "VYG Dev",
|
|
17
|
+
slug: "vyg-dev",
|
|
18
|
+
scope: "company",
|
|
19
|
+
};
|
|
20
|
+
const out = describeChannel(c);
|
|
21
|
+
expect(out).toContain("VYG Dev");
|
|
22
|
+
expect(out).toContain("(company)");
|
|
23
|
+
expect(out).toContain('hq dm vyg-dev "…"');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("identifies an unnamed group DM by its members", () => {
|
|
27
|
+
const c: ChannelSummary = {
|
|
28
|
+
channelId: "chn_grp",
|
|
29
|
+
scope: "group",
|
|
30
|
+
memberCount: 3,
|
|
31
|
+
members: [
|
|
32
|
+
{ personUid: "prs_a", displayName: "Stefan" },
|
|
33
|
+
{ personUid: "prs_b", displayName: "Hassaan" },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
const out = describeChannel(c);
|
|
37
|
+
expect(out).toContain("Stefan, Hassaan");
|
|
38
|
+
expect(out).toContain("(group DM)");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("falls back to a member count when a group has no resolved names", () => {
|
|
42
|
+
const c: ChannelSummary = { channelId: "g", scope: "group", memberCount: 4 };
|
|
43
|
+
expect(describeChannel(c)).toContain("4-person group");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("formatChannelsList", () => {
|
|
48
|
+
it("renders an empty-state line when there are no channels", () => {
|
|
49
|
+
expect(formatChannelsList([])).toMatch(/No channels yet/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("headers with the count and lists each channel", () => {
|
|
53
|
+
const channels: ChannelSummary[] = [
|
|
54
|
+
{ channelId: "chn_v", name: "VYG Dev", slug: "vyg-dev", scope: "company" },
|
|
55
|
+
{
|
|
56
|
+
channelId: "chn_grp",
|
|
57
|
+
scope: "group",
|
|
58
|
+
memberCount: 2,
|
|
59
|
+
members: [{ personUid: "prs_a", displayName: "Stefan" }],
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
const out = formatChannelsList(channels);
|
|
63
|
+
expect(out).toMatch(/2 channels:/);
|
|
64
|
+
expect(out).toContain("VYG Dev");
|
|
65
|
+
expect(out).toContain("Stefan");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("uses the singular 'channel' for one", () => {
|
|
69
|
+
const out = formatChannelsList([
|
|
70
|
+
{ channelId: "chn_v", name: "VYG Dev", slug: "vyg-dev", scope: "company" },
|
|
71
|
+
]);
|
|
72
|
+
expect(out).toMatch(/1 channel:/);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
4
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
5
|
+
import { channelSlug, type ChannelSummary } from "./dm.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Human label for a channel row. Named channels (personal/company) show their
|
|
9
|
+
* name + a `hq dm <slug>` hint; unnamed group DMs are identified by their
|
|
10
|
+
* members (caller excluded), matching how HQ Sync renders them. Pure →
|
|
11
|
+
* unit-testable.
|
|
12
|
+
*/
|
|
13
|
+
export function describeChannel(c: ChannelSummary): string {
|
|
14
|
+
if (c.scope === "group") {
|
|
15
|
+
const names = (c.members ?? [])
|
|
16
|
+
.map((m) => m.displayName)
|
|
17
|
+
.filter((n): n is string => !!n);
|
|
18
|
+
const who =
|
|
19
|
+
names.length > 0
|
|
20
|
+
? names.join(", ")
|
|
21
|
+
: `${c.memberCount ?? "?"}-person group`;
|
|
22
|
+
return `${who} ${chalk.dim("(group DM)")}`;
|
|
23
|
+
}
|
|
24
|
+
const slug = c.slug ?? (c.name ? channelSlug(c.name) : "");
|
|
25
|
+
const scopeTag = c.scope ? chalk.dim(`(${c.scope})`) : "";
|
|
26
|
+
const hint = slug ? chalk.dim(`— hq dm ${slug} "…"`) : "";
|
|
27
|
+
return `${chalk.bold(c.name ?? slug ?? c.channelId)} ${scopeTag} ${hint}`.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render the caller's channels for `hq channels`. Pure (takes the already
|
|
32
|
+
* fetched list) so the formatting is unit-testable without network/auth.
|
|
33
|
+
*/
|
|
34
|
+
export function formatChannelsList(channels: ChannelSummary[]): string {
|
|
35
|
+
if (channels.length === 0) {
|
|
36
|
+
return chalk.dim(
|
|
37
|
+
"No channels yet. Group DMs and named channels you're in will appear here.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const lines = [
|
|
41
|
+
chalk.green(
|
|
42
|
+
`${channels.length} channel${channels.length === 1 ? "" : "s"}:`,
|
|
43
|
+
),
|
|
44
|
+
];
|
|
45
|
+
for (const c of channels) {
|
|
46
|
+
lines.push(` ${describeChannel(c)}`);
|
|
47
|
+
}
|
|
48
|
+
return lines.join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function runChannelsList(): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
const token = await ensureCognitoToken();
|
|
54
|
+
const res = await vaultApiFetch({ token, path: "/v1/notify/channels" });
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
if (res.status === 401) {
|
|
57
|
+
console.error(chalk.red("Not authenticated — run `hq login` and try again."));
|
|
58
|
+
} else {
|
|
59
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
60
|
+
console.error(
|
|
61
|
+
chalk.red(`Could not list channels: ${err.error ?? err.message ?? res.statusText}`),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
const data = (await res.json()) as { channels?: ChannelSummary[] };
|
|
67
|
+
console.log(formatChannelsList(data.channels ?? []));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(
|
|
70
|
+
chalk.red("Error:"),
|
|
71
|
+
err instanceof Error ? err.message : String(err),
|
|
72
|
+
);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function registerChannelsCommand(program: Command): void {
|
|
78
|
+
const channels = program
|
|
79
|
+
.command("channels")
|
|
80
|
+
.description("List the DM channels you're in (name them with `hq dm <name> \"…\"`).");
|
|
81
|
+
|
|
82
|
+
channels
|
|
83
|
+
.command("list", { isDefault: true })
|
|
84
|
+
.description("List your DM channels — named channels and group DMs.")
|
|
85
|
+
.action(async () => {
|
|
86
|
+
await runChannelsList();
|
|
87
|
+
});
|
|
88
|
+
}
|
package/src/commands/dm.test.ts
CHANGED
|
@@ -26,7 +26,11 @@ import {
|
|
|
26
26
|
matchRequest,
|
|
27
27
|
buildConnectionActionBody,
|
|
28
28
|
registerDmCommand,
|
|
29
|
+
channelSlug,
|
|
30
|
+
parseChannelName,
|
|
31
|
+
matchChannelsByName,
|
|
29
32
|
type ConnectionRequest,
|
|
33
|
+
type ChannelSummary,
|
|
30
34
|
} from "./dm.js";
|
|
31
35
|
|
|
32
36
|
describe("parseGroupRecipients", () => {
|
|
@@ -42,6 +46,59 @@ describe("parseGroupRecipients", () => {
|
|
|
42
46
|
});
|
|
43
47
|
});
|
|
44
48
|
|
|
49
|
+
describe("channelSlug", () => {
|
|
50
|
+
it("mirrors the server: lowercases, hyphenates, trims", () => {
|
|
51
|
+
expect(channelSlug("vyg-dev")).toBe("vyg-dev");
|
|
52
|
+
expect(channelSlug("VYG Dev!!")).toBe("vyg-dev");
|
|
53
|
+
expect(channelSlug(" --Team Room-- ")).toBe("team-room");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("parseChannelName", () => {
|
|
58
|
+
it("treats a bare non-email/uid token as a channel name", () => {
|
|
59
|
+
expect(parseChannelName("vyg-dev")).toBe("vyg-dev");
|
|
60
|
+
expect(parseChannelName(" design ")).toBe("design");
|
|
61
|
+
});
|
|
62
|
+
it("strips a leading # (hash-channel form)", () => {
|
|
63
|
+
expect(parseChannelName("#vyg-dev")).toBe("vyg-dev");
|
|
64
|
+
expect(parseChannelName("#")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
it("returns null for person paths and group lists", () => {
|
|
67
|
+
expect(parseChannelName("a@b.com")).toBeNull();
|
|
68
|
+
expect(parseChannelName("prs_01ABC")).toBeNull();
|
|
69
|
+
expect(parseChannelName("agt_01ABC")).toBeNull();
|
|
70
|
+
expect(parseChannelName("a@x.com,b@y.com")).toBeNull();
|
|
71
|
+
expect(parseChannelName("")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("matchChannelsByName", () => {
|
|
76
|
+
const channels: ChannelSummary[] = [
|
|
77
|
+
{ channelId: "chn_1", name: "VYG Dev", slug: "vyg-dev", scope: "company" },
|
|
78
|
+
{ channelId: "chn_2", name: "Design", slug: "design", scope: "personal" },
|
|
79
|
+
{ channelId: "chn_grp", scope: "group", memberCount: 2 },
|
|
80
|
+
];
|
|
81
|
+
it("matches a named channel by slug (case/punctuation-insensitive)", () => {
|
|
82
|
+
expect(matchChannelsByName(channels, "vyg-dev").map((c) => c.channelId)).toEqual([
|
|
83
|
+
"chn_1",
|
|
84
|
+
]);
|
|
85
|
+
expect(matchChannelsByName(channels, "VYG Dev").map((c) => c.channelId)).toEqual([
|
|
86
|
+
"chn_1",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
it("never matches an unnamed group DM", () => {
|
|
90
|
+
expect(matchChannelsByName(channels, "chn_grp")).toEqual([]);
|
|
91
|
+
expect(matchChannelsByName(channels, "")).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
it("returns every match so an ambiguous name can be detected", () => {
|
|
94
|
+
const dup: ChannelSummary[] = [
|
|
95
|
+
{ channelId: "a", name: "ops", slug: "ops", scope: "personal" },
|
|
96
|
+
{ channelId: "b", name: "Ops", slug: "ops", scope: "company" },
|
|
97
|
+
];
|
|
98
|
+
expect(matchChannelsByName(dup, "ops").map((c) => c.channelId)).toEqual(["a", "b"]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
45
102
|
describe("detectRecipient", () => {
|
|
46
103
|
it("classifies an email", () => {
|
|
47
104
|
expect(detectRecipient("Stefan@Getindigo.ai")).toEqual({
|
|
@@ -277,6 +334,78 @@ describe("dm command actions", () => {
|
|
|
277
334
|
expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/dm");
|
|
278
335
|
});
|
|
279
336
|
|
|
337
|
+
const channelsPayload = {
|
|
338
|
+
channels: [
|
|
339
|
+
{ channelId: "chn_v", name: "VYG Dev", slug: "vyg-dev", scope: "company" },
|
|
340
|
+
{ channelId: "chn_grp", scope: "group", memberCount: 2 },
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
it("channel send (bare name): resolves the channel then posts the message", async () => {
|
|
345
|
+
fetchSpy
|
|
346
|
+
.mockResolvedValueOnce(jsonResponse(200, channelsPayload)) // GET channels
|
|
347
|
+
.mockResolvedValueOnce(jsonResponse(200, { eventId: "evt_c" })); // POST message
|
|
348
|
+
await program.parseAsync(["dm", "vyg-dev", "hello channel"], { from: "user" });
|
|
349
|
+
// 1st call lists the caller's channels...
|
|
350
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/channels");
|
|
351
|
+
// ...2nd posts into the resolved channel id.
|
|
352
|
+
const sendCall = fetchSpy.mock.calls[1];
|
|
353
|
+
expect(String(sendCall[0])).toContain("/v1/notify/channels/chn_v/messages");
|
|
354
|
+
expect(JSON.parse((sendCall[1]?.body as string) ?? "{}").body).toBe("hello channel");
|
|
355
|
+
expect(logged()).toMatch(/Message posted to #VYG Dev\./);
|
|
356
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("channel send (#hash form) resolves the same channel", async () => {
|
|
360
|
+
fetchSpy
|
|
361
|
+
.mockResolvedValueOnce(jsonResponse(200, channelsPayload))
|
|
362
|
+
.mockResolvedValueOnce(jsonResponse(200, { eventId: "evt_c" }));
|
|
363
|
+
await program.parseAsync(["dm", "#vyg-dev", "yo"], { from: "user" });
|
|
364
|
+
expect(String(fetchSpy.mock.calls[1][0])).toContain("/v1/notify/channels/chn_v/messages");
|
|
365
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("channel send (--channel flag) takes the message from the positional", async () => {
|
|
369
|
+
fetchSpy
|
|
370
|
+
.mockResolvedValueOnce(jsonResponse(200, channelsPayload))
|
|
371
|
+
.mockResolvedValueOnce(jsonResponse(200, { eventId: "evt_c" }));
|
|
372
|
+
await program.parseAsync(["dm", "--channel", "vyg-dev", "flagged hello"], {
|
|
373
|
+
from: "user",
|
|
374
|
+
});
|
|
375
|
+
const sendCall = fetchSpy.mock.calls[1];
|
|
376
|
+
expect(String(sendCall[0])).toContain("/v1/notify/channels/chn_v/messages");
|
|
377
|
+
expect(JSON.parse((sendCall[1]?.body as string) ?? "{}").body).toBe("flagged hello");
|
|
378
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("channel send: unknown name errors and never posts", async () => {
|
|
382
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, channelsPayload));
|
|
383
|
+
await program
|
|
384
|
+
.parseAsync(["dm", "nope-channel", "hi"], { from: "user" })
|
|
385
|
+
.catch(() => undefined); // process.exit is mocked to throw
|
|
386
|
+
expect(errSpy.mock.calls.map((c) => String(c[1] ?? c[0])).join("\n")).toMatch(
|
|
387
|
+
/No channel named 'nope-channel'/,
|
|
388
|
+
);
|
|
389
|
+
// Only the GET happened — no message POST.
|
|
390
|
+
expect(fetchSpy.mock.calls).toHaveLength(1);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("channel send: an ambiguous name errors and never posts", async () => {
|
|
394
|
+
fetchSpy.mockResolvedValueOnce(
|
|
395
|
+
jsonResponse(200, {
|
|
396
|
+
channels: [
|
|
397
|
+
{ channelId: "a", name: "ops", slug: "ops", scope: "personal" },
|
|
398
|
+
{ channelId: "b", name: "Ops", slug: "ops", scope: "company" },
|
|
399
|
+
],
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
await program
|
|
403
|
+
.parseAsync(["dm", "ops", "hi"], { from: "user" })
|
|
404
|
+
.catch(() => undefined);
|
|
405
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toMatch(/ambiguous/);
|
|
406
|
+
expect(fetchSpy.mock.calls).toHaveLength(1);
|
|
407
|
+
});
|
|
408
|
+
|
|
280
409
|
it("send: prints pending request on 202 connection_requested (not an error)", async () => {
|
|
281
410
|
fetchSpy.mockResolvedValueOnce(
|
|
282
411
|
jsonResponse(202, { state: "connection_requested" }),
|
package/src/commands/dm.ts
CHANGED
|
@@ -42,6 +42,80 @@ export function parseGroupRecipients(recipient: string): string[] | null {
|
|
|
42
42
|
return [...new Set(parts)];
|
|
43
43
|
}
|
|
44
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
|
+
|
|
45
119
|
/**
|
|
46
120
|
* Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
|
|
47
121
|
* Returns null on anything that doesn't match. Pure → unit-testable.
|
|
@@ -265,6 +339,7 @@ interface DmSendOpts {
|
|
|
265
339
|
detailsFile?: string;
|
|
266
340
|
at?: string;
|
|
267
341
|
in?: string;
|
|
342
|
+
channel?: string;
|
|
268
343
|
}
|
|
269
344
|
|
|
270
345
|
/**
|
|
@@ -384,17 +459,136 @@ async function runGroupSend(
|
|
|
384
459
|
}
|
|
385
460
|
}
|
|
386
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
|
+
|
|
387
560
|
async function runDmSend(
|
|
388
|
-
recipient: string,
|
|
561
|
+
recipient: string | undefined,
|
|
389
562
|
message: string | undefined,
|
|
390
563
|
opts: DmSendOpts,
|
|
391
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
|
+
}
|
|
392
580
|
// A comma in the recipient means a group DM — fan into the channel path.
|
|
393
581
|
const group = parseGroupRecipients(recipient);
|
|
394
582
|
if (group) {
|
|
395
583
|
await runGroupSend(group, message);
|
|
396
584
|
return;
|
|
397
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
|
+
}
|
|
398
592
|
try {
|
|
399
593
|
// Resolve prompt/details from inline text or a file.
|
|
400
594
|
let prompt = opts.prompt;
|
|
@@ -516,31 +710,35 @@ export function registerDmCommand(program: Command): void {
|
|
|
516
710
|
);
|
|
517
711
|
|
|
518
712
|
dm
|
|
519
|
-
.command("send
|
|
713
|
+
.command("send [recipient] [message]", { isDefault: true, hidden: true })
|
|
520
714
|
.description(
|
|
521
|
-
'Send a direct message
|
|
715
|
+
'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`.',
|
|
716
|
+
)
|
|
717
|
+
.option(
|
|
718
|
+
"--channel <name>",
|
|
719
|
+
"Post the message to one of your DM channels by name (e.g. --channel vyg-dev)",
|
|
522
720
|
)
|
|
523
721
|
.option(
|
|
524
722
|
"--prompt <text>",
|
|
525
|
-
"Agent-context prompt the recipient can one-click copy into their agent",
|
|
723
|
+
"Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)",
|
|
526
724
|
)
|
|
527
|
-
.option("--prompt-file <path>", "Read the agent prompt from a file")
|
|
725
|
+
.option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs only)")
|
|
528
726
|
.option(
|
|
529
727
|
"--details <text>",
|
|
530
|
-
"Longer detail shown in the recipient's DM detail window",
|
|
728
|
+
"Longer detail shown in the recipient's DM detail window (1:1 DMs only)",
|
|
531
729
|
)
|
|
532
|
-
.option("--details-file <path>", "Read the details from a file")
|
|
730
|
+
.option("--details-file <path>", "Read the details from a file (1:1 DMs only)")
|
|
533
731
|
.option(
|
|
534
732
|
"--at <iso>",
|
|
535
|
-
"Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time)",
|
|
733
|
+
"Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)",
|
|
536
734
|
)
|
|
537
735
|
.option(
|
|
538
736
|
"--in <duration>",
|
|
539
|
-
"Schedule delivery after a relative delay: 30s, 10m, 2h, 1d",
|
|
737
|
+
"Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)",
|
|
540
738
|
)
|
|
541
739
|
.action(
|
|
542
740
|
async (
|
|
543
|
-
recipient: string,
|
|
741
|
+
recipient: string | undefined,
|
|
544
742
|
message: string | undefined,
|
|
545
743
|
opts: DmSendOpts,
|
|
546
744
|
) => {
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { registerFilesBrowseCommands } from "./commands/files-browse.js";
|
|
|
41
41
|
import { registerMembersCommand } from "./commands/members.js";
|
|
42
42
|
import { registerPeopleCommand } from "./commands/people.js";
|
|
43
43
|
import { registerDmCommand } from "./commands/dm.js";
|
|
44
|
+
import { registerChannelsCommand } from "./commands/channels.js";
|
|
44
45
|
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
45
46
|
import { registerMeetingsCommand } from "./commands/meetings.js";
|
|
46
47
|
import { registerSourcesCommand } from "./commands/sources.js";
|
|
@@ -184,6 +185,7 @@ registerMembersCommand(program);
|
|
|
184
185
|
// the local companies/<co>/people store scoped to one company.
|
|
185
186
|
registerPeopleCommand(program);
|
|
186
187
|
registerDmCommand(program);
|
|
188
|
+
registerChannelsCommand(program);
|
|
187
189
|
|
|
188
190
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|
|
189
191
|
registerOnboardCommand(program);
|