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 <id>` is setup's bridge.
This commit is contained in:
garrytan committed 2026-10-03 20:47:44 +00:00
1 parent ac20ef1e13
commit 50d896fa66
2 files changed
+144

No files matched your search

+76
View File
@@ -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 <id>` 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 <id>` — setup's bridge to the table
// above. Prints "<overlay>\t<generic 0|1>" 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 <claude-model-id>');
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}`);
}
+68
View File
@@ -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);
});
});