Skip to main content

omni_dev/cli/ai/
chat.rs

1//! Interactive AI chat command.
2
3use std::io::{self, Write};
4
5use anyhow::Result;
6use clap::Parser;
7use crossterm::{
8    event::{self, Event, KeyCode, KeyModifiers},
9    terminal::enable_raw_mode,
10};
11
12use crate::utils::terminal::RawModeGuard;
13
14/// Interactive AI chat session.
15///
16/// Backend and model selection use the flattened [`AiBackendArgs`] flags
17/// (`--ai-backend`, `--model`, …; propagated as `OMNI_DEV_AI_BACKEND`,
18/// `OMNI_DEV_MODEL`, …) and the per-backend env chain.
19///
20/// [`AiBackendArgs`]: crate::cli::ai_backend_args::AiBackendArgs
21#[derive(Parser)]
22pub struct ChatCommand {
23    /// AI backend selection (`--ai-backend`, `--model`, …).
24    #[command(flatten)]
25    pub ai: crate::cli::ai_backend_args::AiBackendArgs,
26}
27
28impl ChatCommand {
29    /// Executes the chat command.
30    pub async fn execute(self) -> Result<()> {
31        self.ai.apply();
32
33        let ai_info = crate::utils::preflight::check_ai_credentials(None)?;
34        eprintln!(
35            "Connected to {} (model: {})",
36            ai_info.provider, ai_info.model
37        );
38        eprintln!("Enter to send, Shift+Enter for newline, Ctrl+D to exit.\n");
39
40        let client = crate::claude::create_default_claude_client(None, None).await?;
41
42        chat_loop(&client).await
43    }
44}
45
46/// Sends a single user message to the configured AI and returns the response.
47///
48/// Shared between the MCP `ai_chat` tool and any non-interactive CLI callers.
49/// The function performs the same preflight credential check as the CLI chat
50/// loop — on missing credentials it returns the preflight error verbatim so
51/// MCP tool callers see the same diagnostic message the CLI would print.
52///
53/// `model` selects the AI model; `None` uses the environment default.
54/// `system_prompt` defaults to `"You are a helpful assistant."` (matching the
55/// CLI's default) when `None`.
56pub async fn run_chat(
57    message: &str,
58    model: Option<String>,
59    system_prompt: Option<String>,
60) -> Result<String> {
61    crate::utils::preflight::check_ai_credentials(model.as_deref())?;
62    let client = crate::claude::create_default_claude_client(model, None).await?;
63    let system = system_prompt
64        .as_deref()
65        .unwrap_or("You are a helpful assistant.");
66    client.send_message(system, message).await
67}
68
69async fn chat_loop(client: &crate::claude::client::ClaudeClient) -> Result<()> {
70    let system_prompt = "You are a helpful assistant.";
71
72    loop {
73        let input = match read_user_input() {
74            Ok(Some(text)) => text,
75            Ok(None) => {
76                eprintln!("\nGoodbye!");
77                break;
78            }
79            Err(e) => {
80                eprintln!("\nInput error: {e}");
81                break;
82            }
83        };
84
85        let trimmed = input.trim();
86        if trimmed.is_empty() {
87            continue;
88        }
89
90        let response = client.send_message(system_prompt, trimmed).await?;
91        println!("{response}\n");
92    }
93
94    Ok(())
95}
96
97/// Reads multiline user input with "> " prompt.
98///
99/// Returns `Ok(Some(text))` on Enter, `Ok(None)` on Ctrl+D/Ctrl+C.
100fn read_user_input() -> Result<Option<String>> {
101    eprint!("> ");
102    io::stderr().flush()?;
103
104    enable_raw_mode()?;
105    let _guard = RawModeGuard;
106
107    let mut buffer = String::new();
108
109    loop {
110        if let Event::Key(key_event) = event::read()? {
111            match key_event.code {
112                KeyCode::Enter => {
113                    if key_event.modifiers.contains(KeyModifiers::SHIFT) {
114                        buffer.push('\n');
115                        eprint!("\r\n... ");
116                        io::stderr().flush()?;
117                    } else {
118                        eprint!("\r\n");
119                        io::stderr().flush()?;
120                        return Ok(Some(buffer));
121                    }
122                }
123                KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
124                    if buffer.is_empty() {
125                        return Ok(None);
126                    }
127                    eprint!("\r\n");
128                    io::stderr().flush()?;
129                    return Ok(Some(buffer));
130                }
131                KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
132                    return Ok(None);
133                }
134                KeyCode::Char(c) => {
135                    buffer.push(c);
136                    eprint!("{c}");
137                    io::stderr().flush()?;
138                }
139                KeyCode::Backspace if buffer.pop().is_some() => {
140                    eprint!("\x08 \x08");
141                    io::stderr().flush()?;
142                }
143                _ => {}
144            }
145        }
146    }
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used, clippy::expect_used)]
151mod tests {
152    use super::*;
153
154    /// Env-isolation lock — tests in this module must serialise because they
155    /// mutate `HOME` and every provider env var the preflight check reads.
156    ///
157    /// Aliases the crate-wide [`crate::test_support::HOME_ENV_MUTEX`] so
158    /// this module's `HOME` mutation also serialises against
159    /// `mcp::ai_tools::tests` (which mutates the same env vars under its own
160    /// separate lock) and every other domain — see that static's doc
161    /// comment (issue #1465).
162    static ENV_LOCK: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
163
164    const KEYS: &[&str] = &[
165        "OMNI_DEV_AI_BACKEND",
166        "OMNI_DEV_MODEL",
167        "OMNI_DEV_BETA_HEADER",
168        "USE_OPENAI",
169        "USE_OLLAMA",
170        "CLAUDE_CODE_USE_BEDROCK",
171        "CLAUDE_API_KEY",
172        "ANTHROPIC_API_KEY",
173        "ANTHROPIC_AUTH_TOKEN",
174        "ANTHROPIC_BEDROCK_BASE_URL",
175        "OPENAI_API_KEY",
176        "OPENAI_AUTH_TOKEN",
177        "OLLAMA_MODEL",
178        "OLLAMA_BASE_URL",
179        "ANTHROPIC_MODEL",
180        // `_FILE` companions (ADR-0089), so an exported one cannot leak in.
181        "CLAUDE_API_KEY_FILE",
182        "ANTHROPIC_API_KEY_FILE",
183        "ANTHROPIC_AUTH_TOKEN_FILE",
184        "OPENAI_API_KEY_FILE",
185        "OPENAI_AUTH_TOKEN_FILE",
186    ];
187
188    fn snapshot_env() -> Vec<(&'static str, Option<String>)> {
189        let mut v: Vec<(&'static str, Option<String>)> =
190            KEYS.iter().map(|k| (*k, std::env::var(k).ok())).collect();
191        v.push(("HOME", std::env::var("HOME").ok()));
192        v
193    }
194
195    fn restore_env(snap: Vec<(&'static str, Option<String>)>) {
196        for (k, v) in snap {
197            match v {
198                Some(val) => std::env::set_var(k, val),
199                None => std::env::remove_var(k),
200            }
201        }
202    }
203
204    fn isolate_empty_home() -> tempfile::TempDir {
205        let dir = {
206            std::fs::create_dir_all("tmp").ok();
207            tempfile::TempDir::new_in("tmp").unwrap()
208        };
209        std::env::set_var("HOME", dir.path());
210        for k in KEYS {
211            std::env::remove_var(k);
212        }
213        dir
214    }
215
216    #[allow(clippy::await_holding_lock)]
217    #[tokio::test]
218    async fn run_chat_returns_error_when_credentials_missing() {
219        let _guard = ENV_LOCK
220            .lock()
221            .unwrap_or_else(std::sync::PoisonError::into_inner);
222        let snap = snapshot_env();
223        let _home = isolate_empty_home();
224
225        let err = run_chat("hello", None, None).await.unwrap_err();
226        let msg = format!("{err}");
227        assert!(
228            msg.contains("API key not found") || msg.contains("not found"),
229            "expected credential error, got: {msg}"
230        );
231
232        restore_env(snap);
233    }
234
235    #[allow(clippy::await_holding_lock)]
236    #[tokio::test]
237    async fn run_chat_bubbles_up_credential_error_with_custom_system_prompt() {
238        let _guard = ENV_LOCK
239            .lock()
240            .unwrap_or_else(std::sync::PoisonError::into_inner);
241        let snap = snapshot_env();
242        let _home = isolate_empty_home();
243
244        // Custom system prompt should not bypass the preflight check.
245        let err = run_chat("hello", None, Some("be terse".to_string()))
246            .await
247            .unwrap_err();
248        assert!(format!("{err}").contains("not found"));
249
250        restore_env(snap);
251    }
252
253    #[allow(clippy::await_holding_lock)]
254    #[tokio::test]
255    async fn run_chat_propagates_model_override_through_preflight() {
256        let _guard = ENV_LOCK
257            .lock()
258            .unwrap_or_else(std::sync::PoisonError::into_inner);
259        let snap = snapshot_env();
260        let _home = isolate_empty_home();
261
262        // With explicit model override, the same credential check must still run.
263        let err = run_chat("hello", Some("claude-sonnet-4-6".to_string()), None)
264            .await
265            .unwrap_err();
266        assert!(format!("{err}").contains("not found"));
267
268        restore_env(snap);
269    }
270
271    /// Exercises the post-preflight code path (client construction and
272    /// `send_message`) without requiring real AI credentials. Routes through
273    /// Ollama mode, which skips the credential check, and points the client
274    /// at a wiremock server that returns a canned OpenAI-compatible response.
275    #[allow(clippy::await_holding_lock)]
276    #[tokio::test]
277    async fn run_chat_happy_path_via_mocked_ollama_returns_response_text() {
278        let _guard = ENV_LOCK
279            .lock()
280            .unwrap_or_else(std::sync::PoisonError::into_inner);
281        let snap = snapshot_env();
282        let _home = isolate_empty_home();
283
284        let server = wiremock::MockServer::start().await;
285        wiremock::Mock::given(wiremock::matchers::method("POST"))
286            .and(wiremock::matchers::path("/v1/chat/completions"))
287            .respond_with(
288                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
289                    "id": "test",
290                    "object": "chat.completion",
291                    "choices": [{
292                        "index": 0,
293                        "message": {"role": "assistant", "content": "canned-response"},
294                        "finish_reason": "stop"
295                    }]
296                })),
297            )
298            .mount(&server)
299            .await;
300
301        std::env::set_var("USE_OLLAMA", "true");
302        std::env::set_var("OLLAMA_MODEL", "llama2");
303        std::env::set_var("OLLAMA_BASE_URL", server.uri());
304
305        let out = run_chat("hello", None, Some("be terse".to_string()))
306            .await
307            .unwrap();
308        assert_eq!(out, "canned-response");
309
310        restore_env(snap);
311    }
312
313    /// As above but with `system_prompt = None`, exercising the
314    /// `.unwrap_or("You are a helpful assistant.")` default branch.
315    #[allow(clippy::await_holding_lock)]
316    #[tokio::test]
317    async fn run_chat_default_system_prompt_path_via_mocked_ollama() {
318        let _guard = ENV_LOCK
319            .lock()
320            .unwrap_or_else(std::sync::PoisonError::into_inner);
321        let snap = snapshot_env();
322        let _home = isolate_empty_home();
323
324        let server = wiremock::MockServer::start().await;
325        wiremock::Mock::given(wiremock::matchers::method("POST"))
326            .and(wiremock::matchers::path("/v1/chat/completions"))
327            .respond_with(
328                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
329                    "id": "test",
330                    "object": "chat.completion",
331                    "choices": [{
332                        "index": 0,
333                        "message": {"role": "assistant", "content": "ok"},
334                        "finish_reason": "stop"
335                    }]
336                })),
337            )
338            .mount(&server)
339            .await;
340
341        std::env::set_var("USE_OLLAMA", "true");
342        std::env::set_var("OLLAMA_MODEL", "llama2");
343        std::env::set_var("OLLAMA_BASE_URL", server.uri());
344
345        let out = run_chat("hello", None, None).await.unwrap();
346        assert_eq!(out, "ok");
347
348        restore_env(snap);
349    }
350}