use std::path::Path;
use anyhow::Context as _;
pub fn preseed_home_trust(workspace: &Path) -> anyhow::Result<()> {
use serde_json::Value;
let claude_json = home_claude_json()?;
let mut config: Value = match std::fs::read_to_string(&claude_json) {
Ok(text) => match serde_json::from_str::<Value>(&text) {
Ok(val) if val.is_object() => val,
Ok(_) => {
tracing::warn!(
"skipping home trust pre-seed: {} is valid JSON but not an object",
claude_json.display()
);
return Ok(());
}
Err(err) => {
tracing::warn!(
"skipping home trust pre-seed: {} contains malformed JSON ({err})",
claude_json.display()
);
return Ok(());
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Value::Object(serde_json::Map::new()),
Err(err) => {
tracing::warn!(
"skipping home trust pre-seed: failed to read {}: {err}",
claude_json.display()
);
return Ok(());
}
};
let workspace_key = workspace.to_string_lossy().to_string();
let projects = config
.as_object_mut()
.expect("config is an object")
.entry("projects")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !projects.is_object() {
*projects = Value::Object(serde_json::Map::new());
}
let projects = projects.as_object_mut().expect("projects is an object");
let entry = projects
.entry(workspace_key)
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = Value::Object(serde_json::Map::new());
}
let entry = entry.as_object_mut().expect("project entry is an object");
let project_already_seeded = entry.get("hasTrustDialogAccepted") == Some(&Value::Bool(true))
&& entry.get("hasCompletedProjectOnboarding") == Some(&Value::Bool(true))
&& entry
.get("projectOnboardingSeenCount")
.and_then(|v| v.as_i64())
.is_some_and(|n| n >= 1);
entry.insert("hasTrustDialogAccepted".to_string(), Value::Bool(true));
entry.insert(
"hasCompletedProjectOnboarding".to_string(),
Value::Bool(true),
);
entry
.entry("projectOnboardingSeenCount")
.or_insert_with(|| Value::from(1));
let upsell_already_seeded = config
.get("fullscreenUpsellSeenCount")
.and_then(|v| v.as_i64())
.is_some_and(|n| n >= 1);
config
.as_object_mut()
.expect("config is an object")
.entry("fullscreenUpsellSeenCount")
.or_insert_with(|| Value::from(1));
if project_already_seeded && upsell_already_seeded {
return Ok(());
}
if let Some(parent) = claude_json.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create dir {}", parent.display()))?;
}
trusty_common::claude_config::write_json_atomic(&claude_json, &config)
.with_context(|| format!("write {}", claude_json.display()))?;
Ok(())
}
fn home_claude_json() -> anyhow::Result<std::path::PathBuf> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
Ok(home.join(".claude.json"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn with_fake_home<F: FnOnce(&TempDir)>(test: F) {
let fake_home = TempDir::new().unwrap();
let prev_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", fake_home.path()) };
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
test(&fake_home);
}));
match prev_home {
Some(p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
#[serial_test::serial]
#[test]
fn seeds_trust_dialog_accepted() {
with_fake_home(|home| {
let workspace = home.path().join("projects").join("my-repo");
std::fs::create_dir_all(&workspace).unwrap();
preseed_home_trust(&workspace).unwrap();
let text = std::fs::read_to_string(home.path().join(".claude.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let key = workspace.to_string_lossy().to_string();
let proj = val["projects"][&key].as_object().unwrap();
assert_eq!(
proj.get("hasTrustDialogAccepted"),
Some(&serde_json::Value::Bool(true)),
"hasTrustDialogAccepted must be true"
);
assert_eq!(
proj.get("hasCompletedProjectOnboarding"),
Some(&serde_json::Value::Bool(true)),
"hasCompletedProjectOnboarding must be true"
);
assert!(
proj.get("projectOnboardingSeenCount")
.and_then(|v| v.as_i64())
.is_some_and(|n| n >= 1),
"projectOnboardingSeenCount must be >= 1"
);
});
}
#[serial_test::serial]
#[test]
fn seeds_fullscreen_upsell_count() {
with_fake_home(|home| {
let workspace = home.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
preseed_home_trust(&workspace).unwrap();
let text = std::fs::read_to_string(home.path().join(".claude.json")).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
let count = val
.get("fullscreenUpsellSeenCount")
.and_then(|v| v.as_i64())
.expect("fullscreenUpsellSeenCount must be present");
assert!(
count >= 1,
"fullscreenUpsellSeenCount must be >= 1, got {count}"
);
});
}
#[serial_test::serial]
#[test]
fn is_idempotent() {
with_fake_home(|home| {
let workspace = home.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
let claude_json = home.path().join(".claude.json");
preseed_home_trust(&workspace).unwrap();
let after_first = std::fs::read_to_string(&claude_json).unwrap();
preseed_home_trust(&workspace).unwrap();
let after_second = std::fs::read_to_string(&claude_json).unwrap();
assert_eq!(
after_first, after_second,
"preseed_home_trust must be idempotent"
);
});
}
#[serial_test::serial]
#[test]
fn preserves_existing_keys() {
with_fake_home(|home| {
let workspace = home.path().join("repo");
std::fs::create_dir_all(&workspace).unwrap();
let claude_json = home.path().join(".claude.json");
std::fs::write(&claude_json, r#"{"oauthToken":"keep-me","someCount":42}"#).unwrap();
preseed_home_trust(&workspace).unwrap();
let text = std::fs::read_to_string(&claude_json).unwrap();
let val: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
val.get("oauthToken").and_then(|v| v.as_str()),
Some("keep-me"),
"oauthToken must be preserved"
);
assert_eq!(
val.get("someCount").and_then(|v| v.as_i64()),
Some(42),
"someCount must be preserved"
);
});
}
}