1use std::{
2 io::{self, IsTerminal},
3 sync::Arc,
4};
5
6use {
7 crate::cli::{Cli, Command, CredentialStoreCommand, OutputFormat},
8 crate::credential_store::AppCredentialStore,
9 crate::diagnostics::RuntimeDiagnostics,
10 crate::herdr::HerdrReporter,
11 crate::update,
12 rho_providers::model::ModelError,
13};
14
15use super::{
16 agent_binding::{AgentBinder, AgentInvocation, AgentRole},
17 automation, automation_protocol, cli_config,
18 config_repository::ConfigRepository,
19 interactive, login,
20 sdk_config::SdkBootstrapOptions,
21 sessions_cli,
22};
23
24pub async fn run(cli: Cli) -> anyhow::Result<()> {
25 let run_output = match &cli.command {
26 Some(Command::Run { output, .. }) => Some(*output),
27 _ => None,
28 };
29 let result = run_inner(cli).await;
30 let Err(error) = result else {
31 return Ok(());
32 };
33 if error.downcast_ref::<automation::AutomationExit>().is_some()
34 || error
35 .downcast_ref::<automation::AutomationInterrupted>()
36 .is_some()
37 {
38 return Err(error);
39 }
40 if run_output == Some(OutputFormat::Jsonl) {
41 automation::emit_startup_failure()?;
42 return Err(automation::AutomationExit::new(
43 2,
44 automation_protocol::TerminalReason::ConfigurationError,
45 "configuration failed",
46 )
47 .into());
48 }
49 if run_output.is_some() {
50 return Err(automation::AutomationExit::new(
51 2,
52 automation_protocol::TerminalReason::ConfigurationError,
53 error.to_string(),
54 )
55 .into());
56 }
57 Err(error)
58}
59
60async fn run_inner(cli: Cli) -> anyhow::Result<()> {
61 cli_config::validate(&cli)?;
62 if let Some(Command::CredentialStore { command }) = &cli.command {
63 return run_credential_store_command(command, cli.config.clone());
64 }
65 if let Some(Command::Sessions { command }) = &cli.command {
66 return sessions_cli::run(command);
67 }
68 if let Some(Command::Attach { id }) = &cli.command {
69 return crate::tui::run_attachment(id, HerdrReporter::from_env()).await;
70 }
71 if matches!(cli.command, Some(Command::Update)) {
72 return update::run_update(env!("CARGO_PKG_VERSION")).await;
73 }
74 if let Some(Command::Login {
75 provider,
76 device_auth,
77 }) = &cli.command
78 {
79 let config_repository = ConfigRepository::new(cli.config.clone());
80 let mut config = config_repository.load()?;
81 let config_path = absolute_config_path(&config_repository)?;
82 ensure_cli_credential_store_choice(&mut config, Some(config_path.clone()))?;
83 crate::credential_store::initialize_from_config(&mut config, &config_path)?;
84 return login::run(provider, *device_auth).await;
85 }
86
87 let config_path = cli.config.clone();
88 let config_repository = ConfigRepository::new(config_path.clone());
89 let mut config = config_repository.load()?;
90 let absolute_config = absolute_config_path(&config_repository)?;
91 crate::credential_store::initialize_from_config(&mut config, &absolute_config)?;
92 let cwd = std::env::current_dir()?;
93 let automation_prompt = automation::prompt_for_command(&cli.command)?;
94 let (output_file, output, max_steps, timeout) = match &cli.command {
95 Some(Command::Run {
96 output_file,
97 output,
98 max_steps,
99 timeout,
100 ..
101 }) => (output_file.clone(), *output, *max_steps, *timeout),
102 _ => (None, OutputFormat::Text, None, None),
103 };
104 let catalog = crate::agent::AgentCatalog::discover(&cwd)?;
105 let selected_agent = cli.agent.as_deref().unwrap_or("default");
106 let definition = Arc::new(catalog.find(selected_agent)?.definition.clone());
107
108 let store = AppCredentialStore;
109 let provider_refresh = cli_config::refresh_model_cache(&cli, &config, &store).await?;
110 let mut save_config = cli_config::apply_overrides(&mut config, &cli)?;
111 cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
112 save_config |= cli_config::normalize_reasoning_for_cli(
113 &mut config,
114 if cli.reasoning.is_some() {
115 rho_providers::model::ReasoningRequestSource::Explicit
116 } else {
117 rho_providers::model::ReasoningRequestSource::PersistedOrDefault
118 },
119 )?;
120 if save_config {
121 config_repository.save(&config)?;
122 }
123 let reasoning_before_binding = config.reasoning;
124 let role = if automation_prompt.is_some() {
125 AgentRole::AutomationRoot
126 } else {
127 AgentRole::InteractiveRoot
128 };
129 let bound_agent = AgentBinder::bind(
130 definition,
131 AgentInvocation {
132 role,
133 available_tools: host_capabilities(&cli, &config, role),
134 },
135 &config,
136 )?;
137 config = bound_agent.rho_config().cloned().unwrap_or(config);
138
139 validate_terminal_mode(&cli)?;
140 cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
141 let bound_reasoning_source =
142 if cli.reasoning.is_some() && config.reasoning == reasoning_before_binding {
143 rho_providers::model::ReasoningRequestSource::Explicit
144 } else {
145 rho_providers::model::ReasoningRequestSource::PersistedOrDefault
146 };
147 cli_config::normalize_reasoning_for_cli(&mut config, bound_reasoning_source)?;
148 let herdr = HerdrReporter::from_env();
149 if let Some(prompt) = automation_prompt {
150 let diagnostics = RuntimeDiagnostics::new(&config);
151 diagnostics.update_agent(
152 bound_agent.id().as_str(),
153 &bound_agent.fingerprint().to_string(),
154 );
155 return automation::run(
156 prompt,
157 automation::Startup {
158 config: &config,
159 config_path: absolute_config_path(&config_repository)?,
160 cwd,
161 no_system_prompt: cli.no_system_prompt,
162 no_tools: cli.no_tools,
163 no_subagents: cli.no_subagents,
164 usage_purpose: "agent",
165 parent_session_id: None,
166 agent: bound_agent,
167 output_file,
168 output,
169 max_steps,
170 timeout,
171 diagnostics,
172 herdr,
173 host_input: None,
174 },
175 )
176 .await;
177 }
178 let diagnostics = RuntimeDiagnostics::new(&config);
179 diagnostics.update_agent(
180 bound_agent.id().as_str(),
181 &bound_agent.fingerprint().to_string(),
182 );
183
184 let pending_update_notice = config
185 .check_for_updates
186 .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
187
188 let sdk_options = SdkBootstrapOptions::from_config(&config, &cwd)?;
189 let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
190 Arc::new(AppCredentialStore),
191 );
192 let provider_result = rho_providers::providers::build_sdk_provider_with_source(
193 sdk_options.provider,
194 &credentials,
195 );
196 let (missing_auth_error, missing_auth_model_error) = match provider_result {
197 Ok(_) => (None, None),
198 Err(error) if is_interactive_startup_unavailable_error(&error) => {
199 (Some(error.to_string()), Some(error))
200 }
201 Err(error) => return Err(error.into()),
202 };
203 let result = interactive::run(interactive::Startup {
204 cli: &cli,
205 config,
206 config_path: absolute_config_path(&config_repository)?,
207 config_repository,
208 cwd,
209 missing_auth_error,
210 missing_auth_model_error,
211 pending_update_notice,
212 diagnostics,
213 herdr,
214 agent: bound_agent,
215 reasoning_source: bound_reasoning_source,
216 })
217 .await;
218 result
219}
220
221fn ensure_cli_credential_store_choice(
222 config: &mut crate::config::Config,
223 config_path: Option<std::path::PathBuf>,
224) -> anyhow::Result<()> {
225 use rho_providers::credentials::CredentialStoreBackend;
226 use std::io::{self, IsTerminal, Write};
227
228 let Some(request) = crate::credential_store::choice_request(config) else {
229 return Ok(());
230 };
231
232 if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
233 anyhow::bail!(
234 "credential store is unset; set it before non-interactive login with \
235`rho credential-store set os|file`, behavior.credential_store in config.toml, \
236or RHO_CREDENTIAL_STORE=os|file"
237 );
238 }
239
240 let backends = request.available_backends();
241 if backends.is_empty() {
242 anyhow::bail!(
243 "no credential store backend is available (os: {}; file: {})",
244 request.os.detail,
245 request.file.detail
246 );
247 }
248
249 eprintln!("Choose where Rho stores provider credentials:");
250 eprintln!("This is saved to config and used for future logins on this machine.");
251 if request.os.available {
252 eprintln!(" [1] OS credential store (recommended)");
253 } else {
254 eprintln!(
255 " [1] OS credential store (unavailable: {})",
256 request.os.detail
257 );
258 }
259 if request.file.available {
260 eprintln!(" [2] Local file under ~/.rho/credentials (not encrypted at rest)");
261 } else {
262 eprintln!(" [2] Local file (unavailable: {})", request.file.detail);
263 }
264 let default_backend = request
265 .default_backend()
266 .unwrap_or(CredentialStoreBackend::Os);
267 let default_hint = match default_backend {
268 CredentialStoreBackend::Os => "1",
269 CredentialStoreBackend::File => "2",
270 };
271 eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
272 io::stderr().flush()?;
273
274 let mut answer = String::new();
275 io::stdin().read_line(&mut answer)?;
276 let backend = match answer.trim() {
277 "" => default_backend,
278 "1" | "os" | "OS" => CredentialStoreBackend::Os,
279 "2" | "file" | "FILE" => CredentialStoreBackend::File,
280 other => {
281 anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
282 }
283 };
284 if !backends.contains(&backend) {
285 let detail = request.detail_for(backend);
286 anyhow::bail!(
287 "{} credential store is unavailable: {detail}",
288 backend.as_str()
289 );
290 }
291
292 let path = crate::credential_store::set_backend(backend, config_path)?;
293 config.credential_store = Some(backend);
294 eprintln!(
295 "credential store set to {} in {}",
296 backend.as_str(),
297 path.display()
298 );
299 Ok(())
300}
301
302fn run_credential_store_command(
303 command: &CredentialStoreCommand,
304 config_path: Option<std::path::PathBuf>,
305) -> anyhow::Result<()> {
306 match command {
307 CredentialStoreCommand::Probe { backend } => {
308 let result = crate::credential_store::probe(*backend);
309 if result.available {
310 println!("available: {}", result.detail);
311 Ok(())
312 } else {
313 anyhow::bail!(result.detail)
314 }
315 }
316 CredentialStoreCommand::Status => {
317 match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
319 None => println!("unset"),
320 Some(backend) => println!("{}", backend.as_str()),
321 }
322 Ok(())
323 }
324 CredentialStoreCommand::Set { backend } => {
325 let path = crate::credential_store::set_backend(*backend, config_path)?;
326 println!(
327 "credential store set to {} in {}",
328 backend.as_str(),
329 path.display()
330 );
331 Ok(())
332 }
333 }
334}
335
336fn host_capabilities(
337 cli: &Cli,
338 config: &crate::config::Config,
339 role: AgentRole,
340) -> crate::agent::AgentCapabilities {
341 use crate::agent::ToolCapability;
342
343 if cli.no_tools {
344 return crate::agent::AgentCapabilities::default();
345 }
346 let mut tools = crate::agent::AgentCapabilities::all_host_tools();
347 if !crate::tools::web::access_tools(config).is_available() {
348 tools.remove(&ToolCapability::WebSearch);
349 }
350 #[cfg(windows)]
351 tools.remove(&ToolCapability::Bash);
352 #[cfg(not(windows))]
353 tools.remove(&ToolCapability::Powershell);
354 if cli.no_subagents || !config.enable_subagents {
355 tools.remove(&ToolCapability::Agent);
356 tools.remove(&ToolCapability::Agents);
357 }
358 if role != AgentRole::InteractiveRoot {
359 tools.remove(&ToolCapability::Questionnaire);
360 }
361 #[cfg(debug_assertions)]
362 if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
363 tools.insert(ToolCapability::Extension(
364 crate::tools::tui_fixture::NAME.into(),
365 ));
366 }
367 tools
368}
369
370fn absolute_config_path(repository: &ConfigRepository) -> anyhow::Result<std::path::PathBuf> {
371 let path = repository.configured_path()?;
372 if path.is_absolute() {
373 Ok(path)
374 } else {
375 Ok(std::env::current_dir()?.join(path))
376 }
377}
378
379fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
380 if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
381 anyhow::bail!(
382 "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
383 );
384 }
385 Ok(())
386}
387
388fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
389 matches!(
390 error,
391 ModelError::MissingApiKey
392 | ModelError::MissingCodexAuth
393 | ModelError::MissingAnthropicApiKey
394 | ModelError::MissingGithubCopilotAuth
395 | ModelError::MissingXaiApiKey
396 | ModelError::MissingXaiAuth
397 | ModelError::Credentials(_)
398 | ModelError::UnsupportedProvider(_)
399 )
400}
401
402#[cfg(test)]
403#[path = "bootstrap_tests.rs"]
404mod tests;