1use 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#[derive(Parser)]
18pub struct ChatCommand {}
19
20impl ChatCommand {
21 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
36pub 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
87struct RawModeGuard;
89
90impl Drop for RawModeGuard {
91 fn drop(&mut self) {
92 let _ = disable_raw_mode();
93 }
94}
95
96fn 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 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 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 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 #[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 #[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}