|
| 1 | +use std::collections::BTreeSet; |
| 2 | +use std::env; |
| 3 | +use std::fs; |
| 4 | +use std::path::{Path, PathBuf}; |
| 5 | + |
| 6 | +use anyhow::{Context, Result}; |
| 7 | +use serde_json::Value; |
| 8 | + |
| 9 | +const BARE_HOOK_PREFIX: &str = "harness-kit-checks claude-hook "; |
| 10 | +const EXPECTED_GENERATED_HOOKS: &[&str] = &[ |
| 11 | + "permission-auto-approve", |
| 12 | + "destructive-command-guard", |
| 13 | + "github-cli-guard", |
| 14 | + "time-context", |
| 15 | + "skill-invocation-tracker", |
| 16 | +]; |
| 17 | + |
| 18 | +pub fn installed_cli_path(home: &Path) -> PathBuf { |
| 19 | + home.join(".harness-kit/bin/harness-kit-checks") |
| 20 | +} |
| 21 | + |
| 22 | +pub fn render_with_cli_path(settings_text: &str, cli_path: &Path) -> Result<String> { |
| 23 | + let mut value: Value = |
| 24 | + serde_json::from_str(settings_text).context("failed to parse Claude settings JSON")?; |
| 25 | + rewrite_hook_commands(&mut value, &shell_quote(cli_path)); |
| 26 | + Ok(format!("{}\n", serde_json::to_string_pretty(&value)?)) |
| 27 | +} |
| 28 | + |
| 29 | +pub fn validate_settings_file(settings_path: &Path) -> Result<Vec<String>> { |
| 30 | + let text = fs::read_to_string(settings_path) |
| 31 | + .with_context(|| format!("failed to read {}", settings_path.display()))?; |
| 32 | + validate_settings_text(&text) |
| 33 | + .with_context(|| format!("failed to validate {}", settings_path.display())) |
| 34 | +} |
| 35 | + |
| 36 | +pub fn install_rendered_settings(src: &Path, dest: &Path, installed_cli: &Path) -> Result<()> { |
| 37 | + if let Some(parent) = dest.parent() { |
| 38 | + fs::create_dir_all(parent)?; |
| 39 | + } |
| 40 | + let source = |
| 41 | + fs::read_to_string(src).with_context(|| format!("failed to read {}", src.display()))?; |
| 42 | + let rendered = render_with_cli_path(&source, installed_cli)?; |
| 43 | + fs::write(dest, rendered).with_context(|| format!("failed to write {}", dest.display()))?; |
| 44 | + let errors = validate_settings_file(dest)?; |
| 45 | + if !errors.is_empty() { |
| 46 | + anyhow::bail!( |
| 47 | + "generated Claude settings contain unresolvable harness-kit hook commands:\n{}", |
| 48 | + errors.join("\n") |
| 49 | + ); |
| 50 | + } |
| 51 | + Ok(()) |
| 52 | +} |
| 53 | + |
| 54 | +pub fn installed_cli_for_claude_dir(harness_dir: &Path) -> PathBuf { |
| 55 | + let home = harness_dir.parent().unwrap_or(harness_dir); |
| 56 | + installed_cli_path(home) |
| 57 | +} |
| 58 | + |
| 59 | +pub fn validate_settings_text(settings_text: &str) -> Result<Vec<String>> { |
| 60 | + let value: Value = |
| 61 | + serde_json::from_str(settings_text).context("failed to parse Claude settings JSON")?; |
| 62 | + let commands = collect_claude_hook_commands(&value); |
| 63 | + let mut errors = Vec::new(); |
| 64 | + |
| 65 | + if commands.is_empty() { |
| 66 | + errors.push("Claude settings contain no harness-kit claude-hook commands".to_string()); |
| 67 | + return Ok(errors); |
| 68 | + } |
| 69 | + |
| 70 | + let mut observed_hooks = BTreeSet::new(); |
| 71 | + for command in &commands { |
| 72 | + if let Some(hook_name) = claude_hook_name(command) { |
| 73 | + observed_hooks.insert(hook_name.to_string()); |
| 74 | + } |
| 75 | + match first_shell_word(command) { |
| 76 | + Some(binary) if command_binary_resolves(&binary) => {} |
| 77 | + Some(binary) => errors.push(format!( |
| 78 | + "Claude hook command is not resolvable: {command:?} (binary {binary:?} is missing or not executable)" |
| 79 | + )), |
| 80 | + None => errors.push(format!( |
| 81 | + "Claude hook command has no parseable binary: {command:?}" |
| 82 | + )), |
| 83 | + } |
| 84 | + } |
| 85 | + |
| 86 | + for expected in EXPECTED_GENERATED_HOOKS { |
| 87 | + if !observed_hooks.contains(*expected) { |
| 88 | + errors.push(format!("Claude hook command missing: {expected}")); |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + Ok(errors) |
| 93 | +} |
| 94 | + |
| 95 | +fn rewrite_hook_commands(value: &mut Value, quoted_cli_path: &str) { |
| 96 | + match value { |
| 97 | + Value::Object(map) => { |
| 98 | + if let Some(Value::String(command)) = map.get_mut("command") |
| 99 | + && let Some(rest) = command.strip_prefix(BARE_HOOK_PREFIX) |
| 100 | + { |
| 101 | + *command = format!("{quoted_cli_path} claude-hook {rest}"); |
| 102 | + } |
| 103 | + for child in map.values_mut() { |
| 104 | + rewrite_hook_commands(child, quoted_cli_path); |
| 105 | + } |
| 106 | + } |
| 107 | + Value::Array(items) => { |
| 108 | + for child in items { |
| 109 | + rewrite_hook_commands(child, quoted_cli_path); |
| 110 | + } |
| 111 | + } |
| 112 | + _ => {} |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +fn collect_claude_hook_commands(value: &Value) -> Vec<String> { |
| 117 | + let mut commands = Vec::new(); |
| 118 | + collect_claude_hook_commands_into(value, &mut commands); |
| 119 | + commands |
| 120 | +} |
| 121 | + |
| 122 | +fn collect_claude_hook_commands_into(value: &Value, commands: &mut Vec<String>) { |
| 123 | + match value { |
| 124 | + Value::Object(map) => { |
| 125 | + if let Some(Value::String(command)) = map.get("command") |
| 126 | + && claude_hook_name(command).is_some() |
| 127 | + { |
| 128 | + commands.push(command.clone()); |
| 129 | + } |
| 130 | + for child in map.values() { |
| 131 | + collect_claude_hook_commands_into(child, commands); |
| 132 | + } |
| 133 | + } |
| 134 | + Value::Array(items) => { |
| 135 | + for child in items { |
| 136 | + collect_claude_hook_commands_into(child, commands); |
| 137 | + } |
| 138 | + } |
| 139 | + _ => {} |
| 140 | + } |
| 141 | +} |
| 142 | + |
| 143 | +fn claude_hook_name(command: &str) -> Option<&str> { |
| 144 | + command |
| 145 | + .split_once("claude-hook ") |
| 146 | + .and_then(|(_, rest)| rest.split_whitespace().next()) |
| 147 | +} |
| 148 | + |
| 149 | +fn command_binary_resolves(binary: &str) -> bool { |
| 150 | + let path = Path::new(binary); |
| 151 | + if binary.contains('/') { |
| 152 | + return path.is_absolute() && is_executable_file(path); |
| 153 | + } |
| 154 | + env::var_os("PATH") |
| 155 | + .map(|path_var| { |
| 156 | + env::split_paths(&path_var).any(|dir| is_executable_file(&dir.join(binary))) |
| 157 | + }) |
| 158 | + .unwrap_or(false) |
| 159 | +} |
| 160 | + |
| 161 | +fn is_executable_file(path: &Path) -> bool { |
| 162 | + let Ok(metadata) = fs::metadata(path) else { |
| 163 | + return false; |
| 164 | + }; |
| 165 | + if !metadata.is_file() { |
| 166 | + return false; |
| 167 | + } |
| 168 | + #[cfg(unix)] |
| 169 | + { |
| 170 | + use std::os::unix::fs::PermissionsExt; |
| 171 | + metadata.permissions().mode() & 0o111 != 0 |
| 172 | + } |
| 173 | + #[cfg(not(unix))] |
| 174 | + { |
| 175 | + true |
| 176 | + } |
| 177 | +} |
| 178 | + |
| 179 | +fn shell_quote(path: &Path) -> String { |
| 180 | + let value = path.to_string_lossy(); |
| 181 | + format!("'{}'", value.replace('\'', r#"'\''"#)) |
| 182 | +} |
| 183 | + |
| 184 | +fn first_shell_word(command: &str) -> Option<String> { |
| 185 | + let mut chars = command.trim_start().chars().peekable(); |
| 186 | + chars.peek()?; |
| 187 | + |
| 188 | + let mut word = String::new(); |
| 189 | + let mut in_single = false; |
| 190 | + let mut in_double = false; |
| 191 | + |
| 192 | + while let Some(ch) = chars.next() { |
| 193 | + match ch { |
| 194 | + '\'' if !in_double => in_single = !in_single, |
| 195 | + '"' if !in_single => in_double = !in_double, |
| 196 | + '\\' if !in_single => { |
| 197 | + if let Some(next) = chars.next() { |
| 198 | + word.push(next); |
| 199 | + } |
| 200 | + } |
| 201 | + ch if ch.is_whitespace() && !in_single && !in_double => break, |
| 202 | + ch => word.push(ch), |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + if in_single || in_double || word.is_empty() { |
| 207 | + None |
| 208 | + } else { |
| 209 | + Some(word) |
| 210 | + } |
| 211 | +} |
| 212 | + |
| 213 | +#[cfg(test)] |
| 214 | +mod tests { |
| 215 | + use super::*; |
| 216 | + |
| 217 | + #[test] |
| 218 | + fn render_rewrites_bare_claude_hook_commands_to_installed_cli_path() -> Result<()> { |
| 219 | + let rendered = render_with_cli_path( |
| 220 | + r#"{ |
| 221 | + "hooks": { |
| 222 | + "PreToolUse": [ |
| 223 | + {"hooks": [ |
| 224 | + {"command": "harness-kit-checks claude-hook permission-auto-approve"}, |
| 225 | + {"command": "harness-kit-checks claude-hook destructive-command-guard"}, |
| 226 | + {"command": "bash ~/.claude/statusline-command.sh"} |
| 227 | + ]} |
| 228 | + ], |
| 229 | + "SessionStart": [{"hooks": [ |
| 230 | + {"command": "harness-kit-checks claude-hook time-context"} |
| 231 | + ]}], |
| 232 | + "PostToolUse": [{"hooks": [ |
| 233 | + {"command": "harness-kit-checks claude-hook skill-invocation-tracker"} |
| 234 | + ]}] |
| 235 | + } |
| 236 | + }"#, |
| 237 | + Path::new("/tmp/home with spaces/.harness-kit/bin/harness-kit-checks"), |
| 238 | + )?; |
| 239 | + |
| 240 | + assert!(!rendered.contains("\"harness-kit-checks claude-hook")); |
| 241 | + assert!(rendered.contains( |
| 242 | + "'/tmp/home with spaces/.harness-kit/bin/harness-kit-checks' claude-hook permission-auto-approve" |
| 243 | + )); |
| 244 | + assert!(rendered.contains("bash ~/.claude/statusline-command.sh")); |
| 245 | + Ok(()) |
| 246 | + } |
| 247 | + |
| 248 | + #[test] |
| 249 | + fn validate_settings_fails_loud_for_missing_hook_binary() -> Result<()> { |
| 250 | + let errors = validate_settings_text( |
| 251 | + r#"{ |
| 252 | + "hooks": {"SessionStart": [{"hooks": [ |
| 253 | + {"command": "'/tmp/definitely-missing-harness-kit-checks' claude-hook time-context"} |
| 254 | + ]}]} |
| 255 | + }"#, |
| 256 | + )?; |
| 257 | + |
| 258 | + assert!( |
| 259 | + errors |
| 260 | + .iter() |
| 261 | + .any(|error| error.contains("is missing or not executable")) |
| 262 | + ); |
| 263 | + assert!( |
| 264 | + errors |
| 265 | + .iter() |
| 266 | + .any(|error| error.contains("permission-auto-approve")) |
| 267 | + ); |
| 268 | + Ok(()) |
| 269 | + } |
| 270 | + |
| 271 | + #[test] |
| 272 | + fn first_shell_word_handles_single_quote_escapes() { |
| 273 | + assert_eq!( |
| 274 | + first_shell_word(r#"'/tmp/O'\''Brien/harness-kit-checks' claude-hook time-context"#), |
| 275 | + Some("/tmp/O'Brien/harness-kit-checks".to_string()) |
| 276 | + ); |
| 277 | + } |
| 278 | +} |
0 commit comments