Skip to content

Commit 5280515

Browse files
authored
fix(ccusage): support agent-scoped config schema (#1008)
* fix(ccusage): support agent-scoped config schema Generate the ccusage JSON schema with top-level agent namespaces so configurations can use claude, codex, opencode, amp, and pi sections with defaults and command overrides. Merge those agent-scoped settings at runtime for namespaced ccusage subcommands, while preserving legacy Claude top-level defaults and command overrides. Add CLI coverage for Codex config discovery through the main ccusage command. * fix(ccusage): address agent config review Reject array values in agent namespace config validation before they can be merged as option keys. Tighten schema generator token argument typing and cover both spaced and colon-delimited namespaced command parsing in tests. * fix(ccusage): harden config preset validation * fix(ccusage): ignore unsafe config option keys * fix(ccusage): pass agent config into all reports * fix(ccusage): move schema generation to pre-commit * docs(ccusage): document agent-scoped config
1 parent 83722ca commit 5280515

10 files changed

Lines changed: 2091 additions & 1028 deletions

File tree

apps/ccusage/config-schema.json

Lines changed: 1349 additions & 904 deletions
Large diffs are not rendered by default.

apps/ccusage/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@
4747
"generate:schema": "bun scripts/generate-json-schema.ts",
4848
"lint": "eslint --cache .",
4949
"prepack": "pnpm run build && clean-pkg-json",
50-
"prepare": "pnpm run generate:schema || true",
5150
"prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build",
5251
"start": "bun ./src/index.ts",
5352
"test": "TZ=UTC vitest",

apps/ccusage/scripts/generate-json-schema.ts

Lines changed: 200 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,11 @@
1313
import process from 'node:process';
1414
import { Result } from '@praha/byethrow';
1515
import { $ } from 'bun';
16+
import { allArgs } from '../src/commands/all.ts';
1617
// Import command definitions to access their args
1718
import { subCommandUnion } from '../src/commands/index.ts';
1819
import { logger } from '../src/logger.ts';
1920

20-
import { sharedArgs } from '../src/shared-args.ts';
21-
2221
/**
2322
* The filename for the generated JSON Schema file.
2423
* Used for both root directory and docs/public directory output.
@@ -39,19 +38,33 @@ const COMMAND_EXCLUDE_KEYS: Record<string, string[]> = {
3938
blocks: ['live', 'refreshInterval'],
4039
};
4140

41+
const AGENT_NAMES = ['claude', 'codex', 'opencode', 'amp', 'pi'] as const;
42+
type AgentName = (typeof AGENT_NAMES)[number];
43+
type JsonSchemaNode = {
44+
[key: string]: unknown;
45+
type?: string;
46+
properties?: Record<string, JsonSchemaNode>;
47+
definitions?: Record<string, JsonSchemaNode>;
48+
};
49+
type TokenDefinition = {
50+
[key: string]: unknown;
51+
type: string;
52+
choices?: readonly unknown[];
53+
description?: string;
54+
default?: unknown;
55+
};
56+
type TokenSchema = Record<string, TokenDefinition>;
57+
4258
/**
4359
* Convert args-tokens schema to JSON Schema format
4460
*/
45-
function tokensSchemaToJsonSchema(schema: Record<string, any>): Record<string, any> {
46-
const properties: Record<string, any> = {};
61+
function tokensSchemaToJsonSchema(schema: TokenSchema): JsonSchemaNode {
62+
const properties: Record<string, JsonSchemaNode> = {};
4763

48-
for (const [key, arg] of Object.entries(schema)) {
49-
// eslint-disable-next-line ts/no-unsafe-assignment
50-
const argTyped = arg;
51-
const property: Record<string, any> = {};
64+
for (const [key, argTyped] of Object.entries(schema)) {
65+
const property: JsonSchemaNode = {};
5266

5367
// Handle type conversion
54-
// eslint-disable-next-line ts/no-unsafe-member-access
5568
switch (argTyped.type) {
5669
case 'boolean':
5770
property.type = 'boolean';
@@ -65,9 +78,7 @@ function tokensSchemaToJsonSchema(schema: Record<string, any>): Record<string, a
6578
break;
6679
case 'enum':
6780
property.type = 'string';
68-
// eslint-disable-next-line ts/no-unsafe-member-access
6981
if (argTyped.choices != null && Array.isArray(argTyped.choices)) {
70-
// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access
7182
property.enum = argTyped.choices;
7283
}
7384
break;
@@ -76,18 +87,13 @@ function tokensSchemaToJsonSchema(schema: Record<string, any>): Record<string, a
7687
}
7788

7889
// Add description
79-
// eslint-disable-next-line ts/no-unsafe-member-access
8090
if (argTyped.description != null) {
81-
// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access
8291
property.description = argTyped.description;
83-
// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access
8492
property.markdownDescription = argTyped.description;
8593
}
8694

8795
// Add default value
88-
// eslint-disable-next-line ts/no-unsafe-member-access
8996
if ('default' in argTyped && argTyped.default !== undefined) {
90-
// eslint-disable-next-line ts/no-unsafe-assignment, ts/no-unsafe-member-access
9197
property.default = argTyped.default;
9298
}
9399

@@ -101,42 +107,116 @@ function tokensSchemaToJsonSchema(schema: Record<string, any>): Record<string, a
101107
};
102108
}
103109

104-
/**
105-
* Create the complete configuration schema from all command definitions
106-
*/
107-
function createConfigSchemaJson() {
108-
// Create schema for default/shared arguments (excluding CLI-only options)
109-
const defaultsSchema = Object.fromEntries(
110-
Object.entries(sharedArgs).filter(([key]) => !EXCLUDE_KEYS.includes(key)),
110+
function splitCommandName(name: string): { agent?: AgentName; report: string } {
111+
const [prefix, report] = name.split(':');
112+
if (report != null && AGENT_NAMES.includes(prefix as AgentName)) {
113+
return { agent: prefix as AgentName, report };
114+
}
115+
return { report: name };
116+
}
117+
118+
function filterCommandSchema(report: string, schema: TokenSchema): TokenSchema {
119+
const commandExcludes = COMMAND_EXCLUDE_KEYS[report] ?? [];
120+
return Object.fromEntries(
121+
Object.entries(schema).filter(
122+
([key]) => !EXCLUDE_KEYS.includes(key) && !commandExcludes.includes(key),
123+
),
111124
);
125+
}
112126

113-
// Create schemas for each command's specific arguments (excluding CLI-only options)
114-
const commandSchemas: Record<string, any> = {};
115-
for (const [commandName, command] of subCommandUnion) {
116-
const commandExcludes = COMMAND_EXCLUDE_KEYS[commandName] ?? [];
117-
commandSchemas[commandName] = Object.fromEntries(
118-
Object.entries(command.args as Record<string, any>).filter(
119-
([key]) => !EXCLUDE_KEYS.includes(key) && !commandExcludes.includes(key),
120-
),
121-
);
127+
function commonSchemaProperties(commandSchemas: Record<string, TokenSchema>): TokenSchema {
128+
const schemas = Object.values(commandSchemas);
129+
const firstSchema = schemas[0];
130+
if (firstSchema == null) {
131+
return {};
122132
}
133+
const restSchemas = schemas.slice(1);
134+
return Object.fromEntries(
135+
Object.entries(firstSchema).filter(([key]) =>
136+
restSchemas.every((schema) => Object.hasOwn(schema, key)),
137+
),
138+
);
139+
}
123140

124-
// Convert to JSON Schema format
125-
126-
const defaultsJsonSchema = tokensSchemaToJsonSchema(defaultsSchema);
127-
const commandsJsonSchema = {
141+
function createCommandsJsonSchema(
142+
commandSchemas: Record<string, TokenSchema>,
143+
description: string,
144+
): JsonSchemaNode {
145+
return {
128146
type: 'object',
129147
properties: Object.fromEntries(
130148
Object.entries(commandSchemas).map(([name, schema]) => [
131149
name,
132-
// eslint-disable-next-line ts/no-unsafe-argument
133150
tokensSchemaToJsonSchema(schema),
134151
]),
135152
),
136153
additionalProperties: false,
137-
description: 'Command-specific configuration overrides',
138-
markdownDescription: 'Command-specific configuration overrides',
154+
description,
155+
markdownDescription: description,
156+
};
157+
}
158+
159+
function createAgentJsonSchema(
160+
agentName: AgentName,
161+
commandSchemas: Record<string, TokenSchema>,
162+
): JsonSchemaNode {
163+
const agentLabel = agentName === 'pi' ? 'pi-agent' : agentName;
164+
return {
165+
type: 'object',
166+
properties: {
167+
defaults: {
168+
...tokensSchemaToJsonSchema(commonSchemaProperties(commandSchemas)),
169+
description: `Default values for ${agentLabel} commands`,
170+
markdownDescription: `Default values for ${agentLabel} commands`,
171+
},
172+
commands: createCommandsJsonSchema(
173+
commandSchemas,
174+
`Command-specific configuration overrides for ${agentLabel}`,
175+
),
176+
},
177+
additionalProperties: false,
178+
description: `${agentLabel} command configuration`,
179+
markdownDescription: `${agentLabel} command configuration`,
139180
};
181+
}
182+
183+
/**
184+
* Create the complete configuration schema from all command definitions
185+
*/
186+
function createConfigSchemaJson(): JsonSchemaNode {
187+
const topLevelCommandSchemas: Record<string, TokenSchema> = {};
188+
const agentCommandSchemas = Object.fromEntries(AGENT_NAMES.map((agent) => [agent, {}])) as Record<
189+
AgentName,
190+
Record<string, TokenSchema>
191+
>;
192+
193+
for (const [commandName, command] of subCommandUnion) {
194+
const { agent, report } = splitCommandName(commandName);
195+
const commandSchema = filterCommandSchema(report, command.args as TokenSchema);
196+
if (agent == null) {
197+
topLevelCommandSchemas[report] = commandSchema;
198+
} else {
199+
agentCommandSchemas[agent][report] = commandSchema;
200+
}
201+
}
202+
203+
const legacyTopLevelCommandSchemas = Object.fromEntries(
204+
Object.entries(topLevelCommandSchemas).map(([report, schema]) => [
205+
report,
206+
{
207+
...(agentCommandSchemas.claude[report] ?? {}),
208+
...schema,
209+
},
210+
]),
211+
);
212+
const defaultsJsonSchema = tokensSchemaToJsonSchema({
213+
...commonSchemaProperties(agentCommandSchemas.claude),
214+
...filterCommandSchema('daily', allArgs as TokenSchema),
215+
});
216+
const commandsJsonSchema = createCommandsJsonSchema(
217+
legacyTopLevelCommandSchemas,
218+
'Command-specific configuration overrides for all-agent reports',
219+
);
140220

141221
// Main configuration schema
142222
return {
@@ -152,10 +232,15 @@ function createConfigSchemaJson() {
152232
},
153233
defaults: {
154234
...defaultsJsonSchema,
155-
description: 'Default values for all commands',
156-
markdownDescription: 'Default values for all commands',
235+
description: 'Default values for all-agent reports and legacy Claude commands',
236+
markdownDescription: 'Default values for all-agent reports and legacy Claude commands',
157237
},
158238
commands: commandsJsonSchema,
239+
claude: createAgentJsonSchema('claude', agentCommandSchemas.claude),
240+
codex: createAgentJsonSchema('codex', agentCommandSchemas.codex),
241+
opencode: createAgentJsonSchema('opencode', agentCommandSchemas.opencode),
242+
amp: createAgentJsonSchema('amp', agentCommandSchemas.amp),
243+
pi: createAgentJsonSchema('pi', agentCommandSchemas.pi),
159244
},
160245
additionalProperties: false,
161246
},
@@ -168,15 +253,24 @@ function createConfigSchemaJson() {
168253
$schema: 'https://ccusage.com/config-schema.json',
169254
defaults: {
170255
json: false,
171-
mode: 'auto',
172256
timezone: 'Asia/Tokyo',
173257
},
174-
commands: {
175-
daily: {
176-
instances: true,
258+
claude: {
259+
defaults: {
260+
mode: 'auto',
177261
},
178-
blocks: {
179-
tokenLimit: '500000',
262+
commands: {
263+
daily: {
264+
instances: true,
265+
},
266+
blocks: {
267+
tokenLimit: '500000',
268+
},
269+
},
270+
},
271+
codex: {
272+
defaults: {
273+
speed: 'auto',
180274
},
181275
},
182276
},
@@ -290,6 +384,14 @@ if (import.meta.main) {
290384
await generateJsonSchema();
291385
}
292386
if (import.meta.vitest != null) {
387+
function schemaProperties(schema: JsonSchemaNode): Record<string, JsonSchemaNode> {
388+
return schema.properties ?? {};
389+
}
390+
391+
function configSchemaDefinition(schema: JsonSchemaNode): JsonSchemaNode {
392+
return schema.definitions?.['ccusage-config'] ?? {};
393+
}
394+
293395
describe('tokensSchemaToJsonSchema', () => {
294396
it('should convert boolean args to JSON Schema', () => {
295397
const schema = {
@@ -298,10 +400,10 @@ if (import.meta.vitest != null) {
298400
description: 'Enable debug mode',
299401
default: false,
300402
},
301-
};
403+
} satisfies TokenSchema;
302404

303405
const jsonSchema = tokensSchemaToJsonSchema(schema);
304-
expect((jsonSchema.properties as Record<string, any>).debug).toEqual({
406+
expect(schemaProperties(jsonSchema).debug).toEqual({
305407
type: 'boolean',
306408
description: 'Enable debug mode',
307409
markdownDescription: 'Enable debug mode',
@@ -317,10 +419,10 @@ if (import.meta.vitest != null) {
317419
choices: ['auto', 'manual'],
318420
default: 'auto',
319421
},
320-
};
422+
} satisfies TokenSchema;
321423

322424
const jsonSchema = tokensSchemaToJsonSchema(schema);
323-
expect((jsonSchema.properties as Record<string, any>).mode).toEqual({
425+
expect(schemaProperties(jsonSchema).mode).toEqual({
324426
type: 'string',
325427
enum: ['auto', 'manual'],
326428
description: 'Mode selection',
@@ -337,29 +439,62 @@ if (import.meta.vitest != null) {
337439
expect(jsonSchema).toBeDefined();
338440
expect(jsonSchema.$ref).toBe('#/definitions/ccusage-config');
339441
expect(jsonSchema.definitions).toBeDefined();
340-
expect(jsonSchema.definitions['ccusage-config']).toBeDefined();
341-
expect(jsonSchema.definitions['ccusage-config'].type).toBe('object');
442+
expect(configSchemaDefinition(jsonSchema)).toBeDefined();
443+
expect(configSchemaDefinition(jsonSchema).type).toBe('object');
342444
});
343445

344446
it('should include all expected properties', () => {
345447
const jsonSchema = createConfigSchemaJson();
346-
const mainSchema = jsonSchema.definitions['ccusage-config'];
448+
const mainSchema = configSchemaDefinition(jsonSchema);
449+
const properties = schemaProperties(mainSchema);
450+
451+
expect(properties).toHaveProperty('$schema');
452+
expect(properties).toHaveProperty('defaults');
453+
expect(properties).toHaveProperty('commands');
454+
expect(properties).toHaveProperty('claude');
455+
expect(properties).toHaveProperty('codex');
456+
});
347457

348-
expect(mainSchema.properties).toHaveProperty('$schema');
349-
expect(mainSchema.properties).toHaveProperty('defaults');
350-
expect(mainSchema.properties).toHaveProperty('commands');
458+
it('should keep legacy top-level Claude config properties', () => {
459+
const jsonSchema = createConfigSchemaJson();
460+
const mainSchema = configSchemaDefinition(jsonSchema);
461+
const properties = schemaProperties(mainSchema);
462+
const defaultsSchema = properties.defaults ?? {};
463+
const commandsSchema = properties.commands ?? {};
464+
const dailySchema = schemaProperties(commandsSchema).daily ?? {};
465+
466+
expect(schemaProperties(defaultsSchema)).toHaveProperty('mode');
467+
expect(schemaProperties(dailySchema)).toHaveProperty('instances');
351468
});
352469

353470
it('should include all command schemas', () => {
354471
const jsonSchema = createConfigSchemaJson();
355-
const commandsSchema = jsonSchema.definitions['ccusage-config'].properties.commands;
356-
357-
expect(commandsSchema.properties).toHaveProperty('daily');
358-
expect(commandsSchema.properties).toHaveProperty('monthly');
359-
expect(commandsSchema.properties).toHaveProperty('weekly');
360-
expect(commandsSchema.properties).toHaveProperty('session');
361-
expect(commandsSchema.properties).toHaveProperty('blocks');
362-
expect(commandsSchema.properties).toHaveProperty('statusline');
472+
const mainSchema = configSchemaDefinition(jsonSchema);
473+
const commandsSchema = schemaProperties(mainSchema).commands ?? {};
474+
const commandProperties = schemaProperties(commandsSchema);
475+
476+
expect(commandProperties).toHaveProperty('daily');
477+
expect(commandProperties).toHaveProperty('monthly');
478+
expect(commandProperties).toHaveProperty('weekly');
479+
expect(commandProperties).toHaveProperty('session');
480+
expect(commandProperties).not.toHaveProperty('codex:daily');
481+
});
482+
483+
it('should include agent command schemas under agent namespaces', () => {
484+
const jsonSchema = createConfigSchemaJson();
485+
const mainSchema = configSchemaDefinition(jsonSchema);
486+
const properties = schemaProperties(mainSchema);
487+
const claudeCommands = schemaProperties(properties.claude ?? {}).commands ?? {};
488+
const codexCommands = schemaProperties(properties.codex ?? {}).commands ?? {};
489+
const claudeCommandProperties = schemaProperties(claudeCommands);
490+
const codexCommandProperties = schemaProperties(codexCommands);
491+
492+
expect(claudeCommandProperties).toHaveProperty('daily');
493+
expect(claudeCommandProperties).toHaveProperty('blocks');
494+
expect(claudeCommandProperties).toHaveProperty('statusline');
495+
expect(codexCommandProperties).toHaveProperty('daily');
496+
expect(codexCommandProperties).toHaveProperty('monthly');
497+
expect(codexCommandProperties).toHaveProperty('session');
363498
});
364499
});
365500
}

0 commit comments

Comments
 (0)