@indigoai-us/hq-cli 5.105.0 → 5.105.2

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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.105.2] — 2026-09-02
6
+
7
+ ### Fixed
8
+
9
+ - The bundled cloud sync engine now uses `@indigoai-us/hq-cloud` 6.16.3.
10
+ Session logs include hidden files, retain capture state on large first runs,
11
+ continue capturing after one bad file, and are saved only after capture
12
+ succeeds.
13
+
14
+ ## [5.105.1] — 2026-08-31
15
+
16
+ ### Fixed
17
+
18
+ - Token usage reports now automatically include Claude activity stored in
19
+ named profiles such as `.claude-ridge`, with no extra setting required.
20
+
5
21
  ## [5.105.0] — 2026-08-31
6
22
 
7
23
  ### Changed
@@ -133,7 +133,7 @@ export interface ProvisionAgentInput {
133
133
  name: string;
134
134
  slug: string;
135
135
  codexAuthMode: "subscription" | "apiKey";
136
- provider?: "codex" | "grok" | "claude";
136
+ provider?: "codex" | "grok" | "claude" | "agents-v2";
137
137
  codexApiKey?: string;
138
138
  idempotencyKey: string;
139
139
  title?: string;
@@ -45,7 +45,7 @@ export const VALID_EFFORTS = new Set([
45
45
  /** Service-tier (speed) values hq-pro accepts on `runtime-config`. */
46
46
  export const VALID_TIERS = new Set(["default", "priority"]);
47
47
  /** Agent runtimes hq-pro accepts on `POST /v1/agents` (`AgentProvider`). */
48
- export const VALID_PROVIDERS = new Set(["codex", "grok", "claude"]);
48
+ export const VALID_PROVIDERS = new Set(["codex", "grok", "claude", "agents-v2"]);
49
49
  /** Auth modes hq-pro accepts on `POST /v1/agents` (`CodexAuthMode`). */
50
50
  export const VALID_AUTH_MODES = new Set(["subscription", "apiKey"]);
51
51
  /** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
@@ -764,7 +764,7 @@ export function registerAgentsCommand(program) {
764
764
  .description("Provision a new cloud agent (company-specific monthly price shown before creation)")
765
765
  .option("--company <slug>", "Company slug (resolves to companyUid)")
766
766
  .option("--slug <slug>", "Agent slug (defaults to a slug of <name>)")
767
- .option("--provider <provider>", "Runtime: codex | grok | claude (default codex). claude is subscription-only")
767
+ .option("--provider <provider>", "Runtime: codex | grok | claude | agents-v2 (default codex). claude is subscription-only; agents-v2 boots its brain box (codex, or grok via a grok-* model) and receives the v2 runtime post-boot")
768
768
  .option("--auth-mode <mode>", "Auth: subscription | apiKey (default subscription)", "subscription")
769
769
  .option("--api-key-env <VAR>", "Env var holding the API key for --auth-mode apiKey (never pass the key as a flag)")
770
770
  .option("--title <title>", "Org-chart job title")
@@ -186,6 +186,21 @@ export interface OAuthStartInput {
186
186
  * omitting it pins the console callback instead.
187
187
  */
188
188
  redirectUri?: string;
189
+ /**
190
+ * An OAuth app the admin registered with the provider themselves, for the
191
+ * providers that refuse to register HQ dynamically (they only issue
192
+ * confidential clients). hq-pro reads these ONLY on the direct `mcpUrl`
193
+ * path — a catalog, domain, or discovery-receipt connect keeps server-owned
194
+ * client resolution and drops them silently — so callers must pin an
195
+ * `mcpUrl` alongside them rather than hoping they were honoured.
196
+ */
197
+ clientId?: string;
198
+ /**
199
+ * The registered app's secret, for a confidential client. Optional: a public
200
+ * client is a `clientId` on its own. hq-pro keeps it on the server-side
201
+ * state row and attaches it at code exchange; it is never logged.
202
+ */
203
+ clientSecret?: string;
189
204
  }
190
205
  export declare function startOAuth(token: string, companyUid: string, input: OAuthStartInput): Promise<OAuthStartResult>;
191
206
  export declare function completeOAuth(token: string, companyUid: string, input: {
@@ -26,5 +26,25 @@ import { Command } from "commander";
26
26
  export declare function assertAuthMode(value: string | undefined): void;
27
27
  /** Single-quote a value for a copy-pasteable shell command. */
28
28
  export declare function shellQuote(value: string): string;
29
+ /**
30
+ * The console that fronts the control plane this CLI is pointed at.
31
+ *
32
+ * A handoff must land on the console for the SAME backend, because that
33
+ * console starts its own OAuth flow against its own backend. Sending a staging
34
+ * session to the production console would connect the app in the wrong place —
35
+ * so when the control plane is overridden and its console cannot be derived,
36
+ * return undefined and say so rather than guess a host.
37
+ *
38
+ * `HQ_CONSOLE_URL` is the explicit override; otherwise the console is the
39
+ * control-plane host with its `hqapi.` prefix removed.
40
+ */
41
+ export declare function consoleOrigin(vaultApiUrl?: string): string | undefined;
42
+ /**
43
+ * The console's Integrations page for a company, the console root when the
44
+ * caller never named one (a single-membership session resolves the company
45
+ * server-side, so hq-cli holds a uid here, not a slug — and the console routes
46
+ * by slug), or undefined when the console for this control plane is unknown.
47
+ */
48
+ export declare function consoleIntegrationsUrl(companySlug: string | undefined, origin: string | undefined): string | undefined;
29
49
  export declare function registerConnectCommands(integrations: Command): void;
30
50
  //# sourceMappingURL=integrations-connect.d.ts.map
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import chalk from "chalk";
20
20
  import open from "open";
21
- import { ensureCognitoIdToken } from "../utils/cognito-session.js";
21
+ import { DEFAULT_VAULT_API_URL, ensureCognitoIdToken, } from "../utils/cognito-session.js";
22
22
  import { getCompanyUid } from "../utils/vault-api.js";
23
23
  import { IntegrationsCliError, bareProvider, connectionDomain, printJson, revokedConnectionDetails, resolveConnection, } from "./integrations-core.js";
24
24
  import { completeOAuth, discoverDocs, installIntegration, listCatalog, pullBlueprint, startOAuth, } from "./integrations-api.js";
@@ -61,6 +61,21 @@ const CONSOLE_HANDOFF_CODES = new Set([
61
61
  "OAUTH_REDIRECT_URI_NOT_ALLOWED",
62
62
  "OAUTH_LOOPBACK_NOT_SUPPORTED",
63
63
  ]);
64
+ /**
65
+ * Codes meaning "this provider will not register HQ automatically; an admin
66
+ * must supply their own OAuth app's credentials".
67
+ *
68
+ * hq-pro attaches `redirectUri` + `advancedClientSupported` to exactly these
69
+ * (`withAdvancedClientHint`), and the console turns that into its
70
+ * bring-your-own-credentials form. hq-cli has no flag for a client id or
71
+ * secret, so on its own it can only restate the refusal — which reads as a
72
+ * dead end even though the recovery exists one surface over. Route to the
73
+ * console instead.
74
+ */
75
+ const BRING_YOUR_OWN_CLIENT_CODES = new Set([
76
+ "OAUTH_REGISTRATION_UNSUPPORTED",
77
+ "CLIENT_REGISTRATION_REFUSED",
78
+ ]);
64
79
  /**
65
80
  * Looks-like-a-domain test for the positional `<app>` argument. Deliberately
66
81
  * loose — hq-pro does the real resolution — but tight enough that `linear.app`
@@ -325,6 +340,26 @@ async function findCatalogEntryByName(token, companyUid, name) {
325
340
  return null;
326
341
  }
327
342
  }
343
+ /**
344
+ * Confirm the backend recognises this target, without connecting anything.
345
+ *
346
+ * `pullBlueprint` is the read-only half of the install: it takes the same
347
+ * domain / query / catalogEntryId handles and 404s an unknown one. It has no
348
+ * shape for the two remaining refs, and neither needs it — a discovery receipt
349
+ * was minted by `discoverDocs` moments earlier in `resolveTarget`, and an
350
+ * `--mcp-url` is an endpoint the caller typed rather than a handle the backend
351
+ * could confirm (the handoff prints it back so they can see what they gave).
352
+ */
353
+ async function assertTargetResolvable(token, companyUid, target) {
354
+ const { domain, query, catalogEntryId } = target.ref;
355
+ if (!domain && !query && !catalogEntryId)
356
+ return;
357
+ await pullBlueprint(token, companyUid, {
358
+ ...(domain ? { domain } : {}),
359
+ ...(query ? { query } : {}),
360
+ ...(catalogEntryId ? { catalogEntryId } : {}),
361
+ });
362
+ }
328
363
  /**
329
364
  * Run the browser sign-in and finish the install.
330
365
  *
@@ -343,13 +378,26 @@ async function connectViaOAuth(token, companyUid, target, opts) {
343
378
  });
344
379
  }
345
380
  const startInput = { ...target.ref };
381
+ // A user-registered app pins the console callback and skips the loopback
382
+ // entirely — see `userClientSignIn` for why an ephemeral port cannot be the
383
+ // redirect URI here.
384
+ const userClient = await resolveUserOAuthClient(opts, target.label);
385
+ if (userClient)
386
+ return await userClientSignIn(token, companyUid, target, opts, userClient);
346
387
  // A loopback listener only works if the browser runs on THIS machine. Over
347
388
  // SSH the person opens the printed URL on their workstation, so the provider
348
389
  // redirects to the workstation's 127.0.0.1 while the listener sits on the
349
390
  // remote host — the callback can never arrive and the command just times
350
391
  // out. Go straight to the console handoff instead of failing slowly.
351
392
  if (opts.browser === false && isRemoteShell()) {
352
- return await consoleHandoff(token, companyUid, startInput, target, opts);
393
+ // Every other route into the handoff has already put the target in front
394
+ // of the backend (`/oauth/start` rejects one it cannot resolve). This one
395
+ // returns before any request, so a target hq-pro would refuse — a stale
396
+ // `--entry-id`, an unmatched name that fell through to a raw query — would
397
+ // be answered with a confident "go finish it in the console" for an app
398
+ // that is not there. Ask the backend first.
399
+ await assertTargetResolvable(token, companyUid, target);
400
+ return await consoleHandoff(target, opts);
353
401
  }
354
402
  const listener = await startLoopbackListener(timeoutMs === undefined ? {} : { timeoutMs });
355
403
  try {
@@ -361,13 +409,16 @@ async function connectViaOAuth(token, companyUid, target, opts) {
361
409
  });
362
410
  }
363
411
  catch (err) {
364
- if (!(err instanceof IntegrationsCliError) ||
365
- !err.code ||
366
- !CONSOLE_HANDOFF_CODES.has(err.code)) {
412
+ if (!(err instanceof IntegrationsCliError) || !err.code)
367
413
  throw err;
414
+ if (BRING_YOUR_OWN_CLIENT_CODES.has(err.code)) {
415
+ listener.close();
416
+ return await bringYourOwnClientHandoff(target, opts, err.message, err.redirectUri);
368
417
  }
418
+ if (!CONSOLE_HANDOFF_CODES.has(err.code))
419
+ throw err;
369
420
  listener.close();
370
- return await consoleHandoff(token, companyUid, startInput, target, opts);
421
+ return await consoleHandoff(target, opts);
371
422
  }
372
423
  // Arm the waiter BEFORE the browser opens. The listener buffers a redirect
373
424
  // that beats it, but ordering it this way means the happy path never
@@ -391,20 +442,260 @@ async function connectViaOAuth(token, companyUid, target, opts) {
391
442
  listener.close();
392
443
  }
393
444
  }
445
+ const PRODUCTION_CONSOLE_ORIGIN = "https://hq.computer";
446
+ const PRODUCTION_VAULT_API_HOST = "hqapi.hq.computer";
447
+ /**
448
+ * The console that fronts the control plane this CLI is pointed at.
449
+ *
450
+ * A handoff must land on the console for the SAME backend, because that
451
+ * console starts its own OAuth flow against its own backend. Sending a staging
452
+ * session to the production console would connect the app in the wrong place —
453
+ * so when the control plane is overridden and its console cannot be derived,
454
+ * return undefined and say so rather than guess a host.
455
+ *
456
+ * `HQ_CONSOLE_URL` is the explicit override; otherwise the console is the
457
+ * control-plane host with its `hqapi.` prefix removed.
458
+ */
459
+ export function consoleOrigin(vaultApiUrl = DEFAULT_VAULT_API_URL) {
460
+ const explicit = process.env.HQ_CONSOLE_URL?.trim();
461
+ if (explicit)
462
+ return explicit.replace(/\/+$/, "");
463
+ let api;
464
+ try {
465
+ api = new URL(vaultApiUrl);
466
+ }
467
+ catch {
468
+ return undefined;
469
+ }
470
+ if (api.host === PRODUCTION_VAULT_API_HOST)
471
+ return PRODUCTION_CONSOLE_ORIGIN;
472
+ if (!api.hostname.startsWith("hqapi."))
473
+ return undefined;
474
+ return `${api.protocol}//${api.host.slice("hqapi.".length)}`;
475
+ }
476
+ /**
477
+ * The console's Integrations page for a company, the console root when the
478
+ * caller never named one (a single-membership session resolves the company
479
+ * server-side, so hq-cli holds a uid here, not a slug — and the console routes
480
+ * by slug), or undefined when the console for this control plane is unknown.
481
+ */
482
+ export function consoleIntegrationsUrl(
483
+ // Required, deliberately: a default of `consoleOrigin()` would make an
484
+ // explicit `undefined` — exactly what an unresolvable control plane yields —
485
+ // silently fall back to the production console.
486
+ companySlug, origin) {
487
+ if (!origin)
488
+ return undefined;
489
+ const slug = companySlug?.trim();
490
+ // A `cmp_…` uid is not a slug; the console has no route for it.
491
+ if (!slug || slug.startsWith("cmp_"))
492
+ return origin;
493
+ return `${origin}/companies/${encodeURIComponent(slug)}/integrations`;
494
+ }
495
+ /**
496
+ * What the caller named, phrased for someone about to retype it in the console.
497
+ *
498
+ * A `--mcp-url` or `--docs-url` target has no catalog row to search for over
499
+ * there — `resolveTarget` reduces a docs page to an opaque discovery receipt
500
+ * the console cannot take — so unless the handoff carries the address back,
501
+ * the only copy of it is in the caller's scrollback. A catalog target needs no
502
+ * hint: its label IS what they type into the console's search box.
503
+ */
504
+ function targetSelectionHint(opts) {
505
+ if (opts.mcpUrl)
506
+ return `Server address to enter there: ${opts.mcpUrl}`;
507
+ if (opts.docsUrl)
508
+ return `Found from these docs: ${opts.docsUrl}`;
509
+ if (opts.entryId)
510
+ return `Catalog entry: ${opts.entryId}`;
511
+ return undefined;
512
+ }
513
+ /** Shared tail of every console handoff: the page, then what to connect there. */
514
+ function printConsoleDestination(target, opts, url) {
515
+ if (url) {
516
+ console.error(`Open this page and connect ${target.label} from it:\n ${url}`);
517
+ }
518
+ else {
519
+ console.error(`Open your HQ console's Integrations page and connect ${target.label} there. ` +
520
+ "(This session points at a control plane whose console address is not " +
521
+ "derivable — set HQ_CONSOLE_URL to name it.)");
522
+ }
523
+ const hint = targetSelectionHint(opts);
524
+ if (hint)
525
+ console.error(` ${hint}`);
526
+ }
527
+ /**
528
+ * Fallback for a backend that will not accept a loopback callback: send the
529
+ * caller to the console and let it run the whole sign-in.
530
+ *
531
+ * This deliberately does NOT start an OAuth flow. It used to: it called
532
+ * `/oauth/start` against hq-pro's console callback and printed the vendor
533
+ * authorization URL. That URL could never complete. The console's callback
534
+ * route resolves which company to return to from a short-lived httpOnly cookie
535
+ * that only its own `startFactoryOAuthSignIn` server action sets, so a browser
536
+ * arriving from a CLI-minted URL carries no cookie, and the route — by design,
537
+ * rather than erroring — redirects to the console root. The user signed in
538
+ * successfully at the vendor every time and landed on the home page with no
539
+ * explanation, and the burned single-use state row made it look intermittent.
540
+ *
541
+ * Handing over the console page instead means the sign-in is started by the
542
+ * surface that can finish it. hq-cli still cannot report the outcome, so it
543
+ * says so rather than pretending.
544
+ */
545
+ async function consoleHandoff(target, opts) {
546
+ const url = consoleIntegrationsUrl(opts.company, consoleOrigin());
547
+ console.error(chalk.yellow(`${target.label} signs in through the console, not the terminal — this HQ backend finishes these sign-ins there.`));
548
+ printConsoleDestination(target, opts, url);
549
+ if (url && opts.browser !== false)
550
+ await open(url).catch(() => { });
551
+ console.error(chalk.dim("When the browser says it connected, run `hq integrations list` to confirm."));
552
+ return null;
553
+ }
394
554
  /**
395
- * Fallback for a backend that will not accept a loopback callback: start the
396
- * sign-in against hq-pro's own console callback and hand the URL over. The
397
- * console route finishes the install, so this command cannot report the
398
- * result it reports the handoff truthfully instead of pretending.
555
+ * The provider refuses to register HQ automatically. That is recoverable an
556
+ * admin registers their own OAuth app with the provider and pastes its
557
+ * credentials but only the console can collect them, so say what is needed
558
+ * and hand the page over rather than restating the refusal and stopping.
399
559
  */
400
- async function consoleHandoff(token, companyUid, startInput, target, opts) {
401
- const started = await startOAuth(token, companyUid, startInput);
402
- const name = started.displayName || target.label;
403
- console.error(chalk.yellow(`${name} signs in through the browser, and this HQ backend finishes those sign-ins in the console.`));
404
- console.error(`Open this URL to sign in:\n ${started.authorizationUrl}`);
560
+ async function bringYourOwnClientHandoff(target, opts, reason, redirectUri) {
561
+ console.error(chalk.yellow(reason));
562
+ console.error(`${target.label} needs an OAuth app you register with the provider yourself.`);
563
+ // Prefer finishing here. The recovery needs two things the terminal can
564
+ // supply — the callback URL to register, and a client id + secret — and
565
+ // hq-pro sends the first one back on exactly these errors. Falling through
566
+ // to the console when either half is missing keeps the old behaviour rather
567
+ // than printing a half-command that cannot work.
568
+ const command = byoClientCommand(target, opts);
569
+ if (redirectUri && command) {
570
+ console.error("");
571
+ console.error(`In the provider's developer portal, register this callback URL:`);
572
+ console.error(` ${redirectUri}`);
573
+ console.error(`Then run:`);
574
+ console.error(` ${command}`);
575
+ console.error(chalk.dim("The secret is read from stdin, so it stays out of your shell history. " +
576
+ "Omit --client-secret-stdin for a public app that has no secret."));
577
+ return null;
578
+ }
579
+ const url = consoleIntegrationsUrl(opts.company, consoleOrigin());
580
+ console.error("The console shows the callback URL to register and takes the credentials.");
581
+ printConsoleDestination(target, opts, url);
582
+ if (url && opts.browser !== false)
583
+ await open(url).catch(() => { });
584
+ return null;
585
+ }
586
+ /**
587
+ * The copy-pasteable command that finishes a bring-your-own-app connect.
588
+ *
589
+ * Always `connect --mcp-url`, even when the caller reached here through
590
+ * `reconnect`: re-installing the same endpoint IS the reconnect, and the
591
+ * direct-endpoint path is the only one hq-pro reads user-supplied client
592
+ * credentials on. Null when the endpoint is unknown (a bare-name or domain
593
+ * connect that never resolved to a URL) — there is no honest command to print
594
+ * then, so the caller falls back to the console.
595
+ */
596
+ function byoClientCommand(target, opts) {
597
+ const { mcpUrl, provider } = target.ref;
598
+ if (!mcpUrl)
599
+ return null;
600
+ const parts = ["hq integrations connect", `--mcp-url ${shellQuote(mcpUrl)}`];
601
+ if (opts.company)
602
+ parts.push(`--company ${shellQuote(opts.company)}`);
603
+ if (provider)
604
+ parts.push(`--provider ${shellQuote(provider)}`);
605
+ parts.push("--client-id <client-id-from-the-provider>", "--client-secret-stdin");
606
+ return parts.join(" ");
607
+ }
608
+ /**
609
+ * Refuse a `--client-id` that hq-pro would silently ignore, before anything is
610
+ * installed.
611
+ *
612
+ * hq-pro reads user-supplied client credentials only on the direct `mcpUrl`
613
+ * path — on a catalog, domain, or discovery-receipt connect it nulls them out
614
+ * and resolves its own client instead. Passing them there would not fail: the
615
+ * connect would attempt dynamic registration exactly as before and come back
616
+ * with the same refusal, leaving the caller certain they had supplied
617
+ * credentials that were never read. Say so instead.
618
+ */
619
+ function assertUserClientApplicable(opts, target, authMode) {
620
+ if (!opts.clientId && !opts.clientSecretStdin)
621
+ return;
622
+ if (!opts.clientId) {
623
+ throw new IntegrationsCliError("--client-secret-stdin needs --client-id — a secret identifies no app on its own.", { expected: true });
624
+ }
625
+ if (opts.token || opts.tokenStdin) {
626
+ throw new IntegrationsCliError("--client-id is for a browser sign-in and --token is for an API key; pass one or the other, not both.", { expected: true });
627
+ }
628
+ // Only an mode the caller or the catalog stated. An UNKNOWN mode is left
629
+ // alone on purpose: the direct-endpoint path routinely discovers it is
630
+ // OAuth-protected only when hq-pro says so, and rejecting here would block
631
+ // the exact providers this flag exists for.
632
+ if (authMode === "key" || authMode === "none") {
633
+ throw new IntegrationsCliError(`${target.label} does not use a browser sign-in, so --client-id does not apply to it.`, { expected: true });
634
+ }
635
+ if (!target.ref.mcpUrl) {
636
+ throw new IntegrationsCliError("Your own OAuth app can only be used with a specific endpoint. " +
637
+ "Re-run with --mcp-url <endpoint> (find it with `hq integrations inspect`), " +
638
+ "or connect it from the console.", { expected: true });
639
+ }
640
+ }
641
+ /**
642
+ * Collect the user-registered OAuth app, if one was asked for.
643
+ *
644
+ * The secret has no argv flag by design. A client secret is a long-lived
645
+ * credential, and argv is visible in shell history and to `ps`; stdin covers
646
+ * the scripted case and a hidden prompt covers the interactive one, so there
647
+ * is no case left that an argv flag would serve.
648
+ */
649
+ async function resolveUserOAuthClient(opts, appLabel) {
650
+ const clientId = opts.clientId?.trim();
651
+ if (!clientId)
652
+ return null;
653
+ if (opts.clientSecretStdin) {
654
+ const piped = (await readStdin()).trim();
655
+ if (!piped) {
656
+ throw new IntegrationsCliError("--client-secret-stdin was set but stdin was empty.", { expected: true });
657
+ }
658
+ return { clientId, clientSecret: piped };
659
+ }
660
+ if (process.stdin.isTTY) {
661
+ const value = (await promptForKey(`Client secret for ${appLabel} (leave blank if the app has none)`)).trim();
662
+ return value ? { clientId, clientSecret: value } : { clientId };
663
+ }
664
+ // Non-interactive and no secret piped: a public client is legitimate, so
665
+ // proceed with the id alone rather than inventing a blank secret.
666
+ return { clientId };
667
+ }
668
+ /**
669
+ * Sign in with an OAuth app the admin registered themselves.
670
+ *
671
+ * No loopback listener, deliberately. The whole premise is that the person
672
+ * registered ONE redirect URI with the provider ahead of time, and an
673
+ * ephemeral loopback port can never be one of them — so this pins hq-pro's
674
+ * console callback by omitting `redirectUri`, and the sign-in completes on the
675
+ * console. hq-cli cannot observe that completion, so it says what it knows and
676
+ * points at the check, rather than reporting an install it never saw.
677
+ *
678
+ * This requires the console callback to be able to finish a flow it did not
679
+ * start. It could not until hq-pro/#2823 + hq-console/#783: the return company
680
+ * came from a cookie only the console's own start action set, so a CLI-minted
681
+ * URL arrived cookie-less and the route dropped the user on the console root
682
+ * with nothing connected — see `consoleHandoff` below, which exists because of
683
+ * exactly that. hq-pro now resolves the company from the state row when
684
+ * `companyUid` is omitted, so the callback completes and the console names the
685
+ * app it connected. Ship this only against a backend carrying both.
686
+ */
687
+ async function userClientSignIn(token, companyUid, target, opts, client) {
688
+ const started = await startOAuth(token, companyUid, {
689
+ ...target.ref,
690
+ clientId: client.clientId,
691
+ ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
692
+ });
693
+ console.error(`Open this URL to sign in to ${started.displayName || target.label}:`);
694
+ console.error(` ${started.authorizationUrl}`);
405
695
  if (opts.browser !== false)
406
696
  await open(started.authorizationUrl).catch(() => { });
407
- console.error(chalk.dim("When the browser says it connected, run `hq integrations list` to confirm."));
697
+ console.error(chalk.dim("The browser finishes on the HQ console. When it says connected, " +
698
+ "run `hq integrations list` to confirm."));
408
699
  return null;
409
700
  }
410
701
  /**
@@ -459,6 +750,7 @@ function reportInstall(result, opts, expectedRevivedConnectionId) {
459
750
  async function connectApp(token, companyUid, app, opts, expectedRevivedConnectionId) {
460
751
  const target = await resolveTarget(token, companyUid, app, opts, expectedRevivedConnectionId !== undefined);
461
752
  const authMode = opts.auth ?? target.authClass;
753
+ assertUserClientApplicable(opts, target, authMode);
462
754
  if (authMode === "oauth") {
463
755
  const result = await connectViaOAuth(token, companyUid, target, opts);
464
756
  if (result)
@@ -609,6 +901,8 @@ export function registerConnectCommands(integrations) {
609
901
  .option("--token <key>", "API key (prefer --token-stdin: --token lands in shell history)")
610
902
  .option("--token-stdin", "Read the API key from stdin")
611
903
  .option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
904
+ .option("--client-id <id>", "Client id of an OAuth app you registered with the provider yourself")
905
+ .option("--client-secret-stdin", "Read that app's client secret from stdin")
612
906
  .option("--no-browser", "Print the sign-in URL instead of opening a browser")
613
907
  .option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
614
908
  .option("--json", "Machine-readable output")
@@ -627,6 +921,8 @@ export function registerConnectCommands(integrations) {
627
921
  .option("--token <key>", "API key (prefer --token-stdin)")
628
922
  .option("--token-stdin", "Read the API key from stdin")
629
923
  .option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
924
+ .option("--client-id <id>", "Client id of an OAuth app you registered with the provider yourself")
925
+ .option("--client-secret-stdin", "Read that app's client secret from stdin")
630
926
  .option("--no-browser", "Print the sign-in URL instead of opening a browser")
631
927
  .option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
632
928
  .option("--connect", "For a revoked row, run `connect <domain>` to re-add and revive it")
@@ -673,6 +969,13 @@ export function registerConnectCommands(integrations) {
673
969
  ...(opts.auth ? { authClass: opts.auth } : {}),
674
970
  label: connection.installation?.displayName ?? bareProvider(connection.provider),
675
971
  };
972
+ // Same gate `connectApp` applies. This path does not go through it —
973
+ // it resolves the endpoint off the existing connection and branches
974
+ // itself — so without this, flags the CLI promises to reject are
975
+ // silently ignored instead: `--client-secret-stdin` with no id would
976
+ // start an ordinary sign-in, and `--client-id` beside `--token` would
977
+ // reinstall with the bearer token and drop the OAuth app.
978
+ assertUserClientApplicable(opts, target, target.authClass);
676
979
  if (target.authClass === "oauth") {
677
980
  const result = await connectViaOAuth(token, companyUid, target, opts);
678
981
  if (result)
@@ -150,12 +150,23 @@ export declare class IntegrationsCliError extends Error {
150
150
  * fingerprint as anything but the finite allowlist in sentry-fingerprint.ts.
151
151
  */
152
152
  readonly rpcCode?: number;
153
+ /**
154
+ * HQ's own OAuth callback URL, carried on the errors that say a provider
155
+ * refused to register HQ automatically. It is what the admin must register
156
+ * with the provider before their own OAuth app can be used, and it is NOT a
157
+ * secret — every authorization server already receives it in the authorize
158
+ * URL. Taken from the response rather than assembled locally so the CLI
159
+ * prints the callback this backend will actually redirect to, instead of a
160
+ * guess at its route that would send the admin to register the wrong URL.
161
+ */
162
+ readonly redirectUri?: string;
153
163
  constructor(message: string, opts?: {
154
164
  expected?: boolean;
155
165
  code?: string;
156
166
  status?: number;
157
167
  oauthProtected?: boolean;
158
168
  rpcCode?: number;
169
+ redirectUri?: string;
159
170
  });
160
171
  }
161
172
  /**
@@ -51,6 +51,16 @@ export class IntegrationsCliError extends Error {
51
51
  * fingerprint as anything but the finite allowlist in sentry-fingerprint.ts.
52
52
  */
53
53
  rpcCode;
54
+ /**
55
+ * HQ's own OAuth callback URL, carried on the errors that say a provider
56
+ * refused to register HQ automatically. It is what the admin must register
57
+ * with the provider before their own OAuth app can be used, and it is NOT a
58
+ * secret — every authorization server already receives it in the authorize
59
+ * URL. Taken from the response rather than assembled locally so the CLI
60
+ * prints the callback this backend will actually redirect to, instead of a
61
+ * guess at its route that would send the admin to register the wrong URL.
62
+ */
63
+ redirectUri;
54
64
  constructor(message, opts = {}) {
55
65
  super(message);
56
66
  this.name = "IntegrationsCliError";
@@ -63,6 +73,8 @@ export class IntegrationsCliError extends Error {
63
73
  this.oauthProtected = opts.oauthProtected;
64
74
  if (opts.rpcCode !== undefined)
65
75
  this.rpcCode = opts.rpcCode;
76
+ if (opts.redirectUri !== undefined)
77
+ this.redirectUri = opts.redirectUri;
66
78
  }
67
79
  }
68
80
  /**
@@ -184,13 +196,34 @@ export async function raiseForResponse(res, fallback) {
184
196
  // print verbatim — scrub credentials out before it can reach a terminal or a
185
197
  // Sentry crash report (same reasoning as the gateway path below).
186
198
  const detail = redactErrorText(body.error ?? "");
199
+ const redirectUri = httpsUrlOrNull(body.redirectUri);
187
200
  throw new IntegrationsCliError(detail || `${fallback} (HTTP ${res.status})`, {
188
201
  expected: isClientError(res.status),
189
202
  status: res.status,
190
203
  ...(body.code ? { code: body.code } : {}),
191
204
  ...(body.oauthProtected === true ? { oauthProtected: true } : {}),
205
+ ...(redirectUri ? { redirectUri } : {}),
192
206
  });
193
207
  }
208
+ /**
209
+ * Accept a response field only if it really is an absolute https URL.
210
+ *
211
+ * This one is printed as an instruction — "register THIS with the provider" —
212
+ * so a malformed or non-https value must be dropped rather than echoed: the
213
+ * caller's fallback (send them to the console, which shows the same callback)
214
+ * is correct, while a bad URL would have them register a callback that can
215
+ * never complete a sign-in.
216
+ */
217
+ function httpsUrlOrNull(value) {
218
+ if (typeof value !== "string" || !value.trim())
219
+ return null;
220
+ try {
221
+ return new URL(value).protocol === "https:" ? value : null;
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ }
194
227
  /** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
195
228
  export function toolPrefixForProvider(provider) {
196
229
  return provider
@@ -2,6 +2,8 @@ import { type UtilityIo } from "./common.js";
2
2
  export type TokenUsageReportOptions = UtilityIo & {
3
3
  projectDir?: string;
4
4
  now?: Date;
5
+ homeDir?: string;
6
+ menubarPath?: string;
5
7
  };
6
8
  /** Print token usage totals from Claude session JSONL files. */
7
9
  export declare function tokenUsageReport(args?: string[], options?: TokenUsageReportOptions): number;
@@ -11,12 +11,27 @@ const parseJsonl = (file) => fs.readFileSync(file, "utf8").split(/\r?\n/).flatMa
11
11
  catch {
12
12
  return [];
13
13
  } });
14
- const listJsonl = (dir) => { try {
15
- return fs.readdirSync(dir).filter((name) => name.endsWith(".jsonl")).sort();
16
- }
17
- catch {
18
- return [];
19
- } };
14
+ const listJsonl = (dir) => {
15
+ const found = [];
16
+ const visit = (current, relative) => {
17
+ let entries;
18
+ try {
19
+ entries = fs.readdirSync(current, { withFileTypes: true });
20
+ }
21
+ catch {
22
+ return;
23
+ }
24
+ for (const entry of entries) {
25
+ const nextRelative = path.join(relative, entry.name);
26
+ if (entry.isDirectory() && entry.name !== "subagents")
27
+ visit(path.join(current, entry.name), nextRelative);
28
+ else if (entry.isFile() && entry.name.endsWith(".jsonl"))
29
+ found.push(nextRelative);
30
+ }
31
+ };
32
+ visit(dir, "");
33
+ return found.sort();
34
+ };
20
35
  const readNumber = (value) => typeof value === "number" ? value : 0;
21
36
  function firstUser(record) { const content = record.message?.content; if (typeof content === "string")
22
37
  return content.slice(0, 120); if (Array.isArray(content) && content.length) {
@@ -24,10 +39,42 @@ function firstUser(record) { const content = record.message?.content; if (typeof
24
39
  if (first && typeof first === "object" && typeof first.text === "string")
25
40
  return first.text.slice(0, 120);
26
41
  } return ""; }
27
- function comparison(projectDir, before, after, minHours, json, stdout) {
42
+ function savedClaudeProjectsDir(menubarPath) {
43
+ try {
44
+ const value = JSON.parse(fs.readFileSync(menubarPath, "utf8")).claudeProjectsDir;
45
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function resolveProjectDirs(options) {
52
+ if (options.projectDir)
53
+ return [path.resolve(options.projectDir)];
54
+ const projectsEnv = process.env.CLAUDE_PROJECTS_DIR?.trim();
55
+ if (projectsEnv)
56
+ return [path.resolve(projectsEnv)];
57
+ const home = options.homeDir ?? os.homedir();
58
+ const configEnv = process.env.CLAUDE_CONFIG_DIR?.trim();
59
+ if (configEnv)
60
+ return [path.resolve(configEnv, "projects")];
61
+ const roots = [
62
+ path.join(home, ".claude", "projects"),
63
+ savedClaudeProjectsDir(options.menubarPath ?? path.join(home, ".hq", "menubar.json")),
64
+ ];
65
+ try {
66
+ for (const entry of fs.readdirSync(home, { withFileTypes: true })) {
67
+ if (entry.isDirectory() && (entry.name === ".claude" || entry.name.startsWith(".claude-")))
68
+ roots.push(path.join(home, entry.name, "projects"));
69
+ }
70
+ }
71
+ catch { /* standard and saved fallbacks still apply */ }
72
+ return [...new Set(roots.filter((value) => Boolean(value)).map((value) => path.resolve(value)))];
73
+ }
74
+ function comparison(projectDirs, before, after, minHours, json, stdout) {
28
75
  const parseRange = (range) => range.split(":").map((value) => new Date(`${value}T00:00:00Z`));
29
76
  const [bStart, bEnd] = parseRange(before), [aStart, aEnd] = parseRange(after);
30
- const collect = (start, end) => listJsonl(projectDir).flatMap((name) => {
77
+ const collect = (start, end) => projectDirs.flatMap((projectDir) => listJsonl(projectDir).flatMap((name) => {
31
78
  const rows = parseJsonl(path.join(projectDir, name));
32
79
  let cr = 0, first, last;
33
80
  for (const row of rows) {
@@ -47,7 +94,7 @@ function comparison(projectDir, before, after, minHours, json, stdout) {
47
94
  if (hours < minHours)
48
95
  return [];
49
96
  return [{ sid: name.replace(/\.jsonl$/, "").slice(0, 8), hours: Math.round(hours * 10) / 10, cr, cr_per_hour: Math.trunc(cr / hours) }];
50
- });
97
+ }));
51
98
  const bRows = collect(bStart, bEnd), aRows = collect(aStart, aEnd);
52
99
  const median = (rows) => { if (!rows.length)
53
100
  return 0; const values = rows.map((row) => row.cr_per_hour).sort((a, b) => a - b); const mid = Math.floor(values.length / 2); return Math.trunc(values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2); };
@@ -111,62 +158,60 @@ export function tokenUsageReport(args = [], options = {}) {
111
158
  return 1;
112
159
  }
113
160
  }
114
- // Preserve the bundled shell asset's historical parameter-expansion parsing:
115
- // the `}` in `{your-name}` terminates `${CLAUDE_PROJECTS_DIR:-…}` early, so
116
- // its literal `-Documents-HQ}` suffix remains even when the environment
117
- // variable is set. `projectDir` is the explicit native test/integration seam.
118
- const projectDir = options.projectDir ?? `${process.env.CLAUDE_PROJECTS_DIR ?? path.join(os.homedir(), ".claude/projects/-Users-{your-name")}-Documents-HQ}`;
161
+ const projectDirs = resolveProjectDirs(options).filter((dir) => fs.existsSync(dir) && fs.statSync(dir).isDirectory());
119
162
  if (before && after) {
120
- comparison(projectDir, before, after, minHours, json, stdout);
163
+ comparison(projectDirs, before, after, minHours, json, stdout);
121
164
  return 0;
122
165
  }
123
- if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
124
- line(stderr, `Project dir not found: ${projectDir}`);
166
+ if (!projectDirs.length) {
167
+ line(stderr, `Claude activity folders not found`);
125
168
  return 1;
126
169
  }
127
170
  const now = options.now ?? new Date();
128
171
  const cutoff = since || dayOf(new Date(now.getTime() - (lastDays - 1) * 86_400_000));
129
172
  const days = new Map(), sessions = new Map();
130
- for (const name of listJsonl(projectDir)) {
131
- const file = path.join(projectDir, name);
132
- const stat = fs.statSync(file);
133
- const mtime = dayOf(stat.mtime);
134
- if (mtime < cutoff)
135
- continue;
136
- let inp = 0, out = 0, cc = 0, cr = 0, firstTs = "", lastTs = "", first = "";
137
- for (const record of parseJsonl(file)) {
138
- const usage = record.message?.usage;
139
- inp += readNumber(usage?.input_tokens);
140
- out += readNumber(usage?.output_tokens);
141
- cc += readNumber(usage?.cache_creation_input_tokens);
142
- cr += readNumber(usage?.cache_read_input_tokens);
143
- if (record.timestamp) {
144
- if (!firstTs)
145
- firstTs = record.timestamp;
146
- lastTs = record.timestamp;
173
+ for (const projectDir of projectDirs)
174
+ for (const name of listJsonl(projectDir)) {
175
+ const file = path.join(projectDir, name);
176
+ const stat = fs.statSync(file);
177
+ const mtime = dayOf(stat.mtime);
178
+ if (mtime < cutoff)
179
+ continue;
180
+ let inp = 0, out = 0, cc = 0, cr = 0, firstTs = "", lastTs = "", first = "";
181
+ for (const record of parseJsonl(file)) {
182
+ const usage = record.message?.usage;
183
+ inp += readNumber(usage?.input_tokens);
184
+ out += readNumber(usage?.output_tokens);
185
+ cc += readNumber(usage?.cache_creation_input_tokens);
186
+ cr += readNumber(usage?.cache_read_input_tokens);
187
+ if (record.timestamp) {
188
+ if (!firstTs)
189
+ firstTs = record.timestamp;
190
+ lastTs = record.timestamp;
191
+ }
192
+ if (!first && record.type === "user")
193
+ first = firstUser(record);
147
194
  }
148
- if (!first && record.type === "user")
149
- first = firstUser(record);
150
- }
151
- const sid = name.replace(/\.jsonl$/, "");
152
- const subagents = (() => { try {
153
- return fs.readdirSync(path.join(projectDir, sid, "subagents")).filter((entry) => entry.endsWith(".jsonl")).length;
195
+ const localSid = name.replace(/\.jsonl$/, "");
196
+ const sid = `${path.basename(path.dirname(projectDir))}/${localSid}`;
197
+ const subagents = (() => { try {
198
+ return fs.readdirSync(path.join(projectDir, localSid, "subagents")).filter((entry) => entry.endsWith(".jsonl")).length;
199
+ }
200
+ catch {
201
+ return 0;
202
+ } })();
203
+ const day = (firstTs || lastTs || "").slice(0, 10) || mtime;
204
+ if (day < cutoff)
205
+ continue;
206
+ const current = days.get(day) ?? { sessions: new Set(), inp: 0, out: 0, cc: 0, cr: 0 };
207
+ current.sessions.add(sid);
208
+ current.inp += inp;
209
+ current.out += out;
210
+ current.cc += cc;
211
+ current.cr += cr;
212
+ days.set(day, current);
213
+ sessions.set(sid, { day, inp, out, cc, cr, eff: Math.trunc(inp + 5 * out + 1.25 * cc + .1 * cr), subagents, first_user: first.replaceAll("\n", " ") });
154
214
  }
155
- catch {
156
- return 0;
157
- } })();
158
- const day = (firstTs || lastTs || "").slice(0, 10) || mtime;
159
- if (day < cutoff)
160
- continue;
161
- const current = days.get(day) ?? { sessions: new Set(), inp: 0, out: 0, cc: 0, cr: 0 };
162
- current.sessions.add(sid);
163
- current.inp += inp;
164
- current.out += out;
165
- current.cc += cc;
166
- current.cr += cr;
167
- days.set(day, current);
168
- sessions.set(sid, { day, inp, out, cc, cr, eff: Math.trunc(inp + 5 * out + 1.25 * cc + .1 * cr), subagents, first_user: first.replaceAll("\n", " ") });
169
- }
170
215
  const sorted = [...days.keys()].sort();
171
216
  const dayRows = sorted.map((day) => { const value = days.get(day); return { date: day, sessions: value.sessions.size, input: value.inp, output: value.out, cache_create: value.cc, cache_read: value.cr, effective: Math.trunc(value.inp + 5 * value.out + 1.25 * value.cc + .1 * value.cr) }; });
172
217
  const recent = sorted.at(-1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.105.0",
3
+ "version": "5.105.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
32
32
  "@aws-sdk/client-s3": "^3.1049.0",
33
- "@indigoai-us/hq-cloud": "~6.16.0",
33
+ "@indigoai-us/hq-cloud": "~6.16.3",
34
34
  "@indigoai-us/hq-onboarding": "^0.1.0",
35
35
  "@sentry/node": "^10.49.0",
36
36
  "@tobilu/qmd": "2.5.3",