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:
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user