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