toast_api/
unified_cli.rs

1use crate::utils::extract_org_id_from_cookie;
2use anyhow::{anyhow, Context, Result};
3use ctrlc;
4use std::fs;
5use std::future::Future;
6use std::io::{self, Write};
7use std::path::Path;
8use std::pin::Pin;
9use std::process::{self, Command, Stdio};
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12
13use crate::api::{Attachment, Claude, Session as ClaudeSession};
14use crate::config::{HAIKU_MODEL, MAX_INTERNAL_ITERS, OPUS_MODEL, SONNET_MODEL, SYSTEM_PROMPT};
15use crate::deepseek::{DeepSeek, Session as DeepSeekSession};
16use crate::utils::{extract_commands, prettify};
17use log::debug;
18
19/// Unified CLI arguments for both Claude and DeepSeek
20#[derive(Debug)]
21pub struct UnifiedArgs {
22    pub use_deepseek: bool,
23    pub use_opus: bool,
24    pub use_haiku: bool,
25}
26
27/// Execute a shell command and capture its output
28fn execute_command(command: &str) -> Result<String> {
29    let result = Command::new("sh")
30        .arg("-c")
31        .arg(command)
32        .stdout(Stdio::piped())
33        .stderr(Stdio::piped())
34        .spawn()?;
35
36    let output = result.wait_with_output()?;
37    let mut msg = String::new();
38
39    if !output.stdout.is_empty() {
40        msg.push_str("=== STDOUT ===\n");
41        msg.push_str(&String::from_utf8_lossy(&output.stdout));
42        msg.push('\n');
43    }
44
45    if !output.stderr.is_empty() {
46        msg.push_str("=== STDERR ===\n");
47        msg.push_str(&String::from_utf8_lossy(&output.stderr));
48        msg.push('\n');
49    }
50
51    msg.push_str(&format!(
52        "Exit code: {}",
53        output.status.code().unwrap_or(-1)
54    ));
55
56    Ok(msg)
57}
58
59/// Read files into attachments for Claude
60fn collect_claude_attachments(paths: &[&str]) -> Result<Vec<Attachment>> {
61    const LIMIT: usize = 5;
62    const SIZE_LIMIT: u64 = 10 * 1024 * 1024;
63
64    if paths.len() > LIMIT {
65        return Err(anyhow!("cannot attach more than {LIMIT} files"));
66    }
67
68    let mut atts = Vec::new();
69
70    for p in paths {
71        if let Ok(meta) = fs::metadata(p) {
72            if meta.len() > SIZE_LIMIT {
73                eprintln!("Warning: file {p} is larger than 10 MB, skipping");
74                continue;
75            }
76
77            if let Ok(content) = fs::read_to_string(p) {
78                atts.push(Attachment {
79                    file_name: Path::new(p)
80                        .file_name()
81                        .unwrap_or_default()
82                        .to_string_lossy()
83                        .into(),
84                    size: meta.len(),
85                    content,
86                });
87            } else {
88                eprintln!("Warning: couldn't read file {p}");
89            }
90        } else {
91            eprintln!("Warning: couldn't access file {p}");
92        }
93    }
94
95    Ok(atts)
96}
97
98/// Run the unified CLI application with provider selection
99pub async fn run(args: UnifiedArgs) -> Result<()> {
100    // Set up Ctrl-C handler
101    let running = Arc::new(AtomicBool::new(true));
102    {
103        let running = running.clone();
104        ctrlc::set_handler(move || {
105            running.store(false, Ordering::SeqCst);
106            println!("\nGoodbye!");
107            process::exit(0);
108        })?;
109    }
110
111    if args.use_deepseek {
112        run_deepseek(args, running).await
113    } else {
114        run_claude(args, running).await
115    }
116}
117
118/// Run the CLI with DeepSeek provider
119async fn run_deepseek(args: UnifiedArgs, running: Arc<AtomicBool>) -> Result<()> {
120    // Load session values from config files
121    let config_dir = dirs::config_dir()
122        .ok_or_else(|| anyhow!("Could not determine config directory"))?
123        .join("toast")
124        .join("deepseek");
125
126    // Create config directory if it doesn't exist
127    if !config_dir.exists() {
128        fs::create_dir_all(&config_dir)?;
129    }
130
131    let auth_token_path = config_dir.join("auth_token");
132    let cookies_path = config_dir.join("cookies.json");
133
134    // Check auth token
135    let auth_token = if auth_token_path.exists() {
136        fs::read_to_string(&auth_token_path)
137            .context(format!(
138                "Failed to read auth token from {auth_token_path:?}"
139            ))?
140            .trim()
141            .to_string()
142    } else {
143        return Err(anyhow!(
144            "Auth token file not found at {:?}\n\nTo get your DeepSeek auth token:\n1. Go to chat.deepseek.com in your browser\n2. Open Developer Tools (F12)\n3. Go to Network tab\n4. Look for Authorization header in any request\n5. Save the token part (without 'Bearer ') to this file",
145            auth_token_path
146        ));
147    };
148
149    // Check cookies
150    let cookies = if cookies_path.exists() {
151        serde_json::from_str(
152            &fs::read_to_string(&cookies_path)
153                .context(format!("Failed to read cookies from {cookies_path:?}"))?,
154        )?
155    } else {
156        return Err(anyhow!(
157            "Cookies file not found at {:?}\n\nDeepSeek requires Cloudflare cookies.\nUse the deepseek4free library to generate them.",
158            cookies_path
159        ));
160    };
161
162    let session = DeepSeekSession {
163        auth_token,
164        cookies,
165    };
166
167    // Determine model based on flags - R1 is the reasoning model with thinking enabled
168    let model = if args.use_opus {
169        "deepseek-r1" // Use R1 reasoning model for opus
170    } else if args.use_haiku {
171        "deepseek-lite"
172    } else {
173        "deepseek-r1" // Default to R1 reasoning model
174    };
175
176    let mut deepseek = DeepSeek::new(session)?;
177
178    let stdin = io::stdin();
179    let mut stdout = io::stdout();
180
181    // Track if system prompt has been sent
182    let mut system_prompt_sent = false;
183
184    // Create a new chat session
185    println!("Starting new DeepSeek chat session...");
186    let chat_id = match deepseek.create_chat_session().await {
187        Ok(id) => {
188            println!("Session started with DeepSeek!\n");
189            id
190        }
191        Err(e) => {
192            return Err(anyhow!("Failed to create DeepSeek chat session: {}", e));
193        }
194    };
195
196    // Enable detailed thinking for reasoning model
197    let thinking_mode = if model == "deepseek-r1" {
198        crate::deepseek::ThinkingMode::Detailed
199    } else {
200        crate::deepseek::ThinkingMode::Simple
201    };
202    let search_mode = crate::deepseek::SearchMode::Disabled;
203
204    // Main chat loop - simplified like working deepseek_cli.rs
205    while running.load(Ordering::SeqCst) {
206        print!("You: ");
207        stdout.flush()?;
208
209        let mut buf = String::new();
210        match stdin.read_line(&mut buf) {
211            Ok(0) => {
212                // EOF reached, exit gracefully
213                println!("\nGoodbye!");
214                break;
215            }
216            Ok(_) => {
217                let input = buf.trim_end();
218
219                // Check for empty input or exit commands
220                if input.is_empty() {
221                    continue;
222                }
223
224                if input.eq_ignore_ascii_case("/exit")
225                    || input.eq_ignore_ascii_case("exit")
226                    || input == "x"
227                {
228                    break;
229                }
230
231                // Send message to API - let DeepSeek handle all commands in its response
232                print!("DeepSeek: ");
233                stdout.flush()?;
234
235                debug!("Sending to DeepSeek API...");
236
237                // Include system prompt only on first message
238                let system_prompt = if !system_prompt_sent {
239                    system_prompt_sent = true;
240                    Some(SYSTEM_PROMPT)
241                } else {
242                    None
243                };
244
245                match deepseek
246                    .chat_completion(
247                        &chat_id,
248                        input,
249                        None,
250                        thinking_mode,
251                        search_mode,
252                        system_prompt,
253                    )
254                    .await
255                {
256                    Ok(response) => {
257                        debug!("Got response, length: {}", response.len());
258                        println!("{}", prettify(&response));
259
260                        // Process commands in the response
261                        process_deepseek_commands(
262                            &mut deepseek,
263                            &chat_id,
264                            &response,
265                            thinking_mode,
266                            search_mode,
267                        )
268                        .await?;
269                    }
270                    Err(e) => {
271                        debug!("DeepSeek API error: {e}");
272                        eprintln!("\nError: {e}");
273                    }
274                }
275                println!();
276            }
277            Err(e) => {
278                eprintln!("Failed to read input: {e}");
279                break;
280            }
281        }
282    }
283
284    Ok(())
285}
286
287/// Process commands in DeepSeek's response
288async fn process_deepseek_commands(
289    deepseek: &mut DeepSeek,
290    chat_id: &str,
291    response: &str,
292    thinking_mode: crate::deepseek::ThinkingMode,
293    search_mode: crate::deepseek::SearchMode,
294) -> Result<()> {
295    process_deepseek_commands_internal(deepseek, chat_id, response, thinking_mode, search_mode, 0)
296        .await
297}
298
299fn process_deepseek_commands_internal<'a>(
300    deepseek: &'a mut DeepSeek,
301    chat_id: &'a str,
302    response: &'a str,
303    thinking_mode: crate::deepseek::ThinkingMode,
304    search_mode: crate::deepseek::SearchMode,
305    depth: usize,
306) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
307    Box::pin(async move {
308        // Limit recursion depth
309        const MAX_DEPTH: usize = 20;
310        if depth >= MAX_DEPTH {
311            println!(
312                "Maximum command processing depth reached ({MAX_DEPTH}). Returning to user."
313            );
314            return Ok(());
315        }
316
317        // Extract read_file and exec commands
318        let (reads, execs) = extract_commands(response);
319
320        if reads.is_empty() && execs.is_empty() {
321            return Ok(());
322        }
323
324        // Short pause before processing commands
325        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
326
327        // Process file reads
328        if !reads.is_empty() {
329            let mut file_contents = Vec::new();
330
331            for path in &reads {
332                match fs::read_to_string(path) {
333                    Ok(content) => {
334                        file_contents.push(format!("=== File: {path} ===\n{content}"));
335                    }
336                    Err(e) => {
337                        file_contents.push(format!("Error reading file {path}: {e}"));
338                    }
339                }
340            }
341
342            let file_message = format!(
343                "Here are the contents of the files you requested:\n\n{}",
344                file_contents.join("\n\n")
345            );
346
347            // print!("Sending file contents... ");
348            io::stdout().flush()?;
349
350            match deepseek
351                .chat_completion(
352                    chat_id,
353                    &file_message,
354                    None,
355                    thinking_mode,
356                    search_mode,
357                    None,
358                )
359                .await
360            {
361                Ok(response) => {
362                    println!("Done!");
363                    println!("DeepSeek: {}", prettify(&response));
364
365                    // Process next level of commands
366                    process_deepseek_commands_internal(
367                        deepseek,
368                        chat_id,
369                        &response,
370                        thinking_mode,
371                        search_mode,
372                        depth + 1,
373                    )
374                    .await?;
375                }
376                Err(e) => {
377                    println!("Error: {e}");
378                }
379            }
380        }
381
382        // Process exec commands
383        if !execs.is_empty() {
384            for cmd in &execs {
385                println!("\nExecuting: {cmd}");
386
387                match execute_command(cmd) {
388                    Ok(output) => {
389                        println!("{output}");
390
391                        print!("Sending command results... ");
392                        io::stdout().flush()?;
393
394                        let cmd_message =
395                            format!("Command executed: {cmd}\n\nOutput:\n{output}");
396
397                        match deepseek
398                            .chat_completion(
399                                chat_id,
400                                &cmd_message,
401                                None,
402                                thinking_mode,
403                                search_mode,
404                                None,
405                            )
406                            .await
407                        {
408                            Ok(response) => {
409                                println!("Done!");
410                                println!("DeepSeek: {}", prettify(&response));
411
412                                // Process next level of commands
413                                process_deepseek_commands_internal(
414                                    deepseek,
415                                    chat_id,
416                                    &response,
417                                    thinking_mode,
418                                    search_mode,
419                                    depth + 1,
420                                )
421                                .await?;
422                            }
423                            Err(e) => {
424                                println!("Error: {e}");
425                            }
426                        }
427                    }
428                    Err(e) => {
429                        println!("Error executing command: {e}");
430                    }
431                }
432            }
433        }
434
435        Ok(())
436    })
437}
438
439/// Run the CLI with Claude provider
440async fn run_claude(args: UnifiedArgs, running: Arc<AtomicBool>) -> Result<()> {
441    // Load session values from config files
442    let config_dir = dirs::config_dir()
443        .ok_or_else(|| anyhow!("Could not determine config directory"))?
444        .join("toast");
445
446    let cookie_path = config_dir.join("cookie");
447    let org_id_path = config_dir.join("org_id");
448
449    // Check if config directory exists, if not create it and provide instructions
450    if !config_dir.exists() {
451        fs::create_dir_all(&config_dir).context(format!(
452            "Failed to create config directory at {config_dir:?}"
453        ))?;
454        return Err(anyhow!(
455            "Configuration directory created at {:?}\n\nPlease create a cookie file with your Claude cookie", 
456            config_dir,
457        ));
458    }
459
460    // Check and load cookie
461    let cookie = if cookie_path.exists() {
462        fs::read_to_string(&cookie_path)
463            .context(format!("Failed to read cookie from {cookie_path:?}"))?
464            .trim()
465            .to_string()
466    } else {
467        return Err(anyhow!("Cookie file not found at {:?}", cookie_path,));
468    };
469
470    // Check and load org_id, or extract from cookie if file doesn't exist
471    let org_id = if org_id_path.exists() {
472        fs::read_to_string(&org_id_path)
473            .context(format!(
474                "Failed to read organization ID from {org_id_path:?}"
475            ))?
476            .trim()
477            .to_string()
478    } else {
479        // Try to extract org_id from cookie
480        if let Some(extracted_org_id) = extract_org_id_from_cookie(&cookie) {
481            // Save the extracted org_id to the file for future use
482            fs::write(&org_id_path, &extracted_org_id).context(format!(
483                "Failed to write organization ID to {org_id_path:?}"
484            ))?;
485            println!(
486                "Extracted organization ID from cookie and saved to {org_id_path:?}"
487            );
488            extracted_org_id
489        } else {
490            return Err(anyhow!(
491                "Organization ID file not found at {:?} and couldn't extract it from cookie.",
492                org_id_path,
493            ));
494        }
495    };
496
497    let user_agent =
498        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0"
499            .to_string();
500
501    let session = ClaudeSession {
502        cookie,
503        user_agent,
504        organization_id: org_id,
505    };
506
507    // Determine model based on flags
508    let model: &str = if args.use_opus {
509        OPUS_MODEL
510    } else if args.use_haiku {
511        HAIKU_MODEL
512    } else {
513        SONNET_MODEL
514    };
515
516    let claude = Claude::new(session.clone(), model)?;
517    println!("Starting new Claude chat session using model: {model}");
518
519    let stdin = io::stdin();
520    let mut stdout = io::stdout();
521    let mut chat_id = String::new();
522    let mut system_prompt_sent = false;
523
524    while running.load(Ordering::SeqCst) {
525        print!("You: ");
526        stdout.flush()?;
527        let mut buf = String::new();
528        stdin.read_line(&mut buf)?;
529        let input = buf.trim_end();
530        if input.is_empty() {
531            continue;
532        }
533        if input.eq_ignore_ascii_case("/exit") || input.eq_ignore_ascii_case("exit") || input == "x"
534        {
535            if !chat_id.is_empty() {
536                claude.delete_chat(&chat_id).await.ok();
537            }
538            break;
539        }
540
541        // Initialize chat
542        if chat_id.is_empty() {
543            chat_id = claude.create_chat().await.context("creating chat")?;
544        }
545
546        // Handle exec commands
547        if let Some(caps) = crate::utils::EXEC_RE.captures(input) {
548            let cmd = caps[1].to_string();
549            if !system_prompt_sent {
550                claude
551                    .send_message(&chat_id, SYSTEM_PROMPT, &[])
552                    .await
553                    .context("sending system prompt")?;
554                system_prompt_sent = true;
555            }
556
557            match execute_command(&cmd) {
558                Ok(output) => {
559                    let msg = format!("Command executed: {cmd}\n\n{output}");
560                    let ans = claude.send_message(&chat_id, &msg, &[]).await?;
561                    println!("Claude:\n{}", prettify(&ans));
562                    process_claude_commands(&claude, &chat_id, &ans).await?;
563                }
564                Err(e) => {
565                    eprintln!("Warning: command execution failed: {e}");
566                    let msg = format!("Command execution failed: {e}");
567                    let ans = claude.send_message(&chat_id, &msg, &[]).await?;
568                    println!("Claude:\n{}", prettify(&ans));
569                }
570            }
571            continue;
572        }
573
574        // Handle read_file commands
575        if let Some(caps) = crate::utils::READ_RE.captures(input) {
576            let paths: Vec<String> = caps[1].split_whitespace().map(String::from).collect();
577            let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
578            if !system_prompt_sent {
579                claude
580                    .send_message(&chat_id, SYSTEM_PROMPT, &[])
581                    .await
582                    .context("sending system prompt")?;
583                system_prompt_sent = true;
584            }
585
586            let rest = input.strip_prefix(&caps[0]).unwrap_or("").trim();
587            let attachments = collect_claude_attachments(&path_refs).unwrap_or_default();
588            let ans = claude
589                .send_message(&chat_id, rest, &attachments)
590                .await
591                .context("sending user message")?;
592
593            println!("Claude:\n{}", prettify(&ans));
594            process_claude_commands(&claude, &chat_id, &ans).await?;
595        } else {
596            // Regular message
597            if !system_prompt_sent {
598                claude
599                    .send_message(&chat_id, SYSTEM_PROMPT, &[])
600                    .await
601                    .context("sending system prompt")?;
602                system_prompt_sent = true;
603            }
604
605            let ans = claude
606                .send_message(&chat_id, input, &[])
607                .await
608                .context("sending user message")?;
609
610            println!("Claude:\n{}", prettify(&ans));
611            process_claude_commands(&claude, &chat_id, &ans).await?;
612        }
613    }
614
615    Ok(())
616}
617
618/// Process Claude's responses for internal tool commands
619async fn process_claude_commands(claude: &Claude, chat_id: &str, response: &str) -> Result<()> {
620    process_claude_commands_internal(claude, chat_id, response, 0).await
621}
622
623fn process_claude_commands_internal<'a>(
624    claude: &'a Claude,
625    chat_id: &'a str,
626    response: &'a str,
627    depth: usize,
628) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
629    Box::pin(async move {
630        // Avoid infinite recursion
631        if depth >= MAX_INTERNAL_ITERS {
632            println!("Max internal iterations reached, returning to user.");
633            return Ok(());
634        }
635
636        let (reads, execs) = extract_commands(response);
637        if reads.is_empty() && execs.is_empty() {
638            return Ok(());
639        }
640
641        if !reads.is_empty() {
642            let atts =
643                collect_claude_attachments(&reads.iter().map(String::as_str).collect::<Vec<_>>())
644                    .unwrap_or_default();
645
646            match claude
647                .send_message(chat_id, "read_file response:", &atts)
648                .await
649            {
650                Ok(resp) => {
651                    println!("Claude:\n{}", prettify(&resp));
652                    return process_claude_commands_internal(claude, chat_id, &resp, depth + 1)
653                        .await;
654                }
655                Err(e) => {
656                    return Err(e);
657                }
658            }
659        }
660
661        if !execs.is_empty() {
662            let mut outputs = String::new();
663
664            for cmd in &execs {
665                match execute_command(cmd) {
666                    Ok(output) => outputs.push_str(&output),
667                    Err(e) => outputs.push_str(&format!("Command execution failed: {e}")),
668                }
669                outputs.push_str("\n\n---\n\n");
670            }
671
672            match claude.send_message(chat_id, &outputs, &[]).await {
673                Ok(resp) => {
674                    println!("Claude:\n{}", prettify(&resp));
675                    return process_claude_commands_internal(claude, chat_id, &resp, depth + 1)
676                        .await;
677                }
678                Err(e) => {
679                    return Err(e);
680                }
681            }
682        }
683
684        Ok(())
685    })
686}