@indigoai-us/hq-cli 5.39.2 → 5.40.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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.39.3]
6
+
7
+ ### Fixed
8
+
9
+ - **`hq members invite` no longer errors out on an existing member (HQ-11).**
10
+ Inviting (or `--resend`-ing) an email that is already an active member returns
11
+ `409 MEMBERSHIP_ALREADY_EXISTS`; the command used to print a red error and exit
12
+ `1`, so a re-run just re-fired the server's (correctly captured) 409 and a
13
+ bulk-invite script aborted on an already-member. It now treats that 409 as a
14
+ terminal "already a member" outcome — a plain note, exit `0`, no retry. The
15
+ 409 stays the server's correct signal; only the client's needless re-attempt
16
+ is removed. Sibling to the hq-console fix.
17
+
5
18
  ## [5.39.0]
6
19
 
7
20
  ### Added
@@ -14,9 +14,9 @@
14
14
  * by the deploy + sync skills.
15
15
  */
16
16
 
17
- !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]="e8f8cd77-2f24-5e71-8cb6-518095482b22")}catch(e){}}();
17
+ !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]="b9690c95-1d23-5c58-8cb3-0e3bdc8b35e8")}catch(e){}}();
18
18
  import chalk from "chalk";
19
- import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, CognitoAuthError, } from "@indigoai-us/hq-cloud";
19
+ import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
20
20
  import { refreshCachedSession, } from "../utils/cognito-session.js";
21
21
  import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
