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
16 changes: 16 additions & 0 deletions crates/cli/src/extensions/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ pub(super) fn discover_runtime_resources(
collect_package_resources(&mut discovered, cwd, resolved)?;
}

// Apply the user-level skill disable list: keeps source files on disk
// but removes the entries from this session's skill list.
if !settings.disabled_skills.is_empty() {
let disabled: BTreeSet<String> = settings
.disabled_skills
.iter()
.map(|name| name.trim().to_ascii_lowercase())
.filter(|name| !name.is_empty())
.collect();
if !disabled.is_empty() {
discovered
.skills
.retain(|skill| !disabled.contains(&skill.info.name.trim().to_ascii_lowercase()));
}
}

Ok(discovered)
}

Expand Down
71 changes: 71 additions & 0 deletions crates/cli/src/extensions/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,77 @@ async fn loads_package_skills_and_prompts_from_settings() {
);
}

#[tokio::test]
async fn disabled_skills_are_excluded_from_runtime_resources() {
let cwd = tempdir().unwrap();
let package_dir = cwd.path().join("skills-package");
fs::create_dir_all(package_dir.join("skills/alpha")).unwrap();
fs::create_dir_all(package_dir.join("skills/beta")).unwrap();
fs::write(
package_dir.join("package.json"),
r#"{
"name": "skills-package",
"bb": { "skills": ["./skills"] }
}"#,
)
.unwrap();
fs::write(
package_dir.join("skills/alpha/SKILL.md"),
"---\nname: alpha\ndescription: alpha skill\n---\nBody.",
)
.unwrap();
fs::write(
package_dir.join("skills/beta/SKILL.md"),
"---\nname: beta\ndescription: beta skill\n---\nBody.",
)
.unwrap();

// Load with no disabled list first — both should be visible.
let settings_all = Settings {
packages: vec![PackageEntry::Simple(package_dir.display().to_string())],
..Settings::default()
};
let support_all =
load_runtime_extension_support(cwd.path(), &settings_all, &ExtensionBootstrap::default())
.await
.unwrap();
let names_all: Vec<String> = support_all
.session_resources
.skills
.iter()
.map(|s| s.info.name.clone())
.collect();
assert!(names_all.iter().any(|n| n == "alpha"));
assert!(names_all.iter().any(|n| n == "beta"));

// Now disable `alpha` — source file is still on disk, but it must not
// show up in the session resources.
let settings_disabled = Settings {
packages: vec![PackageEntry::Simple(package_dir.display().to_string())],
disabled_skills: vec!["alpha".to_string()],
..Settings::default()
};
let support_disabled = load_runtime_extension_support(
cwd.path(),
&settings_disabled,
&ExtensionBootstrap::default(),
)
.await
.unwrap();
let names_disabled: Vec<String> = support_disabled
.session_resources
.skills
.iter()
.map(|s| s.info.name.clone())
.collect();
assert!(!names_disabled.iter().any(|n| n == "alpha"));
assert!(names_disabled.iter().any(|n| n == "beta"));
assert!(
package_dir.join("skills/alpha/SKILL.md").exists(),
"disable must not delete the source file"
);
}

#[test]
fn project_scoped_package_settings_round_trip() {
let cwd = tempdir().unwrap();
Expand Down
151 changes: 150 additions & 1 deletion crates/cli/src/fullscreen/controller/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ use crate::extensions::{
use crate::fullscreen::build_dynamic_slash_items;
use crate::session_bootstrap::build_tool_defs;
use crate::slash::{
InstallSlashAction, dispatch_local_slash_command, install_help_text, parse_install_command,
InstallSlashAction, SkillAdminAction, dispatch_local_slash_command, install_help_text,
parse_install_command, skill_help_text,
};
use crate::turn_runner;
use crate::update_check::{self, UpdateCheckOutcome};
Expand Down Expand Up @@ -49,6 +50,11 @@ impl FullscreenController {
return Ok(true);
}

if let Some(action) = crate::slash::parse_skill_command(text) {
self.handle_skill_admin_command(action).await?;
return Ok(true);
}

match dispatch_local_slash_command(self, text) {
Ok(handled) => Ok(handled),
Err(err) => {
Expand Down Expand Up @@ -129,6 +135,149 @@ impl FullscreenController {
Ok(())
}

pub(crate) async fn handle_skill_admin_command(
&mut self,
action: SkillAdminAction,
) -> Result<()> {
match action {
SkillAdminAction::Help => {
self.send_command(FullscreenCommand::PushNote {
level: FullscreenNoteLevel::Status,
text: skill_help_text(),
});
}
SkillAdminAction::List => {
let loaded: Vec<String> = self
.runtime_host
.bootstrap()
.resource_bootstrap
.skills
.iter()
.map(|skill| skill.info.name.clone())
.collect();

let settings = Settings::load_merged(&self.session_setup.tool_ctx.cwd);
let disabled: Vec<String> = settings
.disabled_skills
.iter()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.collect();

let mut lines = Vec::new();
lines.push("Loaded skills:".to_string());
if loaded.is_empty() {
lines.push(" (none)".to_string());
} else {
for name in &loaded {
lines.push(format!(" • {name}"));
}
}
lines.push(String::new());
lines.push("Disabled skills (source kept on disk):".to_string());
if disabled.is_empty() {
lines.push(" (none)".to_string());
} else {
for name in &disabled {
lines.push(format!(" • {name}"));
}
}
self.send_command(FullscreenCommand::PushNote {
level: FullscreenNoteLevel::Status,
text: lines.join(
"
",
),
});
}
SkillAdminAction::Disable(name) => {
self.apply_skill_disable(&name, true).await?;
}
SkillAdminAction::Enable(name) => {
self.apply_skill_disable(&name, false).await?;
}
}
Ok(())
}

async fn apply_skill_disable(&mut self, name: &str, disable: bool) -> Result<()> {
if self.streaming {
self.send_command(FullscreenCommand::SetStatusLine(
"Cannot modify skills while a turn is running".to_string(),
));
return Ok(());
}

let trimmed = name.trim();
if trimmed.is_empty() {
self.send_command(FullscreenCommand::PushNote {
level: FullscreenNoteLevel::Warning,
text: "Missing skill name. See /skill for usage.".to_string(),
});
return Ok(());
}

let mut settings = Settings::load_global();
let normalized = trimmed.to_string();
let already = settings
.disabled_skills
.iter()
.any(|entry| entry.trim().eq_ignore_ascii_case(&normalized));

if disable {
if already {
self.send_command(FullscreenCommand::PushNote {
level: FullscreenNoteLevel::Status,
text: format!("Skill '{normalized}' is already disabled."),
});
return Ok(());
}
let known = self
.runtime_host
.bootstrap()
.resource_bootstrap
.skills
.iter()
.any(|skill| skill.info.name.eq_ignore_ascii_case(&normalized));
if !known {
self.send_command(FullscreenCommand::PushNote {
level: FullscreenNoteLevel::Warning,
text: format!(
"Skill '{normalized}' is not currently loaded; saving disable anyway."
),
});
}
settings.disabled_skills.push(normalized.clone());
} else if !already {
self.send_command(FullscreenCommand::PushNote {
level: FullscreenNoteLevel::Status,
text: format!("Skill '{normalized}' is not disabled."),
});
return Ok(());
} else {
settings
.disabled_skills
.retain(|entry| !entry.trim().eq_ignore_ascii_case(&normalized));
}

if let Err(err) = settings.save_global() {
self.send_command(FullscreenCommand::PushNote {
level: FullscreenNoteLevel::Error,
text: format!("Failed to save settings: {err}"),
});
return Ok(());
}

self.reload_runtime_resources().await?;
self.show_startup_resources();
self.send_command(FullscreenCommand::SetStatusLine(if disable {
format!("Disabled skill: {normalized}")
} else {
format!("Enabled skill: {normalized}")
}));
Ok(())
}

pub(crate) async fn maybe_auto_reload_resources(&mut self) -> Result<()> {
let next_watch = ResourceWatchState::capture(&self.session_setup.tool_ctx.cwd);
if next_watch == self.resource_watch {
Expand Down
104 changes: 104 additions & 0 deletions crates/cli/src/slash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,62 @@ pub fn install_help_text() -> String {
install_help_lines().join("\n")
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillAdminAction {
Help,
List,
Disable(String),
Enable(String),
}

pub fn skill_help_text() -> String {
[
"/skill — manage which skills are loaded this session",
"",
" /skill Show this help",
" /skill list Show loaded skills and any disabled ones",
" /skill disable <name> Disable a skill (source file is kept; it just won't load)",
" /skill enable <name> Re-enable a previously disabled skill",
"",
"The disabled list is persisted to global settings and applied on /reload.",
]
.join("\n")
}

pub fn parse_skill_command(text: &str) -> Option<SkillAdminAction> {
let text = text.trim();
let rest = text.strip_prefix("/skill")?;
// Make sure "/skillfoo" does NOT match "/skill".
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return None;
}
let rest = rest.trim();
if rest.is_empty() || rest == "--help" || rest == "-h" || rest == "help" {
return Some(SkillAdminAction::Help);
}
if rest == "list" || rest == "ls" {
return Some(SkillAdminAction::List);
}
if let Some(name) = rest.strip_prefix("disable ") {
let name = name.trim();
if name.is_empty() {
return Some(SkillAdminAction::Help);
}
return Some(SkillAdminAction::Disable(name.to_string()));
}
if let Some(name) = rest.strip_prefix("enable ") {
let name = name.trim();
if name.is_empty() {
return Some(SkillAdminAction::Help);
}
return Some(SkillAdminAction::Enable(name.to_string()));
}
if rest == "disable" || rest == "enable" {
return Some(SkillAdminAction::Help);
}
Some(SkillAdminAction::Help)
}

pub fn parse_install_command(text: &str) -> Option<InstallSlashAction> {
let text = text.trim();
let rest = text.strip_prefix("/install")?.trim();
Expand Down Expand Up @@ -298,6 +354,18 @@ mod tests {
);
}

#[test]
fn does_not_treat_mid_message_slash_text_as_command() {
assert!(matches!(
handle_slash_command("please do not run /compact in the middle"),
SlashResult::NotCommand
));
assert!(matches!(
handle_slash_command("prefix /model sonnet suffix"),
SlashResult::NotCommand
));
}

#[test]
fn dispatches_shared_local_command_through_host() {
let mut host = MockHost::default();
Expand Down Expand Up @@ -331,4 +399,40 @@ mod tests {
);
assert!(install_help_text().contains("/install [-l|--local] <source>"));
}

#[test]
fn parses_skill_admin_commands() {
use super::{SkillAdminAction, parse_skill_command, skill_help_text};
assert_eq!(parse_skill_command("/skill"), Some(SkillAdminAction::Help));
assert_eq!(
parse_skill_command("/skill --help"),
Some(SkillAdminAction::Help)
);
assert_eq!(
parse_skill_command("/skill list"),
Some(SkillAdminAction::List)
);
assert_eq!(
parse_skill_command("/skill ls"),
Some(SkillAdminAction::List)
);
assert_eq!(
parse_skill_command("/skill disable shape"),
Some(SkillAdminAction::Disable("shape".to_string()))
);
assert_eq!(
parse_skill_command("/skill enable my skill "),
Some(SkillAdminAction::Enable("my skill".to_string()))
);
// Bare disable/enable falls back to help.
assert_eq!(
parse_skill_command("/skill disable"),
Some(SkillAdminAction::Help)
);
// /skillfoo should NOT match /skill.
assert_eq!(parse_skill_command("/skillfoo"), None);
// Unrelated slash commands are not touched.
assert_eq!(parse_skill_command("/install"), None);
assert!(skill_help_text().contains("/skill disable <name>"));
}
}
Loading