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