Files
vibe-kanban/crates/server/src/routes/config.rs
Gabriel Gordon-Hall 9b4ca9dc45 feat: edit coding agent profiles (#453)
* edit profiles.json

* move default crate configuration to a default_profiles.json

button to open mcp config in editor

initialse empty mcp config files

fix test

new JSON structure

remove editor buttons

fmt and types

* feat: add profile field to follow-up attempt (#442)

* move default crate configuration to a default_profiles.json

* new JSON structure

* feat: add profile field to follow-up attempt; fix follow ups using wrong session id at 2nd+ follow up

fmt

Profile selection (vibe-kanban cf714482)

Right now in the frontend, when viewing a task card, we show the base_coding_agent from the task attempt. We should also show the currently selected profile there in the same way

feat: add watchkill support to CommandBuilder and integrate with Claude executor

feat: refactor profile handling to use ProfileVariant across executors and requests

feat: restructure command modes in default_profiles.json for clarity and consistency

update profile handling to use ProfileVariant across components and add mode selection

fmt

feat: refactor profile handling to use variants instead of modes across components and update related structures

Fix frontend

* Refactor coding agent representation in task and task attempt models

- Changed `base_coding_agent` field to `profile` in `TaskWithAttemptStatus` and `TaskAttempt` structs.
- Updated SQL queries and data handling to reflect the new `profile` field.
- Modified related API endpoints and request/response structures to use `profile` instead of `base_coding_agent`.
- Adjusted frontend API calls and components to align with the updated data structure.
- Removed unused `BaseCodingAgent` enum and related type guards from the frontend.
- Enhanced MCP server configuration handling to utilize the new profile-based approach.

feat: Introduce MCP configuration management

- Added `McpConfig` struct for managing MCP server configurations.
- Implemented reading and writing of agent config files in JSON and TOML formats.
- Refactored MCP server handling in the `McpServers` component to utilize the new configuration structure.
- Removed deprecated `agent_config.rs` and updated related imports.
- Enhanced error handling for MCP server operations.
- Updated frontend strategies to accommodate the new MCP configuration structure.

feat: Introduce MCP configuration management

- Added `McpConfig` struct for managing MCP server configurations.
- Implemented reading and writing of agent config files in JSON and TOML formats.
- Refactored MCP server handling in the `McpServers` component to utilize the new configuration structure.
- Removed deprecated `agent_config.rs` and updated related imports.
- Enhanced error handling for MCP server operations.
- Updated frontend strategies to accommodate the new MCP configuration structure.

Best effort migration; add missing feature flag

feat: refactor execution process handling and introduce profile variant extraction

feat: add default follow-up variant handling in task details context

feat: enhance profile variant selection with dropdown menus in onboarding and task sections

fmt, types

* refactor: rename ProfileVariant to ProfileVariantLabel; Modified AgentProfile to wrap AgentProfileVariant

Fmt, clippy

* Fix rebase issues

* refactor: replace OnceLock with RwLock for AgentProfiles caching; update profile retrieval in executors and routes

---------

Co-authored-by: Gabriel Gordon-Hall <ggordonhall@gmail.com>

Fmt

Fix tests

refactor: clean up unused imports and default implementations in executor modules

Move profiles to profiles.rs

* rename profile to profile_variant_label for readability

rename AgentProfile to ProfileConfig, AgentProfileVariant to VariantAgentConfig

* remove duplicated profile state

* Amp yolo

---------

Co-authored-by: Alex Netsch <alex@bloop.ai>
2025-08-14 17:33:33 +01:00

382 lines
12 KiB
Rust

use std::collections::HashMap;
use axum::{
body::Body,
extract::{Path, Query, State},
http,
response::{Json as ResponseJson, Response},
routing::{get, put},
Json, Router,
};
use deployment::{Deployment, DeploymentError};
use executors::{
mcp_config::{read_agent_config, write_agent_config, McpConfig},
profile::ProfileConfigs,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use services::services::config::{save_config_to_file, Config, ConfigError, SoundFile};
use tokio::fs;
use ts_rs::TS;
use utils::{assets::config_path, response::ApiResponse};
use crate::{error::ApiError, DeploymentImpl};
pub fn router() -> Router<DeploymentImpl> {
Router::new()
.route("/info", get(get_user_system_info))
.route("/config", put(update_config))
.route("/sounds/{sound}", get(get_sound))
.route("/mcp-config", get(get_mcp_servers).post(update_mcp_servers))
.route("/profiles", get(get_profiles).put(update_profiles))
}
#[derive(Debug, Serialize, Deserialize, TS)]
pub struct Environment {
pub os_type: String,
pub os_version: String,
pub os_architecture: String,
pub bitness: String,
}
impl Default for Environment {
fn default() -> Self {
Self::new()
}
}
impl Environment {
pub fn new() -> Self {
let info = os_info::get();
Environment {
os_type: info.os_type().to_string(),
os_version: info.version().to_string(),
os_architecture: info.architecture().unwrap_or("unknown").to_string(),
bitness: info.bitness().to_string(),
}
}
}
#[derive(Debug, Serialize, Deserialize, TS)]
pub struct UserSystemInfo {
pub config: Config,
#[serde(flatten)]
pub profiles: ProfileConfigs,
pub environment: Environment,
}
// TODO: update frontend, BE schema has changed, this replaces GET /config and /config/constants
#[axum::debug_handler]
async fn get_user_system_info(
State(deployment): State<DeploymentImpl>,
) -> ResponseJson<ApiResponse<UserSystemInfo>> {
let config = deployment.config().read().await;
let user_system_info = UserSystemInfo {
config: config.clone(),
profiles: ProfileConfigs::get_cached(),
environment: Environment::new(),
};
ResponseJson(ApiResponse::success(user_system_info))
}
async fn update_config(
State(deployment): State<DeploymentImpl>,
Json(new_config): Json<Config>,
) -> ResponseJson<ApiResponse<Config>> {
let config_path = config_path();
match save_config_to_file(&new_config, &config_path).await {
Ok(_) => {
let mut config = deployment.config().write().await;
*config = new_config.clone();
drop(config);
ResponseJson(ApiResponse::success(new_config))
}
Err(e) => ResponseJson(ApiResponse::error(&format!("Failed to save config: {}", e))),
}
}
async fn get_sound(Path(sound): Path<SoundFile>) -> Result<Response, ApiError> {
let sound = sound.serve().await.map_err(DeploymentError::Other)?;
let response = Response::builder()
.status(http::StatusCode::OK)
.header(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("audio/wav"),
)
.body(Body::from(sound.data.into_owned()))
.unwrap();
Ok(response)
}
#[derive(TS, Debug, Deserialize)]
pub struct McpServerQuery {
profile: String,
}
#[derive(TS, Debug, Serialize, Deserialize)]
pub struct GetMcpServerResponse {
// servers: HashMap<String, Value>,
mcp_config: McpConfig,
config_path: String,
}
#[derive(TS, Debug, Serialize, Deserialize)]
pub struct UpdateMcpServersBody {
servers: HashMap<String, Value>,
}
async fn get_mcp_servers(
State(_deployment): State<DeploymentImpl>,
Query(query): Query<McpServerQuery>,
) -> Result<ResponseJson<ApiResponse<GetMcpServerResponse>>, ApiError> {
let profiles = ProfileConfigs::get_cached();
let profile = profiles.get_profile(&query.profile).ok_or_else(|| {
ApiError::Config(ConfigError::ValidationError(format!(
"Profile not found: {}",
query.profile
)))
})?;
if !profile.default.agent.supports_mcp() {
return Ok(ResponseJson(ApiResponse::error(
"This executor does not support MCP servers",
)));
}
// Resolve supplied config path or agent default
let config_path = match profile.get_mcp_config_path() {
Some(path) => path,
None => {
return Ok(ResponseJson(ApiResponse::error(
"Could not determine config file path",
)));
}
};
let mut mcpc = profile.default.agent.get_mcp_config();
let raw_config = read_agent_config(&config_path, &mcpc).await?;
let servers = get_mcp_servers_from_config_path(&raw_config, &mcpc.servers_path);
mcpc.set_servers(servers);
Ok(ResponseJson(ApiResponse::success(GetMcpServerResponse {
mcp_config: mcpc,
config_path: config_path.to_string_lossy().to_string(),
})))
}
async fn update_mcp_servers(
State(_deployment): State<DeploymentImpl>,
Query(query): Query<McpServerQuery>,
Json(payload): Json<UpdateMcpServersBody>,
) -> Result<ResponseJson<ApiResponse<String>>, ApiError> {
let profiles = ProfileConfigs::get_cached();
let agent = &profiles
.get_profile(&query.profile)
.ok_or_else(|| {
ApiError::Config(ConfigError::ValidationError(format!(
"Profile not found: {}",
query.profile
)))
})?
.default
.agent;
if !agent.supports_mcp() {
return Ok(ResponseJson(ApiResponse::error(
"This executor does not support MCP servers",
)));
}
// Resolve supplied config path or agent default
let config_path = match agent.default_mcp_config_path() {
Some(path) => path,
None => {
return Ok(ResponseJson(ApiResponse::error(
"Could not determine config file path",
)))
}
};
let mcpc = agent.get_mcp_config();
match update_mcp_servers_in_config(&config_path, &mcpc, payload.servers).await {
Ok(message) => Ok(ResponseJson(ApiResponse::success(message))),
Err(e) => Ok(ResponseJson(ApiResponse::error(&format!(
"Failed to update MCP servers: {}",
e
)))),
}
}
async fn update_mcp_servers_in_config(
config_path: &std::path::Path,
mcpc: &McpConfig,
new_servers: HashMap<String, Value>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).await?;
}
// Read existing config (JSON or TOML depending on agent)
let mut config = read_agent_config(config_path, mcpc).await?;
// Get the current server count for comparison
let old_servers = get_mcp_servers_from_config_path(&config, &mcpc.servers_path).len();
// Set the MCP servers using the correct attribute path
set_mcp_servers_in_config_path(&mut config, &mcpc.servers_path, &new_servers)?;
// Write the updated config back to file (JSON or TOML depending on agent)
write_agent_config(config_path, mcpc, &config).await?;
let new_count = new_servers.len();
let message = match (old_servers, new_count) {
(0, 0) => "No MCP servers configured".to_string(),
(0, n) => format!("Added {} MCP server(s)", n),
(old, new) if old == new => format!("Updated MCP server configuration ({} server(s))", new),
(old, new) => format!(
"Updated MCP server configuration (was {}, now {})",
old, new
),
};
Ok(message)
}
/// Helper function to get MCP servers from config using a path
fn get_mcp_servers_from_config_path(raw_config: &Value, path: &[String]) -> HashMap<String, Value> {
let mut current = raw_config;
for part in path {
current = match current.get(part) {
Some(val) => val,
None => return HashMap::new(),
};
}
// Extract the servers object
match current.as_object() {
Some(servers) => servers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
None => HashMap::new(),
}
}
/// Helper function to set MCP servers in config using a path
fn set_mcp_servers_in_config_path(
raw_config: &mut Value,
path: &[String],
servers: &HashMap<String, Value>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Ensure config is an object
if !raw_config.is_object() {
*raw_config = serde_json::json!({});
}
let mut current = raw_config;
// Navigate/create the nested structure (all parts except the last)
for part in &path[..path.len() - 1] {
if current.get(part).is_none() {
current
.as_object_mut()
.unwrap()
.insert(part.to_string(), serde_json::json!({}));
}
current = current.get_mut(part).unwrap();
if !current.is_object() {
*current = serde_json::json!({});
}
}
// Set the final attribute
let final_attr = path.last().unwrap();
current
.as_object_mut()
.unwrap()
.insert(final_attr.to_string(), serde_json::to_value(servers)?);
Ok(())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProfilesContent {
pub content: String,
pub path: String,
}
async fn get_profiles(
State(_deployment): State<DeploymentImpl>,
) -> ResponseJson<ApiResponse<ProfilesContent>> {
let profiles_path = utils::assets::profiles_path();
let mut profiles = ProfileConfigs::from_defaults();
if let Ok(user_content) = std::fs::read_to_string(&profiles_path) {
match serde_json::from_str::<ProfileConfigs>(&user_content) {
Ok(user_profiles) => {
// Override defaults with user profiles that have the same label
for user_profile in user_profiles.profiles {
if let Some(default_profile) = profiles
.profiles
.iter_mut()
.find(|p| p.default.label == user_profile.default.label)
{
*default_profile = user_profile;
} else {
profiles.profiles.push(user_profile);
}
}
}
Err(e) => {
tracing::error!("Failed to parse profiles.json: {}", e);
}
}
}
let content = serde_json::to_string_pretty(&profiles).unwrap_or_else(|e| {
tracing::error!("Failed to serialize profiles to JSON: {}", e);
serde_json::to_string_pretty(&ProfileConfigs::from_defaults())
.unwrap_or_else(|_| "{}".to_string())
});
ResponseJson(ApiResponse::success(ProfilesContent {
content,
path: profiles_path.display().to_string(),
}))
}
async fn update_profiles(
State(_deployment): State<DeploymentImpl>,
body: String,
) -> ResponseJson<ApiResponse<String>> {
let profiles: ProfileConfigs = match serde_json::from_str(&body) {
Ok(p) => p,
Err(e) => {
return ResponseJson(ApiResponse::error(&format!(
"Invalid profiles format: {}",
e
)))
}
};
let profiles_path = utils::assets::profiles_path();
// Simply save all profiles as provided by the user
let formatted = serde_json::to_string_pretty(&profiles).unwrap();
match fs::write(&profiles_path, formatted).await {
Ok(_) => {
tracing::info!("All profiles saved to {:?}", profiles_path);
// Reload the cached profiles
ProfileConfigs::reload();
ResponseJson(ApiResponse::success(
"Profiles updated successfully".to_string(),
))
}
Err(e) => ResponseJson(ApiResponse::error(&format!(
"Failed to save profiles: {}",
e
))),
}
}