Skip to content

Commit a3fbeb0

Browse files
authored
Lifei/create save recipe to file (block#4895)
1 parent 94a5905 commit a3fbeb0

File tree

11 files changed

+456
-77
lines changed

11 files changed

+456
-77
lines changed

crates/goose-server/src/openapi.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,8 @@ derive_utoipa!(Icon as IconSchema);
368368
super::routes::recipe::scan_recipe,
369369
super::routes::recipe::list_recipes,
370370
super::routes::recipe::delete_recipe,
371+
super::routes::recipe::save_recipe,
372+
super::routes::recipe::parse_recipe,
371373
super::routes::setup::start_openrouter_setup,
372374
super::routes::setup::start_tetrate_setup,
373375
),
@@ -448,6 +450,10 @@ derive_utoipa!(Icon as IconSchema);
448450
super::routes::recipe::RecipeManifestResponse,
449451
super::routes::recipe::ListRecipeResponse,
450452
super::routes::recipe::DeleteRecipeRequest,
453+
super::routes::recipe::SaveRecipeRequest,
454+
super::routes::errors::ErrorResponse,
455+
super::routes::recipe::ParseRecipeRequest,
456+
super::routes::recipe::ParseRecipeResponse,
451457
goose::recipe::Recipe,
452458
goose::recipe::Author,
453459
goose::recipe::Settings,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
use axum::{
2+
http::StatusCode,
3+
response::{IntoResponse, Response},
4+
Json,
5+
};
6+
use serde::Serialize;
7+
use utoipa::ToSchema;
8+
9+
#[derive(Debug, Serialize, ToSchema)]
10+
pub struct ErrorResponse {
11+
pub message: String,
12+
#[serde(skip)]
13+
pub status: StatusCode,
14+
}
15+
16+
impl IntoResponse for ErrorResponse {
17+
fn into_response(self) -> Response {
18+
let body = Json(serde_json::json!({
19+
"message": self.message,
20+
}));
21+
22+
(self.status, body).into_response()
23+
}
24+
}

crates/goose-server/src/routes/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod agent;
22
pub mod audio;
33
pub mod config_management;
44
pub mod context;
5+
pub mod errors;
56
pub mod extension;
67
pub mod health;
78
pub mod recipe;

crates/goose-server/src/routes/recipe.rs

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ use std::sync::Arc;
55
use axum::routing::get;
66
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
77
use goose::conversation::{message::Message, Conversation};
8+
use goose::recipe::recipe_library;
89
use goose::recipe::Recipe;
910
use goose::recipe_deeplink;
1011

1112
use serde::{Deserialize, Serialize};
1213
use utoipa::ToSchema;
1314

15+
use crate::routes::errors::ErrorResponse;
1416
use crate::routes::recipe_utils::get_all_recipes_manifests;
1517
use crate::state::AppState;
1618

@@ -72,11 +74,25 @@ pub struct ScanRecipeResponse {
7274
has_security_warnings: bool,
7375
}
7476

