feat: recipe list (#2814) (#2815)

Signed-off-by: toyamagu2021 <tomoki-yamaguchi@c-fo.com>
Signed-off-by: toyamagu-2021 <toyamagu2021@gmail.com>
Signed-off-by: toyamagu2021@gmail.com <toyamagu2021@gmail.com>
This commit is contained in:
toyamagu-2021
2025-07-16 04:59:11 +09:00
committed by GitHub
parent 8b3c776180
commit 70f41d10df
4 changed files with 340 additions and 7 deletions
+26 -2
View File
@@ -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)]
@@ -441,7 +462,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,
@@ -995,6 +1016,9 @@ pub async fn cli() -> Result<()> {
RecipeCommand::Deeplink { recipe_name } => {
handle_deeplink(&recipe_name)?;
}
RecipeCommand::List { format, verbose } => {
handle_list(&format, verbose)?;
}
}
return Ok(());
}
+64
View File
@@ -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<String> {
}
}
/// 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::*;
+153 -4
View File
@@ -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<String>,
pub description: Option<String>,
}
#[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<Vec<RecipeInfo>> {
discover_github_recipes(repo)
}
fn discover_github_recipes(repo: &str) -> Result<Vec<RecipeInfo>> {
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<RecipeInfo> {
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<RecipeInfo> {
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"))
}
+97 -1
View File
@@ -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";
@@ -136,3 +139,96 @@ fn read_recipe_file<P: AsRef<Path>>(recipe_path: P) -> Result<RecipeFile> {
file_path: canonical,
})
}
/// Lists all available recipes from local paths and GitHub repositories
pub fn list_available_recipes() -> Result<Vec<RecipeInfo>> {
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<Vec<RecipeInfo>> {
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<PathBuf> = 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<Vec<RecipeInfo>> {
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<RecipeInfo> {
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),
})
}