githubclaw 0.2.2

Near-autonomous AI agents that manage open-source projects end-to-end using GitHub as the single source of truth.
Documentation
use std::io::{self, IsTerminal, Write};
use std::process::Command;

pub const GITHUBCLAW_REPO: &str = "NomaDamas/GithubClaw";

trait StartupBackend {
    fn current_version(&self) -> &str;
    fn fetch_latest_version(&mut self) -> Result<Option<String>, String>;
    fn prompt_yes_no(&mut self, message: &str) -> io::Result<bool>;
    fn install_update(&mut self) -> Result<(), String>;
    fn has_starred_repo(&mut self, repo: &str) -> Result<bool, String>;
    fn star_repo(&mut self, repo: &str) -> Result<(), String>;
    fn print_line(&mut self, message: &str);
}

pub fn run_tui_startup_checks() {
    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
        return;
    }

    let mut backend = SystemStartupBackend;
    if let Err(err) = run_tui_startup_checks_with(&mut backend) {
        eprintln!("Warning: failed to run TUI startup checks: {err}");
    }
}

fn run_tui_startup_checks_with<B: StartupBackend>(backend: &mut B) -> io::Result<()> {
    maybe_prompt_for_update(backend)?;
    maybe_prompt_for_star(backend)?;
    Ok(())
}

struct SystemStartupBackend;

impl StartupBackend for SystemStartupBackend {
    fn current_version(&self) -> &str {
        env!("CARGO_PKG_VERSION")
    }

    fn fetch_latest_version(&mut self) -> Result<Option<String>, String> {
        let output = Command::new("cargo")
            .args(["search", "githubclaw", "--limit", "10"])
            .output()
            .map_err(|err| format!("failed to run cargo search: {err}"))?;

        if !output.status.success() {
            return Err(command_failure("cargo search", &output));
        }

        let stdout = String::from_utf8_lossy(&output.stdout);
        Ok(parse_crate_version(&stdout, "githubclaw"))
    }

    fn prompt_yes_no(&mut self, message: &str) -> io::Result<bool> {
        let mut stdout = io::stdout();
        loop {
            write!(stdout, "{message} ")?;
            stdout.flush()?;

            let mut input = String::new();
            let bytes = io::stdin().read_line(&mut input)?;
            if bytes == 0 {
                writeln!(stdout)?;
                return Ok(false);
            }

            match input.trim().to_ascii_lowercase().as_str() {
                "" | "y" | "yes" => return Ok(true),
                "n" | "no" => return Ok(false),
                _ => {
                    writeln!(stdout, "Please answer y or n.")?;
                }
            }
        }
    }

    fn install_update(&mut self) -> Result<(), String> {
        let output = Command::new("cargo")
            .args(["install", "githubclaw", "--locked", "--force"])
            .output()
            .map_err(|err| format!("failed to run cargo install: {err}"))?;

        if output.status.success() {
            Ok(())
        } else {
            Err(command_failure("cargo install", &output))
        }
    }

    fn has_starred_repo(&mut self, repo: &str) -> Result<bool, String> {
        let output = Command::new("gh")
            .args([
                "repo",
                "view",
                repo,
                "--json",
                "viewerHasStarred",
                "-q",
                ".viewerHasStarred",
            ])
            .output()
            .map_err(|err| format!("failed to run gh repo view: {err}"))?;

        if !output.status.success() {
            return Err(command_failure("gh repo view", &output));
        }

        match String::from_utf8_lossy(&output.stdout).trim() {
            "true" => Ok(true),
            "false" => Ok(false),
            other => Err(format!("unexpected gh repo view output: {other}")),
        }
    }

    fn star_repo(&mut self, repo: &str) -> Result<(), String> {
        let output = Command::new("gh")
            .args(["repo", "star", repo, "--yes"])
            .output()
            .map_err(|err| format!("failed to run gh repo star: {err}"))?;

        if output.status.success() {
            Ok(())
        } else {
            Err(command_failure("gh repo star", &output))
        }
    }

    fn print_line(&mut self, message: &str) {
        println!("{message}");
    }
}

fn maybe_prompt_for_update<B: StartupBackend>(backend: &mut B) -> io::Result<()> {
    let latest_version = match backend.fetch_latest_version() {
        Ok(version) => version,
        Err(err) => {
            backend.print_line(&format!("Skipping update check: {err}"));
            return Ok(());
        }
    };

    let Some(latest_version) = latest_version else {
        return Ok(());
    };

    if !is_newer_version(backend.current_version(), &latest_version) {
        return Ok(());
    }

    let prompt = format!(
        "An update is available for githubclaw ({} -> {}). Install now? [Y/n]",
        backend.current_version(),
        latest_version
    );
    if backend.prompt_yes_no(&prompt)? {
        match backend.install_update() {
            Ok(()) => backend.print_line("githubclaw update installed successfully."),
            Err(err) => backend.print_line(&format!("githubclaw update failed: {err}")),
        }
    }

    Ok(())
}

fn maybe_prompt_for_star<B: StartupBackend>(backend: &mut B) -> io::Result<()> {
    let has_starred = match backend.has_starred_repo(GITHUBCLAW_REPO) {
        Ok(value) => value,
        Err(err) => {
            backend.print_line(&format!("Skipping GitHub star check: {err}"));
            return Ok(());
        }
    };

    if has_starred {
        return Ok(());
    }

    let prompt = format!("Would you like to star {GITHUBCLAW_REPO}? [Y/n]");
    if backend.prompt_yes_no(&prompt)? {
        match backend.star_repo(GITHUBCLAW_REPO) {
            Ok(()) => backend.print_line("Thanks. GithubClaw has been starred."),
            Err(err) => backend.print_line(&format!("Failed to star GithubClaw: {err}")),
        }
    }

    Ok(())
}

