-
-
Notifications
You must be signed in to change notification settings - Fork 311
feat(ccusage): add Claude service status command #665
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
him0
wants to merge
13
commits into
ryoppippi:main
Choose a base branch
from
him0:feat/ccusage-claude-status-command
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
7331c95
feat(ccusage): add Claude service status command
him0 363813a
:+1: improve status command output with dynamic URL
him0 d1802b5
refactor(ccusage): improve error handling with @praha/byethrow Result…
him0 5101b35
feat(ccusage): extract color logic and add --color/--no-color support
him0 1372916
test(ccusage): replace mocked tests with practical API integration tests
him0 806a915
fix(ccusage): use process.exit(1) instead of throw in status command
him0 7e11d10
refactor(ccusage): remove unnecessary vitest imports in status API
him0 c3d614a
test(ccusage): remove conditional branches from status API tests
him0 8dfb137
refactor(ccusage): remove manual color handling, rely on picocolors a…
him0 9874e84
test(ccusage): remove conditional assertions in Claude status API tests
him0 6f50af5
refactor(ccusage): optimize getStatusColor tests with branch coverage
him0 1b770e5
chore(ccusage): update config schema
him0 5e612e0
docs(ccusage): add Claude status command documentation
him0 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(''); | ||
| }); | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`); | ||
| } | ||
| }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.