feat: Support workspace-local skill definitions and MCP configurations

This commit is contained in:
2026-07-13 17:12:34 -06:00
parent b91f738209
commit bc3cc10a7b
8 changed files with 111 additions and 27 deletions
+3
View File
@@ -195,6 +195,9 @@ pub struct Cli {
/// Skip discovery and application of all sbx mixins (user and built-in) /// Skip discovery and application of all sbx mixins (user and built-in)
#[arg(long, requires = "sandbox")] #[arg(long, requires = "sandbox")]
pub no_mixins: bool, pub no_mixins: bool,
/// Disable loading workspace MCP servers from .coyote/mcp.json
#[arg(long)]
pub no_workspace_mcp: bool,
} }
impl Cli { impl Cli {
+3
View File
@@ -88,6 +88,7 @@ pub struct AppConfig {
pub user_agent: Option<String>, pub user_agent: Option<String>,
pub save_shell_history: bool, pub save_shell_history: bool,
pub no_workspace_mcp: bool,
pub sync_models_url: Option<String>, pub sync_models_url: Option<String>,
pub clients: Vec<ClientConfig>, pub clients: Vec<ClientConfig>,
@@ -162,6 +163,7 @@ impl Default for AppConfig {
user_agent: None, user_agent: None,
save_shell_history: true, save_shell_history: true,
no_workspace_mcp: false,
sync_models_url: None, sync_models_url: None,
clients: vec![], clients: vec![],
@@ -238,6 +240,7 @@ impl AppConfig {
user_agent: config.user_agent, user_agent: config.user_agent,
save_shell_history: config.save_shell_history, save_shell_history: config.save_shell_history,
no_workspace_mcp: false,
sync_models_url: config.sync_models_url, sync_models_url: config.sync_models_url,
clients: config.clients, clients: config.clients,
+10 -10
View File
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use crate::config::{ use crate::config::{
GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME,
WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths, WORKSPACE_COYOTE_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths,
}; };
pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000; pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
@@ -27,7 +27,7 @@ pub enum WorkspaceMemory {
pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> { pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
for dir in start.ancestors() { for dir in start.ancestors() {
let structured = dir.join(WORKSPACE_MEMORY_DIR_NAME).join(MEMORY_DIR_NAME); let structured = dir.join(WORKSPACE_COYOTE_DIR_NAME).join(MEMORY_DIR_NAME);
if structured.join(MEMORY_INDEX_FILE_NAME).exists() { if structured.join(MEMORY_INDEX_FILE_NAME).exists() {
return Some(WorkspaceMemory::Structured { return Some(WorkspaceMemory::Structured {
workspace_root: dir.to_path_buf(), workspace_root: dir.to_path_buf(),
@@ -84,8 +84,8 @@ pub fn bootstrap_workspace_memory(git_root: &Path) -> Result<PathBuf> {
fn append_gitignore_entry(git_root: &Path) -> Result<bool> { fn append_gitignore_entry(git_root: &Path) -> Result<bool> {
let gitignore = git_root.join(GITIGNORE_FILE_NAME); let gitignore = git_root.join(GITIGNORE_FILE_NAME);
let entry = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}/"); let entry = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}/");
let entry_no_slash = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}"); let entry_no_slash = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}");
let existing = fs::read_to_string(&gitignore).unwrap_or_default(); let existing = fs::read_to_string(&gitignore).unwrap_or_default();
let already_present = existing.lines().any(|line| { let already_present = existing.lines().any(|line| {
@@ -347,7 +347,7 @@ mod tests {
let root = temp_root("phase1"); let root = temp_root("phase1");
let workspace = root.join("workspace"); let workspace = root.join("workspace");
let workspace_memory_dir = workspace let workspace_memory_dir = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&workspace_memory_dir).unwrap(); fs::create_dir_all(&workspace_memory_dir).unwrap();
fs::write( fs::write(
@@ -382,7 +382,7 @@ mod tests {
let root = temp_root("prefer"); let root = temp_root("prefer");
let workspace = root.join("ws"); let workspace = root.join("ws");
let structured = workspace let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap(); fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap();
@@ -415,7 +415,7 @@ mod tests {
let root = temp_root("indexes_only"); let root = temp_root("indexes_only");
let workspace = root.join("ws"); let workspace = root.join("ws");
let structured = workspace let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap(); fs::create_dir_all(&structured).unwrap();
fs::write( fs::write(
@@ -450,7 +450,7 @@ mod tests {
let root = temp_root("drill_bodies"); let root = temp_root("drill_bodies");
let workspace = root.join("ws"); let workspace = root.join("ws");
let structured = workspace let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap(); fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
@@ -485,7 +485,7 @@ mod tests {
let root = temp_root("cap"); let root = temp_root("cap");
let workspace = root.join("ws"); let workspace = root.join("ws");
let structured = workspace let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap(); fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
@@ -575,7 +575,7 @@ mod tests {
let root = temp_root("walk_up"); let root = temp_root("walk_up");
let workspace = root.join("ws"); let workspace = root.join("ws");
let mem_dir = workspace let mem_dir = workspace
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME); .join(MEMORY_DIR_NAME);
fs::create_dir_all(&mem_dir).unwrap(); fs::create_dir_all(&mem_dir).unwrap();
fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap(); fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
+1 -1
View File
@@ -143,7 +143,7 @@ const MCP_FILE_NAME: &str = "mcp.json";
const MEMORY_DIR_NAME: &str = "memory"; const MEMORY_DIR_NAME: &str = "memory";
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md"; const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md"; const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote"; const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
const SBX_KIT_DIR_NAME: &str = "sbx-kit"; const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
+37 -13
View File
@@ -5,7 +5,7 @@ use super::{
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME,
MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME,
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME,
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
}; };
use crate::client::ProviderModels; use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME; use crate::config::REPL_HISTORY_DIR_NAME;
@@ -118,7 +118,7 @@ pub fn global_tools_sbx_mixin_file() -> PathBuf {
pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> { pub fn find_workspace_sbx_mixin(start: &Path) -> Option<PathBuf> {
for dir in start.ancestors() { for dir in start.ancestors() {
let candidate = dir let candidate = dir
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(SBX_MIXIN_FILE_NAME); .join(SBX_MIXIN_FILE_NAME);
if candidate.exists() { if candidate.exists() {
return Some(candidate); return Some(candidate);
@@ -193,6 +193,24 @@ pub fn skill_file(name: &str) -> PathBuf {
skill_dir(name).join("SKILL.md") skill_dir(name).join("SKILL.md")
} }
pub fn workspace_skills_dir() -> PathBuf {
env::current_dir()
.unwrap_or_default()
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(SKILLS_DIR_NAME)
}
pub fn workspace_skill_file(name: &str) -> PathBuf {
workspace_skills_dir().join(name).join("SKILL.md")
}
pub fn workspace_mcp_config_file() -> PathBuf {
env::current_dir()
.unwrap_or_default()
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MCP_FILE_NAME)
}
pub fn validate_skill_name(name: &str) -> Result<()> { pub fn validate_skill_name(name: &str) -> Result<()> {
if name.is_empty() { if name.is_empty() {
bail!("Skill name cannot be empty"); bail!("Skill name cannot be empty");
@@ -318,7 +336,7 @@ pub fn global_memory_index_path() -> PathBuf {
pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf { pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
workspace_root workspace_root
.join(WORKSPACE_MEMORY_DIR_NAME) .join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME) .join(MEMORY_DIR_NAME)
} }
@@ -405,15 +423,21 @@ pub fn has_macro(name: &str) -> bool {
pub fn list_skills() -> Vec<String> { pub fn list_skills() -> Vec<String> {
let mut names = Vec::new(); let mut names = Vec::new();
if let Ok(rd) = read_dir(skills_dir()) { let mut seen = HashSet::new();
for entry in rd.flatten() {
if let Ok(file_type) = entry.file_type() for dir in [workspace_skills_dir(), skills_dir()] {
&& file_type.is_dir() if let Ok(rd) = read_dir(dir) {
&& let Some(name) = entry.file_name().to_str() for entry in rd.flatten() {
&& entry.path().join("SKILL.md").is_file() if let Ok(file_type) = entry.file_type()
&& validate_skill_name(name).is_ok() && file_type.is_dir()
{ && let Some(name) = entry.file_name().to_str()
names.push(name.to_string()); && !seen.contains(name)
&& entry.path().join("SKILL.md").is_file()
&& validate_skill_name(name).is_ok()
{
seen.insert(name.to_string());
names.push(name.to_string());
}
} }
} }
} }
@@ -423,7 +447,7 @@ pub fn list_skills() -> Vec<String> {
} }
pub fn has_skill(name: &str) -> bool { pub fn has_skill(name: &str) -> bool {
skill_file(name).is_file() workspace_skill_file(name).is_file() || skill_file(name).is_file()
} }
pub fn local_models_override() -> Result<Vec<ProviderModels>> { pub fn local_models_override() -> Result<Vec<ProviderModels>> {
+5 -1
View File
@@ -117,7 +117,11 @@ impl Skill {
pub fn load(name: &str) -> Result<Self> { pub fn load(name: &str) -> Result<Self> {
paths::validate_skill_name(name)?; paths::validate_skill_name(name)?;
let path = paths::skill_file(name); let path = if paths::workspace_skill_file(name).is_file() {
paths::workspace_skill_file(name)
} else {
paths::skill_file(name)
};
let content = read_to_string(&path) let content = read_to_string(&path)
.with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?; .with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
Ok(Skill::new(name, &content)) Ok(Skill::new(name, &content))
+5 -1
View File
@@ -187,7 +187,11 @@ async fn main() -> Result<()> {
let abort_signal = create_abort_signal(); let abort_signal = create_abort_signal();
let start_mcp_servers = cli.agent.is_none() && cli.role.is_none(); let start_mcp_servers = cli.agent.is_none() && cli.role.is_none();
let cfg = Config::load_with_interpolation(info_flag).await?; let cfg = Config::load_with_interpolation(info_flag).await?;
let app_config: Arc<AppConfig> = Arc::new(AppConfig::from_config(cfg)?); let mut app_config = AppConfig::from_config(cfg)?;
if cli.no_workspace_mcp {
app_config.no_workspace_mcp = true;
}
let app_config: Arc<AppConfig> = Arc::new(app_config);
let app_state: Arc<AppState> = Arc::new( let app_state: Arc<AppState> = Arc::new(
AppState::init( AppState::init(
app_config, app_config,
+47 -1
View File
@@ -214,7 +214,53 @@ impl McpRegistry {
spec.validate(name)?; spec.validate(name)?;
} }
registry.config = Some(mcp_servers_config); let mut merged = mcp_servers_config;
if !app_config.no_workspace_mcp {
let ws_path = paths::workspace_mcp_config_file();
if ws_path.try_exists().unwrap_or(false) {
match tokio::fs::read_to_string(&ws_path).await {
Ok(ws_content) if !ws_content.trim().is_empty() => {
match interpolate_secrets(&ws_content, vault) {
Ok((parsed, missing)) if missing.is_empty() => {
match serde_json::from_str::<McpServersConfig>(&parsed) {
Ok(ws_config) => {
let mut loaded = Vec::new();
for (name, spec) in ws_config.mcp_servers {
match spec.validate(&name) {
Ok(_) => {
loaded.push(name.clone());
merged.mcp_servers.insert(name, spec);
}
Err(e) => warn!(
"Invalid workspace MCP server '{name}': {e}. Skipping."
),
}
}
if !loaded.is_empty() {
eprintln!(
"Loading workspace MCP servers: {}",
loaded.join(", ")
);
}
}
Err(e) => warn!(
"Failed to parse workspace MCP config: {e}. Skipping."
),
}
}
Ok((_, missing)) => warn!(
"Workspace MCP config references missing vault secrets: {missing:?}. Skipping."
),
Err(e) => {
warn!("Failed to process workspace MCP config: {e}. Skipping.")
}
}
}
_ => {}
}
}
}
registry.config = Some(merged);
if start_mcp_servers && app_config.mcp_server_support { if start_mcp_servers && app_config.mcp_server_support {
abortable_run_with_spinner( abortable_run_with_spinner(