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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 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.

```bash
npx ccusage-fleet daily --hosts localhost,rtzr,jiun-mbp,jiun-mini
Expand All @@ -25,6 +25,19 @@ npx ccusage-fleet daily --hosts localhost,rtzr,jiun-mbp,jiun-mini
# Machine-readable output
npx ccusage-fleet daily --hosts localhost,rtzr --json

# 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
npx ccusage-fleet daily \
--hosts localhost,rtzr \
Expand Down Expand Up @@ -52,6 +65,7 @@ Create `ccusage-fleet.config.json`, `.ccusage-fleet.json`, or `~/.config/ccusage
{ "name": "jiun-mini", "type": "ssh", "target": "jiun-mini" }
],
"timezone": "Asia/Seoul",
"groupBy": "agent",
"concurrency": 4,
"timeoutMs": 120000
}
Expand All @@ -65,6 +79,18 @@ Named entries let the displayed device name differ from its SSH target:

Command-line hosts override configured hosts. Repeated `--host` options are also supported.

## 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

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
1 change: 1 addition & 0 deletions ccusage-fleet.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
}
},
"timezone": { "type": "string" },
"groupBy": { "enum": ["agent", "device", "none"] },
"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.1.0",
"version": "0.2.0",
"description": "Aggregate ccusage reports across local and SSH-connected machines",
"type": "module",
"bin": {
Expand Down
17 changes: 9 additions & 8 deletions src/aggregate.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ export function mergeUsage(target, source, includeAgents = true) {
return target;
}

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');
let target = byPeriod.get(period);
if (target == null) {
Expand All @@ -107,18 +107,19 @@ function totalsFromRows(rows) {

export function aggregateFleet(results, settings) {
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]),
}));
return {
byHost,
ccusageVersion: settings.ccusageVersion,
command: 'daily',
daily,
command: settings.command,
[settings.command]: rows,
generatedAt: new Date().toISOString(),
groupBy: settings.groupBy,
hosts: results.map((result) => ({
durationMs: result.durationMs,
error: result.error,
Expand All @@ -129,6 +130,6 @@ export function aggregateFleet(results, settings) {
})),
schemaVersion: 1,
timezone: settings.timezone,
totals: totalsFromRows(daily),
totals: totalsFromRows(rows),
};
}
12 changes: 8 additions & 4 deletions src/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const VALUE_OPTIONS = new Map([
['--concurrency', 'concurrency'],
['--ssh-connect-timeout', 'sshConnectTimeoutSeconds'],
['--ccusage-version', 'ccusageVersion'],
['--group-by', 'groupBy'],
]);

const BOOLEAN_OPTIONS = new Map([
Expand Down Expand Up @@ -51,8 +52,8 @@ export function parseArgs(argv) {
if (command != null) {
throw new Error(`Unexpected argument '${argument}'`);
}
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.`);
}
command = argument;
continue;
Expand Down Expand Up @@ -88,7 +89,7 @@ export function helpText() {
return `ccusage-fleet - aggregate ccusage across local and SSH hosts

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

HOSTS
--hosts <list> Comma-separated hosts; local or localhost means this machine
Expand All @@ -100,7 +101,8 @@ REPORT
--until <YYYY-MM-DD> Include usage on or before this date
--timezone <IANA> Use one timezone on every host
--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
--offline / --online Use bundled or online ccusage pricing (default: offline)

Expand All @@ -117,6 +119,8 @@ EXECUTION

EXAMPLES
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
`;
}
8 changes: 2 additions & 6 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,12 @@ export async function runCli(argv, dependencies = {}) {
process.stdout.write(`${packageJson.version}\n`);
return 0;
}
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(',')}`);
if (configPath) debug(settings, `config=${configPath}`);

Expand Down
22 changes: 21 additions & 1 deletion src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,32 @@ export function resolveSettings(parsedOptions, config, defaults) {
throw new Error(`Invalid timezone '${timezone}'`);
}

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,
concurrency: integer(parsedOptions.concurrency ?? config.concurrency, 'concurrency', 4, 1, 32),
debug: parsedOptions.debug ?? false,
dryRun: parsedOptions.dryRun ?? false,
groupBy,
hosts,
json: parsedOptions.json ?? false,
noCost: parsedOptions.noCost ?? config.noCost ?? false,
Expand Down
10 changes: 5 additions & 5 deletions src/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function shellQuote(value) {
}

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) {
args.push('--since', settings.since);
}
Expand Down Expand Up @@ -119,7 +119,7 @@ export function spawnCapture(command, args, timeoutMs) {
});
}

function parseReport(stdout) {
function parseReport(stdout, command) {
const trimmed = stdout.trim();
if (!trimmed) {
throw new Error('ccusage returned no JSON');
Expand All @@ -129,8 +129,8 @@ function parseReport(stdout) {
const lastBrace = trimmed.lastIndexOf('}');
const json = firstBrace >= 0 && lastBrace >= firstBrace ? trimmed.slice(firstBrace, lastBrace + 1) : 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`);
}
return report;
} catch (error) {
Expand All @@ -154,7 +154,7 @@ export async function runHost(host, settings, spawnFn = spawnCapture) {
durationMs: result.durationMs,
host,
invocation,
report: parseReport(result.stdout),
report: parseReport(result.stdout, settings.command),
status: 'ok',
stderr: result.stderr.trim(),
};
Expand Down
Loading
Loading