Skip to main content

oy_code_cli/
lib.rs

1use clap::Parser;
2use oy_agent::infrastructure::persistence::{
3    find_latest_session, get_session_preview, list_all_sessions,
4};
5use oy_agent::infrastructure::tools::edit::EditTool;
6use oy_agent::infrastructure::tools::read::ReadTool;
7use oy_agent::infrastructure::tools::write::WriteTool;
8use oy_agent::infrastructure::tools::{ToolRegistry, bash::BashTool};
9use oy_ai::AiConfig;
10use serde::Deserialize;
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13use tokio::process::Command;
14
15/// CLI arguments for oy-agent
16#[derive(Parser, Debug)]
17#[command(author, version, about)]
18pub struct CliArgs {
19    #[command(subcommand)]
20    pub command: Option<Commands>,
21
22    /// Prompt to send to the agent (if omitted, launches the TUI)
23    #[arg(short = 'p', long)]
24    pub prompt: Option<String>,
25
26    #[arg(short = 'm', long)]
27    pub model: Option<String>,
28
29    /// Continue latest session (load most recent session, or start new if none)
30    #[arg(short = 'c', long)]
31    pub r#continue: bool,
32
33    /// Restore a session interactively (session selector)
34    #[arg(short = 'r', long)]
35    pub restore: bool,
36
37    /// Load a specific session file by path
38    #[arg(short = 's', long = "session")]
39    pub session: Option<PathBuf>,
40}
41
42#[derive(Parser, Debug)]
43pub enum Commands {
44    /// Update oy CLI tool to the latest version via npm
45    Update,
46}
47
48/// Configuration loaded from ~/.oy-ai-agent/config.toml
49#[derive(Debug, Deserialize, Default)]
50pub struct CliConfig {
51    pub api_key: Option<String>,
52    pub base_url: Option<String>,
53    pub model: Option<String>,
54}
55
56impl CliConfig {
57    /// Load config from ~/.oy-ai-agent/config.toml, returning defaults for missing fields.
58    pub fn load() -> Self {
59        let home = match dirs::home_dir() {
60            Some(h) => h,
61            None => return Self::default(),
62        };
63        let config_path = home.join(".oy-ai-agent").join("config.toml");
64        if !config_path.exists() {
65            return Self::default();
66        }
67        match std::fs::read_to_string(&config_path) {
68            Ok(content) => toml::from_str(&content).unwrap_or_default(),
69            Err(_) => Self::default(),
70        }
71    }
72}
73
74/// Build an `AiConfig` by merging CLI args, config file, and defaults.
75///
76/// Priority (highest first):
77///   1. CLI argument (`--model`)
78///   2. Config file (`~/.oy-ai-agent/config.toml`)
79///   3. Hardcoded default
80///
81/// `api_key` is required: if none of the sources provide it, the process exits.
82pub fn build_provider_config(cli_config: &CliConfig, cli_args: &CliArgs) -> AiConfig {
83    let api_key = cli_config.api_key.clone().unwrap_or_else(|| {
84        eprintln!(
85            "API key is not set. Set it in ~/.oy-ai-agent/config.toml:\n\n\
86             [api_key]\n\
87             api_key = \"sk-or-...\""
88        );
89        std::process::exit(1);
90    });
91
92    let base_url = cli_config
93        .base_url
94        .clone()
95        .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
96
97    let model = cli_args
98        .model
99        .clone()
100        .or_else(|| cli_config.model.clone())
101        .unwrap_or_else(|| "anthropic/claude-haiku-4.5".to_string());
102
103    AiConfig::new(base_url, api_key, model)
104}
105
106/// Register the default set of tools (Read, Write, Bash).
107pub fn register_default_tools(registry: &mut ToolRegistry) {
108    registry.register(ReadTool);
109    registry.register(WriteTool);
110    registry.register(EditTool);
111    registry.register(BashTool);
112}
113
114/// Run the agent with the given CLI arguments, or launch the TUI if no prompt is given.
115pub async fn run(args: CliArgs) -> Result<(), anyhow::Error> {
116    // 1. Update subcommand
117    if matches!(args.command, Some(Commands::Update)) {
118        return run_update().await;
119    }
120
121    // 2. Continue latest session
122    if args.r#continue {
123        return run_continue_session().await;
124    }
125
126    // 3. Restore session from interactive selector
127    if args.restore {
128        return run_restore_session().await;
129    }
130
131    // 4. Load a specific session file by path
132    if let Some(path) = &args.session {
133        return run_session_path(path).await;
134    }
135
136    // 5. Existing logic: launch TUI (fresh) or handle direct prompt
137    if args.prompt.is_some() {
138        // TODO: implement direct prompt mode
139        return Ok(());
140    }
141
142    // Launch fresh TUI
143    oy_tui::run_tui(None)
144        .await
145        .map_err(|e| anyhow::Error::msg(format!("{}", e)))?;
146    Ok(())
147}
148
149// ── Update subcommand ──────────────────────────────────────────
150
151async fn run_update() -> Result<(), anyhow::Error> {
152    let timeout = Duration::from_secs(300);
153
154    // First attempt: default registry
155    println!(
156        "⏳ Running: npm install -g @ghyper9023/oy (timeout: {}s)...",
157        timeout.as_secs()
158    );
159    match run_npm(&["install", "-g", "@ghyper9023/oy"], timeout).await {
160        Ok(output) => {
161            let stdout = String::from_utf8_lossy(&output.stdout);
162            let stderr = String::from_utf8_lossy(&output.stderr);
163            if !stderr.is_empty() {
164                println!("{}", stderr);
165            }
166            println!("✅ Update successful:\n{}", stdout);
167            return Ok(());
168        }
169        Err(e) => {
170            println!("⚠️  First attempt failed: {}", e);
171            println!("⏳ Retrying with npm official registry...");
172        }
173    }
174
175    // Second attempt: official npm registry
176    match run_npm(
177        &[
178            "install",
179            "-g",
180            "@ghyper9023/oy",
181            "--registry",
182            "https://registry.npmjs.org/",
183        ],
184        timeout,
185    )
186    .await
187    {
188        Ok(output) => {
189            let stdout = String::from_utf8_lossy(&output.stdout);
190            let stderr = String::from_utf8_lossy(&output.stderr);
191            if !stderr.is_empty() {
192                println!("{}", stderr);
193            }
194            println!("✅ Update successful:\n{}", stdout);
195            Ok(())
196        }
197        Err(e) => {
198            eprintln!("❌ Update failed: {}", e);
199            std::process::exit(1);
200        }
201    }
202}
203
204async fn run_npm(args: &[&str], timeout: Duration) -> Result<std::process::Output, anyhow::Error> {
205    let child = Command::new("npm").args(args).kill_on_drop(true).output();
206
207    tokio::time::timeout(timeout, child)
208        .await
209        .map_err(|_| anyhow::anyhow!("Command timed out after {}s", timeout.as_secs()))?
210        .map_err(|e| anyhow::anyhow!("Failed to execute npm: {}", e))
211        .and_then(|output| {
212            if output.status.success() {
213                Ok(output)
214            } else {
215                let stderr = String::from_utf8_lossy(&output.stderr);
216                Err(anyhow::anyhow!(
217                    "npm exited with code {}: {}",
218                    output.status.code().unwrap_or(-1),
219                    stderr.trim()
220                ))
221            }
222        })
223}
224
225// ── Session commands ───────────────────────────────────────────
226
227async fn run_continue_session() -> Result<(), anyhow::Error> {
228    match find_latest_session() {
229        Ok(Some(entry)) => {
230            eprintln!(
231                "📂 Resuming session: {} (project: {})",
232                entry.uuid, entry.project_name
233            );
234            oy_tui::run_tui(Some(entry.path))
235                .await
236                .map_err(|e| anyhow::Error::msg(format!("{}", e)))?;
237        }
238        Ok(None) => {
239            eprintln!("ℹ️  No previous session found. Starting fresh.");
240            oy_tui::run_tui(None)
241                .await
242                .map_err(|e| anyhow::Error::msg(format!("{}", e)))?;
243        }
244        Err(e) => {
245            eprintln!("⚠️  Error finding sessions: {}", e);
246            oy_tui::run_tui(None)
247                .await
248                .map_err(|e| anyhow::Error::msg(format!("{}", e)))?;
249        }
250    }
251    Ok(())
252}
253
254async fn run_restore_session() -> Result<(), anyhow::Error> {
255    let sessions = list_all_sessions()?;
256
257    if sessions.is_empty() {
258        eprintln!("ℹ️  No sessions found. Starting fresh.");
259        oy_tui::run_tui(None)
260            .await
261            .map_err(|e| anyhow::Error::msg(format!("{}", e)))?;
262        return Ok(());
263    }
264
265    // ── Interactive session selector ──
266    eprintln!("\n📋 Select a session to restore:\n");
267    for (i, entry) in sessions.iter().enumerate() {
268        let preview = get_session_preview(&entry.path)
269            .ok()
270            .flatten()
271            .unwrap_or_else(|| "(no user message)".to_string());
272        let uuid_str = entry.uuid.to_string();
273        let uuid_short: String = uuid_str.chars().take(12).collect();
274        eprintln!(
275            "  [{:2}] {}... | {} | {}",
276            i + 1,
277            uuid_short,
278            entry.project_name,
279            preview
280        );
281    }
282    eprintln!("\n  [0] Cancel");
283    eprint!("\nEnter selection (0-{}): ", sessions.len());
284    std::io::Write::flush(&mut std::io::stderr())?;
285
286    let mut input = String::new();
287    std::io::stdin().read_line(&mut input)?;
288    let input = input.trim();
289
290    if let Ok(num) = input.parse::<usize>() {
291        if num == 0 || num > sessions.len() {
292            eprintln!("❌ Cancelled.");
293            return Ok(());
294        }
295        let entry = &sessions[num - 1];
296        eprintln!("📂 Restoring session: {}", entry.uuid);
297        oy_tui::run_tui(Some(entry.path.clone()))
298            .await
299            .map_err(|e| anyhow::Error::msg(format!("{}", e)))?;
300    } else {
301        eprintln!("❌ Invalid selection.");
302    }
303
304    Ok(())
305}
306
307// ── Load session by path ───────────────────────────────────────
308
309async fn run_session_path(path: &Path) -> Result<(), anyhow::Error> {
310    if !path.exists() {
311        eprintln!("❌ Session file not found: {}", path.display());
312        std::process::exit(1);
313    }
314    if !path.is_file() {
315        eprintln!("❌ Path is not a file: {}", path.display());
316        std::process::exit(1);
317    }
318
319    // Validate that the file contains valid session data
320    match oy_agent::infrastructure::persistence::load_session_messages(path) {
321        Ok((uuid, _msgs)) => {
322            eprintln!("📂 Loading session: {} ({})", uuid, path.display());
323            oy_tui::run_tui(Some(path.to_path_buf()))
324                .await
325                .map_err(|e| anyhow::Error::msg(format!("{}", e)))?;
326            Ok(())
327        }
328        Err(e) => {
329            eprintln!("❌ Failed to load session file: {}", e);
330            std::process::exit(1);
331        }
332    }
333}