use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::cli::OutputFormat;
use crate::error::AppError;
include!(concat!(env!("OUT_DIR"), "/generated_hosts.rs"));
pub const GIT_HARDEN_FLAGS: &[&str] = &[
"-c",
"credential.helper=",
"-c",
"core.askPass=",
"-c",
"protocol.allow=never",
"-c",
"protocol.https.allow=always",
"-c",
"http.followRedirects=false",
];
pub const GIT_HARDEN_ENV_REMOVE: &[&str] = &[
"GIT_SSH",
"GIT_SSH_COMMAND",
"GIT_PROXY_COMMAND",
"GIT_ASKPASS",
"GIT_EXEC_PATH",
];
pub const GIT_HARDEN_ENV_SET: &[(&str, &str)] = &[
("GIT_CONFIG_GLOBAL", "/dev/null"),
("GIT_CONFIG_SYSTEM", "/dev/null"),
("GIT_TERMINAL_PROMPT", "0"),
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DestinationStatus {
Absent,
EmptyDir,
NonEmptyDir,
File,
}
impl DestinationStatus {
pub fn as_envelope_str(self) -> &'static str {
match self {
DestinationStatus::Absent => "absent",
DestinationStatus::EmptyDir => "empty-dir",
DestinationStatus::NonEmptyDir => "non-empty-dir",
DestinationStatus::File => "file",
}
}
}
pub fn expand_tilde(template: &str) -> Result<PathBuf, AppError> {
let home = std::env::var("HOME").ok();
expand_tilde_with(template, home.as_deref())
}
pub fn expand_tilde_with(template: &str, home: Option<&str>) -> Result<PathBuf, AppError> {
let needs_home = template == "~" || template.starts_with("~/");
if !needs_home {
return Ok(PathBuf::from(template));
}
let home = home
.filter(|s| !s.is_empty())
.ok_or(AppError::MissingHome)?;
if template == "~" {
return Ok(PathBuf::from(home));
}
let rest = template
.strip_prefix("~/")
.expect("template starts with ~/ per the branch guard");
let mut p = PathBuf::from(home);
p.push(rest);
Ok(p)
}
pub fn check_destination(path: &Path) -> Result<DestinationStatus, AppError> {
match path.try_exists() {
Ok(false) => return Ok(DestinationStatus::Absent),
Ok(true) => {}
Err(e) => {
return Err(AppError::DestReadFailed {
path: path.to_path_buf(),
source: e,
});
}
}
let canonical = fs::canonicalize(path).map_err(|e| AppError::DestReadFailed {
path: path.to_path_buf(),
source: e,
})?;
let metadata = fs::metadata(&canonical).map_err(|e| AppError::DestReadFailed {
path: canonical.clone(),
source: e,
})?;
if metadata.is_file() {
return Err(AppError::DestIsFile { path: canonical });
}
if metadata.is_dir() {
let mut entries = fs::read_dir(&canonical).map_err(|e| AppError::DestReadFailed {
path: canonical.clone(),
source: e,
})?;
if entries.next().is_some() {
return Err(AppError::DestNotEmpty { path: canonical });
}
return Ok(DestinationStatus::EmptyDir);
}
Err(AppError::DestIsFile { path: canonical })
}
pub fn build_clone_command(url: &str, dest: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.args(GIT_HARDEN_FLAGS);
cmd.args(["clone", "--depth", "1"]);
cmd.arg(url);
cmd.arg(dest);
for var in GIT_HARDEN_ENV_REMOVE {
cmd.env_remove(var);
}
for (key, value) in GIT_HARDEN_ENV_SET {
cmd.env(key, value);
}
cmd
}
pub fn format_clone_command(url: &str, dest: &Path) -> String {
format!("git clone --depth 1 {url} {}", dest.display())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct InstallEnvelope {
pub action: &'static str,
pub host: &'static str,
pub mode: &'static str,
pub command: String,
pub destination: String,
pub destination_status: &'static str,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub would_succeed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<&'static str>,
}
const ACTION: &str = "skill-install";
const MODE_DRY_RUN: &str = "dry-run";
const MODE_INSTALL: &str = "install";
const STATUS_SUCCESS: &str = "success";
const STATUS_ERROR: &str = "error";
const REASON_DEST_NOT_EMPTY: &str = "destination-not-empty";
const REASON_DEST_IS_FILE: &str = "destination-is-file";
const REASON_HOME_NOT_SET: &str = "home-not-set";
const REASON_GIT_NOT_FOUND: &str = "git-not-found";
const REASON_GIT_CLONE_FAILED: &str = "git-clone-failed";
pub fn compute_install_envelope(
host: SkillHost,
dry_run: bool,
) -> Result<InstallEnvelope, AppError> {
let (url, dest_template) = resolve_host(host);
let host_str = host_envelope_str(host);
let mode_str = if dry_run { MODE_DRY_RUN } else { MODE_INSTALL };
let dest = match expand_tilde(dest_template) {
Ok(p) => p,
Err(AppError::MissingHome) => {
let command = format!("git clone --depth 1 {url} {dest_template}");
return Ok(InstallEnvelope {
action: ACTION,
host: host_str,
mode: mode_str,
command,
destination: dest_template.to_string(),
destination_status: DestinationStatus::Absent.as_envelope_str(),
status: STATUS_ERROR,
would_succeed: if dry_run { Some(false) } else { None },
exit_code: None,
reason: Some(REASON_HOME_NOT_SET),
});
}
Err(e) => return Err(e),
};
let command = format_clone_command(url, &dest);
let dest_str = dest.display().to_string();
let dest_status = match check_destination(&dest) {
Ok(s) => s,
Err(AppError::DestIsFile { .. }) => {
return Ok(InstallEnvelope {
action: ACTION,
host: host_str,
mode: mode_str,
command,
destination: dest_str,
destination_status: DestinationStatus::File.as_envelope_str(),
status: STATUS_ERROR,
would_succeed: if dry_run { Some(false) } else { None },
exit_code: None,
reason: Some(REASON_DEST_IS_FILE),
});
}
Err(AppError::DestNotEmpty { .. }) => {
return Ok(InstallEnvelope {
action: ACTION,
host: host_str,
mode: mode_str,
command,
destination: dest_str,
destination_status: DestinationStatus::NonEmptyDir.as_envelope_str(),
status: STATUS_ERROR,
would_succeed: if dry_run { Some(false) } else { None },
exit_code: None,
reason: Some(REASON_DEST_NOT_EMPTY),
});
}
Err(e) => return Err(e),
};
let dest_status_str = dest_status.as_envelope_str();
if dry_run {
return Ok(InstallEnvelope {
action: ACTION,
host: host_str,
mode: MODE_DRY_RUN,
command,
destination: dest_str,
destination_status: dest_status_str,
status: STATUS_SUCCESS,
would_succeed: Some(true),
exit_code: None,
reason: None,
});
}
let mut cmd = build_clone_command(url, &dest);
match spawn_git_clone(&mut cmd) {
Ok(()) => Ok(InstallEnvelope {
action: ACTION,
host: host_str,
mode: MODE_INSTALL,
command,
destination: dest_str,
destination_status: dest_status_str,
status: STATUS_SUCCESS,
would_succeed: None,
exit_code: Some(0),
reason: None,
}),
Err(AppError::GitCloneFailed { code }) => Ok(InstallEnvelope {
action: ACTION,
host: host_str,
mode: MODE_INSTALL,
command,
destination: dest_str,
destination_status: dest_status_str,
status: STATUS_ERROR,
would_succeed: None,
exit_code: Some(code),
reason: Some(REASON_GIT_CLONE_FAILED),
}),
Err(AppError::GitNotFound) => Ok(InstallEnvelope {
action: ACTION,
host: host_str,
mode: MODE_INSTALL,
command,
destination: dest_str,
destination_status: dest_status_str,
status: STATUS_ERROR,
would_succeed: None,
exit_code: None,
reason: Some(REASON_GIT_NOT_FOUND),
}),
Err(e) => Err(e),
}
}
fn spawn_git_clone(cmd: &mut Command) -> Result<(), AppError> {
match cmd.status() {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(AppError::GitCloneFailed {
code: status.code().unwrap_or(-1),
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(AppError::GitNotFound),
Err(e) => Err(AppError::Io(e)),
}
}
pub fn emit_result_text(env: &InstallEnvelope) -> String {
if env.status == STATUS_SUCCESS {
match env.mode {
"dry-run" => env.command.clone(),
_ => format!("Installed agent-native-cli into {}", env.destination),
}
} else {
let reason = env.reason.unwrap_or("unknown");
format!("error: {reason}: {}", env.destination)
}
}
pub fn emit_result_json(env: &InstallEnvelope) -> String {
serde_json::to_string_pretty(env)
.expect("InstallEnvelope serialization is infallible by construction")
}
pub fn run_install(host: SkillHost, dry_run: bool, output: OutputFormat) -> Result<i32, AppError> {
let envelope = compute_install_envelope(host, dry_run)?;
let rendered = match output {
OutputFormat::Text => emit_result_text(&envelope),
OutputFormat::Json => emit_result_json(&envelope),
};
crate::output::emit_line(&rendered);
Ok(if envelope.status == STATUS_SUCCESS {
0
} else {
1
})
}
#[cfg(test)]
mod tests {
use super::*;
use clap::ValueEnum;
fn skill_repo_url() -> &'static str {
resolve_host(SkillHost::ClaudeCode).0
}
#[test]
fn resolve_host_returns_expected_pair_for_every_variant() {
let fixture_text = include_str!("skill_install/skill.json");
let fixture: serde_json::Value =
serde_json::from_str(fixture_text).expect("fixture is valid JSON");
let install = fixture
.get("install")
.and_then(|v| v.as_object())
.expect("fixture has install map");
for &host_name in KNOWN_HOSTS {
let cmd = install
.get(host_name)
.and_then(|v| v.as_str())
.unwrap_or_else(|| panic!("fixture missing install.{host_name}"));
let tokens: Vec<&str> = cmd.split_whitespace().collect();
let expected_url = tokens[4];
let expected_dest = tokens[5];
let host = SkillHost::from_str(host_name, false)
.unwrap_or_else(|_| panic!("KNOWN_HOSTS entry {host_name:?} unparseable"));
let (url, dest) = resolve_host(host);
assert_eq!(url, expected_url, "url mismatch for {host_name}");
assert_eq!(dest, expected_dest, "dest mismatch for {host_name}");
}
}
#[test]
fn known_hosts_matches_skill_host_variant_count_and_names() {
let variant_names: Vec<String> = SkillHost::value_variants()
.iter()
.map(|v| {
v.to_possible_value()
.expect("clap ValueEnum variant always has a possible value")
.get_name()
.to_string()
})
.collect();
let known: Vec<String> = KNOWN_HOSTS.iter().map(|s| (*s).to_string()).collect();
assert_eq!(
variant_names, known,
"SkillHost variants and KNOWN_HOSTS must stay in lockstep",
);
}
#[test]
fn git_harden_env_set_disables_user_config() {
let pairs: std::collections::HashMap<&str, &str> =
GIT_HARDEN_ENV_SET.iter().copied().collect();
for var in ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"] {
let v = pairs.get(var).unwrap_or_else(|| {
panic!("GIT_HARDEN_ENV_SET missing {var}; got {GIT_HARDEN_ENV_SET:?}")
});
assert_eq!(
*v, "/dev/null",
"{var} must be set to /dev/null to disable user config; got {v:?}",
);
}
}
#[test]
fn skill_host_clap_value_names_match_known_hosts() {
for &expected in KNOWN_HOSTS {
let parsed = SkillHost::from_str(expected, false)
.unwrap_or_else(|_| panic!("KNOWN_HOSTS entry {expected:?} not parseable"));
let rendered = parsed
.to_possible_value()
.expect("clap ValueEnum variant always has a possible value")
.get_name()
.to_string();
assert_eq!(rendered, expected);
}
}
#[test]
fn expand_tilde_replaces_leading_tilde_slash_with_home() {
let got = expand_tilde_with("~/.claude/skills/agent-native-cli", Some("/home/test"))
.expect("HOME present + ~/ prefix should expand cleanly");
assert_eq!(
got,
PathBuf::from("/home/test/.claude/skills/agent-native-cli")
);
}
#[test]
fn expand_tilde_missing_home_only_when_input_starts_with_tilde() {
let err = expand_tilde_with("~/anything", None)
.expect_err("HOME unset + tilde input should be MissingHome");
assert!(matches!(err, AppError::MissingHome));
let err_empty =
expand_tilde_with("~", Some("")).expect_err("HOME empty string is treated as unset");
assert!(matches!(err_empty, AppError::MissingHome));
}
#[test]
fn expand_tilde_no_tilde_passthrough() {
let got_with_home = expand_tilde_with("/abs/path", Some("/home/test"))
.expect("non-tilde input never errors");
assert_eq!(got_with_home, PathBuf::from("/abs/path"));
let got_without_home =
expand_tilde_with("/abs/path", None).expect("non-tilde input ignores HOME");
assert_eq!(got_without_home, PathBuf::from("/abs/path"));
}
#[test]
fn check_destination_absent_for_nonexistent_path() {
let tmp = tempfile::tempdir().expect("tempdir creation");
let target = tmp.path().join("does-not-exist");
let status = check_destination(&target).expect("absent path should be Ok(Absent)");
assert_eq!(status, DestinationStatus::Absent);
}
#[test]
fn check_destination_empty_dir() {
let tmp = tempfile::tempdir().expect("tempdir creation");
let status = check_destination(tmp.path()).expect("empty tempdir should be Ok(EmptyDir)");
assert_eq!(status, DestinationStatus::EmptyDir);
}
#[test]
fn check_destination_non_empty_dir_errors() {
let tmp = tempfile::tempdir().expect("tempdir creation");
std::fs::write(tmp.path().join("placeholder"), b"x").expect("write placeholder");
let err = check_destination(tmp.path()).expect_err("populated dir should be DestNotEmpty");
assert!(matches!(err, AppError::DestNotEmpty { .. }));
}
#[test]
fn check_destination_regular_file_errors() {
let tmp = tempfile::tempdir().expect("tempdir creation");
let target = tmp.path().join("a-file");
std::fs::write(&target, b"contents").expect("write file");
let err = check_destination(&target).expect_err("file should be DestIsFile");
assert!(matches!(err, AppError::DestIsFile { .. }));
}
#[cfg(unix)]
#[test]
fn check_destination_follows_symlink_to_non_empty_dir() {
let tmp = tempfile::tempdir().expect("tempdir creation");
let real_dir = tmp.path().join("real");
std::fs::create_dir(&real_dir).expect("mkdir real");
std::fs::write(real_dir.join("placeholder"), b"x").expect("populate real");
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real_dir, &link).expect("symlink real -> link");
let err = check_destination(&link).expect_err("symlinked non-empty dir is DestNotEmpty");
assert!(matches!(err, AppError::DestNotEmpty { .. }));
}
#[test]
fn build_clone_command_applies_hardening_surface() {
let url = skill_repo_url();
let dest = Path::new("/tmp/anc-skill-introspect");
let cmd = build_clone_command(url, dest);
let args: Vec<String> = cmd
.get_args()
.map(|s| s.to_string_lossy().into_owned())
.collect();
for &flag in GIT_HARDEN_FLAGS {
assert!(
args.iter().any(|a| a == flag),
"GIT_HARDEN_FLAGS entry {flag:?} missing from command args; got {args:?}",
);
}
assert!(
args.iter().any(|a| a == "clone"),
"missing 'clone' subcommand: {args:?}"
);
assert!(
args.iter().any(|a| a == "--depth"),
"missing --depth flag: {args:?}"
);
assert!(
args.iter().any(|a| a == "1"),
"missing --depth value: {args:?}"
);
assert!(
args.iter().any(|a| a == url),
"missing url operand: {args:?}",
);
assert!(
args.iter().any(|a| a == "/tmp/anc-skill-introspect"),
"missing dest operand: {args:?}",
);
let envs: std::collections::HashMap<String, Option<String>> = cmd
.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|s| s.to_string_lossy().into_owned()),
)
})
.collect();
for &var in GIT_HARDEN_ENV_REMOVE {
let entry = envs.get(var);
assert!(
matches!(entry, Some(None)),
"GIT_HARDEN_ENV_REMOVE entry {var:?} should be removed; got {entry:?}",
);
}
for &(key, value) in GIT_HARDEN_ENV_SET {
let entry = envs.get(key);
assert_eq!(
entry,
Some(&Some(value.to_string())),
"GIT_HARDEN_ENV_SET entry {key}={value:?} not present in env-set list; got {entry:?}",
);
}
}
#[test]
fn format_clone_command_matches_canonical_shape() {
let s = format_clone_command(
skill_repo_url(),
Path::new("/home/u/.claude/skills/agent-native-cli"),
);
assert_eq!(
s,
"git clone --depth 1 https://github.com/brettdavies/agentnative-skill.git /home/u/.claude/skills/agent-native-cli",
);
}
}