Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tigrisdata/cli": minor
---

`tigris usage` shows the active organization's usage and charges for the current month, or any month with `--month YYYY-MM`. Line items with zero usage are hidden unless `--all` is given; `--format json` returns the full invoice. Requires OAuth login.
5 changes: 5 additions & 0 deletions .changeset/iam-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tigrisdata/iam": minor
---

`getUsage({ month })` fetches the organization's usage and charges for a billing month from the management API — the data behind the console's Usage page: every charge with its quantity, billed quantity, tiers and amount, plus subtotal, credits, total, plan and invoice state. Defaults to the current month; needs a session token.
23 changes: 23 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Run `tigris help` to see all available commands, or `tigris <command> help` for
| `tigris configure` (c) | Save access-key credentials to ~/.tigris/config.json for persistent use across all commands |
| `tigris login` (l) | Start a session via OAuth (default) or temporary credentials. Session state is cleared on logout |
| `tigris whoami` (w) | Print the currently authenticated user, organization, and auth method |
| `tigris usage` | Show the active organization's usage and charges for a billing month, as on the console's Usage page. Requires OAuth login |
| `tigris update` | Update the CLI to the latest version |
| `tigris logout` | End the current session and clear login state. Credentials saved via 'configure' are kept |
| `tigris credentials` (creds) | Test whether your current credentials can reach Tigris and optionally verify access to a specific bucket |
Expand Down Expand Up @@ -161,6 +162,28 @@ tigris whoami
tigris whoami
```

### `tigris usage`

Show the active organization's usage and charges for a billing month, as on the console's Usage page. Requires OAuth login

```
tigris usage [flags]
```

| Flag | Description |
|------|-------------|
| `--month` | Billing month as YYYY-MM (default is the current month) |
| `--all` | Show every line item, even those with zero usage |
| `--format` | Output format (default: table) |

**Examples:**
```bash
tigris usage
tigris usage --month 2026-09
tigris usage --all
tigris usage --format json
```

### `tigris update`

Update the CLI to the latest version
Expand Down
197 changes: 197 additions & 0 deletions packages/cli/src/lib/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { getOAuthIAMConfig } from '@auth/iam.js';
import {
getUsage,
type UsageCharge,
type UsageResponse,
} from '@tigrisdata/iam';
import { failWithError } from '@utils/exit.js';
import {
formatJson,
formatTable,
formatXml,
formatXmlObject,
type TableColumn,
} from '@utils/format.js';
import {
msg,
printEmpty,
printHint,
printStart,
printSuccess,
} from '@utils/messages.js';
import { getFormat, getOption } from '@utils/options.js';

const context = msg('usage');

// The console's own formatters, so the two views agree.
const quantityFormat = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 2,
});
const amountFormat = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});

/** Short unit labels: "826 Requests", "43.7 GiB-Month". */
const UNIT_LABELS: Record<string, string> = {
'Number of Requests': 'Requests',
'Number of events published': 'Events',
};

type Row = { type: string; used: string; billed: string; amount: string };

const columns: TableColumn[] = [
{ key: 'type', header: 'Type' },
{ key: 'used', header: 'Used', align: 'right' },
{ key: 'billed', header: 'Billed', align: 'right' },
{ key: 'amount', header: 'Amount', align: 'right' },
];

function quantity(value: number, charge: UsageCharge): string {
return `${quantityFormat.format(value)} ${UNIT_LABELS[charge.unit] ?? charge.unit}`;
}

function monthLabel(usage: UsageResponse): string {
return new Date(usage.startingOn).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
}

/**
* The invoice amounts in the console's order. The commitment is a floor,
* total = max(subtotal, minimumCommit) - credits, which the note printed
* under the table spells out.
*/
function footerRows(usage: UsageResponse): Row[] {
const rows: Row[] = [];
if (usage.minimumCommit) {
rows.push({
type: 'Minimum Commitment',
used: '',
billed: '',
amount: amountFormat.format(usage.minimumCommit),
});
}
rows.push({
type: 'Subtotal',
used: '',
billed: '',
amount: amountFormat.format(usage.subtotal),
});
for (const grant of usage.creditGrants ?? []) {
rows.push({
type: grant.name,
used: '',
billed: '',
amount: `-${amountFormat.format(grant.amount)}`,
});
}
rows.push({
type: 'Total',
used: '',
billed: '',
amount: amountFormat.format(usage.total),
});
return rows;
}

/** Charges as raw values, then the invoice-level amounts. */
function usageXml(charges: UsageCharge[], usage: UsageResponse): string {
const nested = (block: string) => ` ${block.replace(/\n/g, '\n ')}`;
const lines = ['<usage>'];
lines.push(
nested(
formatXml(
charges.map((charge) => ({
id: charge.id,
type: charge.name,
unit: charge.unit,
used: charge.quantity,
billed: charge.chargedQuantity,
amount: charge.total,
})),
'charges',
'charge'
)
)
);
const totals: Record<string, unknown> = { subtotal: usage.subtotal };
if (usage.minimumCommit) totals.minimumCommit = usage.minimumCommit;
lines.push(formatXmlObject(totals, ' '));
if (usage.creditGrants?.length) {
lines.push(nested(formatXml(usage.creditGrants, 'credits', 'credit')));
}
lines.push(formatXmlObject({ total: usage.total }, ' '));
lines.push('</usage>');
return lines.join('\n');
}

export default async function usage(options: Record<string, unknown>) {
printStart(context);

const format = getFormat(options);
const month = getOption<string>(options, ['month']);
const all = getOption<boolean>(options, ['all']) ?? false;

const config = await getOAuthIAMConfig(context);

const { data, error } = await getUsage({ month, config });

if (error) {
failWithError(context, error);
}

if (format === 'json') {
console.log(formatJson(data));
return;
}

const charges = all
? data.charges
: data.charges.filter((charge) => charge.quantity > 0 || charge.total > 0);

const url = data.checkoutUrl ?? data.receiptUrl;
// A commitment or credit can leave an amount owed with nothing metered.
const settles =
data.total !== 0 ||
Boolean(data.minimumCommit) ||
(data.creditGrants?.length ?? 0) > 0 ||
Boolean(url);

if (charges.length === 0 && !settles) {
printEmpty(context, { month: monthLabel(data) });
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.

if (format === 'xml') {
console.log(usageXml(charges, data));
} else {
const rows: Row[] = charges.map((charge) => ({
type: charge.name,
used: quantity(charge.quantity, charge),
billed: quantity(charge.chargedQuantity, charge),
amount: amountFormat.format(charge.total),
}));
// ending_before is the exclusive end of the period: the last refresh.
console.log(
`${monthLabel(data)} · ${data.planName} · as of ${data.endingBefore.slice(0, 10)}`
);
console.log(formatTable(rows, columns, footerRows(data)));
if (data.minimumCommit) {
console.log(
`Minimum commitment: your plan bills at least ${amountFormat.format(data.minimumCommit)} a month, before credits.`
);
}
}

if (url) {
printHint(context, {
action: data.checkoutUrl ? 'Make a payment' : 'View the receipt',
url,
});
}

printSuccess(context);
}
27 changes: 27 additions & 0 deletions packages/cli/src/specs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,33 @@ commands:
onSuccess: ''
onFailure: 'Failed to get user information'
onAlreadyDone: "Not authenticated\nRun \"tigris login\" to authenticate"

# usage
- name: usage
group: Get started
description: Show the active organization's usage and charges for a billing month, as on the console's Usage page. Requires OAuth login
helpText: Show this month's usage and charges
examples:
- "tigris usage"
- "tigris usage --month 2026-09"
- "tigris usage --all"
- "tigris usage --format json"
messages:
onStart: ''
onSuccess: ''
onFailure: 'Failed to get usage'
onEmpty: 'No usage recorded for {{month}}'
hint: '{{action}}: {{url}}'
arguments:
- name: month
description: Billing month as YYYY-MM (default is the current month)
- name: all
description: Show every line item, even those with zero usage
type: flag
- name: format
description: Output format
options: [json, table, xml]
default: table
# update
- name: update
group: CLI
Expand Down
20 changes: 15 additions & 5 deletions packages/cli/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,14 @@ function calculateColumnWidths<T extends Record<string, unknown>>(
*/
export function formatTable<T extends Record<string, unknown>>(
items: T[],
columns: TableColumn[]
columns: TableColumn[],
/** Summary rows (totals and the like), set apart from the data by a rule. */
footer: T[] = []
): string {
const lines: string[] = [];

// Calculate widths based on content
const widths = calculateColumnWidths(items, columns);
const widths = calculateColumnWidths([...items, ...footer], columns);

// Build header separator
const topBorder = `┌${widths.map((w) => '─'.repeat(w + 2)).join('┬')}┐`;
Expand All @@ -210,16 +212,24 @@ export function formatTable<T extends Record<string, unknown>>(
// Middle border
lines.push(middleBorder);

// Data rows
items.forEach((item) => {
const renderRow = (item: T) => {
const cells = columns.map((col, i) => {
const value = truncate(formatCellValue(item[col.key]), widths[i]);
return col.align === 'right'
? value.padStart(widths[i])
: value.padEnd(widths[i]);
});
lines.push(`│ ${cells.join(' │ ')} │`);
});
};

// Data rows
items.forEach(renderRow);

if (footer.length > 0) {
// With no data rows the header rule already sets the footer apart.
if (items.length > 0) lines.push(middleBorder);
footer.forEach(renderRow);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Bottom border
lines.push(`${bottomBorder}\n`);
Expand Down
Loading