use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde_json::{json, Value};
const TMAI_STATUS_PREFIX: &str = "tmai: ";
fn hooks_token_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
.context("Cannot determine config directory")?
.join("tmai");
Ok(config_dir.join("hooks_token"))
}
fn ensure_hook_token(force: bool) -> Result<String> {
let path = hooks_token_path()?;
if !force {
if let Ok(existing) = fs::read_to_string(&path) {
let token = existing.trim().to_string();
if !token.is_empty() {
return Ok(token);
}
}
}
let token = uuid::Uuid::new_v4().to_string();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.with_context(|| format!("Failed to write hooks token to {}", path.display()))?;
file.write_all(token.as_bytes())
.with_context(|| format!("Failed to write hooks token to {}", path.display()))?;
}
#[cfg(not(unix))]
{
fs::write(&path, &token)
.with_context(|| format!("Failed to write hooks token to {}", path.display()))?;
}
println!("Generated hook token: {}", path.display());
Ok(token)
}
pub fn load_hook_token() -> Option<String> {
let path = hooks_token_path().ok()?;
fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn claude_settings_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Cannot determine home directory")?;
Ok(home.join(".claude").join("settings.json"))
}
const HOOK_TIMEOUT_SECS: u64 = 2;
fn build_hook_entry(event: &str, token: &str, port: u16, include_tmux_pane: bool) -> Value {
let mut headers = serde_json::Map::new();
headers.insert(
"Authorization".to_string(),
json!(format!("Bearer {}", token)),
);
if include_tmux_pane {
headers.insert("X-Tmai-Pane-Id".to_string(), json!("$TMUX_PANE"));
}
let mut hook = serde_json::Map::new();
hook.insert("type".to_string(), json!("http"));
hook.insert(
"url".to_string(),
json!(format!("http://localhost:{}/hooks/event", port)),
);
hook.insert("headers".to_string(), json!(headers));
hook.insert("timeout".to_string(), json!(HOOK_TIMEOUT_SECS));
if include_tmux_pane {
hook.insert("allowedEnvVars".to_string(), json!(["TMUX_PANE"]));
}
hook.insert(
"statusMessage".to_string(),
json!(format!("{}{}", TMAI_STATUS_PREFIX, event)),
);
json!({ "hooks": [hook] })
}
fn is_tmai_entry(entry: &Value) -> bool {
if let Some(s) = entry.get("statusMessage").and_then(|v| v.as_str()) {
if s.starts_with(TMAI_STATUS_PREFIX) {
return true;
}
}
if let Some(hooks) = entry.get("hooks").and_then(|v| v.as_array()) {
for h in hooks {
if let Some(s) = h.get("statusMessage").and_then(|v| v.as_str()) {
if s.starts_with(TMAI_STATUS_PREFIX) {
return true;
}
}
}
}
false
}
fn target_events() -> &'static [&'static str] {
&[
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Notification",
"PermissionRequest",
"Stop",
"SubagentStart",
"SubagentStop",
"TeammateIdle",
"TaskCompleted",
"SessionEnd",
"ConfigChange",
"WorktreeCreate",
"WorktreeRemove",
"PreCompact",
"PostToolUseFailure",
"InstructionsLoaded",
]
}
fn merge_hooks(settings: &mut Value, token: &str, port: u16, include_tmux_pane: bool) -> usize {
if !settings.is_object() {
*settings = json!({});
}
let hooks_entry = settings
.as_object_mut()
.unwrap()
.entry("hooks")
.or_insert_with(|| json!({}));
if !hooks_entry.is_object() {
*hooks_entry = json!({});
}
let hooks = hooks_entry.as_object_mut().unwrap();
let mut count = 0;
for event in target_events() {
let event_entry = hooks.entry(event.to_string()).or_insert_with(|| json!([]));
if !event_entry.is_array() {
*event_entry = json!([]);
}
let event_hooks = event_entry.as_array_mut().unwrap();
event_hooks.retain(|entry| !is_tmai_entry(entry));
event_hooks.push(build_hook_entry(event, token, port, include_tmux_pane));
count += 1;
}
count
}
fn remove_tmai_hooks(settings: &mut Value) -> usize {
let Some(hooks) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
return 0;
};
let mut removed = 0;
for (_event, entries) in hooks.iter_mut() {
if let Some(arr) = entries.as_array_mut() {
let before = arr.len();
arr.retain(|entry| !is_tmai_entry(entry));
removed += before - arr.len();
}
}
removed
}
pub fn run_uninit() -> Result<()> {
println!("tmai uninit — Removing Claude Code hooks integration\n");
let settings_path = claude_settings_path()?;
if !settings_path.exists() {
println!("No settings file found at {}", settings_path.display());
println!("Nothing to remove.");
return Ok(());
}
let content = fs::read_to_string(&settings_path)
.with_context(|| format!("Failed to read {}", settings_path.display()))?;
let mut settings: Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", settings_path.display()))?;
let removed = remove_tmai_hooks(&mut settings);
if removed > 0 {
let formatted = serde_json::to_string_pretty(&settings)?;
fs::write(&settings_path, formatted)
.with_context(|| format!("Failed to write {}", settings_path.display()))?;
println!(
"Removed {} tmai hook entries from {}",
removed,
settings_path.display()
);
} else {
println!("No tmai hook entries found in {}", settings_path.display());
}
if let Ok(token_path) = hooks_token_path() {
if token_path.exists() {
fs::remove_file(&token_path)
.with_context(|| format!("Failed to remove {}", token_path.display()))?;
println!("Removed hook token: {}", token_path.display());
}
}
println!("\nDone! tmai hooks have been removed from Claude Code settings.");
Ok(())
}
pub fn run(force: bool) -> Result<()> {
println!("tmai init — Setting up Claude Code hooks integration\n");
let tmai_settings = tmai_core::config::Settings::load(None::<&std::path::PathBuf>)
.context("Failed to load tmai settings for hook setup")?;
let port = tmai_settings.web.port;
let token = ensure_hook_token(force)?;
let settings_path = claude_settings_path()?;
let mut settings: Value = if settings_path.exists() {
let content = fs::read_to_string(&settings_path)
.with_context(|| format!("Failed to read {}", settings_path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", settings_path.display()))?
} else {
json!({})
};
let include_tmux_pane = std::env::var("TMUX").is_ok();
if !include_tmux_pane {
println!("Note: tmux not detected — hooks will use session_id for agent matching");
}
let count = merge_hooks(&mut settings, &token, port, include_tmux_pane);
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent)?;
}
let formatted = serde_json::to_string_pretty(&settings)?;
fs::write(&settings_path, formatted)
.with_context(|| format!("Failed to write {}", settings_path.display()))?;
println!(
"Added {} hook entries to {}",
count,
settings_path.display()
);
println!("\nSetup complete! tmai will now receive hook events from Claude Code.");
println!(
"Make sure to start tmai with web server enabled (port {}).",
port
);
Ok(())
}
fn codex_config_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Cannot determine home directory")?;
Ok(home.join(".codex").join("config.toml"))
}
fn codex_target_events() -> &'static [&'static str] {
&["SessionStart", "Stop", "AfterAgent", "AfterToolUse"]
}
const TMAI_CODEX_MARKER: &str = "# tmai-managed";
fn is_tmai_codex_entry(item: &toml_edit::Item) -> bool {
let has_marker = item
.as_value()
.and_then(|val| val.decor().suffix())
.and_then(|s| s.as_str())
.is_some_and(|s| s.contains(TMAI_CODEX_MARKER));
if has_marker {
return true;
}
item.as_str()
.is_some_and(|s| s.trim_end().ends_with("codex-hook"))
}
pub fn run_codex_init(force: bool) -> Result<()> {
println!("\nConfiguring Codex CLI hooks integration...\n");
let config_path = codex_config_path()?;
let existing = 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 = existing
.parse::<toml_edit::DocumentMut>()
.with_context(|| {
format!(
"Failed to parse {} — fix the TOML syntax and retry",
config_path.display()
)
})?;
let tmai_bin = std::env::current_exe()
.context("Cannot determine tmai binary path")?
.to_string_lossy()
.to_string();
if !doc.contains_table("hooks") {
doc["hooks"] = toml_edit::Item::Table(toml_edit::Table::new());
}
let hooks = doc["hooks"].as_table_mut().unwrap();
let hook_command = if tmai_bin.contains(' ') || tmai_bin.contains('\'') {
format!("\"{}\" codex-hook", tmai_bin.replace('"', "\\\""))
} else {
format!("{} codex-hook", tmai_bin)
};
let mut count = 0;
for event in codex_target_events() {
if let Some(existing_item) = hooks.get(event) {
if !is_tmai_codex_entry(existing_item) && !force {
println!(
" Skipping {} — existing user-managed hook (use --force to overwrite)",
event
);
continue;
}
}
let mut value = toml_edit::value(hook_command.clone());
value
.as_value_mut()
.unwrap()
.decor_mut()
.set_suffix(format!(" {}", TMAI_CODEX_MARKER));
hooks[event] = value;
count += 1;
}
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&config_path, doc.to_string())
.with_context(|| format!("Failed to write {}", config_path.display()))?;
println!("Added {} hook entries to {}", count, config_path.display());
println!("Codex CLI will now forward hook events to tmai via `tmai codex-hook`.");
Ok(())
}
pub fn run_codex_uninit() -> Result<()> {
let config_path = codex_config_path()?;
if !config_path.exists() {
println!("No Codex config found at {}", config_path.display());
return Ok(());
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let mut doc: toml_edit::DocumentMut = content.parse().with_context(|| {
format!(
"Failed to parse {} — fix the TOML syntax and retry",
config_path.display()
)
})?;
if let Some(hooks) = doc.get_mut("hooks").and_then(|h| h.as_table_mut()) {
let mut removed = 0;
for event in codex_target_events() {
if hooks.get(event).is_some_and(is_tmai_codex_entry) {
hooks.remove(event);
removed += 1;
}
}
if removed > 0 {
fs::write(&config_path, doc.to_string())
.with_context(|| format!("Failed to write {}", config_path.display()))?;
println!(
"Removed {} tmai hook entries from {}",
removed,
config_path.display()
);
} else {
println!("No tmai hook entries found in {}", config_path.display());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_hook_entry() {
let entry = build_hook_entry("PreToolUse", "test-token", 9876, true);
let hooks = entry["hooks"].as_array().unwrap();
assert_eq!(hooks.len(), 1);
assert_eq!(hooks[0]["type"], "http");
assert_eq!(hooks[0]["url"], "http://localhost:9876/hooks/event");
assert_eq!(hooks[0]["headers"]["Authorization"], "Bearer test-token");
assert_eq!(hooks[0]["headers"]["X-Tmai-Pane-Id"], "$TMUX_PANE");
assert_eq!(hooks[0]["statusMessage"], "tmai: PreToolUse");
assert_eq!(hooks[0]["timeout"], HOOK_TIMEOUT_SECS);
}
#[test]
fn test_build_hook_entry_without_tmux_pane() {
let entry = build_hook_entry("PostToolUse", "test-token", 9876, false);
let hooks = entry["hooks"].as_array().unwrap();
assert_eq!(hooks[0]["headers"]["Authorization"], "Bearer test-token");
assert!(hooks[0]["headers"].get("X-Tmai-Pane-Id").is_none());
assert!(hooks[0].get("allowedEnvVars").is_none());
}
#[test]
fn test_merge_hooks_empty_settings() {
let mut settings = json!({});
let count = merge_hooks(&mut settings, "token-123", 9876, true);
assert_eq!(count, target_events().len());
let hooks = settings["hooks"].as_object().unwrap();
for event in target_events() {
let entries = hooks[*event].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0]["hooks"][0]["statusMessage"],
format!("tmai: {}", event)
);
}
}
#[test]
fn test_merge_hooks_preserves_existing() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": "echo pre-tool",
"statusMessage": "user: pre-tool"
}
]
}
});
merge_hooks(&mut settings, "token-123", 9876, true);
let pre_tool = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre_tool.len(), 2);
assert_eq!(pre_tool[0]["statusMessage"], "user: pre-tool");
assert!(pre_tool[1]["hooks"][0]["statusMessage"]
.as_str()
.unwrap()
.starts_with("tmai: "));
}
#[test]
fn test_merge_hooks_replaces_existing_tmai_old_format() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{
"type": "http",
"url": "http://localhost:9876/hooks/event",
"statusMessage": "tmai: PreToolUse"
},
{
"type": "command",
"command": "echo other",
"statusMessage": "other: test"
}
]
}
});
merge_hooks(&mut settings, "new-token", 9876, true);
let pre_tool = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre_tool.len(), 2);
assert_eq!(pre_tool[0]["statusMessage"], "other: test");
assert_eq!(
pre_tool[1]["hooks"][0]["headers"]["Authorization"],
"Bearer new-token"
);
}
#[test]
fn test_merge_hooks_replaces_existing_tmai_new_format() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{
"hooks": [{
"type": "http",
"url": "http://localhost:9876/hooks/event",
"statusMessage": "tmai: PreToolUse"
}]
},
{
"hooks": [{
"type": "command",
"command": "echo other",
"statusMessage": "other: test"
}]
}
]
}
});
merge_hooks(&mut settings, "new-token", 9876, true);
let pre_tool = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre_tool.len(), 2);
assert_eq!(pre_tool[0]["hooks"][0]["statusMessage"], "other: test");
assert_eq!(
pre_tool[1]["hooks"][0]["headers"]["Authorization"],
"Bearer new-token"
);
}
#[test]
fn test_remove_tmai_hooks_old_format() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{"statusMessage": "tmai: PreToolUse"},
{"statusMessage": "other: test"}
],
"Stop": [
{"statusMessage": "tmai: Stop"}
]
}
});
let removed = remove_tmai_hooks(&mut settings);
assert_eq!(removed, 2);
let pre_tool = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre_tool.len(), 1);
assert_eq!(pre_tool[0]["statusMessage"], "other: test");
let stop = settings["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 0);
}
#[test]
fn test_remove_tmai_hooks_new_format() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{"hooks": [{"statusMessage": "tmai: PreToolUse"}]},
{"hooks": [{"statusMessage": "other: test"}]}
],
"Stop": [
{"hooks": [{"statusMessage": "tmai: Stop"}]}
]
}
});
let removed = remove_tmai_hooks(&mut settings);
assert_eq!(removed, 2);
let pre_tool = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre_tool.len(), 1);
assert_eq!(pre_tool[0]["hooks"][0]["statusMessage"], "other: test");
let stop = settings["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 0);
}
#[test]
fn test_remove_tmai_hooks_mixed_formats() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{"statusMessage": "tmai: PreToolUse"},
{"hooks": [{"statusMessage": "tmai: PreToolUse"}]},
{"statusMessage": "other: test"}
]
}
});
let removed = remove_tmai_hooks(&mut settings);
assert_eq!(removed, 2);
let pre_tool = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre_tool.len(), 1);
assert_eq!(pre_tool[0]["statusMessage"], "other: test");
}
#[test]
fn test_target_events_count() {
assert_eq!(target_events().len(), 18);
}
#[test]
fn test_remove_tmai_hooks_no_hooks_section() {
let mut settings = json!({ "other": "value" });
let removed = remove_tmai_hooks(&mut settings);
assert_eq!(removed, 0);
}
#[test]
fn test_remove_tmai_hooks_empty_hooks() {
let mut settings = json!({ "hooks": {} });
let removed = remove_tmai_hooks(&mut settings);
assert_eq!(removed, 0);
}
#[test]
fn test_is_tmai_entry_old_format() {
let entry = json!({"type": "http", "statusMessage": "tmai: PreToolUse"});
assert!(is_tmai_entry(&entry));
}
#[test]
fn test_is_tmai_entry_new_format() {
let entry = json!({"hooks": [{"type": "http", "statusMessage": "tmai: PreToolUse"}]});
assert!(is_tmai_entry(&entry));
}
#[test]
fn test_is_tmai_entry_non_tmai() {
let entry = json!({"type": "command", "statusMessage": "other: test"});
assert!(!is_tmai_entry(&entry));
let entry = json!({"hooks": [{"statusMessage": "other: test"}]});
assert!(!is_tmai_entry(&entry));
let entry = json!({"hooks": []});
assert!(!is_tmai_entry(&entry));
}
#[test]
fn test_migration_old_to_new_format() {
let mut settings = json!({
"hooks": {
"PreToolUse": [
{
"type": "http",
"url": "http://localhost:9876/hooks/event",
"headers": {"Authorization": "Bearer old-token"},
"statusMessage": "tmai: PreToolUse"
}
],
"Stop": [
{
"type": "http",
"url": "http://localhost:9876/hooks/event",
"headers": {"Authorization": "Bearer old-token"},
"statusMessage": "tmai: Stop"
},
{
"type": "command",
"command": "echo user-hook",
"statusMessage": "user: stop-hook"
}
]
}
});
merge_hooks(&mut settings, "new-token", 9876, true);
let pre_tool = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre_tool.len(), 1);
assert_eq!(
pre_tool[0]["hooks"][0]["headers"]["Authorization"],
"Bearer new-token"
);
assert!(pre_tool[0].get("statusMessage").is_none());
let stop = settings["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 2);
assert_eq!(stop[0]["statusMessage"], "user: stop-hook");
assert_eq!(
stop[1]["hooks"][0]["headers"]["Authorization"],
"Bearer new-token"
);
}
}