@indigoai-us/hq-cli 5.345.143 → 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.
- package/CHANGELOG.md +71 -0
- package/dist/command-catalog.generated.d.ts +278 -6
- package/dist/command-catalog.generated.js +341 -6
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +1 -0
- package/dist/commands/agent-enroll.js +7 -0
- package/dist/commands/agents.d.ts +25 -3
- package/dist/commands/agents.js +57 -4
- package/dist/commands/api-keys.js +66 -8
- package/dist/commands/billing-sponsor.d.ts +4 -0
- package/dist/commands/billing-sponsor.js +48 -5
- package/dist/commands/billing-usage.d.ts +59 -0
- package/dist/commands/billing-usage.js +162 -0
- package/dist/commands/billing.d.ts +9 -0
- package/dist/commands/billing.js +29 -2
- package/dist/commands/core-policy.js +76 -13
- package/dist/commands/library-add.d.ts +120 -0
- package/dist/commands/library-add.js +434 -0
- package/dist/commands/library-caption.d.ts +112 -0
- package/dist/commands/library-caption.js +393 -0
- package/dist/commands/library-link.d.ts +32 -0
- package/dist/commands/library-link.js +95 -0
- package/dist/commands/library.d.ts +72 -0
- package/dist/commands/library.js +527 -0
- package/dist/commands/meetings.d.ts +8 -0
- package/dist/commands/meetings.js +25 -2
- package/dist/commands/storage-offload.d.ts +16 -2
- package/dist/commands/storage-offload.js +55 -4
- package/dist/commands/storage.d.ts +8 -1
- package/dist/commands/storage.js +6 -2
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2410 -2063
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/billing/plan-lock.d.ts +15 -1
- package/dist/lib/billing/plan-lock.js +45 -5
- package/dist/lib/billing/usage-card.d.ts +82 -0
- package/dist/lib/billing/usage-card.js +198 -0
- package/dist/lib/billing/usage-cost.d.ts +214 -0
- package/dist/lib/billing/usage-cost.js +440 -0
- package/dist/lib/billing/usage-only.d.ts +87 -0
- package/dist/lib/billing/usage-only.js +262 -0
- package/dist/lib/billing/usage-rate-card.d.ts +51 -0
- package/dist/lib/billing/usage-rate-card.js +62 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/plan-limit-nag.d.ts +4 -6
- package/dist/lib/plan-limit-nag.js +19 -1
- package/dist/lib/provider-launcher.d.ts +22 -0
- package/dist/lib/provider-launcher.js +65 -0
- package/dist/lib/search-index/index.d.ts +2 -0
- package/dist/lib/search-index/index.js +34 -0
- package/dist/lib/search-index/max-doc-bytes.d.ts +2 -1
- package/dist/lib/search-index/max-doc-bytes.js +2 -1
- package/dist/main.d.ts +9 -0
- package/dist/main.js +20 -3
- package/dist/utils/billing-gate.d.ts +19 -4
- package/dist/utils/billing-gate.js +25 -4
- package/dist/utils/plan-gate-error.d.ts +10 -0
- package/dist/utils/plan-gate-error.js +54 -0
- package/dist/utils/vault-api.js +4 -1
- package/package.json +2 -2
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq billing usage` core (usage-only-pricing US-015): this month's usage per
|
|
3
|
+
* meter, the projected bill, the allowance left, and the biggest drivers with
|
|
4
|
+
* a fix for each. Pure apart from the injected reads, so it is unit-tested
|
|
5
|
+
* without a network.
|
|
6
|
+
*
|
|
7
|
+
* ONE READ SEAM. Every usage number comes through {@link readMonthUsage}. Today
|
|
8
|
+
* it reads the sources the console and CLI already expose:
|
|
9
|
+
* - `GET /v1/billing/usage-limits?companyUid=` (company) or
|
|
10
|
+
* `GET /v1/billing/person/usage-limits` (solo, person scope) for storage
|
|
11
|
+
* bytes and secrets stored. Both routes are member-gated server-side, so a
|
|
12
|
+
* member only ever gets their own company's numbers.
|
|
13
|
+
* - optionally, the live cloud file-history listing `hq storage status`
|
|
14
|
+
* uses, to split storage into current files and old versions and to see a
|
|
15
|
+
* prune right away (Storage Lens, behind usage-limits, updates daily).
|
|
16
|
+
* usage-limits also answers `vaults` when the server counts them; without it
|
|
17
|
+
* the vault meter is `not-available`.
|
|
18
|
+
* The call-count meters (syncs, gateway calls, HQ MCP calls, agent runs) have
|
|
19
|
+
* no member-visible source yet. They are reported
|
|
20
|
+
* as `not-available`, never as zero, and the bill is marked incomplete.
|
|
21
|
+
*
|
|
22
|
+
* When the daily rollup (US-005, hq-pro `usage-meter-rollup.ts`) gets a read
|
|
23
|
+
* route, add it as a {@link MonthUsageSource} and make it the default here.
|
|
24
|
+
* Nothing else in this file or in the command has to change.
|
|
25
|
+
*
|
|
26
|
+
* Read-only: nothing here prunes, deletes, collects garbage or reclaims space.
|
|
27
|
+
* Fixes are returned as commands for the caller to preview and run with the
|
|
28
|
+
* user's yes (the /hq-cost skill does that).
|
|
29
|
+
*/
|
|
30
|
+
import { BUNDLED_USAGE_CARD, SELF_HOSTED_GUIDE_URL, formatFreeItems } from "./usage-card.js";
|
|
31
|
+
import { BUNDLED_USAGE_METER_RATES, USAGE_MONTHLY_ALLOWANCE_USD, } from "./usage-rate-card.js";
|
|
32
|
+
/** Bytes per billed GB. Storage is billed like S3: 1 GB = 2^30 bytes. */
|
|
33
|
+
export const BYTES_PER_GB = 1024 ** 3;
|
|
34
|
+
export function monthWindow(now) {
|
|
35
|
+
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
36
|
+
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
|
37
|
+
return { start, end };
|
|
38
|
+
}
|
|
39
|
+
function usedOf(body, key) {
|
|
40
|
+
const row = body?.[key];
|
|
41
|
+
if (!row || typeof row !== "object")
|
|
42
|
+
return { value: null, state: "unknown" };
|
|
43
|
+
if (row.usageState === "not-collected")
|
|
44
|
+
return { value: null, state: "not-available" };
|
|
45
|
+
if (typeof row.used === "number" && Number.isFinite(row.used) && row.used >= 0) {
|
|
46
|
+
return { value: row.used, state: "measured" };
|
|
47
|
+
}
|
|
48
|
+
return { value: null, state: "unknown" };
|
|
49
|
+
}
|
|
50
|
+
function hasRow(body, key) {
|
|
51
|
+
const row = body?.[key];
|
|
52
|
+
return row !== undefined && row !== null;
|
|
53
|
+
}
|
|
54
|
+
/** Today's source: plan usage-limits (storage bytes, secrets). */
|
|
55
|
+
export function usageLimitsSource(deps) {
|
|
56
|
+
return {
|
|
57
|
+
name: "usage-limits",
|
|
58
|
+
async read(scope, now) {
|
|
59
|
+
const { start, end } = monthWindow(now);
|
|
60
|
+
const body = await deps.fetchUsageLimits(scope);
|
|
61
|
+
if (body === null) {
|
|
62
|
+
throw new Error("Could not read usage from HQ right now. Try again, or run `hq login` if you are signed out.");
|
|
63
|
+
}
|
|
64
|
+
const storage = usedOf(body, "storageBytes");
|
|
65
|
+
const secrets = usedOf(body, "secrets");
|
|
66
|
+
const vaults = usedOf(body, "vaults");
|
|
67
|
+
const meters = BUNDLED_USAGE_METER_RATES.map(({ key }) => {
|
|
68
|
+
if (key === "storage") {
|
|
69
|
+
return { key, value: storage.value === null ? null : storage.value / BYTES_PER_GB, state: storage.state };
|
|
70
|
+
}
|
|
71
|
+
if (key === "secret")
|
|
72
|
+
return { key, ...secrets };
|
|
73
|
+
// An absent vault row is a server that does not count vaults yet.
|
|
74
|
+
if (key === "vault") {
|
|
75
|
+
return hasRow(body, "vaults") ? { key, ...vaults } : { key, value: null, state: "not-available" };
|
|
76
|
+
}
|
|
77
|
+
// No member-visible source until the daily rollup (US-005) is readable.
|
|
78
|
+
return { key, value: null, state: "not-available" };
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
scope,
|
|
82
|
+
source: "usage-limits",
|
|
83
|
+
period: { start: start.toISOString(), end: end.toISOString(), asOf: now.toISOString() },
|
|
84
|
+
meters,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Rollup meter -> rate-card key, the unit scale to billed units, and how days combine. */
|
|
90
|
+
const ROLLUP_METERS = {
|
|
91
|
+
member_syncs: { key: "member-sync", scale: 1, combine: "sum" },
|
|
92
|
+
storage_bytes: { key: "storage", scale: 1 / BYTES_PER_GB, combine: "average" },
|
|
93
|
+
secrets_stored: { key: "secret", scale: 1, combine: "average" },
|
|
94
|
+
hq_mcp_calls: { key: "hq-mcp-call", scale: 1, combine: "sum" },
|
|
95
|
+
integration_calls: { key: "integration-call", scale: 1, combine: "sum" },
|
|
96
|
+
agent_runs: { key: "agent-run", scale: 1, combine: "sum" },
|
|
97
|
+
vaults: { key: "vault", scale: 1, combine: "average" },
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Turn a month of daily rollup rows into meter readings: counters are summed
|
|
101
|
+
* across days, storage and secrets (daily levels) are averaged over the days
|
|
102
|
+
* that have a value. A meter with no value on any day is `not-available`,
|
|
103
|
+
* never zero. External MCP calls through HQ are counted as gateway calls
|
|
104
|
+
* (`integration_calls`).
|
|
105
|
+
*/
|
|
106
|
+
export function meterReadingsFromDailyRows(rows) {
|
|
107
|
+
const totals = new Map();
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
for (const [meter, spec] of Object.entries(ROLLUP_METERS)) {
|
|
110
|
+
const v = row.meters?.[meter];
|
|
111
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
112
|
+
continue;
|
|
113
|
+
const t = totals.get(spec.key) ?? { sum: 0, days: 0, combine: spec.combine };
|
|
114
|
+
t.sum += v * spec.scale;
|
|
115
|
+
t.days += 1;
|
|
116
|
+
totals.set(spec.key, t);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return BUNDLED_USAGE_METER_RATES.map(({ key }) => {
|
|
120
|
+
const t = totals.get(key);
|
|
121
|
+
if (!t || t.days === 0)
|
|
122
|
+
return { key, value: null, state: "not-available" };
|
|
123
|
+
return { key, value: t.combine === "average" ? t.sum / t.days : t.sum, state: "measured" };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The rollup as a source. `fetchRows` is the US-006 company-usage route (or
|
|
128
|
+
* `readCompanyRange` server-side) for the month to date. Pass this to
|
|
129
|
+
* {@link readMonthUsage} once that route ships; the company scope only.
|
|
130
|
+
*/
|
|
131
|
+
export function dailyRollupSource(fetchRows) {
|
|
132
|
+
return {
|
|
133
|
+
name: "daily-rollup",
|
|
134
|
+
async read(scope, now) {
|
|
135
|
+
const { start, end } = monthWindow(now);
|
|
136
|
+
const rows = await fetchRows(scope, start.toISOString().slice(0, 10), now.toISOString().slice(0, 10));
|
|
137
|
+
if (rows === null)
|
|
138
|
+
throw new Error("Could not read the daily usage rollup right now. Try again later.");
|
|
139
|
+
return {
|
|
140
|
+
scope,
|
|
141
|
+
source: "daily-rollup",
|
|
142
|
+
period: { start: start.toISOString(), end: end.toISOString(), asOf: now.toISOString() },
|
|
143
|
+
meters: meterReadingsFromDailyRows(rows),
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The one function every caller uses to read a month of usage. Switch the
|
|
150
|
+
* default source here when the US-005 rollup has a read route.
|
|
151
|
+
*/
|
|
152
|
+
export async function readMonthUsage(scope, deps, source = usageLimitsSource(deps)) {
|
|
153
|
+
const now = deps.now();
|
|
154
|
+
const read = await source.read(scope, now);
|
|
155
|
+
const trialActive = deps.readTrialActive ? await deps.readTrialActive(scope).catch(() => null) : null;
|
|
156
|
+
const base = typeof trialActive === "boolean" ? { ...read, trialActive } : read;
|
|
157
|
+
if (scope.kind !== "company" || !deps.readStorageSplit)
|
|
158
|
+
return base;
|
|
159
|
+
const split = await deps.readStorageSplit(scope).catch(() => null);
|
|
160
|
+
if (!split)
|
|
161
|
+
return base;
|
|
162
|
+
// The live listing is fresher than the daily Storage Lens figure, so a prune
|
|
163
|
+
// shows up in the very next read.
|
|
164
|
+
const liveGb = (split.currentBytes + split.oldVersionBytes) / BYTES_PER_GB;
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
storage: split,
|
|
168
|
+
meters: base.meters.map((m) => (m.key === "storage" ? { key: m.key, value: liveGb, state: "measured" } : m)),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
172
|
+
export function computeBill(usage, rates = BUNDLED_USAGE_METER_RATES, allowanceUsd = USAGE_MONTHLY_ALLOWANCE_USD) {
|
|
173
|
+
const asOf = new Date(usage.period.asOf);
|
|
174
|
+
const { start, end } = monthWindow(asOf);
|
|
175
|
+
const dayMs = 86_400_000;
|
|
176
|
+
const daysInMonth = Math.round((end.getTime() - start.getTime()) / dayMs);
|
|
177
|
+
// At least a quarter day so a read just after midnight on the 1st does not
|
|
178
|
+
// project a few minutes of use across the whole month.
|
|
179
|
+
const daysElapsed = Math.min(daysInMonth, Math.max(0.25, (asOf.getTime() - start.getTime()) / dayMs));
|
|
180
|
+
const byKey = new Map(usage.meters.map((m) => [m.key, m]));
|
|
181
|
+
let mtd = 0;
|
|
182
|
+
let projected = 0;
|
|
183
|
+
let incomplete = false;
|
|
184
|
+
const meters = rates.map((rate) => {
|
|
185
|
+
const reading = byKey.get(rate.key) ?? { key: rate.key, value: null, state: "not-available" };
|
|
186
|
+
if (reading.state !== "measured" || reading.value === null) {
|
|
187
|
+
incomplete = true;
|
|
188
|
+
return { ...pick(rate), value: null, state: reading.state, monthToDateUsd: null, projectedUsd: null };
|
|
189
|
+
}
|
|
190
|
+
if (rate.waivedInTrial && usage.trialActive === true) {
|
|
191
|
+
return { ...pick(rate), value: reading.value, state: "measured", monthToDateUsd: 0, projectedUsd: 0, waivedInTrial: true };
|
|
192
|
+
}
|
|
193
|
+
const full = reading.value * rate.unitUsd;
|
|
194
|
+
const m = rate.kind === "gauge" ? full * (daysElapsed / daysInMonth) : full;
|
|
195
|
+
const p = rate.kind === "gauge" ? full : full * (daysInMonth / daysElapsed);
|
|
196
|
+
mtd += m;
|
|
197
|
+
projected += p;
|
|
198
|
+
return { ...pick(rate), value: reading.value, state: "measured", monthToDateUsd: round2(m), projectedUsd: round2(p) };
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
meters,
|
|
202
|
+
monthToDateUsd: round2(mtd),
|
|
203
|
+
projectedUsd: round2(projected),
|
|
204
|
+
allowanceUsd,
|
|
205
|
+
allowanceLeftUsd: round2(Math.max(0, allowanceUsd - mtd)),
|
|
206
|
+
projectedBillUsd: round2(Math.max(0, projected - allowanceUsd)),
|
|
207
|
+
incomplete,
|
|
208
|
+
daysElapsed: round2(daysElapsed),
|
|
209
|
+
daysInMonth,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function pick(rate) {
|
|
213
|
+
return { key: rate.key, displayName: rate.displayName, unit: rate.unit, unitUsd: rate.unitUsd };
|
|
214
|
+
}
|
|
215
|
+
function gb(n) {
|
|
216
|
+
return n >= 10 ? n.toFixed(0) : n >= 1 ? n.toFixed(1) : n.toFixed(2);
|
|
217
|
+
}
|
|
218
|
+
function usd(n) {
|
|
219
|
+
return `$${n.toFixed(2)}`;
|
|
220
|
+
}
|
|
221
|
+
function companyFlag(scope) {
|
|
222
|
+
return scope.kind === "company" ? ` --company ${scope.companySlug}` : "";
|
|
223
|
+
}
|
|
224
|
+
function personalFlag(scope) {
|
|
225
|
+
return scope.kind === "person" ? " --personal" : "";
|
|
226
|
+
}
|
|
227
|
+
/** ISO date `days` before `now`, for prune cutoffs. */
|
|
228
|
+
export function daysBefore(now, days) {
|
|
229
|
+
return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
|
|
230
|
+
}
|
|
231
|
+
export function excludeFoldersFix(scope) {
|
|
232
|
+
return {
|
|
233
|
+
id: "exclude-folders",
|
|
234
|
+
title: "Stop syncing folders you do not need in the cloud",
|
|
235
|
+
detail: "Add the folders to .hqignore in your HQ folder so sync skips them, or limit what this company downloads with " +
|
|
236
|
+
"`hq sync mode shared`. Files already in the cloud stay until you remove them; nothing is deleted.",
|
|
237
|
+
preview: ["hq sync mode --show", ...(scope.kind === "company" ? [`hq storage status${companyFlag(scope)} --json`] : [])],
|
|
238
|
+
apply: ["(edit <HQ folder>/.hqignore: add one line per folder, for example `bigfolder/`)"],
|
|
239
|
+
savesUsdPerMonth: null,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
export function turnSyncOffFix() {
|
|
243
|
+
return {
|
|
244
|
+
id: "turn-sync-off",
|
|
245
|
+
title: "Turn cloud sync off and share through git",
|
|
246
|
+
detail: "Stops HQ cloud sync on this computer, so its syncs stop counting. HQ core, the CLI, DMs and deploys (through " +
|
|
247
|
+
"/deploy) keep working, and none of them needs a card. In the HQ desktop app, use its Cloud off switch once it " +
|
|
248
|
+
"ships; until then turn off its Settings > Sync switches. Without the app, `hq daemon sync pause` does it " +
|
|
249
|
+
"(resume with `hq daemon sync resume`). Files already in the cloud stay there and still count as storage until " +
|
|
250
|
+
"you remove them.",
|
|
251
|
+
preview: ["hq daemon sync status --json"],
|
|
252
|
+
apply: ["hq daemon sync pause"],
|
|
253
|
+
savesUsdPerMonth: null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Biggest drivers first. Only measured meters with a cost become drivers; the
|
|
258
|
+
* turn-sync-off fix is always offered while a bill is projected.
|
|
259
|
+
*/
|
|
260
|
+
export function findDrivers(usage, bill) {
|
|
261
|
+
const drivers = [];
|
|
262
|
+
const now = new Date(usage.period.asOf);
|
|
263
|
+
const rate = (k) => bill.meters.find((m) => m.key === k)?.unitUsd ?? 0;
|
|
264
|
+
const cost = (k) => bill.meters.find((m) => m.key === k);
|
|
265
|
+
const scope = usage.scope;
|
|
266
|
+
const storage = cost("storage");
|
|
267
|
+
if (storage?.projectedUsd) {
|
|
268
|
+
const split = usage.storage;
|
|
269
|
+
if (split && split.oldVersionBytes > 0) {
|
|
270
|
+
const oldGb = split.oldVersionBytes / BYTES_PER_GB;
|
|
271
|
+
const cutoff = daysBefore(now, 30);
|
|
272
|
+
const target = scope.kind === "company" ? scope.companySlug : "";
|
|
273
|
+
drivers.push({
|
|
274
|
+
meter: "storage-old-versions",
|
|
275
|
+
summary: `Old file versions: ${gb(oldGb)} GB of earlier copies of files you have since changed (${usd(oldGb * rate("storage"))} a month).`,
|
|
276
|
+
projectedUsd: round2(oldGb * rate("storage")),
|
|
277
|
+
fixes: [
|
|
278
|
+
{
|
|
279
|
+
id: "prune-old-versions",
|
|
280
|
+
title: "Delete old file versions older than 30 days",
|
|
281
|
+
detail: `Deletes earlier versions of files in ${target} that were replaced before ${cutoff}. Current files are not touched. ` +
|
|
282
|
+
"This cannot be undone.",
|
|
283
|
+
preview: [`hq storage prune --cloud-before ${cutoff}${companyFlag(scope)} --dry-run --json`],
|
|
284
|
+
apply: [`hq storage prune --cloud-before ${cutoff}${companyFlag(scope)} --yes --json`],
|
|
285
|
+
savesUsdPerMonth: null,
|
|
286
|
+
...(split.canDelete ? {} : { blockedReason: "Only a company owner or admin can delete old versions. Ask your owner to run /hq-cost." }),
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const currentGb = split ? split.currentBytes / BYTES_PER_GB : (storage.value ?? 0);
|
|
292
|
+
if (currentGb > 0) {
|
|
293
|
+
drivers.push({
|
|
294
|
+
meter: "storage-current",
|
|
295
|
+
summary: split
|
|
296
|
+
? `Stored files: ${gb(currentGb)} GB of current files in the cloud (${usd(currentGb * rate("storage"))} a month).`
|
|
297
|
+
: `Stored files: ${gb(currentGb)} GB in the cloud, old versions included (${usd(currentGb * rate("storage"))} a month). ` +
|
|
298
|
+
"Run without --no-storage-scan to see how much is old versions.",
|
|
299
|
+
projectedUsd: round2(currentGb * rate("storage")),
|
|
300
|
+
fixes: [excludeFoldersFix(scope)],
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const secrets = cost("secret");
|
|
305
|
+
if (secrets?.projectedUsd && secrets.value) {
|
|
306
|
+
drivers.push({
|
|
307
|
+
meter: "secret",
|
|
308
|
+
summary: `Secrets: ${secrets.value} stored at ${usd(secrets.unitUsd)} each a month (${usd(secrets.projectedUsd)}).`,
|
|
309
|
+
projectedUsd: secrets.projectedUsd,
|
|
310
|
+
fixes: [
|
|
311
|
+
{
|
|
312
|
+
id: "remove-unused-secrets",
|
|
313
|
+
title: "Remove secrets nothing uses any more",
|
|
314
|
+
detail: `Each secret you delete saves ${usd(secrets.unitUsd)} a month. HQ does not record when a secret was last read, ` +
|
|
315
|
+
"so the list shows when each was last changed; you pick which to delete, one at a time. This cannot be undone.",
|
|
316
|
+
preview: [`hq secrets${companyFlag(scope)}${personalFlag(scope)} list`],
|
|
317
|
+
// --force skips the CLI's own TTY prompt. Run it only for a name the
|
|
318
|
+
// user said yes to after seeing the list.
|
|
319
|
+
apply: [`hq secrets${companyFlag(scope)}${personalFlag(scope)} delete <NAME> --force`],
|
|
320
|
+
savesUsdPerMonth: secrets.unitUsd,
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const sync = cost("member-sync");
|
|
326
|
+
if (sync?.projectedUsd) {
|
|
327
|
+
drivers.push({
|
|
328
|
+
meter: "member-sync",
|
|
329
|
+
summary: `Syncs: ${Math.round(sync.value ?? 0)} so far this month (${usd(sync.projectedUsd)} at this pace). ` +
|
|
330
|
+
"A sync loop or a folder that changes all the time is the usual cause.",
|
|
331
|
+
projectedUsd: sync.projectedUsd,
|
|
332
|
+
fixes: [excludeFoldersFix(scope), turnSyncOffFix()],
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
for (const key of ["integration-call", "hq-mcp-call", "agent-run"]) {
|
|
336
|
+
const m = cost(key);
|
|
337
|
+
if (m?.projectedUsd) {
|
|
338
|
+
drivers.push({
|
|
339
|
+
meter: key,
|
|
340
|
+
summary: `${m.displayName}: ${gb(m.value ?? 0)} ${m.unit}s so far (${usd(m.projectedUsd)} at this pace).`,
|
|
341
|
+
projectedUsd: m.projectedUsd,
|
|
342
|
+
fixes: [],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
drivers.sort((a, b) => b.projectedUsd - a.projectedUsd);
|
|
347
|
+
if (bill.projectedBillUsd > 0 || bill.incomplete) {
|
|
348
|
+
const hasSyncOff = drivers.some((d) => d.fixes.some((f) => f.id === "turn-sync-off"));
|
|
349
|
+
if (!hasSyncOff) {
|
|
350
|
+
drivers.push({
|
|
351
|
+
meter: "member-sync",
|
|
352
|
+
summary: bill.incomplete
|
|
353
|
+
? "Syncs are not counted here yet. If you do not need cloud sync, turning it off stops them."
|
|
354
|
+
: "Cloud sync is on. If you do not need it, turning it off stops sync charges.",
|
|
355
|
+
projectedUsd: 0,
|
|
356
|
+
fixes: [turnSyncOffFix()],
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return drivers;
|
|
361
|
+
}
|
|
362
|
+
// ── Report ──────────────────────────────────────────────────────────────────
|
|
363
|
+
/** The self-hosted / no-sync guide the usage card links (feed `signup.selfHosted.url`). */
|
|
364
|
+
export const NO_SYNC_GUIDE_URL = SELF_HOSTED_GUIDE_URL;
|
|
365
|
+
export function buildReport(usage, rates) {
|
|
366
|
+
const bill = computeBill(usage, rates.rates, rates.allowanceUsd);
|
|
367
|
+
return {
|
|
368
|
+
scope: usage.scope,
|
|
369
|
+
source: usage.source,
|
|
370
|
+
rateSource: rates.source,
|
|
371
|
+
period: usage.period,
|
|
372
|
+
bill,
|
|
373
|
+
...(usage.storage ? { storage: usage.storage } : {}),
|
|
374
|
+
...(typeof usage.trialActive === "boolean" ? { trialActive: usage.trialActive } : {}),
|
|
375
|
+
freeItems: formatFreeItems(rates.card ?? BUNDLED_USAGE_CARD),
|
|
376
|
+
drivers: findDrivers(usage, bill),
|
|
377
|
+
noSyncGuideUrl: NO_SYNC_GUIDE_URL,
|
|
378
|
+
readOnly: true,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function rateText(m) {
|
|
382
|
+
const price = m.unitUsd >= 1 ? usd(m.unitUsd) : `$${m.unitUsd.toFixed(2)}`;
|
|
383
|
+
const trial = m.key === "vault" && !m.waivedInTrial ? ", waived during the trial" : "";
|
|
384
|
+
return `${price} per ${m.unit}${trial}`;
|
|
385
|
+
}
|
|
386
|
+
function valueText(m) {
|
|
387
|
+
if (m.state === "not-available")
|
|
388
|
+
return "not counted here yet";
|
|
389
|
+
if (m.state === "unknown" || m.value === null)
|
|
390
|
+
return "could not be read";
|
|
391
|
+
if (m.key === "storage")
|
|
392
|
+
return `${gb(m.value)} GB`;
|
|
393
|
+
if (m.key === "vault")
|
|
394
|
+
return `${Math.round(m.value)} vault${Math.round(m.value) === 1 ? "" : "s"}`;
|
|
395
|
+
return `${Math.round(m.value)}`;
|
|
396
|
+
}
|
|
397
|
+
export function formatReport(r) {
|
|
398
|
+
const who = r.scope.kind === "company" ? r.scope.companySlug : "your personal HQ";
|
|
399
|
+
const month = r.period.start.slice(0, 7);
|
|
400
|
+
const lines = [];
|
|
401
|
+
lines.push(`Usage for ${who}, ${month} (day ${Math.floor(r.bill.daysElapsed) + 1} of ${r.bill.daysInMonth})`);
|
|
402
|
+
lines.push("");
|
|
403
|
+
const nameW = Math.max(...r.bill.meters.map((m) => m.displayName.length));
|
|
404
|
+
for (const m of r.bill.meters) {
|
|
405
|
+
const cost = m.waivedInTrial
|
|
406
|
+
? " waived during the trial"
|
|
407
|
+
: m.projectedUsd === null
|
|
408
|
+
? ""
|
|
409
|
+
: ` ${usd(m.projectedUsd)} projected`;
|
|
410
|
+
lines.push(` ${m.displayName.padEnd(nameW)} ${valueText(m)}${cost} (${rateText(m)})`);
|
|
411
|
+
}
|
|
412
|
+
lines.push(` Free, no limit: ${r.freeItems}.`);
|
|
413
|
+
lines.push("");
|
|
414
|
+
const atLeast = r.bill.incomplete ? "at least " : "";
|
|
415
|
+
lines.push(`Used so far: ${atLeast}${usd(r.bill.monthToDateUsd)} of the ${usd(r.bill.allowanceUsd)} included each month (${usd(r.bill.allowanceLeftUsd)} left${r.bill.incomplete ? " at most" : ""}).`);
|
|
416
|
+
lines.push(`Projected for the month: ${atLeast}${usd(r.bill.projectedUsd)}. Projected bill after the included usage: ${atLeast}${usd(r.bill.projectedBillUsd)}.`);
|
|
417
|
+
if (r.scope.kind === "person") {
|
|
418
|
+
lines.push("Your personal account has its own $" + r.bill.allowanceUsd.toFixed(0) + " free each month; usage above it is billed to you.");
|
|
419
|
+
}
|
|
420
|
+
if (r.bill.incomplete) {
|
|
421
|
+
lines.push("Some meters are not counted here yet (they arrive with the daily usage rollup), so the real figures can be higher.");
|
|
422
|
+
}
|
|
423
|
+
if (r.drivers.length > 0) {
|
|
424
|
+
lines.push("");
|
|
425
|
+
lines.push("Biggest drivers:");
|
|
426
|
+
for (const d of r.drivers) {
|
|
427
|
+
lines.push(` - ${d.summary}`);
|
|
428
|
+
for (const f of d.fixes) {
|
|
429
|
+
lines.push(` Fix: ${f.title}${f.blockedReason ? ` (${f.blockedReason})` : ""}`);
|
|
430
|
+
lines.push(` Preview: ${f.preview.join("; ")}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
lines.push("");
|
|
435
|
+
lines.push("No card is needed to sign in, deploy or use DMs.");
|
|
436
|
+
lines.push("This command only reads. Run /hq-cost in Claude Code to preview and apply a fix with your yes.");
|
|
437
|
+
lines.push(`Use HQ free, self-hosted or on-prem: ${r.noSyncGuideUrl}`);
|
|
438
|
+
return lines.join("\n");
|
|
439
|
+
}
|
|
440
|
+
//# sourceMappingURL=usage-cost.js.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this account on usage pricing? (usage-only-pricing landing 2026-10-16, US-012)
|
|
3
|
+
*
|
|
4
|
+
* The signal is hq-pro's own `pricingPlan: "usage-only" | "current"` field,
|
|
5
|
+
* never a local guess. Sources, first answer wins:
|
|
6
|
+
*
|
|
7
|
+
* - Company:
|
|
8
|
+
* 1. the company's row in `GET /membership/me` (hq-pro #5042; every member
|
|
9
|
+
* sees it),
|
|
10
|
+
* 2. `GET /v1/billing/usage-limits?companyUid=` (hq-pro #5042; members),
|
|
11
|
+
* 3. `GET /v1/billing/summary?companyUid=` (hq-pro US-002; owner-gated,
|
|
12
|
+
* 403 for a non-owner).
|
|
13
|
+
* - Person:
|
|
14
|
+
* 1. `GET /v1/billing/person/usage-limits` (hq-pro #5042),
|
|
15
|
+
* 2. `GET /v1/billing/person/summary`, which carries a `personalUsage`
|
|
16
|
+
* block only for a person on usage-only pricing (hq-pro US-004).
|
|
17
|
+
*
|
|
18
|
+
* Any other decoded hq-pro body that carries the same fields is read the same
|
|
19
|
+
* way ({@link pricingPlanFromBody}).
|
|
20
|
+
*
|
|
21
|
+
* UNKNOWN (null) keeps the legacy copy: existing customers see exactly what
|
|
22
|
+
* they saw before, and an account is only told about usage pricing when the
|
|
23
|
+
* server said so.
|
|
24
|
+
*/
|
|
25
|
+
import { type UsageCard } from "./usage-card.js";
|
|
26
|
+
export type PricingPlan = "usage-only" | "current";
|
|
27
|
+
/**
|
|
28
|
+
* Read the pricing plan out of a decoded hq-pro body. `pricingPlan` wins;
|
|
29
|
+
* a `personalUsage` object (person summary) means usage-only. Anything else
|
|
30
|
+
* is UNKNOWN.
|
|
31
|
+
*/
|
|
32
|
+
export declare function pricingPlanFromBody(body: unknown): PricingPlan | null;
|
|
33
|
+
export declare function isUsageOnly(plan: PricingPlan | null | undefined): boolean;
|
|
34
|
+
/** True when a company summary says a usage-only trial is running. Absent is false. */
|
|
35
|
+
export declare function usageTrialActiveFromBody(body: unknown): boolean;
|
|
36
|
+
export interface PricingPlanFetchDeps {
|
|
37
|
+
/** GET a summary path. Resolves the decoded body, or null when it did not answer. */
|
|
38
|
+
getJson?: (token: string, path: string, query?: Record<string, string>) => Promise<unknown | null>;
|
|
39
|
+
/** Per-request timeout. */
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Whole-lookup deadline across every source (default 6000 ms). Past it the
|
|
43
|
+
* plan is unknown and the caller keeps the legacy copy, so a slow or
|
|
44
|
+
* unreachable API can never hold a refusal message hostage.
|
|
45
|
+
*/
|
|
46
|
+
deadlineMs?: number;
|
|
47
|
+
}
|
|
48
|
+
/** Test-only: forget cached reads. */
|
|
49
|
+
export declare function _resetPricingPlanCacheForTests(): void;
|
|
50
|
+
/**
|
|
51
|
+
* The pricing plan on a company's row of a decoded `/membership/me` body,
|
|
52
|
+
* matched by uid or slug. Null when the row or the field is absent.
|
|
53
|
+
*/
|
|
54
|
+
export declare function pricingPlanFromMembership(body: unknown, companyRef: string): PricingPlan | null;
|
|
55
|
+
/** The company billing summary body, read once per process. Null when unreadable. */
|
|
56
|
+
export declare function fetchCompanyBillingSummary(token: string, companyUid: string, deps?: PricingPlanFetchDeps): Promise<unknown | null>;
|
|
57
|
+
export declare function fetchCompanyPricingPlan(token: string, companyUid: string, deps?: PricingPlanFetchDeps): Promise<PricingPlan | null>;
|
|
58
|
+
/** The caller's personal pricing plan (person usage-limits, then summary). Null when unknown. */
|
|
59
|
+
export declare function fetchPersonPricingPlan(token: string, deps?: PricingPlanFetchDeps): Promise<PricingPlan | null>;
|
|
60
|
+
/**
|
|
61
|
+
* Best-effort, non-interactive: resolve the token and company, then the plan.
|
|
62
|
+
* For error paths that do not already hold a token. Never throws and never
|
|
63
|
+
* opens a browser sign-in.
|
|
64
|
+
*/
|
|
65
|
+
export declare function resolveCompanyPricingPlan(company?: string, deps?: PricingPlanFetchDeps): Promise<PricingPlan | null>;
|
|
66
|
+
/** Best-effort person-scope counterpart of {@link resolveCompanyPricingPlan}. */
|
|
67
|
+
export declare function resolvePersonPricingPlan(deps?: PricingPlanFetchDeps): Promise<PricingPlan | null>;
|
|
68
|
+
/** One sentence every usage-only surface uses to point at the bill. */
|
|
69
|
+
export declare const USAGE_PRICING_POINTER = "Run `hq billing usage` for this month's usage, the included usage left and the projected bill.";
|
|
70
|
+
/** "It pays per use, with <included> of usage included each month." */
|
|
71
|
+
export declare function usageIncludedSentence(card?: UsageCard): string;
|
|
72
|
+
/** `hq billing upgrade` for a usage-only company. */
|
|
73
|
+
export declare function usageOnlyUpgradeLines(card?: UsageCard): string[];
|
|
74
|
+
/** The `Plan:` orientation line (`hq whoami --plan`) for a usage-only company. */
|
|
75
|
+
export declare function usageOnlyPlanLine(card?: UsageCard): string;
|
|
76
|
+
/** Agent boxes on usage pricing, priced by size from the card. */
|
|
77
|
+
export declare function usageOnlyBoxSentence(card?: UsageCard): string;
|
|
78
|
+
/**
|
|
79
|
+
* A refusal that a legacy account answers by upgrading its plan. On usage
|
|
80
|
+
* pricing there is no plan: the account adds a card and pays per use.
|
|
81
|
+
*/
|
|
82
|
+
export declare function usageOnlyCardRequiredLines(what: string, card?: UsageCard): string[];
|
|
83
|
+
/** `hq billing sponsor request` for a person on usage pricing. */
|
|
84
|
+
export declare function usageOnlySponsorLines(card?: UsageCard): string[];
|
|
85
|
+
/** A personal feature refusal for a person on usage pricing. */
|
|
86
|
+
export declare function usageOnlyPersonalFeatureNotice(label: string): string;
|
|
87
|
+
//# sourceMappingURL=usage-only.d.ts.map
|