fn skill_description(name: &str) -> &'static str {
match name {
"rhei-plan-writer" => "create and validate Rhei Plans",
"rhei-plan-worker" => "execute tasks in a Rhei Plan",
"rhei-state-machine-writer" => "design custom state machines from project specs and teams",
"rhei-template-writer" => "create and edit reusable Rhei Templates",
_ => "rhei skill",
}
}
fn install_cursor(
skill_sources: &[(String, PathBuf)],
local: bool,
_link: bool,
dry_run: bool,
project_root: Option<&Path>,
) -> MietteResult<()> {
let base = if local {
require_project_root(project_root)?.join(".cursor")
} else {
home_dir()?.join(".cursor")
};
let rules_dir = base.join("rules");
for (name, source) in skill_sources {
let skill_md = source.join("SKILL.md");
let content = fs::read_to_string(&skill_md)
.map_err(|e| file_io_report(&skill_md, "failed to read", e))?;
let description = skill_description(name);
let mdc_content = format!(
"---\ndescription: {description}\nglobs:\n - \"**/*.rhei.md\"\nalwaysApply: false\n---\n\n{content}"
);
let dest = rules_dir.join(format!("{name}.mdc"));
if dry_run {
println!(" [dry-run] would write {}", dest.display());
continue;
}
fs::create_dir_all(&rules_dir)
.map_err(|e| file_io_report(&rules_dir, "failed to create", e))?;
fs::write(&dest, &mdc_content)
.map_err(|e| file_io_report(&dest, "failed to write", e))?;
println!(" ✓ {} — written", dest.display());
}
Ok(())
}
fn install_rules_dir_agent(
dir_name: &str,
skill_sources: &[(String, PathBuf)],
local: bool,
link: bool,
dry_run: bool,
project_root: Option<&Path>,
) -> MietteResult<()> {
let base = if local {
require_project_root(project_root)?.join(dir_name)
} else {
home_dir()?.join(dir_name)
};
let rules_dir = base.join("rules");
for (name, source) in skill_sources {
let dest = rules_dir.join(format!("{name}.md"));
if link {
let skill_md = source.join("SKILL.md");
let src = if local { relative_path(&rules_dir, &skill_md) } else { skill_md };
link_skill(&src, &dest, dry_run)?;
} else {
let skill_md = source.join("SKILL.md");
let content = fs::read_to_string(&skill_md)
.map_err(|e| file_io_report(&skill_md, "failed to read", e))?;
if dry_run {
println!(" [dry-run] would write {}", dest.display());
continue;
}
fs::create_dir_all(&rules_dir)
.map_err(|e| file_io_report(&rules_dir, "failed to create", e))?;
fs::write(&dest, &content)
.map_err(|e| file_io_report(&dest, "failed to write", e))?;
println!(" ✓ {} — written", dest.display());
}
}
Ok(())
}
fn install_windsurf(
skill_sources: &[(String, PathBuf)],
local: bool,
dry_run: bool,
project_root: Option<&Path>,
) -> MietteResult<()> {
let file = if local {
require_project_root(project_root)?
.join(".windsurfrules")
} else {
let alt = home_dir()?.join(".codeium/windsurf/memories/global_rules.md");
if alt.exists() {
alt
} else {
home_dir()?.join(".windsurfrules")
}
};
let content = build_marker_content(skill_sources)?;
inject_marked_section(&file, &content, dry_run)?;
if !dry_run {
println!(" ✓ {} — appended rhei section", file.display());
}
Ok(())
}
fn install_copilot(
skill_sources: &[(String, PathBuf)],
local: bool,
dry_run: bool,
project_root: Option<&Path>,
) -> MietteResult<()> {
let file = if local {
require_project_root(project_root)?
.join(".github/copilot-instructions.md")
} else {
home_dir()?.join(".github/copilot-instructions.md")
};
let content = build_marker_content(skill_sources)?;
inject_marked_section(&file, &content, dry_run)?;
if !dry_run {
println!(" ✓ {} — appended rhei section", file.display());
}
Ok(())
}
fn install_codex(
skill_sources: &[(String, PathBuf)],
local: bool,
link: bool,
dry_run: bool,
project_root: Option<&Path>,
) -> MietteResult<()> {
let base = if local {
require_project_root(project_root)?.join(".agents")
} else {
home_dir()?.join(".agents")
};
let skills_dir = base.join("skills");
for (name, source) in skill_sources {
if link {
let dest = skills_dir.join(name);
let src =
if local { relative_path(dest.parent().unwrap(), source) } else { source.clone() };
link_skill(&src, &dest, dry_run)?;
} else {
let dest = skills_dir.join(name);
copy_skill(source, &dest, dry_run)?;
}
}
Ok(())
}
fn build_marker_content(skill_sources: &[(String, PathBuf)]) -> MietteResult<String> {
let mut parts = Vec::new();
for (name, source) in skill_sources {
let skill_md = source.join("SKILL.md");
let content = fs::read_to_string(&skill_md)
.map_err(|e| file_io_report(&skill_md, "failed to read", e))?;
parts.push(format!(
"## rhei-{name}\n\nWhen the user asks to create/execute a Rhei plan, follow these instructions:\n\n{content}"
));
}
Ok(parts.join("\n\n"))
}
fn uninstall_agent(
agent: &Agent,
local: bool,
dry_run: bool,
skills: &[String],
project_root: Option<&Path>,
) -> MietteResult<()> {
match agent {
Agent::ClaudeCode => {
let base = if local {
require_project_root(project_root)?
.join(".claude")
} else {
home_dir()?.join(".claude")
};
for skill in skills {
let dest = base.join("skills").join(skill);
remove_path(&dest, dry_run)?;
}
let claude_md = base.join("CLAUDE.md");
remove_marked_section(&claude_md, dry_run)?;
}
Agent::Cursor => {
let base = if local {
require_project_root(project_root)?
.join(".cursor")
} else {
home_dir()?.join(".cursor")
};
for skill in skills {
let dest = base.join("rules").join(format!("{skill}.mdc"));
remove_path(&dest, dry_run)?;
}
}
Agent::Windsurf => {
let file = if local {
require_project_root(project_root)?
.join(".windsurfrules")
} else {
let alt = home_dir()?.join(".codeium/windsurf/memories/global_rules.md");
if alt.exists() {
alt
} else {
home_dir()?.join(".windsurfrules")
}
};
remove_marked_section(&file, dry_run)?;
}
Agent::Copilot => {
let file = if local {
require_project_root(project_root)?
.join(".github/copilot-instructions.md")
} else {
home_dir()?.join(".github/copilot-instructions.md")
};
remove_marked_section(&file, dry_run)?;
}
Agent::Codex => {
let base = if local {
require_project_root(project_root)?
.join(".agents")
} else {
home_dir()?.join(".agents")
};
for skill in skills {
let dest = base.join("skills").join(skill);
remove_path(&dest, dry_run)?;
}
}
Agent::Kilocode | Agent::Pi | Agent::Antigravity => {
let dir_name = match agent {
Agent::Kilocode => ".kilocode",
Agent::Pi => ".pi",
Agent::Antigravity => ".antigravity",
_ => unreachable!(),
};
let base = if local {
require_project_root(project_root)?
.join(dir_name)
} else {
home_dir()?.join(dir_name)
};
for skill in skills {
let dest = base.join("rules").join(format!("{skill}.md"));
remove_path(&dest, dry_run)?;
}
}
Agent::All => {} }
println!(" ✓ uninstalled");
Ok(())
}
fn remove_path(path: &Path, dry_run: bool) -> MietteResult<()> {
if !path.exists() && path.symlink_metadata().is_err() {
return Ok(());
}
if dry_run {
println!(" [dry-run] would remove {}", path.display());
return Ok(());
}
if path.is_dir() && !path.is_symlink() {
fs::remove_dir_all(path)
.map_err(|e| file_io_report(path, "failed to remove", e))?;
} else {
fs::remove_file(path).map_err(|e| file_io_report(path, "failed to remove", e))?;
}
Ok(())
}
fn copy_skill(src: &Path, dest: &Path, dry_run: bool) -> MietteResult<()> {
if dry_run {
println!(" [dry-run] would copy {} → {}", src.display(), dest.display());
return Ok(());
}
if dest.exists() {
fs::remove_dir_all(dest)
.map_err(|e| file_io_report(dest, "failed to remove existing", e))?;
}
copy_dir_recursive(src, dest)?;
println!(" ✓ {} — written", dest.display());
Ok(())
}
fn copy_dir_recursive(src: &Path, dest: &Path) -> MietteResult<()> {
fs::create_dir_all(dest)
.map_err(|e| file_io_report(dest, "failed to create directory", e))?;
for entry in fs::read_dir(src)
.map_err(|e| file_io_report(src, "failed to read directory", e))?
{
let entry = entry.map_err(|e| file_io_report(src, "failed to read directory entry in", e))?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dest_path)?;
} else {
fs::copy(&src_path, &dest_path)
.map_err(|e| file_io_report(&dest_path, "failed to write", e))?;
}
}
Ok(())
}
fn link_skill(src: &Path, dest: &Path, dry_run: bool) -> MietteResult<()> {
if dry_run {
println!(" [dry-run] would symlink {} → {}", dest.display(), src.display());
return Ok(());
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|e| file_io_report(parent, "failed to create directory", e))?;
}
if dest.symlink_metadata().is_ok() {
if dest.is_dir() && !dest.is_symlink() {
fs::remove_dir_all(dest)
.map_err(|e| file_io_report(dest, "failed to remove existing", e))?;
} else {
fs::remove_file(dest)
.map_err(|e| file_io_report(dest, "failed to remove existing", e))?;
}
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(src, dest)
.map_err(|e| file_io_report(dest, "failed to symlink", e))?;
println!(" ✓ {} → {}", dest.display(), src.display());
Ok(())
}
#[cfg(not(unix))]
{
Err(miette!(
help = "drop --link and rhei will copy the skill files instead.",
"symlinks are only supported on Unix platforms"
))
}
}