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#[derive(Parser, Debug)]
18#[command(author, version, about)]
19pub struct CliArgs {
20 #[command(subcommand)]
21 pub command: Option<Commands>,
22
23 #[arg(short = 'p', long)]
25 pub prompt: Option<String>,
26
27 #[arg(short = 'm', long)]
28 pub model: Option<String>,
29
30 #[arg(short = 'c', long)]
32 pub r#continue: bool,
33
34 #[arg(short = 'r', long)]
36 pub restore: bool,
37
38 #[arg(short = 's', long = "session")]
40 pub session: Option<PathBuf>,
41}
42
43#[derive(Parser, Debug)]
44pub enum Commands {
45 Update,
47}
48
49#[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 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
75pub 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
113pub 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
121pub async fn run(args: CliArgs) -> Result<(), anyhow::Error> {
123 if matches!(args.command, Some(Commands::Update)) {
125 return run_update().await;
126 }
127
128 if args.r#continue {
130 return run_continue_session().await;
131 }
132
133 if args.restore {
135 return run_restore_session().await;
136 }
137
138 if let Some(path) = &args.session {
140 return run_session_path(path).await;
141 }
142
143 if args.prompt.is_some() {
145 return Ok(());
147 }
148
149 oy_tui::run_tui(None)
151 .await
152 .map_err(|e| anyhow::Error::msg(format!("{}", e)))?;
153 Ok(())
154}
155
156async fn run_update() -> Result<(), anyhow::Error> {
159 let timeout = Duration::from_secs(300);
160
161 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 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
232async 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 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
314async 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 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}