kkernel 0.4.0

khive kernel — stdio MCP server binary and admin CLI (sync, pack introspection, db ops)
Documentation
//! `kkernel kg init` and `kkernel kg hook` — initialization and hook management.

use std::path::Path;

use super::types::{HookCommand, HookStatus, InitArgs};
use anyhow::{bail, Context, Result};

const DEFAULT_KHIVE_TOML: &str = r#"# .khive/khive.toml — project KG configuration
# Committed to git. All collaborators use these settings.

[[backends]]
name = "main"
path = "~/.khive/khive.db"
cache_mb = 256
journal_mode = "wal"

[[engines]]
name = "mE5-small"
dim = 384
weight = 1.0

[packs.kg]
backend = "main"
engines = ["mE5-small"]

[packs.memory]
backend = "main"
engines = ["mE5-small"]

[packs.gtd]
backend = "main"
engines = []

[embed]
model = "mE5-small"
dimensions = 384
auto_embed = true
batch_size = 64

[embed.fields]
include = ["name", "description"]

[schema]
strict = true
"#;

const GITIGNORE_CONTENT: &str = "*\n!.gitignore\n!kg/\n!kg/**\n!khive.toml\n";

const PRE_COMMIT_HOOK: &str = r#"#!/usr/bin/env bash
# .khive/kg/hooks/pre-commit
# Generated by kkernel kg init.
# Runs KG validation on staged NDJSON files.
# Bypass with: git commit --no-verify

set -euo pipefail

staged=$(git diff --cached --name-only \
  | grep -E '^\.khive/kg/(entities|edges)\.ndjson$' || true)
if [ -z "$staged" ]; then
  exit 0
fi

kkernel kg validate
"#;

const CI_WORKFLOW: &str = r#"name: KG Validate
on:
  push:
    paths: [".khive/kg/**"]
  pull_request:
    paths: [".khive/kg/**"]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Validate KG
        run: kkernel kg validate --format github
"#;

pub(super) fn cmd_init(args: InitArgs) -> Result<()> {
    if args.add_hooks {
        return hook_install(&args.repo);
    }

    let khive_dir = args.repo.join(".khive");
    let kg_dir = khive_dir.join("kg");
    let hooks_dir = kg_dir.join("hooks");

    std::fs::create_dir_all(&kg_dir).with_context(|| format!("create {}", kg_dir.display()))?;
    std::fs::create_dir_all(&hooks_dir)
        .with_context(|| format!("create {}", hooks_dir.display()))?;

    for name in &["entities.ndjson", "edges.ndjson"] {
        let path = kg_dir.join(name);
        if !path.exists() {
            std::fs::write(&path, "").with_context(|| format!("create {}", path.display()))?;
        }
    }

    let gitignore = khive_dir.join(".gitignore");
    if !gitignore.exists() {
        std::fs::write(&gitignore, GITIGNORE_CONTENT)
            .with_context(|| format!("write {}", gitignore.display()))?;
    }

    let toml_path = khive_dir.join("khive.toml");
    if !toml_path.exists() {
        std::fs::write(&toml_path, DEFAULT_KHIVE_TOML)
            .with_context(|| format!("write {}", toml_path.display()))?;
        println!("  Initialized {}", toml_path.display());
    } else {
        println!("  Skipped {} (already exists)", toml_path.display());
    }

    let hook_script = hooks_dir.join("pre-commit");
    if !hook_script.exists() {
        std::fs::write(&hook_script, PRE_COMMIT_HOOK)
            .with_context(|| format!("write {}", hook_script.display()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&hook_script)?.permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&hook_script, perms)?;
        }
    }

    println!("  Initialized .khive/kg/ (entities.ndjson, edges.ndjson, hooks/pre-commit)");

    if args.ci {
        let workflow_dir = args.repo.join(".github/workflows");
        std::fs::create_dir_all(&workflow_dir)
            .with_context(|| format!("create {}", workflow_dir.display()))?;
        let workflow_path = workflow_dir.join("kg-validate.yml");
        if !workflow_path.exists() {
            std::fs::write(&workflow_path, CI_WORKFLOW)
                .with_context(|| format!("write {}", workflow_path.display()))?;
            println!("  Generated {}", workflow_path.display());
        }
    }

    Ok(())
}

