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