feat: show installed skills in UI (#7910)

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Vincenzo Palazzo
2026-03-26 21:57:12 +01:00
committed by GitHub
parent f6fdbbdd7e
commit 9feb3f3bca
8 changed files with 547 additions and 18 deletions
@@ -135,6 +135,7 @@ pub enum ConfigValueResponse {
pub enum CommandType {
Builtin,
Recipe,
Skill,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -387,14 +388,23 @@ pub async fn get_provider_models(
}
}
#[derive(Deserialize, utoipa::IntoParams)]
pub struct SlashCommandsQuery {
/// Optional working directory to discover local skills from
pub working_dir: Option<String>,
}
#[utoipa::path(
get,
path = "/config/slash_commands",
params(SlashCommandsQuery),
responses(
(status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse)
)
)]
pub async fn get_slash_commands() -> Result<Json<SlashCommandsResponse>, ErrorResponse> {
pub async fn get_slash_commands(
axum::extract::Query(query): axum::extract::Query<SlashCommandsQuery>,
) -> Result<Json<SlashCommandsResponse>, ErrorResponse> {
let mut commands: Vec<_> = slash_commands::list_commands()
.iter()
.map(|command| SlashCommand {
@@ -412,6 +422,23 @@ pub async fn get_slash_commands() -> Result<Json<SlashCommandsResponse>, ErrorRe
});
}
let working_dir = query.working_dir.map(std::path::PathBuf::from);
for source in
goose::agents::platform_extensions::summon::list_installed_sources(working_dir.as_deref())
{
if matches!(
source.kind,
goose::agents::platform_extensions::summon::SourceKind::Skill
| goose::agents::platform_extensions::summon::SourceKind::BuiltinSkill
) {
commands.push(SlashCommand {
command: source.name,
help: source.description,
command_type: CommandType::Skill,
});
}
}
Ok(Json(SlashCommandsResponse { commands }))
}
@@ -33,6 +33,10 @@ static COMMANDS: &[CommandDef] = &[
name: "clear",
description: "Clear the conversation history",
},
CommandDef {
name: "skills",
description: "List installed skills and other available sources",
},
];
pub fn list_commands() -> &'static [CommandDef] {
@@ -72,6 +76,7 @@ impl Agent {
"prompt" => self.handle_prompt_command(&params, session_id).await,
"compact" => self.handle_compact_command(session_id).await,
"clear" => self.handle_clear_command(session_id).await,
"skills" => self.handle_skills_command(session_id).await,
_ => {
self.handle_recipe_command(command, params_str, session_id)
.await
@@ -129,6 +134,49 @@ impl Agent {
)))
}
async fn handle_skills_command(&self, session_id: &str) -> Result<Option<Message>> {
use super::platform_extensions::summon::{list_installed_sources, SourceKind};
let working_dir = self
.config
.session_manager
.get_session(session_id, false)
.await
.ok()
.map(|s| s.working_dir);
let sources = list_installed_sources(working_dir.as_deref());
let skills: Vec<_> = sources
.iter()
.filter(|s| matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill))
.collect();
let mut output = String::new();
if skills.is_empty() {
output.push_str("No skills installed.\n\n");
output.push_str("Skills are loaded from SKILL.md files in:\n");
output.push_str(" - ~/.claude/skills/\n");
output.push_str(" - ~/.config/agents/skills/\n");
output.push_str(" - .goose/skills/ (in current directory)\n");
output.push_str(" - .claude/skills/ (in current directory)\n");
output.push_str(" - .agents/skills/ (in current directory)\n");
} else {
output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len()));
for skill in &skills {
let kind_label = if skill.kind == SourceKind::BuiltinSkill {
" *(builtin)*"
} else {
""
};
output.push_str(&format!(
"- **{}**{}: {}\n",
skill.name, kind_label, skill.description
));
}
}
Ok(Some(Message::assistant().with_text(output)))
}
async fn handle_prompts_command(
&self,
params: &[&str],
@@ -390,6 +390,16 @@ fn scan_agents_from_dir(
}
}
/// Returns all discovered sources (skills, recipes, agents) from the given working directory.
/// If no directory is provided, falls back to `std::env::current_dir()`.
/// This is useful for listing what's available without needing a full SummonClient instance.
pub fn list_installed_sources(working_dir: Option<&Path>) -> Vec<Source> {
let dir = working_dir
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
discover_filesystem_sources(&dir)
}
fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
let mut sources: Vec<Source> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
+417 -3
View File
@@ -10,6 +10,8 @@ const TELEGRAM_API_BASE: &str = "https://api.telegram.org";
const POLL_TIMEOUT_SECS: u64 = 30;
const MAX_MESSAGE_LENGTH: usize = 4096;
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
/// Maximum voice file size we'll attempt to download (20 MB, Telegram's bot API limit).
const MAX_VOICE_FILE_SIZE: i64 = 20 * 1024 * 1024;
pub struct TelegramGateway {
bot_token: String,
@@ -28,6 +30,45 @@ struct TelegramMessage {
from: Option<TelegramUser>,
chat: TelegramChat,
text: Option<String>,
voice: Option<TelegramVoice>,
audio: Option<TelegramAudio>,
}
#[derive(Debug, Deserialize)]
struct TelegramVoice {
file_id: String,
#[allow(dead_code)]
duration: Option<i32>,
#[allow(dead_code)]
mime_type: Option<String>,
file_size: Option<i64>,
}
/// Audio files sent as documents (not inline voice notes).
#[derive(Debug, Deserialize)]
struct TelegramAudio {
file_id: String,
#[allow(dead_code)]
duration: Option<i32>,
#[allow(dead_code)]
mime_type: Option<String>,
file_size: Option<i64>,
}
/// Metadata extracted from a Telegram voice note or audio attachment.
struct VoiceInfo<'a> {
file_id: &'a str,
file_size: Option<i64>,
duration: Option<i32>,
mime_type: Option<&'a str>,
}
/// Response from the Telegram `getFile` API.
#[derive(Debug, Deserialize)]
struct TelegramFile {
#[allow(dead_code)]
file_id: String,
file_path: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -146,6 +187,137 @@ impl TelegramGateway {
Ok(())
}
/// Download a file from Telegram by its `file_id`.
///
/// This is a two-step process:
/// 1. Call `getFile` to obtain the server-side `file_path`.
/// 2. Fetch the raw bytes from `https://api.telegram.org/file/bot<TOKEN>/<file_path>`.
async fn download_file(&self, file_id: &str) -> anyhow::Result<Vec<u8>> {
// Step 1 resolve file_id → file_path
let resp: TelegramResponse<TelegramFile> = self
.client
.post(self.api_url("getFile"))
.json(&serde_json::json!({ "file_id": file_id }))
.send()
.await?
.json()
.await?;
let tg_file = resp.result.ok_or_else(|| {
anyhow::anyhow!(
"Telegram getFile error: {}",
resp.description.unwrap_or_default()
)
})?;
let file_path = tg_file
.file_path
.ok_or_else(|| anyhow::anyhow!("Telegram getFile returned no file_path"))?;
// Step 2 download raw bytes
let download_url = format!(
"{}/file/bot{}/{}",
TELEGRAM_API_BASE, self.bot_token, file_path
);
let bytes = self.client.get(&download_url).send().await?.bytes().await?;
Ok(bytes.to_vec())
}
/// Save voice bytes to a temporary file and return the path.
///
/// Files are stored under `<tmp>/goose_voice/voice_<uuid>.<ext>` so Goose
/// can access them via its shell tools. The extension is derived from the
/// MIME type when available, falling back to `.ogg` for voice notes.
///
/// On Unix the directory is created with mode `0700` and files with `0600`
/// so other local users cannot read private voice content.
fn save_voice_file(
bytes: &[u8],
mime_type: Option<&str>,
) -> anyhow::Result<std::path::PathBuf> {
let dir = std::env::temp_dir().join("goose_voice");
std::fs::create_dir_all(&dir)?;
// Restrict directory permissions to owner-only on Unix.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
}
let ext = mime_type
.and_then(|m| m.rsplit('/').next())
.map(|sub| {
// Normalise common MIME sub-types to file extensions.
match sub {
"mpeg" => "mp3",
"mp4" | "x-m4a" => "m4a",
"ogg" => "ogg",
"wav" | "x-wav" => "wav",
other => other,
}
})
.unwrap_or("ogg");
let filename = format!("voice_{}.{ext}", uuid::Uuid::new_v4());
let path = dir.join(filename);
std::fs::write(&path, bytes)?;
// Restrict file permissions to owner-only on Unix.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(path)
}
/// Build the text prompt that tells Goose about a voice message file.
fn voice_prompt(
path: &std::path::Path,
duration: Option<i32>,
mime_type: Option<&str>,
) -> String {
let duration_hint = duration
.map(|d| format!(" (duration: {d}s)"))
.unwrap_or_default();
let format_hint = mime_type
.map(|m| format!(" The file format is {m}."))
.unwrap_or_default();
format!(
"The user sent a voice message{duration_hint}. \
The audio file is saved at: {}{format_hint}\n\n\
Please transcribe this audio file using available command-line tools \
(e.g. whisper, ffmpeg, sox, or any STT utility you can find on this system) \
and then respond to what the user said. \
If no transcription tool is available, let the user know and ask them to type their message instead.",
path.display()
)
}
/// Extract metadata from either a voice note or an audio attachment.
/// Returns `None` when neither is present.
fn voice_info(msg: &TelegramMessage) -> Option<VoiceInfo<'_>> {
if let Some(ref v) = msg.voice {
return Some(VoiceInfo {
file_id: &v.file_id,
file_size: v.file_size,
duration: v.duration,
mime_type: v.mime_type.as_deref(),
});
}
if let Some(ref a) = msg.audio {
return Some(VoiceInfo {
file_id: &a.file_id,
file_size: a.file_size,
duration: a.duration,
mime_type: a.mime_type.as_deref(),
});
}
None
}
fn to_platform_user(tg_msg: &TelegramMessage) -> PlatformUser {
PlatformUser {
platform: "telegram".to_string(),
@@ -177,6 +349,21 @@ impl Gateway for TelegramGateway {
tracing::info!("Telegram gateway starting long-poll loop");
// Spawn a background task that periodically removes stale voice files
// (older than 1 hour) so they don't accumulate on disk.
let cleanup_cancel = cancel.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(600));
loop {
tokio::select! {
_ = cleanup_cancel.cancelled() => break,
_ = interval.tick() => {
cleanup_voice_files(std::time::Duration::from_secs(3600));
}
}
}
});
loop {
tokio::select! {
_ = cancel.cancelled() => {
@@ -192,9 +379,46 @@ impl Gateway for TelegramGateway {
let Some(tg_msg) = update.message else {
continue;
};
let text = match tg_msg.text {
Some(ref t) => t.clone(),
None => continue,
// Determine the text to send to the handler.
// Voice/audio messages are downloaded, saved to
// disk, and converted into a prompt that asks
// Goose to transcribe the file using CLI tools.
let text = if let Some(voice) = Self::voice_info(&tg_msg) {
// Reject files that exceed the Telegram bot
// download limit.
if voice.file_size.unwrap_or(0) > MAX_VOICE_FILE_SIZE {
tracing::warn!(
file_size = voice.file_size,
"voice file exceeds size limit, skipping"
);
continue;
}
match self.download_file(voice.file_id).await {
Ok(bytes) => match Self::save_voice_file(&bytes, voice.mime_type) {
Ok(path) => Self::voice_prompt(&path, voice.duration, voice.mime_type),
Err(e) => {
tracing::error!(
error = %e,
"failed to save voice file"
);
continue;
}
},
Err(e) => {
tracing::error!(
error = %e,
"failed to download voice file from Telegram"
);
continue;
}
}
} else if let Some(ref t) = tg_msg.text {
t.clone()
} else {
// Neither text nor voice — skip.
continue;
};
let user = Self::to_platform_user(&tg_msg);
@@ -273,6 +497,29 @@ impl Gateway for TelegramGateway {
}
}
/// Remove voice files from the temp directory that are older than `max_age`.
fn cleanup_voice_files(max_age: std::time::Duration) {
let dir = std::env::temp_dir().join("goose_voice");
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
let cutoff = std::time::SystemTime::now() - max_age;
let mut removed = 0u32;
for entry in entries.flatten() {
let dominated = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.is_some_and(|t| t < cutoff);
if dominated && std::fs::remove_file(entry.path()).is_ok() {
removed += 1;
}
}
if removed > 0 {
tracing::debug!(removed, "cleaned up stale voice files");
}
}
#[allow(clippy::string_slice)]
fn split_message(text: &str, max_len: usize) -> Vec<String> {
if text.len() <= max_len {
@@ -384,6 +631,173 @@ mod tests {
assert_eq!(chunks[1].chars().count(), 1);
}
#[test]
fn voice_info_from_voice_message() {
let msg = TelegramMessage {
message_id: 1,
from: None,
chat: TelegramChat {
id: 123,
chat_type: "private".into(),
},
text: None,
voice: Some(TelegramVoice {
file_id: "voice_file_123".into(),
duration: Some(5),
mime_type: Some("audio/ogg".into()),
file_size: Some(10000),
}),
audio: None,
};
let info = TelegramGateway::voice_info(&msg);
assert!(info.is_some());
let v = info.unwrap();
assert_eq!(v.file_id, "voice_file_123");
assert_eq!(v.file_size, Some(10000));
assert_eq!(v.duration, Some(5));
assert_eq!(v.mime_type, Some("audio/ogg"));
}
#[test]
fn voice_info_from_audio_message() {
let msg = TelegramMessage {
message_id: 1,
from: None,
chat: TelegramChat {
id: 123,
chat_type: "private".into(),
},
text: None,
voice: None,
audio: Some(TelegramAudio {
file_id: "audio_file_456".into(),
duration: Some(120),
mime_type: Some("audio/mpeg".into()),
file_size: Some(500_000),
}),
};
let info = TelegramGateway::voice_info(&msg);
assert!(info.is_some());
let v = info.unwrap();
assert_eq!(v.file_id, "audio_file_456");
assert_eq!(v.duration, Some(120));
assert_eq!(v.mime_type, Some("audio/mpeg"));
}
#[test]
fn voice_info_none_for_text() {
let msg = TelegramMessage {
message_id: 1,
from: None,
chat: TelegramChat {
id: 123,
chat_type: "private".into(),
},
text: Some("hello".into()),
voice: None,
audio: None,
};
assert!(TelegramGateway::voice_info(&msg).is_none());
}
#[test]
fn voice_prefers_voice_over_audio() {
let msg = TelegramMessage {
message_id: 1,
from: None,
chat: TelegramChat {
id: 123,
chat_type: "private".into(),
},
text: None,
voice: Some(TelegramVoice {
file_id: "voice_wins".into(),
duration: Some(3),
mime_type: None,
file_size: None,
}),
audio: Some(TelegramAudio {
file_id: "audio_loses".into(),
duration: Some(60),
mime_type: None,
file_size: None,
}),
};
let v = TelegramGateway::voice_info(&msg).unwrap();
assert_eq!(v.file_id, "voice_wins");
}
#[test]
fn voice_prompt_includes_path_and_duration() {
let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.ogg");
let prompt = TelegramGateway::voice_prompt(&path, Some(10), Some("audio/ogg"));
assert!(prompt.contains("/tmp/goose_voice/voice_test.ogg"));
assert!(prompt.contains("(duration: 10s)"));
assert!(prompt.contains("audio/ogg"));
assert!(prompt.contains("transcribe"));
}
#[test]
fn voice_prompt_without_duration() {
let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.ogg");
let prompt = TelegramGateway::voice_prompt(&path, None, None);
assert!(!prompt.contains("duration"));
assert!(prompt.contains("/tmp/goose_voice/voice_test.ogg"));
}
#[test]
fn voice_prompt_with_mp3_mime() {
let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.mp3");
let prompt = TelegramGateway::voice_prompt(&path, Some(60), Some("audio/mpeg"));
assert!(prompt.contains("audio/mpeg"));
assert!(!prompt.contains("OGG"));
}
#[test]
fn save_voice_file_creates_file_ogg() {
let bytes = b"fake ogg data";
let path = TelegramGateway::save_voice_file(bytes, Some("audio/ogg")).unwrap();
assert!(path.exists());
assert!(path.to_str().unwrap().ends_with(".ogg"));
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_voice_file_creates_file_mp3() {
let bytes = b"fake mp3 data";
let path = TelegramGateway::save_voice_file(bytes, Some("audio/mpeg")).unwrap();
assert!(path.exists());
assert!(path.to_str().unwrap().ends_with(".mp3"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_voice_file_defaults_to_ogg() {
let bytes = b"unknown format";
let path = TelegramGateway::save_voice_file(bytes, None).unwrap();
assert!(path.to_str().unwrap().ends_with(".ogg"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn cleanup_preserves_recent_files() {
let dir = std::env::temp_dir().join("goose_voice");
std::fs::create_dir_all(&dir).unwrap();
let recent_file = dir.join("voice_cleanup_recent_test.ogg");
std::fs::write(&recent_file, b"recent").unwrap();
// With a 1-hour max_age, a just-created file should survive.
cleanup_voice_files(std::time::Duration::from_secs(3600));
assert!(recent_file.exists());
let _ = std::fs::remove_file(&recent_file);
}
#[test]
fn cleanup_handles_missing_dir() {
// Should not panic even when the directory doesn't exist.
cleanup_voice_files(std::time::Duration::from_secs(1));
}
#[test]
fn split_preserves_content() {
let text = format!(
+14 -1
View File
@@ -1569,6 +1569,18 @@
"super::routes::config_management"
],
"operationId": "get_slash_commands",
"parameters": [
{
"name": "working_dir",
"in": "query",
"description": "Optional working directory to discover local skills from",
"required": false,
"schema": {
"type": "string",
"nullable": true
}
}
],
"responses": {
"200": {
"description": "Slash commands retrieved successfully",
@@ -4139,7 +4151,8 @@
"type": "string",
"enum": [
"Builtin",
"Recipe"
"Recipe",
"Skill"
]
},
"ConfigKey": {
+7 -2
View File
@@ -80,7 +80,7 @@ export type CheckProviderRequest = {
provider: string;
};
export type CommandType = 'Builtin' | 'Recipe';
export type CommandType = 'Builtin' | 'Recipe' | 'Skill';
/**
* Configuration key metadata for provider setup
@@ -2837,7 +2837,12 @@ export type SetConfigProviderData = {
export type GetSlashCommandsData = {
body?: never;
path?: never;
query?: never;
query?: {
/**
* Optional working directory to discover local skills from
*/
working_dir?: string | null;
};
url: '/config/slash_commands';
};
+3
View File
@@ -15,6 +15,7 @@ import {
Zap,
BookOpen,
Wrench,
Sparkles,
} from 'lucide-react';
import { DisplayItem } from './MentionPopover';
@@ -33,6 +34,8 @@ export const getItemIcon = (item: DisplayItem): IconInfo => {
return { Icon: Zap, color: '#3b82f6' }; // Blue
case 'Recipe':
return { Icon: BookOpen, color: '#10b981' }; // Green
case 'Skill':
return { Icon: Sparkles, color: '#8b5cf6' }; // Purple
case 'Directory':
return { Icon: Folder, color: '#f59e0b' }; // Amber
default: {
+20 -11
View File
@@ -17,7 +17,8 @@ const typeOrder: Record<DisplayItemType, number> = {
Directory: 0,
File: 1,
Builtin: 2,
Recipe: 3,
Skill: 3,
Recipe: 4,
};
export interface DisplayItem {
@@ -441,6 +442,16 @@ const MentionPopover = forwardRef<
});
}, [items, query, currentWorkingDir]);
const getSelectionText = (item: DisplayItem): string => {
if (item.itemType === 'Skill') {
return `Use the ${item.name} skill to `;
}
if (['Builtin', 'Recipe'].includes(item.itemType)) {
return '/' + item.name;
}
return item.extra;
};
// Expose methods to parent component
useImperativeHandle(
ref,
@@ -448,7 +459,7 @@ const MentionPopover = forwardRef<
getDisplayFiles: () => displayItems,
selectFile: (index: number) => {
if (displayItems[index]) {
onSelect(displayItems[index].extra);
onSelect(getSelectionText(displayItems[index]));
onClose();
}
},
@@ -459,7 +470,10 @@ const MentionPopover = forwardRef<
useEffect(() => {
const loadData = async () => {
if (isSlashCommand) {
const response = await getSlashCommands({ throwOnError: true });
const response = await getSlashCommands({
query: { working_dir: currentWorkingDir },
throwOnError: true,
});
const commandItems: DisplayItem[] = (response.data?.commands || []).map((cmd) => ({
name: cmd.command,
extra: cmd.help,
@@ -475,7 +489,7 @@ const MentionPopover = forwardRef<
if (isOpen) {
loadData();
}
}, [isOpen, isSlashCommand, scanFilesFromRoot]);
}, [isOpen, isSlashCommand, scanFilesFromRoot, currentWorkingDir]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -509,12 +523,7 @@ const MentionPopover = forwardRef<
const handleItemClick = (index: number) => {
if (index >= 0 && index < displayItems.length) {
onSelectedIndexChange(index);
const displayItem = displayItems[index];
onSelect(
['Builtin', 'Recipe'].includes(displayItem.itemType)
? '/' + displayItem.name
: displayItem.extra
);
onSelect(getSelectionText(displayItems[index]));
onClose();
}
};
@@ -551,7 +560,7 @@ const MentionPopover = forwardRef<
>
{displayItems.map((item, index) => (
<div
key={item.extra}
key={`${item.itemType}-${item.name}`}
onClick={() => handleItemClick(index)}
data-selected={index === selectedIndex}
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${