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#[derive(Debug, Parser)]
44#[clap(
45 author,
46 version,
47 subcommand_negates_reqs = true,
49 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 #[clap(visible_alias = "e")]
73 Exec(ExecCli),
74
75 Review(ReviewArgs),
77
78 Mcp(McpCli),
80
81 McpServer,
83
84 AppServer(AppServerCommand),
86
87 Completion(CompletionCommand),
89
90 #[clap(visible_alias = "debug")]
92 Sandbox(SandboxArgs),
93
94 #[clap(hide = true)]
96 Execpolicy(ExecpolicyCommand),
97
98 Resume(ResumeCommand),
100
101 Fork(ForkCommand),
103
104 #[clap(hide = true, name = "responses-api-proxy")]
106 RemovedResponsesApiProxy(RemovedResponsesProxyArgs),
107
108 #[clap(hide = true, name = "stdio-to-uds")]
110 StdioToUds(StdioToUdsCommand),
111
112 #[clap(hide = true)]
114 Dev(DevCommand),
115
116 #[cfg(target_os = "windows")]
118 #[clap(hide = true, name = "__windows-sandbox-setup")]
119 WindowsSandboxSetup(WindowsSandboxSetupCommand),
120
121 #[cfg(target_os = "windows")]
123 #[clap(hide = true, name = "__windows-command-runner")]
124 WindowsCommandRunner(WindowsCommandRunnerCommand),
125
126 Features(FeaturesCli),
128}
129
130#[derive(Debug, Parser)]
131struct CompletionCommand {
132 #[clap(value_enum, default_value_t = Shell::Bash)]
134 shell: Shell,
135}
136
137#[derive(Debug, Parser)]
138struct ResumeCommand {
139 #[arg(value_name = "SESSION_ID")]
142 session_id: Option<String>,
143
144 #[arg(long = "last", default_value_t = false)]
146 last: bool,
147
148 #[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 #[arg(value_name = "SESSION_ID")]
161 session_id: Option<String>,
162
163 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
165 last: bool,
166
167 #[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 #[clap(visible_alias = "seatbelt")]
185 Macos(SeatbeltCommand),
186
187 #[clap(visible_alias = "landlock")]
189 Linux(LandlockCommand),
190
191 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 #[clap(name = "check")]
205 Check(ExecPolicyCheckCommand),
206}
207
208#[derive(Debug, Parser)]
209struct AppServerCommand {
210 #[command(subcommand)]
212 subcommand: Option<AppServerSubcommand>,
213
214 #[arg(long = "analytics-default-enabled")]
230 analytics_default_enabled: bool,
231}
232
233#[derive(Debug, clap::Subcommand)]
234enum AppServerSubcommand {
235 GenerateTs(GenerateTsCommand),
237
238 GenerateJsonSchema(GenerateJsonSchemaCommand),
240}
241
242#[derive(Debug, Args)]
243struct GenerateTsCommand {
244 #[arg(short = 'o', long = "out", value_name = "DIR")]
246 out_dir: PathBuf,
247
248 #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
250 prettier: Option<PathBuf>,
251}
252
253#[derive(Debug, Args)]
254struct GenerateJsonSchemaCommand {
255 #[arg(short = 'o', long = "out", value_name = "DIR")]
257 out_dir: PathBuf,
258}
259
260#[derive(Debug, Parser)]
261struct StdioToUdsCommand {
262 #[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 FileSearch(crate::product::file_search::Cli),
288 Logs(crate::product::state::logs_client::Args),
290 WriteConfigSchema(SchemaOutCommand),
292 WriteModelsSchema(SchemaOutCommand),
294 WriteStateSchema(SchemaOutCommand),
296 WriteAppServerSchema(WriteAppServerSchemaCommand),
298}
299
300#[derive(Debug, Args)]
301struct SchemaOutCommand {
302 #[arg(long = "out", value_name = "PATH")]
304 out: Option<PathBuf>,
305}
306
307#[derive(Debug, Args)]
308struct WriteAppServerSchemaCommand {
309 #[arg(long = "schema-root", value_name = "DIR")]
311 schema_root: Option<PathBuf>,
312
313 #[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
362fn 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 => { }
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
383fn 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 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 #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
425 enable: Vec<String>,
426
427 #[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,
465 Enable(FeatureSetArgs),
467 Disable(FeatureSetArgs),
469}
470
471#[derive(Debug, Parser)]
472struct FeatureSetArgs {
473 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 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 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 let mut cli_kv_overrides = root_config_overrides
659 .parse_overrides()
660 .map_err(anyhow::Error::msg)?;
661
662 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 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
811fn 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 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
861fn 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 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_interactive_cli_flags(&mut interactive, resume_cli);
880
881 prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
883
884 interactive
885}
886
887fn 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 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_interactive_cli_flags(&mut interactive, fork_cli);
906
907 prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
909
910 interactive
911}
912
913fn 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 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}