claude-plugin-validate 0.1.1

CLI validator for Claude Code plugin manifests and plugin content schemas.
Documentation
use super::common::{is_rel_json_path, obj_get, push_issue};
use crate::ValidationIssue;
use serde_json::{Map, Value};

pub(super) fn validate_hooks_field(root: &Map<String, Value>, issues: &mut Vec<ValidationIssue>) {
    let Some(hooks) = obj_get(root, "hooks") else {
        return;
    };

    validate_hooks_value("hooks", hooks, issues);
}

fn is_valid_hook_event(event: &str) -> bool {
    matches!(
        event,
        "PreToolUse"
            | "PostToolUse"
            | "PostToolUseFailure"
            | "Notification"
            | "UserPromptSubmit"
            | "SessionStart"
            | "SessionEnd"
            | "Stop"
            | "StopFailure"
            | "SubagentStart"
            | "SubagentStop"
            | "PreCompact"
            | "PostCompact"
            | "PermissionRequest"
            | "PermissionDenied"
            | "Setup"
            | "TeammateIdle"
            | "TaskCreated"
            | "TaskCompleted"
            | "Elicitation"
            | "ElicitationResult"
            | "ConfigChange"
            | "WorktreeCreate"
            | "WorktreeRemove"
            | "InstructionsLoaded"
            | "CwdChanged"
            | "FileChanged"
    )
}

fn validate_hooks_value(path: &str, hooks: &Value, issues: &mut Vec<ValidationIssue>) {
    if let Some(s) = hooks.as_str() {
        if !is_rel_json_path(s) {
            push_issue(
                issues,
                path,
                "invalid_string",
                "hooks path must start with ./ and end with .json",
            );
        }
        return;
    }

    if let Some(arr) = hooks.as_array() {
        for (idx, item) in arr.iter().enumerate() {
            validate_hooks_value(&format!("{path}.{idx}"), item, issues);
        }
        return;
    }

    let Some(map) = hooks.as_object() else {
        push_issue(
            issues,
            path,
            "invalid_type",
            "hooks must be an object, path, or array",
        );
        return;
    };

    for (event, matchers) in map {
        if !is_valid_hook_event(event) {
            push_issue(
                issues,
                &format!("{path}.{event}"),
                "invalid_enum_value",
                "Unknown hook event",
            );
            continue;
        }

        let Some(matchers_arr) = matchers.as_array() else {
            push_issue(
                issues,
                &format!("{path}.{event}"),
                "invalid_type",
                "Hook event value must be an array",
            );
            continue;
        };

        for (m_idx, matcher) in matchers_arr.iter().enumerate() {
            let Some(m_obj) = matcher.as_object() else {
                push_issue(
                    issues,
                    &format!("{path}.{event}.{m_idx}"),
                    "invalid_type",
                    "Hook matcher must be an object",
                );
                continue;
            };

            if let Some(mv) = m_obj.get("matcher")
                && !mv.is_string()
            {
                push_issue(
                    issues,
                    &format!("{path}.{event}.{m_idx}.matcher"),
                    "invalid_type",
                    "matcher must be a string",
                );
            }

            let Some(hook_list) = m_obj.get("hooks").and_then(Value::as_array) else {
                push_issue(
                    issues,
                    &format!("{path}.{event}.{m_idx}.hooks"),
                    "missing_required",
                    "hooks must be an array",
                );
                continue;
            };

            for (h_idx, hook_cmd) in hook_list.iter().enumerate() {
                validate_hook_command(
                    &format!("{path}.{event}.{m_idx}.hooks.{h_idx}"),
                    hook_cmd,
                    issues,
                );
            }
        }
    }
}

