From 8e8e928380bcc32ef306563a90afc829f07f1498 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Thu, 5 Feb 2026 17:53:46 -0600 Subject: [PATCH] feat: add CLI usage reporting commands (closes #50) --- cli/src/commands/usage.ts | 277 ++++++++++++++++++++++++++++++++++++++ cli/src/index.ts | 2 + cli/src/utils/types.ts | 56 ++++++++ 3 files changed, 335 insertions(+) create mode 100644 cli/src/commands/usage.ts diff --git a/cli/src/commands/usage.ts b/cli/src/commands/usage.ts new file mode 100644 index 00000000..a04805e6 --- /dev/null +++ b/cli/src/commands/usage.ts @@ -0,0 +1,277 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { api } from '../utils/api.js'; +import { findTask } from '../utils/find.js'; +import type { + TokenMetrics, + DurationMetrics, + TaskCostMetrics, + TaskCostEntry, +} from '../utils/types.js'; + +/** + * Format a number as currency (e.g., "$0.42") + */ +function formatCost(cost: number): string { + return `$${cost.toFixed(2)}`; +} + +/** + * Format a duration in milliseconds to human-readable format + */ +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; + } + if (minutes > 0) { + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + } + return `${seconds}s`; +} + +/** + * Format large numbers with commas (e.g., 1,234,567) + */ +function formatNumber(num: number): string { + return num.toLocaleString('en-US'); +} + +/** + * Display usage summary (default behavior) + */ +async function displayUsageSummary(period: string, json: boolean): Promise { + try { + // Fetch token metrics + const tokenMetrics = await api(`/api/metrics/tokens?period=${period}`); + + // Fetch duration metrics + const durationMetrics = await api(`/api/metrics/duration?period=${period}`); + + if (json) { + console.log( + JSON.stringify( + { + period, + tokens: tokenMetrics, + duration: durationMetrics, + }, + null, + 2 + ) + ); + return; + } + + // Display summary in table format + console.log(chalk.bold(`\nšŸ“Š Usage Summary (${period})\n`)); + console.log(chalk.dim('─'.repeat(60))); + + // Token usage + console.log(chalk.bold('\nšŸ’¬ Token Usage')); + console.log(` Total Tokens: ${chalk.cyan(formatNumber(tokenMetrics.totalTokens))}`); + console.log(` Input Tokens: ${chalk.white(formatNumber(tokenMetrics.inputTokens))}`); + console.log(` Output Tokens: ${chalk.white(formatNumber(tokenMetrics.outputTokens))}`); + if (tokenMetrics.cacheTokens > 0) { + console.log(` Cache Tokens: ${chalk.green(formatNumber(tokenMetrics.cacheTokens))}`); + } + console.log(` Runs: ${chalk.white(formatNumber(tokenMetrics.runs))}`); + + // Cost estimation (simple: $0.01/1K input, $0.03/1K output) + const estimatedCost = + (tokenMetrics.inputTokens / 1000) * 0.01 + (tokenMetrics.outputTokens / 1000) * 0.03; + console.log(` Estimated Cost: ${chalk.yellow(formatCost(estimatedCost))}`); + + // Duration metrics + console.log(chalk.bold('\nā±ļø Time Spent')); + console.log(` Average: ${chalk.cyan(formatDuration(durationMetrics.avgMs))}`); + console.log(` Median: ${chalk.white(formatDuration(durationMetrics.p50Ms))}`); + console.log(` 95th %ile: ${chalk.white(formatDuration(durationMetrics.p95Ms))}`); + + console.log(chalk.dim('\n─'.repeat(60))); + console.log(chalk.dim(`\nšŸ’” Tip: Use --agent or --task for detailed breakdowns\n`)); + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } +} + +/** + * Display per-agent usage breakdown + */ +async function displayAgentUsage(agentName: string, period: string, json: boolean): Promise { + try { + const tokenMetrics = await api(`/api/metrics/tokens?period=${period}`); + const durationMetrics = await api(`/api/metrics/duration?period=${period}`); + + // Find agent in breakdown + const agentTokens = tokenMetrics.byAgent.find((a) => a.agent === agentName); + const agentDuration = durationMetrics.byAgent.find((a) => a.agent === agentName); + + if (!agentTokens && !agentDuration) { + console.error(chalk.red(`No data found for agent: ${agentName}`)); + console.log(chalk.dim('\nAvailable agents:')); + tokenMetrics.byAgent.forEach((a) => console.log(chalk.dim(` - ${a.agent}`))); + process.exit(1); + } + + if (json) { + console.log( + JSON.stringify( + { + agent: agentName, + period, + tokens: agentTokens || null, + duration: agentDuration || null, + }, + null, + 2 + ) + ); + return; + } + + console.log(chalk.bold(`\nšŸ“Š Agent Usage: ${agentName} (${period})\n`)); + console.log(chalk.dim('─'.repeat(60))); + + if (agentTokens) { + console.log(chalk.bold('\nšŸ’¬ Token Usage')); + console.log(` Total Tokens: ${chalk.cyan(formatNumber(agentTokens.totalTokens))}`); + console.log(` Input Tokens: ${chalk.white(formatNumber(agentTokens.inputTokens))}`); + console.log(` Output Tokens: ${chalk.white(formatNumber(agentTokens.outputTokens))}`); + if (agentTokens.cacheTokens > 0) { + console.log(` Cache Tokens: ${chalk.green(formatNumber(agentTokens.cacheTokens))}`); + } + console.log(` Runs: ${chalk.white(formatNumber(agentTokens.runs))}`); + + const estimatedCost = + (agentTokens.inputTokens / 1000) * 0.01 + (agentTokens.outputTokens / 1000) * 0.03; + console.log(` Estimated Cost: ${chalk.yellow(formatCost(estimatedCost))}`); + } + + if (agentDuration) { + console.log(chalk.bold('\nā±ļø Time Spent')); + console.log(` Runs: ${chalk.white(formatNumber(agentDuration.runs))}`); + console.log(` Average: ${chalk.cyan(formatDuration(agentDuration.avgMs))}`); + console.log(` Median: ${chalk.white(formatDuration(agentDuration.p50Ms))}`); + console.log(` 95th %ile: ${chalk.white(formatDuration(agentDuration.p95Ms))}`); + } + + console.log(chalk.dim('\n─'.repeat(60) + '\n')); + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } +} + +/** + * Display per-task usage breakdown + */ +async function displayTaskUsage(taskId: string, period: string, json: boolean): Promise { + try { + // Find task to get full ID + const task = await findTask(taskId); + if (!task) { + console.error(chalk.red(`Task not found: ${taskId}`)); + process.exit(1); + } + + // Fetch task cost data + const taskCostMetrics = await api(`/api/metrics/task-cost?period=${period}`); + + // Find this specific task + const taskCost = taskCostMetrics.tasks.find((t) => t.taskId === task.id); + + if (!taskCost) { + console.error(chalk.red(`No usage data found for task: ${task.title}`)); + console.log(chalk.dim('\nThis task may not have any activity in the selected period.')); + process.exit(1); + } + + if (json) { + console.log( + JSON.stringify( + { + taskId: task.id, + taskTitle: task.title, + period, + usage: taskCost, + }, + null, + 2 + ) + ); + return; + } + + console.log(chalk.bold(`\nšŸ“Š Task Usage: ${task.title}\n`)); + console.log(chalk.dim(` ID: ${task.id}`)); + console.log(chalk.dim('─'.repeat(60))); + + console.log(chalk.bold('\nšŸ’¬ Token Usage')); + console.log(` Total Tokens: ${chalk.cyan(formatNumber(taskCost.totalTokens))}`); + console.log(` Input Tokens: ${chalk.white(formatNumber(taskCost.inputTokens))}`); + console.log(` Output Tokens: ${chalk.white(formatNumber(taskCost.outputTokens))}`); + + console.log(chalk.bold('\nšŸ’° Cost')); + console.log(` Total Cost: ${chalk.yellow(formatCost(taskCost.estimatedCost))}`); + console.log(` Runs: ${chalk.white(formatNumber(taskCost.runs))}`); + console.log(` Avg Cost/Run: ${chalk.white(formatCost(taskCost.avgCostPerRun))}`); + + console.log(chalk.dim('\n─'.repeat(60) + '\n')); + } catch (err) { + console.error(chalk.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } +} + +export function registerUsageCommands(program: Command): void { + const usage = program + .command('usage') + .description('Display usage statistics (tokens, costs, time)') + .option( + '--period ', + 'Time period: today, 24h, 3d, 7d, 30d, 3m, 6m, 12m, wtd, mtd, ytd', + '7d' + ) + .option('--agent ', 'Show usage breakdown for a specific agent') + .option('--task ', 'Show usage breakdown for a specific task') + .option('--json', 'Output as JSON') + .action(async (options) => { + const period = options.period; + + // Validate period + const validPeriods = [ + 'today', + '24h', + '3d', + '7d', + '30d', + '3m', + '6m', + '12m', + 'wtd', + 'mtd', + 'ytd', + ]; + if (!validPeriods.includes(period)) { + console.error( + chalk.red(`Invalid period: ${period}. Must be one of: ${validPeriods.join(', ')}`) + ); + process.exit(1); + } + + if (options.task) { + await displayTaskUsage(options.task, period, options.json); + } else if (options.agent) { + await displayAgentUsage(options.agent, period, options.json); + } else { + await displayUsageSummary(period, options.json); + } + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index f1be52aa..13272a3c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,6 +13,7 @@ import { registerAgentStatusCommands } from './commands/agent-status.js'; import { registerProjectCommands } from './commands/projects.js'; import { registerWorkflowCommands } from './commands/workflow.js'; import { registerSetupCommands } from './commands/setup.js'; +import { registerUsageCommands } from './commands/usage.js'; const program = new Command(); @@ -35,5 +36,6 @@ registerAgentStatusCommands(program); registerProjectCommands(program); registerWorkflowCommands(program); registerSetupCommands(program); +registerUsageCommands(program); program.parse(); diff --git a/cli/src/utils/types.ts b/cli/src/utils/types.ts index 5cc0e2c7..340c813b 100644 --- a/cli/src/utils/types.ts +++ b/cli/src/utils/types.ts @@ -1,2 +1,58 @@ // Re-export shared types export type { Task } from '@veritas-kanban/shared'; + +// Metrics types +export interface TokenMetrics { + period: string; + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + runs: number; + perSuccessfulRun: { + avg: number; + p50: number; + p95: number; + }; + byAgent: Array<{ + agent: string; + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + runs: number; + }>; +} + +export interface DurationMetrics { + period: string; + runs: number; + avgMs: number; + p50Ms: number; + p95Ms: number; + byAgent: Array<{ + agent: string; + runs: number; + avgMs: number; + p50Ms: number; + p95Ms: number; + }>; +} + +export interface TaskCostEntry { + taskId: string; + taskTitle?: string; + inputTokens: number; + outputTokens: number; + totalTokens: number; + estimatedCost: number; + runs: number; + avgCostPerRun: number; +} + +export interface TaskCostMetrics { + period: string; + tasks: TaskCostEntry[]; + totalCost: number; + avgCostPerTask: number; +}