Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <type>`, 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 <type>`, which records the event locally and pushes it to the server in real time:

```json
{
Expand Down Expand Up @@ -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 <type>` | Handle a Claude Code hook event (reads JSON from stdin) and stream it to the server |
Expand Down
89 changes: 77 additions & 12 deletions crates/tracevault-cli/src/commands/init.rs
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
softberries marked this conversation as resolved.
explicit: Option<ClaudeSettingsTarget>,
) -> io::Result<ClaudeSettingsTarget> {
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<String> {
std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
Expand Down Expand Up @@ -33,7 +88,8 @@ fn parse_github_org(remote_url: &str) -> Option<String> {
pub async fn init_in_directory(
project_root: &Path,
server_url: Option<&str>,
) -> Result<(), io::Error> {
claude_settings: Option<ClaudeSettingsTarget>,
) -> Result<ClaudeSettingsTarget, io::Error> {
// Check for git repository
if !project_root.join(".git").exists() {
return Err(io::Error::new(
Expand All @@ -42,14 +98,16 @@ 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)?;
fs::create_dir_all(config_dir.join("sessions"))?;
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);
Expand All @@ -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)?;
Expand Down Expand Up @@ -128,18 +186,21 @@ 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)?
} else {
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))
Expand Down Expand Up @@ -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 {
Expand All @@ -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"),
)
})?;

Expand Down
22 changes: 17 additions & 5 deletions crates/tracevault-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ enum Cli {
/// TraceVault server URL for repo registration
#[arg(long)]
server_url: Option<String>,
/// 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<commands::init::ClaudeSettingsTarget>,
},
/// Show current session status
Status,
Expand Down Expand Up @@ -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."
);
Expand Down
Loading
Loading