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