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.rho_config().cloned().unwrap_or(config);
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 host_input: None,
170 },
171 )
172 .await;
173 }
174 let diagnostics = RuntimeDiagnostics::new(&config);
175 diagnostics.update_agent(
176 bound_agent.id().as_str(),
177 &bound_agent.fingerprint().to_string(),
178 );
179
180 let pending_update_notice = config
181 .check_for_updates
182 .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
183
184 let sdk_options = SdkBootstrapOptions::from_config(&config, &cwd)?;
185 let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
186 Arc::new(AppCredentialStore),
187 );
188 let provider_result = rho_providers::providers::build_sdk_provider_with_source(
189 sdk_options.provider,
190 &credentials,
191 );
192 let (missing_auth_error, missing_auth_model_error) = match provider_result {
193 Ok(_) => (None, None),
194 Err(error) if is_interactive_startup_unavailable_error(&error) => {
195 (Some(error.to_string()), Some(error))
196 }
197 Err(error) => return Err(error.into()),
198 };
199 let result = interactive::run(interactive::Startup {
200 cli: &cli,
201 config,
202 config_path: absolute_config_path(&config_repository)?,
203 config_repository,
204 cwd,
205 missing_auth_error,
206 missing_auth_model_error,
207 pending_update_notice,
208 diagnostics,
209 herdr,
210 agent: bound_agent,
211 reasoning_source: bound_reasoning_source,
212 })
213 .await;
214 result
215}
216
217fn ensure_cli_credential_store_choice(
218 config: &mut crate::config::Config,
219 config_path: Option<std::path::PathBuf>,
220) -> anyhow::Result<()> {
221 use rho_providers::credentials::CredentialStoreBackend;
222 use std::io::{self, IsTerminal, Write};
223
224 let Some(request) = crate::credential_store::choice_request(config) else {
225 return Ok(());
226 };
227
228 if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
229 anyhow::bail!(
230 "credential store is unset; set it before non-interactive login with \
231`rho credential-store set os|file`, behavior.credential_store in config.toml, \
232or RHO_CREDENTIAL_STORE=os|file"
233 );
234 }
235
236 let backends = request.available_backends();
237 if backends.is_empty() {
238 anyhow::bail!(
239 "no credential store backend is available (os: {}; file: {})",
240 request.os.detail,
241 request.file.detail
242 );
243 }
244
245 eprintln!("Choose where Rho stores provider credentials:");
246 eprintln!("This is saved to config and used for future logins on this machine.");
247 if request.os.available {
248 eprintln!(" [1] OS credential store (recommended)");
249 } else {
250 eprintln!(
251 " [1] OS credential store (unavailable: {})",
252 request.os.detail
253 );
254 }
255 if request.file.available {
256 eprintln!(" [2] Local file under ~/.rho/credentials (not encrypted at rest)");
257 } else {
258 eprintln!(" [2] Local file (unavailable: {})", request.file.detail);
259 }
260 let default_backend = request
261 .default_backend()
262 .unwrap_or(CredentialStoreBackend::Os);
263 let default_hint = match default_backend {
264 CredentialStoreBackend::Os => "1",
265 CredentialStoreBackend::File => "2",
266 };
267 eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
268 io::stderr().flush()?;
269
270 let mut answer = String::new();
271 io::stdin().read_line(&mut answer)?;
272 let backend = match answer.trim() {
273 "" => default_backend,
274 "1" | "os" | "OS" => CredentialStoreBackend::Os,
275 "2" | "file" | "FILE" => CredentialStoreBackend::File,
276 other => {
277 anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
278 }
279 };
280 if !backends.contains(&backend) {
281 let detail = request.detail_for(backend);
282 anyhow::bail!(
283 "{} credential store is unavailable: {detail}",
284 backend.as_str()
285 );
286 }
287
288 let path = crate::credential_store::set_backend(backend, config_path)?;
289 config.credential_store = Some(backend);
290 eprintln!(
291 "credential store set to {} in {}",
292 backend.as_str(),
293 path.display()
294 );
295 Ok(())
296}
297
298fn run_credential_store_command(
299 command: &CredentialStoreCommand,
300 config_path: Option<std::path::PathBuf>,
301) -> anyhow::Result<()> {
302 match command {
303 CredentialStoreCommand::Probe { backend } => {
304 let result = crate::credential_store::probe(*backend);
305 if result.available {
306 println!("available: {}", result.detail);
307 Ok(())
308 } else {
309 anyhow::bail!(result.detail)
310 }
311 }
312 CredentialStoreCommand::Status => {
313 match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
315 None => println!("unset"),
316 Some(backend) => println!("{}", backend.as_str()),
317 }
318 Ok(())
319 }
320 CredentialStoreCommand::Set { backend } => {
321 let path = crate::credential_store::set_backend(*backend, config_path)?;
322 println!(
323 "credential store set to {} in {}",
324 backend.as_str(),
325 path.display()
326 );
327 Ok(())
328 }
329 }
330}
331
332fn host_capabilities(
333 cli: &Cli,
334 config: &crate::config::Config,
335 role: AgentRole,
336) -> crate::agent::AgentCapabilities {
337 use crate::agent::ToolCapability;
338
339 if cli.no_tools {
340 return crate::agent::AgentCapabilities::default();
341 }
342 let mut tools = crate::agent::AgentCapabilities::all_host_tools();
343 if !crate::tools::web::access_tools(config).is_available() {
344 tools.remove(&ToolCapability::WebSearch);
345 }
346 #[cfg(windows)]
347 tools.remove(&ToolCapability::Bash);
348 #[cfg(not(windows))]
349 tools.remove(&ToolCapability::Powershell);
350 if cli.no_subagents || !config.enable_subagents {
351 tools.remove(&ToolCapability::Agent);
352 tools.remove(&ToolCapability::Agents);
353 }
354 if role != AgentRole::InteractiveRoot {
355 tools.remove(&ToolCapability::Questionnaire);
356 }
357 #[cfg(debug_assertions)]
358 if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
359 tools.insert(ToolCapability::Extension(
360 crate::tools::tui_fixture::NAME.into(),
361 ));
362 }
363 tools
364}
365
366fn absolute_config_path(repository: &ConfigRepository) -> anyhow::Result<std::path::PathBuf> {
367 let path = repository.configured_path()?;
368 if path.is_absolute() {
369 Ok(path)
370 } else {
371 Ok(std::env::current_dir()?.join(path))
372 }
373}
374
375fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
376 if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
377 anyhow::bail!(
378 "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
379 );
380 }
381 Ok(())
382}
383
384fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
385 matches!(
386 error,
387 ModelError::MissingApiKey
388 | ModelError::MissingCodexAuth
389 | ModelError::MissingAnthropicApiKey
390 | ModelError::MissingGithubCopilotAuth
391 | ModelError::MissingXaiApiKey
392 | ModelError::MissingXaiAuth
393 | ModelError::Credentials(_)
394 | ModelError::UnsupportedProvider(_)
395 )
396}
397
398#[cfg(test)]
399#[path = "bootstrap_tests.rs"]
400mod tests;