From 50d896fa66597950e81d642061239f090d008fca Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:47:44 +0000 Subject: [PATCH] W7 R3: resolveClaudeOverlay allowlist and validation table scripts/models.ts gains CLAUDE_OVERLAY_MODELS and resolveClaudeOverlay(id), which maps a --claude-model value to a Claude overlay or an error with problem, cause and fix (bare Claude Code aliases, unknown IDs, non-Claude families). A claude-* ID without its own overlay uses the generic one. `bun scripts/models.ts claude-overlay ` is setup's bridge. --- scripts/models.ts | 76 ++++++++++++++++++++++++++++++ test/claude-overlay-models.test.ts | 68 ++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 test/claude-overlay-models.test.ts diff --git a/scripts/models.ts b/scripts/models.ts index f80a14cd1..0c13ab371 100644 --- a/scripts/models.ts +++ b/scripts/models.ts @@ -101,3 +101,79 @@ export function validateModel(input: string): string | null { if ((ALL_MODEL_NAMES as readonly string[]).includes(input)) return null; return `'${input}' is not a known model. Use ${ALL_MODEL_NAMES.join(', ')}.`; } + +/** + * Overlays `./setup --claude-model ` may select for the Claude host. The + * list is the Claude subset of ALL_MODEL_NAMES; a new Claude overlay joins + * both lists. + */ +export const CLAUDE_OVERLAY_MODELS = ['claude', 'opus-4-7', 'opus-4-8', 'sonnet-5', 'fable-5'] as const; + +export type ClaudeOverlay = (typeof CLAUDE_OVERLAY_MODELS)[number]; + +export interface ClaudeOverlayError { + kind: 'alias' | 'unknown' | 'non-claude'; + problem: string; + cause: string; + fix: string; +} + +export type ClaudeOverlayResult = + | { overlay: ClaudeOverlay; generic: boolean } + | { error: ClaudeOverlayError }; + +const CLAUDE_CODE_ALIASES = ['opus', 'sonnet', 'haiku', 'default']; + +/** + * Map a `--claude-model` value to the overlay setup renders. `resolveModel` + * does the family matching (bracketed context suffixes, dated and `-latest` + * snapshots); this adds the Claude-only allowlist and the errors setup prints. + * `generic` is true when a specific model ID has no overlay of its own and + * uses the generic `claude` overlay (for example `claude-opus-5-5`). + */ +export function resolveClaudeOverlay(id: string): ClaudeOverlayResult { + const s = id.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').trim(); + const problem = `${JSON.stringify(id)} is not a Claude model ID that gstack can map to a skill overlay.`; + if (CLAUDE_CODE_ALIASES.includes(s)) { + return { error: { + kind: 'alias', + problem, + cause: `"${s}" is a Claude Code alias whose model changes over time, so it names no fixed overlay.`, + fix: 'Pass a full model ID, for example ./setup --claude-model claude-opus-4-8, or ./setup --claude-model claude for the generic overlay.', + } }; + } + const family = resolveModel(s); + if (!family) { + return { error: { + kind: 'unknown', + problem, + cause: 'It does not match any model family gstack knows.', + fix: 'Pass a Claude model ID such as claude-opus-4-8 or claude-sonnet-5, or claude for the generic overlay.', + } }; + } + if (!(CLAUDE_OVERLAY_MODELS as readonly string[]).includes(family)) { + return { error: { + kind: 'non-claude', + problem, + cause: `It belongs to the ${family} family, and --claude-model selects Claude overlays only.`, + fix: `For Codex skills use ./setup --host codex --model ${id}. For Claude, pass a Claude model ID such as claude-opus-4-8.`, + } }; + } + return { overlay: family as ClaudeOverlay, generic: family === 'claude' && s !== 'claude' }; +} + +// `bun scripts/models.ts claude-overlay ` — setup's bridge to the table +// above. Prints "\t" or the error lines on stderr (exit 1). +if (import.meta.main) { + const [command, id] = process.argv.slice(2); + if (command !== 'claude-overlay' || id === undefined) { + console.error('Usage: bun scripts/models.ts claude-overlay '); + process.exit(2); + } + const result = resolveClaudeOverlay(id); + if ('error' in result) { + console.error(`${result.error.problem}\n${result.error.cause}\n${result.error.fix}`); + process.exit(1); + } + console.log(`${result.overlay}\t${result.generic ? 1 : 0}`); +} diff --git a/test/claude-overlay-models.test.ts b/test/claude-overlay-models.test.ts new file mode 100644 index 000000000..4b27f1d8f --- /dev/null +++ b/test/claude-overlay-models.test.ts @@ -0,0 +1,68 @@ +/** + * `resolveClaudeOverlay` — the validation table behind `./setup --claude-model`. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { ALL_MODEL_NAMES, CLAUDE_OVERLAY_MODELS, resolveClaudeOverlay } from '../scripts/models'; + +const ROOT = resolve(import.meta.dir, '..'); + +describe('resolveClaudeOverlay', () => { + test.each([ + ['claude', 'claude', false], + ['opus-4-7', 'opus-4-7', false], + ['claude-opus-4-7', 'opus-4-7', false], + ['claude-opus-4-8-20260115', 'opus-4-8', false], + ['claude-opus-4-8-latest', 'opus-4-8', false], + ['claude-sonnet-5[1m]', 'sonnet-5', false], + [' Claude-Sonnet-5 ', 'sonnet-5', false], + ['claude-fable-5-1', 'fable-5', false], + ['claude-opus-5-5', 'claude', true], + ['claude-opus-4-7-1', 'claude', true], + ['claude-haiku-4-5', 'claude', true], + ['claude-3-opus-20240229', 'claude', true], + ])('%p uses the %p overlay', (id, overlay, generic) => { + expect(resolveClaudeOverlay(id)).toEqual({ overlay, generic }); + }); + + test.each([ + ['opus', 'alias'], + ['sonnet', 'alias'], + ['haiku', 'alias'], + ['default', 'alias'], + ['Opus[1m]', 'alias'], + ['gpt-5.4', 'non-claude'], + ['gemini', 'non-claude'], + ['o3', 'non-claude'], + ['gpt-6-astra', 'non-claude'], + ['banana', 'unknown'], + ['', 'unknown'], + ])('%p is rejected as %p with problem, cause and fix', (id, kind) => { + const result = resolveClaudeOverlay(id); + expect('error' in result).toBe(true); + if (!('error' in result)) return; + expect(result.error.kind).toBe(kind); + for (const part of [result.error.problem, result.error.cause, result.error.fix]) expect(part.length).toBeGreaterThan(10); + expect(result.error.fix).toContain('claude'); + }); + + test('every Claude overlay is a known model with an overlay file', () => { + for (const overlay of CLAUDE_OVERLAY_MODELS) { + expect((ALL_MODEL_NAMES as readonly string[]).includes(overlay)).toBe(true); + expect(existsSync(join(ROOT, 'model-overlays', `${overlay}.md`))).toBe(true); + } + }); + + test('the CLI bridge setup calls prints overlay and generic flag, or the error lines', () => { + const run = (id: string) => spawnSync('bun', ['run', 'scripts/models.ts', 'claude-overlay', id], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }); + const ok = run('claude-opus-5-5'); + expect(ok.status).toBe(0); + expect(ok.stdout).toBe('claude\t1\n'); + const bad = run('opus'); + expect(bad.status).toBe(1); + expect(bad.stdout).toBe(''); + expect(bad.stderr.trim().split('\n')).toHaveLength(3); + }); +});