Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions crates/goose-cli/src/session/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -136,6 +161,7 @@ impl GooseCompleter {
"/prompt",
"/mode",
"/recipe",
"/skills",
];

// Find commands that match the prefix
Expand Down Expand Up @@ -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![]));
}

Expand Down
58 changes: 58 additions & 0 deletions crates/goose-cli/src/session/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub enum InputResult {
Compact,
ToggleFullToolOutput,
Edit(Option<String>),
ListSkills,
LoadSkills(Vec<String>),
}

#[derive(Debug)]
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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))
Comment thread
umago marked this conversation as resolved.
}
}
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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
));
}
}
57 changes: 57 additions & 0 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,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(())
}
Expand Down Expand Up @@ -874,6 +882,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?;
Comment thread
umago marked this conversation as resolved.

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));
Comment thread
umago marked this conversation as resolved.

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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading