diff --git a/apps/ccusage/README.md b/apps/ccusage/README.md index 2928db2c..0fcf1452 100644 --- a/apps/ccusage/README.md +++ b/apps/ccusage/README.md @@ -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 @@ -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` diff --git a/apps/ccusage/config-schema.json b/apps/ccusage/config-schema.json index 34392dde..172ad280 100644 --- a/apps/ccusage/config-schema.json +++ b/apps/ccusage/config-schema.json @@ -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": { diff --git a/apps/ccusage/src/_claude-status-api.ts b/apps/ccusage/src/_claude-status-api.ts new file mode 100644 index 00000000..cb7163bb --- /dev/null +++ b/apps/ccusage/src/_claude-status-api.ts @@ -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; + +/** + * 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 { + 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(''); + }); + }); +} diff --git a/apps/ccusage/src/commands/index.ts b/apps/ccusage/src/commands/index.ts index 3e7ef483..fb111041 100644 --- a/apps/ccusage/src/commands/index.ts +++ b/apps/ccusage/src/commands/index.ts @@ -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 @@ -20,6 +21,7 @@ export const subCommandUnion = [ ['weekly', weeklyCommand], ['session', sessionCommand], ['blocks', blocksCommand], + ['status', statusCommand], ['statusline', statuslineCommand], ] as const; diff --git a/apps/ccusage/src/commands/status.ts b/apps/ccusage/src/commands/status.ts new file mode 100644 index 00000000..d62741b8 --- /dev/null +++ b/apps/ccusage/src/commands/status.ts @@ -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}`); + } + }, +}); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 4b8363a6..5e2458bb 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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' }, ], }, diff --git a/docs/guide/index.md b/docs/guide/index.md index 900fc950..d3e13d2c 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -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 diff --git a/docs/guide/status.md b/docs/guide/status.md new file mode 100644 index 00000000..f3aedeb4 --- /dev/null +++ b/docs/guide/status.md @@ -0,0 +1,90 @@ +# Claude Status + +Check the current operational status of Claude services directly from the command line. + +## Basic Usage + +```bash +ccusage status +``` + +## Example Output + +``` +Claude Status: All Systems Operational - https://status.claude.com +``` + +The status message is color-coded based on the current service state: + +| Color | Status | Description | +| ------ | ----------- | ---------------------------------------- | +| Green | Operational | All systems are working normally | +| Yellow | Degraded | Some services may be experiencing issues | +| Red | Outage | Partial or major service outage | + +## JSON Output + +Export status data as JSON for programmatic use: + +```bash +ccusage status --json +``` + +```json +{ + "status": { + "description": "All Systems Operational", + "indicator": "none" + }, + "page": { + "id": "...", + "name": "Claude", + "url": "https://status.claude.com", + "time_zone": "Etc/UTC", + "updated_at": "2025-01-15T12:00:00.000Z" + } +} +``` + +### Status Indicators + +The `indicator` field in JSON output can be: + +- `none` - All systems operational +- `minor` - Minor issues or degraded performance +- `major` - Major outage affecting services +- `critical` - Critical outage + +## Use Cases + +### Quick Status Check + +Before starting a coding session, verify Claude services are available: + +```bash +ccusage status +``` + +### Scripting and Automation + +Use JSON output in scripts to check Claude availability: + +```bash +# Check if Claude is operational +if ccusage status --json | grep -q '"indicator": "none"'; then + echo "Claude is ready!" +fi +``` + +### Troubleshooting + +If you're experiencing issues with Claude Code, check the service status first: + +```bash +ccusage status +# If not operational, visit the status page for more details +``` + +## Related Commands + +- [Statusline Integration](/guide/statusline) - Compact usage display for Claude Code status bar