ccd-cli 1.0.0-beta.4

Bootstrap and validate Continuous Context Development repositories
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);
            // For local install, skip Codex because .agents/skills is the
            // repo-shipped mirror, not a local install target.
            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()))?;
        }

        // Remove existing symlink or file before writing
        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)
}

/// Warn about skill directories under `target_base` that start with `ccd-`
/// but are not in the current embedded set.  These may be leftover from a
/// previous CCD version or installed by an extension — install only warns,
/// it does not delete them.
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('\'', "'\"'\"'"))
}