aristo-cli 0.2.2

Aristo CLI binary (the `aristo` command).
Documentation
//! Claude Code hook integration for `aristo install-skills` /
//! `aristo uninstall-skills`.
//!
//! `aristo install-skills --agent claude-code` writes two hook entries to
//! `<scope>/.claude/settings.json` (workspace scope by default; `--user`
//! writes to `~/.claude/settings.json`):
//!
//! 1. A `UserPromptSubmit` hook running `aristo session active
//!    --hook-format` (slice 27.5 Layer 2) — injects the active review
//!    session into every prompt so the agent can't drift while a review
//!    is open.
//! 2. A `SessionStart` hook running `aristo install-skills --hook-format`
//!    — a best-effort staleness check that, when the installed skills lag
//!    the binary, emits a SessionStart `additionalContext` block telling
//!    the agent to offer the user a refresh (`aristo install-skills
//!    --update`). Notify-only: it never rewrites skills itself.
//!
//! Both entries are identified by a marker substring of their command, so
//! install is idempotent and uninstall removes exactly our entries while
//! preserving any other hooks the user configured.

use std::path::Path;

use serde_json::{Map, Value};

use crate::{CliError, CliResult};

/// The `UserPromptSubmit` command. The trailing `|| true` is load-bearing:
/// if the user installs the hook before updating their on-PATH `aristo` to
/// a version that knows `session`, the hook would otherwise exit non-zero
/// and Claude Code would BLOCK every prompt with an "unrecognized
/// subcommand" error. The `|| true` makes the fallback exit 0, so the hook
/// silently injects nothing instead of breaking the user's workflow.
const HOOK_COMMAND: &str = "aristo session active --hook-format 2>/dev/null || true";

/// Marker substring inside [`HOOK_COMMAND`] uniquely identifying the aristo
/// session hook. Kept narrow (just the subcommand triple) so changes to
/// flags or shell wrapping don't break uninstall's matching.
const HOOK_MARKER: &str = "aristo session active --hook-format";

/// The `SessionStart` command: a best-effort skill-staleness check that
/// prints a SessionStart `additionalContext` block when installed skills
/// lag the binary. Same tolerant `2>/dev/null || true` fallback — a stale
/// or absent on-PATH aristo must never break session start.
const SESSION_START_COMMAND: &str = "aristo install-skills --hook-format 2>/dev/null || true";

/// Marker substring identifying the SessionStart staleness hook.
const SESSION_START_MARKER: &str = "aristo install-skills --hook-format";

/// Settings file path under `<root>/.claude/`.
fn settings_path(root: &Path) -> std::path::PathBuf {
    root.join(".claude").join("settings.json")
}

/// Read the existing settings.json (or an empty JSON object if missing).
/// Bubbles up parse errors so the user notices a manually-corrupted
/// settings file rather than us silently overwriting it.
fn read_settings(path: &Path) -> CliResult<Value> {
    let text = match std::fs::read_to_string(path) {
        Ok(t) => t,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Value::Object(Map::new())),
        Err(e) => return Err(e.into()),
    };
    serde_json::from_str(&text).map_err(|e| CliError::Other {
        message: format!("could not parse {}: {e}", path.display()),
        exit_code: 1,
    })
}

fn write_settings(path: &Path, value: &Value) -> CliResult<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let text = serde_json::to_string_pretty(value).map_err(|e| CliError::Other {
        message: format!("settings serialize: {e}"),
        exit_code: 1,
    })?;
    std::fs::write(path, text)?;
    Ok(())
}

#[aristo::intent(
    "Installing a hook is idempotent — running install twice leaves \
     settings.json with exactly one entry for that hook's marker, not two. \
     Existing entries are found by command substring and left in place. A \
     refactor that appends unconditionally would compound on every \
     reinstall. Applies uniformly to every hook event (UserPromptSubmit, \
     SessionStart).",
    verify = "neural",
    id = "install_claude_hook_is_idempotent"
)]
fn install_hook_into(root: &Path, event: &str, marker: &str, entry: Value) -> CliResult<bool> {
    let path = settings_path(root);
    let mut value = read_settings(&path)?;
    let root_obj = ensure_object(&mut value);

    let hooks = root_obj
        .entry("hooks".to_string())
        .or_insert_with(|| Value::Object(Map::new()))
        .as_object_mut()
        .ok_or_else(|| CliError::Other {
            message: format!(
                "`hooks` key in {} is not a JSON object; refusing to overwrite",
                path.display()
            ),
            exit_code: 1,
        })?;

    let array = hooks
        .entry(event.to_string())
        .or_insert_with(|| Value::Array(Vec::new()))
        .as_array_mut()
        .ok_or_else(|| CliError::Other {
            message: format!(
                "`hooks.{event}` in {} is not a JSON array; refusing to overwrite",
                path.display()
            ),
            exit_code: 1,
        })?;

    let already_present = array.iter().any(|entry| entry_mentions_hook(entry, marker));
    if !already_present {
        array.push(entry);
    }
    write_settings(&path, &value)?;
    Ok(!already_present)
}

