Skip to main content

omni_dev/cli/
ai_backend_args.rs

1//! Per-command AI backend flags (`--ai-backend`, `--model`, `--beta-header`,
2//! the `--claude-cli-*` escape hatches, and `--models-yaml`).
3//!
4//! These used to be `global = true` on the root [`Cli`](crate::cli::Cli), so
5//! every subcommand accepted them and all but a handful silently ignored them
6//! (#1778). They are now declared only on the commands that read them.
7//!
8//! **Placement rule:** a flag attaches to the **narrowest node whose entire
9//! subtree reads it** — subtree-`global = true` there (the `gmail --account`
10//! precedent), or `#[command(flatten)]`ed onto individual leaves when the
11//! readers are scattered, as the AI commands are. clap then rejects a flag on
12//! a command that would ignore it, and `--help` is accurate by construction.
13//!
14//! The values are still forwarded to environment variables by [`apply`]
15//! rather than threaded as parameters: `resolve_backend`/`resolve_model`,
16//! preflight, `ClaudeCliAiClient`, and the MCP binary all read the environment,
17//! so none of them changes. Each owning command calls [`apply`] as the first
18//! line of its `execute()`, before preflight and before building a client.
19//!
20//! [`apply`]: AiBackendArgs::apply
21
22use clap::Args;
23
24use crate::claude::backend::AiBackend;
25
26/// `--models-yaml`: the one AI flag also read by a non-AI command
27/// (`config models show`), so it is its own group.
28#[derive(Args, Debug, Clone, Default)]
29pub struct ModelsYamlArg {
30    /// Path to a single user-side `models.yaml` that short-circuits the
31    /// standard `./.omni-dev/models.yaml` and `~/.omni-dev/models.yaml`
32    /// lookup. The file is still merged over the embedded catalog.
33    /// Equivalent to setting `OMNI_DEV_MODELS_YAML`.
34    #[arg(long, value_name = "PATH")]
35    pub models_yaml: Option<std::path::PathBuf>,
36}
37
38impl ModelsYamlArg {
39    /// Forwards `--models-yaml` to `OMNI_DEV_MODELS_YAML`, which the model
40    /// registry reads on first use. Must run before `get_model_registry()`.
41    pub fn apply(&self) {
42        if let Some(path) = &self.models_yaml {
43            std::env::set_var("OMNI_DEV_MODELS_YAML", path);
44        }
45    }
46}
47
48/// The AI backend flags, flattened onto every command that builds an AI
49/// client (`git commit message {twiddle,check,staged}`, `git branch create pr`,
50/// `ai chat`, `ai jev verify-decision`).
51#[derive(Args, Debug, Clone, Default)]
52#[command(next_help_heading = "AI backend")]
53pub struct AiBackendArgs {
54    /// Selects the AI backend used by this command.
55    ///
56    /// Overrides the `OMNI_DEV_AI_BACKEND` environment variable and the
57    /// legacy `USE_OPENAI`/`USE_OLLAMA`/`CLAUDE_CODE_USE_BEDROCK` variables
58    /// (`default` forces the direct Anthropic API even when they are set).
59    #[arg(long, value_enum)]
60    pub ai_backend: Option<AiBackend>,
61
62    /// AI model to use for this command.
63    ///
64    /// Highest-precedence model selector: it overrides `OMNI_DEV_MODEL` and
65    /// every per-backend model variable (`CLAUDE_MODEL`, `CLAUDE_CODE_MODEL`,
66    /// `ANTHROPIC_MODEL`, `OPENAI_MODEL`, `OLLAMA_MODEL`). Equivalent to
67    /// setting `OMNI_DEV_MODEL`.
68    #[arg(long, value_name = "MODEL")]
69    pub model: Option<String>,
70
71    /// Beta header to send with AI API requests (format: key:value).
72    ///
73    /// Only sent if the model supports it in the model registry. Equivalent
74    /// to setting `OMNI_DEV_BETA_HEADER`. Ignored when `--ai-backend` is
75    /// `claude-cli` (the CLI negotiates betas itself).
76    #[arg(long, value_name = "KEY:VALUE")]
77    pub beta_header: Option<String>,
78
79    /// Weakens the `claude-cli` sandbox by allowing the nested `claude -p`
80    /// session to use its default built-in tools (Read, Edit, Write, Bash,
81    /// Glob, Grep).
82    ///
83    /// **Only use for deliberately tool-capable use cases.** By default the
84    /// nested session runs with `--tools ""` and cannot touch the
85    /// file system. This flag removes that guard. The prompt is built from
86    /// untrusted content (diffs, commit messages, JIRA text), so well-known
87    /// secret env vars (`*_API_KEY`, `*_TOKEN`, etc.) are scrubbed from the
88    /// nested session; set `OMNI_DEV_CLAUDE_CLI_KEEP_ENV` to exempt names.
89    /// Equivalent to setting `OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS=true`.
90    /// Independent of `--claude-cli-allow-mcp`.
91    ///
92    /// Ignored when `--ai-backend` is not `claude-cli`.
93    #[arg(long)]
94    pub claude_cli_allow_tools: bool,
95
96    /// Weakens the `claude-cli` sandbox by allowing the nested `claude -p`
97    /// session to load MCP servers from `~/.claude/settings.json`.
98    ///
99    /// **Only use deliberately.** MCP servers commonly hold OAuth tokens
100    /// (Gmail, Drive, Slack) and may be arbitrary network-attached services;
101    /// enabling this exposes them to the nested session. By default the
102    /// session runs with `--strict-mcp-config` and no MCP servers load.
103    /// Equivalent to setting `OMNI_DEV_CLAUDE_CLI_ALLOW_MCP=true`.
104    /// Independent of `--claude-cli-allow-tools`.
105    ///
106    /// Ignored when `--ai-backend` is not `claude-cli`.
107    #[arg(long)]
108    pub claude_cli_allow_mcp: bool,
109
110    /// Per-invocation spending cap in USD for the `claude-cli` backend.
111    ///
112    /// Forwarded to `claude -p --max-budget-usd`. When the nested session
113    /// exceeds this budget it aborts rather than running away with cost.
114    /// Equivalent to setting `OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD`.
115    ///
116    /// Ignored when `--ai-backend` is not `claude-cli`.
117    #[arg(long, value_name = "AMOUNT")]
118    pub claude_cli_max_budget_usd: Option<f64>,
119
120    /// `--models-yaml`, shared with `config models show`.
121    #[command(flatten)]
122    pub models_yaml: ModelsYamlArg,
123}
124
125impl AiBackendArgs {
126    /// Forwards the flags to the env vars that the backend/model resolvers,
127    /// preflight, and the `claude-cli` client read. Setting the env vars
128    /// (rather than threading extra arguments through every factory) keeps
129    /// those signatures stable and shared with the MCP binary.
130    pub fn apply(&self) {
131        // Every value — including `default` — is written to the env var so
132        // the flag decisively overrides both a pre-set OMNI_DEV_AI_BACKEND
133        // and the legacy USE_* selection flags (#1118).
134        if let Some(backend) = self.ai_backend {
135            std::env::set_var(crate::claude::backend::AI_BACKEND_ENV, backend.env_value());
136        }
137
138        if let Some(model) = &self.model {
139            std::env::set_var(crate::claude::backend::MODEL_ENV, model);
140        }
141
142        if let Some(beta_header) = &self.beta_header {
143            std::env::set_var(crate::claude::backend::BETA_HEADER_ENV, beta_header);
144        }
145
146        // The escape-hatch exports are also recorded in the flag-provenance
147        // registry so the sandbox-weakened WARN can attribute them to the
148        // flag rather than to an ambient shell export (issue #1143).
149        if self.claude_cli_allow_tools {
150            std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS", "true");
151            crate::utils::settings::note_cli_flag_export("OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS");
152        }
153
154        if self.claude_cli_allow_mcp {
155            std::env::set_var("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP", "true");
156            crate::utils::settings::note_cli_flag_export("OMNI_DEV_CLAUDE_CLI_ALLOW_MCP");
157        }
158
159        if let Some(budget) = self.claude_cli_max_budget_usd {
160            std::env::set_var("OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD", format!("{budget}"));
161        }
162
163        self.models_yaml.apply();
164    }
165}
166
167#[cfg(test)]
168#[allow(clippy::unwrap_used, clippy::expect_used)]
169mod tests {
170    use super::*;
171    use crate::cli::Cli;
172    use clap::Parser;
173
174    // ── parsing: the flags live after the AI leaf, and nowhere else ──
175
176    const TWIDDLE: [&str; 5] = ["omni-dev", "git", "commit", "message", "twiddle"];
177
178    fn parse_twiddle(extra: &[&str]) -> AiBackendArgs {
179        let argv: Vec<&str> = TWIDDLE.iter().chain(extra).copied().collect();
180        let cli = Cli::try_parse_from(argv).unwrap();
181        let crate::cli::Commands::Git(git) = cli.command else {
182            panic!("expected a git command");
183        };
184        let crate::cli::git::GitSubcommands::Commit(commit) = git.command else {
185            panic!("expected git commit");
186        };
187        let crate::cli::git::CommitSubcommands::Message(message) = commit.command;
188        let crate::cli::git::MessageSubcommands::Twiddle(twiddle) = message.command else {
189            panic!("expected twiddle");
190        };
191        twiddle.ai
192    }
193
194    #[test]
195    fn parses_every_flag_after_the_leaf() {
196        let ai = parse_twiddle(&[
197            "--ai-backend",
198            "claude-cli",
199            "--model",
200            "claude-opus-4-6",
201            "--beta-header",
202            "anthropic-beta:output-128k-2025-02-19",
203            "--claude-cli-allow-tools",
204            "--claude-cli-allow-mcp",
205            "--claude-cli-max-budget-usd",
206            "0.50",
207            "--models-yaml",
208            "/tmp/custom-models.yaml",
209        ]);
210        assert_eq!(ai.ai_backend, Some(AiBackend::ClaudeCli));
211        assert_eq!(ai.model.as_deref(), Some("claude-opus-4-6"));
212        assert_eq!(
213            ai.beta_header.as_deref(),
214            Some("anthropic-beta:output-128k-2025-02-19")
215        );
216        assert!(ai.claude_cli_allow_tools);
217        assert!(ai.claude_cli_allow_mcp);
218        assert_eq!(ai.claude_cli_max_budget_usd, Some(0.50));
219        assert_eq!(
220            ai.models_yaml.models_yaml.as_deref(),
221            Some(std::path::Path::new("/tmp/custom-models.yaml"))
222        );
223    }
224
225    #[test]
226    fn absent_flags_are_default() {
227        let ai = parse_twiddle(&[]);
228        assert!(ai.ai_backend.is_none());
229        assert!(ai.model.is_none());
230        assert!(ai.beta_header.is_none());
231        assert!(!ai.claude_cli_allow_tools);
232        assert!(!ai.claude_cli_allow_mcp);
233        assert!(ai.claude_cli_max_budget_usd.is_none());
234        assert!(ai.models_yaml.models_yaml.is_none());
235    }
236
237    #[test]
238    fn parses_each_ai_backend_value() {
239        for (value, expected) in [
240            ("default", AiBackend::Default),
241            ("claude-cli", AiBackend::ClaudeCli),
242            ("openai", AiBackend::OpenAi),
243            ("ollama", AiBackend::Ollama),
244            ("bedrock", AiBackend::Bedrock),
245        ] {
246            let ai = parse_twiddle(&["--ai-backend", value]);
247            assert_eq!(ai.ai_backend, Some(expected), "value {value}");
248        }
249    }
250
251    #[test]
252    fn max_budget_usd_rejects_non_numeric() {
253        let argv: Vec<&str> = TWIDDLE
254            .iter()
255            .chain(&["--claude-cli-max-budget-usd", "cheap"])
256            .copied()
257            .collect();
258        let Err(err) = Cli::try_parse_from(argv) else {
259            panic!("expected parse error for non-numeric budget");
260        };
261        assert!(err.to_string().contains("invalid"));
262    }
263
264    /// Every AI leaf accepts the group.
265    #[test]
266    fn every_ai_command_accepts_the_flags() {
267        for leaf in [
268            &["git", "commit", "message", "twiddle"][..],
269            &["git", "commit", "message", "check"],
270            &["git", "commit", "message", "staged"],
271            &["git", "branch", "create", "pr"],
272            &["ai", "chat"],
273            &["ai", "jev", "verify-decision", "#1"],
274        ] {
275            let argv: Vec<&str> = std::iter::once("omni-dev")
276                .chain(leaf.iter().copied())
277                .chain(["--ai-backend", "claude-cli", "--model", "m"])
278                .collect();
279            assert!(
280                Cli::try_parse_from(&argv).is_ok(),
281                "`{}` should accept the AI backend flags",
282                leaf.join(" ")
283            );
284        }
285    }
286
287    /// The pre-#1778 placement — before the subcommand — no longer parses:
288    /// there is no root-level AI flag left to bind it.
289    #[test]
290    fn flags_before_the_subcommand_are_rejected() {
291        for flag in [
292            &["--model", "claude-opus-4-6"][..],
293            &["--ai-backend", "claude-cli"],
294            &["--beta-header", "k:v"],
295            &["--claude-cli-allow-tools"],
296            &["--models-yaml", "/tmp/m.yaml"],
297        ] {
298            let argv: Vec<&str> = std::iter::once("omni-dev")
299                .chain(flag.iter().copied())
300                .chain(TWIDDLE[1..].iter().copied())
301                .collect();
302            assert!(
303                Cli::try_parse_from(&argv).is_err(),
304                "`{}` before the subcommand must be rejected",
305                flag[0]
306            );
307        }
308    }
309
310    /// Regression test for #1778: `ai jev` never builds an AI client, so it
311    /// must reject the AI backend flags instead of silently ignoring them.
312    #[test]
313    fn jev_rejects_ai_backend_flags() {
314        for flag in [
315            &["--claude-cli-allow-tools"][..],
316            &["--claude-cli-allow-mcp"],
317            &["--claude-cli-max-budget-usd", "1"],
318            &["--ai-backend", "claude-cli"],
319            &["--model", "claude-opus-4-6"],
320            &["--beta-header", "k:v"],
321            &["--models-yaml", "/tmp/m.yaml"],
322        ] {
323            let argv: Vec<&str> = [
324                "omni-dev",
325                "ai",
326                "jev",
327                "noul",
328                "--instructions",
329                "i",
330                "--true-means",
331                "t",
332                "--false-means",
333                "f",
334            ]
335            .into_iter()
336            .chain(flag.iter().copied())
337            .collect();
338            let Err(err) = Cli::try_parse_from(&argv) else {
339                panic!("`ai jev noul {}` must be rejected", flag[0]);
340            };
341            assert_eq!(
342                err.kind(),
343                clap::error::ErrorKind::UnknownArgument,
344                "`{}`: {err}",
345                flag[0]
346            );
347        }
348    }
349
350    /// `config models show` reads the registry, so it takes `--models-yaml`
351    /// — and only that: it never builds an AI client.
352    #[test]
353    fn config_models_show_takes_only_models_yaml() {
354        assert!(Cli::try_parse_from([
355            "omni-dev",
356            "config",
357            "models",
358            "show",
359            "--models-yaml",
360            "/tmp/m.yaml",
361        ])
362        .is_ok());
363        assert!(Cli::try_parse_from([
364            "omni-dev",
365            "config",
366            "models",
367            "show",
368            "--ai-backend",
369            "claude-cli",
370        ])
371        .is_err());
372    }
373
374    /// Grep guard: the files under `src/cli/` that build an AI client must be
375    /// exactly the files that flatten [`AiBackendArgs`]. A new AI command that
376    /// forgets the flags (so `--model` is rejected where it should work), or a
377    /// non-AI command that gains them (so they are silently ignored again,
378    /// #1778), fails here.
379    #[test]
380    fn ai_client_builders_are_exactly_the_ai_backend_args_owners() {
381        use std::collections::BTreeSet;
382        use std::path::{Path, PathBuf};
383
384        fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
385            for entry in std::fs::read_dir(dir).unwrap() {
386                let path = entry.unwrap().path();
387                if path.is_dir() {
388                    rust_files(&path, out);
389                } else if path.extension().is_some_and(|e| e == "rs") {
390                    out.push(path);
391                }
392            }
393        }
394
395        let cli_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/cli");
396        let mut files = Vec::new();
397        rust_files(&cli_dir, &mut files);
398
399        let mut builders = BTreeSet::new();
400        let mut owners = BTreeSet::new();
401        for path in files {
402            let rel = path
403                .strip_prefix(&cli_dir)
404                .unwrap()
405                .to_string_lossy()
406                .into_owned();
407            if rel == "ai_backend_args.rs" {
408                continue;
409            }
410            let source = std::fs::read_to_string(&path).unwrap();
411            let code = source
412                .lines()
413                .map(str::trim_start)
414                .filter(|l| !l.starts_with("//"));
415            for line in code {
416                if line.contains("create_default_claude_client(") {
417                    builders.insert(rel.clone());
418                }
419                // A field declaration (`pub ai: …::AiBackendArgs,`), not a
420                // struct-literal initializer (`…::AiBackendArgs::default()`).
421                if line.starts_with("pub ") && line.ends_with("AiBackendArgs,") {
422                    owners.insert(rel.clone());
423                }
424            }
425        }
426
427        assert!(!builders.is_empty(), "found no AI client builders");
428        assert_eq!(
429            builders, owners,
430            "files calling `create_default_claude_client(` must be exactly the \
431             files that flatten `AiBackendArgs` (placement rule, #1778)"
432        );
433    }
434
435    // ── apply() ──
436    //
437    // These tests mutate process-global env vars, so they serialise on
438    // `crate::claude::ai::claude_cli::CLI_ENV_LOCK` (shared with claude-cli's
439    // own env-mutating tests to avoid cross-module races).
440
441    const BACKEND_VAR: &str = "OMNI_DEV_AI_BACKEND";
442    const MODEL_VAR: &str = "OMNI_DEV_MODEL";
443    const BETA_HEADER_VAR: &str = "OMNI_DEV_BETA_HEADER";
444    const ALLOW_TOOLS_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
445    const ALLOW_MCP_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
446    const MAX_BUDGET_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
447    const MODELS_YAML_VAR: &str = "OMNI_DEV_MODELS_YAML";
448
449    /// Locks the shared mutex and snapshots/restores every env var `apply`
450    /// may touch.
451    struct AiEnvGuard {
452        _lock: std::sync::MutexGuard<'static, ()>,
453        saved: [(&'static str, Option<String>); 7],
454    }
455
456    impl AiEnvGuard {
457        fn new() -> Self {
458            let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
459                .lock()
460                .unwrap_or_else(std::sync::PoisonError::into_inner);
461            let names = [
462                BACKEND_VAR,
463                MODEL_VAR,
464                BETA_HEADER_VAR,
465                ALLOW_TOOLS_VAR,
466                ALLOW_MCP_VAR,
467                MAX_BUDGET_VAR,
468                MODELS_YAML_VAR,
469            ];
470            let saved = names.map(|n| (n, std::env::var(n).ok()));
471            for (n, _) in &saved {
472                std::env::remove_var(n);
473            }
474            Self { _lock: lock, saved }
475        }
476    }
477
478    impl Drop for AiEnvGuard {
479        fn drop(&mut self) {
480            for (n, value) in &self.saved {
481                match value {
482                    Some(v) => std::env::set_var(n, v),
483                    None => std::env::remove_var(n),
484                }
485            }
486        }
487    }
488
489    #[test]
490    fn apply_defaults_set_nothing() {
491        let _g = AiEnvGuard::new();
492        AiBackendArgs::default().apply();
493        for var in [
494            BACKEND_VAR,
495            MODEL_VAR,
496            BETA_HEADER_VAR,
497            ALLOW_TOOLS_VAR,
498            ALLOW_MCP_VAR,
499            MAX_BUDGET_VAR,
500            MODELS_YAML_VAR,
501        ] {
502            assert!(std::env::var(var).is_err(), "{var} should be unset");
503        }
504    }
505
506    #[test]
507    fn apply_sets_ai_backend() {
508        let _g = AiEnvGuard::new();
509        for (backend, expected) in [
510            (AiBackend::ClaudeCli, "claude-cli"),
511            (AiBackend::OpenAi, "openai"),
512            (AiBackend::Ollama, "ollama"),
513            (AiBackend::Bedrock, "bedrock"),
514        ] {
515            AiBackendArgs {
516                ai_backend: Some(backend),
517                ..Default::default()
518            }
519            .apply();
520            assert_eq!(std::env::var(BACKEND_VAR).ok().as_deref(), Some(expected));
521        }
522    }
523
524    #[test]
525    fn apply_default_backend_overrides_env_var() {
526        // `--ai-backend default` must *set* the env var (not remove it) so it
527        // decisively overrides both a pre-set backend and the legacy USE_*
528        // flags (#1118).
529        let _g = AiEnvGuard::new();
530        std::env::set_var(BACKEND_VAR, "claude-cli");
531        AiBackendArgs {
532            ai_backend: Some(AiBackend::Default),
533            ..Default::default()
534        }
535        .apply();
536        assert_eq!(std::env::var(BACKEND_VAR).ok().as_deref(), Some("default"));
537    }
538
539    #[test]
540    fn apply_sets_model() {
541        let _g = AiEnvGuard::new();
542        AiBackendArgs {
543            model: Some("claude-opus-4-6".to_string()),
544            ..Default::default()
545        }
546        .apply();
547        assert_eq!(
548            std::env::var(MODEL_VAR).ok().as_deref(),
549            Some("claude-opus-4-6")
550        );
551    }
552
553    #[test]
554    fn apply_sets_beta_header() {
555        let _g = AiEnvGuard::new();
556        AiBackendArgs {
557            beta_header: Some("anthropic-beta:output-128k-2025-02-19".to_string()),
558            ..Default::default()
559        }
560        .apply();
561        assert_eq!(
562            std::env::var(BETA_HEADER_VAR).ok().as_deref(),
563            Some("anthropic-beta:output-128k-2025-02-19")
564        );
565    }
566
567    #[test]
568    fn apply_sets_allow_tools() {
569        let _g = AiEnvGuard::new();
570        AiBackendArgs {
571            claude_cli_allow_tools: true,
572            ..Default::default()
573        }
574        .apply();
575        assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
576        // The flag export is recorded for WARN provenance (issue #1143). The
577        // registry is additive-only, so this assertion is order-independent.
578        assert!(crate::utils::settings::exported_by_cli_flag(
579            ALLOW_TOOLS_VAR
580        ));
581    }
582
583    #[test]
584    fn apply_sets_allow_mcp() {
585        let _g = AiEnvGuard::new();
586        AiBackendArgs {
587            claude_cli_allow_mcp: true,
588            ..Default::default()
589        }
590        .apply();
591        assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
592        assert!(crate::utils::settings::exported_by_cli_flag(ALLOW_MCP_VAR));
593    }
594
595    #[test]
596    fn apply_sets_max_budget_usd() {
597        let _g = AiEnvGuard::new();
598        AiBackendArgs {
599            claude_cli_max_budget_usd: Some(1.5),
600            ..Default::default()
601        }
602        .apply();
603        assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("1.5"));
604    }
605
606    #[test]
607    fn apply_sets_models_yaml() {
608        let _g = AiEnvGuard::new();
609        AiBackendArgs {
610            models_yaml: ModelsYamlArg {
611                models_yaml: Some(std::path::PathBuf::from("/tmp/custom-models.yaml")),
612            },
613            ..Default::default()
614        }
615        .apply();
616        assert_eq!(
617            std::env::var(MODELS_YAML_VAR).ok().as_deref(),
618            Some("/tmp/custom-models.yaml")
619        );
620    }
621
622    #[test]
623    fn apply_independent_flags_compose() {
624        let _g = AiEnvGuard::new();
625        AiBackendArgs {
626            ai_backend: Some(AiBackend::ClaudeCli),
627            claude_cli_allow_tools: true,
628            claude_cli_allow_mcp: true,
629            claude_cli_max_budget_usd: Some(0.25),
630            ..Default::default()
631        }
632        .apply();
633        assert_eq!(
634            std::env::var(BACKEND_VAR).ok().as_deref(),
635            Some("claude-cli")
636        );
637        assert_eq!(std::env::var(ALLOW_TOOLS_VAR).ok().as_deref(), Some("true"));
638        assert_eq!(std::env::var(ALLOW_MCP_VAR).ok().as_deref(), Some("true"));
639        assert_eq!(std::env::var(MAX_BUDGET_VAR).ok().as_deref(), Some("0.25"));
640    }
641}