use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{bail, Context, Result};
use serde::Serialize;
use crate::output::CommandReport;
use crate::shipped_skills::{
self, embedded_skill_files, embedded_skill_names, render_skill_for_runtime, HostSkillHeaders,
};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
const CLI_SHIM_RELATIVE_PATH: &str = ".ccd/bin/ccd";
pub(crate) struct RuntimeTarget {
pub(crate) name: &'static str,
pub(crate) global_dir: &'static str,
pub(crate) local_dir: &'static str,
}
pub(crate) const RUNTIMES: &[RuntimeTarget] = &[
RuntimeTarget {
name: "claude",
global_dir: ".claude/skills",
local_dir: ".claude/skills",
},
RuntimeTarget {
name: "codex",
global_dir: ".codex/skills",
local_dir: ".agents/skills",
},
RuntimeTarget {
name: "gemini",
global_dir: ".gemini/skills",
local_dir: ".gemini/skills",
},
];
#[derive(Serialize)]
pub struct SkillsInstallReport {
command: &'static str,
ok: bool,
mode: &'static str,
installed: Vec<InstalledSkill>,
skipped_runtimes: Vec<SkippedRuntime>,
#[serde(skip_serializing_if = "Option::is_none")]
cli_shim: Option<CliShim>,
#[serde(skip_serializing_if = "Vec::is_empty")]
warnings: Vec<String>,
}
#[derive(Serialize)]
struct InstalledSkill {
runtime: String,
path: String,
skill: String,
}
#[derive(Serialize)]
struct SkippedRuntime {
runtime: String,
reason: String,
}
#[derive(Serialize)]
struct CliShim {
path: String,
target: String,
}
impl CommandReport for SkillsInstallReport {
fn exit_code(&self) -> ExitCode {
ExitCode::SUCCESS
}
fn render_text(&self) {
if self.installed.is_empty() {
println!("No skills installed (no matching runtimes found).");
} else {
for entry in &self.installed {
println!(
"Installed {}/{} -> {}",
entry.runtime, entry.skill, entry.path
);
}
}
for skip in &self.skipped_runtimes {
println!("Skipped {}: {}", skip.runtime, skip.reason);
}
if let Some(cli_shim) = &self.cli_shim {
println!(
"Registered CLI shim -> {} (target: {})",
cli_shim.path, cli_shim.target
);
}
for warning in &self.warnings {
println!("Warning: {warning}");
}
}
}
pub fn run(repo_root: &Path, global: bool) -> Result<SkillsInstallReport> {
let mut installed = Vec::new();
let mut skipped_runtimes = Vec::new();
let mut cli_shim = None;
let mut warnings = Vec::new();
if global {
let home = home_dir()?;
for runtime in RUNTIMES {
let target_base = home.join(runtime.global_dir);
if !home.join(format!(".{}", runtime.name)).is_dir() {
skipped_runtimes.push(SkippedRuntime {
runtime: runtime.name.to_owned(),
reason: format!(
"~/.{} does not exist; {} not detected",
runtime.name, runtime.name
),
});
continue;
}
installed.extend(write_skills(&target_base, runtime.name)?);
warn_stale_skills(&target_base, runtime.name, &mut warnings);
}
match register_cli_shim(&home) {
Ok(shim) => cli_shim = Some(shim),
Err(error) => warnings.push(format!("failed to register CLI shim: {error}")),
}
} else {
for runtime in RUNTIMES {
let target_base = repo_root.join(runtime.local_dir);
if runtime.local_dir == ".agents/skills" {
continue;
}
installed.extend(write_skills(&target_base, runtime.name)?);
warn_stale_skills(&target_base, runtime.name, &mut warnings);
}
}
Ok(SkillsInstallReport {
command: "skills-install",
ok: true,
mode: if global { "global" } else { "local" },
installed,
skipped_runtimes,
cli_shim,
warnings,
})
}
fn write_skills(target_base: &Path, runtime_name: &str) -> Result<Vec<InstalledSkill>> {
let mut installed = Vec::new();
let headers = HostSkillHeaders {
claude: "",
gemini: shipped_skills::DEFAULT_GEMINI_HEADER,
};
for skill_file in embedded_skill_files() {
let dest = target_base.join(skill_file.relative_path);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
if dest.is_symlink() || dest.exists() {
fs::remove_file(&dest)
.with_context(|| format!("failed to remove existing {}", dest.display()))?;
}
let contents = render_skill_for_runtime(
Path::new(skill_file.relative_path),
skill_file.contents,
runtime_name,
headers,
);
fs::write(&dest, contents)
.with_context(|| format!("failed to write {}", dest.display()))?;
installed.push(InstalledSkill {
runtime: runtime_name.to_owned(),
path: dest.display().to_string(),
skill: skill_file.skill.to_owned(),
});
}
Ok(installed)
}
fn warn_stale_skills(target_base: &Path, runtime_name: &str, warnings: &mut Vec<String>) {
let current_names = embedded_skill_names();
let entries = match fs::read_dir(target_base) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => continue,
};
if !dir_name.starts_with("ccd-") {
continue;
}
if current_names.contains(&dir_name) {
continue;
}
warnings.push(format!(
"stale skill `{dir_name}` found for {runtime_name} at {}; remove it manually if it is no longer needed",
path.display()
));
}
}
fn home_dir() -> Result<PathBuf> {
match std::env::var_os("HOME") {
Some(home) => Ok(PathBuf::from(home)),
None => bail!("HOME environment variable is not set"),
}
}
fn register_cli_shim(home: &Path) -> Result<CliShim> {
let target = std::env::current_exe()
.context("failed to resolve current executable")?
.canonicalize()
.context("failed to canonicalize current executable path")?;
let shim = home.join(CLI_SHIM_RELATIVE_PATH);
if let Some(parent) = shim.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
if shim.is_symlink() || shim.exists() {
fs::remove_file(&shim)
.with_context(|| format!("failed to remove existing {}", shim.display()))?;
}
let script = format!("#!/bin/sh\nexec {} \"$@\"\n", shell_quote(&target));
fs::write(&shim, script).with_context(|| format!("failed to write {}", shim.display()))?;
#[cfg(unix)]
fs::set_permissions(&shim, fs::Permissions::from_mode(0o755))
.with_context(|| format!("failed to chmod {}", shim.display()))?;
Ok(CliShim {
path: shim.display().to_string(),
target: target.display().to_string(),
})
}
fn shell_quote(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', "'\"'\"'"))
}