| 1 | //! Command-line pieces every Onde agent shares: `--setup` (choose a provider, store and verify |
| 2 | //! its key), `--list-models`, flag parsing and logging. |
| 3 | |
| 4 | use std::io::IsTerminal; |
| 5 | |
| 6 | use anyhow::Context; |
| 7 | |
| 8 | use crate::AgentInfo; |
| 9 | use crate::llm::{ |
| 10 | LOCAL_DEFAULT_MODEL, LlmClient, LlmConfig, LlmEnv, OPENAI_DEFAULT_BASE_URL, |
| 11 | OPENAI_DEFAULT_MODEL, Provider, |
| 12 | }; |
| 13 | |
| 14 | /// Log to stderr, filtered by `RUST_LOG`. Stdout is reserved for ACP. |
| 15 | pub fn init_logging() { |
| 16 | let _ = tracing_subscriber::fmt() |
| 17 | .with_writer(std::io::stderr) |
| 18 | .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) |
| 19 | .try_init(); |
| 20 | } |
| 21 | |
| 22 | /// `1` or `true` (any case). |
| 23 | pub fn env_flag(name: &str) -> bool { |
| 24 | std::env::var(name).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) |
| 25 | } |
| 26 | |
| 27 | /// Onde credentials are `app-id:app-secret`: exactly one colon, both halves non-empty. |
| 28 | pub fn valid_onde_key(key: &str) -> bool { |
| 29 | let mut parts = key.split(':'); |
| 30 | matches!( |
| 31 | (parts.next(), parts.next(), parts.next()), |
| 32 | (Some(id), Some(secret), None) if !id.is_empty() && !secret.is_empty() |
| 33 | ) |
| 34 | } |
| 35 | |
| 36 | /// `env` file content with `ONDE_API_KEY` set, keeping any other lines. |
| 37 | pub fn with_api_key(existing: &str, key: &str) -> String { |
| 38 | with_vars(existing, &[("ONDE_API_KEY".to_string(), key.to_string())]) |
| 39 | } |
| 40 | |
| 41 | /// `env` file content with each of `vars` set, replacing earlier values and keeping any |
| 42 | /// other lines. |
| 43 | pub fn with_vars(existing: &str, vars: &[(String, String)]) -> String { |
| 44 | let mut lines: Vec<String> = existing |
| 45 | .lines() |
| 46 | .filter(|l| { |
| 47 | let l = l.trim().strip_prefix("export ").unwrap_or(l.trim()); |
| 48 | !vars.iter().any(|(k, _)| l.starts_with(&format!("{k}="))) |
| 49 | }) |
| 50 | .map(String::from) |
| 51 | .collect(); |
| 52 | lines.extend(vars.iter().map(|(k, v)| format!("{k}={v}"))); |
| 53 | lines.join("\n") + "\n" |
| 54 | } |
| 55 | |
| 56 | /// Interactive first-run setup (`--setup`): choose Onde Inference or an OpenAI API compatible |
| 57 | /// endpoint, prompt for its key, verify it, and store it in the platform config dir |
| 58 | /// (`config_dir()/env`, mode 0600). |
| 59 | pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> { |
| 60 | use std::io::Write; |
| 61 | |
| 62 | if !std::io::stdin().is_terminal() { |
| 63 | anyhow::bail!("--setup needs an interactive terminal"); |
| 64 | } |
| 65 | let prompt_line = |label: &str| -> anyhow::Result<String> { |
| 66 | print!("{label}"); |
| 67 | std::io::stdout().flush()?; |
| 68 | let mut line = String::new(); |
| 69 | if std::io::stdin().read_line(&mut line)? == 0 { |
| 70 | anyhow::bail!("no input"); |
| 71 | } |
| 72 | Ok(line.trim().to_string()) |
| 73 | }; |
| 74 | |
| 75 | println!("{} setup\n", info.name); |
| 76 | println!(" 1) Onde Inference (ONDE_API_KEY)"); |
| 77 | println!( |
| 78 | " 2) OpenAI API compatible endpoint (OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL)" |
| 79 | ); |
| 80 | let local = cfg!(feature = "local"); |
| 81 | if local { |
| 82 | println!(" 3) On-device (runs here, no key; downloads a model)"); |
| 83 | } |
| 84 | let last = if local { 3 } else { 2 }; |
| 85 | let provider = loop { |
| 86 | match prompt_line(&format!("\nChoose a provider [1-{last}]: "))?.as_str() { |
| 87 | "" | "1" | "onde" => break Provider::Onde, |
| 88 | "2" | "openai" => break Provider::OpenAi, |
| 89 | "3" | "local" if local => break Provider::Local, |
| 90 | _ => println!("Please enter a number from 1 to {last}."), |
| 91 | } |
| 92 | }; |
| 93 | let mut vars = vec![(info.env.var("PROVIDER"), provider.name().to_string())]; |
| 94 | match provider { |
| 95 | Provider::Onde => { |
| 96 | println!("\nGet credentials: sign in at https://ondeinference.com/root/login,"); |
| 97 | println!("register an app and assign a model. Your key is \"app-id:app-secret\".\n"); |
| 98 | } |
| 99 | // A generic endpoint also needs its URL and a model that supports tool calling. |
| 100 | Provider::OpenAi => { |
| 101 | for (var, label, default) in [ |
| 102 | ("OPENAI_BASE_URL", "Base URL", OPENAI_DEFAULT_BASE_URL), |
| 103 | ("OPENAI_MODEL", "Model", OPENAI_DEFAULT_MODEL), |
| 104 | ] { |
| 105 | let value = prompt_line(&format!("{label} [{default}]: "))?; |
| 106 | let value = if value.is_empty() { |
| 107 | default.to_string() |
| 108 | } else { |
| 109 | value |
| 110 | }; |
| 111 | vars.push((var.to_string(), value)); |
| 112 | } |
| 113 | } |
| 114 | Provider::Local => { |
| 115 | println!( |
| 116 | "\nThe model ({}) downloads the first time the agent needs it.", |
| 117 | LOCAL_DEFAULT_MODEL |
| 118 | ); |
| 119 | println!("Set {} to pick another one.", info.env.var("MODEL")); |
| 120 | return write_env(info, &vars); |
| 121 | } |
| 122 | } |
| 123 | let key_var = provider.key_var(); |
| 124 | let key = loop { |
| 125 | let key = prompt_line(&format!("Paste your {key_var}: "))?; |
| 126 | match provider { |
| 127 | Provider::Onde if !valid_onde_key(&key) => { |
| 128 | println!("Onde credentials look like \"app-id:app-secret\" (exactly one colon).") |
| 129 | } |
| 130 | _ if key.is_empty() => println!("Key must not be empty."), |
| 131 | _ => break key, |
| 132 | } |
| 133 | }; |
| 134 | vars.push((key_var.to_string(), key)); |
| 135 | |
| 136 | // Verify before writing anything: the new values win over the environment. |
| 137 | let config = LlmConfig::from_lookup(&info.env, |name| { |
| 138 | vars.iter() |
| 139 | .find(|(k, _)| k == name) |
| 140 | .map(|(_, v)| v.clone()) |
| 141 | .or_else(|| std::env::var(name).ok()) |
| 142 | }); |
| 143 | print!("Verifying the key with {}… ", provider.display_name()); |
| 144 | std::io::stdout().flush()?; |
| 145 | match LlmClient::new(config).check_auth().await { |
| 146 | Ok(()) => println!("OK"), |
| 147 | Err(e) => anyhow::bail!("\nKey check failed: {e:#}\nNothing was written."), |
| 148 | } |
| 149 | |
| 150 | write_env(info, &vars) |
| 151 | } |
| 152 | |
| 153 | /// Store `vars` in the agent's `env` file (mode 0600), keeping its other lines. |
| 154 | fn write_env(info: &AgentInfo, vars: &[(String, String)]) -> anyhow::Result<()> { |
| 155 | let dir = info |
| 156 | .env |
| 157 | .config_dir() |
| 158 | .context("no config directory available")?; |
| 159 | std::fs::create_dir_all(&dir)?; |
| 160 | let path = dir.join("env"); |
| 161 | let existing = std::fs::read_to_string(&path).unwrap_or_default(); |
| 162 | std::fs::write(&path, with_vars(&existing, vars))?; |
| 163 | #[cfg(unix)] |
| 164 | { |
| 165 | use std::os::unix::fs::PermissionsExt; |
| 166 | std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; |
| 167 | } |
| 168 | println!("\nWrote {}. You're all set.", path.display()); |
| 169 | Ok(()) |
| 170 | } |
| 171 | |
| 172 | /// Print the models the configured endpoint serves, one per line (`id<TAB>owner`). |
| 173 | pub async fn list_models(info: &AgentInfo) -> anyhow::Result<()> { |
| 174 | let client = LlmClient::new(LlmConfig::from_env(&info.env)); |
| 175 | for m in client.models().await? { |
| 176 | match m.owned_by { |
| 177 | Some(owner) => println!("{}\t{}", m.id, owner), |
| 178 | None => println!("{}", m.id), |
| 179 | } |
| 180 | } |
| 181 | Ok(()) |
| 182 | } |
| 183 | |
| 184 | /// How the agent was launched: `tui` (interactive terminal UI) or `acp` (editor). |
| 185 | /// The TUI sets `<PREFIX>_SURFACE=tui` on its subprocess; editors launch `--acp` |
| 186 | /// directly so the default is `acp`. |
| 187 | pub fn surface(env: &LlmEnv) -> &'static str { |
| 188 | match std::env::var(env.var("SURFACE")) { |
| 189 | Ok(s) if s == "tui" => "tui", |
| 190 | _ => "acp", |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | #[cfg(test)] |
| 195 | mod tests { |
| 196 | use super::*; |
| 197 | |
| 198 | #[test] |
| 199 | fn onde_key_shape() { |
| 200 | assert!(valid_onde_key("app:secret")); |
| 201 | assert!(!valid_onde_key("secret")); |
| 202 | assert!(!valid_onde_key("a:b:c")); |
| 203 | assert!(!valid_onde_key(":secret")); |
| 204 | assert!(!valid_onde_key("app:")); |
| 205 | assert!(!valid_onde_key("")); |
| 206 | } |
| 207 | |
| 208 | #[test] |
| 209 | fn env_file_keeps_other_lines_and_replaces_key() { |
| 210 | let out = with_api_key("# hi\nX_MODEL=m\nexport ONDE_API_KEY=old:old\n", "new:key"); |
| 211 | assert_eq!(out, "# hi\nX_MODEL=m\nONDE_API_KEY=new:key\n"); |
| 212 | assert_eq!(with_api_key("", "a:b"), "ONDE_API_KEY=a:b\n"); |
| 213 | let vars = [ |
| 214 | ("X_PROVIDER".to_string(), "openai".to_string()), |
| 215 | ("OPENAI_API_KEY".to_string(), "sk".to_string()), |
| 216 | ]; |
| 217 | assert_eq!( |
| 218 | with_vars("X_PROVIDER=onde\nONDE_API_KEY=a:b\n", &vars), |
| 219 | "ONDE_API_KEY=a:b\nX_PROVIDER=openai\nOPENAI_API_KEY=sk\n" |
| 220 | ); |
| 221 | } |
| 222 | } |