From 5be12e90dc60d67e2edfd4a588c3dbcd855f122a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 1 Jun 2026 13:43:43 -0600 Subject: [PATCH] feat: REPL integration with skills --- src/config/request_context.rs | 124 ++++++++++++++++++++++++++++++++++ src/config/skill.rs | 10 +++ src/repl/mod.rs | 61 +++++++++++++++-- 3 files changed, 191 insertions(+), 4 deletions(-) diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 9a191dd..dd0efef 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -1,5 +1,6 @@ use super::rag_cache::{RagCache, RagKey}; use super::session::Session; +use super::skill::{SKILL_SCAFFOLD, Skill}; use super::skill_policy::SkillPolicy; use super::skill_registry::SkillRegistry; use super::todo::TodoList; @@ -1544,6 +1545,7 @@ impl RequestContext { "session" => (self.sessions_dir(), Some(".yaml")), "rag" => (paths::rags_dir(), Some(".yaml")), "macro" => (paths::macros_dir(), Some(".yaml")), + "skill" => (paths::skills_dir(), None), "agent-data" => (paths::agents_data_dir(), None), _ => bail!("Unknown kind '{kind}'"), }; @@ -1869,6 +1871,16 @@ impl RequestContext { super::map_completion_values(values) } ".macro" => super::map_completion_values(paths::list_macros()), + ".skill" => { + let mut values: Vec = vec![ + "loaded".to_string(), + "load".to_string(), + "unload".to_string(), + "edit".to_string(), + ]; + values.extend(paths::list_skills()); + super::map_completion_values(values) + } ".starter" => match &self.agent { Some(agent) => agent .conversation_starters() @@ -1911,6 +1923,7 @@ impl RequestContext { "session", "rag", "macro", + "skill", "agent-data", ]), ".vault" => { @@ -2473,6 +2486,117 @@ impl RequestContext { Ok(()) } + pub fn upsert_skill(&self, app: &AppConfig, name: &str) -> Result<()> { + let path = paths::skill_file(name); + ensure_parent_exists(&path)?; + let is_new = !path.exists(); + if is_new { + fs::write(&path, SKILL_SCAFFOLD).with_context(|| { + format!("Failed to scaffold skill at {}", path.display()) + })?; + } + + let editor = app.editor()?; + edit_file(&editor, &path)?; + + if self.working_mode.is_repl() { + if is_new { + println!("✓ Created skill at '{}'.", path.display()); + } else { + println!("✓ Saved skill at '{}'.", path.display()); + } + } + + Ok(()) + } + + pub async fn load_skill_repl( + &mut self, + name: &str, + abort_signal: AbortSignal, + ) -> Result<()> { + if !paths::has_skill(name) { + bail!( + "Skill '{name}' is not installed (expected at {})", + paths::skill_file(name).display() + ); + } + + let policy = SkillPolicy::effective( + &self.app.config, + self.role.as_ref(), + self.agent.as_ref(), + self.session.as_ref(), + )?; + + if !policy.skills_enabled { + bail!("Skills are disabled in this context"); + } + + if !policy.allows(name) { + bail!("Skill '{name}' is not enabled in this context"); + } + + let skill = Skill::load(name)?; + let fn_on = self.app.config.function_calling_support; + let mcp_on = self.app.config.mcp_server_support; + let needs_tools = skill + .enabled_tools() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let needs_mcps = skill + .enabled_mcp_servers() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if needs_tools && !fn_on { + bail!("Skill '{name}' requires function calling, which is disabled"); + } + + if needs_mcps && !mcp_on { + bail!("Skill '{name}' requires MCP servers, which are disabled"); + } + + self.skill_registry.insert(skill)?; + if let Err(e) = self.refresh_tool_scope(abort_signal).await { + let _ = self.skill_registry.unload(name); + bail!("Loaded skill '{name}' but failed to refresh tool scope: {e}"); + } + + println!("✓ Loaded skill '{name}'."); + Ok(()) + } + + pub async fn unload_skill_repl( + &mut self, + name: &str, + abort_signal: AbortSignal, + ) -> Result<()> { + self.skill_registry.unload(name)?; + + if let Err(e) = self.refresh_tool_scope(abort_signal).await { + eprintln!( + "Warning: unloaded skill '{name}' but tool scope refresh failed: {e}" + ); + } + + println!("✓ Unloaded skill '{name}'."); + Ok(()) + } + + pub fn list_loaded_skills(&self) { + let names = self.skill_registry.loaded_names(); + + if names.is_empty() { + println!("No skills loaded."); + } else { + println!("Loaded skills:"); + for name in names { + println!(" • {name}"); + } + } + } + pub async fn apply_prelude( &mut self, app: &AppConfig, diff --git a/src/config/skill.rs b/src/config/skill.rs index df938ec..5400038 100644 --- a/src/config/skill.rs +++ b/src/config/skill.rs @@ -14,6 +14,16 @@ struct SkillsAsset; static RE_METADATA: LazyLock = LazyLock::new(|| Regex::new(r"(?s)-{3,}\s*(.*?)\s*-{3,}\s*(.*)").unwrap()); +pub const SKILL_SCAFFOLD: &str = "\ +--- +description: One-line description shown to the model when listing skills. +enabled_tools: +enabled_mcp_servers: +auto_unload: false +--- +Replace this body with the knowledge or methodology this skill teaches. +"; + #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Skill { name: String, diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 15b45c3..a766a98 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -46,7 +46,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {" 4. Continue with the next pending item now. Call tools immediately." }; -static REPL_COMMANDS: LazyLock<[ReplCommand; 42]> = LazyLock::new(|| { +static REPL_COMMANDS: LazyLock<[ReplCommand; 43]> = LazyLock::new(|| { [ ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".info", "Show system info", AssertState::pass()), @@ -191,6 +191,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 42]> = LazyLock::new(|| { AssertState::TrueFalse(StateFlags::RAG, StateFlags::AGENT), ), ReplCommand::new(".macro", "Execute a macro", AssertState::pass()), + ReplCommand::new( + ".skill", + "List, load, unload, edit, or create skills", + AssertState::pass(), + ), ReplCommand::new( ".file", "Include files, directories, URLs or commands", @@ -513,6 +518,54 @@ pub async fn run_repl_command( .role [text]... # Temporarily switch to the role, send the text, and switch back"# ), }, + ".skill" => { + let trimmed = args.map(str::trim).unwrap_or(""); + let mut parts = trimmed.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or(""); + let rest = parts.next().map(str::trim).unwrap_or(""); + match first { + "" => println!( + r#"Usage: + .skill loaded # List currently-loaded skills + .skill load # Load a skill into the current context + .skill unload # Unload a loaded skill + .skill edit # Open an existing skill in $EDITOR + .skill # Open the skill in $EDITOR; create with a scaffold if missing"# + ), + "loaded" => ctx.list_loaded_skills(), + "load" => { + if rest.is_empty() { + println!("Usage: .skill load "); + } else { + ctx.load_skill_repl(rest, abort_signal.clone()).await?; + } + } + "unload" => { + if rest.is_empty() { + println!("Usage: .skill unload "); + } else { + ctx.unload_skill_repl(rest, abort_signal.clone()).await?; + } + } + "edit" => { + if rest.is_empty() { + println!("Usage: .skill edit "); + } else if !paths::has_skill(rest) { + bail!( + "Skill '{rest}' is not installed (expected at {})", + paths::skill_file(rest).display() + ); + } else { + let app = Arc::clone(&ctx.app.config); + ctx.upsert_skill(app.as_ref(), rest)?; + } + } + name => { + let app = Arc::clone(&ctx.app.config); + ctx.upsert_skill(app.as_ref(), name)?; + } + } + } ".session" => { if let Some(name) = graph::active_agent_graph_name(ctx) { bail!( @@ -779,7 +832,7 @@ pub async fn run_repl_command( ctx.delete(args)?; } _ => { - println!("Usage: .delete ") + println!("Usage: .delete ") } }, ".copy" => { @@ -1265,8 +1318,8 @@ mod tests { } #[test] - fn repl_commands_has_42_entries() { - assert_eq!(REPL_COMMANDS.len(), 42); + fn repl_commands_has_43_entries() { + assert_eq!(REPL_COMMANDS.len(), 43); } #[test]