diff --git a/src/cli/args.ts b/src/cli/args.ts index dd61f80..1942bf8 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -14,6 +14,8 @@ export const CLIOptionsSchema = z.object({ help: z.boolean().default(false), /** Max concurrent trigger/skill executions (default: 4) */ parallel: z.number().int().positive().optional(), + /** Model to use for analysis (fallback when not set in config) */ + model: z.string().optional(), // Verbosity options quiet: z.boolean().default(false), verbose: z.number().default(0), @@ -68,6 +70,7 @@ Targets: Options: --skill Run only this skill (default: run all built-in skills) --config Path to warden.toml (default: ./warden.toml) + -m, --model Model to use (fallback when not set in config) --json Output results as JSON --fail-on Exit with code 1 if findings >= severity (critical, high, medium, low, info) @@ -203,6 +206,7 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedArgs options: { skill: { type: 'string' }, config: { type: 'string' }, + model: { type: 'string', short: 'm' }, json: { type: 'boolean', default: false }, 'fail-on': { type: 'string' }, 'comment-on': { type: 'string' }, @@ -312,6 +316,7 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedArgs targets: targets.length > 0 ? targets : undefined, skill: values.skill, config: values.config, + model: values.model, json: values.json, failOn: values['fail-on'] as Severity | undefined, commentOn: values['comment-on'] as Severity | undefined, diff --git a/src/cli/main.ts b/src/cli/main.ts index 3f59f32..a223e51 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -98,9 +98,10 @@ async function runSkills( reporter.blank(); } - // Try to load config for custom skills + // Try to load config for custom skills and defaults let repoPath: string | undefined; let skillsConfig: SkillDefinition[] | undefined; + let defaultsModel: string | undefined; try { repoPath = getRepoRoot(cwd); @@ -110,13 +111,16 @@ async function runSkills( if (existsSync(configPath)) { const config = loadWardenConfig(dirname(configPath)); skillsConfig = config.skills; + defaultsModel = config.defaults?.model; } } catch { // Not in a git repo or no config - that's fine } // Build skill tasks - const runnerOptions: SkillRunnerOptions = { apiKey, abortController }; + // Model precedence: defaults.model > CLI flag > WARDEN_MODEL env var > SDK default + const model = defaultsModel ?? options.model ?? process.env['WARDEN_MODEL']; + const runnerOptions: SkillRunnerOptions = { apiKey, model, abortController }; const tasks: SkillTaskOptions[] = skillNames.map((skillName) => ({ name: skillName, failOn: options.failOn, @@ -374,7 +378,7 @@ async function runConfigMode(options: CLIOptions, reporter: Reporter): Promise resolveTrigger(t, config)); + const resolvedTriggers = config.triggers.map((t) => resolveTrigger(t, config, options.model)); const matchedTriggers = resolvedTriggers.filter((t) => matchTrigger(t, context)); // Filter by skill if specified diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts index e811af1..dfa63cd 100644 --- a/src/config/loader.test.ts +++ b/src/config/loader.test.ts @@ -113,4 +113,54 @@ describe('resolveTrigger', () => { expect(resolved.actions).toEqual(['opened']); expect(resolved.skill).toBe('security-review'); }); + + describe('model precedence', () => { + it('trigger.model takes precedence over cliModel', () => { + const trigger: Trigger = { + ...baseTrigger, + model: 'claude-opus-4-20250514', + }; + + const resolved = resolveTrigger(trigger, baseConfig, 'claude-haiku-3-5-20241022'); + + expect(resolved.model).toBe('claude-opus-4-20250514'); + }); + + it('defaults.model takes precedence over cliModel', () => { + const config: WardenConfig = { + ...baseConfig, + defaults: { + model: 'claude-sonnet-4-20250514', + }, + }; + + const resolved = resolveTrigger(baseTrigger, config, 'claude-haiku-3-5-20241022'); + + expect(resolved.model).toBe('claude-sonnet-4-20250514'); + }); + + it('cliModel is used when no config model is set', () => { + const resolved = resolveTrigger(baseTrigger, baseConfig, 'claude-haiku-3-5-20241022'); + + expect(resolved.model).toBe('claude-haiku-3-5-20241022'); + }); + + it('trigger.model takes precedence over defaults.model', () => { + const trigger: Trigger = { + ...baseTrigger, + model: 'claude-opus-4-20250514', + }; + const config: WardenConfig = { + ...baseConfig, + triggers: [trigger], + defaults: { + model: 'claude-sonnet-4-20250514', + }, + }; + + const resolved = resolveTrigger(trigger, config, 'claude-haiku-3-5-20241022'); + + expect(resolved.model).toBe('claude-opus-4-20250514'); + }); + }); }); diff --git a/src/config/loader.ts b/src/config/loader.ts index c822eed..c7cea5e 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -57,9 +57,21 @@ export interface ResolvedTrigger extends Trigger { /** * Resolve a trigger's configuration by merging with defaults. * Trigger-specific values override defaults. + * + * Model precedence (highest to lowest): + * 1. trigger.model (warden.toml trigger-level) + * 2. defaults.model (warden.toml [defaults]) + * 3. cliModel (--model flag) + * 4. WARDEN_MODEL env var + * 5. SDK default (not set here) */ -export function resolveTrigger(trigger: Trigger, config: WardenConfig): ResolvedTrigger { +export function resolveTrigger( + trigger: Trigger, + config: WardenConfig, + cliModel?: string +): ResolvedTrigger { const defaults = config.defaults; + const envModel = process.env['WARDEN_MODEL']; return { ...trigger, @@ -73,6 +85,6 @@ export function resolveTrigger(trigger: Trigger, config: WardenConfig): Resolved maxFindings: trigger.output?.maxFindings ?? defaults?.output?.maxFindings, labels: trigger.output?.labels ?? defaults?.output?.labels, }, - model: trigger.model ?? defaults?.model, + model: trigger.model ?? defaults?.model ?? cliModel ?? envModel, }; }