use std::path::{Path, PathBuf};
use async_trait::async_trait;
use once_cell::sync::Lazy;
use serde::Deserialize;
use serde_json::{Value, json};
use theway_core::{
AgentTool, AgentToolError, AgentToolResult, AgentToolUpdate, PermissionClassification,
SkillSource, ToolExecutionMode,
};
use theway_llm_provider::{Tool, UserContentBlock};
use tokio_util::sync::CancellationToken;
use super::set_skill_state::default_base_dir;
use super::skill::SkillHarnessCell;
use crate::skill_overrides;
pub struct RemoveSkillTool {
harness: SkillHarnessCell,
base_dir: PathBuf,
}
impl RemoveSkillTool {
pub fn new(harness: SkillHarnessCell) -> Self {
Self::with_base_dir(harness, default_base_dir())
}
pub fn with_base_dir(harness: SkillHarnessCell, base_dir: PathBuf) -> Self {
Self { harness, base_dir }
}
fn skills_root(&self) -> PathBuf {
self.base_dir.join("skills")
}
}
#[derive(Debug, Deserialize)]
struct Input {
name: String,
#[serde(default)]
source: Option<String>,
#[serde(default)]
confirm: bool,
}
#[async_trait]
impl AgentTool for RemoveSkillTool {
fn definition(&self) -> &Tool {
&DEFINITION
}
fn label(&self) -> &str {
"remove_skill"
}
fn execution_mode(&self) -> Option<ToolExecutionMode> {
Some(ToolExecutionMode::Sequential)
}
fn permission_classification(&self, prepared_args: &Value) -> PermissionClassification {
let name = prepared_args
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>");
PermissionClassification::Prompt {
reason: format!("remove user skill `{name}`"),
}
}
async fn execute(
&self,
_id: &str,
params: Value,
_cancel: CancellationToken,
_on_update: Option<AgentToolUpdate>,
) -> Result<AgentToolResult, AgentToolError> {
let input: Input = serde_json::from_value(params)
.map_err(|e| AgentToolError::Message(format!("invalid arguments: {e}")))?;
let harness = self
.harness
.get()
.ok_or_else(|| AgentToolError::from("remove_skill not yet initialized"))?;
let skills = harness.skills();
let Some(skill) = skills.iter().find(|s| s.name == input.name) else {
let mut names: Vec<&str> = skills
.iter()
.filter(|s| s.name.starts_with(&input.name) || s.name.contains(&input.name))
.map(|s| s.name.as_str())
.take(5)
.collect();
names.dedup();
let hint = if names.is_empty() {
String::new()
} else {
format!(" Did you mean: {}?", names.join(", "))
};
return Err(AgentToolError::Message(format!(
"no loaded skill named '{}'. Run /skills to list loaded skills.{hint}",
input.name
)));
};
let source = skill.source;
if source != SkillSource::User {
return Err(AgentToolError::Message(format!(
"'{}' is a {} skill and cannot be removed (builtin skills are compiled in; \
project skills belong to the repo). Disable it instead with set_skill_state \
or `/skills disable {}`.",
input.name,
source.label(),
input.name
)));
}
if let Some(req) = &input.source {
let req_src = parse_source(req)?;
if req_src != SkillSource::User {
return Err(AgentToolError::Message(format!(
"only user-installed skills can be removed; '{}' is a user skill, not '{}'.",
input.name,
req_src.label()
)));
}
}
let skills_root = self.skills_root();
let target = match deletion_target(&skills_root, Path::new(&skill.file_path)) {
Some(t) => t,
None => {
return Err(AgentToolError::Message(format!(
"refusing to remove '{}': its file ({}) is not under the user skills root \
({}).",
input.name,
skill.file_path,
skills_root.display()
)));
}
};
if !input.confirm {
return Ok(AgentToolResult {
content: vec![UserContentBlock::text(format!(
"preview only — call again with `confirm: true` to delete. \
skill={} source=user target={}",
input.name,
target.display()
))],
details: json!({
"phase": "preview",
"name": input.name,
"source": "user",
"target_path": target.display().to_string(),
}),
terminate: None,
});
}
let removed_meta = tokio::fs::symlink_metadata(&target).await;
match removed_meta {
Ok(meta) if meta.is_dir() => {
tokio::fs::remove_dir_all(&target).await.map_err(|e| {
AgentToolError::Message(format!("remove {}: {e}", target.display()))
})?;
}
Ok(_) => {
tokio::fs::remove_file(&target).await.map_err(|e| {
AgentToolError::Message(format!("remove {}: {e}", target.display()))
})?;
}
Err(_) => {
}
}
if let Err(e) = skill_overrides::remove_and_save(&self.base_dir, &input.name, source).await
{
tracing::warn!(
skill = %input.name,
error = %e,
"failed to clear skill-overrides overlay entry after remove"
);
}
let reload = harness
.reload_skills_from_disk()
.await
.map_err(|e| AgentToolError::Message(format!("reload after remove: {e}")))?;
let still_present = reload
.skills
.iter()
.any(|s| s.name == input.name && s.source == SkillSource::User);
let audit = json!({
"op": "remove",
"actor": "tool",
"name": input.name,
"source": "user",
"target_path": target.display().to_string(),
});
let audit_entry_id = match harness
.session()
.append_custom("skill_control_plane", Some(audit))
.await
{
Ok(id) => Some(id),
Err(e) => {
tracing::warn!(
skill = %input.name,
error = %e,
"skill_control_plane audit write failed; removal itself succeeded"
);
None
}
};
Ok(AgentToolResult {
content: vec![UserContentBlock::text(format!(
"removed skill '{}' (user). catalog now has {} skill(s).",
input.name,
reload.skills.len()
))],
details: json!({
"phase": "removed",
"name": input.name,
"source": "user",
"target_path": target.display().to_string(),
"still_present_after_reload": still_present,
"total_skills_after": reload.skills.len(),
"audit_entry_id": audit_entry_id,
}),
terminate: None,
})
}
}
fn deletion_target(skills_root: &Path, file_path: &Path) -> Option<PathBuf> {
let rel = file_path.strip_prefix(skills_root).ok()?;
let first = rel.components().next()?;
match first {
std::path::Component::Normal(c) => Some(skills_root.join(c)),
_ => None,
}
}
fn parse_source(s: &str) -> Result<SkillSource, AgentToolError> {
match s.to_ascii_lowercase().as_str() {
"builtin" => Ok(SkillSource::Builtin),
"user" => Ok(SkillSource::User),
"project" => Ok(SkillSource::Project),
_ => Err(AgentToolError::from(
"invalid `source` (expected one of: builtin, user, project)",
)),
}
}
static DEFINITION: Lazy<Tool> = Lazy::new(|| Tool {
name: "remove_skill".into(),
description:
"Delete a user-installed skill (from ~/.theway/skills/) and hot-reload the catalog. Only \
user-installed skills can be removed — builtin skills are compiled into theway and \
project skills belong to the repo; for those, disable instead via set_skill_state. \
Two-phase: first call previews the target path; call again with `confirm: true` to \
delete. Removing also clears any disabled-state overlay entry for the skill."
.into(),
parameters: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Exact skill name as shown in /skills."
},
"source": {
"type": "string",
"enum": ["builtin", "user", "project"],
"description": "Optional. Must be `user` if given — only user-installed skills are removable."
},
"confirm": {
"type": "boolean",
"default": false,
"description": "When false (default) returns a preview; when true performs the deletion."
}
},
"required": ["name"],
"additionalProperties": false
}),
});
#[cfg(all(test, feature = "local"))]
tests_bridge_macro::tests_bridge!("tools/remove_skill");
#[cfg(all(test, feature = "local"))]
mod remove_skill_extra {
tests_bridge_macro::tests_bridge!("tools/remove_skill/extra");
}
#[cfg(all(test, feature = "local"))]
mod remove_skill_extra_more {
tests_bridge_macro::tests_bridge!("tools/remove_skill/extra_more");
}