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 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 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 #[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
526const FIRST_RUN_OVERRIDE_VAR: &str = "RHO_FIRST_RUN";
529
530fn 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
545fn 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;