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
@@ -88,6 +88,7 @@ pub struct AppConfig {
pub user_agent: Option<String>,
pub save_shell_history: bool,
pub no_workspace_mcp: bool,
pub sync_models_url: Option<String>,
pub clients: Vec<ClientConfig>,
@@ -162,6 +163,7 @@ impl Default for AppConfig {
user_agent: None,
save_shell_history: true,
no_workspace_mcp: false,
sync_models_url: None,
clients: vec![],
@@ -238,6 +240,7 @@ impl AppConfig {
user_agent: config.user_agent,
save_shell_history: config.save_shell_history,
no_workspace_mcp: false,
sync_models_url: config.sync_models_url,
clients: config.clients,
+10 -10
View File
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use crate::config::{
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;
@@ -27,7 +27,7 @@ pub enum WorkspaceMemory {
pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
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() {
return Some(WorkspaceMemory::Structured {
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> {
let gitignore = git_root.join(GITIGNORE_FILE_NAME);
let entry = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}/");
let entry_no_slash = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}");
let entry = format!("{WORKSPACE_COYOTE_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 already_present = existing.lines().any(|line| {
@@ -347,7 +347,7 @@ mod tests {
let root = temp_root("phase1");
let workspace = root.join("workspace");
let workspace_memory_dir = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&workspace_memory_dir).unwrap();
fs::write(
@@ -382,7 +382,7 @@ mod tests {
let root = temp_root("prefer");
let workspace = root.join("ws");
let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap();
@@ -415,7 +415,7 @@ mod tests {
let root = temp_root("indexes_only");
let workspace = root.join("ws");
let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap();
fs::write(
@@ -450,7 +450,7 @@ mod tests {
let root = temp_root("drill_bodies");
let workspace = root.join("ws");
let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
@@ -485,7 +485,7 @@ mod tests {
let root = temp_root("cap");
let workspace = root.join("ws");
let structured = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
@@ -575,7 +575,7 @@ mod tests {
let root = temp_root("walk_up");
let workspace = root.join("ws");
let mem_dir = workspace
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&mem_dir).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_INDEX_FILE_NAME: &str = "MEMORY.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_HASH_FILE: &str = "kit.sha256";
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,
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,
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
};
use crate::client::ProviderModels;
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> {
for dir in start.ancestors() {
let candidate = dir
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(SBX_MIXIN_FILE_NAME);
if candidate.exists() {
return Some(candidate);
@@ -193,6 +193,24 @@ pub fn skill_file(name: &str) -> PathBuf {
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<()> {
if name.is_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 {
workspace_root
.join(WORKSPACE_MEMORY_DIR_NAME)
.join(WORKSPACE_COYOTE_DIR_NAME)
.join(MEMORY_DIR_NAME)
}
@@ -405,15 +423,21 @@ pub fn has_macro(name: &str) -> bool {
pub fn list_skills() -> Vec<String> {
let mut names = Vec::new();
if let Ok(rd) = read_dir(skills_dir()) {
for entry in rd.flatten() {
if let Ok(file_type) = entry.file_type()
&& file_type.is_dir()
&& let Some(name) = entry.file_name().to_str()
&& entry.path().join("SKILL.md").is_file()
&& validate_skill_name(name).is_ok()
{
names.push(name.to_string());
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() {
if let Ok(file_type) = entry.file_type()
&& file_type.is_dir()
&& let Some(name) = entry.file_name().to_str()
&& !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 {
skill_file(name).is_file()
workspace_skill_file(name).is_file() || skill_file(name).is_file()
}
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> {
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)
.with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
Ok(Skill::new(name, &content))