diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index e2edbfb124..9b766f36c9 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -121,6 +121,31 @@ impl GooseCompleter { Ok((line.len(), vec![])) } + /// Complete skill names for the /skills command + fn complete_skill_names(&self, line: &str) -> Result<(usize, Vec)> { + use goose::agents::platform_extensions::skills::list_installed_skills; + + let cwd = std::env::current_dir().unwrap_or_default(); + let skills = list_installed_skills(Some(&cwd)); + let skill_names: Vec = skills.iter().map(|s| s.name.clone()).collect(); + + // Complete the last letter being typed (e.g. "/skills coding in") + let last = line.rsplit_once(' ').map_or("", |(_, w)| w); + let pos = line.len() - last.len(); + + let partial = last.to_lowercase(); + let candidates: Vec = skill_names + .iter() + .filter(|name| name.to_lowercase().starts_with(&partial)) + .map(|name| Pair { + display: name.clone(), + replacement: format!("{} ", name), + }) + .collect(); + + Ok((pos, candidates)) + } + /// Complete slash commands fn complete_slash_commands(&self, line: &str) -> Result<(usize, Vec)> { // Define available slash commands @@ -136,6 +161,7 @@ impl GooseCompleter { "/prompt", "/mode", "/recipe", + "/skills", ]; // Find commands that match the prefix @@ -374,6 +400,10 @@ impl Completer for GooseCompleter { return self.complete_mode_flags(line); } + if line.starts_with("/skills ") { + return self.complete_skill_names(line); + } + return Ok((pos, vec![])); } diff --git a/crates/goose-cli/src/session/input.rs b/crates/goose-cli/src/session/input.rs index 8bc067ddb1..2641a77b72 100644 --- a/crates/goose-cli/src/session/input.rs +++ b/crates/goose-cli/src/session/input.rs @@ -27,6 +27,8 @@ pub enum InputResult { Compact, ToggleFullToolOutput, Edit(Option), + ListSkills, + LoadSkills(Vec), } #[derive(Debug)] @@ -204,6 +206,7 @@ fn handle_slash_command(input: &str) -> Option { const CMD_SUMMARIZE_DEPRECATED: &str = "/summarize"; const CMD_EDIT: &str = "/edit"; const CMD_EDIT_WITH_SPACE: &str = "/edit "; + const CMD_SKILLS: &str = "/skills"; match input { "/exit" | "/quit" => Some(InputResult::Exit), @@ -267,6 +270,16 @@ fn handle_slash_command(input: &str) -> Option { s if s == CMD_CLEAR => Some(InputResult::Clear), s if s.starts_with(CMD_RECIPE) => parse_recipe_command(s), s if s == CMD_COMPACT => Some(InputResult::Compact), + // Match "/skills" exactly or "/skills " with args - avoids matching e.g. "/skillsextra" + s if s == CMD_SKILLS || s.starts_with(&format!("{CMD_SKILLS} ")) => { + let args = s.get(CMD_SKILLS.len()..).unwrap_or("").trim(); + if args.is_empty() { + Some(InputResult::ListSkills) + } else { + let names: Vec = args.split_whitespace().map(String::from).collect(); + Some(InputResult::LoadSkills(names)) + } + } s if s == CMD_SUMMARIZE_DEPRECATED => { println!("{}", console::style("⚠️ Note: /summarize has been renamed to /compact and will be removed in a future release.").yellow()); Some(InputResult::Compact) @@ -417,6 +430,7 @@ fn print_help() { /compact - Compact the current conversation to reduce context length while preserving key information. /edit [text] - Open your prompt editor to compose a message. Optionally pre-fill with text. Uses $GOOSE_PROMPT_EDITOR, $VISUAL, or $EDITOR (in that order). +/skills - List available skills or enable skills by name (usage: /skills [...]) /? or /help - Display this help message /clear - Clears the current chat history @@ -747,4 +761,48 @@ mod tests { // Test /editfoo is not a valid command assert!(handle_slash_command("/editfoo").is_none()); } + + #[test] + fn test_skill_command() { + // Test with a single skill name + let Some(InputResult::LoadSkills(names)) = handle_slash_command("/skills coding") else { + panic!( + "Expected LoadSkills, got {:?}", + handle_slash_command("/skills coding") + ); + }; + assert_eq!(names, vec!["coding"]); + + // Test with multiple skill names + let Some(InputResult::LoadSkills(names)) = handle_slash_command("/skills coding insight") + else { + panic!( + "Expected LoadSkills, got {:?}", + handle_slash_command("/skills coding insight") + ); + }; + assert_eq!(names, vec!["coding", "insight"]); + + // Test with extra whitespace + let Some(InputResult::LoadSkills(names)) = handle_slash_command("/skills my-skill ") + else { + panic!( + "Expected LoadSkills, got {:?}", + handle_slash_command("/skills my-skill ") + ); + }; + assert_eq!(names, vec!["my-skill"]); + + // Test with no name: ListSkills + assert!(matches!( + handle_slash_command("/skills"), + Some(InputResult::ListSkills) + )); + + // Test with only whitespace after /skills: ListSkills + assert!(matches!( + handle_slash_command("/skills "), + Some(InputResult::ListSkills) + )); + } } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 535bc54b7a..493c544813 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -666,6 +666,14 @@ impl CliSession { } } } + InputResult::LoadSkills(names) => { + history.save(editor); + self.handle_load_skills(&names).await?; + } + InputResult::ListSkills => { + history.save(editor); + self.handle_list_skills().await?; + } } Ok(()) } @@ -875,6 +883,55 @@ impl CliSession { } } + async fn handle_load_skills(&mut self, names: &[String]) -> Result<()> { + // NOTE: We don't validate the skill names here because the load_skill tool will + // handle that and provide feedback to the user if any skill names are invalid. + let message = format!( + "Use the load_skill tool to load the following skills: {}.", + names + .iter() + .map(|n| format!("\"{}\"", n)) + .collect::>() + .join(", ") + ); + self.push_message(Message::user().with_text(&message)); + output::show_thinking(); + let result = self + .process_agent_response(true, CancellationToken::default()) + .await; + output::hide_thinking(); + result?; + + Ok(()) + } + + async fn handle_list_skills(&mut self) -> Result<()> { + use comfy_table::{presets, Cell, ContentArrangement, Table}; + use goose::agents::platform_extensions::skills::list_installed_skills; + let cwd = std::env::current_dir().unwrap_or_default(); + let skills = list_installed_skills(Some(&cwd)); + + if skills.is_empty() { + println!("{}", console::style("No skills available.").yellow()); + return Ok(()); + } + + let mut table = Table::new(); + table.set_content_arrangement(ContentArrangement::Dynamic); + table.load_preset(presets::ASCII_FULL); + table.set_header(vec!["Skill", "Description"]); + + let mut sorted_skills = skills; + sorted_skills.sort_by(|a, b| a.name.cmp(&b.name)); + + for skill in &sorted_skills { + table.add_row(vec![Cell::new(&skill.name), Cell::new(&skill.description)]); + } + + println!("{table}"); + Ok(()) + } + async fn handle_compact(&mut self) -> Result<()> { let prompt = "Are you sure you want to compact this conversation? This will condense the message history."; let should_summarize = match cliclack::confirm(prompt).initial_value(true).interact() { diff --git a/documentation/docs/guides/context-engineering/using-skills.md b/documentation/docs/guides/context-engineering/using-skills.md index 6c5f80ea7b..948da14ea7 100644 --- a/documentation/docs/guides/context-engineering/using-skills.md +++ b/documentation/docs/guides/context-engineering/using-skills.md @@ -17,7 +17,7 @@ When a session starts, goose adds any skills that it discovers to its instructio - "Follow the new-service skill to set up the auth service" - "Apply the deployment skill" -You can also ask goose what skills are available. +You can also ask goose what skills are available, or use the CLI `/skills` command to list available skills and load one or more by name (e.g. `/skills code-review edge-case-finder`). :::info Claude Compatibility goose skills are compatible with Claude Desktop and other [agents that support Agent Skills](https://agentskills.io/home#adoption).