Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/ccusage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ npx ccusage daily # Daily token usage and costs
npx ccusage monthly # Monthly aggregated report
npx ccusage session # Usage by conversation session
npx ccusage blocks # 5-hour billing windows
npx ccusage status # Check Claude service status
npx ccusage statusline # Compact status line for hooks (Beta)

# Live monitoring
Expand Down Expand Up @@ -125,6 +126,7 @@ npx ccusage monthly --compact # Compact monthly report
- ⏰ **5-Hour Blocks Report**: Track usage within Claude's billing windows with active block monitoring
- 📈 **Live Monitoring**: Real-time dashboard showing active session progress, token burn rate, and cost projections with `blocks --live`
- 🚀 **Statusline Integration**: Compact usage display for Claude Code status bar hooks (Beta)
- 🌐 **Claude Status**: Check Claude service operational status with `ccusage status`
- 🤖 **Model Tracking**: See which Claude models you're using (Opus, Sonnet, etc.)
- 📊 **Model Breakdown**: View per-model cost breakdown with `--breakdown` flag
- 📅 **Date Filtering**: Filter reports by date range using `--since` and `--until`
Expand Down
12 changes: 12 additions & 0 deletions apps/ccusage/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,18 @@
},
"additionalProperties": false
},
"status": {
"type": "object",
"properties": {
"json": {
"type": "boolean",
"description": "Output in JSON format",
"markdownDescription": "Output in JSON format",
"default": false
}
},
"additionalProperties": false
},
"statusline": {
"type": "object",
"properties": {
Expand Down
193 changes: 193 additions & 0 deletions apps/ccusage/src/_claude-status-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import type { Formatter } from 'picocolors/types';
import { Result } from '@praha/byethrow';
import pc from 'picocolors';
import * as v from 'valibot';

/**
* Claude Status API response schema based on actual API response
*/
const claudeStatusSchema = v.object({
status: v.object({
description: v.string(),
indicator: v.string(),
}),
page: v.object({
id: v.string(),
name: v.string(),
url: v.string(),
time_zone: v.string(),
updated_at: v.string(),
}),
});

export type ClaudeStatus = v.InferInput<typeof claudeStatusSchema>;

/**
* Status indicator types based on common status page indicators
*/
export type StatusIndicator = 'none' | 'minor' | 'major' | 'critical';

/**
* Get the appropriate color formatter for Claude status
* @param indicator - Status indicator from API
* @param description - Status description for fallback detection
* @returns Color formatter function
*/
export function getStatusColor(
indicator: string,
description: string,
): Formatter {
let colorFormatter: Formatter;

// Determine color based on status indicator and description
if (indicator === 'none' || description.toLowerCase().includes('operational')) {
colorFormatter = pc.green;
}
else if (indicator === 'minor' || description.toLowerCase().includes('degraded')) {
colorFormatter = pc.yellow;
}
else if (indicator === 'major' || indicator === 'critical' || description.toLowerCase().includes('outage')) {
colorFormatter = pc.red;
}
else {
// Default: no special coloring for unknown status
colorFormatter = pc.white;
}

// Wrap formatter to handle null/undefined gracefully
return (input: unknown): string => {
if (input == null) {
return '';
}
return colorFormatter(String(input));
};
}

/**
* Fetch Claude status from status.claude.com API
* @returns Result containing Claude status data or error
*/
export async function fetchClaudeStatus(): Result.ResultAsync<ClaudeStatus, Error> {
const result = Result.try({
try: async () => {
const response = await fetch('https://status.claude.com/api/v2/status.json');

if (!response.ok) {
throw new Error(`Failed to fetch Claude status: ${response.status} ${response.statusText}`);
}

const data: unknown = await response.json();

// Validate response data using safeParse
const parseResult = v.safeParse(claudeStatusSchema, data);
if (!parseResult.success) {
throw new Error(`Invalid API response format: ${parseResult.issues.map(issue => issue.message).join(', ')}`);
}

return parseResult.output;
},
catch: (error: unknown) => error instanceof Error ? error : new Error(String(error)),
});

return result();
}

if (import.meta.vitest != null) {
describe('fetchClaudeStatus', () => {
it('should return a Result type', async () => {
const result = await fetchClaudeStatus();

// Always verify that we get a Result type back
expect(Result.isSuccess(result) || Result.isFailure(result)).toBe(true);
});

it('should fetch Claude status successfully', async () => {
// If this test fails, it indicates API trouble
const result = await fetchClaudeStatus();

// Early error if API fails - this makes the test deterministic
if (Result.isFailure(result)) {
throw new Error(`API failed: ${result.error.message}`);
}

expect(Result.isSuccess(result)).toBe(true);
expect(result.value).toHaveProperty('status');
expect(result.value.status).toHaveProperty('description');
expect(result.value.status).toHaveProperty('indicator');
expect(typeof result.value.status.description).toBe('string');
expect(typeof result.value.status.indicator).toBe('string');

expect(result.value).toHaveProperty('page');
expect(result.value.page).toHaveProperty('id');
expect(result.value.page).toHaveProperty('name');
expect(result.value.page).toHaveProperty('url');
expect(result.value.page).toHaveProperty('time_zone');
expect(result.value.page).toHaveProperty('updated_at');
});

it('should validate ClaudeStatus type structure', async () => {
// If this test fails, it indicates API trouble
const result = await fetchClaudeStatus();

// Early error if API fails - this makes the test deterministic
if (Result.isFailure(result)) {
throw new Error(`API failed: ${result.error.message}`);
}

expect(Result.isSuccess(result)).toBe(true);

const status = result.value;
expect(status.status.indicator).toMatch(/^.+$/); // Any non-empty string
expect(status.page.url).toMatch(/^https?:\/\/.+/);
});
});

describe('getStatusColor', () => {
it('should return green formatter for "none" indicator', () => {
const formatter = getStatusColor('none', 'All Systems Operational');
const result = formatter('test');
// Test green branch (indicator-based)
expect(result).toContain('test');
expect(typeof result).toBe('string');
});

it('should return yellow formatter for "minor" indicator', () => {
const formatter = getStatusColor('minor', 'Partially Degraded Service');
const result = formatter('test');
// Test yellow branch (indicator-based)
expect(result).toContain('test');
expect(typeof result).toBe('string');
});

it('should return red formatter for "major" indicator', () => {
const formatter = getStatusColor('major', 'Service Outage');
const result = formatter('test');
// Test red branch (indicator-based)
expect(result).toContain('test');
expect(typeof result).toBe('string');
});

it('should return white formatter for unknown status', () => {
const formatter = getStatusColor('unknown', 'Unknown status');
const result = formatter('test');
// Test white branch (else case)
expect(result).toContain('test');
expect(typeof result).toBe('string');
});

it('should fall back to description-based detection', () => {
const formatter = getStatusColor('unknown', 'All Systems Operational');
const result = formatter('test');
// Test description fallback branch
expect(result).toContain('test');
expect(typeof result).toBe('string');
});

it('should handle null/undefined input gracefully', () => {
const formatter = getStatusColor('none', 'All Systems Operational');
// Test null handling branch
expect(formatter(null)).toBe('');
expect(formatter(undefined)).toBe('');
});
});
}
4 changes: 3 additions & 1 deletion apps/ccusage/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { blocksCommand } from './blocks.ts';
import { dailyCommand } from './daily.ts';
import { monthlyCommand } from './monthly.ts';
import { sessionCommand } from './session.ts';
import { statusCommand } from './status.ts';
import { statuslineCommand } from './statusline.ts';
import { weeklyCommand } from './weekly.ts';

// Re-export all commands for easy importing
export { blocksCommand, dailyCommand, monthlyCommand, sessionCommand, statuslineCommand, weeklyCommand };
export { blocksCommand, dailyCommand, monthlyCommand, sessionCommand, statusCommand, statuslineCommand, weeklyCommand };

/**
* Command entries as tuple array
Expand All @@ -20,6 +21,7 @@ export const subCommandUnion = [
['weekly', weeklyCommand],
['session', sessionCommand],
['blocks', blocksCommand],
['status', statusCommand],
['statusline', statuslineCommand],
] as const;

Expand Down
57 changes: 57 additions & 0 deletions apps/ccusage/src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import process from 'node:process';
import { Result } from '@praha/byethrow';
import { define } from 'gunshi';
import { fetchClaudeStatus, getStatusColor } from '../_claude-status-api.ts';
import { sharedArgs } from '../_shared-args.ts';
import { log, logger } from '../logger.ts';

export const statusCommand = define({
name: 'status',
description: 'Show Claude service status',
args: {
json: sharedArgs.json,
},
toKebab: true,
async run(ctx) {
const useJson = Boolean(ctx.values.json);
if (useJson) {
logger.level = 0;
}

const statusResult = await fetchClaudeStatus();

if (Result.isFailure(statusResult)) {
const error = statusResult.error;
const errorMessage = `Failed to fetch Claude status: ${error.message}`;

if (useJson) {
log(JSON.stringify({
error: error.message,
success: false,
}));
}
else {
logger.error(errorMessage);
}

process.exit(1);
}

const status = statusResult.value;

if (useJson) {
log(JSON.stringify(status, null, 2));
}
else {
// Format the status description with appropriate styling
const description = status.status.description;
const indicator = status.status.indicator;

// Get color formatter based on status
const colorFormatter = getStatusColor(indicator, description);
const styledStatus = colorFormatter(description);

log(`Claude Status: ${styledStatus} - ${status.page.url}`);
}
},
});
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default defineConfig({
{ text: 'Library Usage', link: '/guide/library-usage' },
{ text: 'MCP Server', link: '/guide/mcp-server' },
{ text: 'JSON Output', link: '/guide/json-output' },
{ text: 'Claude Status', link: '/guide/status' },
{ text: 'Statusline Integration', link: '/guide/statusline' },
],
},
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ Unlike other CLI tools, we pay extreme attention to bundle size. ccusage achieve
- Token limit warnings and projections
- Automatic refresh with configurable intervals

### 🌐 Service Status

- **Claude Status** - Check current operational status of Claude services

### 🔧 Flexible Configuration

- **JSON Configuration Files** - Set defaults for all commands or customize per-command
Expand Down
Loading