wsx-core 0.22.3

Composable project, Git worktree, terminal runtime, hook, and model primitives for wsx.
Documentation
use super::IntegrationTarget;
use serde_json::{json, Map, Value};
use std::io;
use std::path::Path;

pub(crate) fn command(path: &Path, action: &str) -> String {
    format!(
        "'{}' {action}",
        path.display().to_string().replace('\'', "'\\''")
    )
}
fn object(content: &str, path: &Path) -> io::Result<Value> {
    if content.trim().is_empty() {
        return Ok(json!({}));
    }
    let value: Value = serde_json::from_str(content)
        .map_err(|e| io::Error::other(format!("failed to parse {}: {e}", path.display())))?;
    if !value.is_object() {
        return Err(io::Error::other(format!(
            "{} must contain a JSON object",
            path.display()
        )));
    }
    Ok(value)
}
fn hooks(value: &mut Value) -> io::Result<&mut Map<String, Value>> {
    value
        .as_object_mut()
        .unwrap()
        .entry("hooks")
        .or_insert_with(|| json!({}))
        .as_object_mut()
        .ok_or_else(|| io::Error::other("hooks must be an object"))
}
fn push_unique(
    map: &mut Map<String, Value>,
    event: &str,
    entry: Value,
    command: &str,
) -> io::Result<()> {
    let list = map
        .entry(event.to_string())
        .or_insert_with(|| json!([]))
        .as_array_mut()
        .ok_or_else(|| io::Error::other(format!("hook {event} must be an array")))?;
    if !list.iter().any(|v| v.to_string().contains(command)) {
        list.push(entry);
    }
    Ok(())
}
fn remove_nested_actions(map: &mut Map<String, Value>, event: &str, path: &Path, actions: &[&str]) {
    let commands = actions
        .iter()
        .map(|action| command(path, action))
        .collect::<Vec<_>>();
    let Some(entries) = map.get_mut(event).and_then(Value::as_array_mut) else {
        return;
    };
    entries.retain_mut(|entry| {
        let Some(hooks) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
            return true;
        };
        hooks.retain(|hook| {
            !hook
                .get("command")
                .and_then(Value::as_str)
                .is_some_and(|value| commands.iter().any(|command| command == value))
        });
        !hooks.is_empty()
    });
    if entries.is_empty() {
        map.remove(event);
    }
}

fn nested(
    map: &mut Map<String, Value>,
    event: &str,
    path: &Path,
    action: &str,
    matcher: Option<&str>,
) -> io::Result<()> {
    let cmd = command(path, action);
    let mut e = json!({"hooks":[{"type":"command","command":cmd,"timeout":10}]});
    if let Some(m) = matcher {
        e.as_object_mut()
            .unwrap()
            .insert("matcher".into(), json!(m));
    }
    push_unique(map, event, e, &cmd)
}

