diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 13374e65..adcdb5e5 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -5,10 +5,12 @@ import { SYNCABLE_PROVIDERS, getResolvedCatalog, refreshCatalogFromProxy, + getAllResolvedCatalogs, } from '../../cliproxy/catalog-cache'; 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'; @@ -173,6 +175,50 @@ 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; + issueUrl?: string; + thinking?: ThinkingSupport; + 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]: CatalogJsonModel[] } + */ +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; + 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.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; + return entry; + }); + } + console.log(JSON.stringify(result)); +} + /** Reset catalog cache */ export async function handleCatalogReset(): Promise { await initUI(); 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'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 50c9013a..52c4cb02 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,12 @@ 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; + } const subcommand = remainingArgs[1]; if (subcommand === 'refresh') { await handleCatalogRefresh(verbose); 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..7bc3e5ea --- /dev/null +++ b/tests/unit/commands/cliproxy-catalog-json.test.ts @@ -0,0 +1,146 @@ +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[]; + +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', () => { + handleCatalogJson(); + + expect(capturedOutput).toHaveLength(1); + 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 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 as Array>) { + expect(typeof model.id).toBe('string'); + expect(typeof model.name).toBe('string'); + } + } + }); + + 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('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(); + + const output = capturedOutput[0]; + expect(output.includes('\n')).toBe(false); + 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'); + }); +});