use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use crate::config::Paths;
use crate::persona::{Persona, SkillsConfig};
use crate::symlink::{self, is_symlink, replace_with_symlink};
const CC_PERSONA_SKILL: &str = "cc-persona";
#[derive(Debug, Default)]
pub struct ReconcileReport {
pub linked: Vec<String>,
pub unlinked: Vec<String>,
pub untracked: Vec<String>,
pub ghosts: Vec<String>,
pub shadowed: Vec<String>,
pub foreign_links: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillEntry {
pub name: String,
pub disabled: bool,
pub managed: bool,
}
pub fn reconcile_skills(
skills_dir: &Path,
skill_store: &Path,
persona: &Persona,
include_cc_persona: bool,
) -> Result<ReconcileReport> {
let mut report = ReconcileReport::default();
let mut desired: Vec<String> = persona
.skills
.as_ref()
.map(|s| s.active.clone())
.unwrap_or_default();
if include_cc_persona && !desired.iter().any(|n| n == CC_PERSONA_SKILL) {
desired.push(CC_PERSONA_SKILL.to_string());
}
symlink::ensure_real_dir(skills_dir)?;
for entry in std::fs::read_dir(skills_dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if is_symlink(&path) {
let target = std::fs::read_link(&path)?;
if !target_in_store(&target, skill_store) {
report.foreign_links.push(name);
continue;
}
if !desired.iter().any(|d| d == &name) {
std::fs::remove_file(&path)
.with_context(|| format!("Failed to unlink {}", path.display()))?;
report.unlinked.push(name);
}
} else if path.is_dir() {
report.untracked.push(name);
}
}
for name in &desired {
let store_skill = skill_store.join(name);
if !store_skill.exists() {
report.ghosts.push(name.clone());
continue;
}
let link = skills_dir.join(name);
if is_symlink(&link) {
continue;
}
if link.exists() {
report.shadowed.push(name.clone());
continue;
}
symlink::link_skill(&store_skill, &link)?;
report.linked.push(name.clone());
}
report.linked.sort();
report.unlinked.sort();
report.untracked.sort();
report.ghosts.sort();
report.shadowed.sort();
report.foreign_links.sort();
Ok(report)
}
fn target_in_store(target: &Path, skill_store: &Path) -> bool {
let resolved = if target.is_absolute() {
target.to_path_buf()
} else {
return std::fs::canonicalize(target)
.ok()
.zip(std::fs::canonicalize(skill_store).ok())
.map(|(t, store)| t.starts_with(&store))
.unwrap_or(false);
};
match (
std::fs::canonicalize(&resolved).ok(),
std::fs::canonicalize(skill_store).ok(),
) {
(Some(t), Some(store)) => t.starts_with(&store),
_ => resolved.starts_with(skill_store),
}
}
pub fn list_skills_ext(skills_dir: &Path) -> Result<Vec<SkillEntry>> {
let mut result = Vec::new();
if !skills_dir.exists() {
return Ok(result);
}
for entry in std::fs::read_dir(skills_dir)? {
let entry = entry?;
let path = entry.path();
let managed = is_symlink(&path);
if !path.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
let content = std::fs::read_to_string(&skill_md)?;
let disabled = is_disabled(&content);
result.push(SkillEntry {
name,
disabled,
managed,
});
}
result.sort_by(|a, b| a.name.cmp(&b.name));
Ok(result)
}
#[allow(dead_code)]
pub fn switch_skills_symlink(paths: &Paths, persona_name: &str) -> Result<()> {
let target = paths.skill_sets.join(persona_name);
if !target.exists() {
std::fs::create_dir_all(&target)?;
}
if paths.claude_skills.exists()
&& !is_symlink(&paths.claude_skills)
&& paths.claude_skills.is_dir()
{
eprintln!(
" Migrating existing skills directory into persona '{}'...",
persona_name
);
migrate_dir_contents(&paths.claude_skills, &target)?;
std::fs::remove_dir_all(&paths.claude_skills)
.context("Failed to remove original skills directory after migration")?;
}
replace_with_symlink(&paths.claude_skills, &target)
.context("Failed to switch skills symlink")?;
Ok(())
}
#[allow(dead_code)]
pub fn resolve_skills_dir(paths: &Paths) -> Result<PathBuf> {
if paths.claude_skills.is_symlink() {
Ok(std::fs::read_link(&paths.claude_skills)?)
} else {
Ok(paths.claude_skills.clone())
}
}
fn migrate_dir_contents(src: &Path, dst: &Path) -> Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if dst_path.exists() {
continue; }
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[allow(dead_code)]
pub fn apply_skill_toggles(skills_dir: &Path, config: &SkillsConfig) -> Result<()> {
if !skills_dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(skills_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
let is_active = config.active.iter().any(|a| a == &skill_name);
set_skill_disabled(&skill_md, !is_active)?;
}
Ok(())
}
pub fn clear_disable_flag(skill_md: &Path) -> Result<()> {
if !skill_md.exists() {
return Ok(());
}
let content = std::fs::read_to_string(skill_md)?;
if is_disabled(&content) {
set_skill_disabled(skill_md, false)?;
}
Ok(())
}
pub fn toggle_skill(skill_md: &Path) -> Result<bool> {
let content = std::fs::read_to_string(skill_md)?;
let currently_disabled = is_disabled(&content);
set_skill_disabled(skill_md, !currently_disabled)?;
Ok(!currently_disabled)
}
#[allow(dead_code)]
pub fn list_skills(skills_dir: &Path) -> Result<Vec<(String, bool)>> {
let mut result = Vec::new();
if !skills_dir.exists() {
return Ok(result);
}
for entry in std::fs::read_dir(skills_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
let content = std::fs::read_to_string(&skill_md)?;
let disabled = is_disabled(&content);
result.push((skill_name, disabled));
}
result.sort_by(|a, b| a.0.cmp(&b.0));
Ok(result)
}
#[allow(dead_code)]
pub fn remove_skill(skills_dir: &Path, name: &str) -> Result<()> {
let skill_dir = skills_dir.join(name);
if skill_dir.exists() {
std::fs::remove_dir_all(&skill_dir)
.with_context(|| format!("Failed to remove skill: {}", name))?;
}
Ok(())
}
pub fn unlink_skill(link: &Path) -> Result<()> {
if is_symlink(link) {
std::fs::remove_file(link)
.with_context(|| format!("Failed to unlink {}", link.display()))?;
}
Ok(())
}
pub fn remove_from_store(store_skill: &Path) -> Result<()> {
if store_skill.exists() {
std::fs::remove_dir_all(store_skill)
.with_context(|| format!("Failed to remove store skill: {}", store_skill.display()))?;
}
Ok(())
}
fn is_disabled(content: &str) -> bool {
if let Some(fm) = extract_frontmatter(content) {
fm.contains("disable-model-invocation: true")
|| fm.contains("disable-model-invocation:true")
} else {
false
}
}
fn set_skill_disabled(path: &Path, disabled: bool) -> Result<()> {
let content = std::fs::read_to_string(path)?;
let new_content = if disabled {
ensure_frontmatter_field(&content, "disable-model-invocation", "true")
} else {
remove_frontmatter_field(&content, "disable-model-invocation")
};
std::fs::write(path, new_content)?;
Ok(())
}
fn extract_frontmatter(content: &str) -> Option<&str> {
let content = content.trim_start();
if !content.starts_with("---") {
return None;
}
let rest = &content[3..];
if let Some(end) = rest.find("\n---") {
Some(&rest[..end])
} else {
None
}
}
fn ensure_frontmatter_field(content: &str, key: &str, value: &str) -> String {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return format!("---\n{}: {}\n---\n{}", key, value, content);
}
let rest = &trimmed[3..];
if let Some(end) = rest.find("\n---") {
let fm = &rest[..end];
let after = &rest[end + 4..];
let field_prefix = format!("{}:", key);
let new_fm: String = if fm
.lines()
.any(|l| l.trim_start().starts_with(&field_prefix))
{
fm.lines()
.map(|l| {
if l.trim_start().starts_with(&field_prefix) {
format!("{}: {}", key, value)
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
} else {
format!("{}\n{}: {}", fm, key, value)
};
format!("---{}\n---{}", new_fm, after)
} else {
content.to_string()
}
}
fn remove_frontmatter_field(content: &str, key: &str) -> String {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return content.to_string();
}
let rest = &trimmed[3..];
if let Some(end) = rest.find("\n---") {
let fm = &rest[..end];
let after = &rest[end + 4..];
let field_prefix = format!("{}:", key);
let new_fm: String = fm
.lines()
.filter(|l| !l.trim_start().starts_with(&field_prefix))
.collect::<Vec<_>>()
.join("\n");
format!("---{}\n---{}", new_fm, after)
} else {
content.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TestEnv;
#[test]
fn is_disabled_only_checks_frontmatter() {
let frontmatter = "---\nname: demo\ndisable-model-invocation: true\n---\nBody\n";
let body_only = "# Title\n\ndisable-model-invocation: true\n";
assert!(is_disabled(frontmatter));
assert!(!is_disabled(body_only));
}
#[test]
fn ensure_frontmatter_field_handles_missing_existing_and_replacement() {
let no_frontmatter = "Body\n";
let added = ensure_frontmatter_field(no_frontmatter, "disable-model-invocation", "true");
assert!(added.starts_with("---\ndisable-model-invocation: true\n---\n"));
assert!(added.ends_with("Body\n"));
let with_frontmatter = "---\nname: demo\n---\nBody\n";
let inserted =
ensure_frontmatter_field(with_frontmatter, "disable-model-invocation", "true");
assert!(inserted.contains("name: demo"));
assert!(inserted.contains("disable-model-invocation: true"));
assert!(inserted.ends_with("\nBody\n"));
let with_existing = "---\nname: demo\ndisable-model-invocation: false\n---\nBody\n";
let replaced = ensure_frontmatter_field(with_existing, "disable-model-invocation", "true");
assert!(replaced.contains("disable-model-invocation: true"));
assert!(!replaced.contains("disable-model-invocation: false"));
assert!(replaced.contains("name: demo"));
}
#[test]
fn remove_frontmatter_field_preserves_other_fields_and_body() {
let content =
"---\nname: demo\ndescription: test\ndisable-model-invocation: true\n---\nBody\n";
let updated = remove_frontmatter_field(content, "disable-model-invocation");
assert!(updated.contains("name: demo"));
assert!(updated.contains("description: test"));
assert!(!updated.contains("disable-model-invocation"));
assert!(updated.ends_with("\nBody\n"));
}
#[test]
fn toggle_skill_round_trips_disable_flag() {
let env = TestEnv::new();
let skill_md = env.paths.root.join("demo").join("SKILL.md");
env.write_file(&skill_md, "---\nname: demo\n---\nBody\n");
let disabled = toggle_skill(&skill_md).unwrap();
assert!(disabled);
assert!(is_disabled(&env.read_file(&skill_md)));
let enabled = toggle_skill(&skill_md).unwrap();
assert!(!enabled);
assert!(!is_disabled(&env.read_file(&skill_md)));
}
#[test]
fn apply_skill_toggles_updates_each_skill_based_on_active_list() {
let env = TestEnv::new();
let skills_dir = env.paths.root.join("skills");
std::fs::create_dir_all(&skills_dir).unwrap();
env.create_skill(&skills_dir, "alpha", "---\nname: alpha\n---\n");
env.create_skill(
&skills_dir,
"beta",
"---\nname: beta\ndisable-model-invocation: true\n---\n",
);
apply_skill_toggles(
&skills_dir,
&SkillsConfig {
active: vec!["alpha".to_string()],
},
)
.unwrap();
assert!(!is_disabled(
&env.read_file(&skills_dir.join("alpha").join("SKILL.md"))
));
assert!(is_disabled(
&env.read_file(&skills_dir.join("beta").join("SKILL.md"))
));
}
#[cfg(unix)]
fn persona_with_active(active: &[&str]) -> Persona {
Persona {
skills: Some(SkillsConfig {
active: active.iter().map(|s| s.to_string()).collect(),
}),
..Default::default()
}
}
#[cfg(unix)]
fn reconcile(env: &TestEnv, persona: &Persona) -> Result<ReconcileReport> {
reconcile_skills(
&env.paths.claude_skills,
&env.paths.skill_store,
persona,
true,
)
}
#[cfg(unix)]
fn read_link_name(path: &Path) -> String {
std::fs::read_link(path)
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.to_string()
}
#[cfg(unix)]
#[test]
fn reconcile_links_only_active_skills_plus_cc_persona() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("alpha", "---\nname: alpha\n---\n");
env.create_store_skill("beta", "---\nname: beta\n---\n");
env.create_store_skill("cc-persona", "---\nname: cc-persona\n---\n");
let report = reconcile(&env, &persona_with_active(&["alpha"])).unwrap();
assert_eq!(report.linked, vec!["alpha", "cc-persona"]);
assert!(env.paths.claude_skills.join("alpha").is_symlink());
assert!(env.paths.claude_skills.join("cc-persona").is_symlink());
assert!(!env.paths.claude_skills.join("beta").exists());
assert_eq!(
read_link_name(&env.paths.claude_skills.join("alpha")),
"alpha"
);
}
#[cfg(unix)]
#[test]
fn reconcile_unlinks_skill_removed_from_active() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("alpha", "---\nname: alpha\n---\n");
env.create_store_skill("cc-persona", "---\nname: cc-persona\n---\n");
reconcile(&env, &persona_with_active(&["alpha"])).unwrap();
assert!(env.paths.claude_skills.join("alpha").is_symlink());
let report = reconcile(&env, &persona_with_active(&[])).unwrap();
assert_eq!(report.unlinked, vec!["alpha"]);
assert!(!env.paths.claude_skills.join("alpha").exists());
assert!(env.paths.claude_skills.join("cc-persona").is_symlink());
}
#[cfg(unix)]
#[test]
fn reconcile_preserves_wild_real_directory_as_untracked() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("cc-persona", "---\nname: cc-persona\n---\n");
let wild = env.paths.claude_skills.join("wild");
std::fs::create_dir_all(&wild).unwrap();
env.write_file(&wild.join("SKILL.md"), "---\nname: wild\n---\nBody\n");
let report = reconcile(&env, &persona_with_active(&[])).unwrap();
assert_eq!(report.untracked, vec!["wild"]);
assert!(!wild.is_symlink());
assert!(wild.is_dir());
assert_eq!(
env.read_file(&wild.join("SKILL.md")),
"---\nname: wild\n---\nBody\n"
);
}
#[cfg(unix)]
#[test]
fn reconcile_records_ghost_when_store_dir_missing() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("cc-persona", "---\nname: cc-persona\n---\n");
let report = reconcile(&env, &persona_with_active(&["missing"])).unwrap();
assert_eq!(report.ghosts, vec!["missing"]);
assert!(!env.paths.claude_skills.join("missing").exists());
}
#[cfg(unix)]
#[test]
fn reconcile_records_shadowed_when_wild_dir_blocks_desired_link() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("alpha", "---\nname: alpha\n---\n");
env.create_store_skill("cc-persona", "---\nname: cc-persona\n---\n");
let blocker = env.paths.claude_skills.join("alpha");
std::fs::create_dir_all(&blocker).unwrap();
env.write_file(&blocker.join("SKILL.md"), "wild alpha");
let report = reconcile(&env, &persona_with_active(&["alpha"])).unwrap();
assert_eq!(report.shadowed, vec!["alpha"]);
assert!(!blocker.is_symlink());
assert_eq!(env.read_file(&blocker.join("SKILL.md")), "wild alpha");
}
#[cfg(unix)]
#[test]
fn reconcile_records_foreign_link_pointing_outside_store() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("cc-persona", "---\nname: cc-persona\n---\n");
std::fs::create_dir_all(&env.paths.claude_skills).unwrap();
let outside = env.paths.root.join("elsewhere");
std::fs::create_dir_all(&outside).unwrap();
let foreign = env.paths.claude_skills.join("foreign");
std::os::unix::fs::symlink(&outside, &foreign).unwrap();
let report = reconcile(&env, &persona_with_active(&[])).unwrap();
assert_eq!(report.foreign_links, vec!["foreign"]);
assert!(foreign.is_symlink());
}
#[cfg(unix)]
#[test]
fn reconcile_is_idempotent() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("alpha", "---\nname: alpha\n---\n");
env.create_store_skill("cc-persona", "---\nname: cc-persona\n---\n");
let first = reconcile(&env, &persona_with_active(&["alpha"])).unwrap();
assert_eq!(first.linked, vec!["alpha", "cc-persona"]);
let second = reconcile(&env, &persona_with_active(&["alpha"])).unwrap();
assert!(second.linked.is_empty());
assert!(second.unlinked.is_empty());
assert!(env.paths.claude_skills.join("alpha").is_symlink());
assert!(env.paths.claude_skills.join("cc-persona").is_symlink());
}
#[cfg(unix)]
#[test]
fn reconcile_always_links_cc_persona_even_when_not_in_active() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("cc-persona", "---\nname: cc-persona\n---\n");
let report = reconcile(&env, &persona_with_active(&[])).unwrap();
assert_eq!(report.linked, vec!["cc-persona"]);
assert!(env.paths.claude_skills.join("cc-persona").is_symlink());
}
#[cfg(unix)]
#[test]
fn reconcile_converts_legacy_symlink_dir_into_real_dir() {
let env = TestEnv::new();
env.paths.ensure_dirs().unwrap();
env.create_store_skill("cc-persona", "---\nname: cc-persona\n---\n");
std::fs::remove_dir_all(&env.paths.claude_skills).ok();
let legacy_target = env.paths.root.join("legacy-skillset");
std::fs::create_dir_all(&legacy_target).unwrap();
std::os::unix::fs::symlink(&legacy_target, &env.paths.claude_skills).unwrap();
assert!(env.paths.claude_skills.is_symlink());
reconcile(&env, &persona_with_active(&[])).unwrap();
assert!(!env.paths.claude_skills.is_symlink());
assert!(env.paths.claude_skills.is_dir());
assert!(env.paths.claude_skills.join("cc-persona").is_symlink());
}
}