pub(crate) fn json_config(
    target: IntegrationTarget,
    content: &str,
    config: &Path,
    hook: &Path,
) -> io::Result<String> {
    let mut root = object(content, config)?;
    match target {
        IntegrationTarget::Claude => {
            let hooks = hooks(&mut root)?;
            let actions = &["session", "idle", "working", "blocked", "done", "error"];
            // Claude has no permission-resolved hook before an approved tool runs,
            // so PermissionRequest cannot authoritatively publish a bounded blocked state.
            // StopFailure matchers receive the documented terminal API error name.
            // ^ https://code.claude.com/docs/en/hooks
            remove_nested_actions(hooks, "PermissionRequest", hook, actions);
            let events = [
                ("SessionStart", "idle"),
                ("SessionEnd", "idle"),
                ("UserPromptSubmit", "working"),
                ("PreToolUse", "working"),
                ("PostToolUse", "working"),
                ("PostToolUseFailure", "working"),
                ("Stop", "done"),
            ];
            for (event, _) in events {
                remove_nested_actions(hooks, event, hook, actions);
            }
            remove_nested_actions(hooks, "StopFailure", hook, actions);
            for (event, action) in events {
                nested(hooks, event, hook, action, Some("*"))?;
            }
            nested(hooks, "StopFailure", hook, "blocked", Some("rate_limit"))?;
            nested(
                hooks,
                "StopFailure",
                hook,
                "error",
                Some("overloaded|authentication_failed|oauth_org_not_allowed|account_on_hold|billing_error|invalid_request|model_not_found|server_error|max_output_tokens|unknown"),
            )?;
        }
        IntegrationTarget::Codex => {
            let hooks = hooks(&mut root)?;
            let events = [
                ("SessionStart", "idle"),
                ("UserPromptSubmit", "working"),
                ("PreToolUse", "working"),
                ("PermissionRequest", "blocked"),
                ("PostToolUse", "working"),
                ("Stop", "done"),
                ("Interrupt", "idle"),
            ];
            for (event, _) in events {
                remove_nested_actions(
                    hooks,
                    event,
                    hook,
                    &["session", "idle", "working", "blocked", "done"],
                );
            }
            for (event, action) in events {
                nested(hooks, event, hook, action, Some("*"))?;
            }
        }
        IntegrationTarget::Droid | IntegrationTarget::Qodercli | IntegrationTarget::Qwen => {
            nested(
                hooks(&mut root)?,
                "SessionStart",
                hook,
                "session",
                Some("*"),
            )?;
        }
        IntegrationTarget::Devin => {
            for event in [
                "SessionStart",
                "UserPromptSubmit",
                "PreToolUse",
                "PostToolUse",
                "PermissionRequest",
                "Stop",
            ] {
                nested(hooks(&mut root)?, event, hook, "session", None)?;
            }
        }
        IntegrationTarget::Copilot => {
            let cmd = command(hook, "session");
            let field = if cfg!(windows) { "powershell" } else { "bash" };
            let mut e = Map::new();
            e.insert("type".into(), json!("command"));
            e.insert(field.into(), json!(cmd));
            e.insert("timeoutSec".into(), json!(10));
            push_unique(hooks(&mut root)?, "SessionStart", Value::Object(e), &cmd)?;
        }
        IntegrationTarget::Cursor => {
            let cmd = command(hook, "session");
            push_unique(
                hooks(&mut root)?,
                "sessionStart",
                json!({"command":cmd}),
                &cmd,
            )?;
        }
        IntegrationTarget::Mastracode => {
            for (event, action) in [
                ("SessionStart", "idle"),
                ("UserPromptSubmit", "working"),
                ("AgentStart", "working"),
                ("PreToolUse", "working"),
                ("PermissionRequest", "blocked"),
                ("PermissionResult", "working"),
                ("SubagentStart", "working"),
                ("SubagentEnd", "working"),
                ("Interrupt", "idle"),
                ("AgentEnd", "done"),
                ("Stop", "done"),
            ] {
                let cmd = command(hook, action);
                push_unique(
                    root.as_object_mut().unwrap(),
                    event,
                    json!({"type":"command","command":cmd,"timeout":10000,"description":"Report MastraCode state to wsx"}),
                    &cmd,
                )?;
            }
        }
        IntegrationTarget::AntigravityCli => {
            let cmd = command(hook, "session");
            root.as_object_mut().unwrap().insert(
                "wsx".into(),
                json!({"PreInvocation":[{"type":"command","command":cmd,"timeout":10}]}),
            );
        }
        _ => return Err(io::Error::other("target has no editable JSON config")),
    }
    serde_json::to_string_pretty(&root)
        .map_err(io::Error::other)
        .map(|mut s| {
            s.push('\n');
            s
        })
}