22
22
  /**
@@ -32,12 +32,22 @@ function peekIdToken(idToken) {
32
32
  const pad = payload.length % 4 === 0 ? "" : "=".repeat(4 - (payload.length % 4));
33
33
  const normalized = payload.replace(/-/g, "+").replace(/_/g, "/") + pad;
34
34
  const decoded = JSON.parse(Buffer.from(normalized, "base64").toString("utf-8"));
35
- return { email: decoded.email, sub: decoded.sub };
35
+ return {
36
+ email: decoded.email,
37
+ sub: decoded.sub,
38
+ entityType: decoded["custom:entityType"],
39
+ entityUid: decoded["custom:entityUid"],
40
+ };
36
41
  }
37
42
  catch {
38
43
  return {};
39
44
  }
40
45
  }
46
+ /** Display label for the local machine identity (company agents). */
47
+ function machineIdentityLabel() {
48
+ const creds = loadMachineCreds();
49
+ return creds ? `machine identity ${creds.username}` : "machine identity";
50
+ }
41
51
  export function registerAuthCommands(program) {
42
52
  const authCmd = program
43
53
  .command("auth")
@@ -47,6 +57,10 @@ export function registerAuthCommands(program) {
47
57
  .description("Sign in to HQ — opens the Cognito Hosted UI and caches tokens locally")
48
58
  .option("--provider <provider>", "OAuth provider to use: google, microsoft, or picker")
49
59
  .action(async (options) => {
60
+ if (isMachineIdentity()) {
61
+ console.log(chalk.green(`Running as ${machineIdentityLabel()} — sessions mint automatically; no browser login needed.`));
62
+ return;
63
+ }
50
64
  const existing = loadCachedTokens();
51
65
  if (existing && !isExpiring(existing, 120)) {
52
66
  const who = peekIdToken(existing.idToken).email ?? "cached session";
@@ -98,12 +112,22 @@ export function registerAuthCommands(program) {
98
112
  .command("status")
99
113
  .description("Show whether a valid HQ session is cached")
100
114
  .action(() => {
115
+ const machine = isMachineIdentity();
101
116
  const cached = loadCachedTokens();
102
117
  if (!cached) {
118
+ if (machine) {
119
+ // No cached session yet, but machine creds mint one on demand —
120
+ // report ready, not signed-out.
121
+ console.log(chalk.green(`${machineIdentityLabel()} — no cached session yet (mints automatically on first use)`));
122
+ return;
123
+ }
103
124
  console.log(chalk.yellow("No cached HQ session — run `hq auth login`"));
104
125
  process.exit(1);
105
126
  }
106
- const who = peekIdToken(cached.idToken).email;
127
+ const claims = peekIdToken(cached.idToken);
128
+ const who = machine
129
+ ? `${machineIdentityLabel()}${claims.entityUid ? ` (${claims.entityUid})` : ""}`
130
+ : claims.email;
107
131
  const expiring = isExpiring(cached);
108
132
  const label = who ? `${who} — ` : "";
109
133
  console.log(expiring
@@ -112,4 +136,4 @@ export function registerAuthCommands(program) {
112
136
  });
113
137
  }
114
138
  //# sourceMappingURL=auth.js.map
115
- //# debugId=e8f8cd77-2f24-5e71-8cb6-518095482b22
139
+ //# debugId=b9690c95-1d23-5c58-8cb3-0e3bdc8b35e8
@@ -8,6 +8,12 @@ export interface DmRecipient {
8
8
  * email/prs_ heuristic used by `hq members`. Returns null for neither.
9
9
  */
10
10
  export declare function detectRecipient(recipient: string): DmRecipient | null;
11
+ /**
12
+ * A comma in the recipient arg signals a GROUP DM. Split into trimmed, de-duped
13
+ * recipient tokens (emails or personUids). Returns null when there's no comma
14
+ * (the normal 1:1 path). Pure → unit-testable.
15
+ */
16
+ export declare function parseGroupRecipients(recipient: string): string[] | null;
11
17
  /**
12
18
  * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
13
19
  * Returns null on anything that doesn't match. Pure → unit-testable.
@@ -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]="19c5d8e0-ec0a-5b57-b3dc-e2209b60cf2f")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="c29bef98-d624-5da1-86c9-b4dc9d6d1d7f")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { ensureCognitoToken } from "../utils/cognito-session.js";
@@ -18,6 +18,20 @@ export function detectRecipient(recipient) {
18
18
  return { toPersonUid: r };
19
19
  return null;
20
20
  }
21
+ /**
22
+ * A comma in the recipient arg signals a GROUP DM. Split into trimmed, de-duped
23
+ * recipient tokens (emails or personUids). Returns null when there's no comma
24
+ * (the normal 1:1 path). Pure → unit-testable.
25
+ */
26
+ export function parseGroupRecipients(recipient) {
27
+ if (!recipient.includes(","))
28
+ return null;
29
+ const parts = recipient
30
+ .split(",")
31
+ .map((s) => s.trim())
32
+ .filter(Boolean);
33
+ return [...new Set(parts)];
34
+ }
21
35
  /**
22
36
  * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
23
37
  * Returns null on anything that doesn't match. Pure → unit-testable.
@@ -178,7 +192,76 @@ async function runConnectionAction(action, identifier) {
178
192
  process.exit(1);
179
193
  }
180
194
  }
195
+ /**
196
+ * Group DM path: `hq dm send "a@x.com,b@y.com" "msg"`. Creates (or reopens, via
197
+ * the server's idempotent participant-key dedupe) a group channel, then posts
198
+ * the message into it. Reuses the same vault API client + auth as the 1:1 path.
199
+ */
200
+ async function runGroupSend(recipients, message) {
201
+ try {
202
+ const participants = [];
203
+ for (const r of recipients) {
204
+ const rc = detectRecipient(r);
205
+ if (!rc) {
206
+ console.error(chalk.red(`Invalid recipient '${r}': each must be an email address or a personUid (prs_…).`));
207
+ process.exit(1);
208
+ }
209
+ participants.push(rc.toEmail ?? rc.toPersonUid);
210
+ }
211
+ if (participants.length < 2) {
212
+ console.error(chalk.red('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".'));
213
+ process.exit(1);
214
+ }
215
+ const body = (message ?? "").trim();
216
+ if (!body) {
217
+ console.error(chalk.red('A message body is required: hq dm send "a@x,b@y" <message>'));
218
+ process.exit(1);
219
+ }
220
+ const token = await ensureCognitoToken();
221
+ // 1) Create or reopen the group (idempotent on the participant set).
222
+ const createRes = await vaultApiFetch({
223
+ token,
224
+ path: "/v1/notify/channels",
225
+ method: "POST",
226
+ body: { scope: "group", participants },
227
+ });
228
+ if (!createRes.ok) {
229
+ const err = (await createRes.json().catch(() => ({})));
230
+ console.error(chalk.red(friendlyDmError(createRes.status, err.code, err.error ?? err.message ?? createRes.statusText)));
231
+ process.exit(1);
232
+ }
233
+ const createData = (await createRes.json());
234
+ const channelId = createData.channel?.channelId;
235
+ if (!channelId) {
236
+ console.error(chalk.red("Group create returned no channel id."));
237
+ process.exit(1);
238
+ }
239
+ // 2) Post the message into the group.
240
+ const sendRes = await vaultApiFetch({
241
+ token,
242
+ path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
243
+ method: "POST",
244
+ body: { body },
245
+ });
246
+ if (!sendRes.ok) {
247
+ const err = (await sendRes.json().catch(() => ({})));
248
+ console.error(chalk.red(friendlyDmError(sendRes.status, err.code, err.error ?? err.message ?? sendRes.statusText)));
249
+ process.exit(1);
250
+ }
251
+ console.log(chalk.green(`Group DM sent to ${participants.length} people${createData.created ? " (new group)" : ""}.`));
252
+ }
253
+ catch (err) {
254
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
255
+ process.exit(1);
256
+ }
257
+ }
181
258
  async function runDmSend(recipient, message, opts) {
259
+ // A comma in the recipient means a group DM — fan into the channel path.
260
+ const group = parseGroupRecipients(recipient);
261
+ if (group) {
262
+ await runGroupSend(group, message);
263
+ return;
264
+ }
182
265
  try {
183
266
  // Resolve prompt/details from inline text or a file.
184
267
  let prompt = opts.prompt;
@@ -261,7 +344,7 @@ export function registerDmCommand(program) {
261
344
  .description("Send a direct message and manage connection requests.");
262
345
  dm
263
346
  .command("send <recipient> [message]", { isDefault: true, hidden: true })
264
- .description("Send a direct message to someone (email or personUid). They receive it as an HQ Sync notification. If you aren't connected yet, it sends a connection request that holds your message.")
347
+ .description('Send a direct message to someone (email or personUid). They receive it as an HQ Sync notification. If you aren\'t connected yet, it sends a connection request that holds your message. For a GROUP DM, pass a comma-separated recipient: hq dm send "a@x.com,b@y.com" "hi".')
265
348
  .option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent")
266
349
  .option("--prompt-file <path>", "Read the agent prompt from a file")
267
350
  .option("--details <text>", "Longer detail shown in the recipient's DM detail window")
@@ -297,4 +380,4 @@ export function registerDmCommand(program) {
297
380
  });
298
381
  }
299
382
  //# sourceMappingURL=dm.js.map
300
- //# debugId=19c5d8e0-ec0a-5b57-b3dc-e2209b60cf2f
383
+ //# debugId=c29bef98-d624-5da1-86c9-b4dc9d6d1d7f
@@ -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]="83c39bc9-e532-580d-bc3a-5f176eb5cba6")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b2b63537-b45f-59d6-8783-172b7edcacd6")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import { ensureCognitoToken } from "../utils/cognito-session.js";
5
5
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
@@ -366,6 +366,16 @@ export function registerMembersCommand(program) {
366
366
  }
367
367
  }
368
368
  catch (err) {
369
+ // The target is ALREADY an active member (409 MEMBERSHIP_ALREADY_EXISTS).
370
+ // Terminal, not a failure: the person is already in the company, so
371
+ // there's nothing to invite or retry. Print a plain note and exit 0 —
372
+ // re-attempting just re-fires the server's (correctly captured) 409,
373
+ // and a bulk-invite script shouldn't abort on an already-member. HQ-11.
374
+ if (err instanceof InviteHttpError &&
375
+ err.code === "MEMBERSHIP_ALREADY_EXISTS") {
376
+ console.log(chalk.yellow(`${target.toLowerCase()} is already a member of this company — nothing to do.`));
377
+ return;
378
+ }
369
379
  if (err instanceof InviteHttpError) {
370
380
  console.error(chalk.red(formatInviteHttpError(err.status, err.message, err.code)));
371
381
  process.exit(1);
@@ -451,4 +461,4 @@ export function registerMembersCommand(program) {
451
461
  });
452
462
  }
453
463
  //# sourceMappingURL=members.js.map
454
- //# debugId=83c39bc9-e532-580d-bc3a-5f176eb5cba6
464
+ //# debugId=b2b63537-b45f-59d6-8783-172b7edcacd6
@@ -2,9 +2,9 @@
2
2
  * hq whoami — displays current user or 'not logged in'
3
3
  */
4
4
 
5
- !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]="7d12196e-3212-5692-9383-5431e2b6a97f")}catch(e){}}();
5
+ !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]="1603fcc9-75f6-5855-b7b2-8333ffda9389")}catch(e){}}();
6
6
  import chalk from 'chalk';
