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