diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 78c374db33..5c3b9aadd2 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -29,6 +29,7 @@ use crate::commands::schedule::{ handle_schedule_sessions, }; use crate::commands::session::{handle_session_list, handle_session_remove}; +use crate::commands::skills::handle_skills_list; use crate::recipes::extract_from_cli::extract_recipe_info_from_cli; use crate::recipes::recipe::{explain_recipe, render_recipe_as_yaml}; use crate::session::{build_session, SessionBuilderConfig}; @@ -699,6 +700,13 @@ enum PluginCommand { }, } +#[derive(Subcommand)] +enum SkillsCommand { + /// List all skills available to the goose agent + #[command(about = "List all skills available to the goose agent")] + List, +} + #[derive(Subcommand)] enum RecipeCommand { /// Validate a recipe file @@ -910,6 +918,13 @@ enum Command { command: RecipeCommand, }, + /// Skill utilities + #[command(about = "Skill utilities")] + Skills { + #[command(subcommand)] + command: SkillsCommand, + }, + /// Manage plugins #[command(about = "Manage plugins")] Plugin { @@ -1273,6 +1288,7 @@ fn get_command_name(command: &Option) -> &'static str { #[cfg(feature = "update")] Some(Command::Update { .. }) => "update", Some(Command::Recipe { .. }) => "recipe", + Some(Command::Skills { .. }) => "skills", Some(Command::Plugin { .. }) => "plugin", Some(Command::Term { .. }) => "term", Some(Command::Tui { .. }) => "tui", @@ -1799,6 +1815,12 @@ fn handle_recipe_subcommand(command: RecipeCommand) -> Result<()> { } } +async fn handle_skills_subcommand(command: SkillsCommand) -> Result<()> { + match command { + SkillsCommand::List => handle_skills_list().await, + } +} + async fn handle_term_subcommand(command: TermCommand) -> Result<()> { match command { TermCommand::Init { @@ -2129,6 +2151,7 @@ pub async fn cli() -> anyhow::Result<()> { Ok(()) } Some(Command::Recipe { command }) => handle_recipe_subcommand(command), + Some(Command::Skills { command }) => handle_skills_subcommand(command).await, Some(Command::Plugin { command }) => handle_plugin_subcommand(command), Some(Command::Term { command }) => handle_term_subcommand(command).await, Some(Command::Tui { args }) => crate::commands::tui::handle_tui(args), diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 09102bc72c..ef42795b3b 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod recipe; pub mod review; pub mod schedule; pub mod session; +pub mod skills; pub mod term; pub mod tui; #[cfg(feature = "update")] diff --git a/crates/goose-cli/src/commands/skills.rs b/crates/goose-cli/src/commands/skills.rs new file mode 100644 index 0000000000..59737c13ff --- /dev/null +++ b/crates/goose-cli/src/commands/skills.rs @@ -0,0 +1,228 @@ +use anyhow::Result; +use console::{measure_text_width, Term}; +use goose::skills::list_installed_skills; +use goose::token_counter::create_token_counter; + +const DESCRIPTION_PREVIEW_CHARS: usize = 50; +const SEPARATOR: &str = " | "; +const MIN_DESCRIPTION_WIDTH: usize = 4; +const MIN_LOCATION_WIDTH: usize = 4; +const NAME_HEADER: &str = "Name"; +const DESCRIPTION_HEADER: &str = "Description"; +const DESCRIPTION_TOKENS_HEADER: &str = "Description tokens"; +const CONTENT_TOKENS_HEADER: &str = "Content tokens"; +const LOCATION_HEADER: &str = "Location"; + +struct SkillRow { + name: String, + description: String, + description_tokens: String, + content_tokens: String, + location: String, +} + +struct ColumnWidths { + name: usize, + description: usize, + description_tokens: usize, + content_tokens: usize, + location: usize, +} + +pub async fn handle_skills_list() -> Result<()> { + let cwd = std::env::current_dir()?; + let terminal_width = terminal_width(); + let token_counter = create_token_counter().await.map_err(anyhow::Error::msg)?; + let mut skills = list_installed_skills(Some(&cwd)); + skills.sort_by(|a, b| a.name.cmp(&b.name)); + + let rows = skills + .iter() + .map(|skill| SkillRow { + name: skill.name.clone(), + description: description_preview(&skill.description), + description_tokens: token_counter.count_tokens(&skill.description).to_string(), + content_tokens: token_counter.count_tokens(&skill.content).to_string(), + location: skill.path.clone(), + }) + .collect::>(); + let widths = column_widths(&rows, terminal_width); + + println!("{}", header_line(&widths, terminal_width)); + for row in rows { + println!("{}", skill_line(&row, &widths, terminal_width)); + } + + Ok(()) +} + +fn terminal_width() -> Option { + Term::stdout() + .size_checked() + .map(|(_height, width)| width as usize) +} + +fn column_widths(rows: &[SkillRow], max_display_width: Option) -> ColumnWidths { + let description_tokens = max_width( + DESCRIPTION_TOKENS_HEADER, + rows.iter().map(|row| row.description_tokens.as_str()), + ); + let content_tokens = max_width( + CONTENT_TOKENS_HEADER, + rows.iter().map(|row| row.content_tokens.as_str()), + ); + let longest_name = max_width(NAME_HEADER, rows.iter().map(|row| row.name.as_str())); + let description = max_width( + DESCRIPTION_HEADER, + rows.iter().map(|row| row.description.as_str()), + ); + let location = max_width( + LOCATION_HEADER, + rows.iter().map(|row| row.location.as_str()), + ); + + let Some(width) = max_display_width else { + return ColumnWidths { + name: longest_name, + description, + description_tokens, + content_tokens, + location, + }; + }; + + let separator_width = measure_text_width(SEPARATOR) * 4; + let available_width = width.saturating_sub(separator_width); + let dynamic_width = available_width.saturating_sub(description_tokens + content_tokens); + + let name = + longest_name.min(dynamic_width.saturating_sub(MIN_DESCRIPTION_WIDTH + MIN_LOCATION_WIDTH)); + let remaining_after_name = dynamic_width.saturating_sub(name); + let description = description.min(remaining_after_name.saturating_sub(MIN_LOCATION_WIDTH)); + let remaining_after_description = remaining_after_name.saturating_sub(description); + let location = location.min(remaining_after_description); + + ColumnWidths { + name, + description, + description_tokens, + content_tokens, + location, + } +} + +fn max_width<'a>(header: &str, values: impl Iterator) -> usize { + values + .map(measure_text_width) + .chain(std::iter::once(measure_text_width(header))) + .max() + .unwrap_or(0) +} + +fn header_line(widths: &ColumnWidths, max_display_width: Option) -> String { + let line = format_line( + NAME_HEADER, + DESCRIPTION_HEADER, + DESCRIPTION_TOKENS_HEADER, + CONTENT_TOKENS_HEADER, + LOCATION_HEADER, + widths, + ); + + match max_display_width { + Some(width) => truncate_to_display_width(&line, width), + None => line, + } +} + +fn skill_line(row: &SkillRow, widths: &ColumnWidths, max_display_width: Option) -> String { + let line = format_line( + &row.name, + &row.description, + &row.description_tokens, + &row.content_tokens, + &row.location, + widths, + ); + + match max_display_width { + Some(width) => truncate_to_display_width(&line, width), + None => line, + } +} + +fn format_line( + name: &str, + description: &str, + description_tokens: &str, + content_tokens: &str, + location: &str, + widths: &ColumnWidths, +) -> String { + format!( + "{}{}{}{}{}{}{}{}{}", + pad_to_display_width(&truncate_to_display_width(name, widths.name), widths.name), + SEPARATOR, + pad_to_display_width( + &truncate_to_display_width(description, widths.description), + widths.description + ), + SEPARATOR, + pad_to_display_width(description_tokens, widths.description_tokens), + SEPARATOR, + pad_to_display_width(content_tokens, widths.content_tokens), + SEPARATOR, + pad_to_display_width( + &truncate_to_display_width(location, widths.location), + widths.location + ), + ) +} + +fn description_preview(description: &str) -> String { + let normalized = description.split_whitespace().collect::>().join(" "); + truncate_to_chars(&normalized, DESCRIPTION_PREVIEW_CHARS) +} + +fn truncate_to_chars(text: &str, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text.to_string(); + } + + if max_chars <= 3 { + return ".".repeat(max_chars); + } + + let mut output = text.chars().take(max_chars - 3).collect::(); + output.push_str("..."); + output +} + +fn truncate_to_display_width(text: &str, max_width: usize) -> String { + if measure_text_width(text) <= max_width { + return text.to_string(); + } + + if max_width <= 3 { + return ".".repeat(max_width); + } + + let mut output = String::new(); + let suffix_width = measure_text_width("..."); + + for ch in text.chars() { + output.push(ch); + if measure_text_width(&output) + suffix_width > max_width { + output.pop(); + break; + } + } + + output.push_str("..."); + output +} + +fn pad_to_display_width(text: &str, width: usize) -> String { + let padding = width.saturating_sub(measure_text_width(text)); + format!("{}{}", text, " ".repeat(padding)) +}