skillnet 0.6.0

Manage canonical AI skill stores, derived views, and calibration data for multi-phase-plan.
Documentation
use std::{env, fs};

use anyhow::{bail, Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use serde_json::{json, Map, Value};

const MANAGED_KEY: &str = "_skillnet_managed";

pub fn install(
    settings: &Utf8Path,
    events: &[String],
    matchers: &[String],
    dry_run: bool,
) -> Result<()> {
    let settings = expand_settings_path(settings)?;
    let events = normalized_values(events, "event")?;
    let matchers = normalized_values(matchers, "matcher")?;
    let mut document = load_settings(&settings)?;

    for event in &events {
        let entries = event_entries_mut(&mut document, event)?;
        entries.retain(|entry| !is_managed_entry(entry));
        for matcher in &matchers {
            entries.push(managed_entry(event, matcher));
        }
    }

    if dry_run {
        println!("would install skillnet hooks in {settings}");
        return Ok(());
    }

    write_settings(&settings, &document)?;
    println!("installed");
    Ok(())
}

pub fn uninstall(settings: &Utf8Path) -> Result<()> {
    let settings = expand_settings_path(settings)?;
    let existed = settings.exists();
    let mut document = load_settings(&settings)?;
    let original = document.clone();
    remove_managed_entries(&mut document)?;
    if !existed || document == original {
        println!("uninstalled");
        return Ok(());
    }
    write_settings(&settings, &document)?;
    println!("uninstalled");
    Ok(())
}

pub fn is_installed(settings: &Utf8Path) -> Result<bool> {
    let settings = expand_settings_path(settings)?;
    let document = load_settings(&settings)?;
    let Some(hooks) = document.get("hooks") else {
        return Ok(false);
    };
    let hooks = hooks
        .as_object()
        .with_context(|| format!("{settings}: top-level `hooks` must be a JSON object"))?;
    Ok(hooks.values().any(|entries| {
        entries
            .as_array()
            .is_some_and(|entries| entries.iter().any(is_managed_entry))
    }))
}

fn load_settings(path: &Utf8Path) -> Result<Value> {
    if !path.exists() {
        return Ok(json!({}));
    }
    let text = fs::read_to_string(path)
        .with_context(|| format!("failed to read Claude settings file {path}"))?;
    if text.trim().is_empty() {
        return Ok(json!({}));
    }
    let value: Value = serde_json::from_str(&text).with_context(|| {
        format!(
            "failed to parse Claude settings JSON at {path}; remove comments or fix the JSON \
             before running `skillnet hook install`"
        )
    })?;
    if !value.is_object() {
        bail!("{path}: Claude settings root must be a JSON object");
    }
    Ok(value)
}

fn event_entries_mut<'a>(document: &'a mut Value, event: &str) -> Result<&'a mut Vec<Value>> {
    let root = document
        .as_object_mut()
        .context("Claude settings root must be a JSON object")?;
    let hooks = root
        .entry("hooks".to_string())
        .or_insert_with(|| Value::Object(Map::new()))
        .as_object_mut()
        .context("top-level `hooks` must be a JSON object")?;
    hooks
        .entry(event.to_string())
        .or_insert_with(|| Value::Array(Vec::new()))
        .as_array_mut()
        .with_context(|| format!("hooks.{event} must be a JSON array"))
}

fn remove_managed_entries(document: &mut Value) -> Result<()> {
    let Some(root) = document.as_object_mut() else {
        bail!("Claude settings root must be a JSON object");
    };
    let Some(hooks) = root.get_mut("hooks") else {
        return Ok(());
    };
    let hooks = hooks
        .as_object_mut()
        .context("top-level `hooks` must be a JSON object")?;
    for (event, entries) in hooks.iter_mut() {
        let entries = entries
            .as_array_mut()
            .with_context(|| format!("hooks.{event} must be a JSON array"))?;
        entries.retain(|entry| !is_managed_entry(entry));
    }
    hooks.retain(|_, entries| entries.as_array().is_none_or(|entries| !entries.is_empty()));
    if hooks.is_empty() {
        root.remove("hooks");
    }
    Ok(())
}

fn managed_entry(event: &str, matcher: &str) -> Value {
    json!({
        "matcher": matcher,
        MANAGED_KEY: true,
        "hooks": [{
            "type": "command",
            "command": format!("skillnet hook ingest --event {event}"),
        }],
    })
}

fn is_managed_entry(entry: &Value) -> bool {
    entry
        .get(MANAGED_KEY)
        .and_then(Value::as_bool)
        .unwrap_or(false)
}

fn normalized_values(values: &[String], label: &str) -> Result<Vec<String>> {
    let values: Vec<String> = values
        .iter()
        .map(|value| value.trim())
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .collect();
    if values.is_empty() {
        bail!("at least one hook {label} is required");
    }
    Ok(values)
}

fn write_settings(path: &Utf8Path, document: &Value) -> Result<()> {
    let text = format!("{}\n", serde_json::to_string_pretty(document)?);
    if path.exists() {
        let current = fs::read_to_string(path)
            .with_context(|| format!("failed to read Claude settings file {path}"))?;
        if current == text {
            return Ok(());
        }
    }

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).with_context(|| format!("failed to create {parent}"))?;
    }
    create_backup_once(path)?;

    let tmp = Utf8PathBuf::from(format!("{path}.tmp"));
    fs::write(&tmp, text).with_context(|| format!("failed to write temporary file {tmp}"))?;
    if path.exists() {
        let permissions = fs::metadata(path)
            .with_context(|| format!("failed to inspect {path}"))?
            .permissions();
        fs::set_permissions(&tmp, permissions)
            .with_context(|| format!("failed to preserve permissions on {tmp}"))?;
    }
    fs::rename(&tmp, path).with_context(|| format!("failed to replace {path} with {tmp}"))?;
    Ok(())
}

fn create_backup_once(path: &Utf8Path) -> Result<()> {
    if !path.exists() {
        return Ok(());
    }
    let backup = Utf8PathBuf::from(format!("{path}.bak"));
    if backup.exists() {
        return Ok(());
    }
    fs::copy(path, &backup).with_context(|| format!("failed to create backup {backup}"))?;
    Ok(())
}

fn expand_settings_path(path: &Utf8Path) -> Result<Utf8PathBuf> {
    let raw = path.as_str();
    if raw == "$HOME" {
        return Ok(Utf8PathBuf::from(home_dir()?));
    }
    if let Some(rest) = raw.strip_prefix("$HOME/") {
        return Ok(Utf8PathBuf::from(home_dir()?).join(rest));
    }
    if raw == "~" {
        return Ok(Utf8PathBuf::from(home_dir()?));
    }
    if let Some(rest) = raw.strip_prefix("~/") {
        return Ok(Utf8PathBuf::from(home_dir()?).join(rest));
    }
    Ok(path.to_path_buf())
}

fn home_dir() -> Result<String> {
    env::var("HOME")
        .ok()
        .filter(|value| !value.is_empty())
        .context("HOME is not set; pass --settings explicitly")
}