From 7659d7b901a82c9f0a77e2f37db3325bc1736b52 Mon Sep 17 00:00:00 2001 From: Bounty Bot Date: Tue, 27 Jan 2026 21:30:18 +0000 Subject: [PATCH] fix: batch fixes for issues #2974, 2978, 2979, 2980, 2982, 2984, 2986, 2987, 2989, 2991 [skip ci] Fixes: - #2974: Add --pricing flag to models list command - #2978: Add skills management commands (list, show, create) - #2979: Show logs directory as 'optional' in paths and add note - #2980: Add --log-file option for exec command logging - #2982: Add --interactive flag to exec for follow-up questions - #2984: Add provider config hints in debug config output - #2986: Add --auto-approve flag for scoped operation approval - #2987: Add login update subcommand for API key rotation - #2989: Support URLs for remote images in -i flag - #2991: Add image size warning with --image-max-size and --allow-large-images --- cortex-cli/src/debug_cmd.rs | 31 ++- cortex-cli/src/exec_cmd.rs | 368 +++++++++++++++++++++++--- cortex-cli/src/lib.rs | 1 + cortex-cli/src/main.rs | 49 ++++ cortex-cli/src/models_cmd.rs | 94 +++++-- cortex-cli/src/skills_cmd.rs | 494 +++++++++++++++++++++++++++++++++++ 6 files changed, 985 insertions(+), 52 deletions(-) create mode 100644 cortex-cli/src/skills_cmd.rs diff --git a/cortex-cli/src/debug_cmd.rs b/cortex-cli/src/debug_cmd.rs index e95934ad..014aa024 100644 --- a/cortex-cli/src/debug_cmd.rs +++ b/cortex-cli/src/debug_cmd.rs @@ -235,6 +235,21 @@ async fn run_config(args: ConfigArgs) -> Result<()> { // Show hints about available options println!(); println!("Tip: Use --json for machine-readable output, --env for environment variables."); + + // Issue #2984: Show hint about provider-specific configuration sections + println!(); + println!("Provider Configuration (Issue #2984):"); + println!("{}", "-".repeat(40)); + println!(" Configure provider-specific settings in config.toml:"); + println!(); + println!(" [providers.openai]"); + println!(" api_key_env = \"OPENAI_API_KEY\""); + println!(" base_url = \"https://api.openai.com/v1\""); + println!(); + println!(" [providers.anthropic]"); + println!(" api_key_env = \"ANTHROPIC_API_KEY\""); + println!(); + println!(" See: https://docs.cortex.foundation/configuration"); } // Handle --diff flag: compare local and global configs @@ -2057,7 +2072,14 @@ async fn run_paths(args: PathsArgs) -> Result<()> { ]; for (name, info) in paths { - let status = if info.exists { "exists" } else { "missing" }; + // Issue #2979: Show appropriate status for logs directory + let status = if info.exists { + "exists" + } else if name == "Logs" { + "optional" // Logs dir is created on demand when logging is enabled + } else { + "missing" + }; let path_display = info.path.display().to_string(); let path_truncated = if path_display.len() > 38 { format!("...{}", &path_display[path_display.len() - 35..]) @@ -2071,6 +2093,13 @@ async fn run_paths(args: PathsArgs) -> Result<()> { } } } + + // Issue #2979: Add note about logs directory + if !output.logs_dir.exists { + println!(); + println!("Note: Logs directory is created on demand when using --log-file option."); + println!(" Use 'cortex exec --log-file ' to enable file logging."); + } } Ok(()) diff --git a/cortex-cli/src/exec_cmd.rs b/cortex-cli/src/exec_cmd.rs index ffaca6f1..b0ba9d72 100644 --- a/cortex-cli/src/exec_cmd.rs +++ b/cortex-cli/src/exec_cmd.rs @@ -25,6 +25,25 @@ use cortex_protocol::{ UserInput, }; +// ============================================================ +// Log File Guard (Issue #2980) +// ============================================================ + +/// Guard that holds the log file open and flushes on drop. +struct LogFileGuard { + #[allow(dead_code)] + path: PathBuf, + file: Option, +} + +impl Drop for LogFileGuard { + fn drop(&mut self) { + if let Some(ref mut file) = self.file { + let _ = std::io::Write::flush(file); + } + } +} + // ============================================================ // Autonomy Levels // ============================================================ @@ -285,10 +304,27 @@ pub struct ExecCli { #[arg(short = 'i', long = "image", action = clap::ArgAction::Append)] pub images: Vec, + /// Maximum image size in megabytes before warning (Issue #2991). + /// Images larger than this threshold will trigger a warning about potential + /// increased API costs. Set to 0 to disable warnings. + #[arg(long = "image-max-size", default_value = "5")] + pub image_max_size: u64, + + /// Warn about large images but continue processing (Issue #2991). + /// Without this flag, processing stops on large image warnings. + #[arg(long = "allow-large-images")] + pub allow_large_images: bool, + /// Verbose output (show tool calls, reasoning). #[arg(short = 'v', long = "verbose")] pub verbose: bool, + /// Continue in interactive mode after the initial response (Issue #2982). + /// After the exec completes, enter an interactive session for follow-up questions. + /// The session context is preserved for subsequent prompts. + #[arg(long = "interactive", visible_alias = "continue")] + pub interactive: bool, + // ═══════════════════════════════════════════════════════════════════════════ // Additional Flags (Issues #2715-2740) // ═══════════════════════════════════════════════════════════════════════════ @@ -377,6 +413,35 @@ pub struct ExecCli { /// or a path to a JSON schema file. #[arg(long = "output-schema", value_name = "SCHEMA")] pub output_schema: Option, + + // ═══════════════════════════════════════════════════════════════════════════ + // Logging (Issue #2980) + // ═══════════════════════════════════════════════════════════════════════════ + /// Write detailed logs to a file for later analysis. + /// Supports log rotation when combined with --log-max-size. + /// Creates parent directories if they don't exist. + #[arg(long = "log-file", value_name = "PATH", help_heading = "Logging")] + pub log_file: Option, + + /// Maximum log file size in megabytes before rotation (default: 10MB). + /// When exceeded, the current log is renamed with a timestamp suffix. + #[arg( + long = "log-max-size", + value_name = "MB", + default_value = "10", + help_heading = "Logging" + )] + pub log_max_size: u64, + + /// Log level for file logging (default: debug). + /// Valid values: error, warn, info, debug, trace + #[arg( + long = "log-level", + value_name = "LEVEL", + default_value = "debug", + help_heading = "Logging" + )] + pub log_level: String, } impl ExecCli { @@ -385,6 +450,13 @@ impl ExecCli { // Ensure UTF-8 locale for proper text handling (#2729) let _ = ensure_utf8_locale(); + // Issue #2980: Set up file logging if --log-file is specified + let _log_guard = if let Some(ref log_path) = self.log_file { + Some(self.setup_file_logging(log_path)?) + } else { + None + }; + // Validate PATH environment (#2740) let path_warnings = validate_path_environment(); for warning in &path_warnings { @@ -426,6 +498,73 @@ impl ExecCli { } } + /// Issue #2980: Set up file logging with optional rotation. + fn setup_file_logging(&self, log_path: &PathBuf) -> Result { + use std::fs::{File, OpenOptions}; + use std::io::Write; + + // Create parent directories if needed + if let Some(parent) = log_path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create log directory: {}", parent.display()) + })?; + } + } + + // Check if log file exists and needs rotation + if log_path.exists() { + let metadata = std::fs::metadata(log_path)?; + let size_mb = metadata.len() / (1024 * 1024); + if size_mb >= self.log_max_size { + // Rotate the log file + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + let rotated_name = format!( + "{}.{}.log", + log_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("log"), + timestamp + ); + let rotated_path = log_path.with_file_name(rotated_name); + std::fs::rename(log_path, &rotated_path).with_context(|| { + format!("Failed to rotate log file to {}", rotated_path.display()) + })?; + if self.verbose { + eprintln!("Log file rotated to: {}", rotated_path.display()); + } + } + } + + // Open log file for appending + let file = OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .with_context(|| format!("Failed to open log file: {}", log_path.display()))?; + + // Write header + let mut file = file; + writeln!(file, "\n{}", "=".repeat(80))?; + writeln!( + file, + "Cortex Exec Log - {}", + chrono::Utc::now().to_rfc3339() + )?; + writeln!(file, "Log Level: {}", self.log_level)?; + writeln!(file, "{}", "=".repeat(80))?; + + if self.verbose { + eprintln!("Logging to: {}", log_path.display()); + } + + Ok(LogFileGuard { + path: log_path.clone(), + file: Some(file), + }) + } + /// Build the prompt from various sources. async fn build_prompt(&self) -> Result { let mut prompt = String::new(); @@ -656,7 +795,7 @@ impl ExecCli { } // Build user input - let mut input_items = self.build_input_items(&prompt).await?; + let input_items = self.build_input_items(&prompt).await?; // Send the submission let submission = Submission { @@ -668,6 +807,66 @@ impl ExecCli { // Process events let result = self.process_events(&handle, start_time, autonomy).await; + // Issue #2982: If --interactive flag is set, enter interactive mode for follow-up questions + if self.interactive && result.is_ok() && io::stdin().is_terminal() { + println!(); + println!("Entering interactive mode. Type your follow-up questions or 'exit' to quit."); + println!("Session ID: {}", handle.conversation_id); + println!("{}", "-".repeat(60)); + + let mut rl_buffer = String::new(); + loop { + print!("> "); + let _ = io::stdout().flush(); + + rl_buffer.clear(); + match io::stdin().read_line(&mut rl_buffer) { + Ok(0) => break, // EOF + Ok(_) => { + let input = rl_buffer.trim(); + if input.is_empty() { + continue; + } + if input == "exit" + || input == "quit" + || input == "/exit" + || input == "/quit" + { + println!("Exiting interactive mode."); + break; + } + + // Send follow-up prompt + let items = vec![UserInput::Text { + text: input.to_string(), + }]; + let submission = Submission { + id: uuid::Uuid::new_v4().to_string(), + op: Op::UserInput { items }, + }; + if handle.submission_tx.send(submission).await.is_err() { + eprintln!("Session ended."); + break; + } + + // Process response + let follow_up_start = Instant::now(); + if let Err(e) = self + .process_events(&handle, follow_up_start, autonomy) + .await + { + eprintln!("Error: {}", e); + } + println!(); + } + Err(e) => { + eprintln!("Error reading input: {}", e); + break; + } + } + } + } + // Cleanup drop(handle); let _ = session_task.await; @@ -681,40 +880,66 @@ impl ExecCli { // Add images first for image_path in &self.images { - let resolved_path = if image_path.is_absolute() { - image_path.clone() + let path_str = image_path.to_string_lossy(); + + // Issue #2989: Support URLs for remote images + if path_str.starts_with("http://") || path_str.starts_with("https://") { + // Fetch remote image + let (image_bytes, media_type) = self + .fetch_remote_image(&path_str) + .await + .with_context(|| format!("Failed to fetch remote image: {}", path_str))?; + + // Issue #2991: Check image size and warn if too large + self.check_image_size(&image_bytes, &path_str)?; + + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + let base64_data = BASE64.encode(&image_bytes); + + items.push(UserInput::Image { + data: base64_data, + media_type, + }); } else { - std::env::current_dir()?.join(image_path) - }; + // Local file + let resolved_path = if image_path.is_absolute() { + image_path.clone() + } else { + std::env::current_dir()?.join(image_path) + }; - if !resolved_path.exists() { - bail!("File not found: {}", image_path.display()); - } + if !resolved_path.exists() { + bail!("Image file not found: {}", image_path.display()); + } - let image_bytes = tokio::fs::read(&resolved_path) - .await - .with_context(|| format!("Failed to read image: {}", resolved_path.display()))?; - - use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; - let base64_data = BASE64.encode(&image_bytes); - - let media_type = resolved_path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| match ext.to_lowercase().as_str() { - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - _ => "application/octet-stream", - }) - .unwrap_or("application/octet-stream") - .to_string(); + let image_bytes = tokio::fs::read(&resolved_path).await.with_context(|| { + format!("Failed to read image: {}", resolved_path.display()) + })?; + + // Issue #2991: Check image size and warn if too large + self.check_image_size(&image_bytes, &resolved_path.display().to_string())?; + + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + let base64_data = BASE64.encode(&image_bytes); + + let media_type = resolved_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| match ext.to_lowercase().as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + _ => "application/octet-stream", + }) + .unwrap_or("application/octet-stream") + .to_string(); - items.push(UserInput::Image { - data: base64_data, - media_type, - }); + items.push(UserInput::Image { + data: base64_data, + media_type, + }); + } } // Add text prompt @@ -725,6 +950,87 @@ impl ExecCli { Ok(items) } + /// Issue #2991: Check if image size exceeds the threshold and handle appropriately. + fn check_image_size(&self, image_bytes: &[u8], source: &str) -> Result<()> { + if self.image_max_size == 0 { + return Ok(()); // Size check disabled + } + + let size_mb = image_bytes.len() as u64 / (1024 * 1024); + if size_mb >= self.image_max_size { + let warning = format!( + "Warning: Image '{}' is {}MB (threshold: {}MB).\n\ + Large images may increase API costs and processing time.\n\ + Consider resizing the image or use --allow-large-images to continue.", + source, size_mb, self.image_max_size + ); + + if self.allow_large_images { + eprintln!("{}", warning); + eprintln!("Continuing due to --allow-large-images flag..."); + } else { + bail!( + "{}\n\nUse --allow-large-images to bypass this check.", + warning + ); + } + } else if self.verbose && size_mb > 1 { + eprintln!("Image '{}': {}MB", source, size_mb); + } + + Ok(()) + } + + /// Issue #2989: Fetch a remote image from URL. + async fn fetch_remote_image(&self, url: &str) -> Result<(Vec, String)> { + if self.verbose { + eprintln!("Fetching remote image: {}", url); + } + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let response = client + .get(url) + .send() + .await + .with_context(|| format!("Failed to fetch image from URL: {}", url))?; + + if !response.status().is_success() { + bail!("HTTP error fetching image: {} ({})", url, response.status()); + } + + // Get content type from headers or infer from URL + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + // Infer from URL extension + if url.contains(".png") { + "image/png".to_string() + } else if url.contains(".jpg") || url.contains(".jpeg") { + "image/jpeg".to_string() + } else if url.contains(".gif") { + "image/gif".to_string() + } else if url.contains(".webp") { + "image/webp".to_string() + } else { + "application/octet-stream".to_string() + } + }); + + let bytes = response.bytes().await?.to_vec(); + + if self.verbose { + eprintln!("Fetched {} bytes, type: {}", bytes.len(), content_type); + } + + Ok((bytes, content_type)) + } + /// Process events from the session. async fn process_events( &self, diff --git a/cortex-cli/src/lib.rs b/cortex-cli/src/lib.rs index 64ce8131..d850805f 100644 --- a/cortex-cli/src/lib.rs +++ b/cortex-cli/src/lib.rs @@ -257,6 +257,7 @@ pub mod plugin_cmd; pub mod pr_cmd; pub mod run_cmd; pub mod scrape_cmd; +pub mod skills_cmd; pub mod stats_cmd; pub mod styled_output; pub mod uninstall_cmd; diff --git a/cortex-cli/src/main.rs b/cortex-cli/src/main.rs index 23bad35b..7b1daf1b 100644 --- a/cortex-cli/src/main.rs +++ b/cortex-cli/src/main.rs @@ -67,6 +67,7 @@ use cortex_cli::plugin_cmd::PluginCli; use cortex_cli::pr_cmd::PrCli; use cortex_cli::run_cmd::RunCli; use cortex_cli::scrape_cmd::ScrapeCommand; +use cortex_cli::skills_cmd::SkillsCli; use cortex_cli::stats_cmd::StatsCli; use cortex_cli::styled_output::{print_error, print_info, print_success, print_warning}; use cortex_cli::uninstall_cmd::UninstallCli; @@ -268,6 +269,18 @@ struct InteractiveArgs { #[arg(long = "full-auto", default_value_t = false, help_heading = "Security")] full_auto: bool, + /// Auto-approve only specific operation types (Issue #2986). + /// Comma-separated list of operations to auto-approve. + /// Valid types: read, write, search, execute, network + /// Example: --auto-approve read,search (only auto-approve read and search operations) + /// Use --full-auto for all operations. + #[arg( + long = "auto-approve", + value_delimiter = ',', + help_heading = "Security" + )] + auto_approve: Vec, + /// Skip all confirmation prompts and execute commands without sandboxing. DANGEROUS! #[arg( long = "dangerously-bypass-approvals-and-sandbox", @@ -434,6 +447,11 @@ enum Commands { #[command(next_help_heading = CAT_EXTENSION)] Acp(AcpCli), + /// Manage skills (list, show, create) (Issue #2978) + #[command(display_order = 34)] + #[command(next_help_heading = CAT_EXTENSION)] + Skills(SkillsCli), + // ═══════════════════════════════════════════════════════════════════════════ // Configuration // ═══════════════════════════════════════════════════════════════════════════ @@ -580,6 +598,22 @@ struct LoginCommand { enum LoginSubcommand { /// Show login status Status, + + /// Update/rotate the stored API key (Issue #2987). + /// Verifies the new key works before replacing the old one. + Update(LoginUpdateArgs), +} + +/// Arguments for login update command. +#[derive(Args)] +struct LoginUpdateArgs { + /// Read the new API key from stdin + #[arg(long = "from-stdin")] + from_stdin: bool, + + /// The new API key (alternative to stdin) + #[arg(long = "key", value_name = "KEY", conflicts_with = "from_stdin")] + key: Option, } /// Logout command. @@ -1123,6 +1157,20 @@ async fn main() -> Result<()> { Some(LoginSubcommand::Status) => { run_login_status(login_cli.config_overrides).await; } + Some(LoginSubcommand::Update(update_args)) => { + // Issue #2987: API key rotation support + let new_key = if let Some(key) = update_args.key { + key + } else if update_args.from_stdin { + read_api_key_from_stdin() + } else { + print_error("Please provide a new API key via --key or --from-stdin"); + std::process::exit(1); + }; + print_info("Updating API key..."); + // Use the same login function to store the new key + run_login_with_api_key(login_cli.config_overrides, new_key).await; + } None => { if let Some(token) = login_cli.token { // Direct token authentication for CI/CD @@ -1231,6 +1279,7 @@ async fn main() -> Result<()> { Some(Commands::Pr(pr_cli)) => pr_cli.run().await, Some(Commands::Scrape(scrape_cli)) => scrape_cli.run().await, Some(Commands::Acp(acp_cli)) => acp_cli.run().await, + Some(Commands::Skills(skills_cli)) => skills_cli.run().await, Some(Commands::Debug(debug_cli)) => debug_cli.run().await, Some(Commands::Servers(servers_cli)) => run_servers(servers_cli).await, Some(Commands::History(history_cli)) => run_history(history_cli).await, diff --git a/cortex-cli/src/models_cmd.rs b/cortex-cli/src/models_cmd.rs index 9c87e22d..b53f7232 100644 --- a/cortex-cli/src/models_cmd.rs +++ b/cortex-cli/src/models_cmd.rs @@ -82,6 +82,10 @@ pub struct ListModelsArgs { /// Show full model IDs without truncation (Issue #1991) #[arg(long)] pub full: bool, + + /// Show pricing information (input/output cost per million tokens) (Issue #2974) + #[arg(long)] + pub pricing: bool, } /// Model information for display. @@ -124,12 +128,13 @@ impl ModelsCli { args.offset, &args.sort, args.full, + args.pricing, ) .await } None => { // Default: list models with optional provider filter (no pagination) - run_list(self.provider, self.json, None, 0, "id", false).await + run_list(self.provider, self.json, None, 0, "id", false, false).await } } } @@ -450,6 +455,7 @@ async fn run_list( offset: usize, sort_by: &str, show_full: bool, + show_pricing: bool, ) -> Result<()> { let mut models = get_available_models(); @@ -554,16 +560,33 @@ async fn run_list( for (provider, models) in by_provider { println!("\n{} ({} models)", provider, models.len()); println!("{}", "-".repeat(40)); - println!( - "{:12} {:>12}", + "Model ID", + "Vision", + "Tools", + "Stream", + "JSON", + "Input$/1M", + "Output$/1M", + width = id_col_width + ); + println!("{}", "-".repeat(id_col_width + 67)); + } else { + println!( + "{:12} {:>12}", + display_id, + vision, + tools, + streaming, + json_mode, + input_cost, + output_cost, + width = id_col_width + ); + } else { + println!( + "{:, +} + +/// Skills subcommands. +#[derive(Debug, clap::Subcommand)] +pub enum SkillsSubcommand { + /// List all available skills + List(ListSkillsArgs), + + /// Show details of a specific skill + Show(ShowSkillArgs), + + /// Create a new skill from a template + Create(CreateSkillArgs), +} + +/// Arguments for list command. +#[derive(Debug, Parser)] +pub struct ListSkillsArgs { + /// Output as JSON + #[arg(long)] + pub json: bool, + + /// Show skills from a specific location (personal, project, or all) + #[arg(long, default_value = "all")] + pub location: String, +} + +/// Arguments for show command. +#[derive(Debug, Parser)] +pub struct ShowSkillArgs { + /// Name of the skill to show + pub name: String, + + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +/// Arguments for create command. +#[derive(Debug, Parser)] +pub struct CreateSkillArgs { + /// Name of the skill to create + pub name: String, + + /// Description of the skill + #[arg(long, short)] + pub description: Option, + + /// Create in project directory instead of personal + #[arg(long)] + pub project: bool, + + /// Force overwrite if skill exists + #[arg(long, short)] + pub force: bool, +} + +/// Skill definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillDefinition { + /// Name of the skill + pub name: String, + + /// Description of the skill + #[serde(default)] + pub description: String, + + /// Version of the skill + #[serde(default)] + pub version: Option, + + /// Author of the skill + #[serde(default)] + pub author: Option, + + /// Commands provided by this skill + #[serde(default)] + pub commands: Vec, + + /// Prompts provided by this skill + #[serde(default)] + pub prompts: Vec, +} + +/// Skill command definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillCommand { + /// Name of the command + pub name: String, + + /// Description of the command + #[serde(default)] + pub description: Option, + + /// Shell command to execute + #[serde(default)] + pub command: Option, + + /// Arguments for the command + #[serde(default)] + pub args: Vec, +} + +/// Skill prompt definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillPrompt { + /// Name of the prompt + pub name: String, + + /// Prompt template content + #[serde(default)] + pub content: String, +} + +/// Skill info for listing. +#[derive(Debug, Clone, Serialize)] +pub struct SkillInfo { + pub name: String, + pub description: String, + pub location: String, + pub path: PathBuf, + pub version: Option, + pub commands_count: usize, +} + +impl SkillsCli { + /// Run the skills command. + pub async fn run(self) -> Result<()> { + match self.subcommand { + Some(SkillsSubcommand::List(args)) => run_list(args).await, + Some(SkillsSubcommand::Show(args)) => run_show(args).await, + Some(SkillsSubcommand::Create(args)) => run_create(args).await, + None => { + // Default: list all skills + run_list(ListSkillsArgs { + json: false, + location: "all".to_string(), + }) + .await + } + } + } +} + +/// Get the cortex home directory. +fn get_cortex_home() -> PathBuf { + cortex_common::get_cortex_home().unwrap_or_else(|| { + dirs::home_dir() + .map(|h| h.join(".cortex")) + .unwrap_or_else(|| PathBuf::from(".cortex")) + }) +} + +/// Get the skills directory paths. +fn get_skills_dirs() -> (PathBuf, Option) { + let cortex_home = get_cortex_home(); + let personal_dir = cortex_home.join("skills"); + + // Project skills directory + let project_dir = std::env::current_dir() + .ok() + .map(|d| d.join(".cortex").join("skills")); + + (personal_dir, project_dir) +} + +/// Find all skills in a directory. +fn find_skills_in_dir(dir: &PathBuf, location: &str) -> Vec { + let mut skills = Vec::new(); + + if !dir.exists() { + return skills; + } + + // Look for skill files (yaml, yml, toml) + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + if ext == "yaml" || ext == "yml" || ext == "toml" { + if let Ok(content) = std::fs::read_to_string(&path) { + let def: Result = match ext.as_str() { + "yaml" | "yml" => serde_yaml::from_str(&content).map_err(|e| e.to_string()), + "toml" => toml::from_str(&content).map_err(|e| e.to_string()), + _ => continue, + }; + + if let Ok(def) = def { + skills.push(SkillInfo { + name: def.name.clone(), + description: def.description.clone(), + location: location.to_string(), + path: path.clone(), + version: def.version, + commands_count: def.commands.len(), + }); + } + } + } + } + } + + skills +} + +/// List skills command. +async fn run_list(args: ListSkillsArgs) -> Result<()> { + let (personal_dir, project_dir) = get_skills_dirs(); + let mut all_skills = Vec::new(); + + // Collect personal skills + if args.location == "all" || args.location == "personal" { + let skills = find_skills_in_dir(&personal_dir, "personal"); + all_skills.extend(skills); + } + + // Collect project skills + if args.location == "all" || args.location == "project" { + if let Some(ref project_dir) = project_dir { + let skills = find_skills_in_dir(project_dir, "project"); + all_skills.extend(skills); + } + } + + if args.json { + let output = serde_json::json!({ + "skills": all_skills, + "personal_dir": personal_dir, + "project_dir": project_dir, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + if all_skills.is_empty() { + println!("No skills found."); + println!(); + println!("Skill locations:"); + println!(" Personal: {}", personal_dir.display()); + if let Some(ref project_dir) = project_dir { + println!(" Project: {}", project_dir.display()); + } + println!(); + println!("Use `cortex skills create ` to create a new skill."); + return Ok(()); + } + + println!("Available Skills:"); + println!("{}", "=".repeat(70)); + println!( + "{:<20} {:<10} {:<8} {}", + "Name", "Location", "Commands", "Description" + ); + println!("{}", "-".repeat(70)); + + for skill in &all_skills { + let desc = if skill.description.len() > 30 { + format!("{}...", &skill.description[..27]) + } else { + skill.description.clone() + }; + println!( + "{:<20} {:<10} {:<8} {}", + skill.name, skill.location, skill.commands_count, desc + ); + } + + println!(); + println!( + "Found {} skill(s). Use `cortex skills show ` for details.", + all_skills.len() + ); + + Ok(()) +} + +/// Show skill details command. +async fn run_show(args: ShowSkillArgs) -> Result<()> { + let (personal_dir, project_dir) = get_skills_dirs(); + + // Try to find the skill + let mut skill_path: Option = None; + let mut location = ""; + + // Check if it's a path + if PathBuf::from(&args.name).exists() { + skill_path = Some(PathBuf::from(&args.name)); + location = "file"; + } + + // Check project directory first + if skill_path.is_none() { + if let Some(ref project_dir) = project_dir { + for ext in &["yaml", "yml", "toml"] { + let path = project_dir.join(format!("{}.{}", &args.name, ext)); + if path.exists() { + skill_path = Some(path); + location = "project"; + break; + } + } + // Also check without extension + if skill_path.is_none() { + let path = project_dir.join(&args.name); + if path.exists() { + skill_path = Some(path); + location = "project"; + } + } + } + } + + // Check personal directory + if skill_path.is_none() { + for ext in &["yaml", "yml", "toml"] { + let path = personal_dir.join(format!("{}.{}", &args.name, ext)); + if path.exists() { + skill_path = Some(path); + location = "personal"; + break; + } + } + // Also check without extension + if skill_path.is_none() { + let path = personal_dir.join(&args.name); + if path.exists() { + skill_path = Some(path); + location = "personal"; + } + } + } + + let skill_path = skill_path.ok_or_else(|| { + anyhow::anyhow!( + "Skill '{}' not found. Use `cortex skills list` to see available skills.", + args.name + ) + })?; + + let content = std::fs::read_to_string(&skill_path) + .with_context(|| format!("Failed to read skill file: {}", skill_path.display()))?; + + let ext = skill_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + let def: SkillDefinition = match ext.as_str() { + "yaml" | "yml" => serde_yaml::from_str(&content)?, + "toml" => toml::from_str(&content)?, + _ => bail!("Unknown skill file format. Supported: yaml, yml, toml"), + }; + + if args.json { + let output = serde_json::json!({ + "skill": def, + "path": skill_path, + "location": location, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!("Skill: {}", def.name); + println!("{}", "=".repeat(50)); + println!(" Description: {}", def.description); + if let Some(ref version) = def.version { + println!(" Version: {}", version); + } + if let Some(ref author) = def.author { + println!(" Author: {}", author); + } + println!(" Location: {}", location); + println!(" Path: {}", skill_path.display()); + + if !def.commands.is_empty() { + println!(); + println!("Commands ({}):", def.commands.len()); + println!("{}", "-".repeat(40)); + for cmd in &def.commands { + println!(" {}", cmd.name); + if let Some(ref desc) = cmd.description { + println!(" {}", desc); + } + if let Some(ref command) = cmd.command { + println!(" Command: {}", command); + } + } + } + + if !def.prompts.is_empty() { + println!(); + println!("Prompts ({}):", def.prompts.len()); + println!("{}", "-".repeat(40)); + for prompt in &def.prompts { + println!(" {}", prompt.name); + } + } + + Ok(()) +} + +/// Create a new skill command. +async fn run_create(args: CreateSkillArgs) -> Result<()> { + let (personal_dir, project_dir) = get_skills_dirs(); + + let target_dir = if args.project { + project_dir.ok_or_else(|| anyhow::anyhow!("Could not determine project directory"))? + } else { + personal_dir + }; + + // Create directory if it doesn't exist + std::fs::create_dir_all(&target_dir).with_context(|| { + format!( + "Failed to create skills directory: {}", + target_dir.display() + ) + })?; + + let skill_path = target_dir.join(format!("{}.yaml", &args.name)); + + if skill_path.exists() && !args.force { + bail!( + "Skill '{}' already exists at {}. Use --force to overwrite.", + args.name, + skill_path.display() + ); + } + + let description = args + .description + .unwrap_or_else(|| format!("A custom skill: {}", args.name)); + + let skill = SkillDefinition { + name: args.name.clone(), + description, + version: Some("0.1.0".to_string()), + author: None, + commands: vec![SkillCommand { + name: "example".to_string(), + description: Some("An example command".to_string()), + command: Some("echo 'Hello from skill!'".to_string()), + args: vec![], + }], + prompts: vec![SkillPrompt { + name: "example-prompt".to_string(), + content: "This is an example prompt template.".to_string(), + }], + }; + + let content = serde_yaml::to_string(&skill)?; + std::fs::write(&skill_path, content) + .with_context(|| format!("Failed to write skill file: {}", skill_path.display()))?; + + println!("Created skill '{}' at:", args.name); + println!(" {}", skill_path.display()); + println!(); + println!("Skill file format (YAML):"); + println!(" name: Skill name"); + println!(" description: What the skill does"); + println!(" version: Semantic version (optional)"); + println!(" author: Author name (optional)"); + println!(" commands: List of commands the skill provides"); + println!(" prompts: List of prompt templates"); + println!(); + println!("Edit the file to customize your skill, then use it with:"); + println!(" cortex skills show {}", args.name); + + Ok(()) +}