use std::path::{Path, PathBuf};
use super::PrepError;
pub(super) const OUTPUT_STYLE: &str = "trusty-mpm";
pub(super) const SPINNER_TIPS: &[&str] = &[
"tm launch — start a configured claude session for this project",
"make check = cargo test + clippy + fmt — must pass before any PR",
"API → CLI → TUI: implement at the lowest layer first",
"Delegate Rust code to rust-engineer — PM never edits .rs files",
"gh issue create to track work; commits include Closes #N",
"tmux ls shows all active tmpm-<folder> sessions",
"tm session list shows daemon-managed sessions",
"/compact at ~50% context to stay focused",
"Layer new features behind the HTTP API before wiring CLI or TUI",
];
pub(super) const TRUSTY_MEMORY_HOOKS: &str = r#"{
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "trusty-memory prompt-context",
"timeout": 60
}
]
}
],
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "trusty-memory inbox-check",
"timeout": 60
}
]
}
]
}"#;
pub(super) const TRUSTY_MEMORY_MCP_SERVER: &str = r#"{
"type": "stdio",
"command": "trusty-memory",
"args": ["serve", "--stdio"]
}"#;
pub(super) const TRUSTY_SEARCH_MCP_SERVER: &str = r#"{
"type": "stdio",
"command": "trusty-search",
"args": ["serve"]
}"#;
pub(super) const GLOBAL_TRUSTY_MEMORY_EVENTS: &[&str] =
&["UserPromptSubmit", "SessionStart", "PostToolUse", "Stop"];
pub(super) fn deploy_output_style(home: &Path) -> Result<PathBuf, PrepError> {
let style_dir = home.join(".claude").join("output-styles");
std::fs::create_dir_all(&style_dir).map_err(|source| PrepError::Io {
path: style_dir.clone(),
source,
})?;
let style_path = style_dir.join("trusty-mpm.md");
std::fs::write(&style_path, crate::core::bundle::OUTPUT_STYLE).map_err(|source| {
PrepError::Io {
path: style_path.clone(),
source,
}
})?;
Ok(style_path)
}
pub(super) fn write_output_style(project_dir: &Path) -> Result<(), PrepError> {
let claude_dir = project_dir.join(".claude");
std::fs::create_dir_all(&claude_dir).map_err(|source| PrepError::Io {
path: claude_dir.clone(),
source,
})?;
let settings_path = claude_dir.join("settings.json");
let mut settings = match std::fs::read_to_string(&settings_path) {
Ok(text) => serde_json::from_str::<serde_json::Value>(&text)
.ok()
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
Err(_) => serde_json::Value::Object(serde_json::Map::new()),
};
settings["outputStyle"] = serde_json::Value::String(OUTPUT_STYLE.to_string());
settings["spinnerTipsEnabled"] = serde_json::Value::Bool(true);
settings["spinnerTipsOverride"] = serde_json::json!({ "tips": SPINNER_TIPS });
let serialized = serde_json::to_string_pretty(&settings)
.map_err(|err| PrepError::Deploy(err.to_string()))?;
std::fs::write(&settings_path, serialized).map_err(|source| PrepError::Io {
path: settings_path.clone(),
source,
})?;
Ok(())
}
pub(super) fn write_project_hooks(project_dir: &Path) -> Result<(), PrepError> {
let claude_dir = project_dir.join(".claude");
std::fs::create_dir_all(&claude_dir).map_err(|source| PrepError::Io {
path: claude_dir.clone(),
source,
})?;
let settings_path = claude_dir.join("settings.json");
let mut settings = match std::fs::read_to_string(&settings_path) {
Ok(text) => serde_json::from_str::<serde_json::Value>(&text)
.ok()
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
Err(_) => serde_json::Value::Object(serde_json::Map::new()),
};
let hooks: serde_json::Value =
serde_json::from_str(TRUSTY_MEMORY_HOOKS).expect("bundled hook block is valid JSON");
settings["hooks"] = hooks;
let serialized = serde_json::to_string_pretty(&settings)
.map_err(|err| PrepError::Deploy(err.to_string()))?;
std::fs::write(&settings_path, serialized).map_err(|source| PrepError::Io {
path: settings_path.clone(),
source,
})?;
Ok(())
}
fn inject_mcp_server(project_path: &Path, name: &str, server_json: &str) -> Result<(), PrepError> {
let mcp_path = project_path.join(".mcp.json");
let mut config = match std::fs::read_to_string(&mcp_path) {
Ok(text) => serde_json::from_str::<serde_json::Value>(&text)
.ok()
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
Err(_) => serde_json::Value::Object(serde_json::Map::new()),
};
let server: serde_json::Value =
serde_json::from_str(server_json).expect("bundled MCP server block is valid JSON");
let servers = config
.as_object_mut()
.expect("config starts as an object")
.entry("mcpServers")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !servers.is_object() {
*servers = serde_json::Value::Object(serde_json::Map::new());
}
let servers = servers
.as_object_mut()
.expect("mcpServers normalized to an object");
if servers.get(name) == Some(&server) {
return Ok(());
}
servers.insert(name.to_string(), server);
let serialized =
serde_json::to_string_pretty(&config).map_err(|err| PrepError::Deploy(err.to_string()))?;
std::fs::write(&mcp_path, serialized).map_err(|source| PrepError::Io {
path: mcp_path.clone(),
source,
})?;
Ok(())
}
pub(super) fn inject_trusty_memory_mcp(project_path: &Path) -> Result<(), PrepError> {
inject_mcp_server(project_path, "trusty-memory", TRUSTY_MEMORY_MCP_SERVER)
}
pub(super) fn inject_trusty_search_mcp(project_path: &Path) -> Result<(), PrepError> {
inject_mcp_server(project_path, "trusty-search", TRUSTY_SEARCH_MCP_SERVER)
}
pub(super) fn preseed_workspace_trust(
claude_json: &Path,
workspace: &Path,
) -> Result<(), PrepError> {
use serde_json::Value;
let mut config: Value = match std::fs::read_to_string(claude_json) {
Ok(text) => match serde_json::from_str::<Value>(&text) {
Ok(value) if value.is_object() => value,
Ok(_) => {
tracing::warn!(
"skipping trust pre-seed: {} is valid JSON but not an object",
claude_json.display()
);
return Ok(());
}
Err(err) => {
tracing::warn!(
"skipping trust pre-seed: {} is not valid JSON ({err}); \
leaving it untouched to protect OAuth state",
claude_json.display()
);
return Ok(());
}
},
Err(_) => Value::Object(serde_json::Map::new()),
};
let 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(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 already_trusted = entry.get("hasTrustDialogAccepted") == Some(&Value::Bool(true))
&& entry.get("hasCompletedProjectOnboarding") == Some(&Value::Bool(true));
if already_trusted {
return Ok(());
}
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 serialized =
serde_json::to_string_pretty(&config).map_err(|err| PrepError::Deploy(err.to_string()))?;
std::fs::write(claude_json, serialized).map_err(|source| PrepError::Io {
path: claude_json.to_path_buf(),
source,
})?;
Ok(())
}
pub(super) fn preseed_workspace_trust_home(workspace: &Path) -> Result<(), PrepError> {
let Some(home) = dirs::home_dir() else {
tracing::warn!("skipping trust pre-seed: home directory unresolved");
return Ok(());
};
preseed_workspace_trust(&home.join(".claude.json"), workspace)
}
pub(super) fn remove_global_trusty_memory_hooks() -> Result<(), PrepError> {
let home = match dirs::home_dir() {
Some(home) => home,
None => {
tracing::warn!("skipping global trusty-memory hook removal: home unresolved");
return Ok(());
}
};
let settings_path = home.join(".claude").join("settings.json");
clean_global_trusty_memory_hooks(&settings_path)
}
pub(super) fn clean_global_trusty_memory_hooks(settings_path: &Path) -> Result<(), PrepError> {
let text = match std::fs::read_to_string(settings_path) {
Ok(text) => text,
Err(_) => return Ok(()),
};
let mut settings = match serde_json::from_str::<serde_json::Value>(&text) {
Ok(value) if value.is_object() => value,
_ => return Ok(()),
};
let Some(hooks) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
return Ok(());
};
for event in GLOBAL_TRUSTY_MEMORY_EVENTS {
let Some(groups) = hooks.get_mut(*event).and_then(|g| g.as_array_mut()) else {
continue;
};
groups.retain(|group| !group_is_trusty_memory(group));
if groups.is_empty() {
hooks.remove(*event);
}
}
let serialized = serde_json::to_string_pretty(&settings)
.map_err(|err| PrepError::Deploy(err.to_string()))?;
std::fs::write(settings_path, serialized).map_err(|source| PrepError::Io {
path: settings_path.to_path_buf(),
source,
})?;
Ok(())
}
pub(super) fn group_is_trusty_memory(group: &serde_json::Value) -> bool {
group
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|handlers| {
handlers.iter().any(|handler| {
handler
.get("command")
.and_then(|c| c.as_str())
.is_some_and(|cmd| cmd == "trusty-memory" || cmd.starts_with("trusty-memory "))
})
})
}