10 Commits

14 changed files with 161 additions and 186 deletions
+1
View File
@@ -37,6 +37,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
* [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains.
* [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union.
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
* [Graph Agents](https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents): Define an agent as a declarative, YAML-driven workflow. A directed graph of typed nodes (LLM calls, scripts, approvals, user input, RAG retrieval, sub-agent spawns).
* [Todo System](https://github.com/Dark-Alex-17/coyote/wiki/TODO-System): Built-in task tracking for improved LLM reliability with smaller models.
+5
View File
@@ -42,6 +42,11 @@ global_tools: # Optional list of additional global tools to e
- web_search
- fs
- python
skills_enabled: true # Master switch for skills in this agent (default: inherit from global)
enabled_skills: # Optional list of skills available when this agent runs.
# Must be a subset of global `visible_skills`. Omit to inherit the global default.
- git-master
- ai-slop-remover
dynamic_instructions: false # Whether to use dynamic instructions for the agent; if false, static instructions are used
instructions: | # Static instructions for the agent; ignored if dynamic instructions are used
You are a AI agent designed to demonstrate agent capabilities.
+15
View File
@@ -80,6 +80,21 @@ mapping_mcp_servers: # Alias for an MCP server or set of servers
git: github,gitmcp
enabled_mcp_servers: null # Which MCP servers to enable by default (e.g. 'github,slack,ddg-search')
# ---- Skills ----
# Skills are modular knowledge or capability packs the LLM can load and unload mid-conversation.
# See the [Skills documentation](https://github.com/Dark-Alex-17/coyote/wiki/Skills) for more details.
skills_enabled: true # Master switch. Set to false to hide all skill management tools from the model.
visible_skills: # The universe of skills allowed to be enabled in any context. Omit (null) for "all installed".
- ai-slop-remover
- code-review
- frontend-ui-ux
- git-master
enabled_skills: null # Which skills are available by default (no role/agent/session active). null = all visible.
# Example: only expose two skills in the bare REPL.
# enabled_skills:
# - git-master
# - ai-slop-remover
# ---- Auto-Continue (Todo System) ----
# The auto-continue system provides built-in task tracking for improved reliability.
# When enabled, the model can create todo lists and the system will automatically
+3
View File
@@ -10,6 +10,9 @@ temperature: 0.2 # The temperature to use for this role whe
top_p: 0 # The top_p to use for this role when querying the model
enabled_tools: fs_ls,fs_cat # A comma-separated list of tools to enable for this role
enabled_mcp_servers: github,gitmcp # A comma-separated list of MCP servers to enable for this role
skills_enabled: true # Master switch for skills in this role (default: inherit from global)
enabled_skills: git-master,ai-slop-remover # Comma-separated list of skills available when this role is active.
# Must be a subset of global `visible_skills`. Omit to inherit the global default.
prompt: null # A custom prompt to use for this role that will immediately query
# the model for output instead of using the instructions below
# Auto-Continue (Todo System)
+7
View File
@@ -207,6 +207,13 @@ impl Agent {
functions.append_teammate_functions();
functions.append_user_interaction_functions();
if app.function_calling_support
&& app.skills_enabled
&& !matches!(agent_config.skills_enabled, Some(false))
{
functions.append_skill_functions();
}
agent_config.replace_tools_placeholder(&functions);
Ok(Self {
+1 -1
View File
@@ -394,7 +394,7 @@ impl AppConfig {
if let Some(Some(v)) = super::read_env_bool(&get_env_name("skills_enabled")) {
self.skills_enabled = v;
}
if let Some(v) = super::read_env_value::<String>(&get_env_name("enabled_skills")) {
self.enabled_skills = v;
}
+18 -1
View File
@@ -375,7 +375,12 @@ fn plan_changes(layout: &RemoteLayout) -> Result<InstallPlan> {
}
if let Some(src_dir) = &layout.skills {
plan_dir_into(src_dir, &paths::skills_dir(), TopCategory::Skills, &mut files)?;
plan_dir_into(
src_dir,
&paths::skills_dir(),
TopCategory::Skills,
&mut files,
)?;
}
if let Some(src_dir) = &layout.macros {
@@ -1289,6 +1294,12 @@ mod tests {
#[test]
fn merge_non_tty_conflict_aborts_without_force() {
if *IS_STDOUT_TERMINAL {
eprintln!(
"Skipping merge_non_tty_conflict_aborts_without_force: requires non-TTY stdout"
);
return;
}
let dir = fresh_temp_dir("merge-non-tty-");
let remote = dir.join("remote.json");
let target = dir.join("target.json");
@@ -1365,6 +1376,12 @@ mod tests {
#[test]
fn handle_missing_secrets_defers_all_in_non_tty() {
if *IS_STDOUT_TERMINAL {
eprintln!(
"Skipping handle_missing_secrets_defers_all_in_non_tty: requires non-TTY stdout"
);
return;
}
let missing = vec![
"COYOTE_TEST_STEP4_A".to_string(),
"COYOTE_TEST_STEP4_B".to_string(),
+31 -29
View File
@@ -15,7 +15,7 @@ use super::{MessageContentToolCalls, prompts};
use crate::client::{Model, ModelType, list_models};
use crate::function::{
FunctionDeclaration, Functions, ToolCallTracker, ToolResult,
user_interaction::USER_FUNCTION_PREFIX,
skill::SKILL_FUNCTION_PREFIX, user_interaction::USER_FUNCTION_PREFIX,
};
use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
@@ -132,6 +132,13 @@ impl RequestContext {
functions.append_user_interaction_functions();
}
if app.config.function_calling_support {
let policy = SkillPolicy::effective(&app.config, None, None, None)?;
if policy.skills_enabled {
functions.append_skill_functions();
}
}
let mut mcp_runtime = McpRuntime::default();
if let Some(registry) = &app.mcp_registry {
mcp_runtime.sync_from_registry(registry);
@@ -891,6 +898,7 @@ impl RequestContext {
("env_file", display_path(&paths::env_file())),
("agents_dir", display_path(&paths::agents_data_dir())),
("roles_dir", display_path(&paths::roles_dir())),
("skills_dir", display_path(&paths::skills_dir())),
("sessions_dir", display_path(&self.sessions_dir())),
("rags_dir", display_path(&paths::rags_dir())),
("macros_dir", display_path(&paths::macros_dir())),
@@ -1137,7 +1145,9 @@ impl RequestContext {
.declarations()
.iter()
.filter(|v| {
v.name.starts_with(USER_FUNCTION_PREFIX) && !existing.contains(&v.name)
(v.name.starts_with(USER_FUNCTION_PREFIX)
|| v.name.starts_with(SKILL_FUNCTION_PREFIX))
&& !existing.contains(&v.name)
})
.cloned()
.collect();
@@ -1873,14 +1883,11 @@ impl RequestContext {
}
".macro" => super::map_completion_values(paths::list_macros()),
".skill" => {
let mut values: Vec<String> = vec![
super::map_completion_values(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
@@ -1937,6 +1944,12 @@ impl RequestContext {
}
_ => vec![],
};
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
{
values = super::map_completion_values(paths::list_skills());
} else if cmd == ".skill" && args.first() == Some(&"unload") && args.len() == 2 {
values = super::map_completion_values(self.skill_registry.loaded_names());
} else if cmd == ".install" && args.first() == Some(&"remote") && args.len() >= 2 {
let prev = args.get(args.len() - 2).copied().unwrap_or("");
if prev == "--filter" {
@@ -2492,9 +2505,8 @@ impl RequestContext {
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())
})?;
fs::write(&path, SKILL_SCAFFOLD)
.with_context(|| format!("Failed to scaffold skill at {}", path.display()))?;
}
let editor = app.editor()?;
edit_file(&editor, &path)?;
@@ -2506,11 +2518,7 @@ impl RequestContext {
Ok(())
}
pub async fn load_skill_repl(
&mut self,
name: &str,
abort_signal: AbortSignal,
) -> Result<()> {
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 {})",
@@ -2563,17 +2571,11 @@ impl RequestContext {
Ok(())
}
pub async fn unload_skill_repl(
&mut self,
name: &str,
abort_signal: AbortSignal,
) -> Result<()> {
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}"
);
eprintln!("Warning: unloaded skill '{name}' but tool scope refresh failed: {e}");
}
println!("✓ Unloaded skill '{name}'.");
@@ -3534,7 +3536,8 @@ mod tests {
let input = Input::from_str(&ctx, "hello", None);
let app = Arc::clone(&ctx.app.config);
ctx.after_chat_completion(app.as_ref(), &input, "response", &[]).unwrap();
ctx.after_chat_completion(app.as_ref(), &input, "response", &[])
.unwrap();
assert!(!ctx.skill_registry.is_loaded("ephemeral"));
assert!(ctx.skill_registry.is_loaded("persistent"));
@@ -3556,11 +3559,10 @@ mod tests {
let input = Input::from_str(&ctx, "hello", None);
let app = Arc::clone(&ctx.app.config);
let tool_result = ToolResult::new(
crate::function::ToolCall::default(),
serde_json::json!({}),
);
ctx.after_chat_completion(app.as_ref(), &input, "", &[tool_result]).unwrap();
let tool_result =
ToolResult::new(crate::function::ToolCall::default(), serde_json::json!({}));
ctx.after_chat_completion(app.as_ref(), &input, "", &[tool_result])
.unwrap();
assert!(
ctx.skill_registry.is_loaded("ephemeral"),
+1 -3
View File
@@ -103,9 +103,7 @@ impl Role {
role.enabled_mcp_servers = value.as_str().map(|v| v.to_string())
}
"skills_enabled" => role.skills_enabled = value.as_bool(),
"enabled_skills" => {
role.enabled_skills = value.as_str().map(|v| v.to_string())
}
"enabled_skills" => role.enabled_skills = value.as_str().map(|v| v.to_string()),
"auto_continue" => role.auto_continue = value.as_bool(),
"max_auto_continues" => {
role.max_auto_continues = value.as_u64().map(|v| v as usize)
+7 -10
View File
@@ -2,11 +2,11 @@ use super::*;
use anyhow::Result;
use fancy_regex::Regex;
use log::{debug, info};
use rust_embed::Embed;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::LazyLock;
use log::{debug, info};
#[derive(Embed)]
#[folder = "assets/skills/"]
@@ -93,9 +93,8 @@ impl Skill {
for file in SkillsAsset::iter() {
debug!("Processing skill file: {}", file.as_ref());
let embedded_file = SkillsAsset::get(&file).ok_or_else(|| {
anyhow!("Failed to load embedded skill file: {}", file.as_ref())
})?;
let embedded_file = SkillsAsset::get(&file)
.ok_or_else(|| anyhow!("Failed to load embedded skill file: {}", file.as_ref()))?;
let content = unsafe { std::str::from_utf8_unchecked(&embedded_file.data) };
let file_path = paths::skills_dir().join(file.as_ref());
@@ -118,9 +117,8 @@ impl Skill {
pub fn load(name: &str) -> Result<Self> {
let path = paths::skill_file(name);
let content = read_to_string(&path).with_context(|| {
format!("Failed to read skill '{name}' at {}", path.display())
})?;
let content = read_to_string(&path)
.with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
Ok(Skill::new(name, &content))
}
@@ -152,7 +150,7 @@ impl Skill {
if self.declares_tools() && !function_calling_enabled {
return false;
}
if self.declares_mcp_servers() && !mcp_enabled {
return false;
}
@@ -307,8 +305,7 @@ mod tests {
#[test]
fn is_compatible_requires_both_when_both_declared() {
let content =
"---\nenabled_tools: shell\nenabled_mcp_servers: github\n---\nbody";
let content = "---\nenabled_tools: shell\nenabled_mcp_servers: github\n---\nbody";
let skill = Skill::new("test", content);
+25 -75
View File
@@ -145,15 +145,9 @@ mod tests {
fn defaults_yield_skills_enabled_with_empty_universe() {
let global = AppConfig::default();
let policy = SkillPolicy::effective_with(
&global,
None,
None,
None,
&always_true,
&empty_installed,
)
.unwrap();
let policy =
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
.unwrap();
assert!(policy.skills_enabled);
assert!(policy.enabled.is_empty());
@@ -177,15 +171,9 @@ mod tests {
fn falls_back_to_visible_when_visible_set_but_no_enabled() {
let global = make_app_config(true, None, Some(&["alpha", "beta"]));
let policy = SkillPolicy::effective_with(
&global,
None,
None,
None,
&always_true,
&empty_installed,
)
.unwrap();
let policy =
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
.unwrap();
assert_eq!(policy.enabled.len(), 2);
assert!(policy.enabled.contains("alpha"));
@@ -196,15 +184,9 @@ mod tests {
fn global_enabled_skills_is_effective_when_no_other_levels() {
let global = make_app_config(true, Some("alpha,beta"), Some(&["alpha", "beta", "gamma"]));
let policy = SkillPolicy::effective_with(
&global,
None,
None,
None,
&always_true,
&empty_installed,
)
.unwrap();
let policy =
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
.unwrap();
assert!(policy.enabled.contains("alpha"));
assert!(policy.enabled.contains("beta"));
@@ -214,10 +196,7 @@ mod tests {
#[test]
fn role_overrides_global_enabled_skills() {
let global = make_app_config(true, Some("alpha"), Some(&["alpha", "beta"]));
let role = Role::new(
"test",
"---\nenabled_skills: beta\n---\nbody",
);
let role = Role::new("test", "---\nenabled_skills: beta\n---\nbody");
let policy = SkillPolicy::effective_with(
&global,
@@ -258,14 +237,9 @@ mod tests {
..AppConfig::default()
};
let policy = SkillPolicy::effective_with(
&global,
None,
None,
None,
&always_true,
&|| vec!["alpha".to_string()],
)
let policy = SkillPolicy::effective_with(&global, None, None, None, &always_true, &|| {
vec!["alpha".to_string()]
})
.unwrap();
assert!(!policy.allows("alpha"));
@@ -275,15 +249,9 @@ mod tests {
fn allows_returns_true_when_skill_in_enabled_set() {
let global = make_app_config(true, Some("alpha"), None);
let policy = SkillPolicy::effective_with(
&global,
None,
None,
None,
&always_true,
&empty_installed,
)
.unwrap();
let policy =
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
.unwrap();
assert!(policy.allows("alpha"));
assert!(!policy.allows("beta"));
@@ -293,15 +261,9 @@ mod tests {
fn validation_rejects_uninstalled_skill_reference() {
let global = make_app_config(true, Some("ghost"), None);
let err = SkillPolicy::effective_with(
&global,
None,
None,
None,
&|_| false,
&empty_installed,
)
.unwrap_err();
let err =
SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed)
.unwrap_err();
assert!(err.to_string().contains("not installed"));
assert!(err.to_string().contains("ghost"));
@@ -311,15 +273,9 @@ mod tests {
fn validation_rejects_skill_not_in_visible_set() {
let global = make_app_config(true, Some("beta"), Some(&["alpha"]));
let err = SkillPolicy::effective_with(
&global,
None,
None,
None,
&always_true,
&empty_installed,
)
.unwrap_err();
let err =
SkillPolicy::effective_with(&global, None, None, None, &always_true, &empty_installed)
.unwrap_err();
assert!(err.to_string().contains("not in visible_skills"));
assert!(err.to_string().contains("beta"));
@@ -329,15 +285,9 @@ mod tests {
fn validation_skipped_when_no_explicit_enabled_skills() {
let global = make_app_config(true, None, None);
let policy = SkillPolicy::effective_with(
&global,
None,
None,
None,
&|_| false,
&empty_installed,
)
.unwrap();
let policy =
SkillPolicy::effective_with(&global, None, None, None, &|_| false, &empty_installed)
.unwrap();
assert!(policy.enabled.is_empty());
}
+20 -46
View File
@@ -10,24 +10,7 @@ pub struct SkillRegistry {
loaded: IndexMap<String, Skill>,
}
#[allow(dead_code)]
impl SkillRegistry {
pub fn new() -> Self {
Self {
loaded: IndexMap::new(),
}
}
pub fn load(&mut self, name: &str) -> Result<()> {
if self.loaded.contains_key(name) {
bail!("Skill '{name}' is already loaded");
}
let skill = Skill::load(name)?;
self.loaded.insert(name.to_string(), skill);
Ok(())
}
pub fn insert(&mut self, skill: Skill) -> Result<()> {
let name = skill.name().to_string();
@@ -93,7 +76,11 @@ impl SkillRegistry {
tools.extend(parse_csv(skill.enabled_tools()));
mcps.extend(parse_csv(skill.enabled_mcp_servers()));
if !skip_body && !skill.body().is_empty() {
let separator = if effective.is_empty_prompt() { "" } else { "\n\n" };
let separator = if effective.is_empty_prompt() {
""
} else {
"\n\n"
};
effective.append_to_prompt(separator);
effective.append_to_prompt(skill.body());
}
@@ -151,7 +138,7 @@ mod tests {
#[test]
fn empty_registry_returns_base_clone() {
let base = Role::new("test", "You are a helper");
let registry = SkillRegistry::new();
let registry = SkillRegistry::default();
let effective = registry.effective_role(&base);
@@ -160,7 +147,7 @@ mod tests {
#[test]
fn one_skill_appends_body_after_base_with_separator() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("git-master", "description: D", "Git knowledge"));
let base = Role::new("test", "You are a helper");
@@ -171,7 +158,7 @@ mod tests {
#[test]
fn two_skills_compose_bodies_in_insertion_order() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("a", "", "Alpha body"));
registry.insert_for_test(make_skill("b", "", "Beta body"));
@@ -183,7 +170,7 @@ mod tests {
#[test]
fn empty_base_prompt_omits_leading_separator() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("a", "", "Alpha"));
registry.insert_for_test(make_skill("b", "", "Beta"));
@@ -195,7 +182,7 @@ mod tests {
#[test]
fn embedded_prompt_base_skips_body_composition() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill(
"git-master",
"enabled_tools: shell",
@@ -212,7 +199,7 @@ mod tests {
#[test]
fn skills_with_empty_body_do_not_inject_separator() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("knowledge", "enabled_tools: fs", ""));
let base = Role::new("test", "Base");
@@ -223,7 +210,7 @@ mod tests {
#[test]
fn tools_and_mcps_are_unioned_and_deduplicated() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill(
"a",
"enabled_tools: shell,fs\nenabled_mcp_servers: github",
@@ -242,10 +229,7 @@ mod tests {
let tools_str = effective.enabled_tools().unwrap();
let tools: BTreeSet<&str> = tools_str.split(',').collect();
assert_eq!(
tools,
BTreeSet::from(["fs", "git", "shell", "web_search"])
);
assert_eq!(tools, BTreeSet::from(["fs", "git", "shell", "web_search"]));
let mcps_str = effective.enabled_mcp_servers().unwrap();
let mcps: BTreeSet<&str> = mcps_str.split(',').collect();
@@ -254,7 +238,7 @@ mod tests {
#[test]
fn no_skill_tool_contributions_preserves_base_none() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge"));
let base = Role::new("test", "Base");
@@ -266,7 +250,7 @@ mod tests {
#[test]
fn base_some_empty_tools_is_preserved() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("knowledge", "", "Pure knowledge"));
let mut base = Role::new("test", "Base");
@@ -276,19 +260,9 @@ mod tests {
assert_eq!(effective.enabled_tools().as_deref(), Some(""));
}
#[test]
fn load_already_loaded_returns_error() {
let mut registry = SkillRegistry::new();
registry.insert_for_test(make_skill("git-master", "", "body"));
let err = registry.load("git-master").unwrap_err();
assert!(err.to_string().contains("already loaded"));
}
#[test]
fn unload_not_loaded_returns_error() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
let err = registry.unload("missing").unwrap_err();
@@ -297,7 +271,7 @@ mod tests {
#[test]
fn unload_existing_succeeds_and_removes() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("git-master", "", "body"));
assert!(registry.is_loaded("git-master"));
@@ -307,7 +281,7 @@ mod tests {
#[test]
fn loaded_names_returns_insertion_order() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("zulu", "", "body"));
registry.insert_for_test(make_skill("alpha", "", "body"));
@@ -321,7 +295,7 @@ mod tests {
#[test]
fn sweep_removes_only_auto_unload_skills() {
let mut registry = SkillRegistry::new();
let mut registry = SkillRegistry::default();
registry.insert_for_test(make_skill("ephemeral", "auto_unload: true", "body"));
registry.insert_for_test(make_skill("persistent", "", "body"));
@@ -333,7 +307,7 @@ mod tests {
#[test]
fn is_loaded_returns_false_for_unknown() {
let registry = SkillRegistry::new();
let registry = SkillRegistry::default();
assert!(!registry.is_loaded("nothing"));
}
+1 -1
View File
@@ -22,6 +22,7 @@ use indoc::formatdoc;
use rust_embed::Embed;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use skill::SKILL_FUNCTION_PREFIX;
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::fs::File;
@@ -33,7 +34,6 @@ use std::{
process::{Command, Stdio},
};
use strum_macros::AsRefStr;
use skill::SKILL_FUNCTION_PREFIX;
use supervisor::SUPERVISOR_FUNCTION_PREFIX;
use todo::TODO_FUNCTION_PREFIX;
use user_interaction::USER_FUNCTION_PREFIX;
+26 -20
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; 43]> = LazyLock::new(|| {
static REPL_COMMANDS: LazyLock<[ReplCommand; 44]> = LazyLock::new(|| {
[
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
ReplCommand::new(".info", "Show system info", AssertState::pass()),
@@ -193,7 +193,12 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 43]> = LazyLock::new(|| {
ReplCommand::new(".macro", "Execute a macro", AssertState::pass()),
ReplCommand::new(
".skill",
"List, load, unload, edit, or create skills",
"List, load, unload, or create skills",
AssertState::pass(),
),
ReplCommand::new(
".edit skill",
"Modify an existing skill by name",
AssertState::pass(),
),
ReplCommand::new(
@@ -529,8 +534,8 @@ pub async fn run_repl_command(
.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"#
.skill <name> # Open the skill in $EDITOR; create with a scaffold if missing
# (Use `.edit skill <name>` to edit an existing skill without the create-if-missing behavior.)"#
),
"loaded" => ctx.list_loaded_skills(),
"load" => {
@@ -547,19 +552,6 @@ pub async fn run_repl_command(
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)?;
@@ -712,9 +704,23 @@ pub async fn run_repl_command(
Some("mcp-config") => {
ctx.edit_mcp_config()?;
}
Some(s) if s == "skill" || s.starts_with("skill ") => {
let name = s.strip_prefix("skill").unwrap_or("").trim();
if name.is_empty() {
println!("Usage: .edit skill <name>");
} else if !paths::has_skill(name) {
bail!(
"Skill '{name}' is not installed (expected at {})",
paths::skill_file(name).display()
);
} else {
let app = Arc::clone(&ctx.app.config);
ctx.upsert_skill(app.as_ref(), name)?;
}
}
_ => {
println!(
r#"Usage: .edit <config|mcp-config|role|session|rag-docs|agent-config>"#
r#"Usage: .edit <config|mcp-config|role|session|rag-docs|agent-config|skill <name>>"#
)
}
}
@@ -1318,8 +1324,8 @@ mod tests {
}
#[test]
fn repl_commands_has_43_entries() {
assert_eq!(REPL_COMMANDS.len(), 43);
fn repl_commands_has_44_entries() {
assert_eq!(REPL_COMMANDS.len(), 44);
}
#[test]