diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 8229fe92205..5c6fbb9ba65 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -8,7 +8,7 @@ use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::mcp::run_server; use crate::commands::project::{handle_project_default, handle_projects_interactive}; -use crate::commands::recipe::{handle_deeplink, handle_validate}; +use crate::commands::recipe::{handle_deeplink, handle_list, handle_validate}; // Import the new handlers from commands::schedule use crate::commands::schedule::{ handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove, @@ -246,6 +246,27 @@ enum RecipeCommand { )] recipe_name: String, }, + + /// List available recipes + #[command(about = "List available recipes")] + List { + /// Output format (text, json) + #[arg( + long = "format", + value_name = "FORMAT", + help = "Output format (text, json)", + default_value = "text" + )] + format: String, + + /// Show verbose information including recipe descriptions + #[arg( + short, + long, + help = "Show verbose information including recipe descriptions" + )] + verbose: bool, + }, } #[derive(Subcommand)] @@ -431,7 +452,7 @@ enum Command { long = "no-session", help = "Run without storing a session file", long_help = "Execute commands without creating or using a session file. Useful for automated runs.", - conflicts_with_all = ["resume", "name", "path"] + conflicts_with_all = ["resume", "name", "path"] )] no_session: bool, @@ -947,6 +968,9 @@ pub async fn cli() -> Result<()> { RecipeCommand::Deeplink { recipe_name } => { handle_deeplink(&recipe_name)?; } + RecipeCommand::List { format, verbose } => { + handle_list(&format, verbose)?; + } } return Ok(()); } diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index 332e693e4c9..1b15a912907 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -1,8 +1,11 @@ use anyhow::Result; use base64::Engine; use console::style; +use serde_json; +use crate::recipes::github_recipe::RecipeSource; use crate::recipes::recipe::load_recipe; +use crate::recipes::search_recipe::list_available_recipes; /// Validates a recipe file /// @@ -61,6 +64,67 @@ pub fn handle_deeplink(recipe_name: &str) -> Result { } } +/// Lists all available recipes from local paths and GitHub repositories +/// +/// # Arguments +/// +/// * `format` - Output format ("text" or "json") +/// * `verbose` - Whether to show detailed information +/// +/// # Returns +/// +/// Result indicating success or failure +pub fn handle_list(format: &str, verbose: bool) -> Result<()> { + let recipes = match list_available_recipes() { + Ok(recipes) => recipes, + Err(e) => { + return Err(anyhow::anyhow!("Failed to list recipes: {}", e)); + } + }; + + match format { + "json" => { + println!("{}", serde_json::to_string(&recipes)?); + } + _ => { + if recipes.is_empty() { + println!("No recipes found"); + return Ok(()); + } else { + println!("Available recipes:"); + for recipe in recipes { + let source_info = match recipe.source { + RecipeSource::Local => format!("local: {}", recipe.path), + RecipeSource::GitHub => format!("github: {}", recipe.path), + }; + + let description = if let Some(desc) = &recipe.description { + if desc.is_empty() { + "(none)" + } else { + desc + } + } else { + "(none)" + }; + + let output = format!("{} - {} - {}", recipe.name, description, source_info); + if verbose { + println!(" {}", output); + if let Some(title) = &recipe.title { + println!(" Title: {}", title); + } + println!(" Path: {}", recipe.path); + } else { + println!("{}", output); + } + } + } + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/goose-cli/src/recipes/github_recipe.rs b/crates/goose-cli/src/recipes/github_recipe.rs index 2763d24fedf..0fcbbe9e6c9 100644 --- a/crates/goose-cli/src/recipes/github_recipe.rs +++ b/crates/goose-cli/src/recipes/github_recipe.rs @@ -1,5 +1,9 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use console::style; +use serde::{Deserialize, Serialize}; + +use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS; +use crate::recipes::search_recipe::RecipeFile; use std::env; use std::fs; use std::path::Path; @@ -8,8 +12,20 @@ use std::process::Command; use std::process::Stdio; use tar::Archive; -use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS; -use crate::recipes::search_recipe::RecipeFile; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecipeInfo { + pub name: String, + pub source: RecipeSource, + pub path: String, + pub title: Option, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum RecipeSource { + Local, + GitHub, +} pub const GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY: &str = "GOOSE_RECIPE_GITHUB_REPO"; pub fn retrieve_recipe_from_github( @@ -83,7 +99,7 @@ fn clone_and_download_recipe(recipe_name: &str, recipe_repo_full_name: &str) -> get_folder_from_github(&local_repo_path, recipe_name) } -fn ensure_gh_authenticated() -> Result<()> { +pub fn ensure_gh_authenticated() -> Result<()> { // Check authentication status let status = Command::new("gh") .args(["auth", "status"]) @@ -197,3 +213,136 @@ fn list_files(dir: &Path) -> Result<()> { } Ok(()) } + +/// Lists all available recipes from a GitHub repository +pub fn list_github_recipes(repo: &str) -> Result> { + discover_github_recipes(repo) +} + +fn discover_github_recipes(repo: &str) -> Result> { + use serde_json::Value; + use std::process::Command; + + // Ensure GitHub CLI is authenticated + ensure_gh_authenticated()?; + + // Get repository contents using GitHub CLI + let output = Command::new("gh") + .args(["api", &format!("repos/{}/contents", repo)]) + .output() + .map_err(|e| anyhow!("Failed to fetch repository contents using 'gh api' command (executed when GOOSE_RECIPE_GITHUB_REPO is configured). This requires GitHub CLI (gh) to be installed and authenticated. Error: {}", e))?; + + if !output.status.success() { + let error_msg = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("GitHub API request failed: {}", error_msg)); + } + + let contents: Value = serde_json::from_slice(&output.stdout) + .map_err(|e| anyhow!("Failed to parse GitHub API response: {}", e))?; + + let mut recipes = Vec::new(); + + if let Some(items) = contents.as_array() { + for item in items { + if let (Some(name), Some(item_type)) = ( + item.get("name").and_then(|n| n.as_str()), + item.get("type").and_then(|t| t.as_str()), + ) { + if item_type == "dir" { + // Check if this directory contains a recipe file + if let Ok(recipe_info) = check_github_directory_for_recipe(repo, name) { + recipes.push(recipe_info); + } + } + } + } + } + + Ok(recipes) +} + +fn check_github_directory_for_recipe(repo: &str, dir_name: &str) -> Result { + use serde_json::Value; + use std::process::Command; + + // Check directory contents for recipe files + let output = Command::new("gh") + .args(["api", &format!("repos/{}/contents/{}", repo, dir_name)]) + .output() + .map_err(|e| anyhow!("Failed to check directory contents: {}", e))?; + + if !output.status.success() { + return Err(anyhow!("Failed to access directory: {}", dir_name)); + } + + let contents: Value = serde_json::from_slice(&output.stdout) + .map_err(|e| anyhow!("Failed to parse directory contents: {}", e))?; + + if let Some(items) = contents.as_array() { + for item in items { + if let Some(name) = item.get("name").and_then(|n| n.as_str()) { + if RECIPE_FILE_EXTENSIONS + .iter() + .any(|ext| name == format!("recipe.{}", ext)) + { + // Found a recipe file, get its content + return get_github_recipe_info(repo, dir_name, name); + } + } + } + } + + Err(anyhow!("No recipe file found in directory: {}", dir_name)) +} + +fn get_github_recipe_info(repo: &str, dir_name: &str, recipe_filename: &str) -> Result { + use serde_json::Value; + use std::process::Command; + + // Get the recipe file content + let output = Command::new("gh") + .args([ + "api", + &format!("repos/{}/contents/{}/{}", repo, dir_name, recipe_filename), + ]) + .output() + .map_err(|e| anyhow!("Failed to get recipe file content: {}", e))?; + + if !output.status.success() { + return Err(anyhow!( + "Failed to access recipe file: {}/{}", + dir_name, + recipe_filename + )); + } + + let file_info: Value = serde_json::from_slice(&output.stdout) + .map_err(|e| anyhow!("Failed to parse file info: {}", e))?; + + if let Some(content_b64) = file_info.get("content").and_then(|c| c.as_str()) { + // Decode base64 content + use base64::{engine::general_purpose, Engine as _}; + let content_bytes = general_purpose::STANDARD + .decode(content_b64.replace('\n', "")) + .map_err(|e| anyhow!("Failed to decode base64 content: {}", e))?; + + let content = String::from_utf8(content_bytes) + .map_err(|e| anyhow!("Failed to convert content to string: {}", e))?; + + // Parse the recipe content + let (recipe, _) = crate::recipes::template_recipe::parse_recipe_content( + &content, + format!("{}/{}", repo, dir_name), + )?; + + return Ok(RecipeInfo { + name: dir_name.to_string(), + source: RecipeSource::GitHub, + path: format!("{}/{}", repo, dir_name), + title: Some(recipe.title), + description: Some(recipe.description), + }); + } + + Err(anyhow!("Failed to get recipe content from GitHub")) +} diff --git a/crates/goose-cli/src/recipes/search_recipe.rs b/crates/goose-cli/src/recipes/search_recipe.rs index d87c30e97cc..02e1a60e59e 100644 --- a/crates/goose-cli/src/recipes/search_recipe.rs +++ b/crates/goose-cli/src/recipes/search_recipe.rs @@ -5,7 +5,10 @@ use std::{env, fs}; use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS; -use super::github_recipe::{retrieve_recipe_from_github, GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY}; +use super::github_recipe::{ + list_github_recipes, retrieve_recipe_from_github, RecipeInfo, RecipeSource, + GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY, +}; const GOOSE_RECIPE_PATH_ENV_VAR: &str = "GOOSE_RECIPE_PATH"; @@ -137,3 +140,96 @@ fn read_recipe_file>(recipe_path: P) -> Result { file_path: canonical, }) } + +/// Lists all available recipes from local paths and GitHub repositories +pub fn list_available_recipes() -> Result> { + let mut recipes = Vec::new(); + + // Search local recipes + if let Ok(local_recipes) = discover_local_recipes() { + recipes.extend(local_recipes); + } + + // Search GitHub recipes if configured + if let Some(repo) = configured_github_recipe_repo() { + if let Ok(github_recipes) = list_github_recipes(&repo) { + recipes.extend(github_recipes); + } + } + + Ok(recipes) +} + +fn discover_local_recipes() -> Result> { + let mut recipes = Vec::new(); + let mut search_dirs = vec![PathBuf::from(".")]; + + // Add GOOSE_RECIPE_PATH directories + if let Ok(recipe_path_env) = env::var(GOOSE_RECIPE_PATH_ENV_VAR) { + let path_separator = if cfg!(windows) { ';' } else { ':' }; + let recipe_path_env_dirs: Vec = recipe_path_env + .split(path_separator) + .map(PathBuf::from) + .collect(); + search_dirs.extend(recipe_path_env_dirs); + } + + for dir in search_dirs { + if let Ok(dir_recipes) = scan_directory_for_recipes(&dir) { + recipes.extend(dir_recipes); + } + } + + Ok(recipes) +} + +fn scan_directory_for_recipes(dir: &Path) -> Result> { + let mut recipes = Vec::new(); + + if !dir.exists() || !dir.is_dir() { + return Ok(recipes); + } + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + if let Some(extension) = path.extension() { + if RECIPE_FILE_EXTENSIONS.contains(&extension.to_string_lossy().as_ref()) { + if let Ok(recipe_info) = create_local_recipe_info(&path) { + recipes.push(recipe_info); + } + } + } + } + } + + Ok(recipes) +} + +fn create_local_recipe_info(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + let recipe_dir = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_string_lossy() + .to_string(); + let (recipe, _) = crate::recipes::template_recipe::parse_recipe_content(&content, recipe_dir)?; + + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let path_str = path.to_string_lossy().to_string(); + + Ok(RecipeInfo { + name, + source: RecipeSource::Local, + path: path_str, + title: Some(recipe.title), + description: Some(recipe.description), + }) +}