@indigoai-us/hq-cli 5.345.142 → 5.345.144

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/command-catalog.generated.d.ts +278 -6
  3. package/dist/command-catalog.generated.js +341 -6
  4. package/dist/command-registration-plan.d.ts +6 -0
  5. package/dist/command-registration-plan.js +1 -0
  6. package/dist/commands/agent-enroll.d.ts +1 -0
  7. package/dist/commands/agent-enroll.js +7 -0
  8. package/dist/commands/agents.d.ts +25 -3
  9. package/dist/commands/agents.js +57 -4
  10. package/dist/commands/api-keys.js +66 -8
  11. package/dist/commands/billing-sponsor.d.ts +4 -0
  12. package/dist/commands/billing-sponsor.js +48 -5
  13. package/dist/commands/billing-usage.d.ts +59 -0
  14. package/dist/commands/billing-usage.js +162 -0
  15. package/dist/commands/billing.d.ts +9 -0
  16. package/dist/commands/billing.js +29 -2
  17. package/dist/commands/core-policy.js +76 -13
  18. package/dist/commands/library-add.d.ts +120 -0
  19. package/dist/commands/library-add.js +434 -0
  20. package/dist/commands/library-caption.d.ts +112 -0
  21. package/dist/commands/library-caption.js +393 -0
  22. package/dist/commands/library-link.d.ts +32 -0
  23. package/dist/commands/library-link.js +95 -0
  24. package/dist/commands/library.d.ts +72 -0
  25. package/dist/commands/library.js +527 -0
  26. package/dist/commands/meetings.d.ts +8 -0
  27. package/dist/commands/meetings.js +25 -2
  28. package/dist/commands/storage-offload.d.ts +16 -2
  29. package/dist/commands/storage-offload.js +55 -4
  30. package/dist/commands/storage.d.ts +8 -1
  31. package/dist/commands/storage.js +6 -2
  32. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2410 -2063
  33. package/dist/hq-cloud-sync-runner/package.json +1 -1
  34. package/dist/lib/billing/plan-lock.d.ts +15 -1
  35. package/dist/lib/billing/plan-lock.js +45 -5
  36. package/dist/lib/billing/usage-card.d.ts +82 -0
  37. package/dist/lib/billing/usage-card.js +198 -0
  38. package/dist/lib/billing/usage-cost.d.ts +214 -0
  39. package/dist/lib/billing/usage-cost.js +440 -0
  40. package/dist/lib/billing/usage-only.d.ts +87 -0
  41. package/dist/lib/billing/usage-only.js +262 -0
  42. package/dist/lib/billing/usage-rate-card.d.ts +51 -0
  43. package/dist/lib/billing/usage-rate-card.js +62 -0
  44. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  45. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  46. package/dist/lib/plan-limit-nag.d.ts +4 -6
  47. package/dist/lib/plan-limit-nag.js +19 -1
  48. package/dist/lib/provider-launcher.d.ts +22 -0
  49. package/dist/lib/provider-launcher.js +65 -0
  50. package/dist/lib/search-index/index.d.ts +2 -0
  51. package/dist/lib/search-index/index.js +34 -0
  52. package/dist/lib/search-index/max-doc-bytes.d.ts +2 -1
  53. package/dist/lib/search-index/max-doc-bytes.js +2 -1
  54. package/dist/main.d.ts +9 -0
  55. package/dist/main.js +20 -3
  56. package/dist/utils/billing-gate.d.ts +19 -4
  57. package/dist/utils/billing-gate.js +25 -4
  58. package/dist/utils/plan-gate-error.d.ts +10 -0
  59. package/dist/utils/plan-gate-error.js +54 -0
  60. package/dist/utils/vault-api.js +4 -1
  61. package/package.json +2 -2
