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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ aimux run w # prefix match → work
aimux run o -m claude-sonnet-4-6 # one-time model override
aimux run w --resume # flags pass through to Claude CLI
aimux status # dashboard
aimux usage # token usage by profile for the last 7 days
aimux usage --all # all known transcript usage

# Set default model per profile (quote model names with special chars)
aimux profile update w -m claude-opus-4-6
Expand All @@ -74,6 +76,8 @@ aimux profile update o -m "claude-opus-4-6[1m]"
| `aimux init` | Auto-detect Claude dirs, create config, migrate profiles |
| `aimux init --source <path>` | Initialize with explicit source directory |
| `aimux status` | TUI dashboard — profiles, auth, symlink health |
| `aimux usage` | Show token usage by profile from Claude transcript metadata |
| `aimux usage --profile work --since 24h` | Show usage for one profile over a recent window |
| `aimux run [profile]` | Launch AI CLI with correct env and model |
| `aimux run` | Interactive picker — history pre-selects last used profile |
| `aimux run w` | Prefix matching — launches `work` if unambiguous |
Expand Down
70 changes: 67 additions & 3 deletions src/cli.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { Command } from 'commander';
import type { AimuxConfig } from './types/index.js';
import type { ProfileUsageSummary } from './core/index.js';
import { rmSync, existsSync, cpSync, mkdirSync, appendFileSync, readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, join } from 'node:path';
Expand All @@ -11,6 +12,7 @@ import {
syncProfile, syncAllProfiles, checkAllProfiles,
launchProfile, getLastProfile, recordHistory, getProfile,
looksLikeSubcommand,
summarizeUsage, parseSinceDuration, totalTokens,
} from './core/index.js';

function requireConfig(): AimuxConfig {
Expand Down Expand Up @@ -56,6 +58,42 @@ function formatSyncSummary(result: {
return parts.join(', ');
}

function formatInteger(value: number): string {
return Math.round(value).toLocaleString('en-US');
}

function formatUsd(value: number): string {
return `$${value.toFixed(2)}`;
}

function topModels(models: Map<string, number>): string {
const entries = Array.from(models.entries()).sort((a, b) => b[1] - a[1]);
if (entries.length === 0) return '-';
return entries.slice(0, 2).map(([model]) => model).join(', ');
}

function printUsageTable(summaries: ProfileUsageSummary[]): void {
const headers = ['PROFILE', 'SESS', 'REQ', 'INPUT', 'CACHE+', 'CACHE', 'OUTPUT', 'TOTAL', 'COST', 'MODELS'];
const rows = summaries.map((s) => [
s.profile,
formatInteger(s.sessions),
formatInteger(s.requests),
formatInteger(s.inputTokens),
formatInteger(s.cacheCreationInputTokens),
formatInteger(s.cacheReadInputTokens),
formatInteger(s.outputTokens),
formatInteger(totalTokens(s)),
formatUsd(s.estimatedCostUsd),
topModels(s.models),
]);
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
console.log(headers.map((h, i) => h.padEnd(widths[i])).join(' '));
console.log(widths.map((w) => '-'.repeat(w)).join(' '));
for (const row of rows) {
console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' '));
}
}


const program = new Command();

Expand All @@ -74,6 +112,32 @@ program
render(<StatusView config={requireConfig()} />);
});

program
.command('usage')
.description('Show token usage by profile from Claude transcript metadata')
.option('-p, --profile <profile>', 'Only show one profile (supports prefix matching)')
.option('--since <duration>', 'Only include usage since duration: 24h, 7d, 4w', '7d')
.option('--all', 'Include all known transcript usage')
.action(async (options: { profile?: string; since: string; all?: boolean }) => {
try {
const config = requireConfig();
const profile = options.profile ? resolveProfile(config, options.profile) : undefined;
const sinceMs = options.all ? undefined : parseSinceDuration(options.since);
const summaries = summarizeUsage(config, { profile, sinceMs });
printUsageTable(summaries);
if (!options.all) {
console.log(`\nWindow: ${options.since}`);
}
console.log('Source: shared projects/*.jsonl transcript usage metadata; duplicate requestIds counted once.');
if (summaries.some((s) => s.profile === 'unknown')) {
console.log('Note: unknown means aimux could not map a transcript session to a profile.');
}
} catch (err) {
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
}
});

program
.command('init')
.description('Initialize aimux — detect and migrate existing Claude directories')
Expand Down Expand Up @@ -680,7 +744,7 @@ program
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
commands="init run status profile rebuild doctor auth completions"
commands="init run status usage profile rebuild doctor auth completions"

