Skip to main content

lha_cli/
entry.rs

1use crate::LandlockCommand;
2use crate::SeatbeltCommand;
3use crate::WindowsCommand;
4use crate::product::arg0::arg0_dispatch_or_else;
5use crate::product::common::CliConfigOverrides;
6use crate::product::exec_cli::Cli as ExecCli;
7use crate::product::exec_cli::Command as ExecCommand;
8use crate::product::exec_cli::ReviewArgs;
9use crate::product::execpolicy::ExecPolicyCheckCommand;
10use crate::product::tui_app::AppExitInfo;
11use crate::product::tui_app::Cli as TuiCli;
12use crate::product::tui_app::ExitReason;
13use crate::product::tui_app::update_action::UpdateAction;
14use clap::Args;
15use clap::CommandFactory;
16use clap::Parser;
17use clap_complete::Shell;
18use clap_complete::generate;
19use owo_colors::OwoColorize;
20use std::io::IsTerminal;
21use std::path::PathBuf;
22use supports_color::Stream;
23
24#[path = "mcp_cmd.rs"]
25mod mcp_cmd;
26#[cfg(not(windows))]
27#[path = "wsl_paths.rs"]
28mod wsl_paths;
29
30use self::mcp_cmd::McpCli;
31
32use crate::product::agent::config::Config;
33use crate::product::agent::config::ConfigOverrides;
34use crate::product::agent::config::edit::ConfigEditsBuilder;
35use crate::product::agent::config::find_lha_home;
36use crate::product::agent::features::Stage;
37use crate::product::agent::features::is_known_feature_key;
38use crate::product::agent::terminal::TerminalName;
39
40/// LHA CLI
41///
42/// If no subcommand is specified, options will be forwarded to the interactive CLI.
43#[derive(Debug, Parser)]
44#[clap(
45    author,
46    version,
47    // If a sub‑command is given, ignore requirements of the default args.
48    subcommand_negates_reqs = true,
49    // The executable is sometimes invoked via a platform‑specific name like
50    // `codex-x86_64-unknown-linux-musl`, but the help output should always use
51    // the generic `lha` command name that users run.
52    bin_name = "lha",
53    override_usage = "lha [OPTIONS] [PROMPT]\n       lha [OPTIONS] <COMMAND> [ARGS]"
54)]
55struct MultitoolCli {
56    #[clap(flatten)]
57    pub config_overrides: CliConfigOverrides,
58
59    #[clap(flatten)]
60    pub feature_toggles: FeatureToggles,
61
62    #[clap(flatten)]
63    interactive: TuiCli,
64
65    #[clap(subcommand)]
66    subcommand: Option<Subcommand>,
67}
68
69#[derive(Debug, clap::Subcommand)]
70enum Subcommand {
71    /// Run LHA non-interactively.
72    #[clap(visible_alias = "e")]
73    Exec(ExecCli),
74
75    /// Run a code review non-interactively.
76    Review(ReviewArgs),
77
78    /// [experimental] Run LHA as an MCP server and manage MCP servers.
79    Mcp(McpCli),
80
81    /// [experimental] Run the LHA MCP server (stdio transport).
82    McpServer,
83
84    /// [experimental] Run the app server or related tooling.
85    AppServer(AppServerCommand),
86
87    /// Generate shell completion scripts.
88    Completion(CompletionCommand),
89
90    /// Run commands within a LHA-provided sandbox.
91    #[clap(visible_alias = "debug")]
92    Sandbox(SandboxArgs),
93
94    /// Execpolicy tooling.
95    #[clap(hide = true)]
96    Execpolicy(ExecpolicyCommand),
97
98    /// Resume a previous interactive session (picker by default; use --last to continue the most recent).
99    Resume(ResumeCommand),
100
101    /// Fork a previous interactive session (picker by default; use --last to fork the most recent).
102    Fork(ForkCommand),
103
104    /// Internal: removed responses API proxy entrypoint.
105    #[clap(hide = true, name = "responses-api-proxy")]
106    RemovedResponsesApiProxy(RemovedResponsesProxyArgs),
107
108    /// Internal: relay stdio to a Unix domain socket.
109    #[clap(hide = true, name = "stdio-to-uds")]
110    StdioToUds(StdioToUdsCommand),
111
112    /// Internal developer tooling.
113    #[clap(hide = true)]
114    Dev(DevCommand),
115
116    /// Internal: run the Windows sandbox setup helper.
117    #[cfg(target_os = "windows")]
118    #[clap(hide = true, name = "__windows-sandbox-setup")]
119    WindowsSandboxSetup(WindowsSandboxSetupCommand),
120
121    /// Internal: run the Windows sandbox command runner helper.
122    #[cfg(target_os = "windows")]
123    #[clap(hide = true, name = "__windows-command-runner")]
124    WindowsCommandRunner(WindowsCommandRunnerCommand),
125
126    /// Inspect feature flags.
127    Features(FeaturesCli),
128}
129
130#[derive(Debug, Parser)]
131struct CompletionCommand {
132    /// Shell to generate completions for
133    #[clap(value_enum, default_value_t = Shell::Bash)]
134    shell: Shell,
135}
136
137#[derive(Debug, Parser)]
138struct ResumeCommand {
139    /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses.
140    /// If omitted, use --last to pick the most recent recorded session.
141    #[arg(value_name = "SESSION_ID")]
142    session_id: Option<String>,
143
144    /// Continue the most recent session without showing the picker.
145    #[arg(long = "last", default_value_t = false)]
146    last: bool,
147
148    /// Show all sessions (disables cwd filtering and shows CWD column).
149    #[arg(long = "all", default_value_t = false)]
150    all: bool,
151
152    #[clap(flatten)]
153    config_overrides: TuiCli,
154}
155
156#[derive(Debug, Parser)]
157struct ForkCommand {
158    /// Conversation/session id (UUID). When provided, forks this session.
159    /// If omitted, use --last to pick the most recent recorded session.
160    #[arg(value_name = "SESSION_ID")]
161    session_id: Option<String>,
162
163    /// Fork the most recent session without showing the picker.
164    #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
165    last: bool,
166
167    /// Show all sessions (disables cwd filtering and shows CWD column).
168    #[arg(long = "all", default_value_t = false)]
169    all: bool,
170
171    #[clap(flatten)]
172    config_overrides: TuiCli,
173}
174
175#[derive(Debug, Parser)]
176struct SandboxArgs {
177    #[command(subcommand)]
178    cmd: SandboxCommand,
179}
180
181#[derive(Debug, clap::Subcommand)]
182enum SandboxCommand {
183    /// Run a command under Seatbelt (macOS only).
184    #[clap(visible_alias = "seatbelt")]
185    Macos(SeatbeltCommand),
186
187    /// Run a command under Landlock+seccomp (Linux only).
188    #[clap(visible_alias = "landlock")]
189    Linux(LandlockCommand),
190
191    /// Run a command under Windows restricted token (Windows only).
192    Windows(WindowsCommand),
193}
194
195#[derive(Debug, Parser)]
196struct ExecpolicyCommand {
197    #[command(subcommand)]
198    sub: ExecpolicySubcommand,
199}
200
201#[derive(Debug, clap::Subcommand)]
202enum ExecpolicySubcommand {
203    /// Check execpolicy files against a command.
204    #[clap(name = "check")]
205    Check(ExecPolicyCheckCommand),
206}
207
208#[derive(Debug, Parser)]
209struct AppServerCommand {
210    /// Omit to run the app server; specify a subcommand for tooling.
211    #[command(subcommand)]
212    subcommand: Option<AppServerSubcommand>,
213
214    /// Controls whether analytics are enabled by default.
215    ///
216    /// Analytics are disabled by default for app-server. Users have to explicitly opt in
217    /// via the `analytics` section in the config.toml file.
218    ///
219    /// However, for first-party use cases like the VSCode IDE extension, we default analytics
220    /// to be enabled by default by setting this flag. Users can still opt out by setting this
221    /// in their config.toml:
222    ///
223    /// ```toml
224    /// [analytics]
225    /// enabled = false
226    /// ```
227    ///
228    /// See https://developers.openai.com/codex/config-advanced/#metrics for more details.
229    #[arg(long = "analytics-default-enabled")]
230    analytics_default_enabled: bool,
231}
232
233#[derive(Debug, clap::Subcommand)]
234enum AppServerSubcommand {
235    /// [experimental] Generate TypeScript bindings for the app server protocol.
236    GenerateTs(GenerateTsCommand),
237
238    /// [experimental] Generate JSON Schema for the app server protocol.
239    GenerateJsonSchema(GenerateJsonSchemaCommand),
240}
241
242#[derive(Debug, Args)]
243struct GenerateTsCommand {
244    /// Output directory where .ts files will be written
245    #[arg(short = 'o', long = "out", value_name = "DIR")]
246    out_dir: PathBuf,
247
248    /// Optional path to the Prettier executable to format generated files
249    #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
250    prettier: Option<PathBuf>,
251}
252
253#[derive(Debug, Args)]
254struct GenerateJsonSchemaCommand {
255    /// Output directory where the schema bundle will be written
256    #[arg(short = 'o', long = "out", value_name = "DIR")]
257    out_dir: PathBuf,
258}
259
260#[derive(Debug, Parser)]
261struct StdioToUdsCommand {
262    /// Path to the Unix domain socket to connect to.
263    #[arg(value_name = "SOCKET_PATH")]
264    socket_path: PathBuf,
265}
266
267#[derive(Debug, Parser)]
268#[command(disable_help_flag = true, disable_version_flag = true)]
269struct RemovedResponsesProxyArgs {
270    #[arg(
271        value_name = "ARGS",
272        allow_hyphen_values = true,
273        trailing_var_arg = true
274    )]
275    _args: Vec<String>,
276}
277
278#[derive(Debug, Parser)]
279struct DevCommand {
280    #[command(subcommand)]
281    sub: DevSubcommand,
282}
283
284#[derive(Debug, clap::Subcommand)]
285enum DevSubcommand {
286    /// Fuzzy-match files from the command line.
287    FileSearch(crate::product::file_search::Cli),
288    /// Tail logs from the state SQLite database.
289    Logs(crate::product::state::logs_client::Args),
290    /// Write the config.toml JSON schema.
291    WriteConfigSchema(SchemaOutCommand),
292    /// Write the models.json JSON schema.
293    WriteModelsSchema(SchemaOutCommand),
294    /// Write the state.json JSON schema.
295    WriteStateSchema(SchemaOutCommand),
296    /// Regenerate vendored app-server schema artifacts.
297    WriteAppServerSchema(WriteAppServerSchemaCommand),
298}
299
300#[derive(Debug, Args)]
301struct SchemaOutCommand {
302    /// Output path for the generated schema.
303    #[arg(long = "out", value_name = "PATH")]
304    out: Option<PathBuf>,
305}
306
307#[derive(Debug, Args)]
308struct WriteAppServerSchemaCommand {
309    /// Root directory containing `typescript/` and `json/`.
310    #[arg(long = "schema-root", value_name = "DIR")]
311    schema_root: Option<PathBuf>,
312
313    /// Optional path to the Prettier executable to format generated TypeScript files.
314    #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
315    prettier: Option<PathBuf>,
316}
317
318#[cfg(target_os = "windows")]
319#[derive(Debug, Parser)]
320struct WindowsSandboxSetupCommand {
321    payload: String,
322}
323
324#[cfg(target_os = "windows")]
325#[derive(Debug, Parser)]
326struct WindowsCommandRunnerCommand {
327    #[arg(long = "request-file", value_name = "PATH")]
328    request_file: PathBuf,
329}
330
331fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<String> {
332    let AppExitInfo {
333        token_usage,
334        thread_id: conversation_id,
335        thread_name,
336        ..
337    } = exit_info;
338
339    if token_usage.is_zero() {
340        return Vec::new();
341    }
342
343    let mut lines = vec![format!(
344        "{}",
345        crate::product::agent::protocol::FinalOutput::from(token_usage)
346    )];
347
348    if let Some(resume_cmd) =
349        crate::product::agent::util::resume_command(thread_name.as_deref(), conversation_id)
350    {
351        let command = if color_enabled {
352            resume_cmd.cyan().to_string()
353        } else {
354            resume_cmd
355        };
356        lines.push(format!("To continue this session, run {command}"));
357    }
358
359    lines
360}
361
362/// Handle the app exit and print the results. Optionally run the update action.
363fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
364    match exit_info.exit_reason {
365        ExitReason::Fatal(message) => {
366            eprintln!("ERROR: {message}");
367            std::process::exit(1);
368        }
369        ExitReason::UserRequested => { /* normal exit */ }
370    }
371
372    let update_action = exit_info.update_action;
373    let color_enabled = supports_color::on(Stream::Stdout).is_some();
374    for line in format_exit_messages(exit_info, color_enabled) {
375        println!("{line}");
376    }
377    if let Some(action) = update_action {
378        run_update_action(action)?;
379    }
380    Ok(())
381}
382
383/// Run the update action and print the result.
384fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
385    println!();
386    let cmd_str = action.command_str();
387    println!("Updating LHA via `{cmd_str}`...");
388
389    let status = {
390        #[cfg(windows)]
391        {
392            // On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics).
393            std::process::Command::new("cmd")
394                .args(["/C", &cmd_str])
395                .status()?
396        }
397        #[cfg(not(windows))]
398        {
399            let (cmd, args) = action.command_args();
400            let command_path = self::wsl_paths::normalize_for_wsl(cmd);
401            let normalized_args: Vec<String> = args
402                .iter()
403                .map(self::wsl_paths::normalize_for_wsl)
404                .collect();
405            std::process::Command::new(&command_path)
406                .args(&normalized_args)
407                .status()?
408        }
409    };
410    if !status.success() {
411        anyhow::bail!("`{cmd_str}` failed with status {status}");
412    }
413    println!("\n🎉 Update ran successfully! Please restart LHA.");
414    Ok(())
415}
416
417fn run_execpolicycheck(cmd: ExecPolicyCheckCommand) -> anyhow::Result<()> {
418    cmd.run()
419}
420
421#[derive(Debug, Default, Parser, Clone)]
422struct FeatureToggles {
423    /// Enable a feature (repeatable). Equivalent to `-c features.<name>=true`.
424    #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
425    enable: Vec<String>,
426
427    /// Disable a feature (repeatable). Equivalent to `-c features.<name>=false`.
428    #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
429    disable: Vec<String>,
430}
431
432impl FeatureToggles {
433    fn to_overrides(&self) -> anyhow::Result<Vec<String>> {
434        let mut v = Vec::new();
435        for feature in &self.enable {
436            Self::validate_feature(feature)?;
437            v.push(format!("features.{feature}=true"));
438        }
439        for feature in &self.disable {
440            Self::validate_feature(feature)?;
441            v.push(format!("features.{feature}=false"));
442        }
443        Ok(v)
444    }
445
446    fn validate_feature(feature: &str) -> anyhow::Result<()> {
447        if is_known_feature_key(feature) {
448            Ok(())
449        } else {
450            anyhow::bail!("Unknown feature flag: {feature}")
451        }
452    }
453}
454
455#[derive(Debug, Parser)]
456struct FeaturesCli {
457    #[command(subcommand)]
458    sub: FeaturesSubcommand,
459}
460
461#[derive(Debug, Parser)]
462enum FeaturesSubcommand {
463    /// List known features with their stage and effective state.
464    List,
465    /// Enable a feature in config.toml.
466    Enable(FeatureSetArgs),
467    /// Disable a feature in config.toml.
468    Disable(FeatureSetArgs),
469}
470
471#[derive(Debug, Parser)]
472struct FeatureSetArgs {
473    /// Feature key to update (for example: unified_exec).
474    feature: String,
475}
476
477fn stage_str(stage: crate::product::agent::features::Stage) -> &'static str {
478    use crate::product::agent::features::Stage;
479    match stage {
480        Stage::UnderDevelopment => "under development",
481        Stage::Experimental { .. } => "experimental",
482        Stage::Stable => "stable",
483        Stage::Deprecated => "deprecated",
484        Stage::Removed => "removed",
485    }
486}
487
488pub fn main() -> anyhow::Result<()> {
489    arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
490        cli_main(codex_linux_sandbox_exe).await?;
491        Ok(())
492    })
493}
494
495async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
496    let MultitoolCli {
497        config_overrides: mut root_config_overrides,
498        feature_toggles,
499        mut interactive,
500        subcommand,
501    } = MultitoolCli::parse();
502
503    // Fold --enable/--disable into config overrides so they flow to all subcommands.
504    let toggle_overrides = feature_toggles.to_overrides()?;
505    root_config_overrides.raw_overrides.extend(toggle_overrides);
506
507    match subcommand {
508        None => {
509            prepend_config_flags(
510                &mut interactive.config_overrides,
511                root_config_overrides.clone(),
512            );
513            let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
514            handle_app_exit(exit_info)?;
515        }
516        Some(Subcommand::Exec(mut exec_cli)) => {
517            prepend_config_flags(
518                &mut exec_cli.config_overrides,
519                root_config_overrides.clone(),
520            );
521            crate::product::exec_cli::run_main(exec_cli, codex_linux_sandbox_exe).await?;
522        }
523        Some(Subcommand::Review(review_args)) => {
524            let mut exec_cli = ExecCli::try_parse_from(["lha", "exec"])?;
525            exec_cli.command = Some(ExecCommand::Review(review_args));
526            prepend_config_flags(
527                &mut exec_cli.config_overrides,
528                root_config_overrides.clone(),
529            );
530            crate::product::exec_cli::run_main(exec_cli, codex_linux_sandbox_exe).await?;
531        }
532        Some(Subcommand::McpServer) => {
533            crate::product::mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides)
534                .await?;
535        }
536        Some(Subcommand::Mcp(mut mcp_cli)) => {
537            // Propagate any root-level config overrides (e.g. `-c key=value`).
538            prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
539            mcp_cli.run().await?;
540        }
541        Some(Subcommand::AppServer(app_server_cli)) => match app_server_cli.subcommand {
542            None => {
543                crate::product::app_server::run_main(
544                    codex_linux_sandbox_exe,
545                    root_config_overrides,
546                    crate::product::agent::config_loader::LoaderOverrides::default(),
547                    app_server_cli.analytics_default_enabled,
548                )
549                .await?;
550            }
551            Some(AppServerSubcommand::GenerateTs(gen_cli)) => {
552                crate::product::app_server_protocol::generate_ts(
553                    &gen_cli.out_dir,
554                    gen_cli.prettier.as_deref(),
555                )?;
556            }
557            Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => {
558                crate::product::app_server_protocol::generate_json(&gen_cli.out_dir)?;
559            }
560        },
561        Some(Subcommand::Resume(ResumeCommand {
562            session_id,
563            last,
564            all,
565            config_overrides,
566        })) => {
567            interactive = finalize_resume_interactive(
568                interactive,
569                root_config_overrides.clone(),
570                session_id,
571                last,
572                all,
573                config_overrides,
574            );
575            let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
576            handle_app_exit(exit_info)?;
577        }
578        Some(Subcommand::Fork(ForkCommand {
579            session_id,
580            last,
581            all,
582            config_overrides,
583        })) => {
584            interactive = finalize_fork_interactive(
585                interactive,
586                root_config_overrides.clone(),
587                session_id,
588                last,
589                all,
590                config_overrides,
591            );
592            let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
593            handle_app_exit(exit_info)?;
594        }
595        Some(Subcommand::Completion(completion_cli)) => {
596            print_completion(completion_cli);
597        }
598        Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd {
599            SandboxCommand::Macos(mut seatbelt_cli) => {
600                prepend_config_flags(
601                    &mut seatbelt_cli.config_overrides,
602                    root_config_overrides.clone(),
603                );
604                crate::debug_sandbox::run_command_under_seatbelt(
605                    seatbelt_cli,
606                    codex_linux_sandbox_exe,
607                )
608                .await?;
609            }
610            SandboxCommand::Linux(mut landlock_cli) => {
611                prepend_config_flags(
612                    &mut landlock_cli.config_overrides,
613                    root_config_overrides.clone(),
614                );
615                crate::debug_sandbox::run_command_under_landlock(
616                    landlock_cli,
617                    codex_linux_sandbox_exe,
618                )
619                .await?;
620            }
621            SandboxCommand::Windows(mut windows_cli) => {
622                prepend_config_flags(
623                    &mut windows_cli.config_overrides,
624                    root_config_overrides.clone(),
625                );
626                crate::debug_sandbox::run_command_under_windows(
627                    windows_cli,
628                    codex_linux_sandbox_exe,
629                )
630                .await?;
631            }
632        },
633        Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub {
634            ExecpolicySubcommand::Check(cmd) => run_execpolicycheck(cmd)?,
635        },
636        Some(Subcommand::RemovedResponsesApiProxy(_)) => {
637            exit_removed_responses_api_proxy_subcommand();
638        }
639        Some(Subcommand::StdioToUds(cmd)) => {
640            let socket_path = cmd.socket_path;
641            tokio::task::spawn_blocking(move || {
642                crate::product::stdio_to_uds::run(socket_path.as_path())
643            })
644            .await??;
645        }
646        Some(Subcommand::Dev(dev)) => run_dev_command(dev).await?,
647        #[cfg(target_os = "windows")]
648        Some(Subcommand::WindowsSandboxSetup(WindowsSandboxSetupCommand { payload })) => {
649            crate::product::windows_sandbox::run_setup_helper_main(payload)?;
650        }
651        #[cfg(target_os = "windows")]
652        Some(Subcommand::WindowsCommandRunner(WindowsCommandRunnerCommand { request_file })) => {
653            crate::product::windows_sandbox::run_command_runner_helper_main(request_file)?;
654        }
655        Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
656            FeaturesSubcommand::List => {
657                // Respect root-level `-c` overrides plus top-level flags like `--profile`.
658                let mut cli_kv_overrides = root_config_overrides
659                    .parse_overrides()
660                    .map_err(anyhow::Error::msg)?;
661
662                // Honor `--search` via the canonical web_search mode.
663                if interactive.web_search {
664                    cli_kv_overrides.push((
665                        "web_search".to_string(),
666                        toml::Value::String("live".to_string()),
667                    ));
668                }
669
670                // Thread through relevant top-level flags (at minimum, `--profile`).
671                let overrides = ConfigOverrides {
672                    config_profile: interactive.config_profile.clone(),
673                    ..Default::default()
674                };
675
676                let config = Config::load_with_cli_overrides_and_harness_overrides(
677                    cli_kv_overrides,
678                    overrides,
679                )
680                .await?;
681                let mut rows = Vec::with_capacity(crate::product::agent::features::FEATURES.len());
682                let mut name_width = 0;
683                let mut stage_width = 0;
684                for def in crate::product::agent::features::FEATURES.iter() {
685                    let name = def.key;
686                    let stage = stage_str(def.stage);
687                    let enabled = config.features.enabled(def.id);
688                    name_width = name_width.max(name.len());
689                    stage_width = stage_width.max(stage.len());
690                    rows.push((name, stage, enabled));
691                }
692
693                for (name, stage, enabled) in rows {
694                    println!("{name:<name_width$}  {stage:<stage_width$}  {enabled}");
695                }
696            }
697            FeaturesSubcommand::Enable(FeatureSetArgs { feature }) => {
698                enable_feature_in_config(&interactive, &feature).await?;
699            }
700            FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => {
701                disable_feature_in_config(&interactive, &feature).await?;
702            }
703        },
704    }
705
706    Ok(())
707}
708
709fn exit_removed_responses_api_proxy_subcommand() -> ! {
710    MultitoolCli::command()
711        .error(
712            clap::error::ErrorKind::InvalidSubcommand,
713            "unrecognized subcommand 'responses-api-proxy'",
714        )
715        .exit()
716}
717
718async fn run_dev_command(dev: DevCommand) -> anyhow::Result<()> {
719    match dev.sub {
720        DevSubcommand::FileSearch(cli) => {
721            crate::product::file_search::run_cli(cli).await?;
722        }
723        DevSubcommand::Logs(args) => {
724            crate::product::state::logs_client::run(args).await?;
725        }
726        DevSubcommand::WriteConfigSchema(cmd) => {
727            let out = cmd.out.unwrap_or_else(|| {
728                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
729                    .join("product/agent_runtime/config.schema.json")
730            });
731            crate::product::agent::config::schema::write_config_schema(&out)?;
732        }
733        DevSubcommand::WriteModelsSchema(cmd) => {
734            let out = cmd.out.unwrap_or_else(|| {
735                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
736                    .join("product/agent_runtime/models.schema.json")
737            });
738            crate::product::agent::config::schema::write_models_schema(&out)?;
739        }
740        DevSubcommand::WriteStateSchema(cmd) => {
741            let out = cmd.out.unwrap_or_else(|| {
742                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
743                    .join("product/agent_runtime/state.schema.json")
744            });
745            crate::product::agent::config::schema::write_state_schema(&out)?;
746        }
747        DevSubcommand::WriteAppServerSchema(cmd) => {
748            let schema_root = cmd.schema_root.unwrap_or_else(|| {
749                PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("product/app_server_protocol/schema")
750            });
751            crate::product::app_server_protocol::write_schema_fixtures(
752                &schema_root,
753                cmd.prettier.as_deref(),
754            )?;
755        }
756    }
757    Ok(())
758}
759
760async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
761    FeatureToggles::validate_feature(feature)?;
762    let lha_home = find_lha_home()?;
763    ConfigEditsBuilder::new(&lha_home)
764        .with_profile(interactive.config_profile.as_deref())
765        .set_feature_enabled(feature, true)
766        .apply()
767        .await?;
768    println!("Enabled feature `{feature}` in config.toml.");
769    maybe_print_under_development_feature_warning(&lha_home, interactive, feature);
770    Ok(())
771}
772
773async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
774    FeatureToggles::validate_feature(feature)?;
775    let lha_home = find_lha_home()?;
776    ConfigEditsBuilder::new(&lha_home)
777        .with_profile(interactive.config_profile.as_deref())
778        .set_feature_enabled(feature, false)
779        .apply()
780        .await?;
781    println!("Disabled feature `{feature}` in config.toml.");
782    Ok(())
783}
784
785fn maybe_print_under_development_feature_warning(
786    lha_home: &std::path::Path,
787    interactive: &TuiCli,
788    feature: &str,
789) {
790    if interactive.config_profile.is_some() {
791        return;
792    }
793
794    let Some(spec) = crate::product::agent::features::FEATURES
795        .iter()
796        .find(|spec| spec.key == feature)
797    else {
798        return;
799    };
800    if !matches!(spec.stage, Stage::UnderDevelopment) {
801        return;
802    }
803
804    let config_path = lha_home.join(crate::product::agent::config::CONFIG_TOML_FILE);
805    eprintln!(
806        "Under-development features enabled: {feature}. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in {}.",
807        config_path.display()
808    );
809}
810
811/// Prepend root-level overrides so they have lower precedence than
812/// CLI-specific ones specified after the subcommand (if any).
813fn prepend_config_flags(
814    subcommand_config_overrides: &mut CliConfigOverrides,
815    cli_config_overrides: CliConfigOverrides,
816) {
817    subcommand_config_overrides
818        .raw_overrides
819        .splice(0..0, cli_config_overrides.raw_overrides);
820}
821
822async fn run_interactive_tui(
823    mut interactive: TuiCli,
824    codex_linux_sandbox_exe: Option<PathBuf>,
825) -> std::io::Result<AppExitInfo> {
826    if let Some(prompt) = interactive.prompt.take() {
827        // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state.
828        interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n"));
829    }
830
831    let terminal_info = crate::product::agent::terminal::terminal_info();
832    if terminal_info.name == TerminalName::Dumb {
833        if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
834            return Ok(AppExitInfo::fatal(
835                "TERM is set to \"dumb\". Refusing to start the interactive TUI because no terminal is available for a confirmation prompt (stdin/stderr is not a TTY). Run in a supported terminal or unset TERM.",
836            ));
837        }
838
839        eprintln!(
840            "WARNING: TERM is set to \"dumb\". LHA's interactive TUI may not work in this terminal."
841        );
842        if !confirm("Continue anyway? [y/N]: ")? {
843            return Ok(AppExitInfo::fatal(
844                "Refusing to start the interactive TUI because TERM is set to \"dumb\". Run in a supported terminal or unset TERM.",
845            ));
846        }
847    }
848
849    crate::product::tui_app::run_main(interactive, codex_linux_sandbox_exe).await
850}
851
852fn confirm(prompt: &str) -> std::io::Result<bool> {
853    eprintln!("{prompt}");
854
855    let mut input = String::new();
856    std::io::stdin().read_line(&mut input)?;
857    let answer = input.trim();
858    Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
859}
860
861/// Build the final `TuiCli` for a `codex resume` invocation.
862fn finalize_resume_interactive(
863    mut interactive: TuiCli,
864    root_config_overrides: CliConfigOverrides,
865    session_id: Option<String>,
866    last: bool,
867    show_all: bool,
868    resume_cli: TuiCli,
869) -> TuiCli {
870    // Start with the parsed interactive CLI so resume shares the same
871    // configuration surface area as `codex` without additional flags.
872    let resume_session_id = session_id;
873    interactive.resume_picker = resume_session_id.is_none() && !last;
874    interactive.resume_last = last;
875    interactive.resume_session_id = resume_session_id;
876    interactive.resume_show_all = show_all;
877
878    // Merge resume-scoped flags and overrides with highest precedence.
879    merge_interactive_cli_flags(&mut interactive, resume_cli);
880
881    // Propagate any root-level config overrides (e.g. `-c key=value`).
882    prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
883
884    interactive
885}
886
887/// Build the final `TuiCli` for a `codex fork` invocation.
888fn finalize_fork_interactive(
889    mut interactive: TuiCli,
890    root_config_overrides: CliConfigOverrides,
891    session_id: Option<String>,
892    last: bool,
893    show_all: bool,
894    fork_cli: TuiCli,
895) -> TuiCli {
896    // Start with the parsed interactive CLI so fork shares the same
897    // configuration surface area as `codex` without additional flags.
898    let fork_session_id = session_id;
899    interactive.fork_picker = fork_session_id.is_none() && !last;
900    interactive.fork_last = last;
901    interactive.fork_session_id = fork_session_id;
902    interactive.fork_show_all = show_all;
903
904    // Merge fork-scoped flags and overrides with highest precedence.
905    merge_interactive_cli_flags(&mut interactive, fork_cli);
906
907    // Propagate any root-level config overrides (e.g. `-c key=value`).
908    prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
909
910    interactive
911}
912
913/// Merge flags provided to `codex resume`/`codex fork` so they take precedence over any
914/// root-level flags. Only overrides fields explicitly set on the subcommand-scoped
915/// CLI. Also appends `-c key=value` overrides with highest precedence.
916fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) {
917    if let Some(model) = subcommand_cli.model {
918        interactive.model = Some(model);
919    }
920    if let Some(profile) = subcommand_cli.config_profile {
921        interactive.config_profile = Some(profile);
922    }
923    if let Some(sandbox) = subcommand_cli.sandbox_mode {
924        interactive.sandbox_mode = Some(sandbox);
925    }
926    if let Some(approval) = subcommand_cli.approval_policy {
927        interactive.approval_policy = Some(approval);
928    }
929    if subcommand_cli.full_auto {
930        interactive.full_auto = true;
931    }
932    if subcommand_cli.dangerously_bypass_approvals_and_sandbox {
933        interactive.dangerously_bypass_approvals_and_sandbox = true;
934    }
935    if subcommand_cli.mouse_capture {
936        interactive.mouse_capture = true;
937        interactive.no_mouse_capture = false;
938    }
939    if subcommand_cli.no_mouse_capture {
940        interactive.no_mouse_capture = true;
941        interactive.mouse_capture = false;
942    }
943    if let Some(cwd) = subcommand_cli.cwd {
944        interactive.cwd = Some(cwd);
945    }
946    if subcommand_cli.web_search {
947        interactive.web_search = true;
948    }
949    if !subcommand_cli.images.is_empty() {
950        interactive.images = subcommand_cli.images;
951    }
952    if !subcommand_cli.add_dir.is_empty() {
953        interactive.add_dir.extend(subcommand_cli.add_dir);
954    }
955    if let Some(prompt) = subcommand_cli.prompt {
956        // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state.
957        interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n"));
958    }
959
960    interactive
961        .config_overrides
962        .raw_overrides
963        .extend(subcommand_cli.config_overrides.raw_overrides);
964}
965
966fn print_completion(cmd: CompletionCommand) {
967    let mut app = MultitoolCli::command();
968    let name = "lha";
969    generate(cmd.shell, &mut app, name, &mut std::io::stdout());
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975    use crate::product::agent::protocol::TokenUsage;
976    use crate::product::protocol::ThreadId;
977    use assert_matches::assert_matches;
978    use pretty_assertions::assert_eq;
979
980    fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
981        let cli = MultitoolCli::try_parse_from(args).expect("parse");
982        let MultitoolCli {
983            interactive,
984            config_overrides: root_overrides,
985            subcommand,
986            feature_toggles: _,
987        } = cli;
988
989        let Subcommand::Resume(ResumeCommand {
990            session_id,
991            last,
992            all,
993            config_overrides: resume_cli,
994        }) = subcommand.expect("resume present")
995        else {
996            unreachable!()
997        };
998
999        finalize_resume_interactive(
1000            interactive,
1001            root_overrides,
1002            session_id,
1003            last,
1004            all,
1005            resume_cli,
1006        )
1007    }
1008
1009    fn finalize_fork_from_args(args: &[&str]) -> TuiCli {
1010        let cli = MultitoolCli::try_parse_from(args).expect("parse");
1011        let MultitoolCli {
1012            interactive,
1013            config_overrides: root_overrides,
1014            subcommand,
1015            feature_toggles: _,
1016        } = cli;
1017
1018        let Subcommand::Fork(ForkCommand {
1019            session_id,
1020            last,
1021            all,
1022            config_overrides: fork_cli,
1023        }) = subcommand.expect("fork present")
1024        else {
1025            unreachable!()
1026        };
1027
1028        finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli)
1029    }
1030
1031    #[test]
1032    fn exec_resume_last_accepts_prompt_positional() {
1033        let cli =
1034            MultitoolCli::try_parse_from(["lha", "exec", "--json", "resume", "--last", "2+2"])
1035                .expect("parse should succeed");
1036
1037        let Some(Subcommand::Exec(exec)) = cli.subcommand else {
1038            panic!("expected exec subcommand");
1039        };
1040        let Some(crate::product::exec_cli::Command::Resume(args)) = exec.command else {
1041            panic!("expected exec resume");
1042        };
1043
1044        assert!(args.last);
1045        assert_eq!(args.session_id, None);
1046        assert_eq!(args.prompt.as_deref(), Some("2+2"));
1047    }
1048
1049    fn app_server_from_args(args: &[&str]) -> AppServerCommand {
1050        let cli = MultitoolCli::try_parse_from(args).expect("parse");
1051        let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else {
1052            unreachable!()
1053        };
1054        app_server
1055    }
1056
1057    #[test]
1058    fn removed_responses_proxy_parses_as_tombstone_after_global_config_flag() {
1059        let cli = MultitoolCli::try_parse_from([
1060            "lha",
1061            "-c",
1062            "model=gpt-5.1",
1063            "responses-api-proxy",
1064            "--help",
1065        ])
1066        .expect("removed proxy tombstone should parse before rejection");
1067
1068        assert_matches!(
1069            cli.subcommand,
1070            Some(Subcommand::RemovedResponsesApiProxy(_))
1071        );
1072    }
1073
1074    #[test]
1075    fn removed_responses_proxy_parses_as_tombstone_without_global_flags() {
1076        let cli = MultitoolCli::try_parse_from([
1077            "lha",
1078            "responses-api-proxy",
1079            "--upstream-url",
1080            "http://example.invalid",
1081        ])
1082        .expect("removed proxy tombstone should parse before rejection");
1083
1084        assert_matches!(
1085            cli.subcommand,
1086            Some(Subcommand::RemovedResponsesApiProxy(_))
1087        );
1088    }
1089
1090    fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
1091        let token_usage = TokenUsage {
1092            output_tokens: 2,
1093            total_tokens: 2,
1094            ..Default::default()
1095        };
1096        AppExitInfo {
1097            token_usage,
1098            thread_id: conversation_id
1099                .map(ThreadId::from_string)
1100                .map(Result::unwrap),
1101            thread_name: thread_name.map(str::to_string),
1102            update_action: None,
1103            exit_reason: ExitReason::UserRequested,
1104        }
1105    }
1106
1107    #[test]
1108    fn format_exit_messages_skips_zero_usage() {
1109        let exit_info = AppExitInfo {
1110            token_usage: TokenUsage::default(),
1111            thread_id: None,
1112            thread_name: None,
1113            update_action: None,
1114            exit_reason: ExitReason::UserRequested,
1115        };
1116        let lines = format_exit_messages(exit_info, false);
1117        assert!(lines.is_empty());
1118    }
1119
1120    #[test]
1121    fn format_exit_messages_includes_resume_hint_without_color() {
1122        let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None);
1123        let lines = format_exit_messages(exit_info, false);
1124        assert_eq!(
1125            lines,
1126            vec![
1127                "Token usage: total=2 input=0 output=2".to_string(),
1128                "To continue this session, run lha resume 123e4567-e89b-12d3-a456-426614174000"
1129                    .to_string(),
1130            ]
1131        );
1132    }
1133
1134    #[test]
1135    fn format_exit_messages_applies_color_when_enabled() {
1136        let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None);
1137        let lines = format_exit_messages(exit_info, true);
1138        assert_eq!(lines.len(), 2);
1139        assert!(lines[1].contains("\u{1b}[36m"));
1140    }
1141
1142    #[test]
1143    fn format_exit_messages_prefers_thread_name() {
1144        let exit_info = sample_exit_info(
1145            Some("123e4567-e89b-12d3-a456-426614174000"),
1146            Some("my-thread"),
1147        );
1148        let lines = format_exit_messages(exit_info, false);
1149        assert_eq!(
1150            lines,
1151            vec![
1152                "Token usage: total=2 input=0 output=2".to_string(),
1153                "To continue this session, run lha resume my-thread".to_string(),
1154            ]
1155        );
1156    }
1157
1158    #[test]
1159    fn resume_model_flag_applies_when_no_root_flags() {
1160        let interactive =
1161            finalize_resume_from_args(["lha", "resume", "-m", "gpt-5.1-test"].as_ref());
1162
1163        assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
1164        assert!(interactive.resume_picker);
1165        assert!(!interactive.resume_last);
1166        assert_eq!(interactive.resume_session_id, None);
1167    }
1168
1169    #[test]
1170    fn resume_picker_logic_none_and_not_last() {
1171        let interactive = finalize_resume_from_args(["lha", "resume"].as_ref());
1172        assert!(interactive.resume_picker);
1173        assert!(!interactive.resume_last);
1174        assert_eq!(interactive.resume_session_id, None);
1175        assert!(!interactive.resume_show_all);
1176    }
1177
1178    #[test]
1179    fn resume_picker_logic_last() {
1180        let interactive = finalize_resume_from_args(["lha", "resume", "--last"].as_ref());
1181        assert!(!interactive.resume_picker);
1182        assert!(interactive.resume_last);
1183        assert_eq!(interactive.resume_session_id, None);
1184        assert!(!interactive.resume_show_all);
1185    }
1186
1187    #[test]
1188    fn resume_picker_logic_with_session_id() {
1189        let interactive = finalize_resume_from_args(["lha", "resume", "1234"].as_ref());
1190        assert!(!interactive.resume_picker);
1191        assert!(!interactive.resume_last);
1192        assert_eq!(interactive.resume_session_id.as_deref(), Some("1234"));
1193        assert!(!interactive.resume_show_all);
1194    }
1195
1196    #[test]
1197    fn resume_all_flag_sets_show_all() {
1198        let interactive = finalize_resume_from_args(["lha", "resume", "--all"].as_ref());
1199        assert!(interactive.resume_picker);
1200        assert!(interactive.resume_show_all);
1201    }
1202
1203    #[test]
1204    fn resume_merges_option_flags_and_full_auto() {
1205        let interactive = finalize_resume_from_args(
1206            [
1207                "lha",
1208                "resume",
1209                "sid",
1210                "--full-auto",
1211                "--search",
1212                "--sandbox",
1213                "workspace-write",
1214                "--ask-for-approval",
1215                "on-request",
1216                "-m",
1217                "gpt-5.1-test",
1218                "-p",
1219                "my-profile",
1220                "-C",
1221                "/tmp",
1222                "-i",
1223                "/tmp/a.png,/tmp/b.png",
1224            ]
1225            .as_ref(),
1226        );
1227
1228        assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
1229        assert_eq!(interactive.config_profile.as_deref(), Some("my-profile"));
1230        assert_matches!(
1231            interactive.sandbox_mode,
1232            Some(crate::product::common::SandboxModeCliArg::WorkspaceWrite)
1233        );
1234        assert_matches!(
1235            interactive.approval_policy,
1236            Some(crate::product::common::ApprovalModeCliArg::OnRequest)
1237        );
1238        assert!(interactive.full_auto);
1239        assert_eq!(
1240            interactive.cwd.as_deref(),
1241            Some(std::path::Path::new("/tmp"))
1242        );
1243        assert!(interactive.web_search);
1244        let has_a = interactive
1245            .images
1246            .iter()
1247            .any(|p| p == std::path::Path::new("/tmp/a.png"));
1248        let has_b = interactive
1249            .images
1250            .iter()
1251            .any(|p| p == std::path::Path::new("/tmp/b.png"));
1252        assert!(has_a && has_b);
1253        assert!(!interactive.resume_picker);
1254        assert!(!interactive.resume_last);
1255        assert_eq!(interactive.resume_session_id.as_deref(), Some("sid"));
1256    }
1257
1258    #[test]
1259    fn resume_merges_dangerously_bypass_flag() {
1260        let interactive = finalize_resume_from_args(
1261            [
1262                "lha",
1263                "resume",
1264                "--dangerously-bypass-approvals-and-sandbox",
1265            ]
1266            .as_ref(),
1267        );
1268        assert!(interactive.dangerously_bypass_approvals_and_sandbox);
1269        assert!(interactive.resume_picker);
1270        assert!(!interactive.resume_last);
1271        assert_eq!(interactive.resume_session_id, None);
1272    }
1273
1274    #[test]
1275    fn resume_merges_no_mouse_capture_flag() {
1276        let interactive =
1277            finalize_resume_from_args(["lha", "resume", "--no-mouse-capture"].as_ref());
1278
1279        assert!(interactive.no_mouse_capture);
1280        assert!(!interactive.mouse_capture);
1281        assert!(interactive.resume_picker);
1282    }
1283
1284    #[test]
1285    fn resume_mouse_capture_flag_overrides_root_no_mouse_capture() {
1286        let interactive = finalize_resume_from_args(
1287            ["lha", "--no-mouse-capture", "resume", "--mouse-capture"].as_ref(),
1288        );
1289
1290        assert!(interactive.mouse_capture);
1291        assert!(!interactive.no_mouse_capture);
1292        assert!(interactive.resume_picker);
1293    }
1294
1295    #[test]
1296    fn fork_picker_logic_none_and_not_last() {
1297        let interactive = finalize_fork_from_args(["lha", "fork"].as_ref());
1298        assert!(interactive.fork_picker);
1299        assert!(!interactive.fork_last);
1300        assert_eq!(interactive.fork_session_id, None);
1301        assert!(!interactive.fork_show_all);
1302    }
1303
1304    #[test]
1305    fn fork_picker_logic_last() {
1306        let interactive = finalize_fork_from_args(["lha", "fork", "--last"].as_ref());
1307        assert!(!interactive.fork_picker);
1308        assert!(interactive.fork_last);
1309        assert_eq!(interactive.fork_session_id, None);
1310        assert!(!interactive.fork_show_all);
1311    }
1312
1313    #[test]
1314    fn fork_picker_logic_with_session_id() {
1315        let interactive = finalize_fork_from_args(["lha", "fork", "1234"].as_ref());
1316        assert!(!interactive.fork_picker);
1317        assert!(!interactive.fork_last);
1318        assert_eq!(interactive.fork_session_id.as_deref(), Some("1234"));
1319        assert!(!interactive.fork_show_all);
1320    }
1321
1322    #[test]
1323    fn fork_all_flag_sets_show_all() {
1324        let interactive = finalize_fork_from_args(["lha", "fork", "--all"].as_ref());
1325        assert!(interactive.fork_picker);
1326        assert!(interactive.fork_show_all);
1327    }
1328
1329    #[test]
1330    fn fork_merges_mouse_capture_flag() {
1331        let interactive = finalize_fork_from_args(["lha", "fork", "--mouse-capture"].as_ref());
1332
1333        assert!(interactive.mouse_capture);
1334        assert!(!interactive.no_mouse_capture);
1335        assert!(interactive.fork_picker);
1336    }
1337
1338    #[test]
1339    fn fork_no_mouse_capture_flag_overrides_root_mouse_capture() {
1340        let interactive = finalize_fork_from_args(
1341            ["lha", "--mouse-capture", "fork", "--no-mouse-capture"].as_ref(),
1342        );
1343
1344        assert!(interactive.no_mouse_capture);
1345        assert!(!interactive.mouse_capture);
1346        assert!(interactive.fork_picker);
1347    }
1348
1349    #[test]
1350    fn app_server_analytics_default_disabled_without_flag() {
1351        let app_server = app_server_from_args(["lha", "app-server"].as_ref());
1352        assert!(!app_server.analytics_default_enabled);
1353    }
1354
1355    #[test]
1356    fn app_server_analytics_default_enabled_with_flag() {
1357        let app_server =
1358            app_server_from_args(["lha", "app-server", "--analytics-default-enabled"].as_ref());
1359        assert!(app_server.analytics_default_enabled);
1360    }
1361
1362    #[test]
1363    fn features_enable_parses_feature_name() {
1364        let cli = MultitoolCli::try_parse_from(["lha", "features", "enable", "unified_exec"])
1365            .expect("parse should succeed");
1366        let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else {
1367            panic!("expected features subcommand");
1368        };
1369        let FeaturesSubcommand::Enable(FeatureSetArgs { feature }) = sub else {
1370            panic!("expected features enable");
1371        };
1372        assert_eq!(feature, "unified_exec");
1373    }
1374
1375    #[test]
1376    fn features_disable_parses_feature_name() {
1377        let cli = MultitoolCli::try_parse_from(["lha", "features", "disable", "shell_tool"])
1378            .expect("parse should succeed");
1379        let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else {
1380            panic!("expected features subcommand");
1381        };
1382        let FeaturesSubcommand::Disable(FeatureSetArgs { feature }) = sub else {
1383            panic!("expected features disable");
1384        };
1385        assert_eq!(feature, "shell_tool");
1386    }
1387
1388    #[test]
1389    fn feature_toggles_known_features_generate_overrides() {
1390        let toggles = FeatureToggles {
1391            enable: vec!["web_search_request".to_string()],
1392            disable: vec!["unified_exec".to_string()],
1393        };
1394        let overrides = toggles.to_overrides().expect("valid features");
1395        assert_eq!(
1396            overrides,
1397            vec![
1398                "features.web_search_request=true".to_string(),
1399                "features.unified_exec=false".to_string(),
1400            ]
1401        );
1402    }
1403
1404    #[test]
1405    fn feature_toggles_unknown_feature_errors() {
1406        let toggles = FeatureToggles {
1407            enable: vec!["does_not_exist".to_string()],
1408            disable: Vec::new(),
1409        };
1410        let err = toggles
1411            .to_overrides()
1412            .expect_err("feature should be rejected");
1413        assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
1414    }
1415
1416    #[test]
1417    fn single_binary_compat_dev_file_search_parses() {
1418        let cli = match MultitoolCli::try_parse_from([
1419            "lha",
1420            "dev",
1421            "file-search",
1422            "--limit",
1423            "5",
1424            "foo",
1425        ]) {
1426            Ok(cli) => cli,
1427            Err(err) => panic!("parse should succeed: {err}"),
1428        };
1429        let Some(Subcommand::Dev(DevCommand {
1430            sub: DevSubcommand::FileSearch(file_search),
1431        })) = cli.subcommand
1432        else {
1433            panic!("expected dev file-search subcommand");
1434        };
1435
1436        assert_eq!(file_search.limit.get(), 5);
1437        assert_eq!(file_search.pattern.as_deref(), Some("foo"));
1438    }
1439
1440    #[test]
1441    fn single_binary_compat_dev_logs_parses() {
1442        let cli = match MultitoolCli::try_parse_from([
1443            "lha",
1444            "dev",
1445            "logs",
1446            "--backfill",
1447            "10",
1448            "--threadless",
1449        ]) {
1450            Ok(cli) => cli,
1451            Err(err) => panic!("parse should succeed: {err}"),
1452        };
1453        let Some(Subcommand::Dev(DevCommand {
1454            sub: DevSubcommand::Logs(logs),
1455        })) = cli.subcommand
1456        else {
1457            panic!("expected dev logs subcommand");
1458        };
1459
1460        assert_eq!(logs.backfill, 10);
1461        assert!(logs.threadless);
1462    }
1463}