pub(crate) fn codex_toml(content: &str) -> String {
    let mut lines: Vec<&str> = content
        .lines()
        .filter(|l| !l.trim_start().starts_with("codex_hooks"))
        .collect();
    let mut found = false;
    let mut in_features = false;
    for line in &mut lines {
        if line.trim_start().starts_with('[') {
            in_features = line.trim() == "[features]";
        }
        if in_features && line.trim_start().starts_with("hooks") {
            *line = "hooks = true";
            found = true;
        }
    }
    let mut out = lines.join("\n");
    if !found {
        if !out.is_empty() {
            out.push_str("\n\n");
        }
        out.push_str("[features]\nhooks = true");
    }
    out.push('\n');
    out
}

pub(crate) fn kimi_toml(content: &str, hook: &Path) -> String {
    const B: &str = "# >>> wsx kimi integration";
    const E: &str = "# <<< wsx kimi integration";
    let mut out = String::new();
    let mut skip = false;
    for l in content.lines() {
        if l.trim() == B {
            skip = true;
            continue;
        }
        if l.trim() == E {
            skip = false;
            continue;
        }
        if !skip {
            out.push_str(l);
            out.push('\n')
        }
    }
    if !out.trim().is_empty() {
        out.push('\n')
    }
    out.push_str(B);
    out.push('\n');
    for (event, matcher, action) in [
        ("SessionStart", None, "idle"),
        ("UserPromptSubmit", None, "working"),
        ("PreToolUse", Some("^(?!AskUserQuestion$).*$"), "working"),
        ("PreToolUse", Some("^AskUserQuestion$"), "blocked"),
        ("PostToolUse", Some("^AskUserQuestion$"), "working"),
        ("PostToolUseFailure", Some("^AskUserQuestion$"), "working"),
        ("SubagentStart", None, "working"),
        ("PreCompact", None, "working"),
        ("PermissionRequest", None, "blocked"),
        ("PermissionResult", None, "working"),
        ("Stop", None, "done"),
        ("Interrupt", None, "idle"),
    ] {
        out.push_str(&format!("[[hooks]]\nevent = \"{event}\"\n"));
        if let Some(matcher) = matcher {
            out.push_str(&format!("matcher = {:?}\n", matcher));
        }
        out.push_str(&format!(
            "command = {:?}\ntimeout = 10\n\n",
            command(hook, action)
        ));
    }
    out.push_str(E);
    out.push('\n');
    out
}

pub(crate) fn hermes_yaml(content: &str) -> String {
    if content
        .lines()
        .any(|line| line.trim().trim_start_matches("- ") == "wsx-agent-status")
    {
        return content.to_string();
    }

    let trailing_newline = content.ends_with('\n');
    let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
    if let Some(plugins) = lines.iter().position(|line| {
        !line.chars().next().is_some_and(char::is_whitespace)
            && line.trim_start().starts_with("plugins:")
    }) {
        if lines[plugins].trim() == "plugins: []" {
            lines.splice(
                plugins..=plugins,
                [
                    "plugins:".to_string(),
                    "  enabled:".to_string(),
                    "    - wsx-agent-status".to_string(),
                ],
            );
        } else {
            let end = lines[plugins + 1..]
                .iter()
                .position(|line| {
                    !line.chars().next().is_some_and(char::is_whitespace)
                        && !line.trim().is_empty()
                        && !line.trim_start().starts_with('#')
                })
                .map(|offset| plugins + 1 + offset)
                .unwrap_or(lines.len());
            if let Some(enabled) = lines[plugins + 1..end]
                .iter()
                .position(|line| line.starts_with("  ") && line.trim() == "enabled:")
                .map(|offset| plugins + 1 + offset)
            {
                lines.insert(enabled + 1, "    - wsx-agent-status".to_string());
            } else {
                lines.insert(plugins + 1, "  enabled:".to_string());
                lines.insert(plugins + 2, "    - wsx-agent-status".to_string());
            }
        }
    } else {
        if !lines.is_empty() {
            lines.push(String::new());
        }
        lines.extend([
            "plugins:".to_string(),
            "  enabled:".to_string(),
            "    - wsx-agent-status".to_string(),
        ]);
    }
    let mut output = lines.join("\n");
    if trailing_newline || !output.is_empty() {
        output.push('\n');
    }
    output
}