feat: extensions read config (#1637)

This commit is contained in:
Lily Delalande
2025-03-12 18:41:31 -07:00
committed by GitHub
parent 68b3b3f9cc
commit 4537264387
15 changed files with 756 additions and 190 deletions
+14 -3
View File
@@ -1,6 +1,7 @@
use cliclack::spinner;
use console::style;
use goose::agents::{extension::Envs, ExtensionConfig};
use goose::config::extensions::name_to_key;
use goose::config::{Config, ConfigError, ExperimentManager, ExtensionEntry, ExtensionManager};
use goose::message::Message;
use goose::providers::{create, providers};
@@ -387,7 +388,10 @@ pub fn toggle_extensions_dialog() -> Result<(), Box<dyn Error>> {
// Update enabled status for each extension
for name in extension_status.iter().map(|(name, _)| name) {
ExtensionManager::set_enabled(name, selected.iter().any(|s| s.as_str() == name))?;
ExtensionManager::set_enabled(
&name_to_key(name),
selected.iter().any(|s| s.as_str() == name),
)?;
}
cliclack::outro("Extension settings updated successfully")?;
@@ -638,10 +642,17 @@ pub fn remove_extension_dialog() -> Result<(), Box<dyn Error>> {
return Ok(());
}
// Filter out only disabled extensions
let disabled_extensions: Vec<_> = extensions
.iter()
.filter(|entry| !entry.enabled)
.map(|entry| (entry.config.name().to_string(), entry.enabled))
.collect();
let selected = cliclack::multiselect("Select extensions to remove (note: you can only remove disabled extensions - use \"space\" to toggle and \"enter\" to submit)")
.required(false)
.items(
&extension_status
&disabled_extensions
.iter()
.filter(|(_, enabled)| !enabled)
.map(|(name, _)| (name, name.as_str(), ""))
@@ -650,7 +661,7 @@ pub fn remove_extension_dialog() -> Result<(), Box<dyn Error>> {
.interact()?;
for name in selected {
ExtensionManager::remove(name)?;
ExtensionManager::remove(&name_to_key(name))?;
cliclack::outro(format!("Removed {} extension", style(name).green()))?;
}
+11 -2
View File
@@ -1,5 +1,8 @@
use utoipa::OpenApi;
use goose::agents::extension::Envs;
use goose::agents::ExtensionConfig;
use goose::config::ExtensionEntry;
use goose::providers::base::ConfigKey;
use goose::providers::base::ProviderMetadata;
@@ -12,6 +15,8 @@ use goose::providers::base::ProviderMetadata;
super::routes::config_management::read_config,
super::routes::config_management::add_extension,
super::routes::config_management::remove_extension,
super::routes::config_management::toggle_extension,
super::routes::config_management::get_extensions,
super::routes::config_management::update_extension,
super::routes::config_management::read_all_config,
super::routes::config_management::providers
@@ -19,13 +24,17 @@ use goose::providers::base::ProviderMetadata;
components(schemas(
super::routes::config_management::UpsertConfigQuery,
super::routes::config_management::ConfigKeyQuery,
super::routes::config_management::ExtensionQuery,
super::routes::config_management::ConfigResponse,
super::routes::config_management::ProvidersResponse,
super::routes::config_management::ProvidersResponse,
super::routes::config_management::ProviderDetails,
super::routes::config_management::ExtensionResponse,
super::routes::config_management::ExtensionQuery,
ProviderMetadata,
ConfigKey
ExtensionEntry,
ExtensionConfig,
ConfigKey,
Envs,
))
)]
pub struct ApiDoc;
@@ -1,10 +1,15 @@
use crate::routes::utils::check_provider_configured;
use crate::state::AppState;
use axum::routing::put;
use axum::{
extract::State,
routing::{delete, get, post},
Json, Router,
};
use goose::agents::ExtensionConfig;
use goose::config::extensions::name_to_key;
use goose::config::Config;
use goose::config::{ExtensionEntry, ExtensionManager};
use goose::providers::base::ProviderMetadata;
use goose::providers::providers as get_providers;
use http::{HeaderMap, StatusCode};
@@ -13,9 +18,6 @@ use serde_json::Value;
use std::collections::HashMap;
use utoipa::ToSchema;
use crate::routes::utils::check_provider_configured;
use crate::state::AppState;
fn verify_secret_key(headers: &HeaderMap, state: &AppState) -> Result<StatusCode, StatusCode> {
// Verify secret key
let secret_key = headers
@@ -30,6 +32,18 @@ fn verify_secret_key(headers: &HeaderMap, state: &AppState) -> Result<StatusCode
}
}
#[derive(Serialize, ToSchema)]
pub struct ExtensionResponse {
pub extensions: Vec<ExtensionEntry>,
}
#[derive(Deserialize, ToSchema)]
pub struct ExtensionQuery {
pub name: String,
pub config: ExtensionConfig,
pub enabled: bool,
}
#[derive(Deserialize, ToSchema)]
pub struct UpsertConfigQuery {
pub key: String,
@@ -43,12 +57,6 @@ pub struct ConfigKeyQuery {
pub is_secret: bool,
}
#[derive(Deserialize, ToSchema)]
pub struct ExtensionQuery {
pub name: String,
pub config: Value,
}
#[derive(Serialize, ToSchema)]
pub struct ConfigResponse {
pub config: HashMap<String, Value>,
@@ -155,9 +163,29 @@ pub async fn read_config(
}
}
#[utoipa::path(
get,
path = "/config/extensions",
responses(
(status = 200, description = "All extensions retrieved successfully", body = ExtensionResponse),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_extensions(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<ExtensionResponse>, StatusCode> {
verify_secret_key(&headers, &state)?;
match ExtensionManager::get_all() {
Ok(extensions) => Ok(Json(ExtensionResponse { extensions })),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
#[utoipa::path(
post,
path = "/config/extension",
path = "/config/extensions",
request_body = ExtensionQuery,
responses(
(status = 200, description = "Extension added successfully", body = String),
@@ -168,35 +196,23 @@ pub async fn read_config(
pub async fn add_extension(
State(state): State<AppState>,
headers: HeaderMap,
Json(extension): Json<ExtensionQuery>,
Json(extension_query): Json<ExtensionQuery>,
) -> Result<Json<String>, StatusCode> {
// Use the helper function to verify the secret key
verify_secret_key(&headers, &state)?;
let config = Config::global();
// Get current extensions or initialize empty map
let mut extensions: HashMap<String, Value> = config
.get_param("extensions")
.unwrap_or_else(|_| HashMap::new());
// Add new extension
extensions.insert(extension.name.clone(), extension.config);
// Save updated extensions
match config.set_param(
"extensions",
Value::Object(extensions.into_iter().collect()),
) {
Ok(_) => Ok(Json(format!("Added extension {}", extension.name))),
// Use ExtensionManager to set the extension
match ExtensionManager::set(ExtensionEntry {
enabled: extension_query.enabled,
config: extension_query.config,
}) {
Ok(_) => Ok(Json(format!("Added extension {}", extension_query.name))),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
#[utoipa::path(
delete,
path = "/config/extension",
request_body = ConfigKeyQuery,
path = "/config/extensions/{name}",
responses(
(status = 200, description = "Extension removed successfully", body = String),
(status = 404, description = "Extension not found"),
@@ -206,31 +222,98 @@ pub async fn add_extension(
pub async fn remove_extension(
State(state): State<AppState>,
headers: HeaderMap,
Json(query): Json<ConfigKeyQuery>,
axum::extract::Path(name): axum::extract::Path<String>,
) -> Result<Json<String>, StatusCode> {
// Use the helper function to verify the secret key
verify_secret_key(&headers, &state)?;
let config = Config::global();
let key = name_to_key(&name);
// Use ExtensionManager to remove the extension
match ExtensionManager::remove(&key) {
Ok(_) => Ok(Json(format!("Removed extension {}", name))),
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
// Get current extensions
let mut extensions: HashMap<String, Value> = match config.get_param("extensions") {
Ok(exts) => exts,
Err(_) => return Err(StatusCode::NOT_FOUND),
#[utoipa::path(
put,
path = "/config/extensions/{name}",
request_body = ExtensionQuery,
responses(
(status = 200, description = "Extension updated successfully", body = String),
(status = 404, description = "Extension not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn update_extension(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Path(name): axum::extract::Path<String>,
Json(extension_query): Json<ExtensionQuery>,
) -> Result<Json<String>, StatusCode> {
verify_secret_key(&headers, &state)?;
let key = name_to_key(&name);
// Check if extension exists
let extensions = ExtensionManager::get_all().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !extensions.iter().any(|entry| entry.config.key() == key) {
return Err(StatusCode::NOT_FOUND);
}
// Use ExtensionManager to update the extension
match ExtensionManager::set(ExtensionEntry {
enabled: extension_query.enabled,
config: extension_query.config,
}) {
Ok(_) => Ok(Json(format!("Updated extension {}", extension_query.name))),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
#[utoipa::path(
post,
path = "/extensions/{name}/toggle",
responses(
(status = 200, description = "Extension toggled successfully", body = String),
(status = 404, description = "Extension not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn toggle_extension(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Path(name): axum::extract::Path<String>,
) -> Result<Json<String>, StatusCode> {
verify_secret_key(&headers, &state)?;
let key = name_to_key(&name);
// Get the extension
let extensions = ExtensionManager::get_all().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let extension = extensions
.iter()
.find(|e| e.config.key() == key)
.ok_or(StatusCode::NOT_FOUND)?;
// Create a new entry with toggled enabled state
let updated_entry = ExtensionEntry {
enabled: !extension.enabled,
config: extension.config.clone(),
};
// Remove extension if it exists
if extensions.remove(&query.key).is_some() {
// Save updated extensions
match config.set_param(
"extensions",
Value::Object(extensions.into_iter().collect()),
) {
Ok(_) => Ok(Json(format!("Removed extension {}", query.key))),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
// Update using ExtensionManager
match ExtensionManager::set(updated_entry) {
Ok(_) => {
let status = if !extension.enabled {
"enabled"
} else {
"disabled"
};
Ok(Json(format!("Extension {} {}", name, status)))
}
} else {
Err(StatusCode::NOT_FOUND)
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
@@ -256,50 +339,6 @@ pub async fn read_all_config(
Ok(Json(ConfigResponse { config: values }))
}
#[utoipa::path(
put,
path = "/config/extension",
request_body = ExtensionQuery,
responses(
(status = 200, description = "Extension configuration updated successfully", body = String),
(status = 404, description = "Extension not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn update_extension(
State(state): State<AppState>,
headers: HeaderMap,
Json(extension): Json<ExtensionQuery>,
) -> Result<Json<String>, StatusCode> {
// Use the helper function to verify the secret key
verify_secret_key(&headers, &state)?;
let config = Config::global();
// Get current extensions
let mut extensions: HashMap<String, Value> = match config.get_param("extensions") {
Ok(exts) => exts,
Err(_) => return Err(StatusCode::NOT_FOUND),
};
// Check if extension exists
if !extensions.contains_key(&extension.name) {
return Err(StatusCode::NOT_FOUND);
}
// Update extension configuration
extensions.insert(extension.name.clone(), extension.config);
// Save updated extensions
match config.set_param(
"extensions",
Value::Object(extensions.into_iter().collect()),
) {
Ok(_) => Ok(Json(format!("Updated extension {}", extension.name))),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// Modified providers function using the new response type
#[utoipa::path(
get,
@@ -341,9 +380,11 @@ pub fn routes(state: AppState) -> Router {
.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", put(update_extension))
.route("/config/extension", delete(remove_extension))
.route("/config/extensions", get(get_extensions))
.route("/config/extensions", post(add_extension))
.route("/config/extensions/:name", put(update_extension))
.route("/config/extensions/:name", delete(remove_extension))
.route("/extensions/:name/toggle", post(toggle_extension))
.route("/config/providers", get(providers))
.with_state(state)
}
+1 -1
View File
@@ -60,7 +60,7 @@ serde_yaml = "0.9.34"
once_cell = "1.20.2"
etcetera = "0.8.0"
rand = "0.8.5"
utoipa = { version = "4.1" }
utoipa = "4.1"
# For Bedrock provider
aws-config = { version = "1.1.7", features = ["behavior-version-latest"] }
+1 -1
View File
@@ -171,7 +171,7 @@ impl Capabilities {
.await
.map_err(|e| ExtensionError::Initialization(config.clone(), e))?;
let sanitized_name = normalize(config.name().to_string());
let sanitized_name = normalize(config.key().to_string());
// Store instructions if provided
if let Some(instructions) = init_result.instructions {
+11 -3
View File
@@ -3,8 +3,10 @@ use std::collections::HashMap;
use mcp_client::client::Error as ClientError;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use utoipa::ToSchema;
use crate::config;
use crate::config::extensions::name_to_key;
/// Errors from Extension operation
#[derive(Error, Debug)]
@@ -21,7 +23,7 @@ pub enum ExtensionError {
pub type ExtensionResult<T> = Result<T, ExtensionError>;
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[derive(Debug, Clone, Deserialize, Serialize, Default, ToSchema)]
pub struct Envs {
/// A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host
#[serde(default)]
@@ -43,7 +45,7 @@ impl Envs {
}
/// Represents the different types of MCP extensions that can be added to the manager
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
#[serde(tag = "type")]
pub enum ExtensionConfig {
/// Server-sent events client with a URI endpoint
@@ -130,13 +132,19 @@ impl ExtensionConfig {
}
}
pub fn key(&self) -> String {
let name = self.name();
name_to_key(&name)
}
/// Get the extension name regardless of variant
pub fn name(&self) -> &str {
pub fn name(&self) -> String {
match self {
Self::Sse { name, .. } => name,
Self::Stdio { name, .. } => name,
Self::Builtin { name, .. } => name,
}
.to_string()
}
}
+23 -15
View File
@@ -3,23 +3,28 @@ use crate::agents::ExtensionConfig;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use utoipa::ToSchema;
pub const DEFAULT_EXTENSION: &str = "developer";
pub const DEFAULT_EXTENSION_TIMEOUT: u64 = 300;
#[derive(Debug, Deserialize, Serialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone, ToSchema)]
pub struct ExtensionEntry {
pub enabled: bool,
#[serde(flatten)]
pub config: ExtensionConfig,
}
pub fn name_to_key(name: &str) -> String {
name.to_string()
}
/// Extension configuration management
pub struct ExtensionManager;
impl ExtensionManager {
/// Get the extension configuration if enabled
pub fn get_config(name: &str) -> Result<Option<ExtensionConfig>> {
/// Get the extension configuration if enabled -- uses key
pub fn get_config(key: &str) -> Result<Option<ExtensionConfig>> {
let config = Config::global();
// Try to get the extension entry
@@ -28,7 +33,7 @@ impl ExtensionManager {
Err(super::ConfigError::NotFound(_)) => {
// Initialize with default developer extension
let defaults = HashMap::from([(
DEFAULT_EXTENSION.to_string(),
name_to_key(DEFAULT_EXTENSION), // Use key format for top-level key in config
ExtensionEntry {
enabled: true,
config: ExtensionConfig::Builtin {
@@ -43,7 +48,7 @@ impl ExtensionManager {
Err(e) => return Err(e.into()),
};
Ok(extensions.get(name).and_then(|entry| {
Ok(extensions.get(key).and_then(|entry| {
if entry.enabled {
Some(entry.config.clone())
} else {
@@ -60,33 +65,35 @@ impl ExtensionManager {
.get_param("extensions")
.unwrap_or_else(|_| HashMap::new());
extensions.insert(entry.config.name().parse()?, entry);
let key = entry.config.key();
extensions.insert(key, entry);
config.set_param("extensions", serde_json::to_value(extensions)?)?;
Ok(())
}
/// Remove an extension configuration
pub fn remove(name: &str) -> Result<()> {
/// Remove an extension configuration -- uses the key
pub fn remove(key: &str) -> Result<()> {
let config = Config::global();
let mut extensions: HashMap<String, ExtensionEntry> = config
.get_param("extensions")
.unwrap_or_else(|_| HashMap::new());
extensions.remove(name);
extensions.remove(key);
config.set_param("extensions", serde_json::to_value(extensions)?)?;
Ok(())
}
/// Enable or disable an extension
pub fn set_enabled(name: &str, enabled: bool) -> Result<()> {
/// Enable or disable an extension -- uses key
pub fn set_enabled(key: &str, enabled: bool) -> Result<()> {
let config = Config::global();
let mut extensions: HashMap<String, ExtensionEntry> = config
.get_param("extensions")
.unwrap_or_else(|_| HashMap::new());
if let Some(entry) = extensions.get_mut(name) {
if let Some(entry) = extensions.get_mut(key) {
entry.enabled = enabled;
config.set_param("extensions", serde_json::to_value(extensions)?)?;
}
@@ -109,16 +116,17 @@ impl ExtensionManager {
.unwrap_or_else(|_| get_keys(Default::default())))
}
/// Check if an extension is enabled
pub fn is_enabled(name: &str) -> Result<bool> {
/// Check if an extension is enabled - FIXED to use key
pub fn is_enabled(key: &str) -> Result<bool> {
let config = Config::global();
let extensions: HashMap<String, ExtensionEntry> = config
.get_param("extensions")
.unwrap_or_else(|_| HashMap::new());
Ok(extensions.get(name).map(|e| e.enabled).unwrap_or(false))
Ok(extensions.get(key).map(|e| e.enabled).unwrap_or(false))
}
}
fn get_keys(entries: HashMap<String, ExtensionEntry>) -> Vec<String> {
entries.into_keys().collect()
}
+1 -1
View File
@@ -1,6 +1,6 @@
mod base;
mod experiments;
mod extensions;
pub mod extensions;
pub use crate::agents::ExtensionConfig;
pub use base::{Config, ConfigError, APP_STRATEGY};
+235 -14
View File
@@ -33,7 +33,28 @@
}
}
},
"/config/extension": {
"/config/extensions": {
"get": {
"tags": [
"super::routes::config_management"
],
"operationId": "get_extensions",
"responses": {
"200": {
"description": "All extensions retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExtensionResponse"
}
}
}
},
"500": {
"description": "Internal server error"
}
}
},
"post": {
"tags": [
"super::routes::config_management"
@@ -67,12 +88,24 @@
"description": "Internal server error"
}
}
},
}
},
"/config/extensions/{name}": {
"put": {
"tags": [
"super::routes::config_management"
],
"operationId": "update_extension",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
@@ -85,7 +118,7 @@
},
"responses": {
"200": {
"description": "Extension configuration updated successfully",
"description": "Extension updated successfully",
"content": {
"text/plain": {
"schema": {
@@ -107,16 +140,16 @@
"super::routes::config_management"
],
"operationId": "remove_extension",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigKeyQuery"
}
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"required": true
},
}
],
"responses": {
"200": {
"description": "Extension removed successfully",
@@ -259,6 +292,42 @@
}
}
}
},
"/extensions/{name}/toggle": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "toggle_extension",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Extension toggled successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Extension not found"
},
"500": {
"description": "Internal server error"
}
}
}
}
},
"components": {
@@ -313,19 +382,171 @@
}
}
},
"Envs": {
"type": "object",
"additionalProperties": {
"type": "string",
"description": "A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host"
}
},
"ExtensionConfig": {
"oneOf": [
{
"type": "object",
"description": "Server-sent events client with a URI endpoint",
"required": [
"name",
"uri",
"type"
],
"properties": {
"envs": {
"$ref": "#/components/schemas/Envs"
},
"name": {
"type": "string",
"description": "The name used to identify this extension"
},
"timeout": {
"type": "integer",
"format": "int64",
"nullable": true,
"minimum": 0
},
"type": {
"type": "string",
"enum": [
"sse"
]
},
"uri": {
"type": "string"
}
}
},
{
"type": "object",
"description": "Standard I/O client with command and arguments",
"required": [
"name",
"cmd",
"args",
"type"
],
"properties": {
"args": {
"type": "array",
"items": {
"type": "string"
}
},
"cmd": {
"type": "string"
},
"envs": {
"$ref": "#/components/schemas/Envs"
},
"name": {
"type": "string",
"description": "The name used to identify this extension"
},
"timeout": {
"type": "integer",
"format": "int64",
"nullable": true,
"minimum": 0
},
"type": {
"type": "string",
"enum": [
"stdio"
]
}
}
},
{
"type": "object",
"description": "Built-in extension that is part of the goose binary",
"required": [
"name",
"type"
],
"properties": {
"name": {
"type": "string",
"description": "The name used to identify this extension"
},
"timeout": {
"type": "integer",
"format": "int64",
"nullable": true,
"minimum": 0
},
"type": {
"type": "string",
"enum": [
"builtin"
]
}
}
}
],
"description": "Represents the different types of MCP extensions that can be added to the manager",
"discriminator": {
"propertyName": "type"
}
},
"ExtensionEntry": {
"allOf": [
{
"$ref": "#/components/schemas/ExtensionConfig"
},
{
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
}
}
}
]
},
"ExtensionQuery": {
"type": "object",
"required": [
"name",
"config"
"config",
"enabled"
],
"properties": {
"config": {},
"config": {
"$ref": "#/components/schemas/ExtensionConfig"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"ExtensionResponse": {
"type": "object",
"required": [
"extensions"
],
"properties": {
"extensions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ExtensionEntry"
}
}
}
},
"ProviderDetails": {
"type": "object",
"required": [
+21 -11
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { ReadAllConfigData, ReadAllConfigResponse, RemoveExtensionData, RemoveExtensionResponse, AddExtensionData, AddExtensionResponse, UpdateExtensionData, UpdateExtensionResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
import type { ReadAllConfigData, ReadAllConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, UpdateExtensionData, UpdateExtensionResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ToggleExtensionData, ToggleExtensionResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -25,20 +25,16 @@ export const readAllConfig = <ThrowOnError extends boolean = false>(options?: Op
});
};
export const removeExtension = <ThrowOnError extends boolean = false>(options: Options<RemoveExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).delete<RemoveExtensionResponse, unknown, ThrowOnError>({
url: '/config/extension',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
export const getExtensions = <ThrowOnError extends boolean = false>(options?: Options<GetExtensionsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetExtensionsResponse, unknown, ThrowOnError>({
url: '/config/extensions',
...options
});
};
export const addExtension = <ThrowOnError extends boolean = false>(options: Options<AddExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<AddExtensionResponse, unknown, ThrowOnError>({
url: '/config/extension',
url: '/config/extensions',
...options,
headers: {
'Content-Type': 'application/json',
@@ -47,9 +43,16 @@ export const addExtension = <ThrowOnError extends boolean = false>(options: Opti
});
};
export const removeExtension = <ThrowOnError extends boolean = false>(options: Options<RemoveExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).delete<RemoveExtensionResponse, unknown, ThrowOnError>({
url: '/config/extensions/{name}',
...options
});
};
export const updateExtension = <ThrowOnError extends boolean = false>(options: Options<UpdateExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).put<UpdateExtensionResponse, unknown, ThrowOnError>({
url: '/config/extension',
url: '/config/extensions/{name}',
...options,
headers: {
'Content-Type': 'application/json',
@@ -96,4 +99,11 @@ export const upsertConfig = <ThrowOnError extends boolean = false>(options: Opti
...options?.headers
}
});
};
export const toggleExtension = <ThrowOnError extends boolean = false>(options: Options<ToggleExtensionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<ToggleExtensionResponse, unknown, ThrowOnError>({
url: '/extensions/{name}/toggle',
...options
});
};
+120 -18
View File
@@ -16,9 +16,55 @@ export type ConfigResponse = {
config: {};
};
export type ExtensionQuery = {
config: unknown;
export type Envs = {
[key: string]: string;
};
/**
* Represents the different types of MCP extensions that can be added to the manager
*/
export type ExtensionConfig = {
envs?: Envs;
/**
* The name used to identify this extension
*/
name: string;
timeout?: number | null;
type: 'sse';
uri: string;
} | {
args: Array<string>;
cmd: string;
envs?: Envs;
/**
* The name used to identify this extension
*/
name: string;
timeout?: number | null;
type: 'stdio';
} | {
/**
* The name used to identify this extension
*/
name: string;
timeout?: number | null;
type: 'builtin';
};
export type ExtensionEntry = ExtensionConfig & {
type?: 'ExtensionEntry';
} & {
enabled: boolean;
};
export type ExtensionQuery = {
config: ExtensionConfig;
enabled: boolean;
name: string;
};
export type ExtensionResponse = {
extensions: Array<ExtensionEntry>;
};
export type ProviderDetails = {
@@ -94,38 +140,34 @@ export type ReadAllConfigResponses = {
export type ReadAllConfigResponse = ReadAllConfigResponses[keyof ReadAllConfigResponses];
export type RemoveExtensionData = {
body: ConfigKeyQuery;
export type GetExtensionsData = {
body?: never;
path?: never;
query?: never;
url: '/config/extension';
url: '/config/extensions';
};
export type RemoveExtensionErrors = {
/**
* Extension not found
*/
404: unknown;
export type GetExtensionsErrors = {
/**
* Internal server error
*/
500: unknown;
};
export type RemoveExtensionResponses = {
export type GetExtensionsResponses = {
/**
* Extension removed successfully
* All extensions retrieved successfully
*/
200: string;
200: ExtensionResponse;
};
export type RemoveExtensionResponse = RemoveExtensionResponses[keyof RemoveExtensionResponses];
export type GetExtensionsResponse = GetExtensionsResponses[keyof GetExtensionsResponses];
export type AddExtensionData = {
body: ExtensionQuery;
path?: never;
query?: never;
url: '/config/extension';
url: '/config/extensions';
};
export type AddExtensionErrors = {
@@ -148,11 +190,42 @@ export type AddExtensionResponses = {
export type AddExtensionResponse = AddExtensionResponses[keyof AddExtensionResponses];
export type RemoveExtensionData = {
body?: never;
path: {
name: string;
};
query?: never;
url: '/config/extensions/{name}';
};
export type RemoveExtensionErrors = {
/**
* Extension not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type RemoveExtensionResponses = {
/**
* Extension removed successfully
*/
200: string;
};
export type RemoveExtensionResponse = RemoveExtensionResponses[keyof RemoveExtensionResponses];
export type UpdateExtensionData = {
body: ExtensionQuery;
path?: never;
path: {
name: string;
};
query?: never;
url: '/config/extension';
url: '/config/extensions/{name}';
};
export type UpdateExtensionErrors = {
@@ -168,7 +241,7 @@ export type UpdateExtensionErrors = {
export type UpdateExtensionResponses = {
/**
* Extension configuration updated successfully
* Extension updated successfully
*/
200: string;
};
@@ -262,6 +335,35 @@ export type UpsertConfigResponses = {
export type UpsertConfigResponse = UpsertConfigResponses[keyof UpsertConfigResponses];
export type ToggleExtensionData = {
body?: never;
path: {
name: string;
};
query?: never;
url: '/extensions/{name}/toggle';
};
export type ToggleExtensionErrors = {
/**
* Extension not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type ToggleExtensionResponses = {
/**
* Extension toggled successfully
*/
200: string;
};
export type ToggleExtensionResponse = ToggleExtensionResponses[keyof ToggleExtensionResponses];
export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {});
};
+46 -12
View File
@@ -4,6 +4,8 @@ import {
readConfig,
removeConfig,
upsertConfig,
getExtensions as apiGetExtensions,
toggleExtension as apiToggleExtension,
addExtension as apiAddExtension,
removeExtension as apiRemoveExtension,
updateExtension as apiUpdateExtension,
@@ -14,8 +16,11 @@ import type {
ConfigResponse,
UpsertConfigQuery,
ConfigKeyQuery,
ExtensionQuery,
ExtensionResponse,
ExtensionEntry,
ProviderDetails,
ExtensionQuery,
ExtensionConfig,
} from '../api/types.gen';
// Initialize client configuration
@@ -33,10 +38,12 @@ interface ConfigContextType {
upsert: (key: string, value: unknown, is_secret: boolean) => Promise<void>;
read: (key: string, is_secret: boolean) => Promise<unknown>;
remove: (key: string, is_secret: boolean) => Promise<void>;
addExtension: (name: string, config: unknown) => Promise<void>;
updateExtension: (name: string, config: unknown) => Promise<void>;
addExtension: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
updateExtension: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
toggleExtension: (name: string) => Promise<void>;
removeExtension: (name: string) => Promise<void>;
getProviders: (b: boolean) => Promise<ProviderDetails[]>;
getExtensions: (b: boolean) => Promise<ExtensionEntry[]>
}
interface ConfigProviderProps {
@@ -48,6 +55,7 @@ const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
const [config, setConfig] = useState<ConfigResponse['config']>({});
const [providersList, setProvidersList] = useState<ProviderDetails[]>([]);
const [extensionsList, setExtensionsList] = useState<ExtensionEntry[]>([]);
useEffect(() => {
// Load all configuration data and providers on mount
@@ -63,6 +71,15 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
} catch (error) {
console.error('Failed to load providers:', error);
}
// Load extensions
try {
const extensionsResponse = await apiGetExtensions()
setExtensionsList(extensionsResponse.data.extensions)
} catch (error) {
console.error('Failed to load extensions:', error)
}
})();
}, []);
@@ -99,8 +116,8 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
await reloadConfig();
};
const addExtension = async (name: string, config: unknown) => {
const query: ExtensionQuery = { name, config };
const addExtension = async (name: string, config: ExtensionConfig, enabled: boolean) => {
const query: ExtensionQuery = { name, config, enabled };
await apiAddExtension({
body: query,
});
@@ -108,18 +125,21 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
};
const removeExtension = async (name: string) => {
const query: ConfigKeyQuery = { key: name, is_secret: false };
await apiRemoveExtension({
await apiRemoveExtension({path: {name: name}});
await reloadConfig();
};
const updateExtension = async (name: string, config: ExtensionConfig, enabled: boolean) => {
const query: ExtensionQuery = { name, config, enabled };
await apiUpdateExtension({
body: query,
path: {name: name}
});
await reloadConfig();
};
const updateExtension = async (name: string, config: unknown) => {
const query: ExtensionQuery = { name, config };
await apiUpdateExtension({
body: query,
});
const toggleExtension = async (name: string) => {
await apiToggleExtension({path: {name: name}});
await reloadConfig();
};
@@ -134,6 +154,18 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
return providersList;
};
const getExtensions = async (forceRefresh = false): Promise<ExtensionEntry[]> => {
if (forceRefresh || extensionsList.length === 0) {
// If a refresh is forced, or we don't have providers yet
const response = await apiGetExtensions();
const extensionResponse: ExtensionResponse = response.data
setExtensionsList(extensionResponse.extensions);
return extensionResponse.extensions;
}
// Otherwise return the cached providers
return extensionsList;
};
const contextValue = useMemo(
() => ({
config,
@@ -144,7 +176,9 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
addExtension,
updateExtension,
removeExtension,
toggleExtension,
getProviders,
getExtensions,
}),
[config, providersList]
); // Functions don't need to be dependencies as they don't change
@@ -6,7 +6,7 @@ import { useConfig } from '../ConfigContext';
import { Button } from '../ui/button';
import { Plus } from 'lucide-react';
import { Gear } from '../icons/Gear';
import ExtensionsSection from './ExtensionsSection';
import ExtensionsSection from './extensions/ExtensionsSection';
interface ModelOption {
id: string;
@@ -1,14 +1,14 @@
import React, { useEffect, useState } from 'react';
import { Button } from '../ui/button';
import { Switch } from '../ui/switch';
import { Button } from '../../ui/button';
import { Switch } from '../../ui/switch';
import { Plus, X } from 'lucide-react';
import { Gear } from '../icons/Gear';
import { GPSIcon } from '../ui/icons';
import { useConfig } from '../ConfigContext';
import Modal from '../Modal';
import { Input } from '../ui/input';
import { Gear } from '../../icons/Gear';
import { GPSIcon } from '../../ui/icons';
import { useConfig } from '../../ConfigContext';
import Modal from '../../Modal';
import { Input } from '../../ui/input';
import Select from 'react-select';
import { createDarkSelectStyles, darkSelectTheme } from '../ui/select-styles';
import { createDarkSelectStyles, darkSelectTheme } from '../../ui/select-styles';
interface ExtensionConfig {
args?: string[];
@@ -16,7 +16,7 @@ interface ExtensionConfig {
enabled: boolean;
envs?: Record<string, string>;
name: string;
type: 'stdio' | 'sse';
type: 'stdio' | 'sse' | 'builtin';
}
interface ExtensionItem {
@@ -50,7 +50,7 @@ const getSubtitle = (config: ExtensionConfig): string => {
};
export default function ExtensionsSection() {
const { config, updateExtension, addExtension } = useConfig();
const { config, read, updateExtension, addExtension } = useConfig();
const [extensions, setExtensions] = useState<ExtensionItem[]>([]);
const [selectedExtension, setSelectedExtension] = useState<ExtensionItem | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -74,8 +74,9 @@ export default function ExtensionsSection() {
});
useEffect(() => {
if (config.extensions) {
const extensionItems: ExtensionItem[] = Object.entries(config.extensions).map(
const extensions = read('extensions', false)
if (extensions) {
const extensionItems: ExtensionItem[] = Object.entries(extensions).map(
([name, ext]) => {
const extensionConfig = ext as ExtensionConfig;
return {
@@ -90,7 +91,7 @@ export default function ExtensionsSection() {
);
setExtensions(extensionItems);
}
}, [config.extensions]);
}, [read]);
useEffect(() => {
if (selectedExtension) {
@@ -0,0 +1,121 @@
// TODO: copied this from old code
// import {View} from '../../../App'
// import {SettingsViewOptions} from "../SettingsView";
// import {toast} from "react-toastify";
//
//
// export async function addExtensionFromDeepLink(
// url: string,
// setView: (view: View, options: SettingsViewOptions) => void
// ) {
// if (!url.startsWith('goose://extension')) {
// handleError(
// 'Failed to install extension: Invalid URL: URL must use the goose://extension scheme'
// );
// return;
// }
//
// const parsedUrl = new URL(url);
//
// if (parsedUrl.protocol !== 'goose:') {
// handleError(
// 'Failed to install extension: Invalid protocol: URL must use the goose:// scheme',
// true
// );
// }
//
// // Check that all required fields are present and not empty
// const requiredFields = ['name', 'description'];
//
// for (const field of requiredFields) {
// const value = parsedUrl.searchParams.get(field);
// if (!value || value.trim() === '') {
// handleError(
// `Failed to install extension: The link is missing required field '${field}'`,
// true
// );
// }
// }
//
// const cmd = parsedUrl.searchParams.get('cmd');
// if (!cmd) {
// handleError("Failed to install extension: Missing required 'cmd' parameter in the URL", true);
// }
//
// // Validate that the command is one of the allowed commands
// const allowedCommands = ['npx', 'uvx', 'goosed'];
// if (!allowedCommands.includes(cmd)) {
// handleError(
// `Failed to install extension: Invalid command: ${cmd}. Only ${allowedCommands.join(', ')} are allowed.`,
// true
// );
// }
//
// // Check for security risk with npx -c command
// const args = parsedUrl.searchParams.getAll('arg');
// if (cmd === 'npx' && args.includes('-c')) {
// handleError(
// 'Failed to install extension: npx with -c argument can lead to code injection',
// true
// );
// }
//
// const envList = parsedUrl.searchParams.getAll('env');
// const id = parsedUrl.searchParams.get('id');
// const name = parsedUrl.searchParams.get('name');
// const description = parsedUrl.searchParams.get('description');
// const timeout = parsedUrl.searchParams.get('timeout');
//
// // split env based on delimiter to a map
// const envs = envList.reduce(
// (acc, env) => {
// const [key, value] = env.split('=');
// acc[key] = value;
// return acc;
// },
// {} as Record<string, string>
// );
//
// // Create a ExtensionConfig from the URL parameters
// // Parse timeout if provided, otherwise use default
// const parsedTimeout = timeout ? parseInt(timeout, 10) : null;
//
// const extensionConfig: ExtensionConfig = {
// id,
// name,
// type: 'stdio',
// cmd,
// args,
// description,
// enabled: true,
// env_keys: Object.keys(envs).length > 0 ? Object.keys(envs) : [],
// timeout:
// parsedTimeout !== null && !isNaN(parsedTimeout) && Number.isInteger(parsedTimeout)
// ? parsedTimeout
// : DEFAULT_EXTENSION_TIMEOUT,
// };
//
// // Store the extension config regardless of env vars status
// storeExtensionConfig(extensionConfig);
//
// // Check if extension requires env vars and go to settings if so
// if (envVarsRequired(extensionConfig)) {
// console.log('Environment variables required, redirecting to settings');
// setView('settings', { extensionId: extensionConfig.id, showEnvVars: true });
// return;
// }
//
// // If no env vars are required, proceed with extending Goosed
// await addExtension(extensionConfig);
// }
//
// function handleError(message: string, shouldThrow = false): void {
// toast.error(message);
// console.error(message);
// if (shouldThrow) {
// throw new Error(message);
// }
// }
// TODO: when rust app starts, add built-in extensions to config.yaml if they aren't there already