feat: Support workspace-local skill definitions and MCP configurations
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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";
|
||||||
|
|||||||
+29
-5
@@ -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,25 +423,31 @@ 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 dir in [workspace_skills_dir(), skills_dir()] {
|
||||||
|
if let Ok(rd) = read_dir(dir) {
|
||||||
for entry in rd.flatten() {
|
for entry in rd.flatten() {
|
||||||
if let Ok(file_type) = entry.file_type()
|
if let Ok(file_type) = entry.file_type()
|
||||||
&& file_type.is_dir()
|
&& file_type.is_dir()
|
||||||
&& let Some(name) = entry.file_name().to_str()
|
&& let Some(name) = entry.file_name().to_str()
|
||||||
|
&& !seen.contains(name)
|
||||||
&& entry.path().join("SKILL.md").is_file()
|
&& entry.path().join("SKILL.md").is_file()
|
||||||
&& validate_skill_name(name).is_ok()
|
&& validate_skill_name(name).is_ok()
|
||||||
{
|
{
|
||||||
|
seen.insert(name.to_string());
|
||||||
names.push(name.to_string());
|
names.push(name.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
names.sort_unstable();
|
names.sort_unstable();
|
||||||
names
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
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
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user