CLI to list skills with token counts (#9326)

This commit is contained in:
Jack Amadeo
2026-05-28 10:09:11 -04:00
committed by GitHub
parent 27d68ba636
commit d10d009b97
3 changed files with 252 additions and 0 deletions
+23
View File
@@ -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<Command>) -> &'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),
+1
View File
@@ -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")]
+228
View File
@@ -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::<Vec<_>>();
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<usize> {
Term::stdout()
.size_checked()
.map(|(_height, width)| width as usize)
}
fn column_widths(rows: &[SkillRow], max_display_width: Option<usize>) -> 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<Item = &'a str>) -> 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<usize>) -> 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<usize>) -> 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::<Vec<_>>().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::<String>();
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))
}