mirror of
https://github.com/aljazceru/goose.git
synced 2026-02-11 01:24:24 +01:00
Enable running sub-recipes from GitHub (#3207)
Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
@@ -522,9 +522,9 @@ enum Command {
|
||||
/// Additional sub-recipe file paths
|
||||
#[arg(
|
||||
long = "sub-recipe",
|
||||
value_name = "FILE",
|
||||
help = "Path to a sub-recipe YAML file (can be specified multiple times)",
|
||||
long_help = "Specify paths to sub-recipe YAML files that contain additional recipe configuration or instructions to be used alongside the main recipe. Can be specified multiple times to include multiple sub-recipes.",
|
||||
value_name = "RECIPE",
|
||||
help = "Sub-recipe name or file path (can be specified multiple times)",
|
||||
long_help = "Specify sub-recipes to include alongside the main recipe. Can be:\n - Recipe names from GitHub (if GOOSE_RECIPE_GITHUB_REPO is configured)\n - Local file paths to YAML files\nCan be specified multiple times to include multiple sub-recipes.",
|
||||
action = clap::ArgAction::Append
|
||||
)]
|
||||
additional_sub_recipes: Vec<String>,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{anyhow, Result};
|
||||
use goose::recipe::{Response, SubRecipe};
|
||||
|
||||
use crate::recipes::search_recipe::retrieve_recipe_file;
|
||||
use crate::{cli::InputConfig, recipes::recipe::load_recipe_as_template, session::SessionSettings};
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -22,20 +23,27 @@ pub fn extract_recipe_info_from_cli(
|
||||
});
|
||||
let mut all_sub_recipes = recipe.sub_recipes.clone().unwrap_or_default();
|
||||
if !additional_sub_recipes.is_empty() {
|
||||
additional_sub_recipes.iter().for_each(|sub_recipe_path| {
|
||||
let path = convert_path(sub_recipe_path);
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let additional_sub_recipe: SubRecipe = SubRecipe {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
name,
|
||||
values: None,
|
||||
};
|
||||
all_sub_recipes.push(additional_sub_recipe);
|
||||
});
|
||||
for sub_recipe_name in additional_sub_recipes {
|
||||
match retrieve_recipe_file(&sub_recipe_name) {
|
||||
Ok(recipe_file) => {
|
||||
let name = extract_recipe_name(&sub_recipe_name);
|
||||
let recipe_file_path = recipe_file.file_path;
|
||||
let additional_sub_recipe = SubRecipe {
|
||||
path: recipe_file_path.to_string_lossy().to_string(),
|
||||
name,
|
||||
values: None,
|
||||
};
|
||||
all_sub_recipes.push(additional_sub_recipe);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow!(
|
||||
"Could not retrieve sub-recipe '{}': {}",
|
||||
sub_recipe_name,
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
InputConfig {
|
||||
@@ -53,13 +61,18 @@ pub fn extract_recipe_info_from_cli(
|
||||
))
|
||||
}
|
||||
|
||||
fn convert_path(path: &str) -> PathBuf {
|
||||
if let Some(stripped) = path.strip_prefix("~/") {
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
return home_dir.join(stripped);
|
||||
}
|
||||
fn extract_recipe_name(recipe_identifier: &str) -> String {
|
||||
// If it's a path (contains / or \), extract the file stem
|
||||
if recipe_identifier.contains('/') || recipe_identifier.contains('\\') {
|
||||
PathBuf::from(recipe_identifier)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
} else {
|
||||
// If it's just a name (like "weekly-updates"), use it directly
|
||||
recipe_identifier.to_string()
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -114,11 +127,22 @@ mod tests {
|
||||
#[test]
|
||||
fn test_extract_recipe_info_from_cli_with_additional_sub_recipes() {
|
||||
let (_temp_dir, recipe_path) = create_recipe();
|
||||
|
||||
// Create actual sub-recipe files in the temp directory
|
||||
std::fs::create_dir_all(_temp_dir.path().join("path/to")).unwrap();
|
||||
std::fs::create_dir_all(_temp_dir.path().join("another")).unwrap();
|
||||
|
||||
let sub_recipe1_path = _temp_dir.path().join("path/to/sub_recipe1.yaml");
|
||||
let sub_recipe2_path = _temp_dir.path().join("another/sub_recipe2.yaml");
|
||||
|
||||
std::fs::write(&sub_recipe1_path, "title: Sub Recipe 1").unwrap();
|
||||
std::fs::write(&sub_recipe2_path, "title: Sub Recipe 2").unwrap();
|
||||
|
||||
let params = vec![("name".to_string(), "my_value".to_string())];
|
||||
let recipe_name = recipe_path.to_str().unwrap().to_string();
|
||||
let additional_sub_recipes = vec![
|
||||
"path/to/sub_recipe1.yaml".to_string(),
|
||||
"another/sub_recipe2.yaml".to_string(),
|
||||
sub_recipe1_path.to_string_lossy().to_string(),
|
||||
sub_recipe2_path.to_string_lossy().to_string(),
|
||||
];
|
||||
|
||||
let (input_config, settings, sub_recipes, response) =
|
||||
@@ -143,10 +167,16 @@ mod tests {
|
||||
assert_eq!(sub_recipes[0].path, "existing_sub_recipe.yaml".to_string());
|
||||
assert_eq!(sub_recipes[0].name, "existing_sub_recipe".to_string());
|
||||
assert!(sub_recipes[0].values.is_none());
|
||||
assert_eq!(sub_recipes[1].path, "path/to/sub_recipe1.yaml".to_string());
|
||||
assert_eq!(
|
||||
sub_recipes[1].path,
|
||||
sub_recipe1_path.to_string_lossy().to_string()
|
||||
);
|
||||
assert_eq!(sub_recipes[1].name, "sub_recipe1".to_string());
|
||||
assert!(sub_recipes[1].values.is_none());
|
||||
assert_eq!(sub_recipes[2].path, "another/sub_recipe2.yaml".to_string());
|
||||
assert_eq!(
|
||||
sub_recipes[2].path,
|
||||
sub_recipe2_path.to_string_lossy().to_string()
|
||||
);
|
||||
assert_eq!(sub_recipes[2].name, "sub_recipe2".to_string());
|
||||
assert!(sub_recipes[2].values.is_none());
|
||||
assert!(response.is_some());
|
||||
|
||||
@@ -9,12 +9,13 @@ use std::process::Stdio;
|
||||
use tar::Archive;
|
||||
|
||||
use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS;
|
||||
use crate::recipes::search_recipe::RecipeFile;
|
||||
|
||||
pub const GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY: &str = "GOOSE_RECIPE_GITHUB_REPO";
|
||||
pub fn retrieve_recipe_from_github(
|
||||
recipe_name: &str,
|
||||
recipe_repo_full_name: &str,
|
||||
) -> Result<(String, PathBuf)> {
|
||||
) -> Result<RecipeFile> {
|
||||
println!(
|
||||
"📦 Looking for recipe \"{}\" in github repo: {}",
|
||||
recipe_name, recipe_repo_full_name
|
||||
@@ -26,7 +27,13 @@ pub fn retrieve_recipe_from_github(
|
||||
for attempt in 1..=max_attempts {
|
||||
match clone_and_download_recipe(recipe_name, recipe_repo_full_name) {
|
||||
Ok(download_dir) => match read_recipe_file(&download_dir) {
|
||||
Ok(content) => return Ok((content, download_dir)),
|
||||
Ok((content, recipe_file_local_path)) => {
|
||||
return Ok(RecipeFile {
|
||||
content,
|
||||
parent_dir: download_dir.clone(),
|
||||
file_path: recipe_file_local_path,
|
||||
})
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
},
|
||||
Err(err) => {
|
||||
@@ -47,7 +54,7 @@ fn clean_cloned_dirs(recipe_repo_full_name: &str) -> anyhow::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn read_recipe_file(download_dir: &Path) -> Result<String> {
|
||||
fn read_recipe_file(download_dir: &Path) -> Result<(String, PathBuf)> {
|
||||
for ext in RECIPE_FILE_EXTENSIONS {
|
||||
let candidate_file_path = download_dir.join(format!("recipe.{}", ext));
|
||||
if candidate_file_path.exists() {
|
||||
@@ -59,7 +66,7 @@ fn read_recipe_file(download_dir: &Path) -> Result<String> {
|
||||
.unwrap()
|
||||
.display()
|
||||
);
|
||||
return Ok(content);
|
||||
return Ok((content, candidate_file_path));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::recipes::print_recipe::{
|
||||
missing_parameters_command_line, print_parameters_with_values, print_recipe_explanation,
|
||||
print_required_parameters_for_template,
|
||||
};
|
||||
use crate::recipes::search_recipe::retrieve_recipe_file;
|
||||
use crate::recipes::search_recipe::{retrieve_recipe_file, RecipeFile};
|
||||
use crate::recipes::template_recipe::{
|
||||
parse_recipe_content, render_recipe_content_with_params, render_recipe_for_preview,
|
||||
};
|
||||
@@ -18,7 +18,11 @@ pub fn load_recipe_content_as_template(
|
||||
recipe_name: &str,
|
||||
params: Vec<(String, String)>,
|
||||
) -> Result<String> {
|
||||
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
|
||||
let RecipeFile {
|
||||
content: recipe_file_content,
|
||||
parent_dir: recipe_parent_dir,
|
||||
..
|
||||
} = retrieve_recipe_file(recipe_name)?;
|
||||
let recipe_dir_str = recipe_parent_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
|
||||
@@ -70,7 +74,11 @@ pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>)
|
||||
}
|
||||
|
||||
pub fn load_recipe(recipe_name: &str) -> Result<Recipe> {
|
||||
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
|
||||
let RecipeFile {
|
||||
content: recipe_file_content,
|
||||
parent_dir: recipe_parent_dir,
|
||||
..
|
||||
} = retrieve_recipe_file(recipe_name)?;
|
||||
let recipe_dir_str = recipe_parent_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
|
||||
@@ -87,7 +95,11 @@ pub fn explain_recipe_with_parameters(
|
||||
recipe_name: &str,
|
||||
params: Vec<(String, String)>,
|
||||
) -> Result<()> {
|
||||
let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?;
|
||||
let RecipeFile {
|
||||
content: recipe_file_content,
|
||||
parent_dir: recipe_parent_dir,
|
||||
..
|
||||
} = retrieve_recipe_file(recipe_name)?;
|
||||
let recipe_dir_str = recipe_parent_dir
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
|
||||
|
||||
@@ -9,8 +9,13 @@ use super::github_recipe::{retrieve_recipe_from_github, GOOSE_RECIPE_GITHUB_REPO
|
||||
|
||||
const GOOSE_RECIPE_PATH_ENV_VAR: &str = "GOOSE_RECIPE_PATH";
|
||||
|
||||
pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> {
|
||||
// If recipe_name ends with yaml or json, treat it as a direct file path
|
||||
pub struct RecipeFile {
|
||||
pub content: String,
|
||||
pub parent_dir: PathBuf,
|
||||
pub file_path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn retrieve_recipe_file(recipe_name: &str) -> Result<RecipeFile> {
|
||||
if RECIPE_FILE_EXTENSIONS
|
||||
.iter()
|
||||
.any(|ext| recipe_name.ends_with(&format!(".{}", ext)))
|
||||
@@ -18,6 +23,12 @@ pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> {
|
||||
let path = PathBuf::from(recipe_name);
|
||||
return read_recipe_file(path);
|
||||
}
|
||||
if is_file_path(recipe_name) || is_file_name(recipe_name) {
|
||||
return Err(anyhow!(
|
||||
"Recipe file {} is not a json or yaml file",
|
||||
recipe_name
|
||||
));
|
||||
}
|
||||
retrieve_recipe_from_local_path(recipe_name).or_else(|e| {
|
||||
if let Some(recipe_repo_full_name) = configured_github_recipe_repo() {
|
||||
retrieve_recipe_from_github(recipe_name, &recipe_repo_full_name)
|
||||
@@ -27,7 +38,18 @@ pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result<(String, PathBuf)> {
|
||||
fn is_file_path(recipe_name: &str) -> bool {
|
||||
recipe_name.contains('/')
|
||||
|| recipe_name.contains('\\')
|
||||
|| recipe_name.starts_with('~')
|
||||
|| recipe_name.starts_with('.')
|
||||
}
|
||||
|
||||
fn is_file_name(recipe_name: &str) -> bool {
|
||||
Path::new(recipe_name).extension().is_some()
|
||||
}
|
||||
|
||||
fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result<RecipeFile> {
|
||||
for ext in RECIPE_FILE_EXTENSIONS {
|
||||
let recipe_path = dir.join(format!("{}.{}", recipe_name, ext));
|
||||
if let Ok(result) = read_recipe_file(recipe_path) {
|
||||
@@ -42,7 +64,7 @@ fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result<(String, PathBuf)
|
||||
)))
|
||||
}
|
||||
|
||||
fn retrieve_recipe_from_local_path(recipe_name: &str) -> Result<(String, PathBuf)> {
|
||||
fn retrieve_recipe_from_local_path(recipe_name: &str) -> Result<RecipeFile> {
|
||||
let mut search_dirs = vec![PathBuf::from(".")];
|
||||
if let Ok(recipe_path_env) = env::var(GOOSE_RECIPE_PATH_ENV_VAR) {
|
||||
let path_separator = if cfg!(windows) { ';' } else { ':' };
|
||||
@@ -78,10 +100,22 @@ fn configured_github_recipe_repo() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_recipe_file<P: AsRef<Path>>(recipe_path: P) -> Result<(String, PathBuf)> {
|
||||
let path = recipe_path.as_ref();
|
||||
fn convert_path_with_tilde_expansion(path: &Path) -> PathBuf {
|
||||
if let Some(path_str) = path.to_str() {
|
||||
if let Some(stripped) = path_str.strip_prefix("~/") {
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
return home_dir.join(stripped);
|
||||
}
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path)
|
||||
fn read_recipe_file<P: AsRef<Path>>(recipe_path: P) -> Result<RecipeFile> {
|
||||
let raw_path = recipe_path.as_ref();
|
||||
let path = convert_path_with_tilde_expansion(raw_path);
|
||||
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow!("Failed to read recipe file {}: {}", path.display(), e))?;
|
||||
|
||||
let canonical = path.canonicalize().map_err(|e| {
|
||||
@@ -97,5 +131,9 @@ fn read_recipe_file<P: AsRef<Path>>(recipe_path: P) -> Result<(String, PathBuf)>
|
||||
.ok_or_else(|| anyhow!("Resolved path has no parent: {}", canonical.display()))?
|
||||
.to_path_buf();
|
||||
|
||||
Ok((content, parent_dir))
|
||||
Ok(RecipeFile {
|
||||
content,
|
||||
parent_dir,
|
||||
file_path: canonical,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user