feat: REPL integration with skills

This commit is contained in:
2026-06-01 13:43:43 -06:00
parent de42cae87f
commit 2e06c0e7d2
3 changed files with 191 additions and 4 deletions
+124
View File
@@ -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<String> = 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,
+10
View File
@@ -14,6 +14,16 @@ struct SkillsAsset;
static RE_METADATA: LazyLock<Regex> =
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,
+57 -4
View File
@@ -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 <name> [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 <name> # Load a skill into the current context
.skill unload <name> # Unload a loaded skill
.skill edit <name> # Open an existing skill in $EDITOR
.skill <name> # 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 <name>");
} else {
ctx.load_skill_repl(rest, abort_signal.clone()).await?;
}
}
"unload" => {
if rest.is_empty() {
println!("Usage: .skill unload <name>");
} else {
ctx.unload_skill_repl(rest, abort_signal.clone()).await?;
}
}
"edit" => {
if rest.is_empty() {
println!("Usage: .skill edit <name>");
} 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 <role|session|rag|macro|agent-data>")
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
}
},
".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]