diff --git a/logs/user_prompt_submit.json b/logs/user_prompt_submit.json index 605a982..e3e36f6 100644 --- a/logs/user_prompt_submit.json +++ b/logs/user_prompt_submit.json @@ -174,5 +174,21 @@ "permission_mode": "bypassPermissions", "hook_event_name": "UserPromptSubmit", "prompt": "create a PR for these changes and set a reminder for me to check back in in 10 minutes" + }, + { + "session_id": "6be261ac-4a50-4c29-b5e1-a1edb3ce813d", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/6be261ac-4a50-4c29-b5e1-a1edb3ce813d.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "create PR for t3-features branch" + }, + { + "session_id": "6be261ac-4a50-4c29-b5e1-a1edb3ce813d", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/6be261ac-4a50-4c29-b5e1-a1edb3ce813d.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "Can you see if there are any review comments in that PR that need to be addressed? Address them within a single commit and then merge once that's done." } ] \ No newline at end of file diff --git a/src/commands/generate.rs b/src/commands/generate.rs index 67e5014..40015f1 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -74,14 +74,13 @@ pub fn new_daemon( let script_path = generator_script_path()?; println!(); - println!( - "{} Generating FGP daemon: {}", - "→".blue(), - service.bold() - ); + println!("{} Generating FGP daemon: {}", "→".blue(), service.bold()); // Build command arguments - let mut args = vec![script_path.to_string_lossy().to_string(), service.to_string()]; + let mut args = vec![ + script_path.to_string_lossy().to_string(), + service.to_string(), + ]; if preset { args.push("--preset".to_string()); diff --git a/src/commands/logs.rs b/src/commands/logs.rs new file mode 100644 index 0000000..89e6679 --- /dev/null +++ b/src/commands/logs.rs @@ -0,0 +1,137 @@ +//! View daemon logs in the terminal. + +use anyhow::{bail, Context, Result}; +use colored::Colorize; +use std::fs::File; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +/// Get the log file path for a service. +fn log_file_path(service: &str) -> PathBuf { + let base = shellexpand::tilde("~/.fgp/services"); + PathBuf::from(base.as_ref()) + .join(service) + .join("logs") + .join("daemon.log") +} + +/// Run the logs command. +pub fn run(service: &str, follow: bool, lines: usize) -> Result<()> { + let log_path = log_file_path(service); + + if !log_path.exists() { + bail!( + "No logs found for service '{}' at {}", + service, + log_path.display() + ); + } + + if follow { + follow_logs(&log_path)?; + } else { + tail_logs(&log_path, lines)?; + } + + Ok(()) +} + +/// Display the last N lines of the log file. +fn tail_logs(path: &PathBuf, lines: usize) -> Result<()> { + let file = File::open(path).context("Failed to open log file")?; + let reader = BufReader::new(file); + + // Read all lines and keep the last N + let all_lines: Vec = reader.lines().map_while(Result::ok).collect(); + let start = if all_lines.len() > lines { + all_lines.len() - lines + } else { + 0 + }; + + for line in &all_lines[start..] { + print_log_line(line); + } + + Ok(()) +} + +/// Follow log output in real-time (like tail -f). +fn follow_logs(path: &PathBuf) -> Result<()> { + let mut file = File::open(path).context("Failed to open log file")?; + + // Seek to end of file + file.seek(SeekFrom::End(0))?; + + println!( + "{} Following logs... (press Ctrl+C to exit)", + "→".blue().bold() + ); + + let mut reader = BufReader::new(file); + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => { + // No new data, wait and try again + thread::sleep(Duration::from_millis(100)); + } + Ok(_) => { + // Got new data + print_log_line(line.trim_end()); + } + Err(e) => { + eprintln!("{} Read error: {}", "✗".red().bold(), e); + break; + } + } + } + + Ok(()) +} + +/// Detect log level from a line using case-insensitive search. +/// Returns the detected level or None for INFO/unknown. +fn detect_log_level(line: &str) -> Option<&'static str> { + // Use case-insensitive byte search to avoid allocation + let bytes = line.as_bytes(); + + // Check for common log level patterns in order of severity + if contains_case_insensitive(bytes, b"ERROR") { + Some("ERROR") + } else if contains_case_insensitive(bytes, b"WARN") { + Some("WARN") + } else if contains_case_insensitive(bytes, b"DEBUG") { + Some("DEBUG") + } else if contains_case_insensitive(bytes, b"TRACE") { + Some("TRACE") + } else { + None // INFO or other + } +} + +/// Case-insensitive byte search without allocation. +fn contains_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || haystack.len() < needle.len() { + return needle.is_empty(); + } + haystack + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} + +/// Print a log line with color-coding by level. +fn print_log_line(line: &str) { + let colored_line = match detect_log_level(line) { + Some("ERROR") => line.red().to_string(), + Some("WARN") => line.yellow().to_string(), + Some("DEBUG") | Some("TRACE") => line.dimmed().to_string(), + _ => line.to_string(), // INFO or other + }; + + println!("{}", colored_line); +} diff --git a/src/commands/mcp_bridge.rs b/src/commands/mcp_bridge.rs new file mode 100644 index 0000000..a554836 --- /dev/null +++ b/src/commands/mcp_bridge.rs @@ -0,0 +1,514 @@ +//! MCP Bridge commands for FGP. +//! +//! Expose FGP daemons as MCP servers for compatibility with Claude Desktop, +//! Cline, Continue, and other MCP-compatible tools. + +use anyhow::{Context, Result}; +use colored::Colorize; +use std::fs; +use std::io::{self, BufRead, Write}; + +// Use shared helpers from parent module +use super::{fgp_services_dir, service_socket_path}; + +/// Maximum retries when waiting for daemon to start. +const MAX_START_RETRIES: u32 = 10; +/// Delay between health check retries (ms). +const RETRY_DELAY_MS: u64 = 100; + +/// Validate that a daemon name contains only safe characters. +/// Prevents path traversal and shell injection attacks. +fn is_valid_daemon_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('.') + && !name.contains('/') + && !name.contains('\\') + && !name.contains('\0') + && name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') +} + +/// Start the MCP bridge in stdio mode. +/// +/// This runs an MCP server that translates MCP tool calls to FGP daemon calls. +pub fn serve() -> Result<()> { + // MCP uses JSON-RPC 2.0 over stdio + let stdin = io::stdin(); + let mut stdout = io::stdout(); + + for line in stdin.lock().lines() { + let line = line.context("Failed to read from stdin")?; + + if line.is_empty() { + continue; + } + + // Parse JSON-RPC request + let request: serde_json::Value = + serde_json::from_str(&line).context("Invalid JSON-RPC request")?; + + let id = request.get("id").cloned(); + let method = request["method"].as_str().unwrap_or(""); + + let response = match method { + "initialize" => handle_initialize(&request), + "tools/list" => handle_tools_list(id), + "tools/call" => handle_tools_call(&request), + _ => { + // Unknown method - return error + json_rpc_error(id, -32601, "Method not found") + } + }; + + // Send response + writeln!(stdout, "{}", response)?; + stdout.flush()?; + } + + Ok(()) +} + +/// Handle MCP initialize request. +fn handle_initialize(request: &serde_json::Value) -> String { + let id = request.get("id").cloned(); + + let result = serde_json::json!({ + "protocolVersion": "2024-11-05", + "serverInfo": { + "name": "fgp-mcp-bridge", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { + "tools": {} + } + }); + + json_rpc_response(id, result) +} + +/// Encode daemon and method into MCP tool name. +/// Uses double underscore (__) as separator since daemon names can contain single underscores. +fn encode_tool_name(daemon: &str, method: &str) -> String { + format!("fgp__{}__{}", daemon, method.replace('.', "_")) +} + +/// Decode MCP tool name into daemon and method. +/// Returns None if the format is invalid. +fn decode_tool_name(tool_name: &str) -> Option<(String, String)> { + let stripped = tool_name.strip_prefix("fgp__")?; + let parts: Vec<&str> = stripped.splitn(2, "__").collect(); + if parts.len() != 2 { + return None; + } + let daemon = parts[0].to_string(); + let method = parts[1].replace('_', "."); + Some((daemon, method)) +} + +/// Handle MCP tools/list request. +fn handle_tools_list(id: Option) -> String { + let mut tools = Vec::new(); + + // Scan installed daemons and collect their methods + let services_dir = fgp_services_dir(); + if services_dir.exists() { + if let Ok(entries) = fs::read_dir(&services_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let socket = service_socket_path(&name); + + if socket.exists() { + // Try to get methods from this daemon + if let Ok(client) = fgp_daemon::FgpClient::new(&socket) { + if let Ok(response) = client.methods() { + if response.ok { + if let Some(result) = response.result { + if let Some(methods) = result["methods"].as_array() { + for method in methods { + let method_name = + method["name"].as_str().unwrap_or("unknown"); + let description = method["description"] + .as_str() + .unwrap_or("No description"); + + // Skip internal methods + if method_name == "health" + || method_name == "stop" + || method_name == "methods" + { + continue; + } + + // Build input schema from method params + let input_schema = method + .get("params") + .cloned() + .unwrap_or(serde_json::json!({ + "type": "object", + "properties": {} + })); + + tools.push(serde_json::json!({ + "name": encode_tool_name(&name, method_name), + "description": format!("[FGP:{}] {}", name, description), + "inputSchema": input_schema + })); + } + } + } + } + } + } + } + } + } + } + + // Add meta-tools + tools.push(serde_json::json!({ + "name": "fgp_list_daemons", + "description": "List all FGP daemons with their status", + "inputSchema": { + "type": "object", + "properties": {} + } + })); + + tools.push(serde_json::json!({ + "name": "fgp_start_daemon", + "description": "Start an FGP daemon", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the daemon to start" + } + }, + "required": ["name"] + } + })); + + tools.push(serde_json::json!({ + "name": "fgp_stop_daemon", + "description": "Stop an FGP daemon", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the daemon to stop" + } + }, + "required": ["name"] + } + })); + + let result = serde_json::json!({ + "tools": tools + }); + + json_rpc_response(id, result) +} + +/// Handle MCP tools/call request. +fn handle_tools_call(request: &serde_json::Value) -> String { + let id = request.get("id").cloned(); + let params = &request["params"]; + let tool_name = params["name"].as_str().unwrap_or(""); + let arguments = params + .get("arguments") + .cloned() + .unwrap_or(serde_json::json!({})); + + // Handle meta-tools + if tool_name == "fgp_list_daemons" { + return handle_list_daemons(id); + } else if tool_name == "fgp_start_daemon" { + let daemon_name = arguments["name"].as_str().unwrap_or(""); + return handle_start_daemon(id, daemon_name); + } else if tool_name == "fgp_stop_daemon" { + let daemon_name = arguments["name"].as_str().unwrap_or(""); + return handle_stop_daemon(id, daemon_name); + } + + // Parse tool name to extract daemon and method + let (daemon, method) = match decode_tool_name(tool_name) { + Some((d, m)) => (d, m), + None => { + return json_rpc_error( + id, + -32602, + "Invalid tool name format. Expected fgp____", + ) + } + }; + + // Validate daemon name to prevent path traversal + if !is_valid_daemon_name(&daemon) { + return json_rpc_error(id, -32602, "Invalid daemon name"); + } + + // Call the daemon + let socket = service_socket_path(&daemon); + + // Auto-start if needed with health polling + if !socket.exists() { + if let Err(e) = fgp_daemon::lifecycle::start_service(&daemon) { + return json_rpc_error(id, -32603, &format!("Failed to start daemon: {}", e)); + } + // Poll for daemon readiness instead of fixed sleep + if !wait_for_daemon_ready(&daemon) { + return json_rpc_error(id, -32603, "Daemon started but not responding"); + } + } + + match fgp_daemon::FgpClient::new(&socket) { + Ok(client) => match client.call(&method, arguments) { + Ok(response) if response.ok => { + let result = serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&response.result).unwrap_or_default() + }] + }); + json_rpc_response(id, result) + } + Ok(response) => { + let error_msg = response + .error + .map(|e| e.message) + .unwrap_or_else(|| "Unknown error".to_string()); + json_rpc_error(id, -32603, &error_msg) + } + Err(e) => json_rpc_error(id, -32603, &format!("Call failed: {}", e)), + }, + Err(e) => json_rpc_error(id, -32603, &format!("Failed to connect to daemon: {}", e)), + } +} + +/// Wait for a daemon to become ready by polling its health endpoint. +fn wait_for_daemon_ready(daemon: &str) -> bool { + let socket = service_socket_path(daemon); + for _ in 0..MAX_START_RETRIES { + std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS)); + if socket.exists() { + if let Ok(client) = fgp_daemon::FgpClient::new(&socket) { + if client.health().is_ok() { + return true; + } + } + } + } + false +} + +/// Handle fgp_list_daemons meta-tool. +fn handle_list_daemons(id: Option) -> String { + let services_dir = fgp_services_dir(); + let mut daemons = Vec::new(); + + if services_dir.exists() { + if let Ok(entries) = fs::read_dir(&services_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let socket = service_socket_path(&name); + + let status = if socket.exists() { + if let Ok(client) = fgp_daemon::FgpClient::new(&socket) { + if client.health().is_ok() { + "running" + } else { + "error" + } + } else { + "error" + } + } else { + "stopped" + }; + + daemons.push(serde_json::json!({ + "name": name, + "status": status + })); + } + } + } + + let result = serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&daemons).unwrap_or_default() + }] + }); + + json_rpc_response(id, result) +} + +/// Handle fgp_start_daemon meta-tool. +fn handle_start_daemon(id: Option, name: &str) -> String { + if !is_valid_daemon_name(name) { + return json_rpc_error(id, -32602, "Invalid daemon name"); + } + + match fgp_daemon::lifecycle::start_service(name) { + Ok(()) => { + let result = serde_json::json!({ + "content": [{ + "type": "text", + "text": format!("Started daemon: {}", name) + }] + }); + json_rpc_response(id, result) + } + Err(e) => json_rpc_error(id, -32603, &format!("Failed to start daemon: {}", e)), + } +} + +/// Handle fgp_stop_daemon meta-tool. +fn handle_stop_daemon(id: Option, name: &str) -> String { + if !is_valid_daemon_name(name) { + return json_rpc_error(id, -32602, "Invalid daemon name"); + } + + match fgp_daemon::lifecycle::stop_service(name) { + Ok(()) => { + let result = serde_json::json!({ + "content": [{ + "type": "text", + "text": format!("Stopped daemon: {}", name) + }] + }); + json_rpc_response(id, result) + } + Err(e) => json_rpc_error(id, -32603, &format!("Failed to stop daemon: {}", e)), + } +} + +/// Create a JSON-RPC response. +fn json_rpc_response(id: Option, result: serde_json::Value) -> String { + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + }); + serde_json::to_string(&response).unwrap_or_default() +} + +/// Create a JSON-RPC error response. +fn json_rpc_error(id: Option, code: i32, message: &str) -> String { + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }); + serde_json::to_string(&response).unwrap_or_default() +} + +/// Register FGP with Claude Code. +pub fn install() -> Result<()> { + println!("{} Registering FGP with Claude Code...", "→".blue().bold()); + + // Run: claude mcp add fgp -- fgp mcp serve + let status = std::process::Command::new("claude") + .args(["mcp", "add", "fgp", "--", "fgp", "mcp", "serve"]) + .status() + .context("Failed to run 'claude mcp add'. Is Claude Code installed?")?; + + if status.success() { + println!("{} FGP registered with Claude Code!", "✓".green().bold()); + println!(); + println!("Verify with: {}", "claude mcp list".cyan()); + println!("Usage: Ask Claude to use FGP tools (e.g., \"List my unread emails with FGP\")"); + } else { + println!("{} Failed to register with Claude Code", "✗".red().bold()); + } + + Ok(()) +} + +/// List available MCP tools from daemons. +pub fn tools() -> Result<()> { + println!("{}", "FGP MCP Tools".bold()); + println!("{}", "=".repeat(50)); + println!(); + + let services_dir = fgp_services_dir(); + if !services_dir.exists() { + println!("{} No FGP services installed", "!".yellow().bold()); + return Ok(()); + } + + let entries = fs::read_dir(&services_dir)?; + let mut total_tools = 0; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let socket = service_socket_path(&name); + + println!("{}", name.cyan().bold()); + + if !socket.exists() { + println!(" {} (not running)", "○".dimmed()); + continue; + } + + match fgp_daemon::FgpClient::new(&socket) { + Ok(client) => match client.methods() { + Ok(response) if response.ok => { + if let Some(result) = response.result { + if let Some(methods) = result["methods"].as_array() { + for method in methods { + let method_name = method["name"].as_str().unwrap_or("unknown"); + let description = + method["description"].as_str().unwrap_or("No description"); + + // Skip internal methods + if method_name == "health" + || method_name == "stop" + || method_name == "methods" + { + continue; + } + + println!( + " {} - {}", + encode_tool_name(&name, method_name).green(), + description.dimmed() + ); + total_tools += 1; + } + } + } + } + _ => { + println!(" {} Error fetching methods", "✗".red()); + } + }, + Err(_) => { + println!(" {} Connection error", "✗".red()); + } + } + + println!(); + } + + // Meta-tools + println!("{}", "Meta-Tools".cyan().bold()); + println!( + " {} - List all FGP daemons with their status", + "fgp_list_daemons".green() + ); + println!(" {} - Start an FGP daemon", "fgp_start_daemon".green()); + println!(" {} - Stop an FGP daemon", "fgp_stop_daemon".green()); + + println!(); + println!("Total: {} tools available", total_tools + 3); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ae4f612..689a286 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,7 +6,10 @@ pub mod dashboard; pub mod generate; pub mod health; pub mod install; +pub mod logs; +pub mod mcp_bridge; pub mod methods; +pub mod monitor; pub mod new; pub mod skill; pub mod skill_export; diff --git a/src/commands/monitor.rs b/src/commands/monitor.rs new file mode 100644 index 0000000..201e288 --- /dev/null +++ b/src/commands/monitor.rs @@ -0,0 +1,176 @@ +//! Health monitor with notifications. +//! +//! Watches FGP daemons and sends system notifications when services +//! change state (crash, recover, go unhealthy). + +use anyhow::Result; +use colored::Colorize; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use crate::notifications; + +// Use shared helpers from parent module +use super::{fgp_services_dir, service_socket_path}; + +/// Service state for tracking changes. +#[derive(Debug, Clone, PartialEq)] +enum ServiceState { + Running, + Stopped, + Unhealthy, + Error, +} + +/// Run the health monitor. +pub fn run(interval_secs: u64, daemon: bool) -> Result<()> { + if daemon { + println!( + "{} Starting health monitor daemon (interval: {}s)...", + "→".blue().bold(), + interval_secs + ); + println!("Monitor will run in background and send notifications on state changes."); + + // Fork to background + // For simplicity, we'll just run in foreground with reduced output + // A proper daemon would use daemonize crate + } + + println!( + "{} Monitoring FGP services (Ctrl+C to stop)...", + "→".blue().bold() + ); + println!(); + + let mut states: HashMap = HashMap::new(); + let interval = Duration::from_secs(interval_secs); + + loop { + check_services(&mut states); + thread::sleep(interval); + } +} + +/// Check all services and send notifications on state changes. +fn check_services(states: &mut HashMap) { + let services_dir = fgp_services_dir(); + + if !services_dir.exists() { + return; + } + + let entries = match fs::read_dir(&services_dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let name = match entry.file_name().to_str() { + Some(n) => n.to_string(), + None => continue, + }; + + let socket = service_socket_path(&name); + let current_state = get_service_state(&socket); + + // Check for state transitions + if let Some(prev_state) = states.get(&name) { + if *prev_state != current_state { + handle_state_change(&name, prev_state, ¤t_state); + } + } + + states.insert(name, current_state); + } +} + +/// Get the current state of a service. +fn get_service_state(socket: &PathBuf) -> ServiceState { + if !socket.exists() { + return ServiceState::Stopped; + } + + match fgp_daemon::FgpClient::new(socket) { + Ok(client) => match client.health() { + Ok(response) if response.ok => { + let result = response.result.unwrap_or_default(); + let status = result["status"].as_str().unwrap_or("running"); + + match status { + "healthy" | "running" => ServiceState::Running, + "degraded" | "unhealthy" => ServiceState::Unhealthy, + _ => ServiceState::Running, + } + } + _ => ServiceState::Error, + }, + Err(_) => ServiceState::Error, + } +} + +/// Handle a state change and send notifications. +fn handle_state_change(name: &str, prev: &ServiceState, current: &ServiceState) { + let (title, message, log_style) = match (prev, current) { + // Service crashed (was running, now error or stopped) + (ServiceState::Running, ServiceState::Error) => ( + "FGP Service Crashed", + format!("{} daemon crashed", name), + format!("{} {} crashed", "✗".red().bold(), name), + ), + (ServiceState::Running, ServiceState::Stopped) => ( + "FGP Service Stopped", + format!("{} daemon stopped unexpectedly", name), + format!("{} {} stopped", "○".dimmed(), name), + ), + + // Service went unhealthy + (ServiceState::Running, ServiceState::Unhealthy) => ( + "FGP Service Unhealthy", + format!("{} daemon is unhealthy", name), + format!("{} {} is unhealthy", "◐".yellow().bold(), name), + ), + + // Service recovered + (ServiceState::Unhealthy, ServiceState::Running) => ( + "FGP Service Recovered", + format!("{} daemon recovered", name), + format!("{} {} recovered", "✓".green().bold(), name), + ), + (ServiceState::Error, ServiceState::Running) => ( + "FGP Service Started", + format!("{} daemon is now running", name), + format!("{} {} started", "●".green().bold(), name), + ), + (ServiceState::Stopped, ServiceState::Running) => ( + "FGP Service Started", + format!("{} daemon started", name), + format!("{} {} started", "●".green().bold(), name), + ), + + // Other transitions - just log, no notification + _ => { + println!( + "[{}] {} state: {:?} → {:?}", + chrono::Local::now().format("%H:%M:%S"), + name, + prev, + current + ); + return; + } + }; + + // Log to terminal + println!( + "[{}] {}", + chrono::Local::now().format("%H:%M:%S"), + log_style + ); + + // Send system notification + notifications::notify(title, &message); +} diff --git a/src/commands/skill.rs b/src/commands/skill.rs index fa084ef..e9d4d82 100644 --- a/src/commands/skill.rs +++ b/src/commands/skill.rs @@ -160,7 +160,13 @@ impl ExportTarget { } pub fn all_targets() -> Vec { - vec![Self::Mcp, Self::Claude, Self::Cursor, Self::ContinueDev, Self::Windsurf] + vec![ + Self::Mcp, + Self::Claude, + Self::Cursor, + Self::ContinueDev, + Self::Windsurf, + ] } } @@ -428,8 +434,7 @@ pub fn list() -> Result<()> { for (skill_key, entries) in &installed.skills { for entry in entries { - let status = if check_daemon_running(&skill_key.split('@').next().unwrap_or(skill_key)) - { + let status = if check_daemon_running(skill_key.split('@').next().unwrap_or(skill_key)) { "● running".green() } else { "○ stopped".dimmed() @@ -459,41 +464,36 @@ fn check_daemon_running(service: &str) -> bool { /// Search for skills in taps and marketplaces pub fn search(query: &str) -> Result<()> { - println!( - "{} {}", - "Searching for:".bold(), - query.cyan() - ); + println!("{} {}", "Searching for:".bold(), query.cyan()); println!(); let mut found = false; // First search taps (new skill.yaml format) - match skill_tap::search_taps(query) { - Ok(results) => { - if !results.is_empty() { - println!("{}", "From taps:".bold().underline()); - for (tap_name, _path, manifest) in &results { - found = true; - println!( - " {} {} (from {})", - manifest.name.cyan().bold(), - format!("v{}", manifest.version).dimmed(), - tap_name.dimmed() - ); - println!(" {}", manifest.description); - if !manifest.keywords.is_empty() { - println!(" Keywords: {}", manifest.keywords.join(", ").dimmed()); - } - if !manifest.daemons.is_empty() { - let daemon_names: Vec<_> = manifest.daemons.iter().map(|d| d.name.as_str()).collect(); - println!(" Daemons: {}", daemon_names.join(", ").dimmed()); - } - println!(); + // Ignore tap search errors, continue with marketplaces + if let Ok(results) = skill_tap::search_taps(query) { + if !results.is_empty() { + println!("{}", "From taps:".bold().underline()); + for (tap_name, _path, manifest) in &results { + found = true; + println!( + " {} v{} (from {})", + manifest.name.cyan().bold(), + manifest.version.dimmed(), + tap_name.dimmed() + ); + println!(" {}", manifest.description); + if !manifest.keywords.is_empty() { + println!(" Keywords: {}", manifest.keywords.join(", ").dimmed()); + } + if !manifest.daemons.is_empty() { + let daemon_names: Vec<_> = + manifest.daemons.iter().map(|d| d.name.as_str()).collect(); + println!(" Daemons: {}", daemon_names.join(", ").dimmed()); } + println!(); } } - Err(_) => {} // Ignore tap search errors, continue with marketplaces } // Also search legacy marketplaces @@ -511,7 +511,10 @@ pub fn search(query: &str) -> Result<()> { let query_lower = query.to_lowercase(); if skill.name.to_lowercase().contains(&query_lower) || skill.description.to_lowercase().contains(&query_lower) - || skill.tags.iter().any(|t| t.to_lowercase().contains(&query_lower)) + || skill + .tags + .iter() + .any(|t| t.to_lowercase().contains(&query_lower)) { if !marketplace_found { println!("{}", "From marketplaces (legacy):".bold().underline()); @@ -551,11 +554,7 @@ pub fn search(query: &str) -> Result<()> { /// Install a skill pub fn install(name: &str, from_marketplace: Option<&str>) -> Result<()> { - println!( - "{} {}...", - "Installing skill:".bold(), - name.cyan() - ); + println!("{} {}...", "Installing skill:".bold(), name.cyan()); // First, try to find the skill in taps (new skill.yaml format) if from_marketplace.is_none() { @@ -718,7 +717,9 @@ pub fn install(name: &str, from_marketplace: Option<&str>) -> Result<()> { binary_path, }; - installed.skills.insert(skill_key.clone(), vec![entry.clone()]); + installed + .skills + .insert(skill_key.clone(), vec![entry.clone()]); save_installed_skills(&installed)?; // Auto-register with ecosystems based on exports config @@ -759,7 +760,12 @@ pub fn install(name: &str, from_marketplace: Option<&str>) -> Result<()> { } // Windsurf - if exports.windsurf.as_ref().map(|w| w.enabled).unwrap_or(false) { + if exports + .windsurf + .as_ref() + .map(|w| w.enabled) + .unwrap_or(false) + { match export_to_windsurf(&skill_manifest) { Ok(()) => {} Err(e) => println!(" {} Windsurf: {}", "✗".red(), e), @@ -775,15 +781,12 @@ pub fn install(name: &str, from_marketplace: Option<&str>) -> Result<()> { ); println!(); println!("Start the daemon with:"); - println!( - " {}", - format!("fgp start {}", daemon_name) - ); + println!(" fgp start {}", daemon_name); println!(); println!("To register with additional ecosystems:"); println!( - " {}", - format!("fgp skill mcp register {} --target=claude,cursor", skill.name) + " fgp skill mcp register {} --target=claude,cursor", + skill.name ); Ok(()) @@ -869,12 +872,20 @@ fn install_from_tap( // Codex if instructions.codex.is_some() { - println!(" {} Codex: available (use 'fgp skill export codex {}')", "○".dimmed(), manifest.name); + println!( + " {} Codex: available (use 'fgp skill export codex {}')", + "○".dimmed(), + manifest.name + ); } // MCP if instructions.mcp.is_some() { - println!(" {} MCP: available (use 'fgp skill export mcp {}')", "○".dimmed(), manifest.name); + println!( + " {} MCP: available (use 'fgp skill export mcp {}')", + "○".dimmed(), + manifest.name + ); } } @@ -956,9 +967,17 @@ fn export_tap_skill_to_cursor( if src_path.exists() { // Read and copy to .cursorrules in current project // or to a global location - println!(" {} Cursor: {} (copy to project)", "✓".green(), cursor_file); + println!( + " {} Cursor: {} (copy to project)", + "✓".green(), + cursor_file + ); } else { - println!(" {} Cursor: file not found ({})", "⚠".yellow(), cursor_file); + println!( + " {} Cursor: file not found ({})", + "⚠".yellow(), + cursor_file + ); } } } @@ -995,7 +1014,7 @@ fn generate_skill_md_from_manifest(manifest: &super::skill_validate::SkillManife let optional = if daemon.optional { " (optional)" } else { "" }; md.push_str(&format!("- `{}`{}\n", daemon.name, optional)); } - md.push_str("\n"); + md.push('\n'); } // Triggers @@ -1005,7 +1024,7 @@ fn generate_skill_md_from_manifest(manifest: &super::skill_validate::SkillManife for kw in &triggers.keywords { md.push_str(&format!("- \"{}\"\n", kw)); } - md.push_str("\n"); + md.push('\n'); } // Workflows @@ -1016,7 +1035,10 @@ fn generate_skill_md_from_manifest(manifest: &super::skill_validate::SkillManife if let Some(ref desc) = workflow.description { md.push_str(&format!("{}\n", desc)); } - md.push_str(&format!("```bash\nfgp workflow run {} --file {}\n```\n\n", name, workflow.file)); + md.push_str(&format!( + "```bash\nfgp workflow run {} --file {}\n```\n\n", + name, workflow.file + )); } } @@ -1082,17 +1104,13 @@ pub fn marketplace_update() -> Result<()> { /// Add a marketplace pub fn marketplace_add(url: &str) -> Result<()> { - println!( - "{} {}", - "Adding marketplace:".bold(), - url.cyan() - ); + println!("{} {}", "Adding marketplace:".bold(), url.cyan()); // Parse URL to get repo name let repo_name = url .trim_end_matches('/') .split('/') - .last() + .next_back() .unwrap_or("marketplace") .trim_end_matches(".git"); @@ -1108,7 +1126,7 @@ pub fn marketplace_add(url: &str) -> Result<()> { // Clone the repository let install_location = marketplaces_dir().join(repo_name); - fs::create_dir_all(&install_location.parent().unwrap())?; + fs::create_dir_all(install_location.parent().unwrap())?; println!(" Cloning repository..."); let status = Command::new("git") @@ -1160,11 +1178,7 @@ pub fn marketplace_add(url: &str) -> Result<()> { println!(); println!("Available skills:"); for skill in &manifest.skills { - println!( - " {} - {}", - skill.name.cyan(), - skill.description.dimmed() - ); + println!(" {} - {}", skill.name.cyan(), skill.description.dimmed()); } } @@ -1193,11 +1207,7 @@ pub fn marketplace_list() -> Result<()> { "○ not cloned".dimmed() }; - println!( - " {} {}", - name.cyan().bold(), - status - ); + println!(" {} {}", name.cyan().bold(), status); println!(" Source: {}", entry.source.repo.dimmed()); if let Some(ref updated) = entry.last_updated { println!(" Last updated: {}", updated.dimmed()); @@ -1372,10 +1382,7 @@ pub fn remove(name: &str) -> Result<()> { ); } None => { - println!( - "{}", - format!("Skill '{}' not found.", name).yellow() - ); + println!("{}", format!("Skill '{}' not found.", name).yellow()); } } @@ -1465,10 +1472,7 @@ pub fn info(name: &str) -> Result<()> { } } - println!( - "{}", - format!("Skill '{}' not found.", name).yellow() - ); + println!("{}", format!("Skill '{}' not found.", name).yellow()); Ok(()) } @@ -1600,7 +1604,11 @@ pub fn mcp_register(name: &str) -> Result<()> { (k, entry) } None => { - bail!("Skill '{}' is not installed. Install it first with: fgp skill install {}", name, name); + bail!( + "Skill '{}' is not installed. Install it first with: fgp skill install {}", + name, + name + ); } }; @@ -1611,7 +1619,10 @@ pub fn mcp_register(name: &str) -> Result<()> { .join("skill.json"); if !skill_manifest_path.exists() { - bail!("Skill manifest not found at {}", skill_manifest_path.display()); + bail!( + "Skill manifest not found at {}", + skill_manifest_path.display() + ); } let skill_content = fs::read_to_string(&skill_manifest_path)?; @@ -1649,7 +1660,10 @@ pub fn mcp_register(name: &str) -> Result<()> { println!(" Manifest: {}", manifest_path.display()); println!(); println!("The skill is now available via the FGP MCP server."); - println!("Tools will be named: {}", format!("fgp_{}_*", daemon_name).cyan()); + println!( + "Tools will be named: {}", + format!("fgp_{}_*", daemon_name).cyan() + ); Ok(()) } @@ -1721,7 +1735,11 @@ pub fn mcp_list() -> Result<()> { status ); println!(" {}", manifest.description.dimmed()); - println!(" Methods: {} | Tools: fgp_{}_*", manifest.methods.len(), manifest.name); + println!( + " Methods: {} | Tools: fgp_{}_*", + manifest.methods.len(), + manifest.name + ); println!(); } } @@ -1757,7 +1775,11 @@ pub fn export_skill(name: &str, targets: &[ExportTarget], binary_path: Option<&s entries.first().context("No installation entry found")? } None => { - bail!("Skill '{}' is not installed. Install it first with: fgp skill install {}", name, name); + bail!( + "Skill '{}' is not installed. Install it first with: fgp skill install {}", + name, + name + ); } }; @@ -1768,13 +1790,18 @@ pub fn export_skill(name: &str, targets: &[ExportTarget], binary_path: Option<&s .join("skill.json"); if !skill_manifest_path.exists() { - bail!("Skill manifest not found at {}", skill_manifest_path.display()); + bail!( + "Skill manifest not found at {}", + skill_manifest_path.display() + ); } let skill_content = fs::read_to_string(&skill_manifest_path)?; let skill: SkillManifest = serde_json::from_str(&skill_content)?; - let bin_path = binary_path.map(|s| s.to_string()).or(entry.binary_path.clone()); + let bin_path = binary_path + .map(|s| s.to_string()) + .or(entry.binary_path.clone()); // Expand 'All' target let actual_targets: Vec = if targets.contains(&ExportTarget::All) { @@ -1836,15 +1863,26 @@ fn export_to_claude(skill: &SkillManifest) -> Result<()> { return Ok(()); } ( - claude.skill_name.clone().unwrap_or_else(|| format!("{}-fgp", daemon_name)), + claude + .skill_name + .clone() + .unwrap_or_else(|| format!("{}-fgp", daemon_name)), claude.triggers.clone(), claude.tools.clone(), ) } else { - (format!("{}-fgp", daemon_name), vec![], vec!["Bash".to_string()]) + ( + format!("{}-fgp", daemon_name), + vec![], + vec!["Bash".to_string()], + ) } } else { - (format!("{}-fgp", daemon_name), vec![], vec!["Bash".to_string()]) + ( + format!("{}-fgp", daemon_name), + vec![], + vec!["Bash".to_string()], + ) }; // Generate SKILL.md content @@ -1866,7 +1904,12 @@ fn export_to_claude(skill: &SkillManifest) -> Result<()> { } /// Generate Claude Code SKILL.md content -fn generate_claude_skill_md(skill: &SkillManifest, skill_name: &str, triggers: &[String], tools: &[String]) -> String { +fn generate_claude_skill_md( + skill: &SkillManifest, + skill_name: &str, + triggers: &[String], + tools: &[String], +) -> String { let daemon_name = skill .daemon .as_ref() @@ -1903,7 +1946,10 @@ fn generate_claude_skill_md(skill: &SkillManifest, skill_name: &str, triggers: & // Prerequisites md.push_str("## Prerequisites\n\n"); - md.push_str(&format!("1. **FGP daemon running**: `fgp start {}` or daemon auto-starts on first call\n", daemon_name)); + md.push_str(&format!( + "1. **FGP daemon running**: `fgp start {}` or daemon auto-starts on first call\n", + daemon_name + )); if !skill.requirements.is_empty() { for (name, req) in &skill.requirements { @@ -1912,7 +1958,7 @@ fn generate_claude_skill_md(skill: &SkillManifest, skill_name: &str, triggers: & } } } - md.push_str("\n"); + md.push('\n'); // Available Methods md.push_str("## Available Methods\n\n"); @@ -1944,7 +1990,7 @@ fn generate_claude_skill_md(skill: &SkillManifest, skill_name: &str, triggers: & param_desc )); } - md.push_str("\n"); + md.push('\n'); } // Example command @@ -1953,7 +1999,9 @@ fn generate_claude_skill_md(skill: &SkillManifest, skill_name: &str, triggers: & md.push_str(&format!("fgp call {}\n", method.name)); } else { // Build example params - let example_params: Vec = method.params.iter() + let example_params: Vec = method + .params + .iter() .filter(|(_, p)| p.required) .map(|(name, p)| { let val = match p.param_type.as_str() { @@ -1969,7 +2017,11 @@ fn generate_claude_skill_md(skill: &SkillManifest, skill_name: &str, triggers: & if example_params.is_empty() { md.push_str(&format!("fgp call {}\n", method.name)); } else { - md.push_str(&format!("fgp call {} -p '{{{}}}'\n", method.name, example_params.join(", "))); + md.push_str(&format!( + "fgp call {} -p '{{{}}}'\n", + method.name, + example_params.join(", ") + )); } } md.push_str("```\n\n---\n\n"); @@ -1998,7 +2050,10 @@ fn export_to_cursor(skill: &SkillManifest) -> Result<()> { println!(" {} Cursor: disabled in skill.json", "○".dimmed()); return Ok(()); } - cursor.server_name.clone().unwrap_or_else(|| format!("fgp-{}", daemon_name)) + cursor + .server_name + .clone() + .unwrap_or_else(|| format!("fgp-{}", daemon_name)) } else { format!("fgp-{}", daemon_name) } @@ -2038,7 +2093,12 @@ fn export_to_cursor(skill: &SkillManifest) -> Result<()> { let mcp_json = serde_json::to_string_pretty(&mcp_config)?; fs::write(&mcp_json_path, &mcp_json)?; - println!(" {} Cursor: {} in {}", "✓".green(), server_name, mcp_json_path.display()); + println!( + " {} Cursor: {} in {}", + "✓".green(), + server_name, + mcp_json_path.display() + ); Ok(()) } @@ -2067,7 +2127,11 @@ fn export_to_continue(skill: &SkillManifest) -> Result<()> { .unwrap_or_else(|| skill.name.replace("-gateway", "")); // Continue.dev doesn't have a stable format yet - log as TODO - println!(" {} Continue: format TBD (daemon: {})", "⚠".yellow(), daemon_name); + println!( + " {} Continue: format TBD (daemon: {})", + "⚠".yellow(), + daemon_name + ); Ok(()) } @@ -2096,13 +2160,18 @@ fn export_to_windsurf(skill: &SkillManifest) -> Result<()> { .unwrap_or_else(|| skill.name.replace("-gateway", "")); // Generate similar markdown to Claude (Windsurf format is similar) - let skill_md = generate_claude_skill_md(skill, &format!("{}-fgp", daemon_name), &[], &["Bash".to_string()]); + let skill_md = generate_claude_skill_md( + skill, + &format!("{}-fgp", daemon_name), + &[], + &["Bash".to_string()], + ); let windsurf_skills_dir = dirs::home_dir() .context("Could not find home directory")? .join(".windsurf") .join("skills") - .join(&format!("{}-fgp", daemon_name)); + .join(format!("{}-fgp", daemon_name)); fs::create_dir_all(&windsurf_skills_dir)?; let skill_md_path = windsurf_skills_dir.join("SKILL.md"); @@ -2183,7 +2252,10 @@ pub fn registration_status(name: &str) -> Result<()> { println!(); // Check MCP - let mcp_manifest = fgp_home().join("services").join(&daemon_name).join("manifest.json"); + let mcp_manifest = fgp_home() + .join("services") + .join(&daemon_name) + .join("manifest.json"); if mcp_manifest.exists() { println!(" ├─ mcp: {} {}", "✓".green(), mcp_manifest.display()); } else { @@ -2228,7 +2300,11 @@ pub fn registration_status(name: &str) -> Result<()> { .join(format!("{}-fgp", daemon_name)) .join("SKILL.md"); if windsurf_skill.exists() { - println!(" └─ windsurf: {} {}", "✓".green(), windsurf_skill.display()); + println!( + " └─ windsurf: {} {}", + "✓".green(), + windsurf_skill.display() + ); } else { println!(" └─ windsurf: {} not registered", "○".dimmed()); } diff --git a/src/commands/skill_export.rs b/src/commands/skill_export.rs index 01cdf79..8452876 100644 --- a/src/commands/skill_export.rs +++ b/src/commands/skill_export.rs @@ -27,7 +27,11 @@ pub fn export(target: &str, skill: &str, output: Option<&str>) -> Result<()> { let skill_path = Path::new(skill); let (skill_dir, manifest_path) = if skill_path.is_dir() { (skill_path.to_path_buf(), skill_path.join("skill.yaml")) - } else if skill_path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) { + } else if skill_path + .extension() + .map(|e| e == "yaml" || e == "yml") + .unwrap_or(false) + { ( skill_path.parent().unwrap_or(Path::new(".")).to_path_buf(), skill_path.to_path_buf(), @@ -53,8 +57,8 @@ pub fn export(target: &str, skill: &str, output: Option<&str>) -> Result<()> { let content = fs::read_to_string(&manifest_path) .with_context(|| format!("Failed to read {}", manifest_path.display()))?; - let manifest: SkillManifest = serde_yaml::from_str(&content) - .with_context(|| "Invalid skill.yaml")?; + let manifest: SkillManifest = + serde_yaml::from_str(&content).with_context(|| "Invalid skill.yaml")?; // Determine output directory let output_dir = match output { @@ -70,9 +74,11 @@ pub fn export(target: &str, skill: &str, output: Option<&str>) -> Result<()> { "mcp" => export_mcp(&manifest, &skill_dir, &output_dir), "windsurf" => export_windsurf(&manifest, &skill_dir, &output_dir), "zed" => export_zed(&manifest, &skill_dir, &output_dir), + "gemini" => export_gemini(&manifest, &skill_dir, &output_dir), + "aider" => export_aider(&manifest, &skill_dir, &output_dir), _ => bail!( "Unknown export target: {}\n\ - Valid targets: claude-code, cursor, codex, mcp, windsurf, zed", + Valid targets: claude-code, cursor, codex, mcp, windsurf, zed, gemini, aider", target ), } @@ -131,7 +137,7 @@ fn export_claude_code(manifest: &SkillManifest, skill_dir: &Path, output_dir: &P let optional = if daemon.optional { " (optional)" } else { "" }; skill_md.push_str(&format!("- **{}**{}\n", daemon.name, optional)); } - skill_md.push_str("\n"); + skill_md.push('\n'); } // Add usage examples @@ -141,7 +147,7 @@ fn export_claude_code(manifest: &SkillManifest, skill_dir: &Path, output_dir: &P for pattern in &triggers.patterns { skill_md.push_str(&format!("- `{}`\n", pattern)); } - skill_md.push_str("\n"); + skill_md.push('\n'); } } @@ -153,7 +159,7 @@ fn export_claude_code(manifest: &SkillManifest, skill_dir: &Path, output_dir: &P let desc = workflow.description.as_deref().unwrap_or(""); skill_md.push_str(&format!("- **{}**{}: {}\n", name, default, desc)); } - skill_md.push_str("\n"); + skill_md.push('\n'); } } @@ -170,10 +176,7 @@ fn export_claude_code(manifest: &SkillManifest, skill_dir: &Path, output_dir: &P // Provide install hint println!(); println!("{}:", "Install".cyan().bold()); - println!( - " cp -r {} ~/.claude/skills/", - skill_output_dir.display() - ); + println!(" cp -r {} ~/.claude/skills/", skill_output_dir.display()); Ok(()) } @@ -192,7 +195,7 @@ fn export_cursor(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path) for keyword in &triggers.keywords { rules.push_str(&format!("- \"{}\"\n", keyword)); } - rules.push_str("\n"); + rules.push('\n'); } // Read cursor-specific instructions @@ -347,10 +350,7 @@ fn export_windsurf(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path rules.push_str("\n## Commands\n\n"); for daemon in &manifest.daemons { for method in &daemon.methods { - rules.push_str(&format!( - "- `fgp call {}.{}`\n", - daemon.name, method - )); + rules.push_str(&format!("- `fgp call {}.{}`\n", daemon.name, method)); } } } @@ -400,13 +400,15 @@ fn export_zed(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path) -> for pattern in &triggers.patterns { rules.push_str(&format!("- Asks to \"{}\"\n", pattern)); } - rules.push_str("\n"); + rules.push('\n'); } // Add FGP daemon usage if !manifest.daemons.is_empty() { rules.push_str("## FGP Daemons\n\n"); - rules.push_str("Use these Fast Gateway Protocol commands for high-performance execution:\n\n"); + rules.push_str( + "Use these Fast Gateway Protocol commands for high-performance execution:\n\n", + ); rules.push_str("```bash\n"); for daemon in &manifest.daemons { for method in &daemon.methods { @@ -425,7 +427,7 @@ fn export_zed(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path) -> for method in &daemon.methods { rules.push_str(&format!("- `{}.{}`\n", daemon.name, method)); } - rules.push_str("\n"); + rules.push('\n'); } } @@ -437,7 +439,7 @@ fn export_zed(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path) -> let desc = workflow.description.as_deref().unwrap_or(""); rules.push_str(&format!("- **{}**{}: {}\n", name, default, desc)); } - rules.push_str("\n"); + rules.push('\n'); } } @@ -459,3 +461,162 @@ fn export_zed(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path) -> Ok(()) } + +/// Export for Gemini CLI (generates extension directory with gemini-extension.json + GEMINI.md). +fn export_gemini(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path) -> Result<()> { + // Create extension directory + let ext_dir = output_dir.join(&manifest.name); + fs::create_dir_all(&ext_dir)?; + + // Generate gemini-extension.json manifest + let extension_json = serde_json::json!({ + "name": manifest.name, + "version": manifest.version, + "contextFileName": "GEMINI.md" + }); + let manifest_path = ext_dir.join("gemini-extension.json"); + fs::write( + &manifest_path, + serde_json::to_string_pretty(&extension_json)?, + )?; + + // Generate GEMINI.md context file + let mut gemini_md = String::new(); + gemini_md.push_str(&format!("# {}\n\n", manifest.name)); + gemini_md.push_str(&format!("{}\n\n", manifest.description)); + + // Read gemini-specific or core instructions + let gemini_instructions = manifest.instructions.as_ref().and_then(|i| i.core.as_ref()); + + if let Some(instruction_path) = gemini_instructions { + let full_path = skill_dir.join(instruction_path); + if full_path.exists() { + let instructions = fs::read_to_string(&full_path)?; + gemini_md.push_str(&instructions); + } + } else { + // Generate default context + if let Some(ref triggers) = manifest.triggers { + gemini_md.push_str("## When to Use\n\n"); + gemini_md.push_str("Activate this skill when the user:\n"); + for keyword in &triggers.keywords { + gemini_md.push_str(&format!("- Mentions \"{}\"\n", keyword)); + } + for pattern in &triggers.patterns { + gemini_md.push_str(&format!("- Asks to \"{}\"\n", pattern)); + } + gemini_md.push('\n'); + } + + // Add FGP daemon usage + if !manifest.daemons.is_empty() { + gemini_md.push_str("## FGP Commands\n\n"); + gemini_md.push_str("Use Fast Gateway Protocol for high-performance execution:\n\n"); + gemini_md.push_str("```bash\n"); + for daemon in &manifest.daemons { + for method in &daemon.methods { + gemini_md.push_str(&format!("fgp call {}.{} -p '{{}}'\n", daemon.name, method)); + } + } + gemini_md.push_str("```\n"); + } + } + + let gemini_md_path = ext_dir.join("GEMINI.md"); + fs::write(&gemini_md_path, &gemini_md)?; + + println!( + "{} Exported Gemini extension to: {}", + "✓".green().bold(), + ext_dir.display() + ); + + // Provide usage hints + println!(); + println!("{}:", "Usage".cyan().bold()); + println!(" 1. Copy directory to ~/.gemini/extensions/"); + println!( + " 2. Or run: gemini extensions install {}", + ext_dir.display() + ); + + Ok(()) +} + +/// Export for Aider (generates CONVENTIONS.md). +fn export_aider(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path) -> Result<()> { + let mut conventions = String::new(); + + conventions.push_str(&format!("# {} Conventions\n\n", manifest.name)); + conventions.push_str(&format!("{}\n\n", manifest.description)); + + // Read aider-specific or core instructions + let aider_instructions = manifest.instructions.as_ref().and_then(|i| i.core.as_ref()); + + if let Some(instruction_path) = aider_instructions { + let full_path = skill_dir.join(instruction_path); + if full_path.exists() { + let instructions = fs::read_to_string(&full_path)?; + conventions.push_str(&instructions); + } + } else { + // Generate default conventions + conventions.push_str("## Guidelines\n\n"); + + if let Some(ref triggers) = manifest.triggers { + conventions.push_str("When working with this skill:\n"); + for keyword in &triggers.keywords { + conventions.push_str(&format!("- Use when dealing with \"{}\"\n", keyword)); + } + conventions.push('\n'); + } + + // Add FGP daemon usage + if !manifest.daemons.is_empty() { + conventions.push_str("## FGP Integration\n\n"); + conventions + .push_str("This project uses Fast Gateway Protocol daemons for performance.\n\n"); + conventions.push_str("### Available Commands\n\n"); + for daemon in &manifest.daemons { + let optional = if daemon.optional { " (optional)" } else { "" }; + conventions.push_str(&format!("**{}**{}:\n", daemon.name, optional)); + for method in &daemon.methods { + conventions.push_str(&format!( + "- `fgp call {}.{} -p '{{\"param\": \"value\"}}'`\n", + daemon.name, method + )); + } + conventions.push('\n'); + } + } + + // Add workflow info + if !manifest.workflows.is_empty() { + conventions.push_str("## Workflows\n\n"); + for (name, workflow) in &manifest.workflows { + let desc = workflow.description.as_deref().unwrap_or(""); + conventions.push_str(&format!("- **{}**: {}\n", name, desc)); + } + conventions.push('\n'); + } + } + + // Write CONVENTIONS.md + let conventions_path = output_dir.join(format!("{}.CONVENTIONS.md", manifest.name)); + fs::write(&conventions_path, &conventions)?; + + println!( + "{} Exported Aider conventions to: {}", + "✓".green().bold(), + conventions_path.display() + ); + + // Provide usage hints + println!(); + println!("{}:", "Usage".cyan().bold()); + println!(" 1. Rename to CONVENTIONS.md in project root"); + println!(" 2. Run: aider --read CONVENTIONS.md"); + println!(" 3. Or add to .aider.conf.yml: read: CONVENTIONS.md"); + + Ok(()) +} diff --git a/src/commands/skill_tap.rs b/src/commands/skill_tap.rs index 776bd91..104ac6a 100644 --- a/src/commands/skill_tap.rs +++ b/src/commands/skill_tap.rs @@ -126,11 +126,7 @@ pub fn add(repo: &str) -> Result<()> { let (owner, repo_name, url) = parse_repo_input(repo)?; let tap_name = format!("{}-{}", owner, repo_name); - println!( - "{} {}", - "→".blue().bold(), - format!("Adding tap {}...", tap_name.cyan()) - ); + println!("{} Adding tap {}...", "→".blue().bold(), tap_name.cyan()); // Check if already exists let mut config = load_taps_config()?; @@ -206,11 +202,7 @@ pub fn remove(name: &str) -> Result<()> { let entry = config.taps.get(&tap_name).unwrap(); let tap_path = PathBuf::from(&entry.path); - println!( - "{} {}", - "→".blue().bold(), - format!("Removing tap {}...", tap_name.cyan()) - ); + println!("{} Removing tap {}...", "→".blue().bold(), tap_name.cyan()); // Remove the directory if tap_path.exists() { @@ -335,7 +327,10 @@ pub fn show(name: &str) -> Result<()> { let tap_path = PathBuf::from(&entry.path); if !tap_path.exists() { - bail!("Tap directory not found. Re-add with 'fgp skill tap add {}'", entry.repo); + bail!( + "Tap directory not found. Re-add with 'fgp skill tap add {}'", + entry.repo + ); } println!("{} {}", "Tap:".bold(), tap_name.cyan()); @@ -359,15 +354,23 @@ fn parse_repo_input(input: &str) -> Result<(String, String, String)> { // Handle full GitHub URL if input.starts_with("https://") || input.starts_with("git@") { - let cleaned = input - .trim_end_matches('/') - .trim_end_matches(".git"); + let cleaned = input.trim_end_matches('/').trim_end_matches(".git"); // Extract owner/repo from URL let parts: Vec<&str> = if cleaned.contains("github.com/") { - cleaned.split("github.com/").last().unwrap_or("").split('/').collect() + cleaned + .split("github.com/") + .last() + .unwrap_or("") + .split('/') + .collect() } else if cleaned.contains("github.com:") { - cleaned.split("github.com:").last().unwrap_or("").split('/').collect() + cleaned + .split("github.com:") + .last() + .unwrap_or("") + .split('/') + .collect() } else { bail!("Could not parse GitHub URL: {}", input); }; @@ -413,19 +416,22 @@ fn find_tap_name(config: &TapsConfig, partial: &str) -> Result { } // Partial match - let matches: Vec<&String> = config - .taps - .keys() - .filter(|k| k.contains(partial)) - .collect(); + let matches: Vec<&String> = config.taps.keys().filter(|k| k.contains(partial)).collect(); match matches.len() { - 0 => bail!("Tap '{}' not found. Use 'fgp skill tap list' to see configured taps.", partial), + 0 => bail!( + "Tap '{}' not found. Use 'fgp skill tap list' to see configured taps.", + partial + ), 1 => Ok(matches[0].clone()), _ => bail!( "Ambiguous tap name '{}'. Matches: {}", partial, - matches.iter().map(|s| s.as_str()).collect::>().join(", ") + matches + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") ), } } @@ -493,7 +499,11 @@ fn list_tap_skills(tap_path: &Path, limit: usize) -> Result<()> { // Try to read the manifest if let Ok(content) = fs::read_to_string(&manifest_path) { if let Ok(manifest) = serde_yaml::from_str::(&content) { - skills.push((manifest.name.clone(), manifest.version.clone(), manifest.description.clone())); + skills.push(( + manifest.name.clone(), + manifest.version.clone(), + manifest.description.clone(), + )); } } } @@ -507,14 +517,13 @@ fn list_tap_skills(tap_path: &Path, limit: usize) -> Result<()> { for (i, (name, version, description)) in skills.iter().enumerate() { if i >= limit { let remaining = skills.len() - limit; - println!(" {} more skill(s)...", format!("... and {}", remaining).dimmed()); + println!( + " {} more skill(s)...", + format!("... and {}", remaining).dimmed() + ); break; } - println!( - " {} {}", - name.cyan(), - format!("v{}", version).dimmed() - ); + println!(" {} {}", name.cyan(), format!("v{}", version).dimmed()); println!(" {}", description.dimmed()); } @@ -589,7 +598,10 @@ pub fn search_taps(query: &str) -> Result> // Match against name, description, or keywords let matches = manifest.name.to_lowercase().contains(&query_lower) || manifest.description.to_lowercase().contains(&query_lower) - || manifest.keywords.iter().any(|k| k.to_lowercase().contains(&query_lower)); + || manifest + .keywords + .iter() + .any(|k| k.to_lowercase().contains(&query_lower)); if matches { results.push((tap_name.clone(), path, manifest)); diff --git a/src/commands/skill_validate.rs b/src/commands/skill_validate.rs index 287c25d..1ae74f1 100644 --- a/src/commands/skill_validate.rs +++ b/src/commands/skill_validate.rs @@ -270,8 +270,8 @@ fn validate_manifest(manifest_path: &Path, skill_dir: &Path) -> Result<()> { let content = fs::read_to_string(manifest_path) .with_context(|| format!("Failed to read {}", manifest_path.display()))?; - let skill: SkillManifest = serde_yaml::from_str(&content) - .with_context(|| "Invalid YAML or schema mismatch")?; + let skill: SkillManifest = + serde_yaml::from_str(&content).with_context(|| "Invalid YAML or schema mismatch")?; // Validation checks validate_name(&skill.name)?; @@ -352,7 +352,12 @@ fn validate_manifest(manifest_path: &Path, skill_dir: &Path) -> Result<()> { if let Some(ref exports) = skill.exports { println!(); println!("{}:", "Export Targets".cyan().bold()); - if exports.claude_code.as_ref().map(|e| e.enabled).unwrap_or(false) { + if exports + .claude_code + .as_ref() + .map(|e| e.enabled) + .unwrap_or(false) + { println!(" - Claude Code"); } if exports.cursor.as_ref().map(|e| e.enabled).unwrap_or(false) { @@ -382,10 +387,18 @@ fn validate_name(name: &str) -> Result<()> { if name.len() > 64 { bail!("Skill name must be at most 64 characters"); } - if !name.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) { + if !name + .chars() + .next() + .map(|c| c.is_ascii_lowercase()) + .unwrap_or(false) + { bail!("Skill name must start with a lowercase letter"); } - if !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { bail!("Skill name must contain only lowercase letters, numbers, and hyphens"); } Ok(()) @@ -393,7 +406,12 @@ fn validate_name(name: &str) -> Result<()> { fn validate_version(version: &str) -> Result<()> { // Simple semver check - let parts: Vec<&str> = version.split('-').next().unwrap_or(version).split('.').collect(); + let parts: Vec<&str> = version + .split('-') + .next() + .unwrap_or(version) + .split('.') + .collect(); if parts.len() != 3 { bail!("Version must be semver format (e.g., 1.0.0)"); } @@ -425,7 +443,8 @@ fn validate_daemons(daemons: &[DaemonDependency]) -> Result<()> { } // Known daemons (could be expanded or loaded from registry) let known_daemons = [ - "browser", "gmail", "calendar", "github", "imessage", "fly", "neon", "vercel", "slack", "travel", + "browser", "gmail", "calendar", "github", "imessage", "fly", "neon", "vercel", "slack", + "travel", ]; if !known_daemons.contains(&daemon.name.as_str()) { eprintln!( @@ -470,7 +489,10 @@ fn validate_workflows( for (name, workflow) in workflows { let workflow_path = skill_dir.join(&workflow.file); if !workflow_path.exists() { - warnings.push(format!("Workflow '{}' file not found: {}", name, workflow.file)); + warnings.push(format!( + "Workflow '{}' file not found: {}", + name, workflow.file + )); } } Ok(()) @@ -508,11 +530,12 @@ fn validate_auth(auth: &AuthConfig) -> Result<()> { for secret in &auth.secrets { // Validate secret name format (UPPER_SNAKE_CASE) - if !secret.name.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') { - bail!( - "Secret name '{}' must be UPPER_SNAKE_CASE", - secret.name - ); + if !secret + .name + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + { + bail!("Secret name '{}' must be UPPER_SNAKE_CASE", secret.name); } } diff --git a/src/commands/workflow.rs b/src/commands/workflow.rs index 70acc6d..3c3c2e6 100644 --- a/src/commands/workflow.rs +++ b/src/commands/workflow.rs @@ -1,7 +1,51 @@ //! Run and validate FGP workflows. -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use colored::Colorize; +use std::fs; +use std::path::PathBuf; + +/// Built-in workflow templates. +static TEMPLATES: &[(&str, &str, &str)] = &[ + ( + "email-summary", + "Summarize unread emails", + r#"name: email-summary +description: Summarize unread emails +steps: + - service: gmail + method: gmail.unread + params: + limit: 10 + output: emails +"#, + ), + ( + "calendar-today", + "Get today's calendar events", + r#"name: calendar-today +description: Get today's calendar events +steps: + - service: calendar + method: calendar.today + output: events +"#, + ), + ( + "github-prs", + "List open PRs needing review", + r#"name: github-prs +description: Find PRs that need your review +steps: + - service: github + method: github.prs + params: + state: open + review_requested: true + output: prs +"#, + ), +]; /// Run a workflow from a YAML file. pub fn run(file: &str, verbose: bool) -> Result<()> { @@ -86,3 +130,94 @@ pub fn validate(file: &str) -> Result<()> { Ok(()) } + +/// List available workflow templates. +pub fn list(builtin_only: bool) -> Result<()> { + println!("{}", "Workflow Templates".bold()); + println!("{}", "=".repeat(50)); + println!(); + + // Built-in templates + println!("{}", "Built-in Templates:".cyan()); + for (name, desc, _) in TEMPLATES { + println!(" {} - {}", name.green(), desc.dimmed()); + } + + if !builtin_only { + // User templates from ~/.fgp/workflows/ + let workflows_dir = workflows_dir(); + if workflows_dir.exists() { + println!(); + println!("{}", "User Workflows:".cyan()); + for entry in fs::read_dir(&workflows_dir)? { + let entry = entry?; + let path = entry.path(); + if path + .extension() + .map(|e| e == "yaml" || e == "yml") + .unwrap_or(false) + { + if let Some(name) = path.file_stem().and_then(|n| n.to_str()) { + println!(" {}", name.green()); + } + } + } + } + } + + Ok(()) +} + +/// Initialize a workflow from a template. +pub fn init(template: &str) -> Result<()> { + // Find template + let content = TEMPLATES + .iter() + .find(|(name, _, _)| *name == template) + .map(|(_, _, content)| *content); + + let content = match content { + Some(c) => c, + None => { + bail!( + "Template '{}' not found. Use 'fgp workflow list --builtin' to see available templates.", + template + ); + } + }; + + // Create workflows directory if needed + let workflows_dir = workflows_dir(); + fs::create_dir_all(&workflows_dir)?; + + // Write template + let output_path = workflows_dir.join(format!("{}.yaml", template)); + if output_path.exists() { + bail!( + "Workflow '{}' already exists at {}", + template, + output_path.display() + ); + } + + fs::write(&output_path, content)?; + + println!( + "{} Created workflow: {}", + "✓".green().bold(), + output_path.display() + ); + println!(); + println!( + "Run with: {}", + format!("fgp workflow run {}", output_path.display()).cyan() + ); + + Ok(()) +} + +/// Get the workflows directory. +fn workflows_dir() -> PathBuf { + let base = shellexpand::tilde("~/.fgp/workflows"); + PathBuf::from(base.as_ref()) +} diff --git a/src/main.rs b/src/main.rs index 5f1b39c..2f7977f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,9 +11,13 @@ //! fgp status # Show running daemons //! fgp call # Call a method //! fgp install # Install from local path +//! fgp logs # View daemon logs +//! fgp mcp serve # Start MCP bridge +//! fgp monitor # Health monitor with notifications //! ``` mod commands; +mod notifications; mod tui; use anyhow::Result; @@ -137,6 +141,37 @@ enum Commands { poll: u64, }, + /// View daemon logs + Logs { + /// Service name + service: String, + + /// Follow log output (like tail -f) + #[arg(short, long)] + follow: bool, + + /// Number of lines to show + #[arg(short = 'n', long, default_value = "50")] + lines: usize, + }, + + /// MCP bridge commands (expose FGP as MCP server) + Mcp { + #[command(subcommand)] + action: McpBridgeAction, + }, + + /// Health monitor with notifications + Monitor { + /// Check interval in seconds + #[arg(short, long, default_value = "60")] + interval: u64, + + /// Run as background daemon + #[arg(short, long)] + daemon: bool, + }, + /// Run or validate a workflow Workflow { #[command(subcommand)] @@ -167,6 +202,31 @@ enum WorkflowAction { /// Path to workflow YAML file file: String, }, + + /// List available workflow templates + List { + /// Show built-in templates only + #[arg(long)] + builtin: bool, + }, + + /// Initialize a workflow from a template + Init { + /// Template name + template: String, + }, +} + +#[derive(Subcommand)] +enum McpBridgeAction { + /// Start MCP bridge server (stdio mode) + Serve, + + /// Register FGP with Claude Code + Install, + + /// List available MCP tools from daemons + Tools, } #[derive(Subcommand)] @@ -217,9 +277,9 @@ enum SkillAction { path: String, }, - /// Export skill for a specific agent (claude-code, cursor, codex, mcp, windsurf, zed) + /// Export skill for a specific agent (claude-code, cursor, codex, mcp, windsurf, zed, gemini, aider) Export { - /// Target agent: claude-code, cursor, codex, mcp, windsurf, zed + /// Target agent: claude-code, cursor, codex, mcp, windsurf, zed, gemini, aider target: String, /// Skill name or path to skill directory @@ -243,7 +303,7 @@ enum SkillAction { }, /// Manage MCP bridge registration - Mcp { + McpReg { #[command(subcommand)] action: McpAction, }, @@ -401,24 +461,37 @@ fn main() -> Result<()> { Commands::Health { service } => commands::health::run(&service), Commands::Dashboard { port, open } => commands::dashboard::run(port, open), Commands::Tui { poll } => commands::tui::run(poll), + Commands::Logs { + service, + follow, + lines, + } => commands::logs::run(&service, follow, lines), + Commands::Mcp { action } => match action { + McpBridgeAction::Serve => commands::mcp_bridge::serve(), + McpBridgeAction::Install => commands::mcp_bridge::install(), + McpBridgeAction::Tools => commands::mcp_bridge::tools(), + }, + Commands::Monitor { interval, daemon } => commands::monitor::run(interval, daemon), Commands::Workflow { action } => match action { WorkflowAction::Run { file, verbose } => commands::workflow::run(&file, verbose), WorkflowAction::Validate { file } => commands::workflow::validate(&file), + WorkflowAction::List { builtin } => commands::workflow::list(builtin), + WorkflowAction::Init { template } => commands::workflow::init(&template), }, Commands::Skill { action } => match action { SkillAction::List => commands::skill::list(), SkillAction::Search { query } => commands::skill::search(&query), - SkillAction::Install { name, from } => { - commands::skill::install(&name, from.as_deref()) - } + SkillAction::Install { name, from } => commands::skill::install(&name, from.as_deref()), SkillAction::Update => commands::skill::check_updates(), SkillAction::Upgrade { skill } => commands::skill::upgrade(skill.as_deref()), SkillAction::Remove { name } => commands::skill::remove(&name), SkillAction::Info { name } => commands::skill::info(&name), SkillAction::Validate { path } => commands::skill_validate::validate(&path), - SkillAction::Export { target, skill, output } => { - commands::skill_export::export(&target, &skill, output.as_deref()) - } + SkillAction::Export { + target, + skill, + output, + } => commands::skill_export::export(&target, &skill, output.as_deref()), SkillAction::Tap { action } => match action { TapAction::Add { repo } => commands::skill_tap::add(&repo), TapAction::Remove { name } => commands::skill_tap::remove(&name), @@ -431,7 +504,7 @@ fn main() -> Result<()> { MarketplaceAction::Add { url } => commands::skill::marketplace_add(&url), MarketplaceAction::Update => commands::skill::marketplace_update(), }, - SkillAction::Mcp { action } => match action { + SkillAction::McpReg { action } => match action { McpAction::Register { name, target } => { if target == "mcp" { commands::skill::mcp_register(&name) diff --git a/src/notifications.rs b/src/notifications.rs new file mode 100644 index 0000000..16b72c5 --- /dev/null +++ b/src/notifications.rs @@ -0,0 +1,66 @@ +//! System notification helpers. +//! +//! Provides cross-platform notification support for FGP alerts. + +/// Escape a string for use in AppleScript string literals. +/// Handles backslashes, double quotes, and newlines which have special meaning in AppleScript. +#[cfg(target_os = "macos")] +fn escape_applescript_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', " ") + .replace('\r', " ") +} + +/// Send a system notification. +/// +/// On macOS, uses osascript to display a native notification. +/// On other platforms, this is a no-op (could be extended with notify-rust). +pub fn notify(title: &str, message: &str) { + #[cfg(target_os = "macos")] + { + // Use AppleScript with proper escaping to prevent command injection + let escaped_title = escape_applescript_string(title); + let escaped_message = escape_applescript_string(message); + + let script = format!( + "display notification \"{}\" with title \"{}\"", + escaped_message, escaped_title + ); + + let _ = std::process::Command::new("osascript") + .args(["-e", &script]) + .output(); + } + + #[cfg(not(target_os = "macos"))] + { + // Could use notify-rust here for Linux/Windows support + let _ = (title, message); // Silence unused warnings + } +} + +/// Send a notification with a sound. +#[allow(dead_code)] +pub fn notify_with_sound(title: &str, message: &str, sound: &str) { + #[cfg(target_os = "macos")] + { + let escaped_title = escape_applescript_string(title); + let escaped_message = escape_applescript_string(message); + let escaped_sound = escape_applescript_string(sound); + + let script = format!( + "display notification \"{}\" with title \"{}\" sound name \"{}\"", + escaped_message, escaped_title, escaped_sound + ); + + let _ = std::process::Command::new("osascript") + .args(["-e", &script]) + .output(); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (title, message, sound); + } +} diff --git a/src/tui/app.rs b/src/tui/app.rs index f7665c8..d562429 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -80,6 +80,12 @@ pub struct App { /// Whether help overlay is visible. pub show_help: bool, + + /// Whether detail overlay is visible. + pub show_detail: bool, + + /// Methods for the currently selected service (for detail view). + pub detail_methods: Vec, } impl App { @@ -93,6 +99,8 @@ impl App { message: None, message_timeout: Duration::from_secs(3), show_help: false, + show_detail: false, + detail_methods: Vec::new(), } } @@ -190,6 +198,98 @@ impl App { } } + /// Restart the selected service. + pub fn restart_selected(&mut self) { + if let Some(service) = self.selected_service().cloned() { + if service.status == ServiceStatus::Running + || service.status == ServiceStatus::Unhealthy + { + // Stop first + if let Err(e) = fgp_daemon::lifecycle::stop_service(&service.name) { + self.set_message( + format!("Failed to stop {}: {}", service.name, e), + MessageType::Error, + ); + return; + } + + // Poll for service to actually stop (max 1 second) + let socket = fgp_daemon::lifecycle::service_socket_path(&service.name); + for _ in 0..10 { + std::thread::sleep(std::time::Duration::from_millis(100)); + if !socket.exists() { + break; + } + // Also check if socket exists but daemon is not responding + if let Ok(client) = fgp_daemon::FgpClient::new(&socket) { + if client.health().is_err() { + break; + } + } else { + break; + } + } + + // Start again + match fgp_daemon::lifecycle::start_service(&service.name) { + Ok(()) => { + self.set_message( + format!("Restarted {}", service.name), + MessageType::Success, + ); + self.refresh_services(); + } + Err(e) => { + self.set_message( + format!("Failed to restart {}: {}", service.name, e), + MessageType::Error, + ); + } + } + } + } + } + + /// Toggle detail overlay. + pub fn toggle_detail(&mut self) { + if !self.show_detail { + self.load_service_methods(); + } + self.show_detail = !self.show_detail; + } + + /// Load methods for the currently selected service. + fn load_service_methods(&mut self) { + self.detail_methods.clear(); + if let Some(service) = self.selected_service() { + let socket = fgp_daemon::lifecycle::service_socket_path(&service.name); + match fgp_daemon::FgpClient::new(&socket) { + Ok(client) => match client.methods() { + Ok(response) if response.ok => { + if let Some(result) = response.result { + if let Some(methods) = result["methods"].as_array() { + for method in methods { + let name = method["name"].as_str().unwrap_or("unknown"); + // Skip internal methods + if name != "health" && name != "stop" && name != "methods" { + self.detail_methods.push(name.to_string()); + } + } + } + } + } + _ => { + self.detail_methods + .push("Error loading methods".to_string()); + } + }, + Err(_) => { + self.detail_methods.push("Daemon not running".to_string()); + } + } + } + } + /// Set a message to display. pub fn set_message(&mut self, text: String, msg_type: MessageType) { self.message = Some((text, msg_type, Instant::now())); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f5dd889..7e053c2 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -69,35 +69,72 @@ fn run_app( use crossterm::event::KeyCode; match key.code { - // Quit - KeyCode::Char('q') | KeyCode::Esc => { - app.should_quit = true; + // Quit / Close overlays + KeyCode::Esc => { + if app.show_detail { + app.show_detail = false; + } else if app.show_help { + app.show_help = false; + } else { + app.should_quit = true; + } + } + KeyCode::Char('q') => { + if !app.show_detail && !app.show_help { + app.should_quit = true; + } } // Navigation KeyCode::Up | KeyCode::Char('k') => { - app.select_previous(); + if !app.show_detail { + app.select_previous(); + } } KeyCode::Down | KeyCode::Char('j') => { - app.select_next(); + if !app.show_detail { + app.select_next(); + } } KeyCode::Home => { - app.select_first(); + if !app.show_detail { + app.select_first(); + } } KeyCode::End => { - app.select_last(); + if !app.show_detail { + app.select_last(); + } } // Actions - KeyCode::Char('s') | KeyCode::Enter => { - app.start_selected(); + KeyCode::Char('s') => { + if !app.show_detail && !app.show_help { + app.start_selected(); + } + } + KeyCode::Enter | KeyCode::Char('d') => { + if !app.show_help { + app.toggle_detail(); + } } KeyCode::Char('x') => { - app.stop_selected(); + if !app.show_detail && !app.show_help { + app.stop_selected(); + } + } + KeyCode::Char('R') => { + if !app.show_detail && !app.show_help { + app.restart_selected(); + } } KeyCode::Char('r') => { - app.refresh_services(); + if !app.show_detail && !app.show_help { + app.refresh_services(); + } } KeyCode::Char('?') => { - app.toggle_help(); + if !app.show_detail { + app.toggle_help(); + } } _ => {} } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 82a5fd4..6a847f6 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -25,8 +25,12 @@ pub fn draw(frame: &mut Frame, app: &App) { draw_service_table(frame, chunks[1], app); draw_footer(frame, chunks[2], app); - // Draw help overlay if visible - if app.show_help { + // Draw overlays + if app.show_detail { + if let Some(service) = app.selected_service() { + draw_detail_overlay(frame, service, &app.detail_methods); + } + } else if app.show_help { draw_help_overlay(frame); } } @@ -178,8 +182,10 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &App) { Span::raw(" Start "), Span::styled("[x]", Style::default().fg(Color::Red)), Span::raw(" Stop "), - Span::styled("[r]", Style::default().fg(Color::Cyan)), - Span::raw(" Refresh "), + Span::styled("[R]", Style::default().fg(Color::Blue)), + Span::raw(" Restart "), + Span::styled("[d]", Style::default().fg(Color::Cyan)), + Span::raw(" Detail "), Span::styled("[?]", Style::default().fg(Color::Magenta)), Span::raw(" Help "), Span::styled("[q]", Style::default().fg(Color::DarkGray)), @@ -249,13 +255,21 @@ fn draw_help_overlay(frame: &mut Frame) { ]), Line::from(""), Line::from(vec![ - Span::styled(" s/Enter ", Style::default().fg(Color::Green)), + Span::styled(" s ", Style::default().fg(Color::Green)), Span::raw("Start selected service"), ]), Line::from(vec![ Span::styled(" x ", Style::default().fg(Color::Red)), Span::raw("Stop selected service"), ]), + Line::from(vec![ + Span::styled(" R ", Style::default().fg(Color::Blue)), + Span::raw("Restart selected service"), + ]), + Line::from(vec![ + Span::styled(" d/Enter ", Style::default().fg(Color::Cyan)), + Span::raw("View service details"), + ]), Line::from(vec![ Span::styled(" r ", Style::default().fg(Color::Cyan)), Span::raw("Refresh service list"), @@ -287,6 +301,103 @@ fn draw_help_overlay(frame: &mut Frame) { frame.render_widget(help_paragraph, area); } +/// Draw the service detail overlay. +fn draw_detail_overlay(frame: &mut Frame, service: &super::app::ServiceInfo, methods: &[String]) { + let area = centered_rect(60, 70, frame.area()); + + // Clear the area first + frame.render_widget(Clear, area); + + let mut lines = vec![ + Line::from(""), + Line::from(vec![Span::styled( + format!(" {}", service.name), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]), + Line::from(""), + ]; + + // Status + let (status_color, status_text) = match service.status { + super::app::ServiceStatus::Running => (Color::Green, "● running"), + super::app::ServiceStatus::Stopped => (Color::DarkGray, "○ stopped"), + super::app::ServiceStatus::Unhealthy => (Color::Yellow, "◐ unhealthy"), + super::app::ServiceStatus::Error => (Color::Red, "● error"), + super::app::ServiceStatus::Starting => (Color::Blue, "◑ starting"), + super::app::ServiceStatus::Stopping => (Color::Blue, "◑ stopping"), + }; + + lines.push(Line::from(vec![ + Span::raw(" Status: "), + Span::styled(status_text, Style::default().fg(status_color)), + ])); + + // Version + if let Some(ref version) = service.version { + lines.push(Line::from(vec![ + Span::raw(" Version: "), + Span::styled(version.clone(), Style::default().fg(Color::White)), + ])); + } + + // Uptime + if let Some(uptime) = service.uptime_seconds { + lines.push(Line::from(vec![ + Span::raw(" Uptime: "), + Span::styled( + super::app::format_uptime(uptime), + Style::default().fg(Color::White), + ), + ])); + } + + lines.push(Line::from("")); + + // Methods + if !methods.is_empty() { + lines.push(Line::from(vec![Span::styled( + format!(" Available Methods ({}):", methods.len()), + Style::default().fg(Color::Yellow), + )])); + + for method in methods.iter().take(15) { + lines.push(Line::from(vec![ + Span::raw(" • "), + Span::styled(method.clone(), Style::default().fg(Color::Green)), + ])); + } + + if methods.len() > 15 { + lines.push(Line::from(vec![Span::styled( + format!(" ... and {} more", methods.len() - 15), + Style::default().fg(Color::DarkGray), + )])); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Press Esc or d to close", + Style::default().fg(Color::DarkGray), + )])); + + let detail_block = Block::default() + .title(Span::styled( + " Service Details ", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Blue)) + .style(Style::default().bg(Color::Black)); + + let detail_paragraph = Paragraph::new(lines).block(detail_block); + frame.render_widget(detail_paragraph, area); +} + /// Create a centered rectangle. fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default()