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, mcp_cli, plugins_cli,
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::Mcp { command }) = &cli.command {
144 return Ok(EarlyDispatch::Handled(mcp_cli::run(command, cli).await));
145 }
146 if let Some(Command::Plugins { command }) = &cli.command {
147 return Ok(EarlyDispatch::Handled(plugins_cli::run(command, cli)));
148 }
149 if let Some(Command::Attach { id }) = &cli.command {
150 let display = crate::tui::AttachmentDisplaySettings::from_config(
154 &ConfigRepository::new(cli.config.clone()).load()?,
155 );
156 return Ok(EarlyDispatch::Handled(
157 crate::tui::run_attachment(id, display, HerdrReporter::from_env()).await,
158 ));
159 }
160 if matches!(cli.command, Some(Command::Update)) {
161 return Ok(EarlyDispatch::Handled(
162 update::run_update(env!("CARGO_PKG_VERSION")).await,
163 ));
164 }
165 if let Some(Command::Login {
166 provider,
167 device_auth,
168 }) = &cli.command
169 {
170 let config_repository = ConfigRepository::new(cli.config.clone());
171 let mut config = config_repository.load()?;
172 let config_path = absolute_config_path(&config_repository)?;
173 ensure_cli_credential_store_choice(&mut config, Some(config_path.clone()))?;
174 crate::credential_store::initialize_from_config(&mut config, &config_path)?;
175 return Ok(EarlyDispatch::Handled(
176 login::run(provider, *device_auth).await,
177 ));
178 }
179 Ok(EarlyDispatch::Continue)
180}
181
182struct PreparedStartup {
183 cli: Cli,
184 config: crate::config::Config,
185 config_repository: ConfigRepository,
186 first_run: Option<SetupEntry>,
187 cwd: std::path::PathBuf,
188 automation_prompt: Option<String>,
189 output_file: Option<std::path::PathBuf>,
190 output: OutputFormat,
191 max_steps: Option<NonZeroUsize>,
192 timeout: Option<Duration>,
193 bound_agent: super::agent_binding::BoundAgent,
194 bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
195 provider_refresh: cli_config::ProviderRefreshStatus,
196 store: AppCredentialStore,
197}
198
199async fn prepare_startup(cli: Cli) -> anyhow::Result<PreparedStartup> {
200 let config_path = cli.config.clone();
201 let config_repository = ConfigRepository::new(config_path.clone());
202 let first_run = detect_first_run(&config_repository);
204 let mut config = config_repository.load()?;
205 let absolute_config = absolute_config_path(&config_repository)?;
206 crate::credential_store::initialize_from_config(&mut config, &absolute_config)?;
207 let cwd = std::env::current_dir()?;
208 let automation_prompt = automation::prompt_for_command(&cli.command)?;
209 let (output_file, output, max_steps, timeout) = match &cli.command {
210 Some(Command::Run {
211 output_file,
212 output,
213 max_steps,
214 timeout,
215 ..
216 }) => (output_file.clone(), *output, *max_steps, *timeout),
217 _ => (None, OutputFormat::Text, None, None),
218 };
219 let catalog = crate::agent::AgentCatalog::discover(&cwd)?;
220 let selected_agent = cli.agent.as_deref().unwrap_or("default");
221 let definition = Arc::new(catalog.find(selected_agent)?.definition.clone());
222
223 let store = AppCredentialStore;
224 let provider_refresh = cli_config::refresh_model_cache(&cli, &config, &store).await?;
225 let permission_mode_before_override = config.permission_mode;
226 let config_changed = cli_config::apply_overrides(&mut config, &cli)?;
227 cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
228 tokio::spawn(rho_providers::model::models_dev::ensure_models_dev_catalog());
232 cli_config::normalize_reasoning_for_cli(
233 &mut config,
234 if cli.reasoning.is_some() {
235 rho_providers::model::ReasoningRequestSource::Explicit
236 } else {
237 rho_providers::model::ReasoningRequestSource::PersistedOrDefault
238 },
239 )?;
240 if cli.save && config_changed {
245 let session_permission_mode = config.permission_mode;
246 config.permission_mode = permission_mode_before_override;
247 config_repository.save(&config)?;
248 config.permission_mode = session_permission_mode;
249 }
250 let reasoning_before_binding = config.reasoning;
251 let role = if automation_prompt.is_some() {
252 AgentRole::AutomationRoot
253 } else {
254 AgentRole::InteractiveRoot
255 };
256 let bound_agent = AgentBinder::bind(
257 definition,
258 AgentInvocation {
259 role,
260 available_tools: host_capabilities(&cli, &config, role),
261 },
262 &config,
263 )?;
264 config = bound_agent.rho_config().cloned().unwrap_or(config);
265 let bound_reasoning_source =
266 if cli.reasoning.is_some() && config.reasoning == reasoning_before_binding {
267 rho_providers::model::ReasoningRequestSource::Explicit
268 } else {
269 rho_providers::model::ReasoningRequestSource::PersistedOrDefault
270 };
271
272 Ok(PreparedStartup {
273 cli,
274 config,
275 config_repository,
276 first_run,
277 cwd,
278 automation_prompt,
279 output_file,
280 output,
281 max_steps,
282 timeout,
283 bound_agent,
284 bound_reasoning_source,
285 provider_refresh,
286 store,
287 })
288}
289
290struct AutomationStartup<'a> {
291 prompt: String,
292 config: &'a crate::config::Config,
293 config_repository: &'a ConfigRepository,
294 cwd: std::path::PathBuf,
295 cli: &'a Cli,
296 bound_agent: super::agent_binding::BoundAgent,
297 output_file: Option<std::path::PathBuf>,
298 output: OutputFormat,
299 max_steps: Option<NonZeroUsize>,
300 timeout: Option<Duration>,
301 herdr: HerdrReporter,
302}
303
304async fn run_automation_startup(startup: AutomationStartup<'_>) -> anyhow::Result<()> {
305 let diagnostics = bind_agent_diagnostics(startup.config, &startup.bound_agent);
306 automation::run(
307 startup.prompt,
308 automation::Startup {
309 config: startup.config,
310 config_path: absolute_config_path(startup.config_repository)?,
311 cwd: startup.cwd,
312 no_system_prompt: startup.cli.no_system_prompt,
313 no_tools: startup.cli.no_tools,
314 no_subagents: startup.cli.no_subagents,
315 usage_purpose: "agent",
316 parent_session_id: None,
317 agent: startup.bound_agent,
318 output_file: startup.output_file,
319 output: startup.output,
320 max_steps: startup.max_steps,
321 timeout: startup.timeout,
322 diagnostics,
323 herdr: startup.herdr,
324 host_input: None,
325 notice_poster: None,
326 steering_slot: None,
327 approval_session: None,
328 approval_classifier: None,
329 hook_host_labels: rho_sdk::hooks::HookHostLabels::new(),
330 },
331 )
332 .await
333}
334
335struct InteractiveStartup<'a> {
336 cli: &'a Cli,
337 config: crate::config::Config,
338 config_repository: ConfigRepository,
339 first_run: Option<SetupEntry>,
340 cwd: std::path::PathBuf,
341 bound_agent: super::agent_binding::BoundAgent,
342 bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
343 herdr: HerdrReporter,
344}
345
346async fn run_interactive_startup(startup: InteractiveStartup<'_>) -> anyhow::Result<()> {
347 let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
348
349 let pending_update_notice = startup
350 .config
351 .check_for_updates
352 .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
353
354 let sdk_options = SdkBootstrapOptions::from_config(&startup.config, &startup.cwd)?;
355 let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
356 Arc::new(AppCredentialStore),
357 );
358 let provider_result = rho_providers::providers::build_sdk_provider_with_source(
359 sdk_options.provider,
360 &credentials,
361 );
362 let (missing_auth_error, missing_auth_model_error) = match provider_result {
363 Ok(_) => (None, None),
364 Err(error) if is_interactive_startup_unavailable_error(&error) => {
365 (Some(error.to_string()), Some(error))
366 }
367 Err(error) => return Err(error.into()),
368 };
369 interactive::run(interactive::Startup {
370 cli: startup.cli,
371 config: startup.config,
372 config_path: absolute_config_path(&startup.config_repository)?,
373 config_repository: startup.config_repository,
374 cwd: startup.cwd,
375 first_run: startup.first_run,
376 missing_auth_error,
377 missing_auth_model_error,
378 pending_update_notice,
379 diagnostics,
380 herdr: startup.herdr,
381 agent: startup.bound_agent,
382 reasoning_source: startup.bound_reasoning_source,
383 })
384 .await
385}
386
387fn bind_agent_diagnostics(
388 config: &crate::config::Config,
389 agent: &super::agent_binding::BoundAgent,
390) -> RuntimeDiagnostics {
391 let diagnostics = RuntimeDiagnostics::new(config);
392 diagnostics.update_agent(agent.id().as_str(), &agent.fingerprint().to_string());
393 diagnostics
394}
395
396fn ensure_cli_credential_store_choice(
397 config: &mut crate::config::Config,
398 config_path: Option<std::path::PathBuf>,
399) -> anyhow::Result<()> {
400 use rho_providers::credentials::CredentialStoreBackend;
401 use std::io::{self, IsTerminal, Write};
402
403 let Some(request) = crate::credential_store::choice_request(config) else {
404 return Ok(());
405 };
406
407 if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
408 anyhow::bail!(
409 "credential store is unset; set it before non-interactive login with \
410`rho credential-store set os|file`, behavior.credential_store in config.toml, \
411or RHO_CREDENTIAL_STORE=os|file"
412 );
413 }
414
415 let backends = request.available_backends();
416 if backends.is_empty() {
417 anyhow::bail!(
418 "no credential store backend is available (os: {}; file: {})",
419 request.os.detail,
420 request.file.detail
421 );
422 }
423
424 eprintln!("Choose where Rho stores provider credentials:");
425 eprintln!("This is saved to config and used for future logins on this machine.");
426 if request.os.available {
427 eprintln!(" [1] OS credential store (recommended)");
428 } else {
429 eprintln!(
430 " [1] OS credential store (unavailable: {})",
431 request.os.detail
432 );
433 }
434 if request.file.available {
435 eprintln!(" [2] Local file under ~/.rho/credentials (not encrypted at rest)");
436 } else {
437 eprintln!(" [2] Local file (unavailable: {})", request.file.detail);
438 }
439 let default_backend = request
440 .default_backend()
441 .unwrap_or(CredentialStoreBackend::Os);
442 let default_hint = match default_backend {
443 CredentialStoreBackend::Os => "1",
444 CredentialStoreBackend::File => "2",
445 };
446 eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
447 io::stderr().flush()?;
448
449 let mut answer = String::new();
450 io::stdin().read_line(&mut answer)?;
451 let backend = match answer.trim() {
452 "" => default_backend,
453 "1" | "os" | "OS" => CredentialStoreBackend::Os,
454 "2" | "file" | "FILE" => CredentialStoreBackend::File,
455 other => {
456 anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
457 }
458 };
459 if !backends.contains(&backend) {
460 let detail = request.detail_for(backend);
461 anyhow::bail!(
462 "{} credential store is unavailable: {detail}",
463 backend.as_str()
464 );
465 }
466
467 let path = crate::credential_store::set_backend(backend, config_path)?;
468 config.credential_store = Some(backend);
469 eprintln!(
470 "credential store set to {} in {}",
471 backend.as_str(),
472 path.display()
473 );
474 Ok(())
475}
476
477fn run_credential_store_command(
478 command: &CredentialStoreCommand,
479 config_path: Option<std::path::PathBuf>,
480) -> anyhow::Result<()> {
481 match command {
482 CredentialStoreCommand::Probe { backend } => {
483 let result = crate::credential_store::probe(*backend);
484 if result.available {
485 println!("available: {}", result.detail);
486 Ok(())
487 } else {
488 anyhow::bail!(result.detail)
489 }
490 }
491 CredentialStoreCommand::Status => {
492 match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
494 None => println!("unset"),
495 Some(backend) => println!("{}", backend.as_str()),
496 }
497 Ok(())
498 }
499 CredentialStoreCommand::Set { backend } => {
500 let path = crate::credential_store::set_backend(*backend, config_path)?;
501 println!(
502 "credential store set to {} in {}",
503 backend.as_str(),
504 path.display()
505 );
506 Ok(())
507 }
508 }
509}
510
511pub(super) fn host_capabilities(
512 cli: &Cli,
513 config: &crate::config::Config,
514 role: AgentRole,
515) -> crate::agent::AgentCapabilities {
516 use crate::agent::ToolCapability;
517
518 if cli.no_tools {
519 return crate::agent::AgentCapabilities::default();
520 }
521 let mut tools = crate::agent::AgentCapabilities::all_host_tools();
522 #[cfg(windows)]
524 tools.remove(&ToolCapability::Bash);
525 #[cfg(not(windows))]
526 tools.remove(&ToolCapability::Powershell);
527 if cli.no_subagents || !config.enable_subagents {
528 tools.remove(&ToolCapability::Agent);
529 tools.remove(&ToolCapability::Agents);
530 }
531 if role != AgentRole::InteractiveRoot {
532 tools.remove(&ToolCapability::Questionnaire);
533 }
534 #[cfg(debug_assertions)]
535 if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
536 tools.insert(ToolCapability::Extension(
537 crate::tools::tui_fixture::NAME.into(),
538 ));
539 }
540 tools
541}
542
543pub(super) fn absolute_config_path(
544 repository: &ConfigRepository,
545) -> anyhow::Result<std::path::PathBuf> {
546 let path = repository.configured_path()?;
547 if path.is_absolute() {
548 Ok(path)
549 } else {
550 Ok(std::env::current_dir()?.join(path))
551 }
552}
553
554const FIRST_RUN_OVERRIDE_VAR: &str = "RHO_FIRST_RUN";
557
558fn parse_first_run_override(value: &str) -> Option<SetupEntry> {
565 match value.trim().to_ascii_lowercase().as_str() {
566 "" | "0" | "false" | "no" => None,
567 "signin" | "sign-in" | "login" => Some(SetupEntry::SignIn),
568 "model" | "models" => Some(SetupEntry::ChooseModel),
569 _ => Some(SetupEntry::Auto),
570 }
571}
572
573fn detect_first_run(repository: &ConfigRepository) -> Option<SetupEntry> {
578 if let Ok(value) = std::env::var(FIRST_RUN_OVERRIDE_VAR) {
579 if let Some(entry) = parse_first_run_override(&value) {
580 return Some(entry);
581 }
582 }
583 repository
584 .configured_path()
585 .is_ok_and(|path| !path.exists())
586 .then_some(SetupEntry::Auto)
587}
588
589fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
590 if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
591 anyhow::bail!(
592 "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
593 );
594 }
595 Ok(())
596}
597
598fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
599 matches!(
600 error,
601 ModelError::MissingCredentials(_)
602 | ModelError::Credentials(_)
603 | ModelError::UnsupportedProvider(_)
604 )
605}
606
607#[cfg(test)]
608#[path = "bootstrap_tests.rs"]
609mod tests;