From 45478734e736be1d49a2392c637bbd311d2ce631 Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 8 May 2026 10:09:54 +0200 Subject: [PATCH] feat(init): add --claude-settings flag to choose shared vs local hooks Adds a `--claude-settings ` option to `tracevault init` that selects whether Claude Code hooks are written into `.claude/settings.json` (default, typically shared with the team) or `.claude/settings.local.json` (personal, conventionally git-ignored). When the flag is omitted, init prompts interactively if stdin is a TTY and falls back to `shared` in non-interactive environments (CI, scripts), preserving previous behavior. The .gitignore entry follows the chosen file so a committed shared `settings.json` is never accidentally ignored when `local` is picked. --- README.md | 4 +- crates/tracevault-cli/src/commands/init.rs | 89 ++++++++++++++--- crates/tracevault-cli/src/main.rs | 22 ++++- crates/tracevault-cli/tests/init_test.rs | 107 ++++++++++++++++++--- 4 files changed, 188 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 873f194..d04ef24 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ tracevault check # evaluate policies against server rules (blocks push on f Session and commit data is streamed to the server continuously via the Claude Code hooks (`tracevault stream`) and the git post-commit hook (`tracevault commit-push`), so there is no separate upload step. -The command also installs the Claude Code hook configuration in `.claude/settings.json`. Each hook runs `tracevault stream --event `, which records the event locally and pushes it to the server in real time: +The command also installs the Claude Code hook configuration. By default it writes to `.claude/settings.json` (shared with the team). Pass `--claude-settings local` (or pick `local` at the interactive prompt) to install into `.claude/settings.local.json` instead — the personal, git-ignored variant. Each hook runs `tracevault stream --event `, which records the event locally and pushes it to the server in real time: ```json { @@ -332,7 +332,7 @@ export DATABASE_URL=postgres://user:password@host:5432/tracevault?sslmode=requir | Command | Description | |---------|-------------| -| `tracevault init [--server-url URL]` | Initialize TraceVault in current repo, install pre-push hook and Claude Code hooks | +| `tracevault init [--server-url URL] [--claude-settings shared\|local]` | Initialize TraceVault in current repo, install pre-push hook and Claude Code hooks. `--claude-settings` chooses between `.claude/settings.json` (default) and `.claude/settings.local.json`; prompts interactively if omitted on a TTY | | `tracevault login --server-url URL [--no-browser]` | Authenticate via device auth flow. Prints the URL and opens a browser when possible; `--no-browser` (or a headless env) skips the auto-open. | | `tracevault logout` | Clear local credentials | | `tracevault stream --event ` | Handle a Claude Code hook event (reads JSON from stdin) and stream it to the server | diff --git a/crates/tracevault-cli/src/commands/init.rs b/crates/tracevault-cli/src/commands/init.rs index 5bcb21c..39d8af2 100644 --- a/crates/tracevault-cli/src/commands/init.rs +++ b/crates/tracevault-cli/src/commands/init.rs @@ -1,9 +1,64 @@ use crate::api_client::ApiClient; use crate::config::TracevaultConfig; use std::fs; -use std::io; +use std::io::{self, BufRead, IsTerminal, Write}; use std::path::Path; +/// Which Claude Code settings file to install hooks into. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum ClaudeSettingsTarget { + /// .claude/settings.json — typically committed/shared with the team. + Shared, + /// .claude/settings.local.json — personal, conventionally git-ignored. + Local, +} + +impl ClaudeSettingsTarget { + pub fn filename(self) -> &'static str { + match self { + ClaudeSettingsTarget::Shared => "settings.json", + ClaudeSettingsTarget::Local => "settings.local.json", + } + } + + pub fn gitignore_entry(self) -> &'static str { + match self { + ClaudeSettingsTarget::Shared => ".claude/settings.json", + ClaudeSettingsTarget::Local => ".claude/settings.local.json", + } + } +} + +/// Resolve which settings file to use. If the caller passed an explicit +/// choice, honor it. Otherwise prompt interactively when stdin is a TTY, +/// or fall back to Shared for non-interactive callers (CI, scripts, tests). +fn resolve_claude_target( + explicit: Option, +) -> io::Result { + if let Some(target) = explicit { + return Ok(target); + } + if !io::stdin().is_terminal() { + return Ok(ClaudeSettingsTarget::Shared); + } + + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + write!( + stdout, + "Install Claude Code hooks into [s]hared (.claude/settings.json) or [l]ocal (.claude/settings.local.json)? [s]: " + )?; + stdout.flush()?; + + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + let answer = line.trim().to_lowercase(); + Ok(match answer.as_str() { + "l" | "local" => ClaudeSettingsTarget::Local, + _ => ClaudeSettingsTarget::Shared, + }) +} + pub fn git_remote_url(project_root: &Path) -> Option { std::process::Command::new("git") .args(["remote", "get-url", "origin"]) @@ -33,7 +88,8 @@ fn parse_github_org(remote_url: &str) -> Option { pub async fn init_in_directory( project_root: &Path, server_url: Option<&str>, -) -> Result<(), io::Error> { + claude_settings: Option, +) -> Result { // Check for git repository if !project_root.join(".git").exists() { return Err(io::Error::new( @@ -42,6 +98,8 @@ pub async fn init_in_directory( )); } + let target = resolve_claude_target(claude_settings)?; + // Create .tracevault/ directory let config_dir = TracevaultConfig::config_dir(project_root); fs::create_dir_all(&config_dir)?; @@ -49,7 +107,7 @@ pub async fn init_in_directory( fs::create_dir_all(config_dir.join("cache"))?; // Keep all tracevault files local — update root .gitignore - update_root_gitignore(project_root)?; + update_root_gitignore(project_root, target)?; // Register repo on server if authenticated, server URL known, and git remote available let remote_url = git_remote_url(project_root); @@ -72,8 +130,8 @@ pub async fn init_in_directory( config.to_toml(), )?; - // Install Claude Code hooks into .claude/settings.json - install_claude_hooks(project_root)?; + // Install Claude Code hooks into the chosen settings file + install_claude_hooks(project_root, target)?; // Install git hooks install_git_hook(project_root)?; @@ -128,10 +186,13 @@ pub async fn init_in_directory( } } - Ok(()) + Ok(target) } -fn update_root_gitignore(project_root: &Path) -> Result<(), io::Error> { +fn update_root_gitignore( + project_root: &Path, + claude_target: ClaudeSettingsTarget, +) -> Result<(), io::Error> { let path = project_root.join(".gitignore"); let existing = if path.exists() { fs::read_to_string(&path)? @@ -139,7 +200,7 @@ fn update_root_gitignore(project_root: &Path) -> Result<(), io::Error> { String::new() }; - let needed: Vec<&str> = [".tracevault/", ".claude/settings.json"] + let needed: Vec<&str> = [".tracevault/", claude_target.gitignore_entry()] .iter() .copied() .filter(|entry| !existing.lines().any(|line| line.trim() == *entry)) @@ -284,17 +345,21 @@ fn install_post_commit_hook(project_root: &Path) -> Result<(), io::Error> { Ok(()) } -fn install_claude_hooks(project_root: &Path) -> Result<(), io::Error> { +fn install_claude_hooks( + project_root: &Path, + target: ClaudeSettingsTarget, +) -> Result<(), io::Error> { let claude_dir = project_root.join(".claude"); fs::create_dir_all(&claude_dir)?; - let settings_path = claude_dir.join("settings.json"); + let filename = target.filename(); + let settings_path = claude_dir.join(filename); let mut settings: serde_json::Value = if settings_path.exists() { let content = fs::read_to_string(&settings_path)?; serde_json::from_str(&content).map_err(|e| { io::Error::new( io::ErrorKind::InvalidData, - format!("Failed to parse .claude/settings.json: {e}"), + format!("Failed to parse .claude/{filename}: {e}"), ) })? } else { @@ -307,7 +372,7 @@ fn install_claude_hooks(project_root: &Path) -> Result<(), io::Error> { let settings_obj = settings.as_object_mut().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, - ".claude/settings.json is not a JSON object", + format!(".claude/{filename} is not a JSON object"), ) })?; diff --git a/crates/tracevault-cli/src/main.rs b/crates/tracevault-cli/src/main.rs index 5962cb4..9454308 100644 --- a/crates/tracevault-cli/src/main.rs +++ b/crates/tracevault-cli/src/main.rs @@ -15,6 +15,12 @@ enum Cli { /// TraceVault server URL for repo registration #[arg(long)] server_url: Option, + /// Where to install Claude Code hooks: `shared` (.claude/settings.json, + /// typically committed) or `local` (.claude/settings.local.json, + /// personal/git-ignored). When omitted, prompts interactively if stdin + /// is a TTY, otherwise defaults to `shared`. + #[arg(long, value_enum)] + claude_settings: Option, }, /// Show current session status Status, @@ -66,14 +72,20 @@ enum Cli { async fn main() { let cli = Cli::parse(); match cli { - Cli::Init { server_url } => { + Cli::Init { + server_url, + claude_settings, + } => { let cwd = env::current_dir().expect("Cannot determine current directory"); - match commands::init::init_in_directory(&cwd, server_url.as_deref()).await { - Ok(()) => { + match commands::init::init_in_directory(&cwd, server_url.as_deref(), claude_settings) + .await + { + Ok(target) => { + let entry = target.gitignore_entry(); println!("TraceVault initialized in {}", cwd.display()); - println!("Claude Code hooks installed (.claude/settings.json)"); + println!("Claude Code hooks installed ({entry})"); println!("Git hooks installed (pre-push, post-commit)"); - println!("Added .tracevault/ and .claude/settings.json to .gitignore"); + println!("Added .tracevault/ and {entry} to .gitignore"); println!( "Nothing needs to be committed — all TraceVault files are local only." ); diff --git a/crates/tracevault-cli/tests/init_test.rs b/crates/tracevault-cli/tests/init_test.rs index 0ab3b75..d43ba90 100644 --- a/crates/tracevault-cli/tests/init_test.rs +++ b/crates/tracevault-cli/tests/init_test.rs @@ -1,5 +1,6 @@ use std::fs; use tempfile::TempDir; +use tracevault_cli::commands::init::ClaudeSettingsTarget; fn tmp_git_repo() -> TempDir { let tmp = TempDir::new().unwrap(); @@ -10,7 +11,7 @@ fn tmp_git_repo() -> TempDir { #[tokio::test] async fn init_fails_without_git() { let tmp = TempDir::new().unwrap(); - let result = tracevault_cli::commands::init::init_in_directory(tmp.path(), None).await; + let result = tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None).await; assert!(result.is_err()); assert!(result .unwrap_err() @@ -23,7 +24,7 @@ async fn init_creates_tracevault_config() { let tmp = tmp_git_repo(); let config_path = tmp.path().join(".tracevault").join("config.toml"); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); @@ -36,7 +37,7 @@ async fn init_creates_tracevault_config() { async fn init_creates_directory_structure() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); @@ -53,7 +54,7 @@ async fn init_creates_directory_structure() { async fn init_installs_claude_hooks() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); @@ -77,7 +78,7 @@ async fn init_merges_into_existing_settings() { fs::create_dir_all(&claude_dir).unwrap(); fs::write(claude_dir.join("settings.json"), r#"{"model": "opus"}"#).unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); @@ -102,7 +103,7 @@ fn tracevault_hooks_has_pre_post_and_notification() { async fn init_installs_git_pre_push_hook() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); @@ -130,7 +131,7 @@ async fn init_preserves_existing_pre_push_hook() { ) .unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); @@ -147,10 +148,10 @@ async fn init_preserves_existing_pre_push_hook() { async fn init_does_not_duplicate_hook_on_reinit() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); @@ -166,7 +167,7 @@ async fn init_does_not_duplicate_hook_on_reinit() { async fn init_installs_post_commit_hook() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); @@ -183,10 +184,10 @@ async fn init_installs_post_commit_hook() { async fn init_does_not_duplicate_post_commit_hook_on_reinit() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), None) + tracevault_cli::commands::init::init_in_directory(tmp.path(), None, None) .await .unwrap(); @@ -198,13 +199,89 @@ async fn init_does_not_duplicate_post_commit_hook_on_reinit() { ); } +#[tokio::test] +async fn init_local_target_writes_to_settings_local_json() { + let tmp = tmp_git_repo(); + + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Local), + ) + .await + .unwrap(); + + let local_path = tmp.path().join(".claude/settings.local.json"); + let shared_path = tmp.path().join(".claude/settings.json"); + assert!(local_path.exists(), "settings.local.json should exist"); + assert!( + !shared_path.exists(), + "settings.json should not be created when local target chosen" + ); + + let content = fs::read_to_string(&local_path).unwrap(); + let settings: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!(settings.get("hooks").is_some()); +} + +#[tokio::test] +async fn init_local_target_gitignores_settings_local_json() { + let tmp = tmp_git_repo(); + + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Local), + ) + .await + .unwrap(); + + let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).unwrap(); + assert!(gitignore.contains(".claude/settings.local.json")); + // Shared path is not added when user chose local — they may have a + // committed settings.json that should not be ignored. + assert!(!gitignore + .lines() + .any(|line| line.trim() == ".claude/settings.json")); +} + +#[tokio::test] +async fn init_local_target_merges_into_existing_settings_local_json() { + let tmp = tmp_git_repo(); + + let claude_dir = tmp.path().join(".claude"); + fs::create_dir_all(&claude_dir).unwrap(); + fs::write( + claude_dir.join("settings.local.json"), + r#"{"model": "opus"}"#, + ) + .unwrap(); + + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + None, + Some(ClaudeSettingsTarget::Local), + ) + .await + .unwrap(); + + let content = fs::read_to_string(claude_dir.join("settings.local.json")).unwrap(); + let settings: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!(settings.get("hooks").is_some()); + assert_eq!(settings.get("model").unwrap(), "opus"); +} + #[tokio::test] async fn init_writes_server_url_to_config() { let tmp = tmp_git_repo(); - tracevault_cli::commands::init::init_in_directory(tmp.path(), Some("https://tv.example.com")) - .await - .unwrap(); + tracevault_cli::commands::init::init_in_directory( + tmp.path(), + Some("https://tv.example.com"), + None, + ) + .await + .unwrap(); let config_path = tmp.path().join(".tracevault/config.toml"); let content = fs::read_to_string(&config_path).unwrap();