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