release/1.3.1
rs 222 lines 8.03 KB
Raw
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 }