aven 0.1.9

Local-first task manager CLI and sync server
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Result, bail};

use crate::cli::{CodingAgentArg, SkillInstallArgs};

const SKILL_NAME: &str = "aven";
const SKILL_BODY: &str = include_str!("../skill.md");
const SKILL_DESCRIPTION: &str =
    "Use aven to find tasks, update status, and leave durable handoff context.";

struct CodingAgent {
    arg: CodingAgentArg,
    name: &'static str,
    user_parent: PathBuf,
    skill_dir: PathBuf,
    workspace_markers: &'static [&'static str],
}

impl CodingAgent {
    fn new(
        arg: CodingAgentArg,
        name: &'static str,
        user_parent: PathBuf,
        workspace_markers: &'static [&'static str],
    ) -> Self {
        let skill_dir = user_parent.join("skills").join(SKILL_NAME);
        Self {
            arg,
            name,
            user_parent,
            skill_dir,
            workspace_markers,
        }
    }

    fn is_detected(&self, cwd: &Path) -> bool {
        self.user_parent.is_dir()
            || ancestors(cwd).any(|dir| {
                self.workspace_markers
                    .iter()
                    .any(|marker| dir.join(marker).exists())
            })
    }
}

pub(crate) fn install(args: SkillInstallArgs) -> Result<()> {
    let home =
        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
    let cwd = std::env::current_dir()?;
    let agents = all_agents(&home);
    let targets: Vec<&CodingAgent> = if args.agent.is_empty() {
        agents
            .iter()
            .filter(|agent| agent.is_detected(&cwd))
            .collect()
    } else {
        agents
            .iter()
            .filter(|agent| args.agent.contains(&agent.arg))
            .collect()
    };

    if targets.is_empty() {
        bail!(
            "no supported coding agents detected (expected ~/.claude, ~/.config/opencode, ~/.codex, or workspace agent config); use --agent to choose a target"
        );
    }

    let content = skill_content();
    for target in targets {
        let path = target.skill_dir.join("SKILL.md");
        fs::create_dir_all(&target.skill_dir)?;
        fs::write(&path, content.as_bytes())?;
        println!(
            "installed {} skill for {} at {}",
            SKILL_NAME,
            target.name,
            shrink_home(&path, &home)
        );
    }
    Ok(())
}

fn all_agents(home: &Path) -> Vec<CodingAgent> {
    vec![
        CodingAgent::new(
            CodingAgentArg::Claude,
            "Claude Code",
            home.join(".claude"),
            &[".claude"],
        ),
        CodingAgent::new(
            CodingAgentArg::Opencode,
            "OpenCode",
            home.join(".config").join("opencode"),
            &[".opencode"],
        ),
        CodingAgent::new(
            CodingAgentArg::Codex,
            "Codex",
            home.join(".codex"),
            &[".codex"],
        ),
    ]
}

fn ancestors(path: &Path) -> impl Iterator<Item = &Path> {
    path.ancestors()
}

fn skill_content() -> String {
    format!("---\nname: {SKILL_NAME}\ndescription: {SKILL_DESCRIPTION}\n---\n\n{SKILL_BODY}")
}

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

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

    #[test]
    fn skill_content_has_frontmatter_and_primer() {
        let content = skill_content();
        assert!(content.starts_with("---\nname: aven\n"));
        assert!(content.contains("# Aven CLI Primer"));
    }
}