diff --git a/package.json b/package.json index 9d98416..2d6f1c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.1.4", + "version": "2.1.5", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/types/StatusJSON.ts b/src/types/StatusJSON.ts index fd5012a..6589a5a 100644 --- a/src/types/StatusJSON.ts +++ b/src/types/StatusJSON.ts @@ -1,5 +1,19 @@ import { z } from 'zod'; +const CoercedNumberSchema = z.preprocess((value) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return value; + } + + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : value; +}, z.number()); + export const StatusJSONSchema = z.looseObject({ hook_event_name: z.string().optional(), session_id: z.string().optional(), @@ -19,27 +33,27 @@ export const StatusJSONSchema = z.looseObject({ version: z.string().optional(), output_style: z.object({ name: z.string().optional() }).optional(), cost: z.object({ - total_cost_usd: z.number().optional(), - total_duration_ms: z.number().optional(), - total_api_duration_ms: z.number().optional(), - total_lines_added: z.number().optional(), - total_lines_removed: z.number().optional() + total_cost_usd: CoercedNumberSchema.optional(), + total_duration_ms: CoercedNumberSchema.optional(), + total_api_duration_ms: CoercedNumberSchema.optional(), + total_lines_added: CoercedNumberSchema.optional(), + total_lines_removed: CoercedNumberSchema.optional() }).optional(), context_window: z.object({ - context_window_size: z.number().nullable().optional(), - total_input_tokens: z.number().nullable().optional(), - total_output_tokens: z.number().nullable().optional(), + context_window_size: CoercedNumberSchema.nullable().optional(), + total_input_tokens: CoercedNumberSchema.nullable().optional(), + total_output_tokens: CoercedNumberSchema.nullable().optional(), current_usage: z.union([ - z.number(), + CoercedNumberSchema, z.object({ - input_tokens: z.number().optional(), - output_tokens: z.number().optional(), - cache_creation_input_tokens: z.number().optional(), - cache_read_input_tokens: z.number().optional() + input_tokens: CoercedNumberSchema.optional(), + output_tokens: CoercedNumberSchema.optional(), + cache_creation_input_tokens: CoercedNumberSchema.optional(), + cache_read_input_tokens: CoercedNumberSchema.optional() }) ]).nullable().optional(), - used_percentage: z.number().nullable().optional(), - remaining_percentage: z.number().nullable().optional() + used_percentage: CoercedNumberSchema.nullable().optional(), + remaining_percentage: CoercedNumberSchema.nullable().optional() }).nullable().optional() }); diff --git a/src/types/__tests__/StatusJSON.test.ts b/src/types/__tests__/StatusJSON.test.ts new file mode 100644 index 0000000..801830c --- /dev/null +++ b/src/types/__tests__/StatusJSON.test.ts @@ -0,0 +1,61 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { StatusJSONSchema } from '../StatusJSON'; + +describe('StatusJSONSchema numeric coercion', () => { + it('coerces numeric strings to numbers', () => { + const result = StatusJSONSchema.safeParse({ + cost: { + total_cost_usd: '1.25', + total_duration_ms: '12345', + total_api_duration_ms: '2345', + total_lines_added: '12', + total_lines_removed: '3' + }, + context_window: { + context_window_size: '200000', + total_input_tokens: '1200', + total_output_tokens: '340', + current_usage: { + input_tokens: '100', + output_tokens: '50', + cache_creation_input_tokens: '20', + cache_read_input_tokens: '10' + }, + used_percentage: '9.3', + remaining_percentage: '90.7' + } + }); + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + expect(result.data.cost?.total_duration_ms).toBe(12345); + expect(result.data.context_window?.context_window_size).toBe(200000); + expect(result.data.context_window?.current_usage).toEqual({ + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 10 + }); + expect(result.data.context_window?.used_percentage).toBe(9.3); + }); + + it('keeps invalid numeric strings rejected', () => { + const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: 'not-a-number' } }); + + expect(result.success).toBe(false); + }); + + it('keeps empty numeric strings rejected', () => { + const result = StatusJSONSchema.safeParse({ context_window: { context_window_size: '' } }); + + expect(result.success).toBe(false); + }); +}); \ No newline at end of file