diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 6f593b172..534e97b08 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -12,7 +12,7 @@ goose = { path = "../goose" } mcp-core = { path = "../mcp-core" } goose-mcp = { path = "../goose-mcp" } mcp-server = { path = "../mcp-server" } -axum = { version = "0.7", features = ["ws"] } +axum = { version = "0.7.2", features = ["ws", "macros"] } tokio = { version = "1.0", features = ["full"] } chrono = "0.4" tower-http = { version = "0.5", features = ["cors"] } @@ -31,11 +31,19 @@ thiserror = "1.0" clap = { version = "4.4", features = ["derive"] } once_cell = "1.20.2" etcetera = "0.8.0" +serde_yaml = "0.9.34" +axum-extra = "0.10.0" +utoipa = { version = "4.1", features = ["axum_extras"] } +dirs = "6.0.0" [[bin]] name = "goosed" path = "src/main.rs" +[[bin]] +name = "generate_schema" +path = "src/bin/generate_schema.rs" + [dev-dependencies] tower = "0.5" -async-trait = "0.1" +async-trait = "0.1" \ No newline at end of file diff --git a/crates/goose-server/build.rs b/crates/goose-server/build.rs new file mode 100644 index 000000000..23a0fa399 --- /dev/null +++ b/crates/goose-server/build.rs @@ -0,0 +1,4 @@ +// We'll generate the schema at runtime since we need access to the complete application context +fn main() { + println!("cargo:rerun-if-changed=src/"); +} diff --git a/crates/goose-server/src/bin/generate_schema.rs b/crates/goose-server/src/bin/generate_schema.rs new file mode 100644 index 000000000..529be54c1 --- /dev/null +++ b/crates/goose-server/src/bin/generate_schema.rs @@ -0,0 +1,22 @@ +use goose_server::openapi; +use std::env; +use std::fs; + +fn main() { + let schema = openapi::generate_schema(); + + // Get the current working directory + let current_dir = env::current_dir().unwrap(); + let output_path = current_dir.join("ui").join("desktop").join("openapi.json"); + + // Ensure parent directory exists + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + + fs::write(&output_path, schema).unwrap(); + println!( + "Successfully generated OpenAPI schema at {}", + output_path.display() + ); +} diff --git a/crates/goose-server/src/lib.rs b/crates/goose-server/src/lib.rs new file mode 100644 index 000000000..36c83824c --- /dev/null +++ b/crates/goose-server/src/lib.rs @@ -0,0 +1,7 @@ +pub mod openapi; +pub mod routes; +pub mod state; + +// Re-export commonly used items +pub use openapi::*; +pub use state::*; diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs index 44c631f9b..25fb6f422 100644 --- a/crates/goose-server/src/main.rs +++ b/crates/goose-server/src/main.rs @@ -11,6 +11,7 @@ mod commands; mod configuration; mod error; mod logging; +mod openapi; mod routes; mod state; diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs new file mode 100644 index 000000000..f90fee8fa --- /dev/null +++ b/crates/goose-server/src/openapi.rs @@ -0,0 +1,27 @@ +use utoipa::OpenApi; + +#[allow(dead_code)] // Used by utoipa for OpenAPI generation +#[derive(OpenApi)] +#[openapi( + paths( + super::routes::config_management::upsert_config, + super::routes::config_management::remove_config, + super::routes::config_management::read_config, + super::routes::config_management::add_extension, + super::routes::config_management::remove_extension, + super::routes::config_management::read_all_config + ), + components(schemas( + super::routes::config_management::UpsertConfigQuery, + super::routes::config_management::ConfigKeyQuery, + super::routes::config_management::ExtensionQuery, + super::routes::config_management::ConfigResponse + )) +)] +pub struct ApiDoc; + +#[allow(dead_code)] // Used by generate_schema binary +pub fn generate_schema() -> String { + let api_doc = ApiDoc::openapi(); + serde_json::to_string_pretty(&api_doc).unwrap() +} diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 46bdb1bd4..204fd82a8 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -95,6 +95,7 @@ async fn extend_prompt( } } +#[axum::debug_handler] async fn create_agent( State(state): State, headers: HeaderMap, diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs new file mode 100644 index 000000000..8713cfc9c --- /dev/null +++ b/crates/goose-server/src/routes/config_management.rs @@ -0,0 +1,206 @@ +use axum::{ + extract::State, + routing::{delete, get, post}, + Json, Router, +}; +use goose::config::Config; +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::Mutex; +use utoipa::ToSchema; + +use crate::state::AppState; + +#[derive(Deserialize, ToSchema)] +pub struct UpsertConfigQuery { + pub key: String, + pub value: Value, + pub is_secret: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct ConfigKeyQuery { + pub key: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct ExtensionQuery { + pub name: String, + pub config: Value, +} + +#[derive(Serialize, ToSchema)] +pub struct ConfigResponse { + pub config: HashMap, +} + +#[utoipa::path( + post, + path = "/config/upsert", + request_body = UpsertConfigQuery, + responses( + (status = 200, description = "Configuration value upserted successfully", body = String), + (status = 500, description = "Internal server error") + ) +)] +pub async fn upsert_config( + State(_state): State>>>, + Json(query): Json, +) -> Result, StatusCode> { + let config = Config::global(); + + let result = if query.is_secret.unwrap_or(false) { + config.set_secret(&query.key, query.value) + } else { + config.set(&query.key, query.value) + }; + + match result { + Ok(_) => Ok(Json(Value::String(format!("Upserted key {}", query.key)))), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +#[utoipa::path( + post, + path = "/config/remove", + request_body = ConfigKeyQuery, + responses( + (status = 200, description = "Configuration value removed successfully", body = String), + (status = 404, description = "Configuration key not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn remove_config( + State(_state): State>>>, + Json(query): Json, +) -> Result, StatusCode> { + let config = Config::global(); + + match config.delete(&query.key) { + Ok(_) => Ok(Json(format!("Removed key {}", query.key))), + Err(_) => Err(StatusCode::NOT_FOUND), + } +} + +#[utoipa::path( + get, + path = "/config/read", + request_body = ConfigKeyQuery, + responses( + (status = 200, description = "Configuration value retrieved successfully", body = Value), + (status = 404, description = "Configuration key not found") + ) +)] +pub async fn read_config( + State(_state): State>>>, + Json(query): Json, +) -> Result, StatusCode> { + let config = Config::global(); + + match config.get::(&query.key) { + Ok(value) => Ok(Json(value)), + Err(_) => Err(StatusCode::NOT_FOUND), + } +} + +#[utoipa::path( + post, + path = "/config/extension", + request_body = ExtensionQuery, + responses( + (status = 200, description = "Extension added successfully", body = String), + (status = 400, description = "Invalid request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn add_extension( + State(_state): State>>>, + Json(extension): Json, +) -> Result, StatusCode> { + let config = Config::global(); + + // Get current extensions or initialize empty map + let mut extensions: HashMap = + config.get("extensions").unwrap_or_else(|_| HashMap::new()); + + // Add new extension + extensions.insert(extension.name.clone(), extension.config); + + // Save updated extensions + match config.set( + "extensions", + Value::Object(extensions.into_iter().collect()), + ) { + Ok(_) => Ok(Json(format!("Added extension {}", extension.name))), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +#[utoipa::path( + delete, + path = "/config/extension", + request_body = ConfigKeyQuery, + responses( + (status = 200, description = "Extension removed successfully", body = String), + (status = 404, description = "Extension not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn remove_extension( + State(_state): State>>>, + Json(query): Json, +) -> Result, StatusCode> { + let config = Config::global(); + + // Get current extensions + let mut extensions: HashMap = match config.get("extensions") { + Ok(exts) => exts, + Err(_) => return Err(StatusCode::NOT_FOUND), + }; + + // Remove extension if it exists + if extensions.remove(&query.key).is_some() { + // Save updated extensions + match config.set( + "extensions", + Value::Object(extensions.into_iter().collect()), + ) { + Ok(_) => Ok(Json(format!("Removed extension {}", query.key))), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } + } else { + Err(StatusCode::NOT_FOUND) + } +} + +#[utoipa::path( + get, + path = "/config", + responses( + (status = 200, description = "All configuration values retrieved successfully", body = ConfigResponse) + ) +)] +pub async fn read_all_config( + State(_state): State>>>, +) -> Result, StatusCode> { + let config = Config::global(); + + // Load values from config file + let values = config.load_values().unwrap_or_default(); + + Ok(Json(ConfigResponse { config: values })) +} + +pub fn routes(state: AppState) -> Router { + Router::new() + .route("/config", get(read_all_config)) + .route("/config/upsert", post(upsert_config)) + .route("/config/remove", post(remove_config)) + .route("/config/read", post(read_config)) + .route("/config/extension", post(add_extension)) + .route("/config/extension", delete(remove_extension)) + .with_state(state.config) +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index b95286e4e..a46fc4f70 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -1,5 +1,6 @@ // Export route modules pub mod agent; +pub mod config_management; pub mod configs; pub mod extension; pub mod health; @@ -14,5 +15,6 @@ pub fn configure(state: crate::state::AppState) -> Router { .merge(reply::routes(state.clone())) .merge(agent::routes(state.clone())) .merge(extension::routes(state.clone())) - .merge(configs::routes(state)) + .merge(configs::routes(state.clone())) + .merge(config_management::routes(state)) } diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 74077f2a0..b5a716ce3 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -559,6 +559,7 @@ mod tests { mod integration_tests { use super::*; use axum::{body::Body, http::Request}; + use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; use tower::ServiceExt; @@ -573,6 +574,7 @@ mod tests { }); let agent = AgentFactory::create("reference", mock_provider).unwrap(); let state = AppState { + config: Arc::new(Mutex::new(HashMap::new())), // Add this line agent: Arc::new(Mutex::new(Some(agent))), secret_key: "test-secret".to_string(), }; diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 66269cc7b..7752889ce 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -1,5 +1,7 @@ use anyhow::Result; use goose::agents::Agent; +use serde_json::Value; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; @@ -9,6 +11,7 @@ use tokio::sync::Mutex; pub struct AppState { pub agent: Arc>>>, pub secret_key: String, + pub config: Arc>>, } impl AppState { @@ -16,6 +19,7 @@ impl AppState { Ok(Self { agent: Arc::new(Mutex::new(None)), secret_key, + config: Arc::new(Mutex::new(HashMap::new())), }) } } diff --git a/crates/goose-server/ui/desktop/openapi.json b/crates/goose-server/ui/desktop/openapi.json new file mode 100644 index 000000000..71924f3ad --- /dev/null +++ b/crates/goose-server/ui/desktop/openapi.json @@ -0,0 +1,279 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "goose-server", + "description": "An AI agent", + "contact": { + "name": "Block", + "email": "ai-oss-tools@block.xyz" + }, + "license": { + "name": "Apache-2.0" + }, + "version": "1.0.4" + }, + "paths": { + "/config": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "summary": "Read all configuration values", + "operationId": "read_all_config", + "responses": { + "200": { + "description": "All configuration values retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigResponse" + } + } + } + } + } + } + }, + "/config/extension": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "summary": "Add an extension configuration", + "operationId": "add_extension", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Extension added successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "super::routes::config_management" + ], + "summary": "Remove an extension configuration", + "operationId": "remove_extension", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigKeyQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Extension removed successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Extension not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/read": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "summary": "Read a configuration value", + "operationId": "read_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigKeyQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Configuration value retrieved successfully", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Configuration key not found" + } + } + } + }, + "/config/remove": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "summary": "Remove a configuration value", + "operationId": "remove_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigKeyQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Configuration value removed successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Configuration key not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/upsert": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "summary": "Upsert a configuration value", + "operationId": "upsert_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertConfigQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Configuration value upserted successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + } + }, + "components": { + "schemas": { + "ConfigKeyQuery": { + "type": "object", + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string", + "description": "The configuration key to operate on" + } + } + }, + "ConfigResponse": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "description": "The configuration values", + "additionalProperties": {} + } + } + }, + "ExtensionQuery": { + "type": "object", + "required": [ + "name", + "config" + ], + "properties": { + "config": { + "description": "The configuration for the extension" + }, + "name": { + "type": "string", + "description": "The name of the extension" + } + } + }, + "UpsertConfigQuery": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "is_secret": { + "type": "boolean", + "description": "Whether this configuration value should be treated as a secret", + "nullable": true + }, + "key": { + "type": "string", + "description": "The configuration key to upsert" + }, + "value": { + "description": "The value to set for the configuration" + } + } + } + } + } +} \ No newline at end of file diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index e2dc1ab78..8f3e02027 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -159,7 +159,7 @@ impl Config { } // Load current values from the config file - fn load_values(&self) -> Result, ConfigError> { + pub fn load_values(&self) -> Result, ConfigError> { if self.config_path.exists() { let file_content = std::fs::read_to_string(&self.config_path)?; // Parse YAML into JSON Value for consistent internal representation diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 557dc1893..08056d445 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "goose-app", - "version": "1.0.5", + "version": "1.0.51", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goose-app", - "version": "1.0.5", + "version": "1.0.51", "license": "Apache-2.0", "dependencies": { "@ai-sdk/openai": "^0.0.72",