🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

ccusage-fleet

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ccusage-fleet - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+1
-0
ccusage-fleet.schema.json

@@ -28,2 +28,3 @@ {

"timezone": { "type": "string" },
"groupBy": { "enum": ["agent", "device", "none"] },
"byHost": { "type": "boolean" },

@@ -30,0 +31,0 @@ "offline": { "type": "boolean" },

+1
-1
{
"name": "ccusage-fleet",
"version": "0.1.0",
"version": "0.2.0",
"description": "Aggregate ccusage reports across local and SSH-connected machines",

@@ -5,0 +5,0 @@ "type": "module",

# ccusage-fleet
Run [ccusage](https://github.com/ccusage/ccusage) on local and SSH-connected machines, then aggregate the usage reports into one daily view.
Run [ccusage](https://github.com/ccusage/ccusage) on local and SSH-connected machines, then aggregate daily, weekly, or monthly usage reports into one view.

@@ -28,2 +28,15 @@ ```bash

# Weekly and monthly reports use the same options
npx ccusage-fleet weekly --hosts localhost,rtzr --group-by agent
npx ccusage-fleet monthly --hosts localhost,rtzr --group-by device
# Default: date -> agent -> models
npx ccusage-fleet daily --hosts localhost,rtzr --group-by agent
# Date -> device -> models
npx ccusage-fleet daily --hosts localhost,rtzr --group-by device
# Date totals only
npx ccusage-fleet daily --hosts localhost,rtzr --group-by none
# One consistent date range and timezone on every device

@@ -56,2 +69,3 @@ npx ccusage-fleet daily \

"timezone": "Asia/Seoul",
"groupBy": "agent",
"concurrency": 4,

@@ -70,2 +84,14 @@ "timeoutMs": 120000

## Grouping
The default table follows ccusage's report shape: one `All` row per date, followed by agent rows and their models, and a final `Total` row.
| Option | Detail rows |
| --- | --- |
| `--group-by agent` | Claude, Codex, and other detected agents aggregated across devices |
| `--group-by device` | One row per local or SSH device |
| `--group-by none` | Date totals without detail rows |
The former `--by-host` and `--no-by-host` options remain as aliases for `--group-by device` and `--group-by none`. Set `CCUSAGE_FLEET_GROUP_BY` to choose a default without a config file.
## Failure handling

@@ -72,0 +98,0 @@

@@ -83,6 +83,6 @@ const TOKEN_FIELDS = [

function aggregateRows(reports) {
function aggregateRows(reports, command) {
const byPeriod = new Map();
for (const { report } of reports) {
for (const row of report.daily) {
for (const row of report[command]) {
const period = String(row.period ?? row.date ?? 'unknown');

@@ -111,7 +111,7 @@ let target = byPeriod.get(period);

const successful = results.filter((result) => result.status === 'ok');
const daily = aggregateRows(successful);
const rows = aggregateRows(successful, settings.command);
const byHost = successful.map((result) => ({
daily: result.report.daily,
[settings.command]: result.report[settings.command],
host: result.host.name,
totals: result.report.totals ?? totalsFromRows(result.report.daily),
totals: result.report.totals ?? totalsFromRows(result.report[settings.command]),
}));

@@ -121,5 +121,6 @@ return {

ccusageVersion: settings.ccusageVersion,
command: 'daily',
daily,
command: settings.command,
[settings.command]: rows,
generatedAt: new Date().toISOString(),
groupBy: settings.groupBy,
hosts: results.map((result) => ({

@@ -135,4 +136,4 @@ durationMs: result.durationMs,

timezone: settings.timezone,
totals: totalsFromRows(daily),
totals: totalsFromRows(rows),
};
}

@@ -12,2 +12,3 @@ const VALUE_OPTIONS = new Map([

['--ccusage-version', 'ccusageVersion'],
['--group-by', 'groupBy'],
]);

@@ -55,4 +56,4 @@

}
if (argument !== 'daily') {
throw new Error(`Unsupported report '${argument}'. ccusage-fleet currently supports daily.`);
if (!['daily', 'weekly', 'monthly'].includes(argument)) {
throw new Error(`Unsupported report '${argument}'. Choose daily, weekly, or monthly.`);
}

@@ -93,3 +94,3 @@ command = argument;

USAGE
ccusage-fleet [daily] [options]
ccusage-fleet [daily|weekly|monthly] [options]

@@ -106,3 +107,4 @@ HOSTS

--json Emit machine-readable fleet JSON
--by-host / --no-by-host Show or hide device rows (default: show)
--group-by <mode> Group detail rows by agent, device, or none (default: agent)
--by-host / --no-by-host Compatibility aliases for --group-by device/none
--no-cost Hide costs

@@ -124,4 +126,6 @@ --offline / --online Use bundled or online ccusage pricing (default: offline)

npx ccusage-fleet daily --hosts localhost,rtzr,jiun-mbp,jiun-mini
npx ccusage-fleet weekly --hosts localhost,server --group-by agent
npx ccusage-fleet monthly --hosts localhost,server --group-by device
CCUSAGE_FLEET_HOSTS=localhost,server npx ccusage-fleet daily --json
`;
}

@@ -31,12 +31,8 @@ import { readFileSync } from 'node:fs';

}
if (command !== 'daily') {
throw new Error(`Unsupported command '${command}'`);
}
const configPath = discoverConfigPath(options.config);
const config = loadConfig(configPath);
const settings = resolveSettings(options, config, {
const settings = { ...resolveSettings(options, config, {
ccusageVersion: packageJson.dependencies.ccusage,
timezone: DEFAULT_TIMEZONE,
});
}), command };
debug(settings, `timezone=${settings.timezone} hosts=${settings.hosts.map((host) => host.name).join(',')}`);

@@ -43,0 +39,0 @@ if (configPath) debug(settings, `config=${configPath}`);

@@ -127,4 +127,23 @@ import { existsSync, readFileSync } from 'node:fs';

const cliCompatibilityGroup = parsedOptions.byHost === true
? 'device'
: parsedOptions.byHost === false
? 'none'
: undefined;
const configCompatibilityGroup = config.byHost === true
? 'device'
: config.byHost === false
? 'none'
: undefined;
const groupBy = parsedOptions.groupBy
?? cliCompatibilityGroup
?? process.env.CCUSAGE_FLEET_GROUP_BY
?? config.groupBy
?? configCompatibilityGroup
?? 'agent';
if (!['agent', 'device', 'none'].includes(groupBy)) {
throw new Error(`group-by must be agent, device, or none (received '${groupBy}')`);
}
return {
byHost: parsedOptions.byHost ?? config.byHost ?? true,
ccusageVersion: parsedOptions.ccusageVersion ?? config.ccusageVersion ?? defaults.ccusageVersion,

@@ -134,2 +153,3 @@ concurrency: integer(parsedOptions.concurrency ?? config.concurrency, 'concurrency', 4, 1, 32),

dryRun: parsedOptions.dryRun ?? false,
groupBy,
hosts,

@@ -136,0 +156,0 @@ json: parsedOptions.json ?? false,

@@ -12,3 +12,3 @@ import { spawn } from 'node:child_process';

export function buildCcusageArgs(settings) {
const args = ['daily', '--json', '--by-agent', '--no-color', '--timezone', settings.timezone];
const args = [settings.command, '--json', '--by-agent', '--no-color', '--timezone', settings.timezone];
if (settings.since) {

@@ -123,3 +123,3 @@ args.push('--since', settings.since);

function parseReport(stdout) {
function parseReport(stdout, command) {
const trimmed = stdout.trim();

@@ -134,4 +134,4 @@ if (!trimmed) {

const report = JSON.parse(json);
if (!Array.isArray(report.daily)) {
throw new Error('missing daily array');
if (!Array.isArray(report[command])) {
throw new Error(`missing ${command} array`);
}

@@ -160,3 +160,3 @@ return report;

invocation,
report: parseReport(result.stdout),
report: parseReport(result.stdout, settings.command),
status: 'ok',

@@ -163,0 +163,0 @@ stderr: result.stderr.trim(),

@@ -1,69 +0,181 @@

function compactNumber(value) {
const number = Number(value) || 0;
const absolute = Math.abs(number);
if (absolute >= 1_000_000_000) return `${(number / 1_000_000_000).toFixed(1)}B`;
if (absolute >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`;
if (absolute >= 1_000) return `${(number / 1_000).toFixed(1)}K`;
return String(Math.round(number));
const NUMBER_FORMAT = new Intl.NumberFormat('en-US');
function number(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
function cost(value, noCost) {
return noCost ? '—' : `$${(Number(value) || 0).toFixed(2)}`;
function formatNumber(value) {
return NUMBER_FORMAT.format(Math.round(number(value)));
}
function agents(row) {
const values = (row.agents ?? []).map((item) => item.agent).filter(Boolean);
return values.length > 0 ? values.join(',') : row.agent === 'all' ? '—' : row.agent;
function formatCost(value, noCost) {
return noCost ? '—' : `$${number(value).toFixed(2)}`;
}
function pad(value, width, right = false) {
const text = String(value);
return right ? text.padStart(width) : text.padEnd(width);
function titleCase(value) {
if (!value) return 'Unknown';
return value.charAt(0).toUpperCase() + value.slice(1);
}
function renderRows(rows) {
const headers = ['Date', 'Host', 'Agents', 'Input', 'Output', 'Cache Create', 'Cache Read', 'Tokens', 'Cost'];
const widths = [10, 14, 18, 9, 9, 12, 11, 9, 10];
const lines = [];
lines.push(headers.map((value, index) => pad(value, widths[index], index >= 3)).join(' '));
lines.push(widths.map((width) => '─'.repeat(width)).join('──'));
for (const { host, row } of rows) {
const values = [
row.period,
host,
agents(row),
compactNumber(row.inputTokens),
compactNumber(row.outputTokens),
compactNumber(row.cacheCreationTokens),
compactNumber(row.cacheReadTokens),
compactNumber(row.totalTokens),
row.formattedCost,
];
lines.push(values.map((value, index) => pad(value, widths[index], index >= 3)).join(' '));
function displayModelName(value) {
return String(value).replace(/^claude-/, '');
}
function modelLines(row) {
const models = row.modelsUsed ?? row.modelBreakdowns?.map((item) => item.modelName) ?? [];
return [...new Set(models)].sort().map((model) => `- ${displayModelName(model)}`);
}
function usageCells(row, noCost) {
return {
cacheCreate: formatNumber(row.cacheCreationTokens),
cacheRead: formatNumber(row.cacheReadTokens),
cost: formatCost(row.totalCost, noCost),
input: formatNumber(row.inputTokens),
output: formatNumber(row.outputTokens),
total: formatNumber(row.totalTokens),
};
}
function detailRowsForPeriod(fleet, aggregate, groupBy) {
if (groupBy === 'agent') {
return (aggregate.agents ?? []).map((agent) => ({
label: titleCase(agent.agent),
row: agent,
}));
}
return lines.join('\n');
if (groupBy === 'device') {
return fleet.byHost.flatMap((host) => {
const row = host[fleet.command].find((candidate) => candidate.period === aggregate.period);
return row == null ? [] : [{ label: host.host, row }];
});
}
return [];
}
export function renderFleetTable(fleet, settings) {
const hostMap = new Map(fleet.byHost.map((entry) => [entry.host, entry.daily]));
function tableRows(fleet, settings) {
const rows = [];
for (const aggregate of fleet.daily) {
rows.push({ host: 'ALL', row: { ...aggregate, formattedCost: cost(aggregate.totalCost, settings.noCost) } });
if (settings.byHost) {
for (const host of settings.hosts) {
const hostRow = (hostMap.get(host.name) ?? []).find((row) => row.period === aggregate.period);
if (hostRow) {
rows.push({
host: host.name,
row: { ...hostRow, formattedCost: cost(hostRow.totalCost, settings.noCost) },
});
}
for (const aggregate of fleet[fleet.command]) {
rows.push({
date: aggregate.period,
group: 'All',
models: [],
...usageCells(aggregate, settings.noCost),
});
for (const detail of detailRowsForPeriod(fleet, aggregate, settings.groupBy)) {
rows.push({
date: '',
group: `- ${detail.label}`,
models: modelLines(detail.row),
...usageCells(detail.row, settings.noCost),
});
}
}
rows.push({
date: 'Total',
group: '',
models: [],
...usageCells(fleet.totals, settings.noCost),
});
return rows;
}
function truncate(value, width) {
const text = String(value);
if (text.length <= width) return text;
if (width <= 1) return '…';
return `${text.slice(0, width - 1)}…`;
}
function cellLines(value) {
if (Array.isArray(value)) return value.length > 0 ? value.map(String) : [''];
return String(value ?? '').split('\n');
}
function chooseWidths(columns, rows, terminalWidth) {
const overhead = columns.length * 3 + 1;
const available = Math.max(80, terminalWidth) - overhead;
const widths = columns.map((column) => {
const natural = Math.max(
column.header.length,
...rows.flatMap((row) => cellLines(row[column.key]).map((line) => line.length)),
);
return Math.min(column.max, Math.max(column.min, natural));
});
while (widths.reduce((sum, width) => sum + width, 0) > available) {
let candidate = -1;
let slack = 0;
for (let index = 0; index < columns.length; index += 1) {
const currentSlack = widths[index] - columns[index].min;
if (currentSlack > slack) {
candidate = index;
slack = currentSlack;
}
}
if (candidate === -1) break;
widths[candidate] -= 1;
}
return widths;
}
function border(left, middle, right, widths) {
return `${left}${widths.map((width) => '─'.repeat(width + 2)).join(middle)}${right}`;
}
function renderLogicalRow(row, columns, widths) {
const values = columns.map((column) => cellLines(row[column.key]));
const height = Math.max(...values.map((lines) => lines.length));
const output = [];
for (let lineIndex = 0; lineIndex < height; lineIndex += 1) {
const cells = values.map((lines, index) => {
const value = truncate(lines[lineIndex] ?? '', widths[index]);
const padded = columns[index].align === 'right'
? value.padStart(widths[index])
: value.padEnd(widths[index]);
return ` ${padded} `;
});
output.push(`│${cells.join('│')}│`);
}
return output;
}
function renderTable(rows, groupBy, command, terminalWidth) {
const groupHeader = groupBy === 'device' ? 'Device' : groupBy === 'agent' ? 'Agent' : 'Group';
const periodHeader = command === 'weekly' ? 'Week' : command === 'monthly' ? 'Month' : 'Date';
const columns = [
{ key: 'date', header: periodHeader, min: 10, max: 10 },
{ key: 'group', header: groupHeader, min: 9, max: 16 },
{ key: 'models', header: 'Models', min: 14, max: 30 },
{ key: 'input', header: 'Input', min: 8, max: 18, align: 'right' },
{ key: 'output', header: 'Output', min: 8, max: 16, align: 'right' },
{ key: 'cacheCreate', header: 'Cache Create', min: 12, max: 18, align: 'right' },
{ key: 'cacheRead', header: 'Cache Read', min: 10, max: 20, align: 'right' },
{ key: 'total', header: 'Total Tokens', min: 12, max: 20, align: 'right' },
{ key: 'cost', header: 'Cost', min: 9, max: 12, align: 'right' },
];
const widths = chooseWidths(columns, rows, terminalWidth);
const output = [border('┌', '┬', '┐', widths)];
const header = Object.fromEntries(columns.map((column) => [column.key, column.header]));
output.push(...renderLogicalRow(header, columns, widths));
output.push(border('├', '┼', '┤', widths));
rows.forEach((row, index) => {
output.push(...renderLogicalRow(row, columns, widths));
output.push(index === rows.length - 1
? border('└', '┴', '┘', widths)
: border('├', '┼', '┤', widths));
});
return output.join('\n');
}
export function renderFleetTable(fleet, settings, terminalWidth = process.stdout.columns ?? 160) {
const ok = fleet.hosts.filter((host) => host.status === 'ok').length;
const failed = fleet.hosts.filter((host) => host.status === 'error').length;
const title = `ccusage Fleet Daily · ${ok}/${fleet.hosts.length} hosts · ${fleet.timezone}`;
const output = [title, '', rows.length > 0 ? renderRows(rows) : 'No usage data found.'];
const grouping = settings.groupBy === 'none' ? 'Totals only' : `Grouped by ${titleCase(settings.groupBy)}`;
const reportName = titleCase(fleet.command);
const title = `ccusage Fleet ${reportName} · ${grouping} · ${ok}/${fleet.hosts.length} hosts · ${fleet.timezone}`;
const output = [title, ''];
output.push(fleet[fleet.command].length > 0
? renderTable(tableRows(fleet, settings), settings.groupBy, fleet.command, terminalWidth)
: 'No usage data found.');
if (failed > 0) {

@@ -70,0 +182,0 @@ output.push('', `${failed} host(s) failed; see stderr or use --json for details.`);