pub mod fetch;
pub mod parse;
pub(crate) use parse::parse_and_validate_skill_md;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use once_cell::sync::Lazy;
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use theway_core::{
AgentTool, AgentToolError, AgentToolResult, AgentToolUpdate, PermissionClassification,
ToolExecutionMode,
};
use theway_llm_provider::{Tool, UserContentBlock};
use tokio_util::sync::CancellationToken;
use super::skill::SkillHarnessCell;
use fetch::fetch_source;
use parse::parse_and_validate;
const SKILL_FETCH_OOM_GUARD_BYTES: usize = 16 * 1024 * 1024;
const HTTP_TIMEOUT_SECS: u64 = 15;
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1024;
pub struct InstallSkillTool {
harness: SkillHarnessCell,
skills_root: PathBuf,
}
impl InstallSkillTool {
pub fn new(harness: SkillHarnessCell) -> Self {
Self::with_skills_root(harness, default_skills_root())
}
pub fn with_skills_root(harness: SkillHarnessCell, skills_root: PathBuf) -> Self {
Self {
harness,
skills_root,
}
}
fn target_path(&self, name: &str) -> PathBuf {
self.skills_root.join(name).join("SKILL.md")
}
}
pub(crate) fn default_skills_root() -> PathBuf {
theway_contract::config::base_dir().join("skills")
}
#[async_trait]
impl AgentTool for InstallSkillTool {
fn definition(&self) -> &Tool {
&DEFINITION
}
fn label(&self) -> &str {
"install_skill"
}
fn execution_mode(&self) -> Option<ToolExecutionMode> {
Some(ToolExecutionMode::Sequential)
}
fn permission_classification(&self, prepared_args: &Value) -> PermissionClassification {
let raw_kind = prepared_args
.get("source")
.and_then(|s| s.get("type"))
.and_then(|t| t.as_str());
let normalized = match raw_kind {
Some("url" | "https") => "url",
Some("path") => "path",
Some("content") => "content",
_ => "<unknown source>",
};
PermissionClassification::Prompt {
reason: format!("install user skill from {normalized}"),
}
}
async fn execute(
&self,
_id: &str,
params: Value,
cancel: CancellationToken,
_on_update: Option<AgentToolUpdate>,
) -> Result<AgentToolResult, AgentToolError> {
let input: InstallInput = serde_json::from_value(params)
.map_err(|e| AgentToolError::Message(format!("invalid arguments: {e}")))?;
let fetched = fetch_source(&input.source, &cancel).await?;
let parsed = parse_and_validate(&fetched)?;
let target_path = self.target_path(&parsed.name);
let existing_hash = on_disk_skill_hash(&target_path).await;
let existing = existing_hash.is_some();
let overwrite_required = existing && existing_hash.as_deref() != Some(&parsed.content_hash);
if !input.confirm {
return Ok(AgentToolResult {
content: vec![UserContentBlock::text(format!(
"preview only — call again with `confirm: true` to install. \
name={} target={} size={}B existing={} overwrite_required={}",
parsed.name,
target_path.display(),
parsed.size,
existing,
overwrite_required
))],
details: json!({
"phase": "preview",
"name": parsed.name,
"description": parsed.description,
"warnings": parsed.warnings,
"target_path": target_path.display().to_string(),
"content_hash": parsed.content_hash,
"size": parsed.size,
"existing": existing,
"overwrite_required": overwrite_required,
}),
terminate: None,
});
}
if overwrite_required && !input.overwrite {
return Err(AgentToolError::Message(format!(
"skill '{}' already exists with different content. Call again with \
`overwrite: true` to replace it (existing hash differs from new content).",
parsed.name
)));
}
atomic_write_skill(&target_path, &parsed.normalized_content).await?;
let harness = self
.harness
.get()
.ok_or_else(|| AgentToolError::from("install_skill not yet initialized"))?;
let reload = harness
.reload_skills_from_disk()
.await
.map_err(|e| AgentToolError::Message(format!("reload after install: {e}")))?;
let installed = reload.skills.iter().any(|s| s.name == parsed.name);
let mut warnings = parsed.warnings.clone();
warnings.extend(
reload
.diagnostics
.iter()
.filter(|d| {
d.path.contains(&parsed.name) || d.path == target_path.display().to_string()
})
.map(|d| format!("{:?}: {}", d.code, d.message)),
);
let source_kind = match &input.source {
Source::Url { .. } => "url",
Source::Path { .. } => "path",
Source::Content { .. } => "content",
};
let source_redacted = audit_source_reference(&input.source);
let audit_payload = json!({
"status": "installed",
"name": parsed.name,
"target_path": target_path.display().to_string(),
"source_kind": source_kind,
"source": source_redacted,
"before_hash": existing_hash,
"after_hash": parsed.content_hash,
"size": parsed.size,
"overwrote": overwrite_required,
"idempotent": existing && !overwrite_required,
"installed_visible_in_catalog": installed,
"diagnostics_count": reload.diagnostics.len(),
"warnings": warnings.clone(),
});
let audit_entry_id = match harness
.session()
.append_custom("skill_install", Some(audit_payload))
.await
{
Ok(id) => Some(id),
Err(e) => {
tracing::warn!(
skill = %parsed.name,
error = %e,
"skill_install audit write failed; install itself succeeded"
);
None
}
};
Ok(AgentToolResult {
content: vec![UserContentBlock::text(format!(
"installed skill '{}' to {} ({}B). catalog now has {} skill(s).",
parsed.name,
target_path.display(),
parsed.size,
reload.skills.len()
))],
details: json!({
"phase": "installed",
"name": parsed.name,
"target_path": target_path.display().to_string(),
"content_hash": parsed.content_hash,
"size": parsed.size,
"overwrote": overwrite_required,
"total_skills_after": reload.skills.len(),
"diagnostics_count": reload.diagnostics.len(),
"warnings": warnings,
"installed_visible_in_catalog": installed,
"audit_entry_id": audit_entry_id,
}),
terminate: None,
})
}
}
#[derive(Debug, Deserialize)]
struct InstallInput {
source: Source,
#[serde(default)]
confirm: bool,
#[serde(default)]
overwrite: bool,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Source {
#[serde(alias = "https")]
Url {
url: String,
},
Path {
path: String,
},
Content {
content: String,
},
}
fn audit_source_reference(source: &Source) -> Value {
match source {
Source::Url { url } => audit_url_reference(url),
Source::Path { path } => json!(path),
Source::Content { .. } => json!(null),
}
}
fn audit_url_reference(url: &str) -> Value {
match reqwest::Url::parse(url) {
Ok(parsed) => {
let mut hasher = Sha256::new();
hasher.update(parsed.path().as_bytes());
json!({
"scheme": parsed.scheme(),
"host": parsed.host_str().unwrap_or(""),
"path_hash": format!("{:x}", hasher.finalize()),
"redacted": true,
})
}
Err(_) => json!({ "redacted": true }),
}
}
pub(crate) async fn on_disk_skill_hash(target_path: &Path) -> Option<String> {
let bytes = tokio::fs::read(target_path).await.ok()?;
let s = String::from_utf8(bytes).ok()?;
let normalized = s.replace("\r\n", "\n").replace('\r', "\n");
let mut hasher = Sha256::new();
hasher.update(normalized.as_bytes());
Some(hex::encode(hasher.finalize()))
}
pub(crate) async fn atomic_write_skill(target: &Path, content: &str) -> Result<(), AgentToolError> {
let parent = target
.parent()
.ok_or_else(|| AgentToolError::from("target path has no parent directory"))?;
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| AgentToolError::Message(format!("create {}: {e}", parent.display())))?;
let tmp_name = format!(
".SKILL.md.{}.{}.tmp",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let tmp = parent.join(tmp_name);
tokio::fs::write(&tmp, content)
.await
.map_err(|e| AgentToolError::Message(format!("write {}: {e}", tmp.display())))?;
if let Err(e) = tokio::fs::rename(&tmp, target).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(AgentToolError::Message(format!(
"rename {} -> {}: {e}",
tmp.display(),
target.display()
)));
}
Ok(())
}
static DEFINITION: Lazy<Tool> = Lazy::new(|| Tool {
name: "install_skill".into(),
description:
"Install a new skill into the user-global skills directory (~/.theway/skills/<name>/) \
and hot-reload the catalog so the next turn can use it. Two-phase: first call \
without `confirm` returns a preview (name, description, target path, hash, size). \
Second call with `confirm: true` writes atomically and reloads. Same-name skill \
requires `overwrite: true` when the new content hash differs. Source is one of: \
https URL, absolute local path, or inline content. Body is never echoed back into \
the tool result — only metadata + preview info."
.into(),
parameters: json!({
"type": "object",
"properties": {
"source": {
"type": "object",
"description": "Where to fetch the SKILL.md from.",
"oneOf": [
{
"properties": {
"type": {
"enum": ["url", "https"],
"description": "Use \"url\" for HTTPS URLs. \"https\" is accepted as a compatibility alias."
},
"url": {
"type": "string",
"description": "https:// URL. http/file/data schemes are rejected; loopback and RFC1918 hosts are rejected."
}
},
"required": ["type", "url"],
"additionalProperties": false
},
{
"properties": {
"type": { "const": "path" },
"path": {
"type": "string",
"description": "Absolute path to a local SKILL.md file."
}
},
"required": ["type", "path"],
"additionalProperties": false
},
{
"properties": {
"type": { "const": "content" },
"content": {
"type": "string",
"description": "Inline SKILL.md content (frontmatter + body)."
}
},
"required": ["type", "content"],
"additionalProperties": false
}
]
},
"confirm": {
"type": "boolean",
"default": false,
"description": "When false (default), returns a preview without writing. When true, performs the install."
},
"overwrite": {
"type": "boolean",
"default": false,
"description": "Required when a skill of the same name already exists with different content."
}
},
"required": ["source"],
"additionalProperties": false
}),
});
#[cfg(test)]
tests_bridge_macro::tests_bridge!("tools/install_skill");
#[cfg(all(test, feature = "local"))]
mod install_skill_extra {
tests_bridge_macro::tests_bridge!("tools/install_skill/extra");
}