agent-offload 0.1.4

Launch coding agents in tmux panes and wait for completion
use anyhow::{Context, Result, anyhow};
use clap::ValueEnum;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};

const SKILL_ID: &str = "agent-offload";
const SKILL_CONTENT: &str = include_str!("../skills/agent-offload/SKILL.md");

#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Provider {
    #[value(name = "claude")]
    Claude,
    #[value(name = "opencode")]
    Opencode,
    #[value(name = "codex")]
    Codex,
    #[value(name = "pi")]
    Pi,
}

impl Provider {
    fn all() -> &'static [Self] {
        use self::Provider::*;
        &[Claude, Opencode, Codex, Pi]
    }

    fn label(&self) -> &'static str {
        match self {
            Provider::Claude => "Claude Code",
            Provider::Opencode => "OpenCode",
            Provider::Codex => "Codex",
            Provider::Pi => "Pi",
        }
    }

    fn parent_dir(
        &self,
        home: &Path,
        claude_config_dir: Option<&Path>,
        pi_coding_agent_dir: Option<&Path>,
    ) -> PathBuf {
        match self {
            Provider::Claude => {
                claude_config_dir.map_or_else(|| home.join(".claude"), |path| path.to_path_buf())
            }
            Provider::Opencode => home.join(".config").join("opencode"),
            Provider::Codex => home.join(".codex"),
            Provider::Pi => pi_coding_agent_dir
                .map_or_else(|| home.join(".pi").join("agent"), |path| path.to_path_buf()),
        }
    }

    fn skill_dir(
        &self,
        home: &Path,
        claude_config_dir: Option<&Path>,
        pi_coding_agent_dir: Option<&Path>,
    ) -> PathBuf {
        self.parent_dir(home, claude_config_dir, pi_coding_agent_dir)
            .join("skills")
            .join(SKILL_ID)
    }
}

pub fn run(provider: Option<Provider>) -> Result<()> {
    let home = dirs::home_dir().context("could not find home directory")?;
    let claude_config_dir = std::env::var("CLAUDE_CONFIG_DIR").ok().map(PathBuf::from);
    let pi_coding_agent_dir = std::env::var("PI_CODING_AGENT_DIR").ok().map(PathBuf::from);

    let targets = collect_targets(
        &home,
        provider,
        claude_config_dir.as_deref(),
        pi_coding_agent_dir.as_deref(),
    );

    if targets.is_empty() {
        return Err(no_provider_error(
            &home,
            claude_config_dir.as_deref(),
            pi_coding_agent_dir.as_deref(),
        ));
    }

    let color = use_color();

    for (provider, skill_dir) in targets {
        println!("{}:", provider.label());
        let dest = skill_dir.join("SKILL.md");

        let up_to_date = fs::read(&dest).is_ok_and(|bytes| bytes == SKILL_CONTENT.as_bytes());

        if up_to_date {
            print_line("up-to-date", &dest, color, None, &home);
            continue;
        }

        fs::create_dir_all(&skill_dir)
            .with_context(|| format!("could not create {}", skill_dir.display()))?;
        fs::write(&dest, SKILL_CONTENT.as_bytes())
            .with_context(|| format!("could not write {}", dest.display()))?;

        print_line("written", &dest, color, Some(32), &home);
    }

    Ok(())
}

fn collect_targets(
    home: &Path,
    provider: Option<Provider>,
    claude_config_dir: Option<&Path>,
    pi_coding_agent_dir: Option<&Path>,
) -> Vec<(Provider, PathBuf)> {
    match provider {
        Some(provider) => vec![(
            provider,
            provider.skill_dir(home, claude_config_dir, pi_coding_agent_dir),
        )],
        None => Provider::all()
            .iter()
            .filter_map(|provider| {
                let parent_dir = provider.parent_dir(home, claude_config_dir, pi_coding_agent_dir);
                if parent_dir.exists() {
                    Some((
                        *provider,
                        provider.skill_dir(home, claude_config_dir, pi_coding_agent_dir),
                    ))
                } else {
                    None
                }
            })
            .collect(),
    }
}

