use crate::LandlockCommand;
use crate::SeatbeltCommand;
use crate::WindowsCommand;
use crate::product::arg0::arg0_dispatch_or_else;
use crate::product::common::CliConfigOverrides;
use crate::product::exec_cli::Cli as ExecCli;
use crate::product::exec_cli::Command as ExecCommand;
use crate::product::exec_cli::ReviewArgs;
use crate::product::execpolicy::ExecPolicyCheckCommand;
use crate::product::tui_app::AppExitInfo;
use crate::product::tui_app::Cli as TuiCli;
use crate::product::tui_app::ExitReason;
use crate::product::tui_app::update_action::UpdateAction;
use clap::Args;
use clap::CommandFactory;
use clap::Parser;
use clap_complete::Shell;
use clap_complete::generate;
use owo_colors::OwoColorize;
use std::io::IsTerminal;
use std::path::PathBuf;
use supports_color::Stream;
#[path = "mcp_cmd.rs"]
mod mcp_cmd;
#[cfg(not(windows))]
#[path = "wsl_paths.rs"]
mod wsl_paths;
use self::mcp_cmd::McpCli;
use crate::product::agent::config::Config;
use crate::product::agent::config::ConfigOverrides;
use crate::product::agent::config::edit::ConfigEditsBuilder;
use crate::product::agent::config::find_lha_home;
use crate::product::agent::features::Stage;
use crate::product::agent::features::is_known_feature_key;
use crate::product::agent::terminal::TerminalName;
#[derive(Debug, Parser)]
#[clap(
author,
version,
// If a sub‑command is given, ignore requirements of the default args.
subcommand_negates_reqs = true,
// The executable is sometimes invoked via a platform‑specific name like
// `codex-x86_64-unknown-linux-musl`, but the help output should always use
// the generic `lha` command name that users run.
bin_name = "lha",
override_usage = "lha [OPTIONS] [PROMPT]\n lha [OPTIONS] <COMMAND> [ARGS]"
)]
struct MultitoolCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[clap(flatten)]
pub feature_toggles: FeatureToggles,
#[clap(flatten)]
interactive: TuiCli,
#[clap(subcommand)]
subcommand: Option<Subcommand>,
}
#[derive(Debug, clap::Subcommand)]
enum Subcommand {
#[clap(visible_alias = "e")]
Exec(ExecCli),
Review(ReviewArgs),
Mcp(McpCli),
McpServer,
AppServer(AppServerCommand),
Completion(CompletionCommand),
#[clap(visible_alias = "debug")]
Sandbox(SandboxArgs),
#[clap(hide = true)]
Execpolicy(ExecpolicyCommand),
Resume(ResumeCommand),
Fork(ForkCommand),
#[clap(hide = true, name = "responses-api-proxy")]
RemovedResponsesApiProxy(RemovedResponsesProxyArgs),
#[clap(hide = true, name = "stdio-to-uds")]
StdioToUds(StdioToUdsCommand),
#[clap(hide = true)]
Dev(DevCommand),
#[cfg(target_os = "windows")]
#[clap(hide = true, name = "__windows-sandbox-setup")]
WindowsSandboxSetup(WindowsSandboxSetupCommand),
#[cfg(target_os = "windows")]
#[clap(hide = true, name = "__windows-command-runner")]
WindowsCommandRunner(WindowsCommandRunnerCommand),
Features(FeaturesCli),
}
#[derive(Debug, Parser)]
struct CompletionCommand {
#[clap(value_enum, default_value_t = Shell::Bash)]
shell: Shell,
}
#[derive(Debug, Parser)]
struct ResumeCommand {
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,
#[arg(long = "last", default_value_t = false)]
last: bool,
#[arg(long = "all", default_value_t = false)]
all: bool,
#[clap(flatten)]
config_overrides: TuiCli,
}
#[derive(Debug, Parser)]
struct ForkCommand {
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
last: bool,
#[arg(long = "all", default_value_t = false)]
all: bool,
#[clap(flatten)]
config_overrides: TuiCli,
}
#[derive(Debug, Parser)]
struct SandboxArgs {
#[command(subcommand)]
cmd: SandboxCommand,
}
#[derive(Debug, clap::Subcommand)]
enum SandboxCommand {
#[clap(visible_alias = "seatbelt")]
Macos(SeatbeltCommand),
#[clap(visible_alias = "landlock")]
Linux(LandlockCommand),
Windows(WindowsCommand),
}
#[derive(Debug, Parser)]
struct ExecpolicyCommand {
#[command(subcommand)]
sub: ExecpolicySubcommand,
}
#[derive(Debug, clap::Subcommand)]
enum ExecpolicySubcommand {
#[clap(name = "check")]
Check(ExecPolicyCheckCommand),
}
#[derive(Debug, Parser)]
struct AppServerCommand {
#[command(subcommand)]
subcommand: Option<AppServerSubcommand>,
#[arg(long = "analytics-default-enabled")]
analytics_default_enabled: bool,
}
#[derive(Debug, clap::Subcommand)]
enum AppServerSubcommand {
GenerateTs(GenerateTsCommand),
GenerateJsonSchema(GenerateJsonSchemaCommand),
}
#[derive(Debug, Args)]
struct GenerateTsCommand {
#[arg(short = 'o', long = "out", value_name = "DIR")]
out_dir: PathBuf,
#[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
prettier: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct GenerateJsonSchemaCommand {
#[arg(short = 'o', long = "out", value_name = "DIR")]
out_dir: PathBuf,
}
#[derive(Debug, Parser)]
struct StdioToUdsCommand {
#[arg(value_name = "SOCKET_PATH")]
socket_path: PathBuf,
}
#[derive(Debug, Parser)]
#[command(disable_help_flag = true, disable_version_flag = true)]
struct RemovedResponsesProxyArgs {
#[arg(
value_name = "ARGS",
allow_hyphen_values = true,
trailing_var_arg = true
)]
_args: Vec<String>,
}
#[derive(Debug, Parser)]
struct DevCommand {
#[command(subcommand)]
sub: DevSubcommand,
}
#[derive(Debug, clap::Subcommand)]
enum DevSubcommand {
FileSearch(crate::product::file_search::Cli),
Logs(crate::product::state::logs_client::Args),
WriteConfigSchema(SchemaOutCommand),
WriteModelsSchema(SchemaOutCommand),
WriteStateSchema(SchemaOutCommand),
WriteAppServerSchema(WriteAppServerSchemaCommand),
}
#[derive(Debug, Args)]
struct SchemaOutCommand {
#[arg(long = "out", value_name = "PATH")]
out: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct WriteAppServerSchemaCommand {
#[arg(long = "schema-root", value_name = "DIR")]
schema_root: Option<PathBuf>,
#[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
prettier: Option<PathBuf>,
}
#[cfg(target_os = "windows")]
#[derive(Debug, Parser)]
struct WindowsSandboxSetupCommand {
payload: String,
}
#[cfg(target_os = "windows")]
#[derive(Debug, Parser)]
struct WindowsCommandRunnerCommand {
#[arg(long = "request-file", value_name = "PATH")]
request_file: PathBuf,
}
fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<String> {
let AppExitInfo {
token_usage,
thread_id: conversation_id,
thread_name,
..
} = exit_info;
if token_usage.is_zero() {
return Vec::new();
}
let mut lines = vec![format!(
"{}",
crate::product::agent::protocol::FinalOutput::from(token_usage)
)];
if let Some(resume_cmd) =
crate::product::agent::util::resume_command(thread_name.as_deref(), conversation_id)
{
let command = if color_enabled {
resume_cmd.cyan().to_string()
} else {
resume_cmd
};
lines.push(format!("To continue this session, run {command}"));
}
lines
}
fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
match exit_info.exit_reason {
ExitReason::Fatal(message) => {
eprintln!("ERROR: {message}");
std::process::exit(1);
}
ExitReason::UserRequested => { }
}
let update_action = exit_info.update_action;
let color_enabled = supports_color::on(Stream::Stdout).is_some();
for line in format_exit_messages(exit_info, color_enabled) {
println!("{line}");
}
if let Some(action) = update_action {
run_update_action(action)?;
}
Ok(())
}
fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
println!();
let cmd_str = action.command_str();
println!("Updating LHA via `{cmd_str}`...");
let status = {
#[cfg(windows)]
{
std::process::Command::new("cmd")
.args(["/C", &cmd_str])
.status()?
}
#[cfg(not(windows))]
{
let (cmd, args) = action.command_args();
let command_path = self::wsl_paths::normalize_for_wsl(cmd);
let normalized_args: Vec<String> = args
.iter()
.map(self::wsl_paths::normalize_for_wsl)
.collect();
std::process::Command::new(&command_path)
.args(&normalized_args)
.status()?
}
};
if !status.success() {
anyhow::bail!("`{cmd_str}` failed with status {status}");
}
println!("\n🎉 Update ran successfully! Please restart LHA.");
Ok(())
}
fn run_execpolicycheck(cmd: ExecPolicyCheckCommand) -> anyhow::Result<()> {
cmd.run()
}
#[derive(Debug, Default, Parser, Clone)]
struct FeatureToggles {
#[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
enable: Vec<String>,
#[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
disable: Vec<String>,
}
impl FeatureToggles {
fn to_overrides(&self) -> anyhow::Result<Vec<String>> {
let mut v = Vec::new();
for feature in &self.enable {
Self::validate_feature(feature)?;
v.push(format!("features.{feature}=true"));
}
for feature in &self.disable {
Self::validate_feature(feature)?;
v.push(format!("features.{feature}=false"));
}
Ok(v)
}
fn validate_feature(feature: &str) -> anyhow::Result<()> {
if is_known_feature_key(feature) {
Ok(())
} else {
anyhow::bail!("Unknown feature flag: {feature}")
}
}
}
#[derive(Debug, Parser)]
struct FeaturesCli {
#[command(subcommand)]
sub: FeaturesSubcommand,
}
#[derive(Debug, Parser)]
enum FeaturesSubcommand {
List,
Enable(FeatureSetArgs),
Disable(FeatureSetArgs),
}
#[derive(Debug, Parser)]
struct FeatureSetArgs {
feature: String,
}
fn stage_str(stage: crate::product::agent::features::Stage) -> &'static str {
use crate::product::agent::features::Stage;
match stage {
Stage::UnderDevelopment => "under development",
Stage::Experimental { .. } => "experimental",
Stage::Stable => "stable",
Stage::Deprecated => "deprecated",
Stage::Removed => "removed",
}
}
pub fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
cli_main(codex_linux_sandbox_exe).await?;
Ok(())
})
}
async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
let MultitoolCli {
config_overrides: mut root_config_overrides,
feature_toggles,
mut interactive,
subcommand,
} = MultitoolCli::parse();
let toggle_overrides = feature_toggles.to_overrides()?;
root_config_overrides.raw_overrides.extend(toggle_overrides);
match subcommand {
None => {
prepend_config_flags(
&mut interactive.config_overrides,
root_config_overrides.clone(),
);
let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
handle_app_exit(exit_info)?;
}
Some(Subcommand::Exec(mut exec_cli)) => {
prepend_config_flags(
&mut exec_cli.config_overrides,
root_config_overrides.clone(),
);
crate::product::exec_cli::run_main(exec_cli, codex_linux_sandbox_exe).await?;
}
Some(Subcommand::Review(review_args)) => {
let mut exec_cli = ExecCli::try_parse_from(["lha", "exec"])?;
exec_cli.command = Some(ExecCommand::Review(review_args));
prepend_config_flags(
&mut exec_cli.config_overrides,
root_config_overrides.clone(),
);
crate::product::exec_cli::run_main(exec_cli, codex_linux_sandbox_exe).await?;
}
Some(Subcommand::McpServer) => {
crate::product::mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides)
.await?;
}
Some(Subcommand::Mcp(mut mcp_cli)) => {
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
mcp_cli.run().await?;
}
Some(Subcommand::AppServer(app_server_cli)) => match app_server_cli.subcommand {
None => {
crate::product::app_server::run_main(
codex_linux_sandbox_exe,
root_config_overrides,
crate::product::agent::config_loader::LoaderOverrides::default(),
app_server_cli.analytics_default_enabled,
)
.await?;
}
Some(AppServerSubcommand::GenerateTs(gen_cli)) => {
crate::product::app_server_protocol::generate_ts(
&gen_cli.out_dir,
gen_cli.prettier.as_deref(),
)?;
}
Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => {
crate::product::app_server_protocol::generate_json(&gen_cli.out_dir)?;
}
},
Some(Subcommand::Resume(ResumeCommand {
session_id,
last,
all,
config_overrides,
})) => {
interactive = finalize_resume_interactive(
interactive,
root_config_overrides.clone(),
session_id,
last,
all,
config_overrides,
);
let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
handle_app_exit(exit_info)?;
}
Some(Subcommand::Fork(ForkCommand {
session_id,
last,
all,
config_overrides,
})) => {
interactive = finalize_fork_interactive(
interactive,
root_config_overrides.clone(),
session_id,
last,
all,
config_overrides,
);
let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?;
handle_app_exit(exit_info)?;
}
Some(Subcommand::Completion(completion_cli)) => {
print_completion(completion_cli);
}
Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd {
SandboxCommand::Macos(mut seatbelt_cli) => {
prepend_config_flags(
&mut seatbelt_cli.config_overrides,
root_config_overrides.clone(),
);
crate::debug_sandbox::run_command_under_seatbelt(
seatbelt_cli,
codex_linux_sandbox_exe,
)
.await?;
}
SandboxCommand::Linux(mut landlock_cli) => {
prepend_config_flags(
&mut landlock_cli.config_overrides,
root_config_overrides.clone(),
);
crate::debug_sandbox::run_command_under_landlock(
landlock_cli,
codex_linux_sandbox_exe,
)
.await?;
}
SandboxCommand::Windows(mut windows_cli) => {
prepend_config_flags(
&mut windows_cli.config_overrides,
root_config_overrides.clone(),
);
crate::debug_sandbox::run_command_under_windows(
windows_cli,
codex_linux_sandbox_exe,
)
.await?;
}
},
Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub {
ExecpolicySubcommand::Check(cmd) => run_execpolicycheck(cmd)?,
},
Some(Subcommand::RemovedResponsesApiProxy(_)) => {
exit_removed_responses_api_proxy_subcommand();
}
Some(Subcommand::StdioToUds(cmd)) => {
let socket_path = cmd.socket_path;
tokio::task::spawn_blocking(move || {
crate::product::stdio_to_uds::run(socket_path.as_path())
})
.await??;
}
Some(Subcommand::Dev(dev)) => run_dev_command(dev).await?,
#[cfg(target_os = "windows")]
Some(Subcommand::WindowsSandboxSetup(WindowsSandboxSetupCommand { payload })) => {
crate::product::windows_sandbox::run_setup_helper_main(payload)?;
}
#[cfg(target_os = "windows")]
Some(Subcommand::WindowsCommandRunner(WindowsCommandRunnerCommand { request_file })) => {
crate::product::windows_sandbox::run_command_runner_helper_main(request_file)?;
}
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
FeaturesSubcommand::List => {
let mut cli_kv_overrides = root_config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
if interactive.web_search {
cli_kv_overrides.push((
"web_search".to_string(),
toml::Value::String("live".to_string()),
));
}
let overrides = ConfigOverrides {
config_profile: interactive.config_profile.clone(),
..Default::default()
};
let config = Config::load_with_cli_overrides_and_harness_overrides(
cli_kv_overrides,
overrides,
)
.await?;
let mut rows = Vec::with_capacity(crate::product::agent::features::FEATURES.len());
let mut name_width = 0;
let mut stage_width = 0;
for def in crate::product::agent::features::FEATURES.iter() {
let name = def.key;
let stage = stage_str(def.stage);
let enabled = config.features.enabled(def.id);
name_width = name_width.max(name.len());
stage_width = stage_width.max(stage.len());
rows.push((name, stage, enabled));
}
for (name, stage, enabled) in rows {
println!("{name:<name_width$} {stage:<stage_width$} {enabled}");
}
}
FeaturesSubcommand::Enable(FeatureSetArgs { feature }) => {
enable_feature_in_config(&interactive, &feature).await?;
}
FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => {
disable_feature_in_config(&interactive, &feature).await?;
}
},
}
Ok(())
}
fn exit_removed_responses_api_proxy_subcommand() -> ! {
MultitoolCli::command()
.error(
clap::error::ErrorKind::InvalidSubcommand,
"unrecognized subcommand 'responses-api-proxy'",
)
.exit()
}
async fn run_dev_command(dev: DevCommand) -> anyhow::Result<()> {
match dev.sub {
DevSubcommand::FileSearch(cli) => {
crate::product::file_search::run_cli(cli).await?;
}
DevSubcommand::Logs(args) => {
crate::product::state::logs_client::run(args).await?;
}
DevSubcommand::WriteConfigSchema(cmd) => {
let out = cmd.out.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("product/agent_runtime/config.schema.json")
});
crate::product::agent::config::schema::write_config_schema(&out)?;
}
DevSubcommand::WriteModelsSchema(cmd) => {
let out = cmd.out.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("product/agent_runtime/models.schema.json")
});
crate::product::agent::config::schema::write_models_schema(&out)?;
}
DevSubcommand::WriteStateSchema(cmd) => {
let out = cmd.out.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("product/agent_runtime/state.schema.json")
});
crate::product::agent::config::schema::write_state_schema(&out)?;
}
DevSubcommand::WriteAppServerSchema(cmd) => {
let schema_root = cmd.schema_root.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("product/app_server_protocol/schema")
});
crate::product::app_server_protocol::write_schema_fixtures(
&schema_root,
cmd.prettier.as_deref(),
)?;
}
}
Ok(())
}
async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
FeatureToggles::validate_feature(feature)?;
let lha_home = find_lha_home()?;
ConfigEditsBuilder::new(&lha_home)
.with_profile(interactive.config_profile.as_deref())
.set_feature_enabled(feature, true)
.apply()
.await?;
println!("Enabled feature `{feature}` in config.toml.");
maybe_print_under_development_feature_warning(&lha_home, interactive, feature);
Ok(())
}
async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
FeatureToggles::validate_feature(feature)?;
let lha_home = find_lha_home()?;
ConfigEditsBuilder::new(&lha_home)
.with_profile(interactive.config_profile.as_deref())
.set_feature_enabled(feature, false)
.apply()
.await?;
println!("Disabled feature `{feature}` in config.toml.");
Ok(())
}
fn maybe_print_under_development_feature_warning(
lha_home: &std::path::Path,
interactive: &TuiCli,
feature: &str,
) {
if interactive.config_profile.is_some() {
return;
}
let Some(spec) = crate::product::agent::features::FEATURES
.iter()
.find(|spec| spec.key == feature)
else {
return;
};
if !matches!(spec.stage, Stage::UnderDevelopment) {
return;
}
let config_path = lha_home.join(crate::product::agent::config::CONFIG_TOML_FILE);
eprintln!(
"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 {}.",
config_path.display()
);
}
fn prepend_config_flags(
subcommand_config_overrides: &mut CliConfigOverrides,
cli_config_overrides: CliConfigOverrides,
) {
subcommand_config_overrides
.raw_overrides
.splice(0..0, cli_config_overrides.raw_overrides);
}
async fn run_interactive_tui(
mut interactive: TuiCli,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> std::io::Result<AppExitInfo> {
if let Some(prompt) = interactive.prompt.take() {
interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n"));
}
let terminal_info = crate::product::agent::terminal::terminal_info();
if terminal_info.name == TerminalName::Dumb {
if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
return Ok(AppExitInfo::fatal(
"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.",
));
}
eprintln!(
"WARNING: TERM is set to \"dumb\". LHA's interactive TUI may not work in this terminal."
);
if !confirm("Continue anyway? [y/N]: ")? {
return Ok(AppExitInfo::fatal(
"Refusing to start the interactive TUI because TERM is set to \"dumb\". Run in a supported terminal or unset TERM.",
));
}
}
crate::product::tui_app::run_main(interactive, codex_linux_sandbox_exe).await
}
fn confirm(prompt: &str) -> std::io::Result<bool> {
eprintln!("{prompt}");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let answer = input.trim();
Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
}
fn finalize_resume_interactive(
mut interactive: TuiCli,
root_config_overrides: CliConfigOverrides,
session_id: Option<String>,
last: bool,
show_all: bool,
resume_cli: TuiCli,
) -> TuiCli {
let resume_session_id = session_id;
interactive.resume_picker = resume_session_id.is_none() && !last;
interactive.resume_last = last;
interactive.resume_session_id = resume_session_id;
interactive.resume_show_all = show_all;
merge_interactive_cli_flags(&mut interactive, resume_cli);
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
interactive
}
fn finalize_fork_interactive(
mut interactive: TuiCli,
root_config_overrides: CliConfigOverrides,
session_id: Option<String>,
last: bool,
show_all: bool,
fork_cli: TuiCli,
) -> TuiCli {
let fork_session_id = session_id;
interactive.fork_picker = fork_session_id.is_none() && !last;
interactive.fork_last = last;
interactive.fork_session_id = fork_session_id;
interactive.fork_show_all = show_all;
merge_interactive_cli_flags(&mut interactive, fork_cli);
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
interactive
}
fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) {
if let Some(model) = subcommand_cli.model {
interactive.model = Some(model);
}
if let Some(profile) = subcommand_cli.config_profile {
interactive.config_profile = Some(profile);
}
if let Some(sandbox) = subcommand_cli.sandbox_mode {
interactive.sandbox_mode = Some(sandbox);
}
if let Some(approval) = subcommand_cli.approval_policy {
interactive.approval_policy = Some(approval);
}
if subcommand_cli.full_auto {
interactive.full_auto = true;
}
if subcommand_cli.dangerously_bypass_approvals_and_sandbox {
interactive.dangerously_bypass_approvals_and_sandbox = true;
}
if subcommand_cli.mouse_capture {
interactive.mouse_capture = true;
interactive.no_mouse_capture = false;
}
if subcommand_cli.no_mouse_capture {
interactive.no_mouse_capture = true;
interactive.mouse_capture = false;
}
if let Some(cwd) = subcommand_cli.cwd {
interactive.cwd = Some(cwd);
}
if subcommand_cli.web_search {
interactive.web_search = true;
}
if !subcommand_cli.images.is_empty() {
interactive.images = subcommand_cli.images;
}
if !subcommand_cli.add_dir.is_empty() {
interactive.add_dir.extend(subcommand_cli.add_dir);
}
if let Some(prompt) = subcommand_cli.prompt {
interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n"));
}
interactive
.config_overrides
.raw_overrides
.extend(subcommand_cli.config_overrides.raw_overrides);
}
fn print_completion(cmd: CompletionCommand) {
let mut app = MultitoolCli::command();
let name = "lha";
generate(cmd.shell, &mut app, name, &mut std::io::stdout());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::product::agent::protocol::TokenUsage;
use crate::product::protocol::ThreadId;
use assert_matches::assert_matches;
use pretty_assertions::assert_eq;
fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let MultitoolCli {
interactive,
config_overrides: root_overrides,
subcommand,
feature_toggles: _,
} = cli;
let Subcommand::Resume(ResumeCommand {
session_id,
last,
all,
config_overrides: resume_cli,
}) = subcommand.expect("resume present")
else {
unreachable!()
};
finalize_resume_interactive(
interactive,
root_overrides,
session_id,
last,
all,
resume_cli,
)
}
fn finalize_fork_from_args(args: &[&str]) -> TuiCli {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let MultitoolCli {
interactive,
config_overrides: root_overrides,
subcommand,
feature_toggles: _,
} = cli;
let Subcommand::Fork(ForkCommand {
session_id,
last,
all,
config_overrides: fork_cli,
}) = subcommand.expect("fork present")
else {
unreachable!()
};
finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli)
}
#[test]
fn exec_resume_last_accepts_prompt_positional() {
let cli =
MultitoolCli::try_parse_from(["lha", "exec", "--json", "resume", "--last", "2+2"])
.expect("parse should succeed");
let Some(Subcommand::Exec(exec)) = cli.subcommand else {
panic!("expected exec subcommand");
};
let Some(crate::product::exec_cli::Command::Resume(args)) = exec.command else {
panic!("expected exec resume");
};
assert!(args.last);
assert_eq!(args.session_id, None);
assert_eq!(args.prompt.as_deref(), Some("2+2"));
}
fn app_server_from_args(args: &[&str]) -> AppServerCommand {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else {
unreachable!()
};
app_server
}
#[test]
fn removed_responses_proxy_parses_as_tombstone_after_global_config_flag() {
let cli = MultitoolCli::try_parse_from([
"lha",
"-c",
"model=gpt-5.1",
"responses-api-proxy",
"--help",
])
.expect("removed proxy tombstone should parse before rejection");
assert_matches!(
cli.subcommand,
Some(Subcommand::RemovedResponsesApiProxy(_))
);
}
#[test]
fn removed_responses_proxy_parses_as_tombstone_without_global_flags() {
let cli = MultitoolCli::try_parse_from([
"lha",
"responses-api-proxy",
"--upstream-url",
"http://example.invalid",
])
.expect("removed proxy tombstone should parse before rejection");
assert_matches!(
cli.subcommand,
Some(Subcommand::RemovedResponsesApiProxy(_))
);
}
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
let token_usage = TokenUsage {
output_tokens: 2,
total_tokens: 2,
..Default::default()
};
AppExitInfo {
token_usage,
thread_id: conversation_id
.map(ThreadId::from_string)
.map(Result::unwrap),
thread_name: thread_name.map(str::to_string),
update_action: None,
exit_reason: ExitReason::UserRequested,
}
}
#[test]
fn format_exit_messages_skips_zero_usage() {
let exit_info = AppExitInfo {
token_usage: TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
};
let lines = format_exit_messages(exit_info, false);
assert!(lines.is_empty());
}
#[test]
fn format_exit_messages_includes_resume_hint_without_color() {
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None);
let lines = format_exit_messages(exit_info, false);
assert_eq!(
lines,
vec![
"Token usage: total=2 input=0 output=2".to_string(),
"To continue this session, run lha resume 123e4567-e89b-12d3-a456-426614174000"
.to_string(),
]
);
}
#[test]
fn format_exit_messages_applies_color_when_enabled() {
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None);
let lines = format_exit_messages(exit_info, true);
assert_eq!(lines.len(), 2);
assert!(lines[1].contains("\u{1b}[36m"));
}
#[test]
fn format_exit_messages_prefers_thread_name() {
let exit_info = sample_exit_info(
Some("123e4567-e89b-12d3-a456-426614174000"),
Some("my-thread"),
);
let lines = format_exit_messages(exit_info, false);
assert_eq!(
lines,
vec![
"Token usage: total=2 input=0 output=2".to_string(),
"To continue this session, run lha resume my-thread".to_string(),
]
);
}
#[test]
fn resume_model_flag_applies_when_no_root_flags() {
let interactive =
finalize_resume_from_args(["lha", "resume", "-m", "gpt-5.1-test"].as_ref());
assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
assert!(interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn resume_picker_logic_none_and_not_last() {
let interactive = finalize_resume_from_args(["lha", "resume"].as_ref());
assert!(interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
assert!(!interactive.resume_show_all);
}
#[test]
fn resume_picker_logic_last() {
let interactive = finalize_resume_from_args(["lha", "resume", "--last"].as_ref());
assert!(!interactive.resume_picker);
assert!(interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
assert!(!interactive.resume_show_all);
}
#[test]
fn resume_picker_logic_with_session_id() {
let interactive = finalize_resume_from_args(["lha", "resume", "1234"].as_ref());
assert!(!interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id.as_deref(), Some("1234"));
assert!(!interactive.resume_show_all);
}
#[test]
fn resume_all_flag_sets_show_all() {
let interactive = finalize_resume_from_args(["lha", "resume", "--all"].as_ref());
assert!(interactive.resume_picker);
assert!(interactive.resume_show_all);
}
#[test]
fn resume_merges_option_flags_and_full_auto() {
let interactive = finalize_resume_from_args(
[
"lha",
"resume",
"sid",
"--full-auto",
"--search",
"--sandbox",
"workspace-write",
"--ask-for-approval",
"on-request",
"-m",
"gpt-5.1-test",
"-p",
"my-profile",
"-C",
"/tmp",
"-i",
"/tmp/a.png,/tmp/b.png",
]
.as_ref(),
);
assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
assert_eq!(interactive.config_profile.as_deref(), Some("my-profile"));
assert_matches!(
interactive.sandbox_mode,
Some(crate::product::common::SandboxModeCliArg::WorkspaceWrite)
);
assert_matches!(
interactive.approval_policy,
Some(crate::product::common::ApprovalModeCliArg::OnRequest)
);
assert!(interactive.full_auto);
assert_eq!(
interactive.cwd.as_deref(),
Some(std::path::Path::new("/tmp"))
);
assert!(interactive.web_search);
let has_a = interactive
.images
.iter()
.any(|p| p == std::path::Path::new("/tmp/a.png"));
let has_b = interactive
.images
.iter()
.any(|p| p == std::path::Path::new("/tmp/b.png"));
assert!(has_a && has_b);
assert!(!interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id.as_deref(), Some("sid"));
}
#[test]
fn resume_merges_dangerously_bypass_flag() {
let interactive = finalize_resume_from_args(
[
"lha",
"resume",
"--dangerously-bypass-approvals-and-sandbox",
]
.as_ref(),
);
assert!(interactive.dangerously_bypass_approvals_and_sandbox);
assert!(interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn resume_merges_no_mouse_capture_flag() {
let interactive =
finalize_resume_from_args(["lha", "resume", "--no-mouse-capture"].as_ref());
assert!(interactive.no_mouse_capture);
assert!(!interactive.mouse_capture);
assert!(interactive.resume_picker);
}
#[test]
fn resume_mouse_capture_flag_overrides_root_no_mouse_capture() {
let interactive = finalize_resume_from_args(
["lha", "--no-mouse-capture", "resume", "--mouse-capture"].as_ref(),
);
assert!(interactive.mouse_capture);
assert!(!interactive.no_mouse_capture);
assert!(interactive.resume_picker);
}
#[test]
fn fork_picker_logic_none_and_not_last() {
let interactive = finalize_fork_from_args(["lha", "fork"].as_ref());
assert!(interactive.fork_picker);
assert!(!interactive.fork_last);
assert_eq!(interactive.fork_session_id, None);
assert!(!interactive.fork_show_all);
}
#[test]
fn fork_picker_logic_last() {
let interactive = finalize_fork_from_args(["lha", "fork", "--last"].as_ref());
assert!(!interactive.fork_picker);
assert!(interactive.fork_last);
assert_eq!(interactive.fork_session_id, None);
assert!(!interactive.fork_show_all);
}
#[test]
fn fork_picker_logic_with_session_id() {
let interactive = finalize_fork_from_args(["lha", "fork", "1234"].as_ref());
assert!(!interactive.fork_picker);
assert!(!interactive.fork_last);
assert_eq!(interactive.fork_session_id.as_deref(), Some("1234"));
assert!(!interactive.fork_show_all);
}
#[test]
fn fork_all_flag_sets_show_all() {
let interactive = finalize_fork_from_args(["lha", "fork", "--all"].as_ref());
assert!(interactive.fork_picker);
assert!(interactive.fork_show_all);
}
#[test]
fn fork_merges_mouse_capture_flag() {
let interactive = finalize_fork_from_args(["lha", "fork", "--mouse-capture"].as_ref());
assert!(interactive.mouse_capture);
assert!(!interactive.no_mouse_capture);
assert!(interactive.fork_picker);
}
#[test]
fn fork_no_mouse_capture_flag_overrides_root_mouse_capture() {
let interactive = finalize_fork_from_args(
["lha", "--mouse-capture", "fork", "--no-mouse-capture"].as_ref(),
);
assert!(interactive.no_mouse_capture);
assert!(!interactive.mouse_capture);
assert!(interactive.fork_picker);
}
#[test]
fn app_server_analytics_default_disabled_without_flag() {
let app_server = app_server_from_args(["lha", "app-server"].as_ref());
assert!(!app_server.analytics_default_enabled);
}
#[test]
fn app_server_analytics_default_enabled_with_flag() {
let app_server =
app_server_from_args(["lha", "app-server", "--analytics-default-enabled"].as_ref());
assert!(app_server.analytics_default_enabled);
}
#[test]
fn features_enable_parses_feature_name() {
let cli = MultitoolCli::try_parse_from(["lha", "features", "enable", "unified_exec"])
.expect("parse should succeed");
let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else {
panic!("expected features subcommand");
};
let FeaturesSubcommand::Enable(FeatureSetArgs { feature }) = sub else {
panic!("expected features enable");
};
assert_eq!(feature, "unified_exec");
}
#[test]
fn features_disable_parses_feature_name() {
let cli = MultitoolCli::try_parse_from(["lha", "features", "disable", "shell_tool"])
.expect("parse should succeed");
let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else {
panic!("expected features subcommand");
};
let FeaturesSubcommand::Disable(FeatureSetArgs { feature }) = sub else {
panic!("expected features disable");
};
assert_eq!(feature, "shell_tool");
}
#[test]
fn feature_toggles_known_features_generate_overrides() {
let toggles = FeatureToggles {
enable: vec!["web_search_request".to_string()],
disable: vec!["unified_exec".to_string()],
};
let overrides = toggles.to_overrides().expect("valid features");
assert_eq!(
overrides,
vec![
"features.web_search_request=true".to_string(),
"features.unified_exec=false".to_string(),
]
);
}
#[test]
fn feature_toggles_unknown_feature_errors() {
let toggles = FeatureToggles {
enable: vec!["does_not_exist".to_string()],
disable: Vec::new(),
};
let err = toggles
.to_overrides()
.expect_err("feature should be rejected");
assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
}
#[test]
fn single_binary_compat_dev_file_search_parses() {
let cli = match MultitoolCli::try_parse_from([
"lha",
"dev",
"file-search",
"--limit",
"5",
"foo",
]) {
Ok(cli) => cli,
Err(err) => panic!("parse should succeed: {err}"),
};
let Some(Subcommand::Dev(DevCommand {
sub: DevSubcommand::FileSearch(file_search),
})) = cli.subcommand
else {
panic!("expected dev file-search subcommand");
};
assert_eq!(file_search.limit.get(), 5);
assert_eq!(file_search.pattern.as_deref(), Some("foo"));
}
#[test]
fn single_binary_compat_dev_logs_parses() {
let cli = match MultitoolCli::try_parse_from([
"lha",
"dev",
"logs",
"--backfill",
"10",
"--threadless",
]) {
Ok(cli) => cli,
Err(err) => panic!("parse should succeed: {err}"),
};
let Some(Subcommand::Dev(DevCommand {
sub: DevSubcommand::Logs(logs),
})) = cli.subcommand
else {
panic!("expected dev logs subcommand");
};
assert_eq!(logs.backfill, 10);
assert!(logs.threadless);
}
}