@@ -32,6 +32,7 @@
32
32
  * writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1` silences all
33
33
  * three surfaces, the threshold notice included.
34
34
  */
35
+ import { type PricingPlan } from "./billing/usage-only.js";
35
36
  export declare const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
36
37
  /**
37
38
  * Cadence of the over-limit per-turn line (US-036). Was once a day; the nag
@@ -67,11 +68,6 @@ export interface PlanLimitThresholdCrossing {
67
68
  used: number;
68
69
  limit: number;
69
70
  }
70
- /**
71
- * Record plan-limit status from a decoded JSON response body.
72
- * Never throws. Overwrites the last-seen cell when a well-formed
73
- * `planLimits` object is present; ignores malformed / absent payloads.
74
- */
75
71
  export declare function recordPlanLimitStatus(body: unknown, opts?: {
76
72
  write?: (s: string) => void;
77
73
  statePath?: string;
@@ -104,7 +100,9 @@ export declare const INDIVIDUAL_PRICE_LABEL = "$50/mo";
104
100
  * own checkout target; an older hq-pro sends none, and the line then names the
105
101
  * plan without a link rather than inventing one.
106
102
  */
107
- export declare function renderPersonalPlanFeatureNotice(feature: string, upgradeUrl?: string | null): string;
103
+ export declare function renderPersonalPlanFeatureNotice(feature: string, upgradeUrl?: string | null, opts?: {
104
+ pricingPlan?: PricingPlan | null;
105
+ }): string;
108
106
  /**
109
107
  * One line per crossing.
110
108
  *
@@ -39,6 +39,7 @@ import * as path from "node:path";
39
39
  import { resolveProcessFlagCompanyContext } from "./flag-registry.js";
40
40
  import { isCliLimitResourceKind, queueCliLimitNoticeExposure, } from "../utils/cli-telemetry.js";
41
41
  import { planLimitUpgradeFallbackUrl, selectPlanLimitUpgradeUrl, } from "./billing/plan-limit-upgrade-url.js";
42
+ import { isUsageOnly, pricingPlanFromBody, usageOnlyPersonalFeatureNotice, } from "./billing/usage-only.js";
42
43
  export const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
43
44
  /**
44
45
  * Cadence of the over-limit per-turn line (US-036). Was once a day; the nag
@@ -220,8 +221,21 @@ function parseThresholdCrossings(body) {
220
221
  * Never throws. Overwrites the last-seen cell when a well-formed
221
222
  * `planLimits` object is present; ignores malformed / absent payloads.
222
223
  */
224
+ /** Set once a body in this process says the account is on usage pricing. */
225
+ let usageOnlySeen = false;
223
226
  export function recordPlanLimitStatus(body, opts = {}) {
224
227
  try {
228
+ // Usage pricing (US-012): a body that says the account is on usage
229
+ // pricing carries no Starter caps to nag about. Drop any earlier Starter
230
+ // status so the completion nag stays silent too.
231
+ // The flag is sticky for the process: a later 2xx body that carries
232
+ // `planLimits` without `pricingPlan` (an older route) must not bring the
233
+ // Starter nag back for the same command.
234
+ if (usageOnlySeen || isUsageOnly(pricingPlanFromBody(body))) {
235
+ usageOnlySeen = true;
236
+ lastSeen = null;
237
+ return;
238
+ }
225
239
  const status = parsePlanLimits(body);
226
240
  const crossings = parseThresholdCrossings(body);
227
241
  let resolvedUpgradeUrl = null;
@@ -384,8 +398,11 @@ const PERSONAL_FEATURE_LABELS = {
384
398
  * own checkout target; an older hq-pro sends none, and the line then names the
385
399
  * plan without a link rather than inventing one.
386
400
  */
387
- export function renderPersonalPlanFeatureNotice(feature, upgradeUrl) {
401
+ export function renderPersonalPlanFeatureNotice(feature, upgradeUrl, opts = {}) {
388
402
  const label = PERSONAL_FEATURE_LABELS[feature] ?? feature;
403
+ // A person on usage pricing has no Individual plan to buy (US-012).
404
+ if (isUsageOnly(opts.pricingPlan))
405
+ return usageOnlyPersonalFeatureNotice(label);
389
406
  const headline = `${label} is included with the HQ ${INDIVIDUAL_PLAN_NAME} plan (${INDIVIDUAL_PRICE_LABEL}).`;
390
407
  const action = typeof upgradeUrl === "string" && upgradeUrl.trim().length > 0
391
408
  ? `Upgrade: ${upgradeUrl.trim()}`
@@ -517,6 +534,7 @@ export function emitPlanLimitNag(opts = {}) {
517
534
  /** Test-only helper — clears last-seen status and session dedupe flags. */
518
535
  export function _resetForTests() {
519
536
  lastSeen = null;
537
+ usageOnlySeen = false;
520
538
  warningShownThisSession = false;
521
539
  overShownThisSession = false;
522
540
  thresholdsShownThisSession.clear();
@@ -8,4 +8,26 @@ export interface ProviderLauncherDependencies {
8
8
  }
9
9
  export declare function providerLoginGuidance(provider: ProviderName, output: string): string | null;
10
10
  export declare function runProviderLauncher(providerValue: string, args: readonly string[], dependencies?: ProviderLauncherDependencies): Promise<number>;
11
+ export interface ProviderCaptureOptions {
12
+ spawn?: typeof nodeSpawn;
13
+ cwd?: string;
14
+ /** Kill the child and report `timedOut` after this many milliseconds. */
15
+ timeoutMs: number;
16
+ }
17
+ export type ProviderCaptureResult = {
18
+ kind: "ok";
19
+ stdout: string;
20
+ } | {
21
+ kind: "not-installed";
22
+ } | {
23
+ kind: "not-signed-in";
24
+ guidance: string;
25
+ } | {
26
+ kind: "timed-out";
27
+ } | {
28
+ kind: "error";
29
+ code: number;
30
+ stderrTail: string;
31
+ };
32
+ export declare function captureProviderRun(provider: "claude", args: readonly string[], options: ProviderCaptureOptions): Promise<ProviderCaptureResult>;
11
33
  //# sourceMappingURL=provider-launcher.d.ts.map
@@ -53,6 +53,71 @@ export async function runProviderLauncher(providerValue, args, dependencies = {}
53
53
  });
54
54
  });
55
55
  }
56
+ const CAPTURE_LIMIT = 1024 * 1024;
57
+ /**
58
+ * Run a noninteractive Claude Code command (`claude -p ...`) and capture its
59
+ * output instead of streaming it. stdin is closed so the child never waits on
60
+ * the terminal, and the run is bounded by `timeoutMs`. Never throws.
61
+ */
62
+ const KILL_GRACE_MS = 5_000;
63
+ export async function captureProviderRun(provider, args, options) {
64
+ if (!args.some((arg) => arg === "-p" || arg === "--print")) {
65
+ throw new Error("captureProviderRun needs a noninteractive Claude Code command (-p or --print).");
66
+ }
67
+ let child;
68
+ try {
69
+ child = (options.spawn ?? nodeSpawn)(provider, [...args], {
70
+ stdio: ["ignore", "pipe", "pipe"],
71
+ windowsHide: true,
72
+ ...(options.cwd ? { cwd: options.cwd } : {}),
73
+ });
74
+ }
75
+ catch (err) {
76
+ const code = err.code;
77
+ if (code === "ENOENT")
78
+ return { kind: "not-installed" };
79
+ return { kind: "error", code: 1, stderrTail: err.message };
80
+ }
81
+ let stdout = "";
82
+ let stderr = "";
83
+ child.stdout?.on("data", (chunk) => {
84
+ stdout = `${stdout}${String(chunk)}`.slice(-CAPTURE_LIMIT);
85
+ });
86
+ child.stderr?.on("data", (chunk) => {
87
+ stderr = `${stderr}${String(chunk)}`.slice(-16_384);
88
+ });
89
+ return await new Promise((resolve) => {
90
+ let settled = false;
91
+ const finish = (result) => {
92
+ if (settled)
93
+ return;
94
+ settled = true;
95
+ clearTimeout(timer);
96
+ resolve(result);
97
+ };
98
+ const timer = setTimeout(() => {
99
+ child.kill("SIGTERM");
100
+ const hardKill = setTimeout(() => {
101
+ if (child.exitCode === null && child.signalCode === null)
102
+ child.kill("SIGKILL");
103
+ }, KILL_GRACE_MS);
104
+ hardKill.unref();
105
+ child.unref();
106
+ finish({ kind: "timed-out" });
107
+ }, options.timeoutMs);
108
+ child.once("error", (err) => {
109
+ finish(err.code === "ENOENT" ? { kind: "not-installed" } : { kind: "error", code: 1, stderrTail: err.message });
110
+ });
111
+ child.once("close", (code) => {
112
+ const guidance = providerLoginGuidance(provider, `${stdout.slice(-16_384)}\n${stderr}`);
113
+ if (guidance)
114
+ return finish({ kind: "not-signed-in", guidance });
115
+ if (code === 0)
116
+ return finish({ kind: "ok", stdout });
117
+ finish({ kind: "error", code: code ?? 1, stderrTail: stderr.trim().slice(-500) });
118
+ });
119
+ });
120
+ }
56
121
  function write(stream, message) {
57
122
  (stream ?? process.stderr).write(message);
58
123
  }
@@ -544,6 +544,8 @@ export type SearchCollection = {
544
544
  mask: string;
545
545
  context: string;
546
546
  };
547
+ /** Mask of the per-company `<co>-assets` collection: library asset placeholders. */
548
+ export declare const ASSET_COLLECTION_MASK = "**/*.hqcloud";
547
549
  /** Derive the local qmd collection policy for one HQ tree. */
548
550
  export declare function deriveCollections(hqRoot: string): SearchCollection[];
549
551
  export type ReconcileCollectionsOptions = {
@@ -1353,6 +1353,24 @@ function containsProjectSource(directory) {
1353
1353
  }
1354
1354
  return false;
1355
1355
  }
1356
+ /** Mask of the per-company `<co>-assets` collection: library asset placeholders. */
1357
+ export const ASSET_COLLECTION_MASK = '**/*.hqcloud';
1358
+ const ASSET_SCAN_SKIP_DIRS = new Set(['.git', 'node_modules']);
1359
+ function containsAssetPlaceholder(directory) {
1360
+ if (!fs.existsSync(directory))
1361
+ return false;
1362
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
1363
+ const child = path.join(directory, entry.name);
1364
+ if (entry.isDirectory()) {
1365
+ if (!ASSET_SCAN_SKIP_DIRS.has(entry.name) && containsAssetPlaceholder(child))
1366
+ return true;
1367
+ }
1368
+ else if (entry.isFile() && entry.name.endsWith('.hqcloud') && entry.name !== '.hqcloud') {
1369
+ return true;
1370
+ }
1371
+ }
1372
+ return false;
1373
+ }
1356
1374
  /** Derive the local qmd collection policy for one HQ tree. */
1357
1375
  export function deriveCollections(hqRoot) {
1358
1376
  const root = path.resolve(hqRoot);
@@ -1385,6 +1403,22 @@ export function deriveCollections(hqRoot) {
1385
1403
  context: `Project PRDs and documentation for ${entry.name}.`,
1386
1404
  });
1387
1405
  }
1406
+ // Library asset placeholders can sit anywhere under a company (offload
1407
+ // replaces a file in place), so the collection is rooted at the company and
1408
+ // its mask admits only `.hqcloud` files. A separate collection, rather than
1409
+ // widening the knowledge/projects masks, because reconcileCollections adds
1410
+ // missing collections but never rewrites an existing collection's mask.
1411
+ for (const entry of companies) {
1412
+ const companyDir = path.join(companiesDir, entry.name);
1413
+ if (!containsAssetPlaceholder(companyDir))
1414
+ continue;
1415
+ collections.push({
1416
+ name: `${entry.name}-assets`,
1417
+ path: companyDir,
1418
+ mask: ASSET_COLLECTION_MASK,
1419
+ context: `Library asset placeholders (.hqcloud) for ${entry.name}.`,
1420
+ });
1421
+ }
1388
1422
  const personalKnowledge = path.join(root, 'personal', 'knowledge');
1389
1423
  if (containsIndexedMarkdown(personalKnowledge)) {
1390
1424
  collections.push({
@@ -101,7 +101,8 @@ export declare function resolveIndexMaxDocBytes(opts?: {
101
101
  /**
102
102
  * Extensions a qmd mask selects, lowercased and without the dot.
103
103
  *
104
- * HQ registers exactly two mask shapes today (see `deriveCollections`), but an
104
+ * HQ registers single-extension (`**\/*.md`, `**\/*.hqcloud`) and brace-list
105
+ * (`**\/*.{md,json}`) masks today (see `deriveCollections`), but an
105
106
  * operator's own collection may carry anything. An unrecognised mask returns
106
107
  * `null`, meaning "match every file" — the cap then applies to the whole
107
108
  * collection, which is the safe direction: we may ignore a file qmd would not
@@ -177,7 +177,8 @@ function parseNonNegativeInt(value) {
177
177
  /**
178
178
  * Extensions a qmd mask selects, lowercased and without the dot.
179
179
  *
180
- * HQ registers exactly two mask shapes today (see `deriveCollections`), but an
180
+ * HQ registers single-extension (`**\/*.md`, `**\/*.hqcloud`) and brace-list
181
+ * (`**\/*.{md,json}`) masks today (see `deriveCollections`), but an
181
182
  * operator's own collection may carry anything. An unrecognised mask returns
182
183
  * `null`, meaning "match every file" — the cap then applies to the whole
183
184
  * collection, which is the safe direction: we may ignore a file qmd would not
package/dist/main.d.ts CHANGED
@@ -7,6 +7,8 @@ import "./node-network-compat.js";
7
7
  import { Sentry } from "./sentry.js";
8
8
  export { handleStreamError } from "./stream-error-boundary.js";
9
9
  export type { StreamErrorDependencies } from "./stream-error-boundary.js";
10
+ import { type PlanGateError } from "./utils/plan-gate-error.js";
11
+ import { type PricingPlan } from "./lib/billing/usage-only.js";
10
12
  import { upgradeToTeam } from "./utils/team-upgrade.js";
11
13
  /**
12
14
  * Total time one CLI invocation may await foreground network work. This spans
@@ -43,7 +45,14 @@ export type TopLevelErrorDependencies = {
43
45
  ask?: (question: string) => Promise<string>;
44
46
  openUrl?: (url: string) => Promise<void>;
45
47
  upgradeToTeam?: typeof upgradeToTeam;
48
+ /**
49
+ * Usage pricing (US-012): resolve the account's pricing plan for a plan gate
50
+ * whose 402 body did not carry one. Absent = use the body only.
51
+ */
52
+ resolvePricingPlan?: (planGate: PlanGateError) => Promise<PricingPlan | null>;
46
53
  };
