Skip to main content

systemprompt_cli/commands/core/hooks/
validate.rs

1//! `core hooks validate` command checking hook commands and plugin-root vars.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use anyhow::{Context, Result};
7use clap::Args;
8use std::path::Path;
9
10use crate::CliConfig;
11use crate::shared::CommandOutput;
12use systemprompt_identifiers::HookId;
13use systemprompt_loader::ServicesRootBootstrap;
14use systemprompt_manifest::{DiskHookConfig, HOOK_CONFIG_FILENAME};
15
16use super::types::{HookValidateEntry, HookValidateOutput};
17
18const PLUGIN_ROOT_VAR: &str = "${CLAUDE_PLUGIN_ROOT}";
19
20#[derive(Debug, Clone, Copy, Args)]
21pub struct ValidateArgs;
22
23pub(super) fn execute(_args: ValidateArgs, _config: &CliConfig) -> Result<(CommandOutput, bool)> {
24    let profile = systemprompt_config::ProfileBootstrap::get().context("Failed to get profile")?;
25    let hooks_path = ServicesRootBootstrap::active_path_or(&profile.paths.services, "hooks");
26
27    let results = validate_all_hooks(&hooks_path)?;
28    let valid = results.iter().all(|entry| entry.valid);
29    let output = HookValidateOutput { results };
30
31    Ok((
32        CommandOutput::table_of(vec!["plugin_id", "valid", "errors"], &output.results)
33            .with_title("Hook Validation Results"),
34        valid,
35    ))
36}
37
38pub fn validate_all_hooks(hooks_path: &Path) -> Result<Vec<HookValidateEntry>> {
39    if !hooks_path.exists() {
40        return Ok(Vec::new());
41    }
42
43    let mut results = Vec::new();
44
45    for dir_entry in std::fs::read_dir(hooks_path)? {
46        let dir_entry = dir_entry?;
47        let path = dir_entry.path();
48        if !path.is_dir() {
49            continue;
50        }
51
52        let dir_name = path
53            .file_name()
54            .and_then(|n| n.to_str())
55            .unwrap_or("")
56            .to_owned();
57        let config_path = path.join(HOOK_CONFIG_FILENAME);
58        if !config_path.exists() {
59            continue;
60        }
61
62        let content = match std::fs::read_to_string(&config_path) {
63            Ok(content) => content,
64            Err(e) => {
65                results.push(HookValidateEntry {
66                    hook_id: HookId::new(dir_name),
67                    valid: false,
68                    errors: vec![format!("Failed to read {HOOK_CONFIG_FILENAME}: {e}")],
69                });
70                continue;
71            },
72        };
73
74        let config: DiskHookConfig = match serde_yaml::from_str(&content) {
75            Ok(c) => c,
76            Err(e) => {
77                results.push(HookValidateEntry {
78                    hook_id: HookId::new(dir_name),
79                    valid: false,
80                    errors: vec![format!("Failed to parse {HOOK_CONFIG_FILENAME}: {e}")],
81                });
82                continue;
83            },
84        };
85
86        let mut errors = Vec::new();
87        let hook_id = config.id.clone().unwrap_or_else(|| HookId::new(dir_name));
88
89        if config.command.is_empty() {
90            errors.push("command must not be empty".to_owned());
91        } else {
92            validate_hook_command(&config.command, &path, &mut errors);
93        }
94
95        results.push(HookValidateEntry {
96            hook_id,
97            valid: errors.is_empty(),
98            errors,
99        });
100    }
101
102    Ok(results)
103}
104
105fn validate_hook_command(command: &str, hook_dir: &Path, errors: &mut Vec<String>) {
106    if command.contains(PLUGIN_ROOT_VAR) {
107        let relative = command.replace(&format!("{PLUGIN_ROOT_VAR}/"), "");
108        let script_path = hook_dir.join(&relative);
109        if !script_path.exists() {
110            errors.push(format!(
111                "Hook command references missing script: {relative}"
112            ));
113        }
114    }
115}