fn no_provider_error(
    home: &Path,
    claude_config_dir: Option<&Path>,
    pi_coding_agent_dir: Option<&Path>,
) -> anyhow::Error {
    let mut detail = String::from("No provider configuration directory was detected.\n");
    detail.push_str("Checked:\n");
    for provider in Provider::all() {
        let dir = provider.parent_dir(home, claude_config_dir, pi_coding_agent_dir);
        detail.push_str(&format!("  {} => {}\n", provider.label(), dir.display()));
    }
    detail.push_str("Run with --provider to install for a specific provider.");
    anyhow!(detail)
}

fn use_color() -> bool {
    std::io::stdout().is_terminal()
        && std::env::var("NO_COLOR")
            .map(|v| v.is_empty())
            .unwrap_or(true)
}

fn print_line(status: &str, path: &Path, color: bool, ansi_color: Option<u8>, home: &Path) {
    let display = shrink_path(path, home);
    if color {
        if let Some(code) = ansi_color {
            println!("\x1b[{code}m{status:<12}\x1b[0m {display}");
        } else {
            println!("\x1b[2m{status:<12}\x1b[0m {display}");
        }
    } else {
        println!("{status:<12} {display}");
    }
}

fn shrink_path(path: &Path, home: &Path) -> String {
    path.strip_prefix(home)
        .map(|rel| format!("~/{}", rel.display()))
        .unwrap_or_else(|_| path.display().to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;
    use tempfile::tempdir;

    #[test]
    fn test_shrink_path_under_home() {
        let home = PathBuf::from("/tmp/home");
        let path = home.join(".claude/skills/agent-offload/SKILL.md");
        assert_eq!(
            shrink_path(&path, &home),
            "~/.claude/skills/agent-offload/SKILL.md"
        );
    }

    #[test]
    fn test_shrink_path_outside_home() {
        let home = PathBuf::from("/tmp/home");
        let path = PathBuf::from("/var/tmp/SKILL.md");
        assert_eq!(shrink_path(&path, &home), "/var/tmp/SKILL.md");
    }

    #[test]
    fn test_provider_path_resolves_env_overrides() {
        let home = PathBuf::from("/tmp/home");
        let claude_override = PathBuf::from("/tmp/claude-config");
        let pi_override = PathBuf::from("/tmp/pi-agent");

        assert_eq!(
            Provider::Claude.skill_dir(&home, Some(&claude_override), Some(&pi_override),),
            PathBuf::from("/tmp/claude-config/skills/agent-offload"),
        );
        assert_eq!(
            Provider::Pi.skill_dir(&home, Some(&claude_override), Some(&pi_override)),
            PathBuf::from("/tmp/pi-agent/skills/agent-offload"),
        );
        assert_eq!(
            Provider::Opencode.skill_dir(&home, Some(&claude_override), Some(&pi_override),),
            PathBuf::from("/tmp/home/.config/opencode/skills/agent-offload"),
        );
    }

    #[test]
    fn test_collect_targets_detects_existing_providers() {
        let home = tempdir().unwrap();
        let home = home.path();

        let claude_dir = home.join(".claude");
        let codex_dir = home.join(".codex");
        fs::create_dir_all(&claude_dir).unwrap();
        fs::create_dir_all(&codex_dir).unwrap();

        let targets = super::collect_targets(home, None, None, None);
        assert_eq!(
            targets,
            vec![
                (
                    Provider::Claude,
                    claude_dir.join("skills").join("agent-offload")
                ),
                (
                    Provider::Codex,
                    codex_dir.join("skills").join("agent-offload")
                ),
            ]
        );
    }

    #[test]
    fn test_collect_targets_uses_provider_override() {
        let home = tempdir().unwrap();
        let home = home.path();

        let pi_override = home.join(".custom-pi");
        fs::create_dir_all(&pi_override).unwrap();
        let targets = super::collect_targets(
            home,
            Some(Provider::Pi),
            Some(&home.join(".other")),
            Some(&pi_override),
        );
        assert_eq!(
            targets,
            vec![(
                Provider::Pi,
                pi_override.join("skills").join("agent-offload")
            )]
        );
    }
}