From dceb6f6d37972322a881d32c4d8b0c420e2f52db Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 07:39:16 +0200 Subject: [PATCH 1/8] feat(cliproxy): add --json flag to catalog command Add machine-readable JSON output for `ccs cliproxy catalog --json` to enable programmatic access to available models per provider. Output format: { "provider": [{ "id": "...", "name": "..." }, ...] } Closes CCS-1 Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 15 +++++++++++++++ src/commands/cliproxy/index.ts | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 13374e65..a41cb56f 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -5,6 +5,7 @@ import { SYNCABLE_PROVIDERS, getResolvedCatalog, refreshCatalogFromProxy, + getAllResolvedCatalogs, } from '../../cliproxy/catalog-cache'; import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; @@ -173,6 +174,20 @@ export async function handleCatalogRefresh(verbose: boolean): Promise { console.log(''); } +/** + * Output catalog as JSON for programmatic consumption. + * Used by OnSteroids and other tools to get available models per provider. + * Format: { provider: [{ id, name }], ... } + */ +export function handleCatalogJson(): void { + const catalogs = getAllResolvedCatalogs(); + const result: Record> = {}; + for (const [provider, catalog] of Object.entries(catalogs)) { + result[provider] = catalog.models.map((m) => ({ id: m.id, name: m.name })); + } + console.log(JSON.stringify(result)); +} + /** Reset catalog cache */ export async function handleCatalogReset(): Promise { await initUI(); diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 50c9013a..bad626b4 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -40,6 +40,7 @@ import { handleCatalogStatus, handleCatalogRefresh, handleCatalogReset, + handleCatalogJson, } from './catalog-subcommand'; /** @@ -146,6 +147,10 @@ export async function handleCliproxyCommand(args: string[]): Promise { // Catalog commands if (command === 'catalog') { + if (hasAnyFlag(remainingArgs, ['--json'])) { + handleCatalogJson(); + return; + } const subcommand = remainingArgs[1]; if (subcommand === 'refresh') { await handleCatalogRefresh(verbose); From ac92688cc5f14054f4bd73c15723d521baa9afb1 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 18:02:32 +0200 Subject: [PATCH 2/8] docs(cliproxy): clarify catalog --json output format in JSDoc Use TypeScript-style index signature notation instead of ambiguous placeholder name in format description. Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index a41cb56f..183759d7 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -177,7 +177,7 @@ export async function handleCatalogRefresh(verbose: boolean): Promise { /** * Output catalog as JSON for programmatic consumption. * Used by OnSteroids and other tools to get available models per provider. - * Format: { provider: [{ id, name }], ... } + * Format: { [providerName: string]: Array<{ id: string, name: string }> } */ export function handleCatalogJson(): void { const catalogs = getAllResolvedCatalogs(); From 509d2e4dd83840775915c7297621da89a7b7fe9c Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 18:27:39 +0200 Subject: [PATCH 3/8] test(cliproxy): add catalog --json tests and clarify flag priority - Add unit tests verifying JSON output structure, field filtering, and minified format - Document that --json takes priority over subcommands (refresh/reset) Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/index.ts | 2 + .../commands/cliproxy-catalog-json.test.ts | 64 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/unit/commands/cliproxy-catalog-json.test.ts diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index bad626b4..52c4cb02 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -147,6 +147,8 @@ export async function handleCliproxyCommand(args: string[]): Promise { // Catalog commands if (command === 'catalog') { + // --json takes priority over subcommands (refresh/reset) — it always + // outputs the current resolved catalog regardless of other arguments. if (hasAnyFlag(remainingArgs, ['--json'])) { handleCatalogJson(); return; diff --git a/tests/unit/commands/cliproxy-catalog-json.test.ts b/tests/unit/commands/cliproxy-catalog-json.test.ts new file mode 100644 index 00000000..0e1928ff --- /dev/null +++ b/tests/unit/commands/cliproxy-catalog-json.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +let originalConsoleLog: typeof console.log; +let capturedOutput: string[]; + +beforeEach(() => { + originalConsoleLog = console.log; + capturedOutput = []; + console.log = (...args: unknown[]) => { + capturedOutput.push(args.map(String).join(' ')); + }; +}); + +afterEach(() => { + console.log = originalConsoleLog; +}); + +describe('cliproxy catalog --json output', () => { + it('outputs valid JSON mapping provider names to model arrays', async () => { + const { handleCatalogJson } = await import( + `../../../src/commands/cliproxy/catalog-subcommand?catalog-json-format=${Date.now()}` + ); + + handleCatalogJson(); + + expect(capturedOutput).toHaveLength(1); + const parsed = JSON.parse(capturedOutput[0]) as Record< + string, + Array<{ id: string; name: string }> + >; + + // Must be a non-empty object (static catalog always has providers) + expect(typeof parsed).toBe('object'); + expect(Object.keys(parsed).length).toBeGreaterThan(0); + + // Every provider entry must be an array of { id, name } objects + for (const [provider, models] of Object.entries(parsed)) { + expect(Array.isArray(models)).toBe(true); + expect(models.length).toBeGreaterThan(0); + for (const model of models) { + expect(typeof model.id).toBe('string'); + expect(typeof model.name).toBe('string'); + // Only id and name — no extra metadata leaks + expect(Object.keys(model).sort()).toEqual(['id', 'name']); + } + // Provider key should be a non-empty string + expect(provider.length).toBeGreaterThan(0); + } + }); + + it('outputs minified JSON (single line, no whitespace formatting)', async () => { + const { handleCatalogJson } = await import( + `../../../src/commands/cliproxy/catalog-subcommand?catalog-json-minified=${Date.now()}` + ); + + handleCatalogJson(); + + const output = capturedOutput[0]; + // Minified JSON has no newlines + expect(output.includes('\n')).toBe(false); + // Valid JSON roundtrip + expect(JSON.stringify(JSON.parse(output))).toBe(output); + }); +}); From fbfb130f4d6313f10a6c757ceccfcfe2d7e69241 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 18:53:33 +0200 Subject: [PATCH 4/8] feat(cliproxy): include model metadata in catalog --json output - Expose tier, description, deprecated, broken, extendedContext, nativeImageInput fields (omitted when undefined) - Simplify tests to use static imports instead of dynamic cache-busting - Add integration tests verifying --json routing through handleCliproxyCommand - Test --json priority over refresh/reset subcommands Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 29 +++++- .../commands/cliproxy-catalog-json.test.ts | 93 ++++++++++++++----- 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 183759d7..78fb6e1d 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -174,16 +174,39 @@ export async function handleCatalogRefresh(verbose: boolean): Promise { console.log(''); } +/** JSON-serialisable model entry emitted by `catalog --json`. */ +interface CatalogJsonModel { + id: string; + name: string; + tier?: 'free' | 'pro' | 'ultra'; + description?: string; + deprecated?: boolean; + deprecationReason?: string; + broken?: boolean; + extendedContext?: boolean; + nativeImageInput?: boolean; +} + /** * Output catalog as JSON for programmatic consumption. * Used by OnSteroids and other tools to get available models per provider. - * Format: { [providerName: string]: Array<{ id: string, name: string }> } + * Format: { [providerName: string]: CatalogJsonModel[] } */ export function handleCatalogJson(): void { const catalogs = getAllResolvedCatalogs(); - const result: Record> = {}; + const result: Record = {}; for (const [provider, catalog] of Object.entries(catalogs)) { - result[provider] = catalog.models.map((m) => ({ id: m.id, name: m.name })); + result[provider] = catalog.models.map((m) => { + const entry: CatalogJsonModel = { id: m.id, name: m.name }; + if (m.tier) entry.tier = m.tier; + if (m.description) entry.description = m.description; + if (m.deprecated) entry.deprecated = m.deprecated; + if (m.deprecationReason) entry.deprecationReason = m.deprecationReason; + if (m.broken) entry.broken = m.broken; + if (m.extendedContext) entry.extendedContext = m.extendedContext; + if (m.nativeImageInput) entry.nativeImageInput = m.nativeImageInput; + return entry; + }); } console.log(JSON.stringify(result)); } diff --git a/tests/unit/commands/cliproxy-catalog-json.test.ts b/tests/unit/commands/cliproxy-catalog-json.test.ts index 0e1928ff..cd3fe17f 100644 --- a/tests/unit/commands/cliproxy-catalog-json.test.ts +++ b/tests/unit/commands/cliproxy-catalog-json.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { handleCatalogJson } from '../../../src/commands/cliproxy/catalog-subcommand'; +import { handleCliproxyCommand } from '../../../src/commands/cliproxy/index'; let originalConsoleLog: typeof console.log; let capturedOutput: string[]; @@ -16,49 +18,96 @@ afterEach(() => { }); describe('cliproxy catalog --json output', () => { - it('outputs valid JSON mapping provider names to model arrays', async () => { - const { handleCatalogJson } = await import( - `../../../src/commands/cliproxy/catalog-subcommand?catalog-json-format=${Date.now()}` - ); - + it('outputs valid JSON mapping provider names to model arrays', () => { handleCatalogJson(); expect(capturedOutput).toHaveLength(1); - const parsed = JSON.parse(capturedOutput[0]) as Record< - string, - Array<{ id: string; name: string }> - >; + const parsed = JSON.parse(capturedOutput[0]) as Record; // Must be a non-empty object (static catalog always has providers) expect(typeof parsed).toBe('object'); expect(Object.keys(parsed).length).toBeGreaterThan(0); - // Every provider entry must be an array of { id, name } objects - for (const [provider, models] of Object.entries(parsed)) { + // Every provider entry must be an array of objects with at least id and name + for (const models of Object.values(parsed)) { expect(Array.isArray(models)).toBe(true); expect(models.length).toBeGreaterThan(0); - for (const model of models) { + for (const model of models as Array>) { expect(typeof model.id).toBe('string'); expect(typeof model.name).toBe('string'); - // Only id and name — no extra metadata leaks - expect(Object.keys(model).sort()).toEqual(['id', 'name']); } - // Provider key should be a non-empty string - expect(provider.length).toBeGreaterThan(0); } }); - it('outputs minified JSON (single line, no whitespace formatting)', async () => { - const { handleCatalogJson } = await import( - `../../../src/commands/cliproxy/catalog-subcommand?catalog-json-minified=${Date.now()}` - ); + it('includes metadata fields when present on model entries', () => { + handleCatalogJson(); + const parsed = JSON.parse(capturedOutput[0]) as Record< + string, + Array> + >; + const allModels = Object.values(parsed).flat(); + + // At least some models in the static catalog have tier set + const withTier = allModels.filter((m) => m.tier !== undefined); + expect(withTier.length).toBeGreaterThan(0); + + // Tier values must be one of the allowed strings + for (const model of withTier) { + expect(['free', 'pro', 'ultra']).toContain(model.tier); + } + }); + + it('omits undefined optional fields instead of including nulls', () => { + handleCatalogJson(); + + const parsed = JSON.parse(capturedOutput[0]) as Record< + string, + Array> + >; + const allModels = Object.values(parsed).flat(); + + for (const model of allModels) { + for (const value of Object.values(model)) { + expect(value).not.toBeNull(); + expect(value).not.toBeUndefined(); + } + } + }); + + it('outputs minified JSON (single line, no whitespace formatting)', () => { handleCatalogJson(); const output = capturedOutput[0]; - // Minified JSON has no newlines expect(output.includes('\n')).toBe(false); - // Valid JSON roundtrip expect(JSON.stringify(JSON.parse(output))).toBe(output); }); }); + +describe('cliproxy catalog --json routing', () => { + it('routes catalog --json through handleCliproxyCommand', async () => { + await handleCliproxyCommand(['catalog', '--json']); + + expect(capturedOutput).toHaveLength(1); + const parsed = JSON.parse(capturedOutput[0]); + expect(typeof parsed).toBe('object'); + expect(Object.keys(parsed).length).toBeGreaterThan(0); + }); + + it('--json takes priority over refresh subcommand', async () => { + await handleCliproxyCommand(['catalog', 'refresh', '--json']); + + expect(capturedOutput).toHaveLength(1); + // Should output JSON, not refresh output + const parsed = JSON.parse(capturedOutput[0]); + expect(typeof parsed).toBe('object'); + }); + + it('--json takes priority when placed before subcommand', async () => { + await handleCliproxyCommand(['catalog', '--json', 'reset']); + + expect(capturedOutput).toHaveLength(1); + const parsed = JSON.parse(capturedOutput[0]); + expect(typeof parsed).toBe('object'); + }); +}); From 9ac1ac98040503cd395d51e4cffff123a1ace44f Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 19:08:24 +0200 Subject: [PATCH 5/8] fix(cliproxy): preserve explicit false values in catalog --json Use !== undefined checks instead of truthy checks so that explicitly set false booleans (e.g. extendedContext: false) appear in JSON output, allowing consumers to distinguish "not set" from "explicitly disabled". Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 78fb6e1d..3090c28e 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -198,13 +198,13 @@ export function handleCatalogJson(): void { for (const [provider, catalog] of Object.entries(catalogs)) { result[provider] = catalog.models.map((m) => { const entry: CatalogJsonModel = { id: m.id, name: m.name }; - if (m.tier) entry.tier = m.tier; - if (m.description) entry.description = m.description; - if (m.deprecated) entry.deprecated = m.deprecated; - if (m.deprecationReason) entry.deprecationReason = m.deprecationReason; - if (m.broken) entry.broken = m.broken; - if (m.extendedContext) entry.extendedContext = m.extendedContext; - if (m.nativeImageInput) entry.nativeImageInput = m.nativeImageInput; + if (m.tier !== undefined) entry.tier = m.tier; + if (m.description !== undefined) entry.description = m.description; + if (m.deprecated !== undefined) entry.deprecated = m.deprecated; + if (m.deprecationReason !== undefined) entry.deprecationReason = m.deprecationReason; + if (m.broken !== undefined) entry.broken = m.broken; + if (m.extendedContext !== undefined) entry.extendedContext = m.extendedContext; + if (m.nativeImageInput !== undefined) entry.nativeImageInput = m.nativeImageInput; return entry; }); } From 22acd96ba40dda2c9ed596b2db2643863b3b7e4d Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 19:22:05 +0200 Subject: [PATCH 6/8] feat(cliproxy): add thinking config to catalog --json and test false values - Include thinking support configuration (type, min, max, levels) in JSON output for reasoning-capable models - Add test verifying explicit false booleans are preserved (not omitted) - Add test verifying thinking config appears for thinking models Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 3 ++ .../commands/cliproxy-catalog-json.test.ts | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 3090c28e..05c4f172 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -10,6 +10,7 @@ import { import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import type { ThinkingSupport } from '../../cliproxy/model-catalog'; import type { CLIProxyProvider } from '../../cliproxy/types'; import type { RemoteModelInfo } from '../../cliproxy/management-api-types'; import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing'; @@ -183,6 +184,7 @@ interface CatalogJsonModel { deprecated?: boolean; deprecationReason?: string; broken?: boolean; + thinking?: ThinkingSupport; extendedContext?: boolean; nativeImageInput?: boolean; } @@ -203,6 +205,7 @@ export function handleCatalogJson(): void { if (m.deprecated !== undefined) entry.deprecated = m.deprecated; if (m.deprecationReason !== undefined) entry.deprecationReason = m.deprecationReason; if (m.broken !== undefined) entry.broken = m.broken; + if (m.thinking !== undefined) entry.thinking = m.thinking; if (m.extendedContext !== undefined) entry.extendedContext = m.extendedContext; if (m.nativeImageInput !== undefined) entry.nativeImageInput = m.nativeImageInput; return entry; diff --git a/tests/unit/commands/cliproxy-catalog-json.test.ts b/tests/unit/commands/cliproxy-catalog-json.test.ts index cd3fe17f..7bc3e5ea 100644 --- a/tests/unit/commands/cliproxy-catalog-json.test.ts +++ b/tests/unit/commands/cliproxy-catalog-json.test.ts @@ -75,6 +75,39 @@ describe('cliproxy catalog --json output', () => { } }); + it('includes explicit false boolean values in output', () => { + handleCatalogJson(); + + const parsed = JSON.parse(capturedOutput[0]) as Record< + string, + Array> + >; + const allModels = Object.values(parsed).flat(); + + // Static catalog has models with extendedContext: false + const withExplicitFalse = allModels.filter((m) => m.extendedContext === false); + expect(withExplicitFalse.length).toBeGreaterThan(0); + }); + + it('includes thinking configuration when present on models', () => { + handleCatalogJson(); + + const parsed = JSON.parse(capturedOutput[0]) as Record< + string, + Array> + >; + const allModels = Object.values(parsed).flat(); + + // Static catalog has thinking models (e.g. Claude Opus 4.6 Thinking) + const withThinking = allModels.filter((m) => m.thinking !== undefined); + expect(withThinking.length).toBeGreaterThan(0); + + for (const model of withThinking) { + const thinking = model.thinking as Record; + expect(['budget', 'levels', 'none']).toContain(thinking.type); + } + }); + it('outputs minified JSON (single line, no whitespace formatting)', () => { handleCatalogJson(); From 7bf5b4b95e0bb12afc1d23d668fb43546608db58 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 19:33:00 +0200 Subject: [PATCH 7/8] fix(cliproxy): guard against undefined catalog and add issueUrl field - Add null check for catalog entries from Partial type - Include issueUrl in JSON output for broken model tracking Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/catalog-subcommand.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 05c4f172..adcdb5e5 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -184,6 +184,7 @@ interface CatalogJsonModel { deprecated?: boolean; deprecationReason?: string; broken?: boolean; + issueUrl?: string; thinking?: ThinkingSupport; extendedContext?: boolean; nativeImageInput?: boolean; @@ -198,6 +199,9 @@ export function handleCatalogJson(): void { const catalogs = getAllResolvedCatalogs(); const result: Record = {}; for (const [provider, catalog] of Object.entries(catalogs)) { + if (!catalog) { + continue; + } result[provider] = catalog.models.map((m) => { const entry: CatalogJsonModel = { id: m.id, name: m.name }; if (m.tier !== undefined) entry.tier = m.tier; @@ -205,6 +209,7 @@ export function handleCatalogJson(): void { if (m.deprecated !== undefined) entry.deprecated = m.deprecated; if (m.deprecationReason !== undefined) entry.deprecationReason = m.deprecationReason; if (m.broken !== undefined) entry.broken = m.broken; + if (m.issueUrl !== undefined) entry.issueUrl = m.issueUrl; if (m.thinking !== undefined) entry.thinking = m.thinking; if (m.extendedContext !== undefined) entry.extendedContext = m.extendedContext; if (m.nativeImageInput !== undefined) entry.nativeImageInput = m.nativeImageInput; From ea2b16bb6622c7e4d668aa63b735093701313036 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 11 Apr 2026 20:47:10 +0200 Subject: [PATCH 8/8] docs(cliproxy): add catalog --json to --help output Built [OnSteroids](https://onsteroids.ai) --- src/commands/cliproxy/help-subcommand.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 56a2863d..823b1317 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -39,6 +39,7 @@ export async function showHelp(): Promise { ['catalog', 'Show catalog status, routing hints, and pinned short prefixes'], ['catalog refresh', 'Sync models from remote CLIProxy'], ['catalog reset', 'Clear cache, revert to static catalog'], + ['catalog --json', 'Output full model catalog as minified JSON'], ], ], [