#[aristo::intent(
    "Uninstall removes ONLY the aristo entry matching the given marker from \
     its hook-event array; any other hooks the user configured are \
     preserved. After removal an empty array is left in place rather than \
     removed — the user may have intentional structure around the key. \
     Idempotent: uninstalling-when-not-installed is a no-op return.",
    verify = "neural",
    id = "uninstall_claude_hook_preserves_other_hooks"
)]
fn uninstall_hook_from(root: &Path, event: &str, marker: &str) -> CliResult<bool> {
    let path = settings_path(root);
    if !path.exists() {
        return Ok(false);
    }
    let mut value = read_settings(&path)?;
    let Some(root_obj) = value.as_object_mut() else {
        return Ok(false);
    };
    let Some(hooks) = root_obj.get_mut("hooks").and_then(Value::as_object_mut) else {
        return Ok(false);
    };
    let Some(arr) = hooks.get_mut(event).and_then(Value::as_array_mut) else {
        return Ok(false);
    };
    let before = arr.len();
    arr.retain(|entry| !entry_mentions_hook(entry, marker));
    let removed = arr.len() < before;
    if removed {
        write_settings(&path, &value)?;
    }
    Ok(removed)
}

/// Install the `UserPromptSubmit` review-session hook. Returns `true` if a
/// new entry was written, `false` if one was already present.
pub fn install_claude_hook(root: &Path) -> CliResult<bool> {
    install_hook_into(root, "UserPromptSubmit", HOOK_MARKER, session_hook_entry())
}

/// Install the `SessionStart` skill-staleness hook.
pub fn install_session_start_hook(root: &Path) -> CliResult<bool> {
    install_hook_into(
        root,
        "SessionStart",
        SESSION_START_MARKER,
        session_start_hook_entry(),
    )
}

/// Remove the `UserPromptSubmit` review-session hook.
pub fn uninstall_claude_hook(root: &Path) -> CliResult<bool> {
    uninstall_hook_from(root, "UserPromptSubmit", HOOK_MARKER)
}

/// Remove the `SessionStart` skill-staleness hook.
pub fn uninstall_session_start_hook(root: &Path) -> CliResult<bool> {
    uninstall_hook_from(root, "SessionStart", SESSION_START_MARKER)
}

fn ensure_object(value: &mut Value) -> &mut Map<String, Value> {
    if !value.is_object() {
        *value = Value::Object(Map::new());
    }
    value.as_object_mut().expect("just set to object")
}

/// `UserPromptSubmit` entry shape: `{matcher, hooks: [{type, command}]}`.
fn session_hook_entry() -> Value {
    serde_json::json!({
        "matcher": ".*",
        "hooks": [ { "type": "command", "command": HOOK_COMMAND } ]
    })
}

/// `SessionStart` entry shape: `{hooks: [{type, command}]}` (no matcher —
/// SessionStart fires once per session, not per tool/prompt).
fn session_start_hook_entry() -> Value {
    serde_json::json!({
        "hooks": [ { "type": "command", "command": SESSION_START_COMMAND } ]
    })
}