54
+ /** Production resolver: membership row / usage-limits / summary, never interactive. */
55
+ export declare function resolvePlanGatePricingPlan(planGate: PlanGateError): Promise<PricingPlan | null>;
47
56
  export declare const MAX_OWN_DIST_RELATIVE_PATH_BYTES = 256;
48
57
  /** Return a bounded path only when targetPath is beneath packageRoot/dist. */
49
58
  export declare function relativeOwnDistModulePath(packageRootPath: string, targetPath: string): string | undefined;
package/dist/main.js CHANGED
@@ -40,7 +40,8 @@ import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js
40
40
  import { callbackPortBusyMessage, DEFAULT_CALLBACK_PORT } from "./utils/callback-port-busy.js";
41
41
  import { DEFAULT_COGNITO, isMachineIdentity, loadMachineCachedTokens, } from "./utils/cognito-session.js";
42
42
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
43
- import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, } from "./utils/plan-gate-error.js";
43
+ import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, withPricingPlan, } from "./utils/plan-gate-error.js";
44
+ import { isUsageOnly, resolveCompanyPricingPlan, resolvePersonPricingPlan, } from "./lib/billing/usage-only.js";
44
45
  import { upgradeToTeam } from "./utils/team-upgrade.js";
45
46
  import { printCachedVersionNudge, printVersionAndNudge, refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
46
47
  import { enforceVersionGate, getVersionGatePhase, isLocalDependencyInstall, resolveRunningInstall, shouldSkipGate, } from "./utils/version-gate.js";
@@ -375,8 +376,19 @@ export async function runCli() {
375
376
  }
376
377
  }