77+
#[derive(Debug, Deserialize, ToSchema)]
78+
pub struct SaveRecipeRequest {
79+
recipe: Recipe,
80+
id: Option<String>,
81+
is_global: Option<bool>,
82+
}
83+
#[derive(Debug, Deserialize, ToSchema)]
84+
pub struct ParseRecipeRequest {
85+
pub content: String,
86+
}
87+
88+
#[derive(Debug, Serialize, ToSchema)]
89+
pub struct ParseRecipeResponse {
90+
pub recipe: Recipe,
91+
}
92+
7593
#[derive(Debug, Serialize, ToSchema)]
7694
pub struct RecipeManifestResponse {
7795
name: String,
78-
#[serde(rename = "isGlobal")]
79-
is_global: bool,
8096
recipe: Recipe,
8197
#[serde(rename = "lastModified")]
8298
last_modified: String,
@@ -235,7 +251,6 @@ async fn list_recipes(
235251
recipe_file_hash_map.insert(id.clone(), file_path);
236252
RecipeManifestResponse {
237253
name: recipe_manifest_with_path.name.clone(),
238-
is_global: recipe_manifest_with_path.is_global,
239254
recipe: recipe_manifest_with_path.recipe.clone(),
240255
id: id.clone(),
241256
last_modified: recipe_manifest_with_path.last_modified.clone(),
@@ -278,6 +293,57 @@ async fn delete_recipe(
278293
StatusCode::NO_CONTENT
279294
}
280295

296+
#[utoipa::path(
297+
post,
298+
path = "/recipes/save",
299+
request_body = SaveRecipeRequest,
300+
responses(
301+
(status = 204, description = "Recipe saved to file successfully"),
302+
(status = 401, description = "Unauthorized - Invalid or missing API key"),
303+
(status = 500, description = "Internal server error", body = ErrorResponse)
304+
),
305+
tag = "Recipe Management"
306+
)]
307+
async fn save_recipe(
308+
State(state): State<Arc<AppState>>,
309+
Json(request): Json<SaveRecipeRequest>,
310+
) -> Result<StatusCode, ErrorResponse> {
311+
let file_path = match request.id {
312+
Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(),
313+
None => None,
314+
};
315+
316+
match recipe_library::save_recipe_to_file(request.recipe, request.is_global, file_path) {
317+
Ok(_) => Ok(StatusCode::NO_CONTENT),
318+
Err(e) => Err(ErrorResponse {
319+
message: e.to_string(),
320+
status: StatusCode::INTERNAL_SERVER_ERROR,
321+
}),
322+
}
323+
}
324+
325+
#[utoipa::path(
326+
post,
327+
path = "/recipes/parse",
328+
request_body = ParseRecipeRequest,
329+
responses(
330+
(status = 200, description = "Recipe parsed successfully", body = ParseRecipeResponse),
331+
(status = 400, description = "Bad request - Invalid recipe format", body = ErrorResponse),
332+
(status = 500, description = "Internal server error", body = ErrorResponse)
333+
),
334+
tag = "Recipe Management"
335+
)]
336+
async fn parse_recipe(
337+
Json(request): Json<ParseRecipeRequest>,
338+
) -> Result<Json<ParseRecipeResponse>, ErrorResponse> {
339+
let recipe = Recipe::from_content(&request.content).map_err(|e| ErrorResponse {
340+
message: format!("Invalid recipe format: {}", e),
341+
status: StatusCode::BAD_REQUEST,
342+
})?;
343+
344+
Ok(Json(ParseRecipeResponse { recipe }))
345+
}
346+
281347
pub fn routes(state: Arc<AppState>) -> Router {
282348
Router::new()
283349
.route("/recipes/create", post(create_recipe))
@@ -286,6 +352,8 @@ pub fn routes(state: Arc<AppState>) -> Router {
286352
.route("/recipes/scan", post(scan_recipe))
287353
.route("/recipes/list", get(list_recipes))
288354
.route("/recipes/delete", post(delete_recipe))
355+
.route("/recipes/save", post(save_recipe))
356+
.route("/recipes/parse", post(parse_recipe))
289357
.with_state(state)
290358
}
291359

crates/goose-server/src/routes/recipe_utils.rs

Lines changed: 24 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@ use std::hash::{Hash, Hasher};
44
use std::path::PathBuf;
55

66
use anyhow::Result;
7-
use etcetera::{choose_app_strategy, AppStrategy};
87

9-
use goose::config::APP_STRATEGY;
10-
use goose::recipe::read_recipe_file_content::read_recipe_file;
8+
use goose::recipe::recipe_library::list_all_recipes_from_library;
119
use goose::recipe::Recipe;
1210

1311
use std::path::Path;
@@ -18,7 +16,6 @@ use utoipa::ToSchema;
1816
pub struct RecipeManifestWithPath {
1917
pub id: String,
2018
pub name: String,
21-
pub is_global: bool,
2219
pub recipe: Recipe,
2320
pub file_path: PathBuf,
2421
pub last_modified: String,
@@ -31,59 +28,31 @@ fn short_id_from_path(path: &str) -> String {
3128
format!("{:016x}", h)
3229
}
3330

34-
fn load_recipes_from_path(path: &PathBuf, is_global: bool) -> Result<Vec<RecipeManifestWithPath>> {
35-
let mut recipe_manifests_with_path = Vec::new();
36-
if path.exists() {
37-
for entry in fs::read_dir(path)? {
38-
let path = entry?.path();
39-
if path.extension() == Some("yaml".as_ref()) {
40-
let Ok(recipe_file) = read_recipe_file(path.clone()) else {
41-
continue;
42-
};
43-
let Ok(recipe) = Recipe::from_content(&recipe_file.content) else {
44-
continue;
45-
};
46-
let Ok(last_modified) = fs::metadata(path.clone()).map(|m| {
47-
chrono::DateTime::<chrono::Utc>::from(m.modified().unwrap()).to_rfc3339()
48-
}) else {
49-
continue;
50-
};
51-
let recipe_metadata =
52-
RecipeManifestMetadata::from_yaml_file(&path).unwrap_or_else(|_| {
53-
RecipeManifestMetadata {
54-
name: recipe.title.clone(),
55-
is_global,
56-
}
57-
});
58-
59-
let manifest_with_path = RecipeManifestWithPath {
60-
id: short_id_from_path(recipe_file.file_path.to_string_lossy().as_ref()),
61-
name: recipe_metadata.name,
62-
is_global: recipe_metadata.is_global,
63-
recipe,
64-
file_path: recipe_file.file_path,
65-
last_modified,
66-
};
67-
recipe_manifests_with_path.push(manifest_with_path);
68-
}
69-
}
70-
}
71-
Ok(recipe_manifests_with_path)
72-
}
73-
7431
pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
75-
let current_dir = std::env::current_dir()?;
76-
let local_recipe_path = current_dir.join(".goose/recipes");
77-
78-
let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone())
79-
.expect("goose requires a home dir")
80-
.config_dir()
81-
.join("recipes");
82-
32+
let recipes_with_path = list_all_recipes_from_library()?;
8333
let mut recipe_manifests_with_path = Vec::new();
84-
85-
recipe_manifests_with_path.extend(load_recipes_from_path(&local_recipe_path, false)?);
86-
recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_path, true)?);
34+
for (file_path, recipe) in recipes_with_path {
35+
let Ok(last_modified) = fs::metadata(file_path.clone())
36+
.map(|m| chrono::DateTime::<chrono::Utc>::from(m.modified().unwrap()).to_rfc3339())
37+
else {
38+
continue;
39+
};
40+
let recipe_metadata =
41+
RecipeManifestMetadata::from_yaml_file(&file_path).unwrap_or_else(|_| {
42+
RecipeManifestMetadata {
43+
name: recipe.title.clone(),
44+
}
45+
});
46+
47+
let manifest_with_path = RecipeManifestWithPath {
48+
id: short_id_from_path(file_path.to_string_lossy().as_ref()),
49+
name: recipe_metadata.name,
50+
recipe,
51+
file_path,
52+
last_modified,
53+
};
54+
recipe_manifests_with_path.push(manifest_with_path);
55+
}
8756
recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
8857

8958
Ok(recipe_manifests_with_path)
@@ -93,8 +62,6 @@ pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
9362
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
9463
struct RecipeManifestMetadata {
9564
pub name: String,
96-
#[serde(rename = "isGlobal")]
97-
pub is_global: bool,
9865
}
9966

10067
impl RecipeManifestMetadata {
@@ -129,6 +96,5 @@ recipe: recipe_content
12996
let result = RecipeManifestMetadata::from_yaml_file(&file_path).unwrap();
13097

13198
assert_eq!(result.name, "Test Recipe");
132-
assert!(result.is_global);
13399
}
134100
}

crates/goose/src/recipe/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use utoipa::ToSchema;
1212

1313
pub mod build_recipe;
1414
pub mod read_recipe_file_content;
15+
pub mod recipe_library;
1516
pub mod template_recipe;
1617

1718
pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";

0 commit comments

Comments
 (0)