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