/// True if the given hook entry contains a sub-hook whose command mentions
/// our marker. Tolerant of both flat (`command` at top level) and nested
/// (`hooks: [{command}]`) shapes so Claude Code schema changes don't
/// silently leave stale entries behind.
fn entry_mentions_hook(entry: &Value, marker: &str) -> bool {
    fn has(entry: &Value, marker: &str) -> bool {
        if let Some(s) = entry.get("command").and_then(Value::as_str) {
            if s.contains(marker) {
                return true;
            }
        }
        if let Some(arr) = entry.get("hooks").and_then(Value::as_array) {
            return arr.iter().any(|e| has(e, marker));
        }
        false
    }
    has(entry, marker)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn install_creates_settings_when_missing() {
        let tmp = TempDir::new().unwrap();
        let inserted = install_claude_hook(tmp.path()).unwrap();
        assert!(inserted);
        let body = std::fs::read_to_string(settings_path(tmp.path())).unwrap();
        assert!(body.contains(HOOK_COMMAND), "body: {body}");
    }

    #[test]
    fn both_hook_commands_use_tolerant_shell_fallback() {
        // Regression guard: both hooks MUST exit 0 even when aristo is
        // missing or doesn't recognize the subcommand — otherwise installing
        // a hook before updating PATH-resident aristo would break sessions.
        for cmd in [HOOK_COMMAND, SESSION_START_COMMAND] {
            assert!(cmd.contains("|| true"), "`{cmd}` must fall back gracefully");
            assert!(cmd.contains("2>/dev/null"), "`{cmd}` must suppress stderr");
        }
        assert!(HOOK_COMMAND.contains(HOOK_MARKER));
        assert!(SESSION_START_COMMAND.contains(SESSION_START_MARKER));
    }

    #[test]
    fn install_twice_remains_idempotent() {
        let tmp = TempDir::new().unwrap();
        assert!(install_claude_hook(tmp.path()).unwrap());
        assert!(
            !install_claude_hook(tmp.path()).unwrap(),
            "second install should be a no-op"
        );
        let body = std::fs::read_to_string(settings_path(tmp.path())).unwrap();
        assert_eq!(body.matches(HOOK_COMMAND).count(), 1);
    }

    #[test]
    fn session_start_hook_installs_idempotently_under_its_own_event() {
        let tmp = TempDir::new().unwrap();
        assert!(install_session_start_hook(tmp.path()).unwrap());
        assert!(!install_session_start_hook(tmp.path()).unwrap());
        let body = std::fs::read_to_string(settings_path(tmp.path())).unwrap();
        assert!(body.contains("SessionStart"));
        assert_eq!(body.matches(SESSION_START_COMMAND).count(), 1);
    }

    #[test]
    fn both_hooks_coexist_in_one_settings_file() {
        let tmp = TempDir::new().unwrap();
        install_claude_hook(tmp.path()).unwrap();
        install_session_start_hook(tmp.path()).unwrap();
        let body = std::fs::read_to_string(settings_path(tmp.path())).unwrap();
        assert!(body.contains("UserPromptSubmit"));
        assert!(body.contains("SessionStart"));
        assert!(body.contains(HOOK_MARKER));
        assert!(body.contains(SESSION_START_MARKER));
    }

    #[test]
    fn install_preserves_other_existing_hooks() {
        let tmp = TempDir::new().unwrap();
        let settings = settings_path(tmp.path());
        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
        std::fs::write(
            &settings,
            r#"{ "hooks": { "UserPromptSubmit": [{ "matcher": ".*", "hooks": [{"type":"command","command":"someone_elses_tool --hook"}] }] } }"#,
        )
        .unwrap();
        install_claude_hook(tmp.path()).unwrap();
        let body = std::fs::read_to_string(&settings).unwrap();
        assert!(body.contains("someone_elses_tool"));
        assert!(body.contains(HOOK_COMMAND));
    }

    #[test]
    fn uninstall_removes_only_aristo_entry() {
        let tmp = TempDir::new().unwrap();
        let settings = settings_path(tmp.path());
        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
        std::fs::write(
            &settings,
            r#"{ "hooks": { "UserPromptSubmit": [{ "matcher": ".*", "hooks": [{"type":"command","command":"someone_elses_tool --hook"}] }] } }"#,
        )
        .unwrap();
        install_claude_hook(tmp.path()).unwrap();
        assert!(uninstall_claude_hook(tmp.path()).unwrap());
        let body = std::fs::read_to_string(&settings).unwrap();
        assert!(!body.contains(HOOK_COMMAND));
        assert!(body.contains("someone_elses_tool"));
    }

    #[test]
    fn uninstall_session_start_removes_only_its_entry() {
        let tmp = TempDir::new().unwrap();
        install_claude_hook(tmp.path()).unwrap();
        install_session_start_hook(tmp.path()).unwrap();
        assert!(uninstall_session_start_hook(tmp.path()).unwrap());
        let body = std::fs::read_to_string(settings_path(tmp.path())).unwrap();
        // SessionStart entry gone; UserPromptSubmit one preserved.
        assert!(!body.contains(SESSION_START_COMMAND));
        assert!(body.contains(HOOK_COMMAND));
    }

    #[test]
    fn uninstall_when_not_installed_is_no_op() {
        let tmp = TempDir::new().unwrap();
        assert!(!uninstall_claude_hook(tmp.path()).unwrap());
        assert!(!uninstall_session_start_hook(tmp.path()).unwrap());
    }

    #[test]
    fn install_then_uninstall_idempotent_on_repeat() {
        let tmp = TempDir::new().unwrap();
        install_claude_hook(tmp.path()).unwrap();
        assert!(uninstall_claude_hook(tmp.path()).unwrap());
        assert!(!uninstall_claude_hook(tmp.path()).unwrap());
    }
}