use crate::installer::context::InstallContext;
use crate::lockfile::{LockFile, LockedResource};
use crate::manifest::patches::AppliedPatches;
use anyhow::Result;
use std::path::{Path, PathBuf};
pub fn collect_skill_patches(
entry: &LockedResource,
context: &InstallContext<'_>,
) -> AppliedPatches {
let resource_type = entry.resource_type.to_plural();
let lookup_name = entry.lookup_name();
tracing::debug!(
"Collecting skill patches: resource_type={}, lookup_name={}, name={}, manifest_alias={:?}",
resource_type,
lookup_name,
entry.name,
entry.manifest_alias
);
let project_patches = context
.project_patches
.and_then(|patches| patches.get(resource_type, lookup_name))
.cloned()
.unwrap_or_default();
tracing::debug!("Found {} project patches for skill {}", project_patches.len(), lookup_name);
let private_patches = context
.private_patches
.and_then(|patches| patches.get(resource_type, lookup_name))
.cloned()
.unwrap_or_default();
tracing::debug!("Found {} private patches for skill {}", private_patches.len(), lookup_name);
AppliedPatches {
project: project_patches,
private: private_patches,
}
}
pub async fn install_skill_directory(
entry: &LockedResource,
dest_path: &Path,
applied_patches: &AppliedPatches,
should_install: bool,
content_changed: bool,
context: &InstallContext<'_>,
) -> Result<bool> {
use crate::utils::fs::ensure_dir;
use anyhow::Context;
if !should_install {
tracing::debug!("Skipping skill directory installation (install=false)");
return Ok(false);
}
if !content_changed {
tracing::debug!("Skipping skill directory installation (content unchanged)");
return Ok(false);
}
let source_dir = get_skill_source_directory(entry, context).await?;
if !source_dir.is_dir() {
return Err(anyhow::anyhow!("Skill source is not a directory: {}", source_dir.display()));
}
tracing::debug!("Validating skill size limits: {}", source_dir.display());
let dir_info = crate::skills::validate_skill_size(&source_dir)
.await
.with_context(|| format!("Skill size validation failed: {}", source_dir.display()))?;
let (skill_frontmatter, skill_files) =
crate::skills::extract_skill_metadata_from_info(&dir_info, &source_dir)
.with_context(|| format!("Invalid skill directory: {}", source_dir.display()))?;
tracing::debug!(
"Installing skill '{}' with {} files: {}",
skill_frontmatter.name,
skill_files.len(),
source_dir.display()
);
let skill_dest_dir = if dest_path.extension().and_then(|s| s.to_str()) == Some("md") {
dest_path.with_extension("")
} else {
dest_path.to_path_buf()
};
if let Some(parent) = skill_dest_dir.parent() {
ensure_dir(parent)?;
}
tracing::debug!("Removing existing skill directory if present: {}", skill_dest_dir.display());
let skill_dest_dir_clone = skill_dest_dir.clone();
let removal_result =
tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&skill_dest_dir_clone))
.await
.map_err(|e| anyhow::anyhow!("Task join error during directory cleanup: {}", e))?;
match removal_result {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!("Skill directory did not exist, nothing to remove");
}
Err(e) => {
return Err(anyhow::anyhow!(
"Failed to remove existing skill directory {}: {}",
skill_dest_dir.display(),
e
));
}
}
tracing::debug!(
"Installing skill directory from {} to {}",
source_dir.display(),
skill_dest_dir.display()
);
let source_dir_clone = source_dir.clone();
let skill_dest_dir_clone = skill_dest_dir.clone();
tokio::task::spawn_blocking(move || {
crate::utils::fs::copy_dir(&source_dir_clone, &skill_dest_dir_clone)
})
.await
.with_context(|| {
format!(
"Failed to copy skill directory from {} to {}",
source_dir.display(),
skill_dest_dir.display()
)
})??;
if !applied_patches.is_empty() {
tracing::debug!(
"Applying {} patches to skill SKILL.md file",
applied_patches.total_count()
);
let installed_skill_md = skill_dest_dir.join("SKILL.md");
let skill_md_content = tokio::fs::read_to_string(&installed_skill_md).await?;
let (patched_content, _) = crate::skills::patches::apply_skill_patches_preview(
&skill_md_content,
&applied_patches.project,
&applied_patches.private,
)?;
tokio::fs::write(&installed_skill_md, patched_content).await?;
}
Ok(true)
}
pub async fn compute_skill_directory_checksum(
entry: &LockedResource,
context: &InstallContext<'_>,
) -> Result<String> {
let checksum_path = get_skill_source_directory(entry, context).await?;
tracing::debug!(
"Computing directory checksum for skill '{}' from path: {}",
entry.name,
checksum_path.display()
);
let checksum = LockFile::compute_directory_checksum(&checksum_path)?;
tracing::debug!(
"Calculated directory checksum for skill {}: {} (from: {})",
entry.name,
checksum,
checksum_path.display()
);
Ok(checksum)
}
pub async fn get_skill_source_directory(
entry: &LockedResource,
context: &InstallContext<'_>,
) -> Result<PathBuf> {
use crate::core::file_error::{FileOperation, FileResultExt};
if let Some(source_name) = &entry.source {
let is_local_source = entry.resolved_commit.as_deref().is_none_or(str::is_empty);
if is_local_source {
let manifest = context
.manifest
.ok_or_else(|| anyhow::anyhow!("Manifest not available for local skill"))?;
let manifest_dir = manifest.manifest_dir.as_ref().ok_or_else(|| {
anyhow::anyhow!("Manifest directory not available for local skill")
})?;
let skill_path = manifest_dir.join(&entry.path);
Ok(skill_path.canonicalize().with_file_context(
FileOperation::Canonicalize,
&skill_path,
format!("resolving local skill path for {}", entry.name),
"get_skill_source_directory",
)?)
} else {
let url = entry
.url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Resource {} has no URL", entry.name))?;
let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"Resource {} missing resolved commit SHA. Run 'agpm update' to regenerate lockfile.",
entry.name
)
})?;
if sha.len() != 40 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!(
"Invalid SHA '{}' for resource {}. Expected 40 hex characters.",
sha,
entry.name
));
}
let cache_dir = context
.cache
.get_or_create_worktree_for_sha(source_name, url, sha, Some(&entry.name))
.await?;
Ok(cache_dir.join(&entry.path))
}
} else {
tracing::debug!("Processing local skill with no source: path='{}'", entry.path);
let candidate = Path::new(&entry.path);
Ok(if candidate.is_absolute() {
candidate.to_path_buf()
} else {
let manifest = context
.manifest
.ok_or_else(|| anyhow::anyhow!("Manifest not available for local skill"))?;
let manifest_dir = manifest.manifest_dir.as_ref().ok_or_else(|| {
anyhow::anyhow!("Manifest directory not available for local skill")
})?;
manifest_dir.join(&entry.path)
})
}
}