kaizen-cli 0.1.44

Distributable agent observability: real-time-tailable sessions, agile-style retros, and repo-level improvement (Cursor, Claude Code, Codex). SQLite, redact before any sync you enable.
Documentation
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::guidance::types::{Artifact, ArtifactKind, ArtifactRef};
use anyhow::{Context, Result, anyhow};
use std::fs;
use std::path::{Path, PathBuf};

pub fn scan(workspace: &Path) -> Result<Vec<Artifact>> {
    let mut out = Vec::new();
    out.extend(scan_skills(workspace)?);
    out.extend(scan_rules(workspace)?);
    out.sort_by(|a, b| (a.kind, &a.slug).cmp(&(b.kind, &b.slug)));
    Ok(out)
}

pub fn find(workspace: &Path, artifact: &ArtifactRef) -> Result<Artifact> {
    scan(workspace)?
        .into_iter()
        .find(|a| a.kind == artifact.kind && a.slug == artifact.slug)
        .ok_or_else(|| anyhow!("artifact not found: {artifact}"))
}

fn scan_skills(workspace: &Path) -> Result<Vec<Artifact>> {
    let dir = workspace.join(".cursor/skills");
    scan_dirs(workspace, &dir, ArtifactKind::Skill)
}

fn scan_rules(workspace: &Path) -> Result<Vec<Artifact>> {
    let dir = workspace.join(".cursor/rules");
    scan_files(workspace, &dir, "mdc", ArtifactKind::Rule)
}

fn scan_dirs(workspace: &Path, dir: &Path, kind: ArtifactKind) -> Result<Vec<Artifact>> {
    if !dir.is_dir() {
        return Ok(vec![]);
    }
    fs::read_dir(dir)?
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
        .filter_map(|e| artifact_from_skill_dir(workspace, e.path(), kind).transpose())
        .collect()
}

fn scan_files(
    workspace: &Path,
    dir: &Path,
    ext: &str,
    kind: ArtifactKind,
) -> Result<Vec<Artifact>> {
    if !dir.is_dir() {
        return Ok(vec![]);
    }
    fs::read_dir(dir)?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some(ext))
        .map(|p| artifact_from_file(workspace, p, kind))
        .collect()
}

fn artifact_from_skill_dir(
    workspace: &Path,
    dir: PathBuf,
    kind: ArtifactKind,
) -> Result<Option<Artifact>> {
    let path = dir.join("SKILL.md");
    if path.is_file() {
        return artifact_from_file(workspace, path, kind).map(Some);
    }
    Ok(None)
}

fn artifact_from_file(workspace: &Path, path: PathBuf, kind: ArtifactKind) -> Result<Artifact> {
    let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
    let meta = fs::metadata(&path)?;
    Ok(Artifact {
        kind,
        slug: slug_for(workspace, &path, kind)?,
        path,
        content_hash: hex::encode(blake3::hash(&bytes).as_bytes()),
        bytes: bytes.len() as u64,
        mtime_ms: mtime_ms(&meta),
    })
}

fn slug_for(workspace: &Path, path: &Path, kind: ArtifactKind) -> Result<String> {
    match kind {
        ArtifactKind::Skill => path
            .parent()
            .and_then(|p| p.file_name())
            .and_then(|s| s.to_str())
            .map(str::to_string)
            .ok_or_else(|| anyhow!("bad skill path: {}", path.display())),
        ArtifactKind::Rule => path
            .strip_prefix(workspace.join(".cursor/rules"))?
            .file_stem()
            .and_then(|s| s.to_str())
            .map(str::to_string)
            .ok_or_else(|| anyhow!("bad rule path: {}", path.display())),
    }
}

fn mtime_ms(meta: &fs::Metadata) -> u64 {
    meta.modified()
        .ok()
        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| d.as_millis() as u64)
        .unwrap_or_default()
}