case "\${prev}" in
run|auth)
Expand All @@ -700,7 +764,7 @@ complete -F _aimux aimux
console.log(`#compdef aimux
_aimux() {
local -a commands profiles
commands=(init run status profile rebuild doctor auth completions)
commands=(init run status usage profile rebuild doctor auth completions)
profiles=(${profiles})

_arguments '1:command:($commands)' '*::arg:->args'
Expand All @@ -717,7 +781,7 @@ _aimux() {
_aimux
# Add to ~/.zshrc: eval "$(aimux completions zsh)"`);
} else if (shell === 'fish') {
console.log(`complete -c aimux -n '__fish_use_subcommand' -a 'init run status profile rebuild doctor auth completions'
console.log(`complete -c aimux -n '__fish_use_subcommand' -a 'init run status usage profile rebuild doctor auth completions'
complete -c aimux -n '__fish_seen_subcommand_from run' -a '${profiles}'
complete -c aimux -n '__fish_seen_subcommand_from profile' -a 'add list update remove clone'
complete -c aimux -n '__fish_seen_subcommand_from auth' -a 'login status'
Expand Down
3 changes: 3 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ export type { DetectedDir, InitResult } from './init.js';

export { buildRunParams, launchProfile, looksLikeSubcommand } from './run.js';
export type { RunOptions, RunParams } from './run.js';

export { summarizeUsage, parseSinceDuration, totalTokens } from './usage.js';
export type { ProfileUsageSummary, UsageOptions, UsageTotals } from './usage.js';
2 changes: 1 addition & 1 deletion src/core/sessionScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export function parseSessionJsonl(
return { cwd, intent, createdAtMs, events, isSubagent };
}

function quickFirstLineType(filePath: string): string | null {
export function quickFirstLineType(filePath: string): string | null {
let fd: number | undefined;
try {
fd = openSync(filePath, 'r');
Expand Down
231 changes: 231 additions & 0 deletions src/core/usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { AimuxConfig } from '../types/index.js';
import { getAimuxDir, setAimuxDir } from './paths.js';
import { summarizeUsage, parseSinceDuration, totalTokens } from './usage.js';

const TEST_DIR = join(tmpdir(), `aimux-usage-test-${Date.now()}`);
const NOW_TS = '2026-05-14T00:00:00.000Z';
const LATER_TS = '2026-05-14T00:00:01.000Z';
const OLD_TS = '2026-05-10T00:00:00.000Z';
const CUTOFF_TS = '2026-05-13T00:00:00.000Z';

let originalAimuxDir: string;

function makeConfig(): AimuxConfig {
return {
version: 1,
shared_source: join(TEST_DIR, 'shared'),
profiles: {
main: { cli: 'claude', path: join(TEST_DIR, 'shared'), is_source: true },
work: { cli: 'claude', path: join(TEST_DIR, 'profiles', 'work') },
},
private: ['.credentials.json'],
};
}

function writeProfileSession(profile: string, sessionId: string, modified: number) {
writeProfileSessions(profile, [{ sessionId, modified }]);
}

function writeProfileSessions(
profile: string,
sessions: Array<{ sessionId: string; modified: number }>,
) {
const profilePath = profile === 'main' ? join(TEST_DIR, 'shared') : join(TEST_DIR, 'profiles', profile);
mkdirSync(profilePath, { recursive: true });
const projects: Record<string, { lastSessionId: string; lastSessionModified: number }> = {};
for (const { sessionId, modified } of sessions) {
projects[`/tmp/project-${sessionId}`] = {
lastSessionId: sessionId,
lastSessionModified: modified,
};
}
writeFileSync(
join(profilePath, '.claude.json'),
JSON.stringify({ projects }),
);
}

function writeTranscript(cwdHash: string, sessionId: string, lines: unknown[]) {
const dir = join(TEST_DIR, 'shared', 'projects', cwdHash);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${sessionId}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n'));
}

function assistantLine(
sessionId: string,
requestId: string,
usage: Record<string, unknown>,
timestamp = NOW_TS,
) {
return {
type: 'assistant',
requestId,
timestamp,
sessionId,
message: {
id: `msg-${requestId}`,
model: 'claude-opus-4-7',
usage,
},
};
}

function queueOperationLine() {
return {
type: 'queue-operation',
operation: 'task',
timestamp: NOW_TS,
};
}

beforeEach(() => {
originalAimuxDir = getAimuxDir();
mkdirSync(TEST_DIR, { recursive: true });
setAimuxDir(join(TEST_DIR, '.aimux'));
});

afterEach(() => {
setAimuxDir(originalAimuxDir);
rmSync(TEST_DIR, { recursive: true, force: true });
});

describe('summarizeUsage', () => {
it('attributes transcript usage to profiles via .claude.json session ownership', () => {
writeProfileSession('work', 'session-a', 1000);
writeTranscript('-tmp-project', 'session-a', [
assistantLine('session-a', 'req-1', {
input_tokens: 10,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
output_tokens: 40,
}),
]);

const summaries = summarizeUsage(makeConfig());
const work = summaries.find((s) => s.profile === 'work')!;
expect(work.sessions).toBe(1);
expect(work.requests).toBe(1);
expect(totalTokens(work)).toBe(100);
expect(work.models.get('claude-opus-4-7')).toBe(1);
});

it('deduplicates repeated transcript lines for the same requestId', () => {
writeProfileSession('work', 'session-a', 1000);
const repeated = assistantLine('session-a', 'req-1', {
input_tokens: 10,
output_tokens: 5,
});
writeTranscript('-tmp-project', 'session-a', [repeated, repeated]);

const work = summarizeUsage(makeConfig()).find((s) => s.profile === 'work')!;
expect(work.requests).toBe(1);
expect(work.inputTokens).toBe(10);
expect(work.outputTokens).toBe(5);
});

it('deduplicates forked sessions that share a requestId', () => {
writeProfileSessions('work', [
{ sessionId: 'session-original', modified: 1000 },
{ sessionId: 'session-fork', modified: 2000 },
]);
writeTranscript('-tmp-project', 'session-original', [
assistantLine('session-original', 'req-shared', {
input_tokens: 10,
output_tokens: 5,
}),
]);
writeTranscript('-tmp-project', 'session-fork', [
assistantLine('session-fork', 'req-shared', {
input_tokens: 10,
output_tokens: 5,
}, LATER_TS),
]);

const work = summarizeUsage(makeConfig()).find((s) => s.profile === 'work')!;
expect(work.requests).toBe(1);
expect(work.inputTokens).toBe(10);
expect(work.outputTokens).toBe(5);
});

it('skips subagent transcripts', () => {
writeProfileSession('work', 'session-subagent', 1000);
writeTranscript('-tmp-project', 'session-subagent', [
queueOperationLine(),
assistantLine('session-subagent', 'req-subagent', {
input_tokens: 1000,
output_tokens: 500,
}),
]);

const work = summarizeUsage(makeConfig()).find((s) => s.profile === 'work')!;
expect(work.requests).toBe(0);
expect(totalTokens(work)).toBe(0);
});

it('counts stable line fallback keys when request identifiers are missing', () => {
writeProfileSession('work', 'session-a', 1000);
const line = assistantLine('session-a', '', {
input_tokens: 10,
estimated_cost_usd: 0.01,
});
delete line.requestId;
delete line.message.id;
writeTranscript('-tmp-project', 'session-a', [line, line]);

const work = summarizeUsage(makeConfig()).find((s) => s.profile === 'work')!;
expect(work.requests).toBe(2);
expect(work.inputTokens).toBe(20);
expect(work.estimatedCostUsd).toBe(0.02);
});

it('ignores malformed non-numeric usage values', () => {
writeProfileSession('work', 'session-a', 1000);
writeTranscript('-tmp-project', 'session-a', [
assistantLine('session-a', 'req-bad', {
input_tokens: '10',
output_tokens: Number.NaN,
}),
]);

const work = summarizeUsage(makeConfig()).find((s) => s.profile === 'work')!;
expect(work.requests).toBe(1);
expect(totalTokens(work)).toBe(0);
});

it('filters by profile and since timestamp', () => {
writeProfileSession('main', 'session-main', 1000);
writeProfileSession('work', 'session-work', 1000);
writeTranscript('-tmp-project', 'session-main', [
assistantLine('session-main', 'req-main', { input_tokens: 100 }, OLD_TS),
]);
writeTranscript('-tmp-project', 'session-work', [
assistantLine('session-work', 'req-old', { input_tokens: 100 }, OLD_TS),
assistantLine('session-work', 'req-new', { input_tokens: 200 }),
]);

const summaries = summarizeUsage(makeConfig(), {
profile: 'work',
sinceMs: Date.parse(CUTOFF_TS),
});
expect(summaries.map((s) => s.profile)).toEqual(['work']);
expect(summaries[0].requests).toBe(1);
expect(summaries[0].inputTokens).toBe(200);
});
});

describe('parseSinceDuration', () => {
it('parses hours, days, and weeks', () => {
const now = Date.parse(NOW_TS);
expect(parseSinceDuration('24h', now)).toBe(now - 24 * 60 * 60 * 1000);
expect(parseSinceDuration('7d', now)).toBe(now - 7 * 24 * 60 * 60 * 1000);
expect(parseSinceDuration('2w', now)).toBe(now - 14 * 24 * 60 * 60 * 1000);
});

it('rejects invalid durations', () => {
expect(() => parseSinceDuration('yesterday')).toThrow('Invalid duration');
});
});
Loading