use std::path::{Path, PathBuf};
use super::flags::InitFlags;
use super::helpers::{resolve_config_dir_for_agent, HOOK_SCRIPT_NAME, SETTINGS_FILE};
pub(crate) const MAX_SETTINGS_SIZE: u64 = 10 * 1024 * 1024;
pub(super) struct DetectedState {
pub(super) skim_binary: PathBuf,
pub(super) skim_version: String,
pub(super) config_dir: PathBuf,
pub(super) settings_path: PathBuf,
pub(super) settings_exists: bool,
pub(super) hook_installed: bool,
pub(super) hook_version: Option<String>,
pub(super) marketplace_installed: bool,
pub(super) dual_scope_warning: Option<String>,
pub(super) existing_bash_hooks: Vec<String>,
pub(super) agent_cli_name: &'static str,
}
pub(super) fn detect_state(flags: &InitFlags) -> anyhow::Result<DetectedState> {
let skim_binary = std::env::current_exe()?;
let skim_version = env!("CARGO_PKG_VERSION").to_string();
let config_dir = resolve_config_dir_for_agent(flags.project, flags.agent)?;
let settings_path = config_dir.join(SETTINGS_FILE);
let settings_exists = settings_path.exists();
let mut hook_installed = false;
let mut hook_version = None;
let mut marketplace_installed = false;
let parsed_settings = read_settings_json(&settings_path);
if let Some(ref json) = parsed_settings {
if let Some(arr) = json
.get("hooks")
.and_then(|h| h.get("PreToolUse"))
.and_then(|v| v.as_array())
{
for entry in arr {
if has_skim_hook_entry(entry) {
hook_installed = true;
hook_version = extract_hook_version_from_entry(entry, &config_dir);
}
}
}
if json
.get("extraKnownMarketplaces")
.and_then(|m| m.get("skim"))
.is_some()
{
marketplace_installed = true;
}
}
let existing_bash_hooks = scan_existing_bash_hooks(parsed_settings.as_ref());
let dual_scope_warning = check_dual_scope(flags)?;
Ok(DetectedState {
skim_binary,
skim_version,
config_dir,
settings_path,
settings_exists,
hook_installed,
hook_version,
marketplace_installed,
dual_scope_warning,
existing_bash_hooks,
agent_cli_name: flags.agent.cli_name(),
})
}
fn scan_existing_bash_hooks(parsed: Option<&serde_json::Value>) -> Vec<String> {
let json = match parsed {
Some(j) => j,
None => return Vec::new(),
};
let entries = match json
.get("hooks")
.and_then(|h| h.get("PreToolUse"))
.and_then(|ptu| ptu.as_array())
{
Some(arr) => arr,
None => return Vec::new(),
};
let mut other_hooks = Vec::new();
for entry in entries {
let is_bash_matcher = entry
.get("matcher")
.and_then(|m| m.as_str())
.is_some_and(|m| m == "Bash");
if !is_bash_matcher {
continue;
}
if has_skim_hook_entry(entry) {
continue;
}
if let Some(hooks) = entry.get("hooks").and_then(|h| h.as_array()) {
for hook in hooks {
if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) {
other_hooks.push(cmd.to_string());
}
}
}
}
other_hooks
}
pub(super) fn check_dual_scope(flags: &InitFlags) -> anyhow::Result<Option<String>> {
let other_dir = if flags.project {
resolve_config_dir_for_agent(false, flags.agent)?
} else {
match resolve_config_dir_for_agent(true, flags.agent) {
Ok(dir) => dir,
Err(_) => return Ok(None),
}
};
let other_settings = other_dir.join(SETTINGS_FILE);
let has_hook = read_settings_json(&other_settings)
.and_then(|json| {
json.get("hooks")?
.get("PreToolUse")?
.as_array()
.map(|arr| arr.iter().any(has_skim_hook_entry))
})
.unwrap_or(false);
if !has_hook {
return Ok(None);
}
let scope = if flags.project {
"globally"
} else {
"in project"
};
let uninstall_scope = if flags.project {
"--global"
} else {
"--project"
};
let path = other_settings.display();
Ok(Some(format!(
"skim hook is also installed {scope} ({path})\n \
Both hooks will fire, but this is harmless -- the second is a no-op.\n \
To remove: skim init {uninstall_scope} --uninstall"
)))
}
pub(super) fn read_settings_json(path: &Path) -> Option<serde_json::Value> {
let metadata = std::fs::metadata(path).ok()?;
if metadata.len() > MAX_SETTINGS_SIZE {
return None;
}
let contents = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&contents).ok()
}
pub(crate) fn has_skim_hook_entry(entry: &serde_json::Value) -> bool {
entry
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|hooks| {
hooks.iter().any(|hook| {
hook.get("command")
.and_then(|c| c.as_str())
.is_some_and(|cmd| cmd.contains("skim-rewrite"))
})
})
}
pub(super) fn extract_hook_version_from_entry(
entry: &serde_json::Value,
config_dir: &Path,
) -> Option<String> {
let hooks_dir = config_dir.join("hooks");
let hooks = entry.get("hooks")?.as_array()?;
for hook in hooks {
let cmd = hook.get("command")?.as_str()?;
if cmd.contains("skim-rewrite") {
let script_path = if cmd.starts_with('/') || cmd.starts_with('.') {
PathBuf::from(cmd)
} else {
hooks_dir.join(HOOK_SCRIPT_NAME)
};
let canonical = std::fs::canonicalize(&script_path).ok()?;
let canonical_hooks_dir = std::fs::canonicalize(&hooks_dir).ok()?;
if !canonical.starts_with(&canonical_hooks_dir) {
return None;
}
if let Ok(contents) = std::fs::read_to_string(&canonical) {
for line in contents.lines() {
if let Some(ver) = line.strip_prefix("# skim-hook v").or_else(|| {
line.strip_prefix("export SKIM_HOOK_VERSION=\"")
.and_then(|s| s.strip_suffix('"'))
}) {
return Some(ver.to_string());
}
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scan_existing_bash_hooks_none_input() {
let result = scan_existing_bash_hooks(None);
assert!(result.is_empty());
}
#[test]
fn test_scan_existing_bash_hooks_no_other_hooks() {
let settings = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "/home/.claude/hooks/skim-rewrite.sh"}]
}]
}
});
let result = scan_existing_bash_hooks(Some(&settings));
assert!(result.is_empty(), "skim entries should be excluded");
}
#[test]
fn test_scan_existing_bash_hooks_detects_other_bash_hook() {
let settings = serde_json::json!({
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "/home/.claude/hooks/skim-rewrite.sh"}]
},
{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "/usr/bin/other-security-hook"}]
}
]
}
});
let result = scan_existing_bash_hooks(Some(&settings));
assert_eq!(result.len(), 1);
assert_eq!(result[0], "/usr/bin/other-security-hook");
}
#[test]
fn test_scan_existing_bash_hooks_ignores_non_bash_matchers() {
let settings = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Edit",
"hooks": [{"type": "command", "command": "/usr/bin/some-hook"}]
}]
}
});
let result = scan_existing_bash_hooks(Some(&settings));
assert!(result.is_empty(), "non-Bash matchers should be ignored");
}
}