Skip to main content

rho_coding_agent/app/
bootstrap.rs

1use std::{
2    io::{self, IsTerminal},
3    num::NonZeroUsize,
4    sync::Arc,
5    time::Duration,
6};
7
8use {
9    crate::cli::{Cli, Command, CredentialStoreCommand, OutputFormat},
10    crate::credential_store::AppCredentialStore,
11    crate::diagnostics::RuntimeDiagnostics,
12    crate::herdr::HerdrReporter,
13    crate::update,
14    rho_providers::model::ModelError,
15};
16
17use super::{
18    agent_binding::{AgentBinder, AgentInvocation, AgentRole},
19    automation, automation_protocol, cli_config,
20    config_repository::ConfigRepository,
21    interactive, login,
22    sdk_config::SdkBootstrapOptions,
23    sessions_cli,
24};
25
26pub async fn run(cli: Cli) -> anyhow::Result<()> {
27    let run_output = match &cli.command {
28        Some(Command::Run { output, .. }) => Some(*output),
29        _ => None,
30    };
31    let result = run_inner(cli).await;
32    let Err(error) = result else {
33        return Ok(());
34    };
35    if error.downcast_ref::<automation::AutomationExit>().is_some()
36        || error
37            .downcast_ref::<automation::AutomationInterrupted>()
38            .is_some()
39    {
40        return Err(error);
41    }
42    if run_output == Some(OutputFormat::Jsonl) {
43        automation::emit_startup_failure()?;
44        return Err(automation::AutomationExit::new(
45            2,
46            automation_protocol::TerminalReason::ConfigurationError,
47            "configuration failed",
48        )
49        .into());
50    }
51    if run_output.is_some() {
52        return Err(automation::AutomationExit::new(
53            2,
54            automation_protocol::TerminalReason::ConfigurationError,
55            error.to_string(),
56        )
57        .into());
58    }
59    Err(error)
60}
61
62async fn run_inner(cli: Cli) -> anyhow::Result<()> {
63    cli_config::validate(&cli)?;
64    if let EarlyDispatch::Handled(result) = dispatch_early_command(&cli).await? {
65        return result;
66    }
67
68    let PreparedStartup {
69        cli,
70        mut config,
71        config_repository,
72        cwd,
73        automation_prompt,
74        output_file,
75        output,
76        max_steps,
77        timeout,
78        bound_agent,
79        bound_reasoning_source,
80        provider_refresh,
81        store,
82    } = prepare_startup(cli).await?;
83
84    validate_terminal_mode(&cli)?;
85    cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
86    cli_config::normalize_reasoning_for_cli(&mut config, bound_reasoning_source)?;
87    let herdr = HerdrReporter::from_env();
88    if let Some(prompt) = automation_prompt {
89        return run_automation_startup(AutomationStartup {
90            prompt,
91            config: &config,
92            config_repository: &config_repository,
93            cwd,
94            cli: &cli,
95            bound_agent,
96            output_file,
97            output,
98            max_steps,
99            timeout,
100            herdr,
101        })
102        .await;
103    }
104    run_interactive_startup(InteractiveStartup {
105        cli: &cli,
106        config,
107        config_repository,
108        cwd,
109        bound_agent,
110        bound_reasoning_source,
111        herdr,
112    })
113    .await
114}
115
116enum EarlyDispatch {
117    Handled(anyhow::Result<()>),
118    Continue,
119}
120
121async fn dispatch_early_command(cli: &Cli) -> anyhow::Result<EarlyDispatch> {
122    if let Some(Command::CredentialStore { command }) = &cli.command {
123        return Ok(EarlyDispatch::Handled(run_credential_store_command(
124            command,
125            cli.config.clone(),
126        )));
127    }
128    if let Some(Command::Sessions { command }) = &cli.command {
129        return Ok(EarlyDispatch::Handled(sessions_cli::run(command)));
130    }
131    if let Some(Command::Attach { id }) = &cli.command {
132        return Ok(EarlyDispatch::Handled(
133            crate::tui::run_attachment(id, HerdrReporter::from_env()).await,
134        ));
135    }
136    if matches!(cli.command, Some(Command::Update)) {
137        return Ok(EarlyDispatch::Handled(
138            update::run_update(env!("CARGO_PKG_VERSION")).await,
139        ));
140    }
141    if let Some(Command::Login {
142        provider,
143        device_auth,
144    }) = &cli.command
145    {
146        let config_repository = ConfigRepository::new(cli.config.clone());
147        let mut config = config_repository.load()?;
148        let config_path = absolute_config_path(&config_repository)?;
149        ensure_cli_credential_store_choice(&mut config, Some(config_path.clone()))?;
150        crate::credential_store::initialize_from_config(&mut config, &config_path)?;
151        return Ok(EarlyDispatch::Handled(
152            login::run(provider, *device_auth).await,
153        ));
154    }
155    Ok(EarlyDispatch::Continue)
156}
157
158struct PreparedStartup {
159    cli: Cli,
160    config: crate::config::Config,
161    config_repository: ConfigRepository,
162    cwd: std::path::PathBuf,
163    automation_prompt: Option<String>,
164    output_file: Option<std::path::PathBuf>,
165    output: OutputFormat,
166    max_steps: Option<NonZeroUsize>,
167    timeout: Option<Duration>,
168    bound_agent: super::agent_binding::BoundAgent,
169    bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
170    provider_refresh: cli_config::ProviderRefreshStatus,
171    store: AppCredentialStore,
172}
173
174async fn prepare_startup(cli: Cli) -> anyhow::Result<PreparedStartup> {
175    let config_path = cli.config.clone();
176    let config_repository = ConfigRepository::new(config_path.clone());
177    let mut config = config_repository.load()?;
178    let absolute_config = absolute_config_path(&config_repository)?;
179    crate::credential_store::initialize_from_config(&mut config, &absolute_config)?;
180    let cwd = std::env::current_dir()?;
181    let automation_prompt = automation::prompt_for_command(&cli.command)?;
182    let (output_file, output, max_steps, timeout) = match &cli.command {
183        Some(Command::Run {
184            output_file,
185            output,
186            max_steps,
187            timeout,
188            ..
189        }) => (output_file.clone(), *output, *max_steps, *timeout),
190        _ => (None, OutputFormat::Text, None, None),
191    };
192    let catalog = crate::agent::AgentCatalog::discover(&cwd)?;
193    let selected_agent = cli.agent.as_deref().unwrap_or("default");
194    let definition = Arc::new(catalog.find(selected_agent)?.definition.clone());
195
196    let store = AppCredentialStore;
197    let provider_refresh = cli_config::refresh_model_cache(&cli, &config, &store).await?;
198    let mut save_config = cli_config::apply_overrides(&mut config, &cli)?;
199    cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
200    save_config |= cli_config::normalize_reasoning_for_cli(
201        &mut config,
202        if cli.reasoning.is_some() {
203            rho_providers::model::ReasoningRequestSource::Explicit
204        } else {
205            rho_providers::model::ReasoningRequestSource::PersistedOrDefault
206        },
207    )?;
208    if save_config {
209        config_repository.save(&config)?;
210    }
211    let reasoning_before_binding = config.reasoning;
212    let role = if automation_prompt.is_some() {
213        AgentRole::AutomationRoot
214    } else {
215        AgentRole::InteractiveRoot
216    };
217    let bound_agent = AgentBinder::bind(
218        definition,
219        AgentInvocation {
220            role,
221            available_tools: host_capabilities(&cli, &config, role),
222        },
223        &config,
224    )?;
225    config = bound_agent.rho_config().cloned().unwrap_or(config);
226    let bound_reasoning_source =
227        if cli.reasoning.is_some() && config.reasoning == reasoning_before_binding {
228            rho_providers::model::ReasoningRequestSource::Explicit
229        } else {
230            rho_providers::model::ReasoningRequestSource::PersistedOrDefault
231        };
232
233    Ok(PreparedStartup {
234        cli,
235        config,
236        config_repository,
237        cwd,
238        automation_prompt,
239        output_file,
240        output,
241        max_steps,
242        timeout,
243        bound_agent,
244        bound_reasoning_source,
245        provider_refresh,
246        store,
247    })
248}
249
250struct AutomationStartup<'a> {
251    prompt: String,
252    config: &'a crate::config::Config,
253    config_repository: &'a ConfigRepository,
254    cwd: std::path::PathBuf,
255    cli: &'a Cli,
256    bound_agent: super::agent_binding::BoundAgent,
257    output_file: Option<std::path::PathBuf>,
258    output: OutputFormat,
259    max_steps: Option<NonZeroUsize>,
260    timeout: Option<Duration>,
261    herdr: HerdrReporter,
262}
263
264async fn run_automation_startup(startup: AutomationStartup<'_>) -> anyhow::Result<()> {
265    let diagnostics = bind_agent_diagnostics(startup.config, &startup.bound_agent);
266    automation::run(
267        startup.prompt,
268        automation::Startup {
269            config: startup.config,
270            config_path: absolute_config_path(startup.config_repository)?,
271            cwd: startup.cwd,
272            no_system_prompt: startup.cli.no_system_prompt,
273            no_tools: startup.cli.no_tools,
274            no_subagents: startup.cli.no_subagents,
275            usage_purpose: "agent",
276            parent_session_id: None,
277            agent: startup.bound_agent,
278            output_file: startup.output_file,
279            output: startup.output,
280            max_steps: startup.max_steps,
281            timeout: startup.timeout,
282            diagnostics,
283            herdr: startup.herdr,
284            host_input: None,
285        },
286    )
287    .await
288}
289
290struct InteractiveStartup<'a> {
291    cli: &'a Cli,
292    config: crate::config::Config,
293    config_repository: ConfigRepository,
294    cwd: std::path::PathBuf,
295    bound_agent: super::agent_binding::BoundAgent,
296    bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
297    herdr: HerdrReporter,
298}
299
300async fn run_interactive_startup(startup: InteractiveStartup<'_>) -> anyhow::Result<()> {
301    let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
302
303    let pending_update_notice = startup
304        .config
305        .check_for_updates
306        .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
307
308    let sdk_options = SdkBootstrapOptions::from_config(&startup.config, &startup.cwd)?;
309    let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
310        Arc::new(AppCredentialStore),
311    );
312    let provider_result = rho_providers::providers::build_sdk_provider_with_source(
313        sdk_options.provider,
314        &credentials,
315    );
316    let (missing_auth_error, missing_auth_model_error) = match provider_result {
317        Ok(_) => (None, None),
318        Err(error) if is_interactive_startup_unavailable_error(&error) => {
319            (Some(error.to_string()), Some(error))
320        }
321        Err(error) => return Err(error.into()),
322    };
323    interactive::run(interactive::Startup {
324        cli: startup.cli,
325        config: startup.config,
326        config_path: absolute_config_path(&startup.config_repository)?,
327        config_repository: startup.config_repository,
328        cwd: startup.cwd,
329        missing_auth_error,
330        missing_auth_model_error,
331        pending_update_notice,
332        diagnostics,
333        herdr: startup.herdr,
334        agent: startup.bound_agent,
335        reasoning_source: startup.bound_reasoning_source,
336    })
337    .await
338}
339
340fn bind_agent_diagnostics(
341    config: &crate::config::Config,
342    agent: &super::agent_binding::BoundAgent,
343) -> RuntimeDiagnostics {
344    let diagnostics = RuntimeDiagnostics::new(config);
345    diagnostics.update_agent(agent.id().as_str(), &agent.fingerprint().to_string());
346    diagnostics
347}
348
349fn ensure_cli_credential_store_choice(
350    config: &mut crate::config::Config,
351    config_path: Option<std::path::PathBuf>,
352) -> anyhow::Result<()> {
353    use rho_providers::credentials::CredentialStoreBackend;
354    use std::io::{self, IsTerminal, Write};
355
356    let Some(request) = crate::credential_store::choice_request(config) else {
357        return Ok(());
358    };
359
360    if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
361        anyhow::bail!(
362            "credential store is unset; set it before non-interactive login with \
363`rho credential-store set os|file`, behavior.credential_store in config.toml, \
364or RHO_CREDENTIAL_STORE=os|file"
365        );
366    }
367
368    let backends = request.available_backends();
369    if backends.is_empty() {
370        anyhow::bail!(
371            "no credential store backend is available (os: {}; file: {})",
372            request.os.detail,
373            request.file.detail
374        );
375    }
376
377    eprintln!("Choose where Rho stores provider credentials:");
378    eprintln!("This is saved to config and used for future logins on this machine.");
379    if request.os.available {
380        eprintln!("  [1] OS credential store (recommended)");
381    } else {
382        eprintln!(
383            "  [1] OS credential store (unavailable: {})",
384            request.os.detail
385        );
386    }
387    if request.file.available {
388        eprintln!("  [2] Local file under ~/.rho/credentials (not encrypted at rest)");
389    } else {
390        eprintln!("  [2] Local file (unavailable: {})", request.file.detail);
391    }
392    let default_backend = request
393        .default_backend()
394        .unwrap_or(CredentialStoreBackend::Os);
395    let default_hint = match default_backend {
396        CredentialStoreBackend::Os => "1",
397        CredentialStoreBackend::File => "2",
398    };
399    eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
400    io::stderr().flush()?;
401
402    let mut answer = String::new();
403    io::stdin().read_line(&mut answer)?;
404    let backend = match answer.trim() {
405        "" => default_backend,
406        "1" | "os" | "OS" => CredentialStoreBackend::Os,
407        "2" | "file" | "FILE" => CredentialStoreBackend::File,
408        other => {
409            anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
410        }
411    };
412    if !backends.contains(&backend) {
413        let detail = request.detail_for(backend);
414        anyhow::bail!(
415            "{} credential store is unavailable: {detail}",
416            backend.as_str()
417        );
418    }
419
420    let path = crate::credential_store::set_backend(backend, config_path)?;
421    config.credential_store = Some(backend);
422    eprintln!(
423        "credential store set to {} in {}",
424        backend.as_str(),
425        path.display()
426    );
427    Ok(())
428}
429
430fn run_credential_store_command(
431    command: &CredentialStoreCommand,
432    config_path: Option<std::path::PathBuf>,
433) -> anyhow::Result<()> {
434    match command {
435        CredentialStoreCommand::Probe { backend } => {
436            let result = crate::credential_store::probe(*backend);
437            if result.available {
438                println!("available: {}", result.detail);
439                Ok(())
440            } else {
441                anyhow::bail!(result.detail)
442            }
443        }
444        CredentialStoreCommand::Status => {
445            // Saved config policy only (ignore RHO_CREDENTIAL_STORE).
446            match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
447                None => println!("unset"),
448                Some(backend) => println!("{}", backend.as_str()),
449            }
450            Ok(())
451        }
452        CredentialStoreCommand::Set { backend } => {
453            let path = crate::credential_store::set_backend(*backend, config_path)?;
454            println!(
455                "credential store set to {} in {}",
456                backend.as_str(),
457                path.display()
458            );
459            Ok(())
460        }
461    }
462}
463
464fn host_capabilities(
465    cli: &Cli,
466    config: &crate::config::Config,
467    role: AgentRole,
468) -> crate::agent::AgentCapabilities {
469    use crate::agent::ToolCapability;
470
471    if cli.no_tools {
472        return crate::agent::AgentCapabilities::default();
473    }
474    let mut tools = crate::agent::AgentCapabilities::all_host_tools();
475    if !crate::tools::web::access_tools(config).is_available() {
476        tools.remove(&ToolCapability::WebSearch);
477    }
478    #[cfg(windows)]
479    tools.remove(&ToolCapability::Bash);
480    #[cfg(not(windows))]
481    tools.remove(&ToolCapability::Powershell);
482    if cli.no_subagents || !config.enable_subagents {
483        tools.remove(&ToolCapability::Agent);
484        tools.remove(&ToolCapability::Agents);
485    }
486    if role != AgentRole::InteractiveRoot {
487        tools.remove(&ToolCapability::Questionnaire);
488    }
489    #[cfg(debug_assertions)]
490    if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
491        tools.insert(ToolCapability::Extension(
492            crate::tools::tui_fixture::NAME.into(),
493        ));
494    }
495    tools
496}
497
498fn absolute_config_path(repository: &ConfigRepository) -> anyhow::Result<std::path::PathBuf> {
499    let path = repository.configured_path()?;
500    if path.is_absolute() {
501        Ok(path)
502    } else {
503        Ok(std::env::current_dir()?.join(path))
504    }
505}
506
507fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
508    if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
509        anyhow::bail!(
510            "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
511        );
512    }
513    Ok(())
514}
515
516fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
517    matches!(
518        error,
519        ModelError::MissingApiKey
520            | ModelError::MissingCodexAuth
521            | ModelError::MissingAnthropicApiKey
522            | ModelError::MissingGithubCopilotAuth
523            | ModelError::MissingXaiApiKey
524            | ModelError::MissingXaiAuth
525            | ModelError::Credentials(_)
526            | ModelError::UnsupportedProvider(_)
527    )
528}
529
530#[cfg(test)]
531#[path = "bootstrap_tests.rs"]
532mod tests;