Skip to main content

rho_coding_agent/app/
bootstrap.rs

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