Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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,

Expand Down Expand Up @@ -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(());
}
Expand Down
64 changes: 64 additions & 0 deletions crates/goose-cli/src/commands/recipe.rs
Original file line number Diff line number Diff line change
@@ -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
///
Expand Down Expand Up @@ -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::*;
Expand Down
157 changes: 153 additions & 4 deletions crates/goose-cli/src/recipes/github_recipe.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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"))
}
Loading
Loading