use anyhow::{Context, Result};
use colored::Colorize;
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{value, Array, DocumentMut, Item, Table};
use super::SKILL_MD;
use crate::index::paths::get_colgrep_data_dir;
const COLGREP_MARKER_START: &str = "<!-- COLGREP_START -->";
const COLGREP_MARKER_END: &str = "<!-- COLGREP_END -->";
const SANDBOX_TABLE: &str = "sandbox_workspace_write";
const SANDBOX_ROOTS_KEY: &str = "writable_roots";
fn get_codex_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not determine home directory")?;
Ok(home.join(".codex"))
}
fn get_agents_md_path() -> Result<PathBuf> {
let codex_dir = get_codex_dir()?;
Ok(codex_dir.join("AGENTS.md"))
}
fn get_codex_config_path() -> Result<PathBuf> {
let codex_dir = get_codex_dir()?;
Ok(codex_dir.join("config.toml"))
}
fn add_sandbox_writable_root(path: &Path) -> Result<bool> {
let config_path = get_codex_config_path()?;
let codex_dir = get_codex_dir()?;
fs::create_dir_all(&codex_dir)
.with_context(|| format!("Failed to create {}", codex_dir.display()))?;
let raw = if config_path.exists() {
fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?
} else {
String::new()
};
let mut doc: DocumentMut = raw
.parse()
.with_context(|| format!("{} is not valid TOML", config_path.display()))?;
if !doc.contains_key(SANDBOX_TABLE) {
let mut tbl = Table::new();
tbl.set_implicit(false);
doc.insert(SANDBOX_TABLE, Item::Table(tbl));
}
let table = doc[SANDBOX_TABLE]
.as_table_mut()
.context("[sandbox_workspace_write] is not a TOML table")?;
if !table.contains_key(SANDBOX_ROOTS_KEY) {
table.insert(SANDBOX_ROOTS_KEY, value(Array::new()));
}
let arr = table[SANDBOX_ROOTS_KEY]
.as_array_mut()
.context("writable_roots is not a TOML array")?;
let path_str = path.to_string_lossy().to_string();
let already_present = arr.iter().any(|v| v.as_str() == Some(path_str.as_str()));
if already_present {
return Ok(false);
}
arr.push(path_str);
fs::write(&config_path, doc.to_string())
.with_context(|| format!("Failed to write {}", config_path.display()))?;
Ok(true)
}
fn remove_sandbox_writable_root(path: &Path) -> Result<bool> {
let config_path = get_codex_config_path()?;
if !config_path.exists() {
return Ok(false);
}
let raw = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let mut doc: DocumentMut = raw
.parse()
.with_context(|| format!("{} is not valid TOML", config_path.display()))?;
let Some(table) = doc.get_mut(SANDBOX_TABLE).and_then(|i| i.as_table_mut()) else {
return Ok(false);
};
let Some(arr) = table
.get_mut(SANDBOX_ROOTS_KEY)
.and_then(|i| i.as_array_mut())
else {
return Ok(false);
};
let path_str = path.to_string_lossy().to_string();
let before = arr.len();
arr.retain(|v| v.as_str() != Some(path_str.as_str()));
if arr.len() == before {
return Ok(false);
}
if arr.is_empty() {
table.remove(SANDBOX_ROOTS_KEY);
}
if table.is_empty() {
doc.remove(SANDBOX_TABLE);
}
if doc.as_table().is_empty() {
fs::remove_file(&config_path)
.with_context(|| format!("Failed to remove {}", config_path.display()))?;
} else {
fs::write(&config_path, doc.to_string())
.with_context(|| format!("Failed to write {}", config_path.display()))?;
}
Ok(true)
}
fn add_to_agents_md() -> Result<()> {
let codex_dir = get_codex_dir()?;
fs::create_dir_all(&codex_dir)?;
let agents_path = get_agents_md_path()?;
let mut content = if agents_path.exists() {
fs::read_to_string(&agents_path)?
} else {
String::from("# Codex Agent Tools\n\n")
};
if content.contains(COLGREP_MARKER_START) {
if let (Some(start), Some(end)) = (
content.find(COLGREP_MARKER_START),
content.find(COLGREP_MARKER_END),
) {
let end_pos = end + COLGREP_MARKER_END.len();
content = format!("{}{}", &content[..start], &content[end_pos..]);
}
}
let colgrep_section = format!(
"{}\n{}\n{}\n",
COLGREP_MARKER_START, SKILL_MD, COLGREP_MARKER_END
);
content.push_str(&colgrep_section);
fs::write(&agents_path, content)?;
Ok(())
}
fn remove_from_agents_md() -> Result<()> {
let agents_path = get_agents_md_path()?;
if !agents_path.exists() {
return Ok(());
}
let content = fs::read_to_string(&agents_path)?;
if let (Some(start), Some(end)) = (
content.find(COLGREP_MARKER_START),
content.find(COLGREP_MARKER_END),
) {
let end_pos = end + COLGREP_MARKER_END.len();
let new_content = format!("{}{}", &content[..start], &content[end_pos..]);
let cleaned = new_content.trim().to_string();
if cleaned.is_empty() || cleaned == "# Codex Agent Tools" {
fs::remove_file(&agents_path)?;
} else {
fs::write(&agents_path, format!("{}\n", cleaned))?;
}
}
Ok(())
}
pub fn install_codex() -> Result<()> {
println!("Installing colgrep for Codex...");
add_to_agents_md()?;
let agents_path = get_agents_md_path()?;
println!(
"{} Added colgrep instructions to {}",
"✓".green(),
agents_path.display()
);
let data_dir = get_colgrep_data_dir()?;
let config_path = get_codex_config_path()?;
match add_sandbox_writable_root(&data_dir) {
Ok(true) => println!(
"{} Allowed colgrep index writes in Codex sandbox: added {} to {}",
"✓".green(),
data_dir.display(),
config_path.display()
),
Ok(false) => println!(
"{} Codex sandbox already allows writes to {}",
"✓".green(),
data_dir.display()
),
Err(e) => {
println!(
"{} Could not auto-configure Codex sandbox ({}). To fix issue #95 manually, add this to {}:",
"!".yellow(),
e,
config_path.display()
);
println!(
" [{}]\n {} = [\"{}\"]",
SANDBOX_TABLE,
SANDBOX_ROOTS_KEY,
data_dir.display()
);
}
}
print_codex_success();
Ok(())
}
pub fn uninstall_codex() -> Result<()> {
println!("Uninstalling colgrep from Codex...");
remove_from_agents_md()?;
println!("{} Removed colgrep from AGENTS.md", "✓".green());
let data_dir = get_colgrep_data_dir()?;
if let Ok(true) = remove_sandbox_writable_root(&data_dir) {
let config_path = get_codex_config_path()?;
println!(
"{} Removed colgrep sandbox writable_root entry from {}",
"✓".green(),
config_path.display()
);
}
println!();
println!("{}", "Colgrep has been uninstalled from Codex.".green());
Ok(())
}
fn print_codex_success() {
println!();
println!("{}", "═".repeat(70).cyan());
println!();
println!(
" {} {}",
"✓".green().bold(),
"COLGREP INSTALLED FOR CODEX".green().bold()
);
println!();
println!(
" {}",
"Colgrep is now available as a semantic search tool in Codex.".white()
);
println!();
println!(" {}", "Usage in Codex:".cyan().bold());
println!(
" {}",
"Use natural language to search your codebase.".white()
);
println!(" {}", "Example: \"find error handling logic\"".white());
println!();
println!(" {}", "To uninstall:".cyan().bold());
println!(" {}", "colgrep --uninstall-codex".green());
println!();
println!("{}", "═".repeat(70).cyan());
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn upsert(config_path: &PathBuf, root: &Path) -> Result<bool> {
let raw = if config_path.exists() {
fs::read_to_string(config_path)?
} else {
String::new()
};
let mut doc: DocumentMut = raw.parse()?;
if !doc.contains_key(SANDBOX_TABLE) {
let mut tbl = Table::new();
tbl.set_implicit(false);
doc.insert(SANDBOX_TABLE, Item::Table(tbl));
}
let table = doc[SANDBOX_TABLE].as_table_mut().unwrap();
if !table.contains_key(SANDBOX_ROOTS_KEY) {
table.insert(SANDBOX_ROOTS_KEY, value(Array::new()));
}
let arr = table[SANDBOX_ROOTS_KEY].as_array_mut().unwrap();
let s = root.to_string_lossy().to_string();
if arr.iter().any(|v| v.as_str() == Some(s.as_str())) {
return Ok(false);
}
arr.push(s);
fs::write(config_path, doc.to_string())?;
Ok(true)
}
fn upsert_remove(config_path: &PathBuf, root: &Path) -> Result<bool> {
if !config_path.exists() {
return Ok(false);
}
let raw = fs::read_to_string(config_path)?;
let mut doc: DocumentMut = raw.parse()?;
let Some(table) = doc.get_mut(SANDBOX_TABLE).and_then(|i| i.as_table_mut()) else {
return Ok(false);
};
let Some(arr) = table
.get_mut(SANDBOX_ROOTS_KEY)
.and_then(|i| i.as_array_mut())
else {
return Ok(false);
};
let s = root.to_string_lossy().to_string();
let before = arr.len();
arr.retain(|v| v.as_str() != Some(s.as_str()));
if arr.len() == before {
return Ok(false);
}
if arr.is_empty() {
table.remove(SANDBOX_ROOTS_KEY);
}
if table.is_empty() {
doc.remove(SANDBOX_TABLE);
}
if doc.as_table().is_empty() {
fs::remove_file(config_path)?;
} else {
fs::write(config_path, doc.to_string())?;
}
Ok(true)
}
#[test]
fn test_writes_minimal_config_when_file_missing() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
let root = PathBuf::from("/home/u/.local/share/colgrep/indices");
assert!(upsert(&cfg, &root).unwrap());
let written = fs::read_to_string(&cfg).unwrap();
assert!(written.contains("[sandbox_workspace_write]"));
assert!(written.contains("writable_roots"));
assert!(written.contains("/home/u/.local/share/colgrep/indices"));
}
#[test]
fn test_idempotent_when_path_already_listed() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
let root = PathBuf::from("/opt/data/colgrep");
assert!(upsert(&cfg, &root).unwrap());
assert!(!upsert(&cfg, &root).unwrap());
let written = fs::read_to_string(&cfg).unwrap();
assert_eq!(written.matches("/opt/data/colgrep").count(), 1);
}
#[test]
fn test_preserves_other_top_level_keys() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
fs::write(
&cfg,
"model = \"o1\"\n\
# Don't touch my comment\n\
[tools.fetch]\n\
timeout_ms = 5000\n",
)
.unwrap();
assert!(upsert(&cfg, Path::new("/opt/data/colgrep")).unwrap());
let written = fs::read_to_string(&cfg).unwrap();
assert!(written.contains("model = \"o1\""));
assert!(written.contains("Don't touch my comment"));
assert!(written.contains("[tools.fetch]"));
assert!(written.contains("timeout_ms = 5000"));
assert!(written.contains("[sandbox_workspace_write]"));
assert!(written.contains("/opt/data/colgrep"));
}
#[test]
fn test_appends_to_existing_writable_roots() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
fs::write(
&cfg,
"[sandbox_workspace_write]\n\
writable_roots = [\"/already/here\"]\n\
network_access = false\n",
)
.unwrap();
assert!(upsert(&cfg, Path::new("/opt/colgrep")).unwrap());
let written = fs::read_to_string(&cfg).unwrap();
assert!(written.contains("/already/here"));
assert!(written.contains("/opt/colgrep"));
assert!(written.contains("network_access = false"));
}
#[test]
fn test_remove_only_strips_our_entry() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
fs::write(
&cfg,
"[sandbox_workspace_write]\n\
writable_roots = [\"/already/here\", \"/opt/colgrep\"]\n\
network_access = true\n",
)
.unwrap();
assert!(upsert_remove(&cfg, Path::new("/opt/colgrep")).unwrap());
let written = fs::read_to_string(&cfg).unwrap();
assert!(written.contains("/already/here"));
assert!(!written.contains("/opt/colgrep"));
assert!(written.contains("network_access = true"));
}
#[test]
fn test_remove_deletes_file_when_we_were_the_only_thing() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
let root = PathBuf::from("/opt/data/colgrep");
upsert(&cfg, &root).unwrap();
assert!(cfg.exists());
assert!(upsert_remove(&cfg, &root).unwrap());
assert!(!cfg.exists());
}
#[test]
fn test_remove_noop_on_missing_file() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
assert!(!upsert_remove(&cfg, Path::new("/opt/colgrep")).unwrap());
}
#[test]
fn test_remove_noop_when_path_not_listed() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("config.toml");
upsert(&cfg, Path::new("/already/here")).unwrap();
assert!(!upsert_remove(&cfg, Path::new("/not/installed")).unwrap());
let written = fs::read_to_string(&cfg).unwrap();
assert!(written.contains("/already/here"));
}
}