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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ npx ccusage-fleet daily --hosts localhost,rtzr --group-by device
# Date totals only
npx ccusage-fleet daily --hosts localhost,rtzr --group-by none

# Append a report-bucket token distribution graph
npx ccusage-fleet daily --hosts localhost,rtzr --graph

# Scale the graph by estimated cost
npx ccusage-fleet daily --hosts localhost,rtzr --graph --graph-metric cost

# One consistent date range and timezone on every device
npx ccusage-fleet daily \
--hosts localhost,rtzr \
Expand Down Expand Up @@ -66,6 +72,8 @@ Create `ccusage-fleet.config.json`, `.ccusage-fleet.json`, or `~/.config/ccusage
],
"timezone": "Asia/Seoul",
"groupBy": "agent",
"graph": false,
"graphMetric": "tokens",
"concurrency": 4,
"timeoutMs": 120000
}
Expand All @@ -91,6 +99,12 @@ The default table follows ccusage's report shape: one `All` row per date, follow

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.

## Graph

Add `--graph` to append a proportional distribution chart after the table. Bars use total tokens by default; choose `--graph-metric cost` to scale them by estimated cost. Buckets follow the report command: days for `daily`, weeks for `weekly`, and months for `monthly`.

Hour buckets will require a future normalized event export from ccusage; ccusage-fleet does not copy raw transcripts to approximate them.

## Failure handling

By default, reachable hosts are reported even when another host fails. Add `--strict` to return a non-zero exit status if any host fails. `--timeout` is applied per host.
Expand Down
2 changes: 2 additions & 0 deletions ccusage-fleet.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
},
"timezone": { "type": "string" },
"groupBy": { "enum": ["agent", "device", "none"] },
"graph": { "type": "boolean" },
"graphMetric": { "enum": ["tokens", "cost"] },
"byHost": { "type": "boolean" },
"offline": { "type": "boolean" },
"noCost": { "type": "boolean" },
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ccusage-fleet",
"version": "0.2.0",
"version": "0.3.0",
"description": "Aggregate ccusage reports across local and SSH-connected machines",
"type": "module",
"bin": {
Expand Down
5 changes: 5 additions & 0 deletions src/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const VALUE_OPTIONS = new Map([
['--ssh-connect-timeout', 'sshConnectTimeoutSeconds'],
['--ccusage-version', 'ccusageVersion'],
['--group-by', 'groupBy'],
['--graph-metric', 'graphMetric'],
]);

const BOOLEAN_OPTIONS = new Map([
Expand All @@ -22,6 +23,8 @@ const BOOLEAN_OPTIONS = new Map([
['--no-cost', ['noCost', true]],
['--debug', ['debug', true]],
['--dry-run', ['dryRun', true]],
['--graph', ['graph', true]],
['--no-graph', ['graph', false]],
['--help', ['help', true]],
['-h', ['help', true]],
['--version', ['version', true]],
Expand Down Expand Up @@ -103,6 +106,8 @@ REPORT
--json Emit machine-readable fleet JSON
--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
--graph / --no-graph Show a report-bucket distribution graph
--graph-metric <metric> Scale graph bars by tokens or cost (default: tokens)
--no-cost Hide costs
--offline / --online Use bundled or online ccusage pricing (default: offline)

Expand Down
7 changes: 6 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { helpText, parseArgs } from './args.js';
import { discoverConfigPath, loadConfig, resolveSettings } from './config.js';
import { mapConcurrent, runHost } from './runner.js';
import { renderFleetTable } from './table.js';
import { renderFleetGraph } from './graph.js';

const here = dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8'));
Expand Down Expand Up @@ -62,7 +63,11 @@ export async function runCli(argv, dependencies = {}) {
if (settings.json) {
process.stdout.write(`${JSON.stringify(fleet, null, 2)}\n`);
} else {
process.stdout.write(`${renderFleetTable(fleet, settings)}\n`);
const sections = [renderFleetTable(fleet, settings)];
if (settings.graph) {
sections.push(renderFleetGraph(fleet, settings));
}
process.stdout.write(`${sections.join('\n\n')}\n`);
}
return settings.strict && successful.length !== results.length ? 1 : 0;
}
12 changes: 11 additions & 1 deletion src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,16 +143,26 @@ export function resolveSettings(parsedOptions, config, defaults) {
if (!['agent', 'device', 'none'].includes(groupBy)) {
throw new Error(`group-by must be agent, device, or none (received '${groupBy}')`);
}
const graphMetric = parsedOptions.graphMetric ?? config.graphMetric ?? 'tokens';
if (!['tokens', 'cost'].includes(graphMetric)) {
throw new Error(`graph-metric must be tokens or cost (received '${graphMetric}')`);
}
const noCost = parsedOptions.noCost ?? config.noCost ?? false;
if (graphMetric === 'cost' && noCost) {
throw new Error('graph-metric cost cannot be combined with --no-cost');
}

return {
ccusageVersion: parsedOptions.ccusageVersion ?? config.ccusageVersion ?? defaults.ccusageVersion,
concurrency: integer(parsedOptions.concurrency ?? config.concurrency, 'concurrency', 4, 1, 32),
debug: parsedOptions.debug ?? false,
dryRun: parsedOptions.dryRun ?? false,
groupBy,
graph: parsedOptions.graph ?? config.graph ?? false,
graphMetric,
hosts,
json: parsedOptions.json ?? false,
noCost: parsedOptions.noCost ?? config.noCost ?? false,
noCost,
offline: parsedOptions.offline ?? config.offline ?? true,
since: parsedOptions.since,
sshConnectTimeoutSeconds: integer(
Expand Down
65 changes: 65 additions & 0 deletions src/graph.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
function number(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}

function compact(value, prefix = '') {
const numeric = number(value);
const absolute = Math.abs(numeric);
if (absolute >= 1_000_000_000) return `${prefix}${(numeric / 1_000_000_000).toFixed(1)}B`;
if (absolute >= 1_000_000) return `${prefix}${(numeric / 1_000_000).toFixed(1)}M`;
if (absolute >= 1_000) return `${prefix}${(numeric / 1_000).toFixed(1)}K`;
return `${prefix}${Math.round(numeric)}`;
}

function bar(value, peak, width) {
if (value <= 0 || peak <= 0) return ' '.repeat(width);
const scaled = Math.min(width, (value / peak) * width);
let full = Math.floor(scaled);
const fraction = scaled - full;
const partials = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
const partialIndex = Math.round(fraction * 8);
if (partialIndex === 8) {
full += 1;
}
const partial = full < width ? partials[partialIndex] ?? '' : '';
const used = Math.min(width, full + (partial ? 1 : 0));
return `${'█'.repeat(Math.min(full, width))}${partial}${'░'.repeat(width - used)}`;
}

function pad(value, width, right = false) {
const text = String(value);
return right ? text.padStart(width) : text.padEnd(width);
}

function border(left, middle, right, widths, fill = '─') {
return `${left}${widths.map((width) => fill.repeat(width + 2)).join(middle)}${right}`;
}

export function renderFleetGraph(fleet, settings, terminalWidth = process.stdout.columns ?? 120) {
const rows = fleet[fleet.command];
const bucketName = fleet.command === 'weekly' ? 'week' : fleet.command === 'monthly' ? 'month' : 'day';
const metricKey = settings.graphMetric === 'cost' ? 'totalCost' : 'totalTokens';
const values = rows.map((row) => number(row[metricKey]));
const total = values.reduce((sum, value) => sum + value, 0);
const peak = Math.max(0, ...values);
const metricTitle = settings.graphMetric === 'cost' ? 'Cost' : 'Total tokens';
const metricValue = (value) => settings.graphMetric === 'cost' ? compact(value, '$') : compact(value);
const title = `${metricTitle} over time · ${bucketName} buckets · total ${metricValue(total)} · peak ${metricValue(peak)}`;

const widths = [10, 10, 10, Math.max(16, Math.min(60, terminalWidth - 46))];
const output = [title, border('╭', '┬', '╮', widths)];
const headers = ['BUCKET', 'TOKENS', 'COST', 'DISTRIBUTION'];
output.push(`│ ${headers.map((header, index) => pad(header, widths[index])).join(' ┆ ')} │`);
output.push(border('╞', '╪', '╡', widths, '═'));
for (const row of rows) {
const cells = [
pad(row.period, widths[0]),
pad(compact(row.totalTokens), widths[1], true),
pad(settings.noCost ? '—' : compact(row.totalCost, '$'), widths[2], true),
bar(number(row[metricKey]), peak, widths[3]),
];
output.push(`│ ${cells.join(' ┆ ')} │`);
}
output.push(border('╰', '┴', '╯', widths));
return output.join('\n');
}
13 changes: 13 additions & 0 deletions test/args-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ test('rejects an unknown grouping', () => {
);
});

test('configures token and cost graphs', () => {
const tokens = resolveSettings(parseArgs(['--graph']).options, {}, defaults);
assert.equal(tokens.graph, true);
assert.equal(tokens.graphMetric, 'tokens');

const cost = resolveSettings(parseArgs(['--graph', '--graph-metric', 'cost']).options, {}, defaults);
assert.equal(cost.graphMetric, 'cost');
assert.throws(
() => resolveSettings(parseArgs(['--graph-metric', 'cost', '--no-cost']).options, {}, defaults),
/cannot be combined with --no-cost/,
);
});

test('rejects SSH option and shell injection', () => {
assert.throws(() => validateSshTarget('-oProxyCommand=bad'), /Invalid SSH target/);
assert.throws(() => validateSshTarget('host;touch /tmp/pwned'), /Invalid SSH target/);
Expand Down
35 changes: 35 additions & 0 deletions test/graph.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { renderFleetGraph } from '../src/graph.js';

const fleet = {
command: 'daily',
daily: [
{ period: '2026-07-14', totalCost: 25, totalTokens: 500 },
{ period: '2026-07-15', totalCost: 50, totalTokens: 1000 },
],
};

test('renders a proportional day-bucket token graph', () => {
const output = renderFleetGraph(fleet, { graphMetric: 'tokens', noCost: false }, 100);
assert.match(output, /Total tokens over time · day buckets · total 1\.5K · peak 1\.0K/);
assert.match(output, /2026-07-14/);
assert.match(output, /████/);
assert.match(output, /░/);
assert.match(output, /╭─/);
});

test('supports cost-scaled graphs', () => {
const output = renderFleetGraph(fleet, { graphMetric: 'cost', noCost: false }, 100);
assert.match(output, /Cost over time/);
assert.match(output, /total \$75/);
});

test('uses the active report bucket label', () => {
for (const [command, bucket] of [['weekly', 'week'], ['monthly', 'month']]) {
const report = { command, [command]: fleet.daily };
const output = renderFleetGraph(report, { graphMetric: 'tokens', noCost: false }, 100);
assert.match(output, new RegExp(`${bucket} buckets`));
}
});
Loading