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.config().clone();
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            },
170        )
171        .await;
172    }
173    let diagnostics = RuntimeDiagnostics::new(&config);
174    diagnostics.update_agent(
175        bound_agent.id().as_str(),
176        &bound_agent.fingerprint().to_string(),
177    );
178
179    let pending_update_notice = config
180        .check_for_updates
181        .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
182
183    let sdk_options = SdkBootstrapOptions::from_config(&config, &cwd)?;
184    let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
185        Arc::new(AppCredentialStore),
186    );
187    let provider_result = rho_providers::providers::build_sdk_provider_with_source(
188        sdk_options.provider,
189        &credentials,
190    );
191    let (missing_auth_error, missing_auth_model_error) = match provider_result {
192        Ok(_) => (None, None),
193        Err(error) if is_interactive_startup_unavailable_error(&error) => {
194            (Some(error.to_string()), Some(error))
195        }
196        Err(error) => return Err(error.into()),
197    };
198    let result = interactive::run(interactive::Startup {
199        cli: &cli,
200        config,
201        config_path: absolute_config_path(&config_repository)?,
202        config_repository,
203        cwd,
204        missing_auth_error,
205        missing_auth_model_error,
206        pending_update_notice,
207        diagnostics,
208        herdr,
209        agent: bound_agent,
210        reasoning_source: bound_reasoning_source,
211    })
212    .await;
213    result
214}
215
216fn ensure_cli_credential_store_choice(
217    config: &mut crate::config::Config,
218    config_path: Option<std::path::PathBuf>,
219) -> anyhow::Result<()> {
220    use rho_providers::credentials::CredentialStoreBackend;
221    use std::io::{self, IsTerminal, Write};
222
223    let Some(request) = crate::credential_store::choice_request(config) else {
224        return Ok(());
225    };
226
227    if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
228        anyhow::bail!(
229            "credential store is unset; set it before non-interactive login with \
230`rho credential-store set os|file`, behavior.credential_store in config.toml, \
231or RHO_CREDENTIAL_STORE=os|file"
232        );
233    }
234
235    let backends = request.available_backends();
236    if backends.is_empty() {
237        anyhow::bail!(
238            "no credential store backend is available (os: {}; file: {})",
239            request.os.detail,
240            request.file.detail
241        );
242    }
243
244    eprintln!("Choose where Rho stores provider credentials:");
245    eprintln!("This is saved to config and used for future logins on this machine.");
246    if request.os.available {
247        eprintln!("  [1] OS credential store (recommended)");
248    } else {
249        eprintln!(
250            "  [1] OS credential store (unavailable: {})",
251            request.os.detail
252        );
253    }
254    if request.file.available {
255        eprintln!("  [2] Local file under ~/.rho/credentials (not encrypted at rest)");
256    } else {
257        eprintln!("  [2] Local file (unavailable: {})", request.file.detail);
258    }
259    let default_backend = request
260        .default_backend()
261        .unwrap_or(CredentialStoreBackend::Os);
262    let default_hint = match default_backend {
263        CredentialStoreBackend::Os => "1",
264        CredentialStoreBackend::File => "2",
265    };
266    eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
267    io::stderr().flush()?;
268
269    let mut answer = String::new();
270    io::stdin().read_line(&mut answer)?;
271    let backend = match answer.trim() {
272        "" => default_backend,
273        "1" | "os" | "OS" => CredentialStoreBackend::Os,
274        "2" | "file" | "FILE" => CredentialStoreBackend::File,
275        other => {
276            anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
277        }
278    };
279    if !backends.contains(&backend) {
280        let detail = request.detail_for(backend);
281        anyhow::bail!(
282            "{} credential store is unavailable: {detail}",
283            backend.as_str()
284        );
285    }
286
287    let path = crate::credential_store::set_backend(backend, config_path)?;
288    config.credential_store = Some(backend);
289    eprintln!(
290        "credential store set to {} in {}",
291        backend.as_str(),
292        path.display()
293    );
294    Ok(())
295}
296
297fn run_credential_store_command(
298    command: &CredentialStoreCommand,
299    config_path: Option<std::path::PathBuf>,
300) -> anyhow::Result<()> {
301    match command {
302        CredentialStoreCommand::Probe { backend } => {
303            let result = crate::credential_store::probe(*backend);
304            if result.available {
305                println!("available: {}", result.detail);
306                Ok(())
307            } else {
308                anyhow::bail!(result.detail)
309            }
310        }
311        CredentialStoreCommand::Status => {
312            // Saved config policy only (ignore RHO_CREDENTIAL_STORE).
313            match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
314                None => println!("unset"),
315                Some(backend) => println!("{}", backend.as_str()),
316            }
317            Ok(())
318        }
319        CredentialStoreCommand::Set { backend } => {
320            let path = crate::credential_store::set_backend(*backend, config_path)?;
321            println!(
322                "credential store set to {} in {}",
323                backend.as_str(),
324                path.display()
325            );
326            Ok(())
327        }
328    }
329}
330
331fn host_capabilities(
332    cli: &Cli,
333    config: &crate::config::Config,
334    role: AgentRole,
335) -> crate::agent::AgentCapabilities {
336    use crate::agent::ToolCapability;
337
338    if cli.no_tools {
339        return crate::agent::AgentCapabilities::default();
340    }
341    let mut tools = crate::agent::AgentCapabilities::all_host_tools();
342    if !crate::tools::web::access_tools(config).is_available() {
343        tools.remove(&ToolCapability::WebSearch);
344    }
345    #[cfg(windows)]
346    tools.remove(&ToolCapability::Bash);
347    #[cfg(not(windows))]
348    tools.remove(&ToolCapability::Powershell);
349    if cli.no_subagents || !config.enable_subagents {
350        tools.remove(&ToolCapability::Agent);
351        tools.remove(&ToolCapability::Agents);
352    }
353    if role != AgentRole::InteractiveRoot {
354        tools.remove(&ToolCapability::Questionnaire);
355    }
356    #[cfg(debug_assertions)]
357    if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
358        tools.insert(ToolCapability::Extension(
359            crate::tools::tui_fixture::NAME.into(),
360        ));
361    }
362    tools
363}
364
365fn absolute_config_path(repository: &ConfigRepository) -> anyhow::Result<std::path::PathBuf> {
366    let path = repository.configured_path()?;
367    if path.is_absolute() {
368        Ok(path)
369    } else {
370        Ok(std::env::current_dir()?.join(path))
371    }
372}
373
374fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
375    if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
376        anyhow::bail!(
377            "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
378        );
379    }
380    Ok(())
381}
382
383fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
384    matches!(
385        error,
386        ModelError::MissingApiKey
387            | ModelError::MissingCodexAuth
388            | ModelError::MissingAnthropicApiKey
389            | ModelError::MissingGithubCopilotAuth
390            | ModelError::MissingXaiApiKey
391            | ModelError::MissingXaiAuth
392            | ModelError::Credentials(_)
393            | ModelError::UnsupportedProvider(_)
394    )
395}
396
397#[cfg(test)]
398#[path = "bootstrap_tests.rs"]
399mod tests;