argyph-cli 1.0.1

Local-first MCP server giving AI coding agents fast, structured, and semantic context over any codebase.
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

const BEGIN: &str = "<!-- argyph:begin -->";
const END: &str = "<!-- argyph:end -->";
const TARGETS: [&str; 3] = ["CLAUDE.md", "AGENTS.md", "GEMINI.md"];

const BLOCK: &str = r#"<!-- argyph:begin -->
## Code & context lookup

This repo is indexed by Argyph (MCP). For any lookup of code, symbols, files,
or content, prefer the `ask` tool over grep, find, or reading files directly.
Argyph returns minimal validated spans, not full files.

- `ask` — primary entry point. Pass a query and optional focus.
- `pack_repo` — only when you genuinely need a flat dump.
- Other Argyph tools — advanced, prefer `ask` first.
<!-- argyph:end -->
"#;

pub fn run(path: Option<&str>) -> ExitCode {
    let root = path
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    if !root.is_dir() {
        eprintln!("init: not a directory: {}", root.display());
        return ExitCode::FAILURE;
    }

    let existing: Vec<&str> = TARGETS
        .iter()
        .copied()
        .filter(|name| root.join(name).is_file())
        .collect();
    let targets = if existing.is_empty() {
        vec!["CLAUDE.md"]
    } else {
        existing
    };

    for target in targets {
        let path = root.join(target);
        if let Err(error) = install_block(&path) {
            eprintln!("init: failed to update {}: {}", path.display(), error);
            return ExitCode::FAILURE;
        }
        println!("init: updated {}", path.display());
    }

    ExitCode::SUCCESS
}

fn install_block(path: &Path) -> std::io::Result<()> {
    let content = if path.exists() {
        fs::read_to_string(path)?
    } else {
        String::new()
    };

    let updated = if let Some((begin, end)) = existing_block_range(&content) {
        let mut updated = String::with_capacity(content.len() + BLOCK.len());
        updated.push_str(&content[..begin]);
        updated.push_str(BLOCK.trim_end_matches('\n'));
        updated.push_str(&content[end..]);
        updated
    } else {
        let mut updated = content;
        if !updated.is_empty() && !updated.ends_with('\n') {
            updated.push('\n');
        }
        if !updated.is_empty() {
            updated.push('\n');
        }
        updated.push_str(BLOCK);
        updated
    };

    fs::write(path, updated)
}

fn existing_block_range(content: &str) -> Option<(usize, usize)> {
    let begin = content.find(BEGIN)?;
    let end = content[begin..].find(END)? + begin + END.len();
    Some((begin, end))
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicUsize, Ordering};

    static NEXT_DIR: AtomicUsize = AtomicUsize::new(0);

    fn tempdir() -> PathBuf {
        let id = NEXT_DIR.fetch_add(1, Ordering::Relaxed);
        let path =
            std::env::temp_dir().join(format!("argyph-init-test-{}-{id}", std::process::id()));
        fs::create_dir(&path).unwrap();
        path
    }

    #[test]
    fn creates_claude_md_when_none_exists() {
        let dir = tempdir();
        let code = run(Some(dir.to_str().unwrap()));
        assert_eq!(code, ExitCode::SUCCESS);
        let written = fs::read_to_string(dir.join("CLAUDE.md")).unwrap();
        assert!(written.contains(BEGIN));
        assert!(written.contains("## Code & context lookup"));
        assert!(written.contains("`ask`"));
        assert!(written.contains(END));
    }

    #[test]
    fn idempotent_on_rerun() {
        let dir = tempdir();
        assert_eq!(run(Some(dir.to_str().unwrap())), ExitCode::SUCCESS);
        assert_eq!(run(Some(dir.to_str().unwrap())), ExitCode::SUCCESS);
        let written = fs::read_to_string(dir.join("CLAUDE.md")).unwrap();
        assert_eq!(written.matches(BEGIN).count(), 1);
        assert_eq!(written.matches(END).count(), 1);
    }

    #[test]
    fn preserves_surrounding_content() {
        let dir = tempdir();
        let path = dir.join("CLAUDE.md");
        fs::write(&path, "# Project\n\nExisting notes.\n").unwrap();
        assert_eq!(run(Some(dir.to_str().unwrap())), ExitCode::SUCCESS);
        let written = fs::read_to_string(path).unwrap();
        assert!(written.starts_with("# Project\n\nExisting notes.\n"));
        assert!(written.contains(BEGIN));
    }

    #[test]
    fn updates_all_existing_instruction_files() {
        let dir = tempdir();
        fs::write(dir.join("CLAUDE.md"), "# Claude\n").unwrap();
        fs::write(dir.join("AGENTS.md"), "# Agents\n").unwrap();
        fs::write(dir.join("GEMINI.md"), "# Gemini\n").unwrap();
        assert_eq!(run(Some(dir.to_str().unwrap())), ExitCode::SUCCESS);
        for name in ["CLAUDE.md", "AGENTS.md", "GEMINI.md"] {
            let written = fs::read_to_string(dir.join(name)).unwrap();
            assert!(
                written.contains(BEGIN),
                "{name} should contain begin marker"
            );
            assert!(written.contains(END), "{name} should contain end marker");
        }
    }
}