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 generate_statusline_script(
token: &str,
port: u16,
include_tmux_pane: bool,
existing_command: Option<&str>,
) -> Result<PathBuf> {
let home = dirs::home_dir().context("Cannot determine home directory")?;
let script_path = home.join(".claude").join("statusline.sh");
let pane_header = if include_tmux_pane {
"-H \"X-Tmai-Pane-Id: $TMUX_PANE\" ".to_string()
} else {
String::new()
};
let display_section = if let Some(cmd) = existing_command {
format!(
r#"# Delegate display to existing statusline tool
echo "$INPUT" | {cmd}"#
)
} else {
r#"# Display a minimal status line (no existing tool detected)
MODEL=$(echo "$INPUT" | jq -r '.model.display_name // empty')
COST=$(echo "$INPUT" | jq -r '.cost.total_cost_usd // empty')
CTX=$(echo "$INPUT" | jq -r '.context_window.used_percentage // empty')
STATUS=""
[ -n "$MODEL" ] && STATUS="$MODEL"
[ -n "$COST" ] && STATUS="$STATUS \$$COST"
[ -n "$CTX" ] && STATUS="$STATUS ctx:$CTX%"
echo "$STATUS""#
.to_string()
};
let script = format!(
r#"#!/usr/bin/env bash
# tmai statusline hook — forwards statusline JSON to tmai, then delegates display
# Generated by `tmai init`. Re-run `tmai init` to regenerate.
INPUT=$(cat)
# Forward to tmai (fire-and-forget, don't block Claude Code)
curl -s -o /dev/null -m 1 \
-X POST "http://localhost:{port}/hooks/statusline" \
-H "Authorization: Bearer {token}" \
{pane_header}-H "Content-Type: application/json" \
-d "$INPUT" &
{display_section}
"#
);
if let Some(parent) = script_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(0o755)
.open(&script_path)
.with_context(|| {
format!(
"Failed to write statusline script to {}",
script_path.display()
)
})?;
file.write_all(script.as_bytes())?;
}
#[cfg(not(unix))]
{
fs::write(&script_path, &script).with_context(|| {
format!(
"Failed to write statusline script to {}",
script_path.display()
)
})?;
}
Ok(script_path)
}
fn detect_existing_statusline(settings: &Value) -> Option<String> {
let cmd = settings.get("statusLine")?.get("command")?.as_str()?;
if cmd.contains("statusline.sh") {
if let Ok(content) = std::fs::read_to_string(cmd) {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("echo \"$INPUT\" | ") {
let rest = rest.trim();
if !rest.is_empty() {
return Some(rest.to_string());
}
}
}
}
return None;
}
Some(cmd.to_string())
}
fn set_statusline_config(settings: &mut Value, script_path: &std::path::Path) {
let obj = match settings.as_object_mut() {
Some(o) => o,
None => return,
};
obj.insert(
"statusLine".to_string(),
json!({
"type": "command",
"command": script_path.to_string_lossy()
}),
);
}
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",
"TaskCreated",
"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
}
const MCP_SERVER_NAME: &str = "tmai";
fn claude_json_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Cannot determine home directory")?;
Ok(home.join(".claude.json"))
}
fn add_mcp_server_to_claude_json() -> Result<bool> {
let path = claude_json_path()?;
let mut config: Value = if path.exists() {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?
} else {
json!({})
};
if !config.is_object() {
config = json!({});
}
let mcp_servers = config
.as_object_mut()
.unwrap()
.entry("mcpServers")
.or_insert_with(|| json!({}));
if !mcp_servers.is_object() {
*mcp_servers = json!({});
}
let servers = mcp_servers.as_object_mut().unwrap();
if let Some(existing) = servers.get(MCP_SERVER_NAME) {
let is_managed = existing
.get("_tmai_managed")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !is_managed {
return Ok(false);
}
}
let tmai_command = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| "tmai".to_string());
let entry = json!({
"type": "stdio",
"command": tmai_command,
"args": ["mcp"],
"_tmai_managed": true
});
servers.insert(MCP_SERVER_NAME.to_string(), entry);
let formatted = serde_json::to_string_pretty(&config)?;
fs::write(&path, formatted).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(true)
}
fn remove_mcp_server_from_claude_json() -> Result<bool> {
let path = claude_json_path()?;
if !path.exists() {
return Ok(false);
}
let content =
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
let mut config: Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
let Some(mcp_servers) = config.get_mut("mcpServers").and_then(|s| s.as_object_mut()) else {
return Ok(false);
};
let should_remove = mcp_servers
.get(MCP_SERVER_NAME)
.and_then(|e| e.get("_tmai_managed"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !should_remove {
return Ok(false);
}
mcp_servers.remove(MCP_SERVER_NAME);
let formatted = serde_json::to_string_pretty(&config)?;
fs::write(&path, formatted).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(true)
}
#[cfg(test)]
fn merge_mcp_server(settings: &mut Value) -> bool {
if !settings.is_object() {
*settings = json!({});
}
let mcp_servers = settings
.as_object_mut()
.unwrap()
.entry("mcpServers")
.or_insert_with(|| json!({}));
if !mcp_servers.is_object() {
*mcp_servers = json!({});
}
let servers = mcp_servers.as_object_mut().unwrap();
if let Some(existing) = servers.get(MCP_SERVER_NAME) {
if !existing
.get("_tmai_managed")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return false;
}
}
servers.insert(
MCP_SERVER_NAME.to_string(),
json!({"type": "stdio", "command": "tmai", "args": ["mcp"], "_tmai_managed": true}),
);
true
}
#[cfg(test)]
fn remove_mcp_server(settings: &mut Value) -> bool {
let Some(mcp_servers) = settings
.get_mut("mcpServers")
.and_then(|s| s.as_object_mut())
else {
return false;
};
if !mcp_servers
.get(MCP_SERVER_NAME)
.and_then(|e| e.get("_tmai_managed"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return false;
}
mcp_servers.remove(MCP_SERVER_NAME);
true
}
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);
let mcp_removed = remove_mcp_server_from_claude_json().unwrap_or(false);
let mut statusline_removed = false;
if let Some(obj) = settings.as_object_mut() {
if let Some(sl) = obj.get("statusLine") {
if sl
.get("command")
.and_then(|v| v.as_str())
.is_some_and(|cmd| cmd.contains("statusline.sh"))
{
obj.remove("statusLine");
statusline_removed = true;
}
}
}
if removed > 0 || statusline_removed || mcp_removed {
let formatted = serde_json::to_string_pretty(&settings)?;
fs::write(&settings_path, formatted)
.with_context(|| format!("Failed to write {}", settings_path.display()))?;
if removed > 0 {
println!(
"Removed {} tmai hook entries from {}",
removed,
settings_path.display()
);
}
if mcp_removed {
println!("Removed tmai MCP server from mcpServers");
}
if statusline_removed {
println!("Removed statusLine config from {}", settings_path.display());
}
} else {
println!("No tmai entries found in {}", settings_path.display());
}
if let Some(home) = dirs::home_dir() {
let script_path = home.join(".claude").join("statusline.sh");
if script_path.exists() {
fs::remove_file(&script_path)
.with_context(|| format!("Failed to remove {}", script_path.display()))?;
println!("Removed statusline script: {}", script_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);
let existing_cmd = detect_existing_statusline(&settings);
if let Some(ref cmd) = existing_cmd {
println!("Detected existing statusLine command: {cmd}");
println!(" → tmai will forward data to tmai AND delegate display to this command");
}
let statusline_path =
generate_statusline_script(&token, port, include_tmux_pane, existing_cmd.as_deref())?;
set_statusline_config(&mut settings, &statusline_path);
println!("Generated statusline script: {}", statusline_path.display());
let mcp_added = add_mcp_server_to_claude_json().unwrap_or(false);
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()
);
if mcp_added {
println!("Added tmai MCP server to ~/.claude.json");
}
if let Some(obj) = settings.as_object_mut() {
if let Some(mcp) = obj.get_mut("mcpServers").and_then(|m| m.as_object_mut()) {
if mcp
.get("tmai")
.and_then(|e| e.get("_tmai_managed"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
mcp.remove("tmai");
let formatted = serde_json::to_string_pretty(&settings)?;
fs::write(&settings_path, formatted)
.with_context(|| format!("Failed to write {}", settings_path.display()))?;
println!(
"Cleaned up legacy MCP entry from {}",
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(), 19);
}
#[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"
);
}
#[test]
fn test_merge_mcp_server_empty_settings() {
let mut settings = json!({});
let added = merge_mcp_server(&mut settings);
assert!(added);
let mcp = settings["mcpServers"]["tmai"].as_object().unwrap();
assert!(mcp.contains_key("command"));
assert_eq!(mcp["args"], json!(["mcp"]));
assert_eq!(mcp["_tmai_managed"], json!(true));
}
#[test]
fn test_merge_mcp_server_preserves_others() {
let mut settings = json!({
"mcpServers": {
"other-server": {"command": "other", "args": []}
}
});
merge_mcp_server(&mut settings);
assert!(settings["mcpServers"]["other-server"].is_object());
assert!(settings["mcpServers"]["tmai"].is_object());
}
#[test]
fn test_remove_mcp_server() {
let mut settings = json!({
"mcpServers": {
"tmai": {"command": "tmai", "args": ["mcp"], "_tmai_managed": true},
"other": {"command": "other"}
}
});
let removed = remove_mcp_server(&mut settings);
assert!(removed);
assert!(!settings["mcpServers"]
.as_object()
.unwrap()
.contains_key("tmai"));
assert!(settings["mcpServers"]
.as_object()
.unwrap()
.contains_key("other"));
}
#[test]
fn test_remove_mcp_server_not_managed() {
let mut settings = json!({
"mcpServers": {
"tmai": {"command": "custom-tmai", "args": ["custom"]}
}
});
let removed = remove_mcp_server(&mut settings);
assert!(!removed);
assert!(settings["mcpServers"]
.as_object()
.unwrap()
.contains_key("tmai"));
}
#[test]
fn test_merge_mcp_server_skips_user_defined() {
let mut settings = json!({
"mcpServers": {
"tmai": {"command": "custom-tmai", "args": ["custom-arg"]}
}
});
let added = merge_mcp_server(&mut settings);
assert!(!added);
assert_eq!(settings["mcpServers"]["tmai"]["command"], "custom-tmai");
assert_eq!(
settings["mcpServers"]["tmai"]["args"],
json!(["custom-arg"])
);
}
#[test]
fn test_merge_mcp_server_updates_managed() {
let mut settings = json!({
"mcpServers": {
"tmai": {"command": "old-path", "args": ["mcp"], "_tmai_managed": true}
}
});
let added = merge_mcp_server(&mut settings);
assert!(added);
assert_ne!(settings["mcpServers"]["tmai"]["command"], "old-path");
assert_eq!(settings["mcpServers"]["tmai"]["_tmai_managed"], true);
}
}