Add /skills command (#8600)

Signed-off-by: Lucas Alvares Gomes <lucasagomes@gmail.com>
Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
Lucas Alvares Gomes 2026-04-22 13:36:39 +01:00 committed by GitHub
parent 501dde5570
commit bdf5c43f3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 146 additions and 1 deletions

View file

@ -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<Pair>)> {
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<String> = skills.iter().map(|s| s.name.clone()).collect();
// Complete the last letter being typed (e.g. "/skills coding in<tab>")
let last = line.rsplit_once(' ').map_or("", |(_, w)| w);
let pos = line.len() - last.len();
let partial = last.to_lowercase();
let candidates: Vec<Pair> = 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<Pair>)> {
// 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![]));
}

View file

@ -27,6 +27,8 @@ pub enum InputResult {
Compact,
ToggleFullToolOutput,
Edit(Option<String>),
ListSkills,
LoadSkills(Vec<String>),
}
#[derive(Debug)]
@ -204,6 +206,7 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
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<InputResult> {
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<String> = 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 [<name>...])
/? 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)
));
}
}

View file

@ -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::<Vec<_>>()
.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() {

View file

@ -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).