ccusage-fleet
Advanced tools
@@ -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", |
+27
-1
| # 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 @@ |
+9
-8
@@ -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), | ||
| }; | ||
| } |
+8
-4
@@ -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 | ||
| `; | ||
| } |
+2
-6
@@ -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}`); |
+21
-1
@@ -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, |
+5
-5
@@ -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(), |
+162
-50
@@ -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.`); |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
36155
19.26%844
17.06%115
29.21%7
16.67%