7
- import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
7
+ import { loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, } from '@indigoai-us/hq-cloud';
8
8
  function peekIdToken(idToken) {
9
9
  try {
10
10
  const payload = idToken.split('.')[1];
@@ -13,7 +13,12 @@ function peekIdToken(idToken) {
13
13
  const pad = payload.length % 4 === 0 ? '' : '='.repeat(4 - (payload.length % 4));
14
14
  const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + pad;
15
15
  const decoded = JSON.parse(Buffer.from(normalized, 'base64').toString('utf-8'));
16
- return { email: decoded.email, sub: decoded.sub };
16
+ return {
17
+ email: decoded.email,
18
+ sub: decoded.sub,
19
+ entityType: decoded['custom:entityType'],
20
+ entityUid: decoded['custom:entityUid'],
21
+ };
17
22
  }
18
23
  catch {
19
24
  return {};
@@ -25,7 +30,19 @@ export function registerWhoamiCommand(program) {
25
30
  .description('Show the currently authenticated user')
26
31
  .action(async () => {
27
32
  try {
33
+ const machine = isMachineIdentity();
28
34
  const cached = loadCachedTokens();
35
+ if (machine) {
36
+ // Machine identities (company agents) mint sessions on demand from
37
+ // long-lived creds — report the machine identity honestly even when
38
+ // no session is cached yet.
39
+ const username = loadMachineCreds()?.username ?? 'unknown';
40
+ const entityUid = cached
41
+ ? peekIdToken(cached.idToken).entityUid
42
+ : undefined;
43
+ console.log(`Machine identity ${username}${entityUid ? ` (agent ${entityUid})` : ''} — sessions mint automatically`);
44
+ return;
45
+ }
29
46
  if (!cached) {
30
47
  console.log("Not logged in. Run 'hq login' to authenticate.");
31
48
  return;
@@ -45,4 +62,4 @@ export function registerWhoamiCommand(program) {
45
62
  });
46
63
  }
47
64
  //# sourceMappingURL=whoami.js.map
48
- //# debugId=7d12196e-3212-5692-9383-5431e2b6a97f
65
+ //# debugId=1603fcc9-75f6-5855-b7b2-8333ffda9389
@@ -19,13 +19,13 @@
19
19
  * HQ_VAULT_API_URL — vault-service API Gateway URL
20
20
  */
21
21
 
22
- !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]="984bafe3-242a-5322-a2fe-65261b24ee0b")}catch(e){}}();
22
+ !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]="783fe478-9e12-55a7-a3d8-241ea2cf5886")}catch(e){}}();
23
23
  import * as fs from "fs";
24
24
  import * as os from "os";
25
25
  import * as path from "path";
26
26
  import * as yaml from "js-yaml";
27
27
  import chalk from "chalk";