377
378
  }
379
+ /** Production resolver: membership row / usage-limits / summary, never interactive. */
380
+ export async function resolvePlanGatePricingPlan(planGate) {
381
+ if (planGate.code === "PERSONAL_PLAN_REQUIRED")
382
+ return resolvePersonPricingPlan({ timeoutMs: 3000 });
383
+ const companyUid = planGate.details.companyUid;
384
+ if (typeof companyUid === "string" && /^cmp_[A-Za-z0-9_-]+$/.test(companyUid)) {
385
+ return resolveCompanyPricingPlan(companyUid, { timeoutMs: 3000 });
386
+ }
387
+ return null;
388
+ }
378
389
  const defaultTopLevelErrorDependencies = {
379
390
  sentry: Sentry,
391
+ resolvePricingPlan: resolvePlanGatePricingPlan,
380
392
  stderr: process.stderr,
381
393
  setExitCode: (code) => {
382
394
  process.exitCode = code;
@@ -562,7 +574,11 @@ function ownDistModuleMissingCaptureContext(err) {
562
574
  export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
563
575
  // A typed plan gate, or an hq-cloud vault error carrying a plan-limit
564
576
  // refusal (402 / PLAN_LIMIT_* from a sync, share or skill upload).
565
- const planGate = planGateErrorFromVaultError(err);
577
+ const decodedPlanGate = planGateErrorFromVaultError(err);
578
+ const planGate = decodedPlanGate && !decodedPlanGate.details.pricingPlan && deps.resolvePricingPlan
579
+ ? withPricingPlan(decodedPlanGate, await deps.resolvePricingPlan(decodedPlanGate).catch(() => null))
580
+ : decodedPlanGate;
581
+ const usageOnlyGate = isUsageOnly(planGate?.details.pricingPlan);
566
582
  // A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
567
583
  // (`hq … | head`, `source <(hq …)`, a parent that exited). That is normal
568
584
  // Unix behavior with no user-facing degradation — exit cleanly (0) and
@@ -654,7 +670,8 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
654
670
  ? queueCliLimitNoticeExposure(planGate.details.resource, "refusal", planGate.details.companyUid)
655
671
  : null;
656
672
  deps.setExitCode(1);
657
- if (interactive && planGate.details.upgradeUrl) {
673
+ // A usage-only account has no plan to buy: no checkout or upgrade offer.
674
+ if (interactive && planGate.details.upgradeUrl && !usageOnlyGate) {
658
675
  const companyUid = planGate.details.companyUid;
659
676
  const isCompanyWorkforceCheckout = typeof companyUid === "string" &&
660
677
  /^cmp_[A-Za-z0-9_-]+$/.test(companyUid) &&
@@ -15,14 +15,29 @@
15
15
  * 3. `hq billing checkout` (see commands/billing.ts) reuses `mintPaymentLink`
16
16
  * to hand out the same link proactively.
17
17
  *
18
- * Prices mirror the console's authoritative constants
18
+ * Legacy prices mirror the console's authoritative constants
19
19
  * (hq-console `src/lib/billing-pricing.ts`): amounts are in the smallest
20
- * currency unit (US cents) to match Stripe's wire shape.
20
+ * currency unit (US cents) to match Stripe's wire shape. They apply to
21
+ * accounts on the plans that existed before usage pricing.
22
+ *
23
+ * Usage pricing (US-012): boxes are quoted by size from the usage card
24
+ * (`src/lib/billing/usage-card.ts`, `GET /v1/pricing` with a bundled
25
+ * fallback): small t3.small, medium t3.medium, large t3.large, xlarge
26
+ * t3.xlarge. Use {@link usageBoxQuotes} / {@link usageBoxQuoteCents}, never
27
+ * the legacy constants, for a usage-only account.
21
28
  */
22
- /** Per-Agent monthly list price, in cents ($100.00 / agent / month). */
29
+ import { type UsageCard } from "../lib/billing/usage-card.js";
30
+ /** Legacy per-Agent monthly list price, in cents ($100.00 / agent / month). */
23
31
  export declare const AGENT_PRICE_CENTS = 10000;
24
- /** Per-Outpost monthly list price, in cents ($80.00 / Outpost / month). */
32
+ /** Legacy per-Outpost monthly list price, in cents ($80.00 / Outpost / month). */
25
33
  export declare const OUTPOST_PRICE_CENTS = 8000;
34
+ /** Usage-pricing box sizes, in card order ("small (t3.small) $100/month, ..."). */
35
+ export declare function usageBoxQuotes(card?: UsageCard): string;
36
+ /**
37
+ * Monthly price of one usage-pricing box, in cents, by size or instance type.
38
+ * Null for a size the card does not list.
39
+ */
40
+ export declare function usageBoxQuoteCents(sizeOrInstanceType: string, card?: UsageCard): number | null;
26
41
  /** Render a cents amount as a `$X.YZ` USD string. */
27
42
  export declare function formatUsd(cents: number): string;
28
43
  /**
@@ -15,16 +15,37 @@
15
15
  * 3. `hq billing checkout` (see commands/billing.ts) reuses `mintPaymentLink`
16
16
  * to hand out the same link proactively.
17
17
  *
18
- * Prices mirror the console's authoritative constants
18
+ * Legacy prices mirror the console's authoritative constants
19
19
  * (hq-console `src/lib/billing-pricing.ts`): amounts are in the smallest
20
- * currency unit (US cents) to match Stripe's wire shape.
20
+ * currency unit (US cents) to match Stripe's wire shape. They apply to
21
+ * accounts on the plans that existed before usage pricing.
22
+ *
23
+ * Usage pricing (US-012): boxes are quoted by size from the usage card
24
+ * (`src/lib/billing/usage-card.ts`, `GET /v1/pricing` with a bundled
25
+ * fallback): small t3.small, medium t3.medium, large t3.large, xlarge
26
+ * t3.xlarge. Use {@link usageBoxQuotes} / {@link usageBoxQuoteCents}, never
27
+ * the legacy constants, for a usage-only account.
21
28
  */
22
29
  import chalk from "chalk";
23
30
  import { vaultApiFetch } from "./vault-api.js";
24
- /** Per-Agent monthly list price, in cents ($100.00 / agent / month). */
31
+ import { BUNDLED_USAGE_CARD, formatBoxQuotes, } from "../lib/billing/usage-card.js";
32
+ /** Legacy per-Agent monthly list price, in cents ($100.00 / agent / month). */
25
33
  export const AGENT_PRICE_CENTS = 10_000;
26
- /** Per-Outpost monthly list price, in cents ($80.00 / Outpost / month). */
34
+ /** Legacy per-Outpost monthly list price, in cents ($80.00 / Outpost / month). */
27
35
  export const OUTPOST_PRICE_CENTS = 8_000;
36
+ /** Usage-pricing box sizes, in card order ("small (t3.small) $100/month, ..."). */
37
+ export function usageBoxQuotes(card = BUNDLED_USAGE_CARD) {
38
+ return formatBoxQuotes(card);
39
+ }
40
+ /**
41
+ * Monthly price of one usage-pricing box, in cents, by size or instance type.
42
+ * Null for a size the card does not list.
43
+ */
44
+ export function usageBoxQuoteCents(sizeOrInstanceType, card = BUNDLED_USAGE_CARD) {
45
+ const ref = sizeOrInstanceType.trim().toLowerCase();
46
+ const box = card.boxes.find((b) => b.size.toLowerCase() === ref || b.instanceType.toLowerCase() === ref);
47
+ return box ? Math.round(box.monthlyUsd * 100) : null;
48
+ }
28
49
  /** Render a cents amount as a `$X.YZ` USD string. */
29
50
  export function formatUsd(cents) {
30
51
  return `$${(Math.max(0, Math.round(cents)) / 100).toFixed(2)}`;
@@ -1,4 +1,5 @@
1
1
  import { type PlanLimitFreeMonth } from "../lib/billing/free-month.js";
2
+ import { type PricingPlan } from "../lib/billing/usage-only.js";
2
3
  export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED" | "MEETING_PLAN_REQUIRED"
3
4
  /** A PERSONAL scope asked for a feature the paid Individual plan includes. */
4
5
  | "PERSONAL_PLAN_REQUIRED"
@@ -27,7 +28,16 @@ export interface PlanGateDetails {
27
28
  fixOptions?: Record<string, number>;
28
29
  /** Hard-stop 402: the free-month offer, present only when it stands. */
29
30
  freeMonth?: PlanLimitFreeMonth;
31
+ /**
32
+ * Usage pricing (US-012): from the 402 body, or attached by the caller from
33
+ * /membership/me, usage-limits or the billing summary. `"usage-only"`
34
+ * renders no free month, Starter, Workforce or Individual copy and no plan
35
+ * upgrade link. Absent keeps the legacy copy.
36
+ */
37
+ pricingPlan?: PricingPlan;
30
38
  }
39
+ /** Copy a plan gate with the caller's pricing plan attached. */
40
+ export declare function withPricingPlan(err: PlanGateError, pricingPlan: PricingPlan | null | undefined): PlanGateError;
31
41
  /** One renderer for server-selected upgrade links across CLI plan refusals. */
32
42
  export declare function formatUpgradeRefusal(reason: string, upgradeUrl: string | undefined, options?: {
33
43
  urlOnly?: boolean;
@@ -6,6 +6,48 @@
6
6
  import { PLAN_LIMIT_DIMENSION_LABELS, renderPersonalPlanFeatureNotice, } from "../lib/plan-limit-nag.js";
7
7
  import { withCliLimitEntrySurface } from "../lib/billing/plan-limit-upgrade-url.js";
8
8
  import { parseFreeMonth, } from "../lib/billing/free-month.js";
9
+ import { isUsageOnly, pricingPlanFromBody, usageOnlyCardRequiredLines, USAGE_PRICING_POINTER, } from "../lib/billing/usage-only.js";
10
+ /** Legacy plan names and prices a usage-only account must never be shown. */
11
+ const LEGACY_PLAN_COPY = /Starter|Workforce|Individual|\$500|\$50\b|free month|upgrade/i;
12
+ /** Copy a plan gate with the caller's pricing plan attached. */
13
+ export function withPricingPlan(err, pricingPlan) {
14
+ if (!pricingPlan || err.details.pricingPlan === pricingPlan)
15
+ return err;
16
+ return new PlanGateError(err.code, { ...err.details, pricingPlan });
17
+ }
18
+ /** The usage-only rendering of a plan gate. Never names a legacy plan. */
19
+ function usageOnlyPlanGateReason(code, details) {
20
+ switch (code) {
21
+ case "PERSONAL_PLAN_REQUIRED":
22
+ return renderPersonalPlanFeatureNotice(details.feature ?? "this feature", null, {
23
+ pricingPlan: "usage-only",
24
+ });
25
+ case "MEETING_PLAN_REQUIRED":
26
+ return usageOnlyCardRequiredLines("A meeting notetaker bot").join("\n");
27
+ case "BILLING_REQUIRED":
28
+ return usageOnlyCardRequiredLines("An external agent").join("\n");
29
+ case "PLAN_REQUIRED":
30
+ return usageOnlyCardRequiredLines("This feature").join("\n");
31
+ case "PLAN_LIMIT_EXCEEDED": {
32
+ const server = details.reason?.trim();
33
+ const head = server && !LEGACY_PLAN_COPY.test(server) ? server : "This account has reached a usage limit.";
34
+ const lines = [head];
35
+ for (const row of details.resources ?? []) {
36
+ lines.push(` ${resourceLabel(row.resource)}: ` +
37
+ `${formatResourceAmount(row.resource, row.used)} of ` +
38
+ `${formatResourceAmount(row.resource, row.limit)} used.`);
39
+ }
40
+ const fixes = Object.entries(details.fixOptions ?? {}).map(([resource, target]) => `${resourceFixAction(resource)} to make room (the limit is ` +
41
+ `${formatResourceAmount(resource, target)}).`);
42
+ if (fixes.length > 0) {
43
+ lines.push(" Ways to sort it:");
44
+ fixes.forEach((fix, index) => lines.push(` ${index + 1}. ${fix}`));
45
+ }
46
+ lines.push(USAGE_PRICING_POINTER);
47
+ return lines.join("\n");
48
+ }
49
+ }
50
+ }
9
51
  const GIB = 1024 ** 3;
10
52
  const MIB = 1024 ** 2;
11
53
  const KIB = 1024;
@@ -75,6 +117,8 @@ function planLimitRefusalText(head, details) {
75
117
  return lines.join("\n");
76
118
  }
77
119
  function planGateReason(code, details) {
120
+ if (isUsageOnly(details.pricingPlan))
121
+ return usageOnlyPlanGateReason(code, details);
78
122
  if (code === "PLAN_LIMIT_EXCEEDED") {
79
123
  const head = details.reason?.trim()
80
124
  ? details.reason.trim()
@@ -143,6 +187,8 @@ export function companyUidFromPlanUpgradeUrl(value) {
143
187
  return match?.[1];
144
188
  }
145
189
  function formatPlanGateErrorText(code, details) {
190
+ if (isUsageOnly(details.pricingPlan))
191
+ return planGateReason(code, details);
146
192
  return formatUpgradeRefusal(planGateReason(code, details), details.upgradeUrl);
147
193
  }
148
194
  export class PlanGateError extends Error {
@@ -160,6 +206,9 @@ export function isPlanGateError(err) {
160
206
  }
161
207
  /** Plain, stable CLI copy for plan-gated resource creation. */
162
208
  export function formatPlanGateError(err, options = {}) {
209
+ // A usage-only account has no plan to upgrade to: no upgrade link.
210
+ if (isUsageOnly(err.details.pricingPlan))
211
+ return planGateReason(err.code, err.details);
163
212
  return formatUpgradeRefusal(planGateReason(err.code, err.details), err.details.upgradeUrl, options);
164
213
  }
165
214
  export function canOfferTeamUpgrade(argv = process.argv) {
@@ -229,6 +278,11 @@ export function planGateErrorFromPayload(status, body, companyUid) {
229
278
  if (status !== 402 || !body || typeof body !== "object")
230
279
  return null;
231
280
  const payload = body;
281
+ const decoded = decodePlanGatePayload(payload, companyUid);
282
+ const pricingPlan = pricingPlanFromBody(payload);
283
+ return decoded && pricingPlan ? withPricingPlan(decoded, pricingPlan) : decoded;
284
+ }
285
+ function decodePlanGatePayload(payload, companyUid) {
232
286
  // US-054: the personal feature denial. hq-pro identifies it with
233
287
  // `error: "plan_feature_unavailable"` plus `scope: "personal"`; both the
234
288
  // `scope` and `requiredPlan` fields are OPTIONAL on the wire, so an hq-pro
@@ -172,7 +172,10 @@ function hasPlanLimitStatusPayload(body) {
172
172
  if (body === null || typeof body !== 'object' || Array.isArray(body)) {
173
173
  return false;
174
174
  }
175
- return Object.hasOwn(body, 'planLimits') || Object.hasOwn(body, 'thresholdCrossings');
175
+ return (Object.hasOwn(body, 'planLimits') ||
176
+ Object.hasOwn(body, 'thresholdCrossings') ||
177
+ // Usage pricing (US-012): a usage-only body silences the Starter nag.
178
+ body.pricingPlan === 'usage-only');
176
179
  }
177
180
  /**
178
181
  * Map Cognito JWT paths onto parallel Bearer `hqk_` routes under `/v1/keys/…`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.142",
3
+ "version": "5.345.144",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -48,7 +48,7 @@
48
48
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
49
49
  "@aws-sdk/client-s3": "^3.1049.0",
50
50
  "@aws-sdk/dsql-signer": "^3.1146.0",
51
- "@indigoai-us/hq-cloud": "~6.18.77",
51
+ "@indigoai-us/hq-cloud": "~6.18.82",
52
52
  "@indigoai-us/hq-flags-client": "^0.1.2",
53
53
  "@modelcontextprotocol/sdk": "1.29.0",
54
54
  "@sentry/node": "^10.49.0",