fn is_newer_version(current: &str, latest: &str) -> bool {
    match (parse_version(current), parse_version(latest)) {
        (Some(current), Some(latest)) => latest > current,
        _ => false,
    }
}

fn parse_version(input: &str) -> Option<(u64, u64, u64)> {
    let core = input.trim().trim_start_matches('v').split('-').next()?;
    let mut parts = core.split('.');
    let major = parts.next()?.parse().ok()?;
    let minor = parts.next().unwrap_or("0").parse().ok()?;
    let patch = parts.next().unwrap_or("0").parse().ok()?;
    Some((major, minor, patch))
}

fn parse_crate_version(search_output: &str, crate_name: &str) -> Option<String> {
    let prefix = format!("{crate_name} = \"");
    search_output.lines().find_map(|line| {
        let trimmed = line.trim();
        let version = trimmed.strip_prefix(&prefix)?;
        let end = version.find('"')?;
        Some(version[..end].to_string())
    })
}

fn command_failure(command_name: &str, output: &std::process::Output) -> String {
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    if !stderr.is_empty() {
        return format!("{command_name} failed: {stderr}");
    }

    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if !stdout.is_empty() {
        return format!("{command_name} failed: {stdout}");
    }

    format!(
        "{command_name} failed with exit code {}",
        output.status.code().unwrap_or_default()
    )
}

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

    struct MockBackend {
        current_version: String,
        latest_version: Option<String>,
        update_answer: bool,
        star_answer: bool,
        starred: bool,
        update_calls: usize,
        star_calls: usize,
        prompts: Vec<String>,
        lines: Vec<String>,
        latest_version_error: Option<String>,
        star_check_error: Option<String>,
        update_error: Option<String>,
        star_error: Option<String>,
    }

    impl MockBackend {
        fn new(current_version: &str) -> Self {
            Self {
                current_version: current_version.to_string(),
                latest_version: None,
                update_answer: true,
                star_answer: true,
                starred: false,
                update_calls: 0,
                star_calls: 0,
                prompts: Vec::new(),
                lines: Vec::new(),
                latest_version_error: None,
                star_check_error: None,
                update_error: None,
                star_error: None,
            }
        }
    }

    impl StartupBackend for MockBackend {
        fn current_version(&self) -> &str {
            &self.current_version
        }

        fn fetch_latest_version(&mut self) -> Result<Option<String>, String> {
            if let Some(err) = &self.latest_version_error {
                return Err(err.clone());
            }
            Ok(self.latest_version.clone())
        }

        fn prompt_yes_no(&mut self, message: &str) -> io::Result<bool> {
            self.prompts.push(message.to_string());
            if message.contains("update") {
                Ok(self.update_answer)
            } else {
                Ok(self.star_answer)
            }
        }

        fn install_update(&mut self) -> Result<(), String> {
            self.update_calls += 1;
            match &self.update_error {
                Some(err) => Err(err.clone()),
                None => Ok(()),
            }
        }

        fn has_starred_repo(&mut self, _repo: &str) -> Result<bool, String> {
            if let Some(err) = &self.star_check_error {
                return Err(err.clone());
            }
            Ok(self.starred)
        }

        fn star_repo(&mut self, _repo: &str) -> Result<(), String> {
            self.star_calls += 1;
            match &self.star_error {
                Some(err) => Err(err.clone()),
                None => Ok(()),
            }
        }

        fn print_line(&mut self, message: &str) {
            self.lines.push(message.to_string());
        }
    }

    #[test]
    fn prompts_for_update_then_star_when_tui_starts() {
        let mut backend = MockBackend::new("0.1.0");
        backend.latest_version = Some("0.2.0".to_string());
        backend.update_answer = true;
        backend.star_answer = true;

        run_tui_startup_checks_with(&mut backend).unwrap();

        assert_eq!(backend.update_calls, 1);
        assert_eq!(backend.star_calls, 1);
        assert_eq!(backend.prompts.len(), 2);
        assert!(backend.prompts[0].contains("update"));
        assert!(backend.prompts[1].contains("star"));
    }

    #[test]
    fn skips_update_prompt_when_current_version_is_latest() {
        let mut backend = MockBackend::new("0.2.0");
        backend.latest_version = Some("0.2.0".to_string());
        backend.star_answer = false;

        run_tui_startup_checks_with(&mut backend).unwrap();

        assert_eq!(backend.update_calls, 0);
        assert_eq!(backend.star_calls, 0);
        assert_eq!(backend.prompts.len(), 1);
        assert!(backend.prompts[0].contains("star"));
    }

    #[test]
    fn skips_star_prompt_when_repo_is_already_starred() {
        let mut backend = MockBackend::new("0.1.0");
        backend.latest_version = Some("0.1.1".to_string());
        backend.update_answer = false;
        backend.starred = true;

        run_tui_startup_checks_with(&mut backend).unwrap();

        assert_eq!(backend.update_calls, 0);
        assert_eq!(backend.star_calls, 0);
        assert_eq!(backend.prompts.len(), 1);
        assert!(backend.prompts[0].contains("update"));
    }

    #[test]
    fn parses_crate_version_from_cargo_search_output() {
        let output = "githubclaw = \"0.2.0\"    # Near-autonomous AI agents\nother = \"1.0.0\"";
        assert_eq!(
            parse_crate_version(output, "githubclaw"),
            Some("0.2.0".to_string())
        );
    }

    #[test]
    fn compares_versions_using_semver_core() {
        assert!(is_newer_version("0.1.0", "0.1.1"));
        assert!(is_newer_version("0.1.9", "0.2.0"));
        assert!(!is_newer_version("0.2.0", "0.2.0"));
        assert!(!is_newer_version("0.2.1", "0.2.0"));
    }
}