28
- import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, } from "@indigoai-us/hq-cloud";
28
+ import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, mintMachineTokens, } from "@indigoai-us/hq-cloud";
29
29
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
30
30
  export const DEFAULT_COGNITO = {
31
31
  region: process.env.AWS_REGION ?? "us-east-1",
@@ -232,6 +232,18 @@ export const DEFAULT_HQ_ROOT = resolveDefaultHqRoot();
232
232
  */
233
233
  export async function ensureCognitoToken(options = {}) {
234
234
  const interactive = options.interactive ?? true;
235
+ // Machine identities (company agents) mint sessions on demand via
236
+ // USER_PASSWORD_AUTH — no refresh-token dance, no browser. The vault API's
237
+ // JWT authorizer accepts ID tokens, and the agent's identity claims
238
+ // (custom:entityType=agent, custom:entityUid=agt_*) ride the ID token ONLY,
239
+ // so vault-API calls from a machine identity send the ID token. The cached
240
+ // token file keeps correct field semantics (real access token in
241
+ // accessToken) for consumers that need token_use=access, e.g. the deploy
242
+ // API via the deploy skill.
243
+ if (isMachineIdentity()) {
244
+ const machine = await getValidMachineTokens(DEFAULT_COGNITO);
245
+ return machine.idToken;
246
+ }
235
247
  const cached = loadCachedTokens();
236
248
  if (cached && !isExpiring(cached, 120)) {
237
249
  return cached.accessToken;
@@ -286,6 +298,20 @@ export function buildVaultConfig(authToken) {
286
298
  * with a reason string so the caller can decide what to do.
287
299
  */
288
300
  export async function refreshCachedSession() {
301
+ // Machine identities have no refresh token — a "refresh" is a fresh mint
302
+ // from the long-lived machine creds.
303
+ if (isMachineIdentity()) {
304
+ try {
305
+ await mintMachineTokens(DEFAULT_COGNITO);
306
+ return { refreshed: true };
307
+ }
308
+ catch (err) {
309
+ return {
310
+ refreshed: false,
311
+ reason: err instanceof Error ? err.message : String(err),
312
+ };
313
+ }
314
+ }
289
315
  const cached = loadCachedTokens();
290
316
  if (!cached) {
291
317
  return { refreshed: false, reason: "no cached session" };
@@ -302,4 +328,4 @@ export async function refreshCachedSession() {
302
328
  }
303
329
  }
304
330
  //# sourceMappingURL=cognito-session.js.map
305
- //# debugId=984bafe3-242a-5322-a2fe-65261b24ee0b
331
+ //# debugId=783fe478-9e12-55a7-a3d8-241ea2cf5886
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.39.2",
4
- "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
3
+ "version": "5.40.0",
4
+ "description": "HQ by Indigo management CLI modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "hq": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "clean": "rm -rf dist"
16
16
  },
17
17
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "^6.6.0",
18
+ "@indigoai-us/hq-cloud": "^6.7.0",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",
@@ -21,6 +21,8 @@ import {
21
21
  clearCachedTokens,
22
22
  loadCachedTokens,
23
23
  isExpiring,
24
+ isMachineIdentity,
25
+ loadMachineCreds,
24
26
  CognitoAuthError,
25
27
  } from "@indigoai-us/hq-cloud";
26
28
  import {
@@ -36,7 +38,7 @@ import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
36
38
  */
37
39
  function peekIdToken(
38
40
  idToken: string,
39
- ): { email?: string; sub?: string } {
41
+ ): { email?: string; sub?: string; entityType?: string; entityUid?: string } {
40
42
  try {
41
43
  const payload = idToken.split(".")[1];
42
44
  if (!payload) return {};
@@ -47,12 +49,23 @@ function peekIdToken(
47
49
  const decoded = JSON.parse(
48
50
  Buffer.from(normalized, "base64").toString("utf-8"),
49
51
  );
50
- return { email: decoded.email, sub: decoded.sub };
52
+ return {
53
+ email: decoded.email,
54
+ sub: decoded.sub,
55
+ entityType: decoded["custom:entityType"],
56
+ entityUid: decoded["custom:entityUid"],
57
+ };
51
58
  } catch {
52
59
  return {};
53
60
  }
54
61
  }
55
62
 
63
+ /** Display label for the local machine identity (company agents). */
64
+ function machineIdentityLabel(): string {
65
+ const creds = loadMachineCreds();
66
+ return creds ? `machine identity ${creds.username}` : "machine identity";
67
+ }
68
+
56
69
  export function registerAuthCommands(program: Command): void {
57
70
  const authCmd = program
58
71
  .command("auth")
@@ -68,6 +81,14 @@ export function registerAuthCommands(program: Command): void {
68
81
  "OAuth provider to use: google, microsoft, or picker",
69
82
  )
70
83
  .action(async (options: { provider?: string }) => {
84
+ if (isMachineIdentity()) {
85
+ console.log(
86
+ chalk.green(
87
+ `Running as ${machineIdentityLabel()} — sessions mint automatically; no browser login needed.`,
88
+ ),
89
+ );
90
+ return;
91
+ }
71
92
  const existing = loadCachedTokens();
72
93
  if (existing && !isExpiring(existing, 120)) {
73
94
  const who = peekIdToken(existing.idToken).email ?? "cached session";
@@ -134,12 +155,26 @@ export function registerAuthCommands(program: Command): void {
134
155
  .command("status")
135
156
  .description("Show whether a valid HQ session is cached")
136
157
  .action(() => {
158
+ const machine = isMachineIdentity();
137
159
  const cached = loadCachedTokens();
138
160
  if (!cached) {
161
+ if (machine) {
162
+ // No cached session yet, but machine creds mint one on demand —
163
+ // report ready, not signed-out.
164
+ console.log(
165
+ chalk.green(
166
+ `${machineIdentityLabel()} — no cached session yet (mints automatically on first use)`,
167
+ ),
168
+ );
169
+ return;
170
+ }
139
171
  console.log(chalk.yellow("No cached HQ session — run `hq auth login`"));
140
172
  process.exit(1);
141
173
  }
142
- const who = peekIdToken(cached.idToken).email;
174
+ const claims = peekIdToken(cached.idToken);
175
+ const who = machine
176
+ ? `${machineIdentityLabel()}${claims.entityUid ? ` (${claims.entityUid})` : ""}`
177
+ : claims.email;
143
178
  const expiring = isExpiring(cached);
144
179
  const label = who ? `${who} — ` : "";
145
180
  console.log(
@@ -21,6 +21,7 @@ import { Command } from "commander";
21
21
  import {
22
22
  detectRecipient,
23
23
  parseDuration,
24
+ parseGroupRecipients,
24
25
  buildDmBody,
25
26
  matchRequest,
26
27
  buildConnectionActionBody,
@@ -28,6 +29,19 @@ import {
28
29
  type ConnectionRequest,
29
30
  } from "./dm.js";
30
31
 
32
+ describe("parseGroupRecipients", () => {
33
+ it("returns null with no comma (the 1:1 path)", () => {
34
+ expect(parseGroupRecipients("a@x.com")).toBeNull();
35
+ expect(parseGroupRecipients("prs_1")).toBeNull();
36
+ });
37
+ it("splits, trims, and de-dupes a comma list", () => {
38
+ expect(parseGroupRecipients("a@x.com, b@y.com ,a@x.com")).toEqual([
39
+ "a@x.com",
40
+ "b@y.com",
41
+ ]);
42
+ });
43
+ });
44
+
31
45
  describe("detectRecipient", () => {
32
46
  it("classifies an email", () => {
33
47
  expect(detectRecipient("Stefan@Getindigo.ai")).toEqual({
@@ -220,6 +234,36 @@ describe("dm command actions", () => {
220
234
  expect(errSpy).not.toHaveBeenCalled();
221
235
  });
222
236
 
237
+ it("group send: a comma recipient creates a group then posts the message", async () => {
238
+ fetchSpy
239
+ .mockResolvedValueOnce(
240
+ jsonResponse(200, { channel: { channelId: "chn_grp1" }, created: true }),
241
+ )
242
+ .mockResolvedValueOnce(jsonResponse(200, { eventId: "evt_g" }));
243
+ await program.parseAsync(
244
+ ["dm", "alice@example.com,bob@example.com", "hey team"],
245
+ { from: "user" },
246
+ );
247
+ // 1st call creates the group channel...
248
+ const createCall = fetchSpy.mock.calls[0];
249
+ expect(String(createCall[0])).toContain("/v1/notify/channels");
250
+ const createBody = JSON.parse((createCall[1]?.body as string) ?? "{}");
251
+ expect(createBody.scope).toBe("group");
252
+ expect(createBody.participants).toEqual(["alice@example.com", "bob@example.com"]);
253
+ // ...2nd posts the message into it.
254
+ const sendCall = fetchSpy.mock.calls[1];
255
+ expect(String(sendCall[0])).toContain("/v1/notify/channels/chn_grp1/messages");
256
+ expect(JSON.parse((sendCall[1]?.body as string) ?? "{}").body).toBe("hey team");
257
+ expect(logged()).toMatch(/Group DM sent to 2 people \(new group\)\./);
258
+ expect(errSpy).not.toHaveBeenCalled();
259
+ });
260
+
261
+ it("group send: a single recipient still uses the 1:1 dm path", async () => {
262
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { eventId: "evt_1" }));
263
+ await program.parseAsync(["dm", "alice@example.com", "hi"], { from: "user" });
264
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/dm");
265
+ });
266
+
223
267
  it("send: prints pending request on 202 connection_requested (not an error)", async () => {
224
268
  fetchSpy.mockResolvedValueOnce(
225
269
  jsonResponse(202, { state: "connection_requested" }),
@@ -23,6 +23,20 @@ export function detectRecipient(recipient: string): DmRecipient | null {
23
23
  return null;
24
24
  }
25
25
 
26
+ /**
27
+ * A comma in the recipient arg signals a GROUP DM. Split into trimmed, de-duped
28
+ * recipient tokens (emails or personUids). Returns null when there's no comma
29
+ * (the normal 1:1 path). Pure → unit-testable.
30
+ */
31
+ export function parseGroupRecipients(recipient: string): string[] | null {
32
+ if (!recipient.includes(",")) return null;
33
+ const parts = recipient
34
+ .split(",")
35
+ .map((s) => s.trim())
36
+ .filter(Boolean);
37
+ return [...new Set(parts)];
38
+ }
39
+
26
40
  /**
27
41
  * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
28
42
  * Returns null on anything that doesn't match. Pure → unit-testable.
@@ -248,11 +262,124 @@ interface DmSendOpts {
248
262
  in?: string;
249
263
  }
250
264
 
265
+ /**
266
+ * Group DM path: `hq dm send "a@x.com,b@y.com" "msg"`. Creates (or reopens, via
267
+ * the server's idempotent participant-key dedupe) a group channel, then posts
268
+ * the message into it. Reuses the same vault API client + auth as the 1:1 path.
269
+ */
270
+ async function runGroupSend(
271
+ recipients: string[],
272
+ message: string | undefined,
273
+ ): Promise<void> {
274
+ try {
275
+ const participants: string[] = [];
276
+ for (const r of recipients) {
277
+ const rc = detectRecipient(r);
278
+ if (!rc) {
279
+ console.error(
280
+ chalk.red(
281
+ `Invalid recipient '${r}': each must be an email address or a personUid (prs_…).`,
282
+ ),
283
+ );
284
+ process.exit(1);
285
+ }
286
+ participants.push(rc.toEmail ?? rc.toPersonUid!);
287
+ }
288
+ if (participants.length < 2) {
289
+ console.error(
290
+ chalk.red(
291
+ '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".',
292
+ ),
293
+ );
294
+ process.exit(1);
295
+ }
296
+ const body = (message ?? "").trim();
297
+ if (!body) {
298
+ console.error(
299
+ chalk.red('A message body is required: hq dm send "a@x,b@y" <message>'),
300
+ );
301
+ process.exit(1);
302
+ }
303
+
304
+ const token = await ensureCognitoToken();
305
+
306
+ // 1) Create or reopen the group (idempotent on the participant set).
307
+ const createRes = await vaultApiFetch({
308
+ token,
309
+ path: "/v1/notify/channels",
310
+ method: "POST",
311
+ body: { scope: "group", participants },
312
+ });
313
+ if (!createRes.ok) {
314
+ const err = (await createRes.json().catch(() => ({}))) as Record<string, string>;
315
+ console.error(
316
+ chalk.red(
317
+ friendlyDmError(
318
+ createRes.status,
319
+ err.code,
320
+ err.error ?? err.message ?? createRes.statusText,
321
+ ),
322
+ ),
323
+ );
324
+ process.exit(1);
325
+ }
326
+ const createData = (await createRes.json()) as {
327
+ channel?: { channelId?: string };
328
+ created?: boolean;
329
+ };
330
+ const channelId = createData.channel?.channelId;
331
+ if (!channelId) {
332
+ console.error(chalk.red("Group create returned no channel id."));
333
+ process.exit(1);
334
+ }
335
+
336
+ // 2) Post the message into the group.
337
+ const sendRes = await vaultApiFetch({
338
+ token,
339
+ path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
340
+ method: "POST",
341
+ body: { body },
342
+ });
343
+ if (!sendRes.ok) {
344
+ const err = (await sendRes.json().catch(() => ({}))) as Record<string, string>;
345
+ console.error(
346
+ chalk.red(
347
+ friendlyDmError(
348
+ sendRes.status,
349
+ err.code,
350
+ err.error ?? err.message ?? sendRes.statusText,
351
+ ),
352
+ ),
353
+ );
354
+ process.exit(1);
355
+ }
356
+ console.log(
357
+ chalk.green(
358
+ `Group DM sent to ${participants.length} people${
359
+ createData.created ? " (new group)" : ""
360
+ }.`,
361
+ ),
362
+ );
363
+ } catch (err) {
364
+ console.error(
365
+ chalk.red("Error:"),
366
+ err instanceof Error ? err.message : String(err),
367
+ );
368
+ process.exit(1);
369
+ }
370
+ }
371
+
251
372
  async function runDmSend(
252
373
  recipient: string,
253
374
  message: string | undefined,
254
375
  opts: DmSendOpts,
255
376
  ): Promise<void> {
377
+ // A comma in the recipient means a group DM — fan into the channel path.
378
+ const group = parseGroupRecipients(recipient);
379
+ if (group) {
380
+ await runGroupSend(group, message);
381
+ return;
382
+ }
256
383
  try {
257
384
  // Resolve prompt/details from inline text or a file.
258
385
  let prompt = opts.prompt;
@@ -376,7 +503,7 @@ export function registerDmCommand(program: Command): void {
376
503
  dm
377
504
  .command("send <recipient> [message]", { isDefault: true, hidden: true })
378
505
  .description(
379
- "Send a direct message to someone (email or personUid). They receive it as an HQ Sync notification. If you aren't connected yet, it sends a connection request that holds your message.",
506
+ 'Send a direct message to someone (email or personUid). They receive it as an HQ Sync notification. If you aren\'t connected yet, it sends a connection request that holds your message. For a GROUP DM, pass a comma-separated recipient: hq dm send "a@x.com,b@y.com" "hi".',
380
507
  )
381
508
  .option(
382
509
  "--prompt <text>",
@@ -225,6 +225,31 @@ describe("inviteMember", () => {
225
225
  ).rejects.toBeInstanceOf(InviteHttpError);
226
226
  });
227
227
 
228
+ // HQ-11: inviting an already-active member returns 409 with the machine code
229
+ // MEMBERSHIP_ALREADY_EXISTS. The code must ride on the InviteHttpError so the
230
+ // `invite` command can treat it as a terminal "already a member" (no retry).
231
+ it("surfaces the server `code` (MEMBERSHIP_ALREADY_EXISTS) on the InviteHttpError", async () => {
232
+ fetchSpy.mockResolvedValueOnce(
233
+ jsonResponse(409, {
234
+ error:
235
+ "Membership already exists for person email:alice@example.com in company cmp_acme",
236
+ code: "MEMBERSHIP_ALREADY_EXISTS",
237
+ }),
238
+ );
239
+
240
+ const err = await inviteMember({
241
+ target: "alice@example.com",
242
+ role: "member",
243
+ companyUid: "cmp_acme",
244
+ callerUid: "prs_admin",
245
+ token: "test-token",
246
+ }).catch((e: unknown) => e);
247
+
248
+ expect(err).toBeInstanceOf(InviteHttpError);
249
+ expect((err as InviteHttpError).status).toBe(409);
250
+ expect((err as InviteHttpError).code).toBe("MEMBERSHIP_ALREADY_EXISTS");
251
+ });
252
+
228
253
  it("resolves the token when it is nested on the membership row", async () => {
229
254
  fetchSpy.mockResolvedValueOnce(
230
255
  jsonResponse(200, {
@@ -650,6 +650,22 @@ export function registerMembersCommand(program: Command): void {
650
650
  );
651
651
  }
652
652
  } catch (err) {
653
+ // The target is ALREADY an active member (409 MEMBERSHIP_ALREADY_EXISTS).
654
+ // Terminal, not a failure: the person is already in the company, so
655
+ // there's nothing to invite or retry. Print a plain note and exit 0 —
656
+ // re-attempting just re-fires the server's (correctly captured) 409,
657
+ // and a bulk-invite script shouldn't abort on an already-member. HQ-11.
658
+ if (
659
+ err instanceof InviteHttpError &&
660
+ err.code === "MEMBERSHIP_ALREADY_EXISTS"
661
+ ) {
662
+ console.log(
663
+ chalk.yellow(
664
+ `${target.toLowerCase()} is already a member of this company — nothing to do.`,
665
+ ),
666
+ );
667
+ return;
668
+ }
653
669
  if (err instanceof InviteHttpError) {
654
670
  console.error(
655
671
  chalk.red(
@@ -4,16 +4,31 @@
4
4
 
5
5
  import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
- import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
7
+ import {
8
+ loadCachedTokens,
9
+ isExpiring,
10
+ isMachineIdentity,
11
+ loadMachineCreds,
12
+ } from '@indigoai-us/hq-cloud';
8
13
 
9
- function peekIdToken(idToken: string): { email?: string; sub?: string } {
14
+ function peekIdToken(idToken: string): {
15
+ email?: string;
16
+ sub?: string;
17
+ entityType?: string;
18
+ entityUid?: string;
19
+ } {
10
20
  try {
11
21
  const payload = idToken.split('.')[1];
12
22
  if (!payload) return {};
13
23
  const pad = payload.length % 4 === 0 ? '' : '='.repeat(4 - (payload.length % 4));
14
24
  const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + pad;
15
25
  const decoded = JSON.parse(Buffer.from(normalized, 'base64').toString('utf-8'));
16
- return { email: decoded.email, sub: decoded.sub };
26
+ return {
27
+ email: decoded.email,
28
+ sub: decoded.sub,
29
+ entityType: decoded['custom:entityType'],
30
+ entityUid: decoded['custom:entityUid'],
31
+ };
17
32
  } catch {
18
33
  return {};
19
34
  }
@@ -25,8 +40,23 @@ export function registerWhoamiCommand(program: Command): void {
25
40
  .description('Show the currently authenticated user')
26
41
  .action(async () => {
27
42
  try {
43
+ const machine = isMachineIdentity();
28
44
  const cached = loadCachedTokens();
29
45
 
46
+ if (machine) {
47
+ // Machine identities (company agents) mint sessions on demand from
48
+ // long-lived creds — report the machine identity honestly even when
49
+ // no session is cached yet.
50
+ const username = loadMachineCreds()?.username ?? 'unknown';
51
+ const entityUid = cached
52
+ ? peekIdToken(cached.idToken).entityUid
53
+ : undefined;
54
+ console.log(
55
+ `Machine identity ${username}${entityUid ? ` (agent ${entityUid})` : ''} — sessions mint automatically`
56
+ );
57
+ return;
58
+ }
59
+
30
60
  if (!cached) {
31
61
  console.log("Not logged in. Run 'hq login' to authenticate.");
32
62
  return;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Machine-identity behavior of the shared Cognito session helpers.
3
+ *
4
+ * When machine creds are present (company agent boxes), the CLI must:
5
+ * - return the ID token from ensureCognitoToken() — the vault API's JWT
6
+ * authorizer accepts ID tokens and the agent claims
7
+ * (custom:entityType/custom:entityUid) ride the ID token only
8
+ * - never refresh or open a browser — sessions re-mint on demand
9
+ * - treat `hq auth refresh` as a fresh mint instead of "no cached session"
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, vi } from "vitest";
13
+
14
+ const mocks = vi.hoisted(() => ({
15
+ isMachineIdentity: vi.fn(),
16
+ getValidMachineTokens: vi.fn(),
17
+ mintMachineTokens: vi.fn(),
18
+ loadCachedTokens: vi.fn(),
19
+ refreshTokens: vi.fn(),
20
+ browserLogin: vi.fn(),
21
+ isExpiring: vi.fn(),
22
+ }));
23
+
24
+ vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import("@indigoai-us/hq-cloud")>();
26
+ return { ...actual, ...mocks };
27
+ });
28
+
29
+ import {
30
+ ensureCognitoToken,
31
+ refreshCachedSession,
32
+ } from "./cognito-session.js";
33
+
34
+ const MACHINE_TOKENS = {
35
+ accessToken: "machine-access-token",
36
+ idToken: "machine-id-token",
37
+ refreshToken: "",
38
+ expiresAt: Date.now() + 3600_000,
39
+ tokenType: "Bearer" as const,
40
+ };
41
+
42
+ beforeEach(() => {
43
+ vi.resetAllMocks();
44
+ });
45
+
46
+ describe("ensureCognitoToken in machine mode", () => {
47
+ it("returns the ID token (agent claims ride the ID token only)", async () => {
48
+ mocks.isMachineIdentity.mockReturnValue(true);
49
+ mocks.getValidMachineTokens.mockResolvedValue(MACHINE_TOKENS);
50
+
51
+ const token = await ensureCognitoToken({ interactive: false });
52
+
53
+ expect(token).toBe("machine-id-token");
54
+ expect(mocks.getValidMachineTokens).toHaveBeenCalledTimes(1);
55
+ expect(mocks.refreshTokens).not.toHaveBeenCalled();
56
+ expect(mocks.browserLogin).not.toHaveBeenCalled();
57
+ expect(mocks.loadCachedTokens).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it("falls through to the browser-session path for human identities", async () => {
61
+ mocks.isMachineIdentity.mockReturnValue(false);
62
+ mocks.loadCachedTokens.mockReturnValue({
63
+ ...MACHINE_TOKENS,
64
+ accessToken: "human-access-token",
65
+ });
66
+ mocks.isExpiring.mockReturnValue(false);
67
+
68
+ const token = await ensureCognitoToken({ interactive: false });
69
+
70
+ expect(token).toBe("human-access-token");
71
+ expect(mocks.getValidMachineTokens).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("propagates mint failures instead of opening a browser", async () => {
75
+ mocks.isMachineIdentity.mockReturnValue(true);
76
+ mocks.getValidMachineTokens.mockRejectedValue(new Error("mint failed"));
77
+
78
+ await expect(ensureCognitoToken()).rejects.toThrow("mint failed");
79
+ expect(mocks.browserLogin).not.toHaveBeenCalled();
80
+ });
81
+ });
82
+
83
+ describe("refreshCachedSession in machine mode", () => {
84
+ it("re-mints instead of answering 'no cached session'", async () => {
85
+ mocks.isMachineIdentity.mockReturnValue(true);
86
+ mocks.mintMachineTokens.mockResolvedValue(MACHINE_TOKENS);
87
+ mocks.loadCachedTokens.mockReturnValue(null);
88
+
89
+ const result = await refreshCachedSession();
90
+
91
+ expect(result).toEqual({ refreshed: true });
92
+ expect(mocks.mintMachineTokens).toHaveBeenCalledTimes(1);
93
+ expect(mocks.refreshTokens).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("surfaces the mint error as the reason on failure", async () => {
97
+ mocks.isMachineIdentity.mockReturnValue(true);
98
+ mocks.mintMachineTokens.mockRejectedValue(new Error("NotAuthorized"));
99
+
100
+ const result = await refreshCachedSession();
101
+
102
+ expect(result.refreshed).toBe(false);
103
+ expect(result.reason).toContain("NotAuthorized");
104
+ });
105
+
106
+ it("keeps the human refresh path unchanged", async () => {
107
+ mocks.isMachineIdentity.mockReturnValue(false);
108
+ mocks.loadCachedTokens.mockReturnValue(null);
109
+
110
+ const result = await refreshCachedSession();
111
+
112
+ expect(result).toEqual({ refreshed: false, reason: "no cached session" });
113
+ });
114
+ });
@@ -30,6 +30,9 @@ import {
30
30
  refreshTokens,
31
31
  browserLogin,
32
32
  detectHqCoreVersion,
33
+ isMachineIdentity,
34
+ getValidMachineTokens,
35
+ mintMachineTokens,
33
36
  type CognitoAuthConfig,
34
37
  type ClientInfo,
35
38
  type VaultServiceConfig,
@@ -266,6 +269,20 @@ export async function ensureCognitoToken(options: {
266
269
  interactive?: boolean;
267
270
  } = {}): Promise<string> {
268
271
  const interactive = options.interactive ?? true;
272
+
273
+ // Machine identities (company agents) mint sessions on demand via
274
+ // USER_PASSWORD_AUTH — no refresh-token dance, no browser. The vault API's
275
+ // JWT authorizer accepts ID tokens, and the agent's identity claims
276
+ // (custom:entityType=agent, custom:entityUid=agt_*) ride the ID token ONLY,
277
+ // so vault-API calls from a machine identity send the ID token. The cached
278
+ // token file keeps correct field semantics (real access token in
279
+ // accessToken) for consumers that need token_use=access, e.g. the deploy
280
+ // API via the deploy skill.
281
+ if (isMachineIdentity()) {
282
+ const machine = await getValidMachineTokens(DEFAULT_COGNITO);
283
+ return machine.idToken;
284
+ }
285
+
269
286
  const cached = loadCachedTokens();
270
287
 
271
288
  if (cached && !isExpiring(cached, 120)) {
@@ -335,6 +352,20 @@ export async function refreshCachedSession(): Promise<{
335
352
  refreshed: boolean;
336
353
  reason?: string;
337
354
  }> {
355
+ // Machine identities have no refresh token — a "refresh" is a fresh mint
356
+ // from the long-lived machine creds.
357
+ if (isMachineIdentity()) {
358
+ try {
359
+ await mintMachineTokens(DEFAULT_COGNITO);
360
+ return { refreshed: true };
361
+ } catch (err) {
362
+ return {
363
+ refreshed: false,
364
+ reason: err instanceof Error ? err.message : String(err),
365
+ };
366
+ }
367
+ }
368
+
338
369
  const cached = loadCachedTokens();
339
370
  if (!cached) {
340
371
  return { refreshed: false, reason: "no cached session" };