diff --git a/Cargo.lock b/Cargo.lock index 630026c0de..5f3a61f698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2110,7 +2110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e5768da2206272c81ef0b5e951a41862938a6070da63bcea197899942d3b947" dependencies = [ "cfg-if", - "rustix", + "rustix 0.38.44", "windows-sys 0.52.0", ] @@ -2123,6 +2123,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "flate2" version = "1.1.0" @@ -2611,6 +2623,7 @@ dependencies = [ "serde_json", "serde_yaml", "shlex", + "tar", "temp-env", "tempfile", "test-case", @@ -3649,7 +3662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -3660,6 +3673,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.9.0", "libc", + "redox_syscall", ] [[package]] @@ -3686,6 +3700,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "litemap" version = "0.7.5" @@ -5209,7 +5229,20 @@ dependencies = [ "bitflags 2.9.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys 0.9.4", "windows-sys 0.59.0", ] @@ -5945,6 +5978,17 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5971,7 +6015,7 @@ dependencies = [ "fastrand", "getrandom 0.3.1", "once_cell", - "rustix", + "rustix 0.38.44", "windows-sys 0.59.0", ] @@ -5990,7 +6034,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" dependencies = [ - "rustix", + "rustix 0.38.44", "windows-sys 0.59.0", ] @@ -7061,7 +7105,7 @@ dependencies = [ "either", "home", "once_cell", - "rustix", + "rustix 0.38.44", ] [[package]] @@ -7535,6 +7579,16 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "xattr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" +dependencies = [ + "libc", + "rustix 1.0.7", +] + [[package]] name = "xcap" version = "0.0.14" diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 43c5f2a4b6..dfe73e4863 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -54,6 +54,7 @@ base64 = "0.22.1" regex = "1.11.1" minijinja = "2.8.0" nix = { version = "0.30.1", features = ["process", "signal"] } +tar = "0.4" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/recipes/github_recipe.rs b/crates/goose-cli/src/recipes/github_recipe.rs index 785978ea9a..3f0c0ec657 100644 --- a/crates/goose-cli/src/recipes/github_recipe.rs +++ b/crates/goose-cli/src/recipes/github_recipe.rs @@ -1,14 +1,18 @@ use anyhow::Result; +use console::style; use std::env; +use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::Command; +use std::process::Stdio; +use tar::Archive; 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 { +) -> Result<(String, PathBuf)> { println!( "retrieving recipe from github repo {}", recipe_repo_full_name @@ -16,19 +20,22 @@ pub fn retrieve_recipe_from_github( ensure_gh_authenticated()?; let local_repo_path = ensure_repo_cloned(recipe_repo_full_name)?; fetch_origin(&local_repo_path)?; + let download_dir = get_folder_from_github(&local_repo_path, recipe_name)?; let file_extensions = ["yaml", "json"]; for ext in file_extensions { - let file_path_in_repo = format!("{}/recipe.{}", recipe_name, ext); - match get_file_content_from_github(&local_repo_path, &file_path_in_repo) { - Ok(content) => { - println!( - "retrieved recipe from github repo {}/{}", - recipe_repo_full_name, file_path_in_repo - ); - return Ok(content); - } - Err(_) => continue, + let candidate_file_path = download_dir.join(format!("recipe.{}", ext)); + if candidate_file_path.exists() { + let content = std::fs::read_to_string(&candidate_file_path)?; + println!( + "retrieved recipe from github repo {}/{}", + recipe_repo_full_name, + candidate_file_path + .strip_prefix(&download_dir) + .unwrap() + .display() + ); + return Ok((content, download_dir)); } } Err(anyhow::anyhow!( @@ -38,28 +45,6 @@ pub fn retrieve_recipe_from_github( )) } -pub fn get_file_content_from_github( - local_repo_path: &Path, - file_path_in_repo: &str, -) -> Result { - let ref_and_path = format!("origin/main:{}", file_path_in_repo); - let error_message: String = format!( - "Failed to get content from {} in github repo", - file_path_in_repo - ); - let output = Command::new("git") - .args(["show", &ref_and_path]) - .current_dir(local_repo_path) - .output() - .map_err(|_: std::io::Error| anyhow::anyhow!(error_message.clone()))?; - - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } else { - Err(anyhow::anyhow!(error_message.clone())) - } -} - fn ensure_gh_authenticated() -> Result<()> { // Check authentication status let status = Command::new("gh") @@ -129,3 +114,41 @@ fn fetch_origin(local_repo_path: &Path) -> Result<()> { Err(anyhow::anyhow!(error_message)) } } + +fn get_folder_from_github(local_repo_path: &Path, recipe_name: &str) -> Result { + let ref_and_path = format!("origin/main:{}", recipe_name); + let output_dir = env::temp_dir().join(recipe_name); + + if output_dir.exists() { + fs::remove_dir_all(&output_dir)?; + } + fs::create_dir_all(&output_dir)?; + + let archive_output = Command::new("git") + .args(["archive", &ref_and_path]) + .current_dir(local_repo_path) + .stdout(Stdio::piped()) + .spawn()?; + + let stdout = archive_output + .stdout + .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout from git archive"))?; + + let mut archive = Archive::new(stdout); + archive.unpack(&output_dir)?; + list_files(&output_dir)?; + + Ok(output_dir) +} + +fn list_files(dir: &Path) -> Result<()> { + println!("{}", style("Files downloaded from github:").bold()); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() { + println!(" - {}", path.display()); + } + } + Ok(()) +} diff --git a/crates/goose-cli/src/recipes/print_recipe.rs b/crates/goose-cli/src/recipes/print_recipe.rs index 5cef0b8ebe..bc43842f8d 100644 --- a/crates/goose-cli/src/recipes/print_recipe.rs +++ b/crates/goose-cli/src/recipes/print_recipe.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use console::style; use goose::recipe::Recipe; +use crate::recipes::recipe::BUILT_IN_RECIPE_DIR_PARAM; + pub fn print_recipe_explanation(recipe: &Recipe) { println!( "{} {}", @@ -33,6 +35,17 @@ pub fn print_recipe_explanation(recipe: &Recipe) { } } +pub fn print_parameters_with_values(params: HashMap) { + for (key, value) in params { + let label = if key == BUILT_IN_RECIPE_DIR_PARAM { + " (built-in)" + } else { + "" + }; + println!(" {}{}: {}", key, label, value); + } +} + pub fn print_required_parameters_for_template( params_for_template: HashMap, missing_params: Vec, @@ -42,9 +55,7 @@ pub fn print_required_parameters_for_template( "{}", style("📥 Parameters used to load this recipe:").bold() ); - for (key, value) in params_for_template { - println!(" {}: {}", key, value); - } + print_parameters_with_values(params_for_template) } if !missing_params.is_empty() { println!( diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index 539538feff..5d4fe61cdc 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -2,7 +2,7 @@ use anyhow::Result; use console::style; use crate::recipes::print_recipe::{ - missing_parameters_command_line, print_recipe_explanation, + missing_parameters_command_line, print_parameters_with_values, print_recipe_explanation, print_required_parameters_for_template, }; use crate::recipes::search_recipe::retrieve_recipe_file; @@ -11,7 +11,9 @@ use minijinja::{Environment, Error, Template, UndefinedBehavior}; use serde_json::Value as JsonValue; use serde_yaml::Value as YamlValue; use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; /// Loads, validates a recipe from a YAML or JSON file, and renders it with the given parameters /// /// # Arguments @@ -29,12 +31,12 @@ use std::collections::{HashMap, HashSet}; /// - Recipe is not valid /// - The required fields are missing pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) -> Result { - let recipe_file_content = retrieve_recipe_file(recipe_name)?; + let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?; let recipe = validate_recipe_file_parameters(&recipe_file_content)?; let (params_for_template, missing_params) = - apply_values_to_parameters(¶ms, recipe.parameters, true)?; + apply_values_to_parameters(¶ms, recipe.parameters, recipe_parent_dir, true)?; if !missing_params.is_empty() { return Err(anyhow::anyhow!( "Please provide the following parameters in the command line: {}", @@ -56,9 +58,7 @@ pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) if !params_for_template.is_empty() { println!("{}", style("Parameters used to load this recipe:").bold()); - for (key, value) in params_for_template { - println!("{}: {}", key, value); - } + print_parameters_with_values(params_for_template); } println!(); Ok(recipe) @@ -83,7 +83,7 @@ pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) /// - The YAML/JSON is invalid /// - The parameter definition does not match the template variables in the recipe file pub fn load_recipe(recipe_name: &str) -> Result { - let recipe_file_content = retrieve_recipe_file(recipe_name)?; + let (recipe_file_content, _) = retrieve_recipe_file(recipe_name)?; validate_recipe_file_parameters(&recipe_file_content) } @@ -92,13 +92,13 @@ pub fn explain_recipe_with_parameters( recipe_name: &str, params: Vec<(String, String)>, ) -> Result<()> { - let recipe_file_content = retrieve_recipe_file(recipe_name)?; + let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?; let raw_recipe = validate_recipe_file_parameters(&recipe_file_content)?; print_recipe_explanation(&raw_recipe); let recipe_parameters = raw_recipe.parameters; let (params_for_template, missing_params) = - apply_values_to_parameters(¶ms, recipe_parameters, false)?; + apply_values_to_parameters(¶ms, recipe_parameters, recipe_parent_dir, false)?; print_required_parameters_for_template(params_for_template, missing_params); Ok(()) @@ -115,7 +115,8 @@ fn validate_parameters_in_template( recipe_parameters: &Option>, recipe_file_content: &str, ) -> Result<()> { - let template_variables = extract_template_variables(recipe_file_content)?; + let mut template_variables = extract_template_variables(recipe_file_content)?; + template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM); let param_keys: HashSet = recipe_parameters .as_ref() @@ -207,9 +208,17 @@ fn extract_template_variables(template_str: &str) -> Result> { fn apply_values_to_parameters( user_params: &[(String, String)], recipe_parameters: Option>, + recipe_parent_dir: PathBuf, enable_user_prompt: bool, ) -> Result<(HashMap, Vec)> { let mut param_map: HashMap = user_params.iter().cloned().collect(); + let recipe_parent_dir_str = recipe_parent_dir + .to_str() + .ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in recipe_dir"))?; + param_map.insert( + BUILT_IN_RECIPE_DIR_PARAM.to_string(), + recipe_parent_dir_str.to_string(), + ); let mut missing_params: Vec = Vec::new(); for param in recipe_parameters.unwrap_or_default() { if !param_map.contains_key(¶m.key) { diff --git a/crates/goose-cli/src/recipes/search_recipe.rs b/crates/goose-cli/src/recipes/search_recipe.rs index 5d85fed5fc..4280f1e93e 100644 --- a/crates/goose-cli/src/recipes/search_recipe.rs +++ b/crates/goose-cli/src/recipes/search_recipe.rs @@ -1,11 +1,11 @@ -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Result}; use goose::config::Config; use std::fs; use std::path::{Path, PathBuf}; use super::github_recipe::{retrieve_recipe_from_github, GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY}; -pub fn retrieve_recipe_file(recipe_name: &str) -> Result { +pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> { // If recipe_name ends with yaml or json, treat it as a direct path if recipe_name.ends_with(".yaml") || recipe_name.ends_with(".json") { let path = PathBuf::from(recipe_name); @@ -14,8 +14,8 @@ pub fn retrieve_recipe_file(recipe_name: &str) -> Result { // First check current directory let current_dir = std::env::current_dir()?; - if let Ok(content) = read_recipe_in_dir(¤t_dir, recipe_name) { - return Ok(content); + if let Ok((content, recipe_parent_dir)) = read_recipe_in_dir(¤t_dir, recipe_name) { + return Ok((content, recipe_parent_dir)); } read_recipe_in_dir(¤t_dir, recipe_name).or_else(|e| { if let Some(recipe_repo_full_name) = configured_github_recipe_repo() { @@ -34,23 +34,33 @@ fn configured_github_recipe_repo() -> Option { } } -fn read_recipe_file>(recipe_path: P) -> Result { +fn read_recipe_file>(recipe_path: P) -> Result<(String, PathBuf)> { let path = recipe_path.as_ref(); - if path.exists() { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read recipe file: {}", path.display()))?; - Ok(content) - } else { - Err(anyhow!("Recipe file not found: {}", path.display())) - } + 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| { + anyhow!( + "Failed to resolve absolute path for {}: {}", + path.display(), + e + ) + })?; + + let parent_dir = canonical + .parent() + .ok_or_else(|| anyhow!("Resolved path has no parent: {}", canonical.display()))? + .to_path_buf(); + + Ok((content, parent_dir)) } -fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result { +fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result<(String, PathBuf)> { for ext in &["yaml", "json"] { let recipe_path = dir.join(format!("{}.{}", recipe_name, ext)); match read_recipe_file(recipe_path) { - Ok(content) => return Ok(content), + Ok((content, recipe_parent_dir)) => return Ok((content, recipe_parent_dir)), Err(_) => continue, } }