lwc 0.17.11

Agent-driven proactive memory CLI for AI agents β€” autonomously recall, maintain, and evolve persistent, source-grounded knowledge across sessions.
#![cfg(windows)]

use serde_json::Value;
use std::{
    collections::BTreeMap,
    env, fs,
    io::Write,
    path::{Path, PathBuf},
    process::{Command, Stdio},
};

fn project_command(project: &Path, project_root: &Path, home: &Path) -> Command {
    let mut command = Command::new(env!("CARGO_BIN_EXE_lwc"));
    command
        .current_dir(project)
        .env("HOME", home)
        .env("USERPROFILE", home)
        .env("LWC_PROJECT_ROOT", project_root);
    command
}

fn project_json(project: &Path, project_root: &Path, home: &Path, args: &[&str]) -> Value {
    let output = project_command(project, project_root, home)
        .args(args)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "{args:?}\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    serde_json::from_slice(&output.stdout).unwrap()
}

fn project_hook(
    project: &Path,
    project_root: &Path,
    home: &Path,
    event: &str,
    input: &Value,
) -> Value {
    let mut child = project_command(project, project_root, home)
        .args([
            "--scope", "all", "agent", "hook", "--agent", "claude", "--event", event,
        ])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();
    child
        .stdin
        .take()
        .unwrap()
        .write_all(input.to_string().as_bytes())
        .unwrap();
    let output = child.wait_with_output().unwrap();
    assert!(
        output.status.success(),
        "{event}\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    serde_json::from_slice(&output.stdout).unwrap()
}

fn tree_snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
    fn visit(root: &Path, path: &Path, snapshot: &mut BTreeMap<PathBuf, Vec<u8>>) {
        let metadata = fs::symlink_metadata(path).unwrap();
        let relative = path.strip_prefix(root).unwrap().to_path_buf();
        if metadata.file_type().is_symlink() {
            snapshot.insert(
                relative,
                fs::read_link(path)
                    .unwrap()
                    .to_string_lossy()
                    .as_bytes()
                    .to_vec(),
            );
        } else if metadata.is_dir() {
            snapshot.insert(relative, b"directory".to_vec());
            let mut entries = fs::read_dir(path)
                .unwrap()
                .map(|entry| entry.unwrap())
                .collect::<Vec<_>>();
            entries.sort_by_key(|entry| entry.file_name());
            for entry in entries {
                visit(root, &entry.path(), snapshot);
            }
        } else {
            snapshot.insert(relative, fs::read(path).unwrap());
        }
    }

    let mut snapshot = BTreeMap::new();
    visit(root, root, &mut snapshot);
    snapshot
}

fn signal_batch(context: &str) -> Value {
    let line = context
        .lines()
        .find_map(|line| line.strip_prefix("LWC_SIGNAL "))
        .expect("Hook context must contain an LWC_SIGNAL batch");
    serde_json::from_str(line).unwrap()
}

fn readiness(context: &str) -> Value {
    let line = context
        .lines()
        .find_map(|line| line.strip_prefix("LWC_READINESS "))
        .expect("Hook context must contain LWC_READINESS");
    serde_json::from_str(line).unwrap()
}