fn validate_hook_command(path: &str, value: &Value, issues: &mut Vec<ValidationIssue>) {
    let Some(obj) = value.as_object() else {
        push_issue(
            issues,
            path,
            "invalid_type",
            "Hook command must be an object",
        );
        return;
    };

    let Some(kind) = obj.get("type").and_then(Value::as_str) else {
        push_issue(
            issues,
            &format!("{path}.type"),
            "missing_required",
            "Hook command.type is required",
        );
        return;
    };

    if let Some(ifv) = obj.get("if")
        && !ifv.is_string()
    {
        push_issue(
            issues,
            &format!("{path}.if"),
            "invalid_type",
            "if must be a string",
        );
    }

    if let Some(tv) = obj.get("timeout") {
        if let Some(num) = tv.as_f64() {
            if num <= 0.0 {
                push_issue(
                    issues,
                    &format!("{path}.timeout"),
                    "too_small",
                    "timeout must be > 0",
                );
            }
        } else {
            push_issue(
                issues,
                &format!("{path}.timeout"),
                "invalid_type",
                "timeout must be a number",
            );
        }
    }

    if let Some(msg) = obj.get("statusMessage")
        && !msg.is_string()
    {
        push_issue(
            issues,
            &format!("{path}.statusMessage"),
            "invalid_type",
            "statusMessage must be a string",
        );
    }
    for bool_key in ["once", "async", "asyncRewake"] {
        if let Some(v) = obj.get(bool_key)
            && !v.is_boolean()
        {
            push_issue(
                issues,
                &format!("{path}.{bool_key}"),
                "invalid_type",
                &format!("{bool_key} must be a boolean"),
            );
        }
    }

    match kind {
        "command" => {
            if obj.get("command").and_then(Value::as_str).is_none() {
                push_issue(
                    issues,
                    &format!("{path}.command"),
                    "missing_required",
                    "command hook requires command (string)",
                );
            }
            if let Some(shell) = obj.get("shell") {
                match shell.as_str() {
                    Some("bash") | Some("powershell") => {}
                    Some(_) => push_issue(
                        issues,
                        &format!("{path}.shell"),
                        "invalid_enum_value",
                        "shell must be bash or powershell",
                    ),
                    None => push_issue(
                        issues,
                        &format!("{path}.shell"),
                        "invalid_type",
                        "shell must be a string",
                    ),
                }
            }
        }
        "prompt" | "agent" => {
            if obj.get("prompt").and_then(Value::as_str).is_none() {
                push_issue(
                    issues,
                    &format!("{path}.prompt"),
                    "missing_required",
                    "prompt/agent hook requires prompt (string)",
                );
            }
            if let Some(model) = obj.get("model")
                && !model.is_string()
            {
                push_issue(
                    issues,
                    &format!("{path}.model"),
                    "invalid_type",
                    "model must be a string",
                );
            }
        }
        "http" => {
            match obj.get("url").and_then(Value::as_str) {
                Some(url) if url::Url::parse(url).is_ok() => {}
                Some(_) => push_issue(
                    issues,
                    &format!("{path}.url"),
                    "invalid_string",
                    "http hook url must be a valid URL",
                ),
                None => push_issue(
                    issues,
                    &format!("{path}.url"),
                    "missing_required",
                    "http hook requires url",
                ),
            }

            if let Some(headers) = obj.get("headers") {
                let Some(hm) = headers.as_object() else {
                    push_issue(
                        issues,
                        &format!("{path}.headers"),
                        "invalid_type",
                        "headers must be a string map",
                    );
                    return;
                };
                for (k, v) in hm {
                    if !v.is_string() {
                        push_issue(
                            issues,
                            &format!("{path}.headers.{k}"),
                            "invalid_type",
                            "header values must be strings",
                        );
                    }
                }
            }

            if let Some(allowed) = obj.get("allowedEnvVars") {
                let Some(arr) = allowed.as_array() else {
                    push_issue(
                        issues,
                        &format!("{path}.allowedEnvVars"),
                        "invalid_type",
                        "allowedEnvVars must be an array of strings",
                    );
                    return;
                };
                for (i, item) in arr.iter().enumerate() {
                    if !item.is_string() {
                        push_issue(
                            issues,
                            &format!("{path}.allowedEnvVars.{i}"),
                            "invalid_type",
                            "allowedEnvVars item must be a string",
                        );
                    }
                }
            }
        }
        _ => push_issue(
            issues,
            &format!("{path}.type"),
            "invalid_enum_value",
            "Hook command.type must be one of: command|prompt|agent|http",
        ),
    }
}