pub(super) fn cmd_hook(cmd: HookCommand) -> Result<()> {
    match cmd {
        HookCommand::Install { repo } => hook_install(&repo),
        HookCommand::Uninstall { repo } => hook_uninstall(&repo),
        HookCommand::Status { repo } => hook_status(&repo),
    }
}

pub(super) fn hook_install(repo: &Path) -> Result<()> {
    let hook_script = repo.join(".khive/kg/hooks/pre-commit");
    let git_hook = repo.join(".git/hooks/pre-commit");

    if !hook_script.exists() {
        bail!(
            "Hook script not found: {}. Run `kkernel kg init` first.",
            hook_script.display()
        );
    }

    if let Some(parent) = git_hook.parent() {
        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
    }

    if git_hook.exists() || git_hook.is_symlink() {
        std::fs::remove_file(&git_hook)
            .with_context(|| format!("remove existing {}", git_hook.display()))?;
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::symlink;
        let absolute_script = hook_script
            .canonicalize()
            .unwrap_or_else(|_| hook_script.clone());
        symlink(&absolute_script, &git_hook)
            .with_context(|| format!("create symlink {}", git_hook.display()))?;
    }

    #[cfg(not(unix))]
    {
        std::fs::copy(&hook_script, &git_hook)
            .with_context(|| format!("copy hook to {}", git_hook.display()))?;
    }

    println!(
        "  Installed: {} -> {}",
        git_hook.display(),
        hook_script.display()
    );
    Ok(())
}

fn hook_uninstall(repo: &Path) -> Result<()> {
    let git_hook = repo.join(".git/hooks/pre-commit");
    if git_hook.exists() || git_hook.is_symlink() {
        std::fs::remove_file(&git_hook)
            .with_context(|| format!("remove {}", git_hook.display()))?;
        println!("  Uninstalled: {}", git_hook.display());
    } else {
        println!("  No hook installed at {}", git_hook.display());
    }
    Ok(())
}

fn hook_status(repo: &Path) -> Result<()> {
    let git_hook = repo.join(".git/hooks/pre-commit");
    let symlink_exists = git_hook.exists() || git_hook.is_symlink();
    let symlink_target = if symlink_exists {
        std::fs::read_link(&git_hook)
            .ok()
            .map(|p| p.display().to_string())
    } else {
        None
    };
    let target_valid = symlink_target
        .as_deref()
        .map(|t| Path::new(t).exists())
        .unwrap_or(false);

    let status = HookStatus {
        symlink_exists,
        symlink_target,
        target_valid,
    };
    let json = serde_json::to_string(&status).expect("serialize HookStatus");
    println!("{json}");
    Ok(())
}

#[cfg(test)]
mod tests {
    use tempfile::TempDir;

    use super::*;

    #[test]
    fn init_creates_expected_files() {
        let tmp = TempDir::new().unwrap();
        let args = InitArgs {
            repo: tmp.path().to_path_buf(),
            ci: false,
            add_hooks: false,
        };
        cmd_init(args).unwrap();

        assert!(tmp.path().join(".khive/kg/entities.ndjson").exists());
        assert!(tmp.path().join(".khive/kg/edges.ndjson").exists());
        assert!(tmp.path().join(".khive/khive.toml").exists());
        assert!(tmp.path().join(".khive/kg/hooks/pre-commit").exists());
    }

    #[test]
    fn init_does_not_overwrite_existing_toml() {
        let tmp = TempDir::new().unwrap();
        std::fs::create_dir_all(tmp.path().join(".khive")).unwrap();
        let toml_path = tmp.path().join(".khive/khive.toml");
        std::fs::write(&toml_path, "# custom\n").unwrap();

        let args = InitArgs {
            repo: tmp.path().to_path_buf(),
            ci: false,
            add_hooks: false,
        };
        cmd_init(args).unwrap();

        let content = std::fs::read_to_string(&toml_path).unwrap();
        assert_eq!(content, "# custom\n", "should not overwrite existing toml");
    }
}