#[test]
fn npm_cmd_shim_passes_agent_install_preflight() {
    let temp = tempfile::tempdir().unwrap();
    let home = temp.path().join("home");
    let project = temp.path().join("project");
    let bin = home.join("bin");
    fs::create_dir_all(&bin).unwrap();
    fs::create_dir_all(&project).unwrap();
    fs::write(
        bin.join("lwc.cmd"),
        "@echo off\r\nif \"%1\"==\"serve\" if \"%2\"==\"--help\" echo Usage: lwc serve --mcp\r\nexit /b 0\r\n",
    )
    .unwrap();
    let path = env::join_paths(
        std::iter::once(bin.clone()).chain(env::split_paths(&env::var_os("PATH").unwrap())),
    )
    .unwrap();

    let output = Command::new(env!("CARGO_BIN_EXE_lwc"))
        .current_dir(&project)
        .env("HOME", &home)
        .env("USERPROFILE", &home)
        .env("PATH", path)
        .env("PATHEXT", ".COM;.EXE;.BAT;.CMD")
        .env_remove("CODEX_HOME")
        .args([
            "agent",
            "install",
            "--target",
            "codex",
            "--location",
            "global",
            "--yes",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let receipt: Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(receipt["targets"][0]["status"], "installed");
    let config = fs::read_to_string(home.join(".codex/config.toml")).unwrap();
    assert!(config.contains("command = \"lwc\""));
    assert!(config.contains("args = [\"serve\", \"--mcp\"]"));
}

#[test]
fn canonical_project_root_hooks_read_the_active_plan_without_writes() {
    let temp = tempfile::tempdir().unwrap();
    let project = temp.path().join("project space δΈ­ζ–‡");
    let home = temp.path().join("home");
    fs::create_dir_all(&project).unwrap();
    fs::create_dir_all(&home).unwrap();
    let canonical_project = project.canonicalize().unwrap();
    assert!(
        canonical_project.to_string_lossy().starts_with(r"\\?\"),
        "Windows canonicalize must exercise the verbatim-drive path: {canonical_project:?}"
    );

    project_json(&project, &canonical_project, &home, &["init"]);
    project_json(
        &project,
        &canonical_project,
        &home,
        &["config", "set", "--plan", "enabled"],
    );
    let created = project_json(
        &project,
        &canonical_project,
        &home,
        &[
            "plan",
            "create",
            "WINDOWS_VERBATIM_PLAN",
            "--objective",
            "continue safely",
            "--done-when",
            "the Hook sees this Plan",
            "--step",
            "verify the current step",
        ],
    );
    let plan_id = created["plan"]["id"].as_str().unwrap();
    let session_id = "WINDOWS_VERBATIM_SESSION";
    let discovery = project_hook(
        &project,
        &canonical_project,
        &home,
        "SessionStart",
        &serde_json::json!({
            "hook_event_name":"SessionStart",
            "source":"startup",
            "session_id":session_id,
        }),
    );
    let discovery_context = discovery
        .pointer("/hookSpecificOutput/additionalContext")
        .and_then(Value::as_str)
        .expect("SessionStart must expose the resolved Agent context");
    let context_id = readiness(discovery_context)["agent_context"]["context_id"]
        .as_str()
        .unwrap()
        .to_owned();
    project_json(
        &project,
        &canonical_project,
        &home,
        &["plan", "track", plan_id, "--context", &context_id],
    );
    let before = tree_snapshot(temp.path());

    let session = project_hook(
        &project,
        &canonical_project,
        &home,
        "SessionStart",
        &serde_json::json!({
            "hook_event_name":"SessionStart",
            "source":"startup",
            "session_id":session_id,
        }),
    );
    let session_context = session
        .pointer("/hookSpecificOutput/additionalContext")
        .and_then(Value::as_str)
        .expect("SessionStart must preserve the native Claude context envelope");
    let session_batch = signal_batch(session_context);
    assert_eq!(session_batch["event"], "session_start");
    assert_eq!(session_batch["signals"][0]["kind"], "plan.resume");
    assert_eq!(session_batch["signals"][0]["state"]["id"], plan_id);
    assert_eq!(tree_snapshot(temp.path()), before);

    let stop = project_hook(
        &project,
        &canonical_project,
        &home,
        "Stop",
        &serde_json::json!({
            "hook_event_name":"Stop",
            "stop_hook_active":false,
            "session_id":session_id,
        }),
    );
    assert_eq!(stop["decision"], "block", "{stop}");
    let stop_batch = signal_batch(stop["reason"].as_str().unwrap());
    assert_eq!(stop_batch["event"], "stop");
    assert_eq!(stop_batch["signals"][0]["kind"], "plan.continue");
    assert_eq!(stop_batch["signals"][0]["state"]["id"], plan_id);
    assert_eq!(tree_snapshot(temp.path()), before);
}