use anyhow::{Context, Result};
use colored::Colorize;
use serde_json;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
const MARKETPLACE_ID: &str = "lightonai-colgrep";
const PLUGIN_NAME: &str = "colgrep";
const MIN_CLAUDE_VERSION: &str = "2.0.36";
const MARKETPLACE_JSON: &str = include_str!("marketplace.json");
const PLUGIN_JSON: &str = include_str!("plugin.json");
const HOOK_JSON: &str = include_str!("hook.json");
use super::SKILL_MD;
fn get_marketplace_dir() -> Result<PathBuf> {
let data_dir = dirs::data_dir().context("Could not determine data directory")?;
Ok(data_dir.join("colgrep").join("claude-marketplace"))
}
fn create_marketplace_files() -> Result<PathBuf> {
let marketplace_dir = get_marketplace_dir()?;
let marketplace_claude_dir = marketplace_dir.join(".claude-plugin");
fs::create_dir_all(&marketplace_claude_dir)?;
let plugin_dir = marketplace_dir.join("plugins").join("colgrep");
let plugin_claude_dir = plugin_dir.join(".claude-plugin");
let hooks_dir = plugin_dir.join("hooks");
let skills_dir = plugin_dir.join("skills").join("colgrep");
fs::create_dir_all(&plugin_claude_dir)?;
fs::create_dir_all(&hooks_dir)?;
fs::create_dir_all(&skills_dir)?;
fs::write(
marketplace_claude_dir.join("marketplace.json"),
MARKETPLACE_JSON,
)?;
fs::write(plugin_claude_dir.join("plugin.json"), PLUGIN_JSON)?;
fs::write(hooks_dir.join("hook.json"), HOOK_JSON)?;
fs::write(skills_dir.join("SKILL.md"), SKILL_MD)?;
Ok(marketplace_dir)
}
fn get_plugin_dir() -> Result<PathBuf> {
get_marketplace_dir()
}
fn check_claude_cli() -> Result<()> {
let shell = get_shell();
let check = Command::new(&shell)
.args(["-c", "claude --version"])
.output()
.context("Failed to check claude CLI")?;
if !check.status.success() {
anyhow::bail!(
"Claude Code CLI not found. Please install it with:\n npm install -g @anthropic-ai/claude-code"
);
}
Ok(())
}
pub fn install_claude_code() -> Result<()> {
check_claude_cli()?;
let shell = get_shell();
println!("Creating colgrep marketplace files...");
let marketplace_dir = create_marketplace_files()?;
let marketplace_path = marketplace_dir.to_string_lossy();
println!(
"{} Marketplace files created at {}",
"✓".green(),
marketplace_path
);
let _ = Command::new(&shell)
.args([
"-c",
&format!("claude plugin marketplace remove {}", MARKETPLACE_ID),
])
.output();
let _ = Command::new(&shell)
.args(["-c", &format!("claude plugin uninstall {}", PLUGIN_NAME)])
.output();
println!("Adding colgrep marketplace to Claude Code...");
let marketplace_add = Command::new(&shell)
.args([
"-c",
&format!("claude plugin marketplace add \"{}\"", marketplace_path),
])
.output()
.context("Failed to execute claude CLI")?;
if !marketplace_add.status.success() {
let stderr = String::from_utf8_lossy(&marketplace_add.stderr);
let stdout = String::from_utf8_lossy(&marketplace_add.stdout);
eprintln!(
"{} Failed to add marketplace: {} {}",
"Error:".red(),
stderr,
stdout
);
eprintln!(
"{}",
format!(
"Do you have Claude Code version {} or higher installed?",
MIN_CLAUDE_VERSION
)
.yellow()
);
anyhow::bail!("Failed to add marketplace");
}
println!("{} Added colgrep marketplace", "✓".green());
println!("Installing colgrep plugin...");
let plugin_install = Command::new(&shell)
.args([
"-c",
&format!("claude plugin install {}@{}", PLUGIN_NAME, MARKETPLACE_ID),
])
.output()
.context("Failed to execute claude CLI")?;
if !plugin_install.status.success() {
let stderr = String::from_utf8_lossy(&plugin_install.stderr);
let stdout = String::from_utf8_lossy(&plugin_install.stdout);
eprintln!(
"{} Failed to install plugin: {} {}",
"Error:".red(),
stderr,
stdout
);
eprintln!(
"{}",
format!(
"Do you have Claude Code version {} or higher installed?",
MIN_CLAUDE_VERSION
)
.yellow()
);
anyhow::bail!("Failed to install plugin");
}
println!("{} Installed colgrep plugin", "✓".green());
print_install_success();
Ok(())
}
fn get_claude_settings_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not determine home directory")?;
Ok(home.join(".claude").join("settings.json"))
}
fn remove_hooks_from_settings() -> Result<bool> {
let settings_path = get_claude_settings_path()?;
if !settings_path.exists() {
return Ok(false);
}
let content = fs::read_to_string(&settings_path).context("Failed to read settings.json")?;
let mut settings: serde_json::Value =
serde_json::from_str(&content).context("Failed to parse settings.json")?;
let mut modified = false;
if let Some(hooks) = settings.get_mut("hooks") {
if let Some(hooks_obj) = hooks.as_object_mut() {
for (_event_name, matchers) in hooks_obj.iter_mut() {
if let Some(matchers_arr) = matchers.as_array_mut() {
let original_len = matchers_arr.len();
matchers_arr.retain(|matcher| {
if let Some(hooks_list) = matcher.get("hooks").and_then(|h| h.as_array()) {
let has_colgrep = hooks_list.iter().any(|hook| {
hook.get("command")
.and_then(|c| c.as_str())
.map(|cmd| cmd.contains("colgrep"))
.unwrap_or(false)
});
!has_colgrep } else {
true }
});
if matchers_arr.len() != original_len {
modified = true;
}
}
}
let empty_events: Vec<String> = hooks_obj
.iter()
.filter(|(_, v)| v.as_array().map(|a| a.is_empty()).unwrap_or(false))
.map(|(k, _)| k.clone())
.collect();
for event in empty_events {
hooks_obj.remove(&event);
modified = true;
}
}
if hooks.as_object().map(|o| o.is_empty()).unwrap_or(false) {
if let Some(settings_obj) = settings.as_object_mut() {
settings_obj.remove("hooks");
modified = true;
}
}
}
if modified {
let new_content =
serde_json::to_string_pretty(&settings).context("Failed to serialize settings.json")?;
fs::write(&settings_path, new_content).context("Failed to write settings.json")?;
}
Ok(modified)
}
pub fn uninstall_claude_code() -> Result<()> {
let shell = get_shell();
println!("Removing colgrep hooks from settings...");
match remove_hooks_from_settings() {
Ok(true) => println!("{} Removed colgrep hooks from settings.json", "✓".green()),
Ok(false) => println!(" No colgrep hooks found in settings.json"),
Err(e) => eprintln!(
"{} Failed to clean settings.json: {}",
"Warning:".yellow(),
e
),
}
println!("Uninstalling colgrep plugin...");
let plugin_uninstall = Command::new(&shell)
.args(["-c", &format!("claude plugin uninstall {}", PLUGIN_NAME)])
.output()
.context("Failed to execute claude CLI")?;
if !plugin_uninstall.status.success() {
let stderr = String::from_utf8_lossy(&plugin_uninstall.stderr);
eprintln!("{} Failed to uninstall plugin: {}", "Error:".red(), stderr);
eprintln!(
"{}",
format!(
"Do you have Claude Code version {} or higher installed?",
MIN_CLAUDE_VERSION
)
.yellow()
);
} else {
println!("{} Uninstalled colgrep plugin", "✓".green());
}
println!("Removing colgrep from marketplace...");
let marketplace_remove = Command::new(&shell)
.args([
"-c",
&format!("claude plugin marketplace remove {}", MARKETPLACE_ID),
])
.output()
.context("Failed to execute claude CLI")?;
if !marketplace_remove.status.success() {
let stderr = String::from_utf8_lossy(&marketplace_remove.stderr);
eprintln!(
"{} Failed to remove plugin from marketplace: {}",
"Error:".red(),
stderr
);
eprintln!(
"{}",
format!(
"Do you have Claude Code version {} or higher installed?",
MIN_CLAUDE_VERSION
)
.yellow()
);
} else {
println!("{} Removed colgrep from marketplace", "✓".green());
}
if let Ok(plugin_dir) = get_plugin_dir() {
if plugin_dir.exists() {
if let Err(e) = fs::remove_dir_all(&plugin_dir) {
eprintln!(
"{} Failed to remove plugin files at {}: {}",
"Warning:".yellow(),
plugin_dir.display(),
e
);
} else {
println!("{} Removed plugin files", "✓".green());
}
}
}
println!();
println!(
"{}",
"Colgrep has been uninstalled from Claude Code.".green()
);
Ok(())
}
fn get_shell() -> String {
if cfg!(target_os = "windows") {
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
} else {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
}
fn print_install_success() {
let border = "═".repeat(70);
println!();
println!("{}", border.yellow());
println!();
println!(
" {} {}",
"✓".green().bold(),
"COLGREP INSTALLED FOR CLAUDE CODE".green().bold()
);
println!();
println!(" Colgrep is now available as a semantic search tool in Claude Code.");
println!(" Claude will automatically use colgrep for code searches.");
println!();
println!(
" {} {}",
"→".cyan(),
"Restart Claude Code in a new terminal to enable colgrep.".bold()
);
println!();
println!(" {}", "What happens:".cyan().bold());
println!(" • Colgrep indexes your project on first search");
println!(" • Subsequent searches use the cached index");
println!(" • Index updates automatically when files change");
println!();
println!(" {}", "To uninstall:".cyan().bold());
println!(" {}", "colgrep --uninstall-claude-code".green());
println!();
println!("{}", border.yellow());
println!();
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
use std::process::Command;
#[test]
fn test_hook_json_is_valid() {
let parsed: Result<Value, _> = serde_json::from_str(HOOK_JSON);
assert!(
parsed.is_ok(),
"hook.json is not valid JSON: {:?}",
parsed.err()
);
}
#[test]
fn test_hook_commands_produce_valid_json() {
let hook_config: Value =
serde_json::from_str(HOOK_JSON).expect("hook.json should be valid JSON");
let hooks = hook_config
.get("hooks")
.expect("hook.json should have 'hooks' field");
if let Value::Object(events) = hooks {
for (event_name, matchers) in events {
if let Value::Array(matcher_list) = matchers {
for matcher_entry in matcher_list {
let matcher = matcher_entry
.get("matcher")
.and_then(|m| m.as_str())
.unwrap_or("*");
if let Some(Value::Array(hook_list)) = matcher_entry.get("hooks") {
for hook in hook_list {
if let Some(command) = hook.get("command").and_then(|c| c.as_str())
{
if command.contains("colgrep") {
continue;
}
let output = Command::new("sh")
.args(["-c", command])
.output()
.expect("Failed to execute hook command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout_trimmed = stdout.trim();
if !stdout_trimmed.is_empty() {
let parsed: Result<Value, _> =
serde_json::from_str(stdout_trimmed);
assert!(
parsed.is_ok(),
"Hook command for event '{}' matcher '{}' produced invalid JSON.\n\
Command: {}\n\
Output: {}\n\
Error: {:?}",
event_name,
matcher,
command,
stdout_trimmed,
parsed.err()
);
if event_name == "PreToolUse" {
let json = parsed.unwrap();
assert!(
json.get("hookSpecificOutput").is_some(),
"PreToolUse hook should have 'hookSpecificOutput' field.\n\
Command: {}\n\
Output: {}",
command,
stdout_trimmed
);
}
}
}
}
}
}
}
}
}
}
#[test]
fn test_hook_json_structure() {
let hook_config: Value =
serde_json::from_str(HOOK_JSON).expect("hook.json should be valid JSON");
assert!(
hook_config.get("description").is_some(),
"hook.json should have 'description' field"
);
let hooks = hook_config
.get("hooks")
.expect("hook.json should have 'hooks' field");
assert!(hooks.is_object(), "'hooks' should be an object");
}
}