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