diff --git a/Cargo.lock b/Cargo.lock index 2b9c8e7..e561e4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,7 +340,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "corgea" -version = "1.8.8" +version = "1.9.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index d60edad..1d75fce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "corgea" -version = "1.8.8" +version = "1.9.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/config.rs b/src/config.rs index 257a483..f8d7db0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,8 @@ pub struct Config { pub(crate) url: String, pub(crate) debug: i8, pub(crate) token: String, + #[serde(default)] + pub(crate) default_agent: Option, } impl Config { @@ -34,6 +36,7 @@ impl Config { url: "https://www.corgea.app".to_string(), debug: 0, token: "".to_string(), + default_agent: None, }; let toml = toml::to_string(&config).expect("Failed to serialize config"); @@ -100,4 +103,19 @@ impl Config { self.debug } + + pub fn set_default_agent(&mut self, agent: String) -> io::Result<()> { + self.default_agent = Some(agent); + self.save() + } + + pub fn get_default_agent(&self) -> Option { + if let Ok(agent) = env::var("CORGEA_DEFAULT_AGENT") { + if !agent.trim().is_empty() { + return Some(agent); + } + } + + self.default_agent.clone() + } } diff --git a/src/main.rs b/src/main.rs index 442c5a1..b748fe4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod list; mod log; mod scan; mod setup_hooks; +mod skill; mod wait; mod scanners { pub mod blast; @@ -194,6 +195,11 @@ enum Commands { )] default_config: bool, }, + /// Manage agent skills from the Corgea registry + Skill { + #[command(subcommand)] + command: SkillCommands, + }, /// Offline dependency inventory: scan, graph, explain, diff, sbom, policy Deps { #[command(subcommand)] @@ -201,6 +207,45 @@ enum Commands { }, } +#[derive(Subcommand, Debug)] +enum SkillCommands { + /// Install an approved skill into your agent's skills directory + Install { + #[arg(help = "Skill name, optionally with a version: name or name@version")] + name: String, + + #[arg( + long, + help = "Agent to install for (e.g. cursor, claude-code, codex). Defaults to the configured default agent." + )] + agent: Option, + + #[arg( + long, + default_value = "project", + help = "Installation scope: project or user." + )] + scope: String, + + #[arg( + long, + help = "Install to a custom directory (overrides --agent and --scope)." + )] + dir: Option, + + #[arg( + long, + help = "Persist the provided --agent as the default for future installs." + )] + set_default: bool, + }, + /// Configure the default agent used when --agent is not provided + SetDefaultAgent { + #[arg(help = "Agent id (e.g. cursor, claude-code, codex).")] + agent: String, + }, +} + #[derive(Subcommand, Debug, Clone, PartialEq)] enum Scanner { Snyk, @@ -500,6 +545,28 @@ fn main() { Some(Commands::SetupHooks { default_config }) => { setup_hooks::setup_pre_commit_hook(*default_config); } + Some(Commands::Skill { command }) => match command { + SkillCommands::Install { + name, + agent, + scope, + dir, + set_default, + } => { + verify_token_and_exit_when_fail(&corgea_config); + skill::run_install( + &mut corgea_config, + name, + agent.clone(), + scope, + dir.clone(), + *set_default, + ); + } + SkillCommands::SetDefaultAgent { agent } => { + skill::run_set_default_agent(&mut corgea_config, agent); + } + }, Some(Commands::Deps { command }) => { // Offline: no token / network. Exit code propagates fail-on policy. std::process::exit(i32::from(corgea::deps::run::run(command.clone()))); diff --git a/src/scan.rs b/src/scan.rs index 8c669ca..af1fd4a 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -322,22 +322,18 @@ pub fn upload_scan( Ok(res) => { if !res.status().is_success() { true - } else { - if let Some(server_offset) = res.headers().get("Upload-Offset") { - let expected_offset = offset + chunk.len(); - if let Ok(server_offset_str) = server_offset.to_str() { - if let Ok(server_offset_val) = server_offset_str.parse::() { - if server_offset_val != expected_offset { - log::error!( - "Upload offset mismatch on chunk {}/{}: server has {} bytes but expected {}. \ - This may indicate that chunks are being routed to different server instances. \ - Please contact support.", - index + 1, total_chunks, server_offset_val, expected_offset - ); - true - } else { - false - } + } else if let Some(server_offset) = res.headers().get("Upload-Offset") { + let expected_offset = offset + chunk.len(); + if let Ok(server_offset_str) = server_offset.to_str() { + if let Ok(server_offset_val) = server_offset_str.parse::() { + if server_offset_val != expected_offset { + log::error!( + "Upload offset mismatch on chunk {}/{}: server has {} bytes but expected {}. \ + This may indicate that chunks are being routed to different server instances. \ + Please contact support.", + index + 1, total_chunks, server_offset_val, expected_offset + ); + true } else { false } @@ -347,6 +343,8 @@ pub fn upload_scan( } else { false } + } else { + false } } Err(_) => true, diff --git a/src/skill.rs b/src/skill.rs new file mode 100644 index 0000000..be035f7 --- /dev/null +++ b/src/skill.rs @@ -0,0 +1,367 @@ +use crate::config::Config; +use crate::utils; +use crate::utils::terminal::{set_text_color, TerminalColor}; +use std::path::{Path, PathBuf}; + +/// Supported agents and where their skills are installed. +/// +/// Tuple layout: `(agent_id, project_relative_dir, user_relative_dir)`. +/// `project_relative_dir` is resolved against the current working directory; +/// `user_relative_dir` is resolved against the user's home directory. +pub const SUPPORTED_AGENTS: &[(&str, &str, &str)] = &[ + ("cursor", ".cursor/skills", ".cursor/skills"), + ("claude-code", ".claude/skills", ".claude/skills"), + ("codex", ".codex/skills", ".codex/skills"), + ( + "github-copilot", + ".github/skills", + ".config/github-copilot/skills", + ), + ("gemini-cli", ".gemini/skills", ".gemini/skills"), + ("windsurf", ".windsurf/skills", ".codeium/windsurf/skills"), + ("opencode", ".opencode/skills", ".config/opencode/skills"), + ("universal", ".ai/skills", ".ai/skills"), +]; + +fn supported_agent_ids() -> String { + SUPPORTED_AGENTS + .iter() + .map(|(id, _, _)| *id) + .collect::>() + .join(", ") +} + +fn is_supported_agent(agent: &str) -> bool { + SUPPORTED_AGENTS.iter().any(|(id, _, _)| *id == agent) +} + +/// Parse a `name[@version]` argument into `(name, Option)`. +pub fn parse_skill_arg(arg: &str) -> (String, Option) { + match arg.split_once('@') { + Some((name, version)) if !version.is_empty() => { + (name.to_string(), Some(version.to_string())) + } + _ => (arg.to_string(), None), + } +} + +/// Resolve the directory that will contain the skill's `SKILL.md`. +/// +/// When `dir` is provided it overrides `agent`/`scope` and is used as the base +/// skills directory. Otherwise the agent's directory for the given scope is +/// used. The skill is always placed in a `` subfolder. +pub fn resolve_skill_dir( + skill_name: &str, + agent: Option<&str>, + scope: &str, + dir: Option<&str>, + cwd: &Path, + home: &Path, +) -> Result { + let base = if let Some(custom) = dir { + PathBuf::from(custom) + } else { + let agent = agent.ok_or_else(|| { + format!( + "No agent specified. Pass --agent , set a default with \ + 'corgea skill set-default-agent ', or use --dir. \ + Supported agents: {}", + supported_agent_ids() + ) + })?; + let entry = SUPPORTED_AGENTS + .iter() + .find(|(id, _, _)| *id == agent) + .ok_or_else(|| { + format!( + "Unsupported agent '{}'. Supported agents: {}", + agent, + supported_agent_ids() + ) + })?; + match scope { + "project" => cwd.join(entry.1), + "user" => home.join(entry.2), + other => { + return Err(format!( + "Invalid scope '{}'. Expected 'project' or 'user'.", + other + )) + } + } + }; + + Ok(base.join(skill_name)) +} + +/// `corgea skill set-default-agent ` +pub fn run_set_default_agent(config: &mut Config, agent: &str) { + if !is_supported_agent(agent) { + eprintln!( + "Unsupported agent '{}'. Supported agents: {}", + agent, + supported_agent_ids() + ); + std::process::exit(1); + } + match config.set_default_agent(agent.to_string()) { + Ok(()) => println!( + "{}", + set_text_color( + &format!("Default agent set to '{}'.", agent), + TerminalColor::Green + ) + ), + Err(e) => { + eprintln!("Failed to save default agent: {}", e); + std::process::exit(1); + } + } +} + +/// `corgea skill install ` +pub fn run_install( + config: &mut Config, + name_arg: &str, + agent: Option, + scope: &str, + dir: Option, + set_default: bool, +) { + let (skill_name, version) = parse_skill_arg(name_arg); + + if !["project", "user"].contains(&scope) { + eprintln!("Invalid scope '{}'. Expected 'project' or 'user'.", scope); + std::process::exit(1); + } + + // Resolve the agent (flag > configured default) unless a custom dir is set. + let resolved_agent = agent.clone().or_else(|| config.get_default_agent()); + if dir.is_none() && resolved_agent.is_none() { + eprintln!( + "No agent specified. Pass --agent , set a default with \ + 'corgea skill set-default-agent ', or use --dir.\nSupported agents: {}", + supported_agent_ids() + ); + std::process::exit(1); + } + if dir.is_none() { + if let Some(ref a) = resolved_agent { + if !is_supported_agent(a) { + eprintln!( + "Unsupported agent '{}'. Supported agents: {}", + a, + supported_agent_ids() + ); + std::process::exit(1); + } + } + } + + let result = utils::api::get_skill(config.get_url().as_str(), &skill_name, version.as_deref()); + + let response = match result { + Ok(Some(resp)) => resp, + Ok(None) => { + eprintln!( + "{}", + set_text_color( + &format!("No skill named '{}' was found.", skill_name), + TerminalColor::Red + ) + ); + std::process::exit(1); + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + }; + + let version_info = match response.version { + Some(v) => v, + None => { + eprintln!( + "{}", + set_text_color( + &format!("Skill '{}' has no versions to install.", skill_name), + TerminalColor::Yellow + ) + ); + std::process::exit(1); + } + }; + + if !version_info.is_installable || version_info.content.is_none() { + match version_info.status.as_str() { + "pending_review" => { + println!( + "{}", + set_text_color( + &format!( + "Skill '{}' (v{}) is pending security review and is not yet installable.", + skill_name, version_info.version + ), + TerminalColor::Yellow + ) + ); + } + "rejected" => { + println!( + "{}", + set_text_color( + &format!( + "Skill '{}' (v{}) was rejected during security review and cannot be installed.", + skill_name, version_info.version + ), + TerminalColor::Red + ) + ); + if !version_info.security_concerns.is_empty() { + println!("Reason: {}", version_info.security_concerns); + } + } + other => { + println!( + "Skill '{}' (v{}) is not installable (status: {}).", + skill_name, version_info.version, other + ); + } + } + std::process::exit(1); + } + + let content = version_info.content.unwrap_or_default(); + + let cwd = match std::env::current_dir() { + Ok(p) => p, + Err(e) => { + eprintln!("Failed to determine current directory: {}", e); + std::process::exit(1); + } + }; + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + let skill_dir = match resolve_skill_dir( + &skill_name, + resolved_agent.as_deref(), + scope, + dir.as_deref(), + &cwd, + &home, + ) { + Ok(p) => p, + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + }; + + if let Err(e) = utils::generic::create_path_if_not_exists(&skill_dir) { + eprintln!("Failed to create skill directory {:?}: {}", skill_dir, e); + std::process::exit(1); + } + + let skill_file = skill_dir.join("SKILL.md"); + if let Err(e) = std::fs::write(&skill_file, content) { + eprintln!("Failed to write skill file {:?}: {}", skill_file, e); + std::process::exit(1); + } + + println!( + "{}", + set_text_color( + &format!( + "Installed skill '{}' (v{}) to {}", + skill_name, + version_info.version, + skill_file.display() + ), + TerminalColor::Green + ) + ); + + if set_default { + if let Some(a) = resolved_agent { + if let Err(e) = config.set_default_agent(a.clone()) { + eprintln!("Warning: failed to save default agent: {}", e); + } else { + println!("Default agent set to '{}'.", a); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_skill_arg_with_version() { + let (name, version) = parse_skill_arg("my-skill@1.2.3"); + assert_eq!(name, "my-skill"); + assert_eq!(version, Some("1.2.3".to_string())); + } + + #[test] + fn test_parse_skill_arg_without_version() { + let (name, version) = parse_skill_arg("my-skill"); + assert_eq!(name, "my-skill"); + assert_eq!(version, None); + } + + #[test] + fn test_parse_skill_arg_trailing_at() { + let (name, version) = parse_skill_arg("my-skill@"); + assert_eq!(name, "my-skill@"); + assert_eq!(version, None); + } + + #[test] + fn test_resolve_project_scope() { + let cwd = PathBuf::from("/work/project"); + let home = PathBuf::from("/home/user"); + let dir = resolve_skill_dir("foo", Some("cursor"), "project", None, &cwd, &home).unwrap(); + assert_eq!(dir, PathBuf::from("/work/project/.cursor/skills/foo")); + } + + #[test] + fn test_resolve_user_scope() { + let cwd = PathBuf::from("/work/project"); + let home = PathBuf::from("/home/user"); + let dir = resolve_skill_dir("foo", Some("claude-code"), "user", None, &cwd, &home).unwrap(); + assert_eq!(dir, PathBuf::from("/home/user/.claude/skills/foo")); + } + + #[test] + fn test_resolve_custom_dir_overrides_agent() { + let cwd = PathBuf::from("/work/project"); + let home = PathBuf::from("/home/user"); + let dir = resolve_skill_dir( + "foo", + Some("cursor"), + "project", + Some("/custom/place"), + &cwd, + &home, + ) + .unwrap(); + assert_eq!(dir, PathBuf::from("/custom/place/foo")); + } + + #[test] + fn test_resolve_unsupported_agent_errors() { + let cwd = PathBuf::from("/work/project"); + let home = PathBuf::from("/home/user"); + let result = resolve_skill_dir("foo", Some("notreal"), "project", None, &cwd, &home); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_missing_agent_errors() { + let cwd = PathBuf::from("/work/project"); + let home = PathBuf::from("/home/user"); + let result = resolve_skill_dir("foo", None, "project", None, &cwd, &home); + assert!(result.is_err()); + } +} diff --git a/src/utils/api.rs b/src/utils/api.rs index 9b9a445..2900751 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -615,6 +615,92 @@ pub fn get_issue(url: &str, issue: &str) -> Result, + #[serde(default)] + pub is_installable: bool, + #[serde(default)] + pub latest_approved_version: Option, +} + +#[derive(Deserialize, Debug)] +pub struct SkillVersionInfo { + pub version: String, + pub status: String, + #[serde(default)] + pub is_installable: bool, + #[serde(default)] + pub security_concerns: String, + #[serde(default)] + pub content: Option, +} + +#[derive(Deserialize, Debug)] +#[allow(dead_code)] +pub struct SkillResponse { + #[serde(default)] + pub status: String, + pub skill: SkillInfo, + #[serde(default)] + pub version: Option, +} + +/// Fetch a single skill (optionally a specific version) for installation. +/// +/// Returns `Ok(None)` when no skill/version matches (HTTP 404), `Ok(Some(..))` +/// on success, and `Err(..)` for auth or other failures. +pub fn get_skill( + url: &str, + slug: &str, + version: Option<&str>, +) -> Result, Box> { + let mut request_url = format!("{}{}/skills/{}", url, API_BASE, slug); + if let Some(v) = version { + request_url = format!("{}?version={}", request_url, v); + } + + let client = http_client(); + debug(&format!("Sending request to URL: {}", request_url)); + + let response = client + .get(&request_url) + .send() + .map_err(|e| format!("Failed to send request: {}", e))?; + + check_for_warnings(response.headers(), response.status()); + + let status = response.status(); + if status == StatusCode::NOT_FOUND { + return Ok(None); + } + if status == StatusCode::UNAUTHORIZED { + return Err("Authentication failed. Please run 'corgea login'.".into()); + } + if status == StatusCode::FORBIDDEN { + return Err("Permission denied: you do not have access to skills.".into()); + } + if !status.is_success() { + return Err(format!("Unable to fetch skill. Status code: {}", status).into()); + } + + let response_text = response.text()?; + let skill_response: SkillResponse = serde_json::from_str(&response_text).map_err(|e| { + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + format!("Failed to parse response: {}", e) + })?; + Ok(Some(skill_response)) +} + pub fn query_scan_list( url: &str, project: Option<&str>,