1use std::io::{self, IsTerminal, Write};
9use std::sync::Arc;
10
11fn is_stdin_tty() -> bool {
14 io::stdin().is_terminal()
15}
16
17use crate::auth::{AuthStore, Credential};
18use crate::config::{Config, ConfigStore, FsConfigStore, ProviderConfig};
19use crate::event::AutonomyLevel;
20use crate::memory::Memory;
21use crate::provider::{Brain, BrainEvent, BrainRequest, ContentBlock, Msg, PromptCacheConfig};
22
23use futures::StreamExt;
24
25pub async fn run_setup_agent(
28 config: &Config,
29 store: &FsConfigStore,
30 memory: Arc<dyn Memory>,
31 build_brains: impl Fn(
32 &Config,
33 &Arc<dyn Memory>,
34 bool,
35 ) -> std::collections::HashMap<String, Vec<Arc<dyn Brain>>>,
36) -> anyhow::Result<()> {
37 print_welcome(config);
38
39 let providers = build_brains(config, &memory, false);
41 let any_brain: Option<Arc<dyn Brain>> = providers
42 .values()
43 .flat_map(|chain| chain.iter())
44 .next()
45 .cloned();
46
47 let Some(brain) = any_brain else {
48 eprintln!(
49 "⚠ Aucun modèle disponible pour piloter le Setup Agent.\n → fallback en mode interactif simple.\n"
50 );
51 return fallback_interactive(config, store).await;
52 };
53
54 println!("Setup Agent piloté par : {}\n", brain.id());
55 println!("Décris ce que tu veux en langage naturel.");
56 println!("Exemple :");
57 println!(" \"Use my NVIDIA and Anthropic keys, keep daily cost under €5,");
58 println!(
59 " default to trusted autonomy, reach me on Telegram, code in a hardened sandbox.\"\n"
60 );
61 print!("> ");
62 io::stdout().flush().ok();
63
64 let mut intent = String::new();
65 io::stdin().read_line(&mut intent)?;
66 let intent = intent.trim().to_string();
67 if intent.is_empty() {
68 eprintln!("Aucun intent fourni, setup annulé.");
69 return Ok(());
70 }
71
72 let json_value = ask_llm_for_setup(&brain, &intent, None).await?;
74
75 let mut value = json_value;
77 if let Some(question) = value
78 .get("clarifying_question")
79 .and_then(|v| v.as_str())
80 .filter(|s| !s.is_empty())
81 .map(str::to_string)
82 {
83 println!("\n{}\n", question);
84 print!("> ");
85 io::stdout().flush().ok();
86 let mut clarif = String::new();
87 io::stdin().read_line(&mut clarif)?;
88 let clarif = clarif.trim().to_string();
89 value = ask_llm_for_setup(&brain, &intent, Some(&clarif)).await?;
90 }
91
92 let updated = apply_setup_json(config, &value)?;
94
95 let missing: Vec<String> = value
97 .get("missing_keys")
98 .and_then(|v| v.as_array())
99 .map(|arr| {
100 arr.iter()
101 .filter_map(|v| v.as_str().map(|s| s.to_string()))
102 .collect()
103 })
104 .unwrap_or_default();
105 if !missing.is_empty() {
106 let auth = crate::auth::store::ChainedAuthStore::new(updated.config_dir.clone());
107 let interactive = is_stdin_tty();
112 for env_var in &missing {
113 let provider_id = env_var.to_lowercase().replace("_api_key", "");
115 if !interactive {
116 println!(
117 "\nSkipping API key prompt for {} ({}) — stdin is not a TTY. \
118 Set the env var or run `sparrow setup` in an interactive shell.",
119 provider_id, env_var
120 );
121 continue;
122 }
123 print!("\nPaste API key for {} ({}): ", provider_id, env_var);
124 io::stdout().flush().ok();
125 let key = match rpassword::read_password() {
126 Ok(k) => k.trim().to_string(),
127 Err(e) => {
128 eprintln!(
129 "\nwarning: could not read API key for {}: {}. Skipping.",
130 provider_id, e
131 );
132 continue;
133 }
134 };
135 if !key.is_empty() {
136 auth.set(&provider_id, Credential::api_key(key))?;
137 println!(" ✓ Credential stored for {}", provider_id);
138 }
139 }
140 }
141
142 store.save(&updated)?;
144 print_summary(&updated, &value);
145 Ok(())
146}
147
148fn print_welcome(config: &Config) {
149 println!("══════════════════════════════════════════════════");
150 println!(" Sparrow Setup Agent (§16)");
151 println!("══════════════════════════════════════════════════");
152 println!(" Config dir : {:?}", config.config_dir);
153 println!(" State dir : {:?}", config.state_dir);
154 println!();
155}
156
157const SETUP_SYSTEM_PROMPT: &str = r#"You are the Sparrow Setup Agent. The user describes in natural language how they want Sparrow configured. Output ONLY valid JSON conforming to this exact schema (no markdown, no commentary):
158
159{
160 "providers_to_enable": ["<provider_id>", ...],
161 "routing_policy": {"trivial": "<id>", "small": "<id>", "medium": "<id>", "hard": "<id>", "vision": "<id>"},
162 "budget_daily_usd": <number>,
163 "budget_session_usd": <number>,
164 "default_autonomy": "supervised" | "trusted" | "autonomous",
165 "default_sandbox": "local" | "local-hardened" | "docker",
166 "surfaces": {
167 "telegram": {"enabled": <bool>, "allow_users": [<numbers>]},
168 "discord": {"enabled": <bool>, "allow_users": [<strings>]},
169 "slack": {"enabled": <bool>, "allow_users": [<strings>]}
170 },
171 "missing_keys": ["ANTHROPIC_API_KEY", ...],
172 "clarifying_question": null | "<one short question if absolutely needed>"
173}
174
175Valid provider ids: anthropic, openai-codex, nvidia, openrouter, deepseek, gemini, xai, huggingface, nous, novita, alibaba, bedrock, kimi-coding, minimax, xiaomi, zai, gmi, arcee, stepfun, custom, azure-foundry, qwen-oauth, opencode-zen, kilocode, copilot, alibaba-coding-plan, ollama, groq, together, cerebras, mistral, fireworks, perplexity, cohere.
176
177Rules:
178- Map user mentions ("NVIDIA", "Anthropic", "Telegram", "local") to the right keys.
179- If the user mentions a cost cap in euros or dollars, set both daily_usd and session_usd (session_usd typically 1/5 of daily).
180- Only ask a clarifying_question if a critical field is genuinely ambiguous; otherwise null.
181- For each provider in providers_to_enable, add its standard env-key name to missing_keys.
182- Default to autonomy=trusted, sandbox=local-hardened if not specified.
183"#;
184
185async fn ask_llm_for_setup(
186 brain: &Arc<dyn Brain>,
187 intent: &str,
188 clarification: Option<&str>,
189) -> anyhow::Result<serde_json::Value> {
190 let mut messages = vec![Msg {
191 role: "user".into(),
192 content: vec![ContentBlock::Text {
193 text: format!("User intent:\n{}", intent),
194 }],
195 }];
196 if let Some(c) = clarification {
197 messages.push(Msg {
198 role: "user".into(),
199 content: vec![ContentBlock::Text {
200 text: format!("Clarification:\n{}", c),
201 }],
202 });
203 }
204
205 let req = BrainRequest {
206 system: Some(SETUP_SYSTEM_PROMPT.into()),
207 messages,
208 tools: vec![],
209 max_tokens: 1500,
210 temperature: 0.0,
211 stop: vec![],
212 cache: PromptCacheConfig::disabled(),
213 };
214
215 let mut stream = brain.complete(req).await?;
216 let mut buf = String::new();
217 while let Some(evt) = stream.next().await {
218 match evt {
219 BrainEvent::TextDelta(t) => buf.push_str(&t),
220 BrainEvent::Done(_) => break,
221 BrainEvent::Error(e) => anyhow::bail!("Setup Agent LLM error: {}", e),
222 _ => {}
223 }
224 }
225
226 let cleaned = buf
228 .trim()
229 .trim_start_matches("```json")
230 .trim_start_matches("```")
231 .trim_end_matches("```")
232 .trim();
233
234 serde_json::from_str(cleaned)
235 .map_err(|e| anyhow::anyhow!("Setup Agent returned non-JSON: {}\nRaw:\n{}", e, buf))
236}
237
238fn apply_setup_json(base: &Config, value: &serde_json::Value) -> anyhow::Result<Config> {
239 let mut next = base.clone();
240 let registry: std::collections::HashSet<String> = crate::config::providers::provider_registry()
241 .into_iter()
242 .map(|p| p.id)
243 .collect();
244
245 if let Some(arr) = value.get("providers_to_enable").and_then(|v| v.as_array()) {
247 for v in arr {
248 let Some(id) = v.as_str() else { continue };
249 if !registry.contains(id) {
250 eprintln!(" ! Unknown provider id '{}' — skipped.", id);
251 continue;
252 }
253 let def = crate::config::providers::find_provider(id);
254 next.providers
255 .entry(id.to_string())
256 .or_insert_with(|| ProviderConfig {
257 adapter: def
258 .as_ref()
259 .map(|d| d.adapter.clone())
260 .unwrap_or_else(|| "openai-compatible".into()),
261 base_url: def.as_ref().map(|d| d.base_url.clone()),
262 models: def
263 .as_ref()
264 .map(|d| crate::config::providers::default_models(&d.id))
265 .unwrap_or_default(),
266 api_key_env: def.and_then(|d| d.api_key_env),
267 });
268 }
269 }
270
271 if let Some(obj) = value.get("routing_policy").and_then(|v| v.as_object()) {
273 for (tier, provider) in obj {
274 if let Some(p) = provider.as_str() {
275 if registry.contains(p) {
276 next.routing.policy.insert(tier.clone(), p.to_string());
277 }
278 }
279 }
280 }
281
282 if let Some(d) = value.get("budget_daily_usd").and_then(|v| v.as_f64()) {
284 next.budget.daily_usd = d.max(0.0);
285 }
286 if let Some(s) = value.get("budget_session_usd").and_then(|v| v.as_f64()) {
287 next.budget.session_usd = s.max(0.0);
288 }
289
290 if let Some(s) = value.get("default_autonomy").and_then(|v| v.as_str()) {
292 next.defaults.autonomy = match s {
293 "supervised" => AutonomyLevel::Supervised,
294 "trusted" => AutonomyLevel::Trusted,
295 "autonomous" => AutonomyLevel::Autonomous,
296 _ => next.defaults.autonomy,
297 };
298 }
299
300 if let Some(s) = value.get("default_sandbox").and_then(|v| v.as_str()) {
302 next.defaults.sandbox = s.to_string();
303 }
304
305 if let Some(tg) = value.pointer("/surfaces/telegram") {
307 let enabled = tg.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
308 let allow_users: Vec<String> = tg
309 .get("allow_users")
310 .and_then(|v| v.as_array())
311 .map(|arr| {
312 arr.iter()
313 .filter_map(|v| {
314 v.as_str()
315 .map(String::from)
316 .or_else(|| v.as_i64().map(|n| n.to_string()))
317 })
318 .collect()
319 })
320 .unwrap_or_default();
321 if enabled {
322 next.surfaces.telegram = Some(crate::config::MessagingSurface {
323 enabled: true,
324 allow_users,
325 token_env: Some("TELEGRAM_BOT_TOKEN".into()),
326 });
327 }
328 }
329
330 Ok(next)
331}
332
333fn print_summary(config: &Config, value: &serde_json::Value) {
334 println!("\n══════════════════════════════════════════════════");
335 println!(" Setup terminé");
336 println!("══════════════════════════════════════════════════");
337 let provs: Vec<String> = config.providers.keys().cloned().collect();
338 println!(" Providers : {}", provs.join(", "));
339 println!(
340 " Budget : ${:.2}/day, ${:.2}/session",
341 config.budget.daily_usd, config.budget.session_usd
342 );
343 println!(" Autonomy : {:?}", config.defaults.autonomy);
344 println!(" Sandbox : {}", config.defaults.sandbox);
345 if let Some(tg) = &config.surfaces.telegram {
346 if tg.enabled {
347 println!(
348 " Telegram : enabled ({} users allowed)",
349 tg.allow_users.len()
350 );
351 }
352 }
353 if let Some(arr) = value.get("missing_keys").and_then(|v| v.as_array()) {
354 if !arr.is_empty() {
355 println!(
356 " Missing keys : {}",
357 arr.iter()
358 .filter_map(|v| v.as_str())
359 .collect::<Vec<_>>()
360 .join(", ")
361 );
362 }
363 }
364 println!("\n→ Run 'sparrow doctor' to verify.\n");
365}
366
367async fn fallback_interactive(config: &Config, store: &FsConfigStore) -> anyhow::Result<()> {
369 println!("Fallback minimal: configuring ollama (local) as default.\n");
370 let mut next = config.clone();
371 next.providers.insert(
372 "ollama".into(),
373 ProviderConfig {
374 adapter: "ollama".into(),
375 base_url: Some("http://localhost:11434/v1".into()),
376 models: vec!["qwen3.5:32b".into()],
377 api_key_env: None,
378 },
379 );
380 next.routing
381 .policy
382 .insert("trivial".into(), "ollama".into());
383 next.routing.policy.insert("small".into(), "ollama".into());
384 next.routing.policy.insert("medium".into(), "ollama".into());
385 next.routing.policy.insert("hard".into(), "ollama".into());
386 store.save(&next)?;
387 println!(
388 "Ollama configured locally. Add real provider keys with 'sparrow auth add <provider>'."
389 );
390 Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn apply_setup_json_maps_intent_to_config() {
399 let base = Config::default();
400 let json = serde_json::json!({
401 "providers_to_enable": ["anthropic", "nvidia", "not-a-real-provider"],
402 "routing_policy": {"medium": "nvidia", "hard": "anthropic"},
403 "budget_daily_usd": 5.0,
404 "budget_session_usd": 1.0,
405 "default_autonomy": "trusted",
406 "default_sandbox": "local-hardened",
407 "surfaces": {"telegram": {"enabled": true, "allow_users": [1780070685]}},
408 "missing_keys": ["ANTHROPIC_API_KEY"],
409 "clarifying_question": null
410 });
411 let cfg = apply_setup_json(&base, &json).unwrap();
412 assert!(cfg.providers.contains_key("anthropic"));
413 assert!(cfg.providers.contains_key("nvidia"));
414 assert!(
415 !cfg.providers.contains_key("not-a-real-provider"),
416 "unknown provider must be skipped"
417 );
418 assert_eq!(
419 cfg.routing.policy.get("medium").map(String::as_str),
420 Some("nvidia")
421 );
422 assert_eq!(
423 cfg.routing.policy.get("hard").map(String::as_str),
424 Some("anthropic")
425 );
426 assert_eq!(cfg.budget.daily_usd, 5.0);
427 assert_eq!(cfg.defaults.autonomy, crate::event::AutonomyLevel::Trusted);
428 assert_eq!(cfg.defaults.sandbox, "local-hardened");
429 let tg = cfg.surfaces.telegram.expect("telegram enabled");
430 assert!(tg.enabled);
431 assert_eq!(tg.allow_users, vec!["1780070685".to_string()]);
432 }
433}