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