From 5e697bd0bb764c3f2635e64139e824463ffe1170 Mon Sep 17 00:00:00 2001 From: sloppa Date: Sat, 8 Aug 2026 02:19:26 -0400 Subject: [PATCH] feat(agent): per-agent skill allowlist + skill selection UI Add a per-agent skill allowlist so each agent starts with zero skills and only explicitly enabled skills are exposed via the load_skill tool. - Add skills_allowlist to Config, parsed from BUZZ_AGENT_SKILLS env - Gate hint discovery by the allowlist (default empty = none) - New Tauri command list_installed_skills: union of all runtime skill dirs (codex, claude, goose, .agents/skills, .buzz/.agents/skills) - SkillSelectionPanel: fixed-height scrollable box at the bottom of the agent runtime tab, on/off Switch per skill, all-off default - Persistence rides the existing spawn env (BUZZ_AGENT_SKILLS) Co-authored-by: sloppa Signed-off-by: sloppa --- crates/buzz-agent/src/config.rs | 26 +++ crates/buzz-agent/src/hints.rs | 110 +++++++++-- crates/buzz-agent/src/lib.rs | 5 +- crates/buzz-agent/src/llm.rs | 1 + crates/buzz-agent/tests/hints_integration.rs | 18 +- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/commands/skills.rs | 178 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + .../agents/ui/AgentInstanceEditDialog.tsx | 31 +++ .../agents/ui/SkillSelectionPanel.tsx | 132 +++++++++++++ 10 files changed, 484 insertions(+), 20 deletions(-) create mode 100644 desktop/src-tauri/src/commands/skills.rs create mode 100644 desktop/src/features/agents/ui/SkillSelectionPanel.tsx diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index ecd2e88668..e49e8c673e 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::time::Duration; pub const PROTOCOL_VERSION: u32 = 2; @@ -840,6 +841,13 @@ pub struct Config { /// existing `auto` semantics. pub prefer_mesh_for_auto: bool, pub hints_enabled: bool, + /// Per-agent skill allowlist. When non-empty, only these skill names are + /// offered via the built-in `load_skill` tool (and advertised in the + /// "Available Skills" hint section). An empty set — the default — disables + /// all skills, so every agent starts with zero skills and skills are only + /// available once the operator explicitly enables them for that agent. + /// Set via `BUZZ_AGENT_SKILLS` (comma- or space-separated skill names). + pub skills_allowlist: HashSet, /// Thinking/reasoning effort level. `None` = use provider default (no /// thinking config sent). Set via `BUZZ_AGENT_THINKING_EFFORT`. pub thinking_effort: Option, @@ -963,6 +971,7 @@ impl Config { require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0, hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, + skills_allowlist: parse_skills_allowlist(env("BUZZ_AGENT_SKILLS").as_deref()), thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?, thinking_summary: parse_thinking_summary( env("BUZZ_AGENT_THINKING_SUMMARY").as_deref(), @@ -1009,6 +1018,7 @@ impl Config { require_reply: false, hook_servers: HookServers::None, hints_enabled: false, + skills_allowlist: HashSet::new(), thinking_effort: None, thinking_summary: ThinkingSummary::Auto, prompt_caching: false, @@ -1107,6 +1117,22 @@ fn env_or(k: &str, d: &str) -> String { env(k).unwrap_or_else(|| d.into()) } +/// Parse the per-agent skill allowlist from `BUZZ_AGENT_SKILLS`. Splits on +/// commas and whitespace and drops empties; names match the skill `name` +/// frontmatter exactly (discovery is case-sensitive). Unset/empty → an empty +/// set, which disables all skills for that agent (the default: agents start +/// with zero skills). +fn parse_skills_allowlist(v: Option<&str>) -> HashSet { + v.map(|raw| { + raw.split(|c: char| c == ',' || c.is_whitespace()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + fn req(k: &str) -> Result { env(k).ok_or_else(|| format!("config: {k} required")) } diff --git a/crates/buzz-agent/src/hints.rs b/crates/buzz-agent/src/hints.rs index 9fb99f0ebc..1687dc6242 100644 --- a/crates/buzz-agent/src/hints.rs +++ b/crates/buzz-agent/src/hints.rs @@ -201,7 +201,30 @@ fn collect_supporting_files_impl( } } -fn discover_skills_impl(cwd: &Path, home: Option<&Path>) -> Vec { +/// Filter discovered skills down to the per-agent allowlist. +/// +/// `Some(allow)` means only skills whose `name` is in `allow` are offered; +/// an empty set therefore disables all skills (the default, so agents start +/// with zero skills). `None` keeps every discovered skill (used by tests and +/// callers that perform no per-agent gating). +fn apply_allowlist( + skills: Vec, + allowed: Option<&HashSet>, +) -> Vec { + match allowed { + Some(allow) => skills + .into_iter() + .filter(|s| allow.contains(&s.name)) + .collect(), + None => skills, + } +} + +fn discover_skills_impl( + cwd: &Path, + home: Option<&Path>, + allowed: Option<&HashSet>, +) -> Vec { let mut seen = HashSet::new(); let mut skills = Vec::new(); @@ -213,16 +236,23 @@ fn discover_skills_impl(cwd: &Path, home: Option<&Path>) -> Vec { scan_skill_dir(&home.join(".agents/skills"), &mut seen, &mut skills); } - skills + apply_allowlist(skills, allowed) } -pub fn build_hints_section(cwd: &Path) -> (String, Vec) { - build_hints_section_impl(cwd, home_dir().as_deref()) +pub fn build_hints_section( + cwd: &Path, + allowed: Option<&HashSet>, +) -> (String, Vec) { + build_hints_section_impl(cwd, home_dir().as_deref(), allowed) } -fn build_hints_section_impl(cwd: &Path, home: Option<&Path>) -> (String, Vec) { +fn build_hints_section_impl( + cwd: &Path, + home: Option<&Path>, + allowed: Option<&HashSet>, +) -> (String, Vec) { let hints_text = load_hint_files_impl(cwd, home); - let skills = discover_skills_impl(cwd, home); + let skills = discover_skills_impl(cwd, home, allowed); if hints_text.is_empty() && skills.is_empty() { return (String::new(), skills); @@ -368,7 +398,7 @@ mod tests { ) .unwrap(); - let skills = discover_skills_impl(cwd, None); + let skills = discover_skills_impl(cwd, None, None); assert_eq!(skills.len(), 2); let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); assert!(names.contains(&"my-skill"), "missing my-skill"); @@ -402,7 +432,7 @@ mod tests { ) .unwrap(); - let skills = discover_skills_impl(cwd, None); + let skills = discover_skills_impl(cwd, None, None); assert_eq!(skills.len(), 1, "duplicate name should be deduplicated"); assert_eq!( skills[0].description, "from agents", @@ -429,14 +459,64 @@ mod tests { ) .unwrap(); - let skills = discover_skills_impl(cwd, None); + let skills = discover_skills_impl(cwd, None, None); assert!(skills.is_empty(), "entry without name should be skipped"); } + fn setup_two_skills(cwd: &Path) { + let a = cwd.join(".agents/skills/alpha"); + std::fs::create_dir_all(&a).unwrap(); + std::fs::write( + a.join("SKILL.md"), + "---\nname: alpha\ndescription: Alpha\n---\nAlpha body.\n", + ) + .unwrap(); + + let b = cwd.join(".agents/skills/beta"); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write( + b.join("SKILL.md"), + "---\nname: beta\ndescription: Beta\n---\nBeta body.\n", + ) + .unwrap(); + } + + #[test] + fn discover_skills_empty_allowlist_gives_zero_skills() { + let tmp = TempDir::new().unwrap(); + setup_two_skills(tmp.path()); + + // The default state: an empty allowlist means NO skills are offered. + let allowed = HashSet::new(); + let skills = discover_skills_impl(tmp.path(), None, Some(&allowed)); + assert!(skills.is_empty(), "empty allowlist should yield zero skills"); + } + + #[test] + fn discover_skills_allowlist_keeps_only_members() { + let tmp = TempDir::new().unwrap(); + setup_two_skills(tmp.path()); + + let mut allowed = HashSet::new(); + allowed.insert("beta".to_string()); + let skills = discover_skills_impl(tmp.path(), None, Some(&allowed)); + assert_eq!(skills.len(), 1, "only allowlisted skill should be kept"); + assert_eq!(skills[0].name, "beta"); + } + + #[test] + fn discover_skills_none_keeps_all() { + let tmp = TempDir::new().unwrap(); + setup_two_skills(tmp.path()); + + let skills = discover_skills_impl(tmp.path(), None, None); + assert_eq!(skills.len(), 2, "None allowlist keeps every discovered skill"); + } + #[test] fn build_hints_section_empty() { let tmp = TempDir::new().unwrap(); - let (result, skills) = build_hints_section_impl(tmp.path(), None); + let (result, skills) = build_hints_section_impl(tmp.path(), None, None); assert_eq!(result, ""); assert!(skills.is_empty()); } @@ -456,7 +536,7 @@ mod tests { ) .unwrap(); - let (result, skills) = build_hints_section_impl(cwd, None); + let (result, skills) = build_hints_section_impl(cwd, None, None); assert!( result.contains("# Additional Instructions"), @@ -567,7 +647,7 @@ mod tests { "---\nname: global-skill\ndescription: A global skill\n---\nGlobal body.\n", ) .unwrap(); - let skills = discover_skills_impl(cwd.path(), Some(home.path())); + let skills = discover_skills_impl(cwd.path(), Some(home.path()), None); assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "global-skill"); } @@ -593,7 +673,7 @@ mod tests { ) .unwrap(); - let skills = discover_skills_impl(cwd.path(), Some(home.path())); + let skills = discover_skills_impl(cwd.path(), Some(home.path()), None); assert_eq!(skills.len(), 1, "duplicate name should be deduplicated"); assert_eq!( skills[0].description, "from project", @@ -611,7 +691,7 @@ mod tests { "---\nname: local\ndescription: Local skill\n---\nBody.\n", ) .unwrap(); - let skills = discover_skills_impl(cwd.path(), None); + let skills = discover_skills_impl(cwd.path(), None, None); assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "local"); } @@ -713,7 +793,7 @@ mod tests { std::fs::create_dir_all(&refs).unwrap(); std::fs::write(refs.join("guide.md"), "guide content").unwrap(); - let skills = discover_skills_impl(cwd, None); + let skills = discover_skills_impl(cwd, None, None); assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "with-refs"); assert_eq!(skills[0].supporting_files.len(), 1); diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index f222ce7ac2..035a0c2ef8 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -363,7 +363,10 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen } } let (hints_text, skills) = if app.cfg.hints_enabled { - hints::build_hints_section(std::path::Path::new(&p.cwd)) + hints::build_hints_section( + std::path::Path::new(&p.cwd), + Some(&app.cfg.skills_allowlist), + ) } else { (String::new(), Vec::new()) }; diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 289adbd1ad..584a238721 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2735,6 +2735,7 @@ mod tests { openai_api: OpenAiApi::Chat, prefer_mesh_for_auto: false, hints_enabled: true, + skills_allowlist: std::collections::HashSet::new(), thinking_effort: None, thinking_summary: ThinkingSummary::Auto, prompt_caching: true, diff --git a/crates/buzz-agent/tests/hints_integration.rs b/crates/buzz-agent/tests/hints_integration.rs index 63a55514db..aebb1e106f 100644 --- a/crates/buzz-agent/tests/hints_integration.rs +++ b/crates/buzz-agent/tests/hints_integration.rs @@ -263,7 +263,8 @@ async fn skills_loaded_from_agents_skills_dir() { .unwrap(); let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn_with_env(&llm.url, &[]).await; + let mut h = + Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_SKILLS", "test-skill")]).await; let sid = init_session(&mut h, cwd.to_str().unwrap()).await; let p = h @@ -412,7 +413,14 @@ async fn global_skills_loaded_and_project_wins() { let llm = spawn_capturing_llm(vec![openai_text("done")]).await; let mut h = - Harness::spawn_with_env(&llm.url, &[("HOME", home_tmp.path().to_str().unwrap())]).await; + Harness::spawn_with_env( + &llm.url, + &[ + ("HOME", home_tmp.path().to_str().unwrap()), + ("BUZZ_AGENT_SKILLS", "global-only,shared-name"), + ], + ) + .await; let sid = init_session(&mut h, cwd_tmp.path().to_str().unwrap()).await; let p = h @@ -485,7 +493,8 @@ async fn symlinked_skill_dir_is_discovered() { std::os::unix::fs::symlink(&real_skill_dir, skills_dir.join("symlinked-skill")).unwrap(); let llm = spawn_capturing_llm(vec![openai_text("done")]).await; - let mut h = Harness::spawn_with_env(&llm.url, &[]).await; + let mut h = + Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_SKILLS", "symlinked-skill")]).await; let sid = init_session(&mut h, cwd.to_str().unwrap()).await; let p = h @@ -547,7 +556,8 @@ async fn load_skill_tool_returns_body() { let end_turn = openai_text("done"); let llm = spawn_capturing_llm(vec![load_skill_call, end_turn]).await; - let mut h = Harness::spawn_with_env(&llm.url, &[]).await; + let mut h = + Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_SKILLS", "my-skill")]).await; let sid = init_session(&mut h, cwd.to_str().unwrap()).await; let p = h diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 322834630a..18d8db73d5 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -56,6 +56,7 @@ mod qr_download; mod relay_members; mod relay_reconnect; mod social; +mod skills; mod team_snapshot; mod teams; mod updater; @@ -109,6 +110,7 @@ pub use qr_download::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; +pub use skills::*; pub use team_snapshot::*; pub use teams::*; pub use updater::*; diff --git a/desktop/src-tauri/src/commands/skills.rs b/desktop/src-tauri/src/commands/skills.rs new file mode 100644 index 0000000000..be1bd83f75 --- /dev/null +++ b/desktop/src-tauri/src/commands/skills.rs @@ -0,0 +1,178 @@ +use serde::Serialize; +use std::path::{Path, PathBuf}; + +/// A single installed skill discovered on disk. +/// +/// Feeds the desktop "Skill selection" panel. Each skill is a directory that +/// contains a `SKILL.md` whose YAML frontmatter provides the human `name` and +/// `description` shown in the UI. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledSkill { + /// Skill `name` from SKILL.md frontmatter. + pub name: String, + /// One-line `description` from frontmatter (may be empty). + pub description: String, + /// Absolute path to the skill directory on disk. + pub path: String, + /// Which skill root it came from (e.g. `.agents/skills`). + pub source: String, +} + +/// Relative skill roots (under `$HOME`) that Buzz scans for installed skills. +/// +/// These are the union of every runtime skill location: the canonical +/// `.agents/skills` nest home, the per-runtime codex / claude / goose folders, +/// and the bundled `buzz-cli` skill. Order matters for first-wins dedup only. +const SKILL_ROOTS: &[&str] = &[ + ".agents/skills", + ".codex/skills", + ".claude/skills", + ".goose/skills", + ".buzz/.agents/skills", +]; + +/// List every installed skill across the known runtime skill roots. +/// +/// Returns a de-duplicated, name-sorted list. A missing `HOME` yields an empty +/// list; missing roots are skipped silently. +#[tauri::command] +pub fn list_installed_skills() -> Vec { + let Some(home) = home_dir() else { + return Vec::new(); + }; + let mut skills: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for root in SKILL_ROOTS { + scan_skill_root(&home.join(root), root, &mut seen, &mut skills); + } + skills.sort_by(|a, b| a.name.cmp(&b.name)); + skills +} + +fn home_dir() -> Option { + std::env::var_os("HOME").map(PathBuf::from) +} + +fn scan_skill_root( + root: &Path, + source: &str, + seen: &mut std::collections::HashSet, + out: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let skill_md = entry.path().join("SKILL.md"); + if !skill_md.is_file() { + continue; + } + let Ok(content) = std::fs::read_to_string(&skill_md) else { + continue; + }; + let Some((name, description)) = parse_frontmatter(&content) else { + continue; + }; + // Deduplicate by name across roots; the first root in SKILL_ROOTS wins. + if !seen.insert(name.clone()) { + continue; + } + out.push(InstalledSkill { + name, + description, + path: entry.path().to_string_lossy().into_owned(), + source: source.to_string(), + }); + } +} + +/// Parse `name` (required) and `description` (optional) from the leading YAML +/// frontmatter block of a SKILL.md. Returns `None` when there is no frontmatter +/// or no `name:` field — such directories are not treated as skills. +fn parse_frontmatter(content: &str) -> Option<(String, String)> { + let body = content.strip_prefix("---\n")?; + let block = body.split("\n---").next()?; + let mut name: Option = None; + let mut description = String::new(); + for line in block.lines() { + if let Some(rest) = line.strip_prefix("name:") { + name = Some(rest.trim().to_string()); + } else if let Some(rest) = line.strip_prefix("description:") { + description = rest.trim().to_string(); + } + } + name.map(|n| (n, description)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_home(label: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!( + "buzz-skills-test-{label}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn write_skill_with_frontmatter(path: &Path, name: &str, description: &str) { + std::fs::create_dir_all(path).unwrap(); + std::fs::write( + path.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {description}\n---\nBody.\n"), + ) + .unwrap(); + } + + #[test] + fn lists_skills_across_roots_dedupes_and_sorts() { + let home = temp_home("roots"); + let agents = home.join(".agents/skills"); + let codex = home.join(".codex/skills"); + std::fs::create_dir_all(&agents).unwrap(); + std::fs::create_dir_all(&codex).unwrap(); + // Same name in two roots -> first root (.agents) wins. + write_skill_with_frontmatter(&agents.join("shared"), "shared", "from agents"); + write_skill_with_frontmatter(&codex.join("shared"), "shared", "from codex"); + write_skill_with_frontmatter(&codex.join("zeta"), "zeta", "Z skill"); + // A directory without SKILL.md is ignored. + std::fs::create_dir_all(&codex.join("no-skill")).unwrap(); + + let env_guard = std::env::set_var("HOME", &home); + let skills = list_installed_skills(); + drop(env_guard); + + assert_eq!(skills.len(), 2, "shared deduped, zeta present"); + let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, vec!["shared", "zeta"], "name-sorted"); + let shared = &skills[0]; + assert_eq!(shared.description, "from agents", "first root wins"); + assert_eq!(shared.source, ".agents/skills"); + } + + #[test] + fn skills_without_name_are_skipped() { + let home = temp_home("noname"); + let dir = home.join(".agents/skills/anon"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("SKILL.md"), + "---\ndescription: no name here\n---\nBody.\n", + ) + .unwrap(); + let env_guard = std::env::set_var("HOME", &home); + let skills = list_installed_skills(); + drop(env_guard); + assert!(skills.is_empty()); + } + + #[test] + fn missing_home_returns_empty() { + std::env::remove_var("HOME"); + assert!(list_installed_skills().is_empty()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 66816f8b98..38ebbfd48f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -681,6 +681,7 @@ pub fn run() { delete_custom_harness, connect_acp_runtime, discover_managed_agent_prereqs, + list_installed_skills, sign_event, sign_nostr_identity_binding, sign_out, diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f7ee098833..f962bef329 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -66,6 +66,12 @@ import { import { AgentCreationPreview } from "./AgentCreationPreview"; import { OwnerOnlyAccessField } from "./OwnerOnlyAccessField"; import type { EnvVarsValue } from "./EnvVarsEditor"; +import { + BUZZ_AGENT_SKILLS_ENV, + envValueToSkillNames, + SkillSelectionPanel, + skillNamesToEnvValue, +} from "./SkillSelectionPanel"; import { useRequiredCredentialState } from "./useRequiredCredentialState"; import { RunOnSummarySection } from "./RunOnSummarySection"; import { PersonaDropdownField } from "./PersonaDropdownField"; @@ -161,6 +167,25 @@ export function AgentInstanceEditDialog({ agent.respondToAllowlist, ); const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); + + // Enabled skills are stored in the per-agent `BUZZ_AGENT_SKILLS` env var, + // which the runtime already layers into the spawned agent's environment. + const enabledSkillNames = React.useMemo( + () => envValueToSkillNames(envVars[BUZZ_AGENT_SKILLS_ENV]), + [envVars], + ); + const handleSkillsChange = React.useCallback((names: string[]) => { + setEnvVars((prev) => { + const next = { ...prev }; + const envValue = skillNamesToEnvValue(names); + if (envValue.length === 0) { + delete next[BUZZ_AGENT_SKILLS_ENV]; + } else { + next[BUZZ_AGENT_SKILLS_ENV] = envValue; + } + return next; + }); + }, []); const [avatarUrl, setAvatarUrl] = React.useState(agent.avatarUrl ?? ""); const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); @@ -1213,6 +1238,12 @@ export function AgentInstanceEditDialog({ + {/* Skill selection — bottom of the runtime tab */} + + {/* Error */} {updateMutation.error instanceof Error ? (

diff --git a/desktop/src/features/agents/ui/SkillSelectionPanel.tsx b/desktop/src/features/agents/ui/SkillSelectionPanel.tsx new file mode 100644 index 0000000000..a0a6e80b4e --- /dev/null +++ b/desktop/src/features/agents/ui/SkillSelectionPanel.tsx @@ -0,0 +1,132 @@ +import { useQuery } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; +import { Layers, Loader2 } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { Switch } from "@/shared/ui/switch"; + +/** + * A single installed skill discovered across the runtime skill roots. + * Returned by the `list_installed_skills` Tauri command. + */ +export type InstalledSkill = { + name: string; + description: string; + path: string; + source: string; +}; + +/** Env var (per-agent) that carries the comma-separated skill allowlist. */ +export const BUZZ_AGENT_SKILLS_ENV = "BUZZ_AGENT_SKILLS"; + +/** Serialize enabled skill names into the `BUZZ_AGENT_SKILLS` env value. */ +export function skillNamesToEnvValue(names: readonly string[]): string { + return names.join(","); +} + +/** Parse the `BUZZ_AGENT_SKILLS` env value back into enabled skill names. */ +export function envValueToSkillNames(value: string | undefined): string[] { + return (value ?? "") + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +type SkillSelectionPanelProps = { + /** Currently enabled skill names (drives the toggles). */ + value: readonly string[]; + /** Called with the new full set of enabled skill names on any toggle. */ + onChange: (names: string[]) => void; + className?: string; +}; + +/** + * "Skill selection" — the fixed-height, inner-scrollable list of every + * installed skill with an on/off toggle per skill. + * + * Everyone starts with everything off; only enabled skills are written to the + * agent's `BUZZ_AGENT_SKILLS` allowlist and offered via `load_skill`. The outer + * box keeps a fixed height (`h-64`) and the list scrolls inside it, so the box + * never moves with the content. + */ +export function SkillSelectionPanel({ + value, + onChange, + className, +}: SkillSelectionPanelProps) { + const enabled = React.useMemo(() => new Set(value), [value]); + const query = useQuery({ + queryKey: ["list_installed_skills"], + queryFn: () => invoke("list_installed_skills"), + }); + + const skills = query.data ?? []; + + const toggle = (name: string, on: boolean) => { + const next = new Set(value); + if (on) { + next.add(name); + } else { + next.delete(name); + } + onChange(Array.from(next).sort()); + }; + + return ( +

+
+ + + Skill selection + + + {enabled.size} / {skills.length} enabled + +
+ +
+ {query.isLoading && ( +
+ + Loading skills… +
+ )} + {!query.isLoading && skills.length === 0 && ( +
+ {query.isError ? "Couldn't load skills." : "No skills installed."} +
+ )} + {!query.isLoading && skills.length > 0 && ( +
+ {skills.map((skill) => { + const active = enabled.has(skill.name); + return ( + + ); + })} +
+ )} +
+
+ ); +}