//! Project initialization for deciduous
//!
//! `deciduous init` creates all the files needed for decision graph tracking
//! with AI assistant integration (Claude Code and/or OpenCode).
pub mod guard;
mod known_templates;
pub mod remote_templates;
pub mod templates;
use crate::opencode;
use colored::Colorize;
use std::fs;
use std::path::Path;
use templates::{
BUILD_TEST_MD, CLAUDE_AGENTS_TOML, CLAUDE_MD_SECTION, CLAUDE_SETTINGS_JSON, CLEANUP_WORKFLOW,
DECISION_GRAPH_MD, DECISION_MD, DEFAULT_CONFIG, DEMO_SWARM_MD, DOCUMENT_MD,
HOOK_BOARD_MENTIONS, HOOK_VERSION_CHECK, RECOVER_MD, SERVE_UI_MD, SKILL_ARCHAEOLOGY,
SKILL_NARRATIVES, SKILL_PULSE, SYNC_MD, WINDSURF_HOOKS_JSON, WINDSURF_RULES_DECIDUOUS, WORK_MD,
};
/// The templates whose text depends on where the project's graph lives: in
/// `.deciduous/graph.json`, carried by git, or on a server (`[remote]`).
struct GraphModelTemplates {
decision: &'static str,
recover: &'static str,
work: &'static str,
document: &'static str,
sync: &'static str,
claude_md: &'static str,
}
impl GraphModelTemplates {
fn for_project(root: &Path) -> Self {
use remote_templates as r;
if guard::remote_configured(root) {
GraphModelTemplates {
decision: r::DECISION_MD_REMOTE,
recover: r::RECOVER_MD_REMOTE,
work: r::WORK_MD_REMOTE,
document: r::DOCUMENT_MD_REMOTE,
sync: r::SYNC_MD_REMOTE,
claude_md: r::CLAUDE_MD_SECTION_REMOTE,
}
} else {
GraphModelTemplates {
decision: DECISION_MD,
recover: RECOVER_MD,
work: WORK_MD,
document: DOCUMENT_MD,
sync: SYNC_MD,
claude_md: CLAUDE_MD_SECTION,
}
}
}
}
/// Initialize a new deciduous project with AI assistant integration
///
/// # Arguments
/// * `setup_claude` - Whether to set up Claude Code integration
/// * `setup_opencode` - Whether to set up OpenCode integration
/// * `setup_windsurf` - Whether to set up Windsurf integration (also auto-detects .windsurf/)
/// * `no_auto_update` - Deprecated (ignored). Version checking is now always-on.
pub fn init_project(
setup_claude: bool,
setup_opencode: bool,
setup_windsurf: bool,
_no_auto_update: bool,
) -> Result<(), String> {
let cwd =
std::env::current_dir().map_err(|e| format!("Could not get current directory: {}", e))?;
let assistant_name = match (setup_claude, setup_opencode) {
(true, true) => "Claude Code + OpenCode",
(true, false) => "Claude Code",
(false, true) => "OpenCode",
(false, false) => return Err("At least one assistant must be selected".to_string()),
};
println!(
"\n{}",
format!("Initializing Deciduous for {}...", assistant_name)
.cyan()
.bold()
);
println!(" Directory: {}\n", cwd.display());
// 1. Create .deciduous directory
let deciduous_dir = cwd.join(".deciduous");
create_dir_if_missing(&deciduous_dir)?;
// 1a. Create .deciduous/documents directory for file attachments
let documents_dir = deciduous_dir.join("documents");
create_dir_if_missing(&documents_dir)?;
// 1b. Create default config.toml if it doesn't exist
let config_path = deciduous_dir.join("config.toml");
write_file_if_missing(&config_path, DEFAULT_CONFIG, ".deciduous/config.toml")?;
// 1c. Write version file for auto-update detection
let version_path = deciduous_dir.join(".version");
let version = env!("CARGO_PKG_VERSION");
fs::write(&version_path, version)
.map_err(|e| format!("Could not write version file: {}", e))?;
println!(
" {} .deciduous/.version ({})",
"Creating".green(),
version
);
// 2. Initialize database by opening it (creates tables)
let db_path = deciduous_dir.join("deciduous.db");
if db_path.exists() {
println!(
" {} .deciduous/deciduous.db (already exists, preserving data)",
"Skipping".yellow()
);
} else {
println!(" {} .deciduous/deciduous.db", "Creating".green());
}
// Set the env var so Database::open() uses this path
std::env::set_var("DECIDUOUS_DB_PATH", &db_path);
// 3. Create Claude Code configuration (if enabled)
let model = GraphModelTemplates::for_project(&cwd);
if setup_claude {
// Create .claude/commands directory
let claude_dir = cwd.join(".claude").join("commands");
create_dir_if_missing(&claude_dir)?;
// Write decision.md slash command
let decision_path = claude_dir.join("decision.md");
write_file_if_missing(
&decision_path,
model.decision,
".claude/commands/decision.md",
)?;
// Write recover.md slash command
let recover_path = claude_dir.join("recover.md");
write_file_if_missing(&recover_path, model.recover, ".claude/commands/recover.md")?;
// Write work.md slash command (transaction model)
let work_path = claude_dir.join("work.md");
write_file_if_missing(&work_path, model.work, ".claude/commands/work.md")?;
// Write document.md slash command
let document_path = claude_dir.join("document.md");
write_file_if_missing(
&document_path,
model.document,
".claude/commands/document.md",
)?;
// Write build-test.md slash command
let build_test_path = claude_dir.join("build-test.md");
write_file_if_missing(
&build_test_path,
BUILD_TEST_MD,
".claude/commands/build-test.md",
)?;
// Write serve-ui.md slash command
let serve_ui_path = claude_dir.join("serve-ui.md");
write_file_if_missing(&serve_ui_path, SERVE_UI_MD, ".claude/commands/serve-ui.md")?;
// Write decision-graph.md slash command
let decision_graph_path = claude_dir.join("decision-graph.md");
write_file_if_missing(
&decision_graph_path,
DECISION_GRAPH_MD,
".claude/commands/decision-graph.md",
)?;
// Write sync.md slash command
let sync_path = claude_dir.join("sync.md");
write_file_if_missing(&sync_path, model.sync, ".claude/commands/sync.md")?;
// Write demo-swarm.md slash command (the multi-agent demo)
let demo_path = claude_dir.join("demo-swarm.md");
write_file_if_missing(&demo_path, DEMO_SWARM_MD, ".claude/commands/demo-swarm.md")?;
// Write agents.toml for subagent configuration
let claude_base = cwd.join(".claude");
let agents_path = claude_base.join("agents.toml");
write_file_if_missing(&agents_path, CLAUDE_AGENTS_TOML, ".claude/agents.toml")?;
// Create .claude/hooks directory; the only hook is the version check
let hooks_dir = claude_base.join("hooks");
create_dir_if_missing(&hooks_dir)?;
// Write version-check.sh hook (opt-in auto-update check)
let version_check_path = hooks_dir.join("version-check.sh");
write_executable_if_missing(
&version_check_path,
HOOK_VERSION_CHECK,
".claude/hooks/version-check.sh",
)?;
// Board mentions: shows a labelled session what it has not answered
write_executable_if_missing(
&hooks_dir.join("board-mentions.sh"),
HOOK_BOARD_MENTIONS,
".claude/hooks/board-mentions.sh",
)?;
// Write settings.json with hooks configuration; an existing one gets
// the board hook's entries added and keeps everything else.
let settings_path = claude_base.join("settings.json");
if settings_path.exists() {
ensure_board_hook_settings(&settings_path)?;
} else {
write_file_if_missing(
&settings_path,
CLAUDE_SETTINGS_JSON,
".claude/settings.json",
)?;
}
// Create .claude/skills directory and write skill files
let skills_dir = claude_base.join("skills");
create_dir_if_missing(&skills_dir)?;
let pulse_path = skills_dir.join("pulse.md");
write_file_if_missing(&pulse_path, SKILL_PULSE, ".claude/skills/pulse.md")?;
let narratives_path = skills_dir.join("narratives.md");
write_file_if_missing(
&narratives_path,
SKILL_NARRATIVES,
".claude/skills/narratives.md",
)?;
let archaeology_path = skills_dir.join("archaeology.md");
write_file_if_missing(
&archaeology_path,
SKILL_ARCHAEOLOGY,
".claude/skills/archaeology.md",
)?;
// Append to or create CLAUDE.md
let claude_md_path = cwd.join("CLAUDE.md");
append_config_md(&claude_md_path, model.claude_md, "CLAUDE.md")?;
}
// 3b. Create OpenCode configuration (if enabled)
if setup_opencode {
opencode::install_opencode(&cwd)?;
}
// 3c. Set up Windsurf if requested OR if .windsurf directory exists
let windsurf_dir = cwd.join(".windsurf");
let should_setup_windsurf = setup_windsurf || windsurf_dir.exists();
if should_setup_windsurf {
// Create .windsurf directory if it doesn't exist (when --windsurf flag used)
if !windsurf_dir.exists() {
create_dir_if_missing(&windsurf_dir)?;
}
if setup_windsurf {
println!("\n{}", "Setting up Windsurf integration...".cyan());
} else {
println!(
"\n{}",
"Detected .windsurf directory - setting up Windsurf integration...".cyan()
);
}
setup_windsurf_integration(&cwd)?;
}
// 4. Track the shared record store, keep the local database private
ensure_gitignore(&cwd)?;
ensure_gitattributes(&cwd)?;
if ensure_merge_driver(&cwd)? {
println!(
" {} git merge driver for graph records",
"Configured".green()
);
}
ensure_graph_file(&cwd)?;
// No GitHub Pages viewer, docs/ export or deploy workflow since 1.0.5: the
// graph lives on the shared server and is viewed with `deciduous serve`.
// The cleanup workflow stays: it removes the PR images `dot --auto` makes.
if cwd.join(".github").exists() || cwd.join(".git").exists() {
let workflows_dir = cwd.join(".github").join("workflows");
create_dir_if_missing(&workflows_dir)?;
write_file_if_missing(
&workflows_dir.join("cleanup-decision-graphs.yml"),
CLEANUP_WORKFLOW,
".github/workflows/cleanup-decision-graphs.yml",
)?;
}
// Check if Windsurf was set up (use the flag we already computed)
let windsurf_configured = should_setup_windsurf;
let final_name = if windsurf_configured {
format!("{} + Windsurf", assistant_name)
} else {
assistant_name.to_string()
};
println!(
"\n{}",
format!("Deciduous initialized for {}!", final_name)
.green()
.bold()
);
println!("\nNext steps:");
println!(
" 1. Restart your assistant so it connects to the server; it sends the logging instructions"
);
println!(
" 2. Use slash commands: {}, {}, {}, {}, etc.",
"/decision".cyan(),
"/recover".cyan(),
"/work".cyan(),
"/document".cyan()
);
println!(
" 3. Run {} to browse the graph locally",
"deciduous serve".cyan()
);
if windsurf_configured {
println!();
println!("{}", "Windsurf integration:".cyan().bold());
println!(" - Always-on rules in .windsurf/rules/deciduous.md");
println!(" - Version check in .windsurf/hooks.json");
}
println!();
Ok(())
}
/// Update tooling files to the latest version (overwrites existing)
/// Auto-detects which assistants are installed (.claude/ and/or .opencode/)
pub fn update_tooling() -> Result<(), String> {
let cwd =
std::env::current_dir().map_err(|e| format!("Could not get current directory: {}", e))?;
// Verify .deciduous exists
let deciduous_dir = cwd.join(".deciduous");
if !deciduous_dir.exists() {
println!(
" {} .deciduous/ not found - run 'deciduous init' first",
"Warning:".yellow()
);
}
// Auto-detect which assistants are installed
let claude_dir = cwd.join(".claude");
let opencode_dir = cwd.join(".opencode");
let has_claude = claude_dir.exists();
let has_opencode = opencode_dir.exists();
if !has_claude && !has_opencode {
return Err(
"No assistant integration found. Run 'deciduous init' first, or use 'deciduous init --opencode' / 'deciduous init --claude'."
.to_string(),
);
}
let assistant_name = match (has_claude, has_opencode) {
(true, true) => "Claude Code + OpenCode",
(true, false) => "Claude Code",
(false, true) => "OpenCode",
(false, false) => unreachable!(),
};
println!(
"\n{}",
format!("Updating Deciduous tooling for {}...", assistant_name)
.cyan()
.bold()
);
println!(" Directory: {}\n", cwd.display());
// Update Claude Code if installed
if has_claude {
update_claude_code(&cwd)?;
}
// Update OpenCode if installed
if has_opencode {
opencode::update_opencode(&cwd)?;
}
// Update Windsurf if .windsurf directory exists
let windsurf_dir = cwd.join(".windsurf");
let has_windsurf = windsurf_dir.exists();
if has_windsurf {
println!(
"\n{}",
"Detected .windsurf directory - updating Windsurf integration...".cyan()
);
update_windsurf(&cwd)?;
}
// Make sure the record store exists and is tracked (projects initialised
// before 0.17 ignored all of .deciduous/). Not on a project that points at
// a shared server: its graph lives there, and the graph.json sync would
// only write into .gitignore, .gitattributes and .git/config, which are
// the project's files, for nothing.
if crate::config::Config::load().remote.is_configured() {
println!(
" {} .gitignore, .gitattributes, git merge driver, graph.json (remote configured; the graph lives on the server)",
"Skipped".dimmed()
);
} else {
ensure_gitignore(&cwd)?;
ensure_gitattributes(&cwd)?;
if ensure_merge_driver(&cwd)? {
println!(
" {} git merge driver for graph records",
"Configured".green()
);
}
if deciduous_dir.exists() {
ensure_graph_file(&cwd)?;
}
}
if let Some(dir) = guard::backup_dir(&cwd) {
println!(
" {} previous versions of changed files in {}",
"Backed up".green(),
dir.strip_prefix(&cwd).unwrap_or(&dir).display()
);
}
// Write version file for auto-update detection
if deciduous_dir.exists() {
let version_path = deciduous_dir.join(".version");
let version = env!("CARGO_PKG_VERSION");
fs::write(&version_path, version)
.map_err(|e| format!("Could not write version file: {}", e))?;
println!(" {} .deciduous/.version ({})", "Updated".green(), version);
}
let final_name = if has_windsurf {
format!("{} + Windsurf", assistant_name)
} else {
assistant_name.to_string()
};
println!(
"\n{}",
format!("Tooling updated for {}!", final_name)
.green()
.bold()
);
println!("\nUpdated files contain the latest:");
println!(" - Slash commands (/decision, /recover, /work, /document, /build-test, /serve-ui, /decision-graph, /sync, /demo-swarm)");
println!(" - Skills (/pulse, /narratives, /archaeology)");
if has_claude {
println!(" - Agent configurations (agents.toml)");
println!(" - No logging hooks: the ones earlier versions installed are removed");
println!(" - Board-mentions hook (silent unless DECIDUOUS_AGENT_LABEL is set)");
}
if has_opencode {
println!(" - OpenCode version-check plugin (the logging plugins are removed)");
println!(" - OpenCode skills, agents, and tools");
println!(" - OpenCode configuration (opencode.json)");
}
if has_windsurf {
println!(" - Windsurf rules and version-check hook");
}
println!();
// Prompt user to commit the updated configuration files
let version = env!("CARGO_PKG_VERSION");
println!(
"{}",
"Commit the updated files to lock in the new configuration:".yellow()
);
if has_claude {
println!(
" git add .claude/commands/ .claude/hooks/ .claude/skills/ .claude/settings.json .claude/agents.toml CLAUDE.md"
);
}
if has_opencode {
println!(" git add .opencode/ AGENTS.md");
}
if has_windsurf {
println!(" git add .windsurf/");
}
println!(
" git commit -m \"chore: update deciduous tooling to v{}\"",
version
);
println!();
Ok(())
}
/// Update Claude Code tooling files
fn update_claude_code(cwd: &std::path::Path) -> Result<(), String> {
// Create .claude/commands directory if needed
let claude_dir = cwd.join(".claude").join("commands");
create_dir_if_missing(&claude_dir)?;
let model = GraphModelTemplates::for_project(cwd);
// Overwrite decision.md slash command
let decision_path = claude_dir.join("decision.md");
write_file_overwrite(
&decision_path,
model.decision,
".claude/commands/decision.md",
)?;
// Overwrite recover.md slash command
let recover_path = claude_dir.join("recover.md");
write_file_overwrite(&recover_path, model.recover, ".claude/commands/recover.md")?;
// Overwrite work.md slash command
let work_path = claude_dir.join("work.md");
write_file_overwrite(&work_path, model.work, ".claude/commands/work.md")?;
// Overwrite document.md slash command
let document_path = claude_dir.join("document.md");
write_file_overwrite(
&document_path,
model.document,
".claude/commands/document.md",
)?;
// Overwrite build-test.md slash command
let build_test_path = claude_dir.join("build-test.md");
write_file_overwrite(
&build_test_path,
BUILD_TEST_MD,
".claude/commands/build-test.md",
)?;
// Overwrite serve-ui.md slash command
let serve_ui_path = claude_dir.join("serve-ui.md");
write_file_overwrite(&serve_ui_path, SERVE_UI_MD, ".claude/commands/serve-ui.md")?;
// GitHub Pages is gone since 1.0.5: /sync-graph and the Pages workflow init
// wrote are removed when deciduous wrote them, kept when someone edited
// them. docs/ is left alone: graph-data.json is the project's own data.
remove_guarded(
&claude_dir.join("sync-graph.md"),
".claude/commands/sync-graph.md",
)?;
for name in RETIRED_WORKFLOWS {
remove_guarded(
&cwd.join(".github/workflows").join(name),
&format!(".github/workflows/{name}"),
)?;
}
// Overwrite decision-graph.md slash command
let decision_graph_path = claude_dir.join("decision-graph.md");
write_file_overwrite(
&decision_graph_path,
DECISION_GRAPH_MD,
".claude/commands/decision-graph.md",
)?;
// Overwrite sync.md slash command
let sync_path = claude_dir.join("sync.md");
write_file_overwrite(&sync_path, model.sync, ".claude/commands/sync.md")?;
// Overwrite demo-swarm.md slash command (the multi-agent demo)
let demo_path = claude_dir.join("demo-swarm.md");
write_file_overwrite(&demo_path, DEMO_SWARM_MD, ".claude/commands/demo-swarm.md")?;
// Create/update hooks directory
let claude_base = cwd.join(".claude");
let hooks_dir = claude_base.join("hooks");
create_dir_if_missing(&hooks_dir)?;
// The logging hooks are gone since 1.0.3: remove the scripts deciduous
// wrote, keep any the user wrote, and take the entries out of settings.json.
for name in RETIRED_HOOK_SCRIPTS {
remove_guarded(&hooks_dir.join(name), &format!(".claude/hooks/{name}"))?;
}
// Overwrite version-check.sh hook (opt-in auto-update check)
let version_check_path = hooks_dir.join("version-check.sh");
write_executable_overwrite(
&version_check_path,
HOOK_VERSION_CHECK,
".claude/hooks/version-check.sh",
)?;
write_executable_overwrite(
&hooks_dir.join("board-mentions.sh"),
HOOK_BOARD_MENTIONS,
".claude/hooks/board-mentions.sh",
)?;
strip_retired_hook_settings(&claude_base.join("settings.json"))?;
ensure_board_hook_settings(&claude_base.join("settings.json"))?;
// Overwrite agents.toml
let agents_path = claude_base.join("agents.toml");
write_file_overwrite(&agents_path, CLAUDE_AGENTS_TOML, ".claude/agents.toml")?;
// Create/update skills directory and files
let skills_dir = claude_base.join("skills");
create_dir_if_missing(&skills_dir)?;
let pulse_path = skills_dir.join("pulse.md");
write_file_overwrite(&pulse_path, SKILL_PULSE, ".claude/skills/pulse.md")?;
let narratives_path = skills_dir.join("narratives.md");
write_file_overwrite(
&narratives_path,
SKILL_NARRATIVES,
".claude/skills/narratives.md",
)?;
let archaeology_path = skills_dir.join("archaeology.md");
write_file_overwrite(
&archaeology_path,
SKILL_ARCHAEOLOGY,
".claude/skills/archaeology.md",
)?;
// Update CLAUDE.md section
let claude_md_path = cwd.join("CLAUDE.md");
replace_config_md_section(&claude_md_path, model.claude_md, "CLAUDE.md")?;
// Update Windsurf if .windsurf directory exists
let windsurf_dir = cwd.join(".windsurf");
if windsurf_dir.exists() {
update_windsurf(cwd)?;
}
Ok(())
}
/// Set up Windsurf integration (called during init if --windsurf or .windsurf exists)
fn setup_windsurf_integration(cwd: &Path) -> Result<(), String> {
// Create .windsurf/hooks directory
let windsurf_hooks_dir = cwd.join(".windsurf").join("hooks");
create_dir_if_missing(&windsurf_hooks_dir)?;
// Create .windsurf/rules directory
let windsurf_rules_dir = cwd.join(".windsurf").join("rules");
create_dir_if_missing(&windsurf_rules_dir)?;
// Write hooks.json
let hooks_json_path = cwd.join(".windsurf").join("hooks.json");
write_file_if_missing(
&hooks_json_path,
WINDSURF_HOOKS_JSON,
".windsurf/hooks.json",
)?;
// Write version-check.sh hook (opt-in auto-update check)
let version_check_path = windsurf_hooks_dir.join("version-check.sh");
write_executable_if_missing(
&version_check_path,
HOOK_VERSION_CHECK,
".windsurf/hooks/version-check.sh",
)?;
// Write deciduous.md rules file
let rules_path = windsurf_rules_dir.join("deciduous.md");
write_file_if_missing(
&rules_path,
WINDSURF_RULES_DECIDUOUS,
".windsurf/rules/deciduous.md",
)?;
println!(" {} Windsurf integration", "Configured".green());
Ok(())
}
/// Update Windsurf integration (called during update if .windsurf exists)
fn update_windsurf(cwd: &Path) -> Result<(), String> {
// Create directories if needed
let windsurf_hooks_dir = cwd.join(".windsurf").join("hooks");
create_dir_if_missing(&windsurf_hooks_dir)?;
let windsurf_rules_dir = cwd.join(".windsurf").join("rules");
create_dir_if_missing(&windsurf_rules_dir)?;
// Overwrite hooks.json
let hooks_json_path = cwd.join(".windsurf").join("hooks.json");
write_file_overwrite(
&hooks_json_path,
WINDSURF_HOOKS_JSON,
".windsurf/hooks.json",
)?;
for name in RETIRED_HOOK_SCRIPTS {
remove_guarded(
&windsurf_hooks_dir.join(name),
&format!(".windsurf/hooks/{name}"),
)?;
}
// Overwrite version-check.sh hook (opt-in auto-update check)
let version_check_path = windsurf_hooks_dir.join("version-check.sh");
write_executable_overwrite(
&version_check_path,
HOOK_VERSION_CHECK,
".windsurf/hooks/version-check.sh",
)?;
// Overwrite deciduous.md rules file
let rules_path = windsurf_rules_dir.join("deciduous.md");
write_file_overwrite(
&rules_path,
WINDSURF_RULES_DECIDUOUS,
".windsurf/rules/deciduous.md",
)?;
Ok(())
}
fn create_dir_if_missing(path: &Path) -> Result<(), String> {
if !path.exists() {
fs::create_dir_all(path)
.map_err(|e| format!("Could not create {}: {}", path.display(), e))?;
println!(" {} {}", "Creating".green(), path.display());
}
Ok(())
}
/// Hook scripts deciduous installed before 1.0.3 to make agents log, for
/// Claude Code and Windsurf alike. Logging is encouraged through the
/// instructions and the tool replies now, not enforced by a hook, so
/// `update` removes the ones it wrote.
pub const RETIRED_HOOK_SCRIPTS: [&str; 2] = ["require-action-node.sh", "post-commit-reminder.sh"];
/// The GitHub Pages workflow `init` wrote before 1.0.5; `update` removes it
/// when deciduous wrote it.
pub const RETIRED_WORKFLOWS: [&str; 1] = ["deploy-pages.yml"];
/// Takes the retired logging hooks out of `.claude/settings.json`: every
/// command that runs `deciduous log-loop`, and every command that runs a
/// retired script that is no longer on disk. A retired script the user
/// wrote was kept, so the entry that runs it stays. Entries and events left
/// empty are dropped; everything else, including key order, is kept. A file
/// that is not valid JSON is reported and left alone.
pub fn strip_retired_hook_settings(path: &Path) -> Result<(), String> {
let Ok(raw) = fs::read_to_string(path) else {
return Ok(());
};
let mut settings: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
println!(
" {} .claude/settings.json (not valid JSON: {e}); remove any `deciduous log-loop` hooks by hand",
"Skipped".yellow()
);
return Ok(());
}
};
let hooks_dir = path.parent().map(|p| p.join("hooks"));
let retired = |command: &str| {
command.contains("deciduous log-loop")
|| RETIRED_HOOK_SCRIPTS.iter().any(|name| {
command.contains(&format!(".claude/hooks/{name}"))
&& !hooks_dir.as_ref().is_some_and(|d| d.join(name).exists())
})
};
let mut changed = false;
if let Some(events) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) {
for entries in events.values_mut() {
let Some(list) = entries.as_array_mut() else {
continue;
};
for entry in list.iter_mut() {
if let Some(hs) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) {
let before = hs.len();
hs.retain(|h| !retired(h["command"].as_str().unwrap_or("")));
changed |= hs.len() != before;
}
}
let before = list.len();
list.retain(|e| e["hooks"].as_array().is_none_or(|hs| !hs.is_empty()));
changed |= list.len() != before;
}
let before = events.len();
events.retain(|_, v| v.as_array().is_none_or(|l| !l.is_empty()));
changed |= events.len() != before;
}
if changed {
if let Some(root) = path.parent().and_then(|p| p.parent()) {
guard::backup(root, ".claude/settings.json")?;
}
let body = render_in_original_order(&settings, &raw);
fs::write(path, body + "\n")
.map_err(|e| format!("Could not write {}: {}", path.display(), e))?;
println!(
" {} .claude/settings.json (logging hooks removed)",
"Updated".green()
);
}
Ok(())
}
/// The events the board-mentions hook runs on, and the argument it gets.
const BOARD_HOOK_EVENTS: [(&str, &str); 2] =
[("SessionStart", "start"), ("UserPromptSubmit", "prompt")];
/// Adds the board-mentions hook to `.claude/settings.json` for each event
/// that does not run it yet. Everything already there, the user's hooks
/// included, is kept in its order; a file that is not valid JSON is reported
/// and left alone, and a missing file is written from the template.
pub fn ensure_board_hook_settings(path: &Path) -> Result<(), String> {
let raw = match fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return write_file_if_missing(path, CLAUDE_SETTINGS_JSON, ".claude/settings.json");
}
Err(e) => return Err(format!("Could not read {}: {}", path.display(), e)),
};
let mut settings: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
println!(
" {} .claude/settings.json (not valid JSON: {e}); add the board-mentions.sh hook by hand",
"Skipped".yellow()
);
return Ok(());
}
};
let Some(root) = settings.as_object_mut() else {
println!(
" {} .claude/settings.json (not a JSON object); add the board-mentions.sh hook by hand",
"Skipped".yellow()
);
return Ok(());
};
let hooks = root.entry("hooks").or_insert_with(|| serde_json::json!({}));
let Some(events) = hooks.as_object_mut() else {
return Ok(());
};
let mut added = Vec::new();
for (event, arg) in BOARD_HOOK_EVENTS {
let list = events.entry(event).or_insert_with(|| serde_json::json!([]));
let Some(list) = list.as_array_mut() else {
continue;
};
let present = list.iter().any(|entry| {
entry["hooks"].as_array().is_some_and(|hs| {
hs.iter().any(|h| {
h["command"]
.as_str()
.is_some_and(|c| c.contains("board-mentions.sh"))
})
})
});
if !present {
list.push(serde_json::json!({
"hooks": [{
"type": "command",
"command": format!("\"$CLAUDE_PROJECT_DIR/.claude/hooks/board-mentions.sh\" {arg}"),
"timeout": 10
}]
}));
added.push(event);
}
}
if added.is_empty() {
return Ok(());
}
if let Some(root) = path.parent().and_then(|p| p.parent()) {
guard::backup(root, ".claude/settings.json")?;
}
let body = render_in_original_order(&settings, &raw);
fs::write(path, body + "\n")
.map_err(|e| format!("Could not write {}: {}", path.display(), e))?;
println!(
" {} .claude/settings.json (board-mentions hook on {})",
"Updated".green(),
added.join(", ")
);
Ok(())
}
/// Pretty-prints `value` (2-space indent, like `to_string_pretty`) with each
/// object's keys in the order they first appear in `original`, and keys that
/// are new after them in the order serde_json holds them. serde_json's map is
/// sorted, and `preserve_order` cannot be turned on for one call: it would
/// reorder every JSON the program writes, graph.json included. A user's
/// settings.json keeps its shape; only what deciduous adds is new.
fn render_in_original_order(value: &serde_json::Value, original: &str) -> String {
let orders = key_orders(original);
let mut out = String::new();
render_ordered(value, &orders, "", 0, &mut out);
out
}
/// For every object in `raw`, keyed by its path ("/hooks/PreToolUse/0"), its
/// keys in the order written. A small scanner, not a full JSON parser: it only
/// has to find keys in a document serde_json has already accepted.
fn key_orders(raw: &str) -> std::collections::HashMap<String, Vec<String>> {
fn skip_ws(b: &[u8], i: &mut usize) {
while *i < b.len() && (b[*i] as char).is_whitespace() {
*i += 1;
}
}
fn string(b: &[u8], i: &mut usize) -> String {
let start = *i;
*i += 1;
while *i < b.len() && b[*i] != b'"' {
if b[*i] == b'\\' {
*i += 1;
}
*i += 1;
}
*i += 1;
serde_json::from_slice(&b[start..*i]).unwrap_or_default()
}
fn value(
b: &[u8],
i: &mut usize,
path: &str,
out: &mut std::collections::HashMap<String, Vec<String>>,
) {
skip_ws(b, i);
match b.get(*i) {
Some(b'{') => {
*i += 1;
let mut keys = Vec::new();
loop {
skip_ws(b, i);
match b.get(*i) {
Some(b'}') => {
*i += 1;
break;
}
Some(b',') => *i += 1,
Some(b'"') => {
let k = string(b, i);
skip_ws(b, i);
*i += 1; // ':'
value(b, i, &format!("{path}/{k}"), out);
keys.push(k);
}
_ => return,
}
}
out.insert(path.to_string(), keys);
}
Some(b'[') => {
*i += 1;
let mut n = 0;
loop {
skip_ws(b, i);
match b.get(*i) {
Some(b']') => {
*i += 1;
break;
}
Some(b',') => *i += 1,
Some(_) => {
value(b, i, &format!("{path}/{n}"), out);
n += 1;
}
None => return,
}
}
}
Some(b'"') => {
string(b, i);
}
Some(_) => {
while *i < b.len() && !matches!(b[*i], b',' | b'}' | b']') {
*i += 1;
}
}
None => {}
}
}
let mut out = std::collections::HashMap::new();
let mut i = 0;
value(raw.as_bytes(), &mut i, "", &mut out);
out
}
fn render_ordered(
v: &serde_json::Value,
orders: &std::collections::HashMap<String, Vec<String>>,
path: &str,
depth: usize,
out: &mut String,
) {
use serde_json::Value;
let pad = " ".repeat(depth + 1);
let close = " ".repeat(depth);
match v {
Value::Object(map) if !map.is_empty() => {
let known = orders.get(path);
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_by_key(|k| {
known
.and_then(|o| o.iter().position(|x| x == *k))
.unwrap_or(usize::MAX)
});
out.push_str("{\n");
for (i, k) in keys.iter().enumerate() {
out.push_str(&pad);
out.push_str(&serde_json::to_string(k).unwrap());
out.push_str(": ");
render_ordered(&map[*k], orders, &format!("{path}/{k}"), depth + 1, out);
out.push_str(if i + 1 < keys.len() { ",\n" } else { "\n" });
}
out.push_str(&close);
out.push('}');
}
Value::Array(items) if !items.is_empty() => {
out.push_str("[\n");
for (i, item) in items.iter().enumerate() {
out.push_str(&pad);
render_ordered(item, orders, &format!("{path}/{i}"), depth + 1, out);
out.push_str(if i + 1 < items.len() { ",\n" } else { "\n" });
}
out.push_str(&close);
out.push(']');
}
other => out.push_str(&serde_json::to_string(other).unwrap()),
}
}
fn write_file_if_missing(path: &Path, content: &str, display_name: &str) -> Result<(), String> {
if path.exists() {
println!(
" {} {} (already exists)",
"Skipping".yellow(),
display_name
);
} else {
fs::write(path, content).map_err(|e| format!("Could not write {}: {}", display_name, e))?;
println!(" {} {}", "Creating".green(), display_name);
}
Ok(())
}
#[cfg(unix)]
fn write_executable_if_missing(
path: &Path,
content: &str,
display_name: &str,
) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
if path.exists() {
println!(
" {} {} (already exists)",
"Skipping".yellow(),
display_name
);
} else {
fs::write(path, content).map_err(|e| format!("Could not write {}: {}", display_name, e))?;
// Make executable (chmod +x)
let mut perms = fs::metadata(path)
.map_err(|e| format!("Could not get metadata for {}: {}", display_name, e))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
.map_err(|e| format!("Could not set permissions for {}: {}", display_name, e))?;
println!(" {} {} (executable)", "Creating".green(), display_name);
}
Ok(())
}
#[cfg(not(unix))]
fn write_executable_if_missing(
path: &Path,
content: &str,
display_name: &str,
) -> Result<(), String> {
// On non-Unix systems, just write the file without setting permissions
write_file_if_missing(path, content, display_name)
}
/// `deciduous update --all <dir>`: every deciduous project directly under
/// `dir`, and `dir` itself if it is one, updated one after another. A project
/// is a directory with `.deciduous/` and an assistant integration to update.
/// One project failing does not stop the rest. Returns how many failed.
pub fn update_all(root: &Path) -> usize {
let start = std::env::current_dir().unwrap_or_default();
let mut dirs: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
if let Ok(entries) = fs::read_dir(root) {
let mut children: Vec<_> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
children.sort();
dirs.extend(children);
}
let is_project = |d: &Path| {
d.join(".deciduous").is_dir()
&& [".claude", ".opencode", ".windsurf"]
.iter()
.any(|a| d.join(a).is_dir())
};
let (mut ok, mut failed) = (0usize, Vec::new());
for d in dirs.into_iter().filter(|d| is_project(d)) {
println!("\n{} {}", "==>".cyan(), d.display());
let result = std::env::set_current_dir(&d)
.map_err(|e| format!("cannot enter {}: {e}", d.display()))
.and_then(|_| update_tooling())
.and_then(|_| crate::server::ensure(&d, crate::server::Caller::Update));
match result {
Ok(()) => ok += 1,
Err(e) => {
eprintln!(" {} {}", "Failed:".red(), e);
failed.push(d.display().to_string());
}
}
}
let _ = std::env::set_current_dir(start);
println!(
"\n{} {} updated, {} failed",
"Done:".green(),
ok,
failed.len()
);
for f in &failed {
println!(" {} {}", "failed".red(), f);
}
failed.len()
}
/// The project root for a harness path: callers pass the path relative to the
/// project as `display_name`, so the root is the path with that suffix removed.
fn harness_root(path: &Path, display_name: &str) -> std::path::PathBuf {
let full = path.to_string_lossy();
match full.strip_suffix(display_name) {
Some(root) if !root.is_empty() => std::path::PathBuf::from(root),
_ => std::env::current_dir().unwrap_or_default(),
}
}
/// Writes a harness file through [`guard::write`]: replaces what deciduous
/// wrote, keeps (and for Markdown, appends to) what someone else wrote.
fn write_file_overwrite(path: &Path, content: &str, display_name: &str) -> Result<(), String> {
write_guarded(path, content, display_name, false)
}
fn write_executable_overwrite(
path: &Path,
content: &str,
display_name: &str,
) -> Result<(), String> {
write_guarded(path, content, display_name, true)
}
fn write_guarded(
path: &Path,
content: &str,
display_name: &str,
executable: bool,
) -> Result<(), String> {
let outcome = guard::write(&harness_root(path, display_name), path, content, executable)?;
let label = outcome.label();
let label = match outcome {
guard::Outcome::KeptYours | guard::Outcome::Appended => label.yellow(),
guard::Outcome::Unchanged => label.dimmed(),
_ => label.green(),
};
let root = harness_root(path, display_name);
let left = std::path::Path::new(guard::TEMPLATE_DIR).join(display_name);
if outcome == guard::Outcome::KeptYours && root.join(&left).is_file() {
println!(
" {} {} (edited by someone{}; the template it would have got: {})",
label,
display_name,
if guard::remote_configured(&root) {
", and this project has a [remote], so nothing is appended"
} else {
""
},
left.display()
);
} else {
println!(" {} {}", label, display_name);
}
Ok(())
}
fn remove_guarded(path: &Path, display_name: &str) -> Result<(), String> {
match guard::remove(&harness_root(path, display_name), path)? {
Some(guard::Outcome::KeptYours) => println!(
" {} {} (yours; left in place)",
"Kept".yellow(),
display_name
),
Some(outcome) => println!(" {} {}", outcome.label().green(), display_name),
None => {}
}
Ok(())
}
/// Where the deciduous section of a CLAUDE.md-style file sits: from its start
/// marker (or, in a file older than the markers, its heading) to the end of
/// its end marker, or to the next `## ` heading when the end marker is
/// missing. `terminated` says which.
struct SectionSpan {
start: usize,
end: usize,
terminated: bool,
}
fn find_section(existing: &str) -> Option<SectionSpan> {
const LEGACY_HEADINGS: [&str; 2] = [
"## Decision Graph Workflow",
"## MANDATORY: Decision Graph Workflow",
];
let start = existing.find(guard::BLOCK_START).or_else(|| {
LEGACY_HEADINGS
.iter()
.filter_map(|m| existing.find(m))
.min()
})?;
if let Some(i) = existing[start..].find(guard::BLOCK_END) {
return Some(SectionSpan {
start,
end: start + i + guard::BLOCK_END.len(),
terminated: true,
});
}
// No end marker: the section runs to the next `## ` heading after its
// own. Its own is the first `## ` line from `start` on, which for a
// marked section comes after the marker. Searching from the heading
// instead of from the marker is what left 1.0.10's update with two start
// markers: the old one sat outside the span it replaced.
let heading = existing[start..]
.match_indices("## ")
.map(|(i, _)| start + i)
.find(|&i| i == 0 || existing.as_bytes()[i - 1] == b'\n')
.unwrap_or(start);
let after_heading = existing[heading..]
.find('\n')
.map_or(existing.len(), |i| heading + i);
let end = existing[after_heading..]
.find("\n## ")
.map_or(existing.len(), |i| after_heading + i + 1);
Some(SectionSpan {
start,
end,
terminated: false,
})
}
/// Replaces the deciduous section of `path` with `section_content`, when the
/// section there is one deciduous wrote. A section someone edited is left
/// exactly as it is, and the section it would have become is put in
/// `.deciduous/update-templates/` for comparison. This used to overwrite any
/// section, so a project that had rewritten its section for a graph server
/// got the git workflow back on every update.
fn replace_config_md_section(
path: &Path,
section_content: &str,
file_name: &str,
) -> Result<(), String> {
let root = path.parent().unwrap_or(Path::new("."));
let section = section_content.trim();
let Ok(existing) = fs::read_to_string(path) else {
fs::write(path, section).map_err(|e| format!("Could not create {}: {}", file_name, e))?;
println!(" {} {}", "Creating".green(), file_name);
return Ok(());
};
let Some(span) = find_section(&existing) else {
let body = format!("{}\n\n{}\n", existing.trim_end(), section);
guard::backup(root, file_name)?;
fs::write(path, body).map_err(|e| format!("Could not write {}: {}", file_name, e))?;
println!(" {} {} (section added)", "Updated".green(), file_name);
return Ok(());
};
let current = &existing[span.start..span.end];
if !guard::shipped_section(current) && current.trim() != section {
let left = guard::leave_template(root, file_name, section);
println!(
" {} {} (its deciduous section was edited by someone and is left as it is{})",
"Kept yours".yellow(),
file_name,
left.map(|p| format!("; the section it would have got: {p}"))
.unwrap_or_default()
);
return Ok(());
}
let after = &existing[span.end..];
let joint = match (span.terminated, after.is_empty()) {
(true, _) => "",
(false, true) => "\n",
(false, false) => "\n\n",
};
let body = format!("{}{}{}{}", &existing[..span.start], section, joint, after);
if body == existing {
println!(" {} {}", "Unchanged".dimmed(), file_name);
return Ok(());
}
guard::backup(root, file_name)?;
fs::write(path, &body).map_err(|e| format!("Could not write {}: {}", file_name, e))?;
println!(
" {} {} ({})",
"Updated".green(),
file_name,
if span.terminated {
"section replaced"
} else {
"section replaced, markers added"
}
);
Ok(())
}
/// Append the Decision Graph Workflow section to CLAUDE.md
fn append_config_md(path: &Path, section_content: &str, file_name: &str) -> Result<(), String> {
let marker = "## Decision Graph Workflow";
if path.exists() {
let existing =
fs::read_to_string(path).map_err(|e| format!("Could not read {}: {}", file_name, e))?;
if existing.contains(marker) {
println!(
" {} {} (workflow section already present)",
"Skipping".yellow(),
file_name
);
return Ok(());
}
// Append the section
let new_content = format!("{}\n{}", existing.trim_end(), section_content);
fs::write(path, new_content)
.map_err(|e| format!("Could not update {}: {}", file_name, e))?;
println!(
" {} {} (added workflow section)",
"Updated".green(),
file_name
);
} else {
// Create new file
let content = format!("# Project Instructions\n{}", section_content);
fs::write(path, content).map_err(|e| format!("Could not create {}: {}", file_name, e))?;
println!(" {} {}", "Creating".green(), file_name);
}
Ok(())
}
/// The `.gitignore` rules deciduous needs: the SQLite file is private to a
/// machine, the graph file and config are shared through git.
const GITIGNORE_BLOCK: &str =
"# Deciduous: local database stays private, the shared graph is tracked\n\
.deciduous/*\n\
!.deciduous/config.toml\n\
!.deciduous/graph.json\n";
/// Lines that older versions of deciduous (or people) wrote for the same
/// purpose. They are replaced by [`GITIGNORE_BLOCK`].
fn is_stale_deciduous_ignore_line(line: &str) -> bool {
matches!(
line.trim(),
".deciduous"
| ".deciduous/"
| "/.deciduous"
| "/.deciduous/"
| ".deciduous/*"
| "!.deciduous/config.toml"
| "!.deciduous/graph.json"
| "!.deciduous/sync/"
| "!.deciduous/sync"
| "!.deciduous/patches/"
| "!.deciduous/patches"
| "# Deciduous database (local)"
| "# Deciduous database (local) - but track patches for sharing"
| "# Deciduous: local database stays private, shared graph records are tracked"
| "# Deciduous: local database stays private, the shared graph is tracked"
)
}
/// Ensure `.gitignore` hides the database but tracks `.deciduous/graph.json`.
///
/// A blanket `.deciduous/` line (what pre-0.17 `init` wrote) would hide the
/// graph file, so it is replaced. Anything else in the file is left alone.
fn ensure_gitignore(cwd: &Path) -> Result<(), String> {
let path = cwd.join(".gitignore");
let existing = if path.exists() {
fs::read_to_string(&path).map_err(|e| format!("Could not read .gitignore: {}", e))?
} else {
String::new()
};
let has = |needle: &str| existing.lines().any(|l| l.trim() == needle);
let blanket = existing.lines().any(|l| {
matches!(
l.trim(),
".deciduous" | ".deciduous/" | "/.deciduous" | "/.deciduous/"
)
});
if has(".deciduous/*")
&& has("!.deciduous/config.toml")
&& has("!.deciduous/graph.json")
&& !blanket
{
return Ok(());
}
let kept: Vec<&str> = existing
.lines()
.filter(|l| !is_stale_deciduous_ignore_line(l))
.collect();
let mut content = kept.join("\n").trim_end().to_string();
if !content.is_empty() {
content.push_str("\n\n");
}
content.push_str(GITIGNORE_BLOCK);
fs::write(&path, content).map_err(|e| format!("Could not write .gitignore: {}", e))?;
println!(
" {} .gitignore (track .deciduous/graph.json, ignore the database)",
if existing.is_empty() {
"Creating".green()
} else {
"Updated".green()
}
);
Ok(())
}
const GITATTRIBUTES_LINE: &str = ".deciduous/graph.json merge=deciduous linguist-generated=true";
/// Patterns earlier versions wrote for the same purpose.
const STALE_GITATTRIBUTES_PATTERNS: [&str; 2] = [".deciduous/sync/**", ".deciduous/graph.json"];
/// Route the graph file through the `deciduous` merge driver and mark it as
/// generated so GitHub folds it in pull request diffs. An older line for the
/// same purpose is upgraded in place.
pub fn ensure_gitattributes(cwd: &Path) -> Result<(), String> {
let path = cwd.join(".gitattributes");
let existing = if path.exists() {
fs::read_to_string(&path).map_err(|e| format!("Could not read .gitattributes: {}", e))?
} else {
String::new()
};
if existing.lines().any(|l| l.trim() == GITATTRIBUTES_LINE) {
return Ok(());
}
let mut lines: Vec<String> = existing
.lines()
.filter(|l| {
!l.split_whitespace()
.next()
.is_some_and(|pat| STALE_GITATTRIBUTES_PATTERNS.contains(&pat))
})
.map(str::to_string)
.collect();
lines.push(GITATTRIBUTES_LINE.to_string());
let mut content = lines.join("\n").trim_end().to_string();
content.push('\n');
fs::write(&path, content).map_err(|e| format!("Could not write .gitattributes: {}", e))?;
println!(
" {} .gitattributes (merge the graph file record by record, fold it in PR diffs)",
if existing.is_empty() {
"Creating".green()
} else {
"Updated".green()
}
);
Ok(())
}
const MERGE_DRIVER_COMMAND: &str = "deciduous merge-record %O %A %B";
/// Register the `deciduous` merge driver in this clone's git config so two
/// people editing the same record get a field-level merge instead of
/// conflict markers. Git config is per clone, so this runs from `init`,
/// `update`, and `sync`. Returns `Ok(true)` when it (re)wrote the config.
/// Silently does nothing outside a git repository.
pub fn ensure_merge_driver(cwd: &Path) -> Result<bool, String> {
let inside = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output();
match inside {
Ok(out) if out.status.success() => {}
_ => return Ok(false),
}
let current = std::process::Command::new("git")
.args(["config", "--get", "merge.deciduous.driver"])
.current_dir(cwd)
.output()
.map_err(|e| format!("git config: {}", e))?;
if current.status.success()
&& String::from_utf8_lossy(¤t.stdout).trim() == MERGE_DRIVER_COMMAND
{
return Ok(false);
}
for (key, value) in [
("merge.deciduous.name", "deciduous decision graph record"),
("merge.deciduous.driver", MERGE_DRIVER_COMMAND),
] {
let status = std::process::Command::new("git")
.args(["config", key, value])
.current_dir(cwd)
.status()
.map_err(|e| format!("git config {}: {}", key, e))?;
if !status.success() {
return Err(format!("git config {} failed", key));
}
}
Ok(true)
}
/// Create `.deciduous/graph.json` so it exists in every clone, and fold a
/// 0.17 `.deciduous/sync/` directory into it if one is still there.
fn ensure_graph_file(cwd: &Path) -> Result<(), String> {
let path = cwd.join(".deciduous").join(crate::records::STORE_FILE_NAME);
let existed = path.is_file();
let store = crate::records::RecordStore::create(&path)
.map_err(|e| format!("Could not create {}: {}", path.display(), e))?;
if !existed {
println!(
" {} .deciduous/graph.json (run `deciduous sync` to fill it, then commit it)",
"Creating".green()
);
}
if store.has_legacy_record_dir() {
let report = store
.import_legacy_record_dir()
.map_err(|e| format!("Could not import .deciduous/sync/: {}", e))?;
println!(
" {} .deciduous/sync/ into .deciduous/graph.json ({} nodes, {} edges)",
"Folded".green(),
report.nodes,
report.edges
);
if report.removed {
println!(
" {} .deciduous/sync/ (git rm -r it too)",
"Removed".green()
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_create_dir_if_missing() {
let tmp = TempDir::new().unwrap();
let new_dir = tmp.path().join("new_dir");
assert!(!new_dir.exists());
create_dir_if_missing(&new_dir).unwrap();
assert!(new_dir.exists());
// Should not error on existing dir
create_dir_if_missing(&new_dir).unwrap();
}
#[test]
fn test_write_file_if_missing() {
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("test.txt");
write_file_if_missing(&file_path, "content", "test.txt").unwrap();
assert_eq!(fs::read_to_string(&file_path).unwrap(), "content");
// Should not overwrite existing
write_file_if_missing(&file_path, "new content", "test.txt").unwrap();
assert_eq!(fs::read_to_string(&file_path).unwrap(), "content");
}
/// What a 1.0.2 `update` left in a user's settings.json: their own entry,
/// the wrapper entries, and the log-loop entries.
const SETTINGS_1_0_2: &str = "{\n \"permissions\": {\n \"allow\": [\n \"Bash(mix *)\"\n ]\n },\n \"hooks\": {\n \"PreToolUse\": [\n {\n \"matcher\": \"Edit\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"mine.sh\"\n }\n ]\n },\n {\n \"matcher\": \"Edit|Write|NotebookEdit|Bash\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"\\\"$CLAUDE_PROJECT_DIR/.claude/hooks/require-action-node.sh\\\"\"\n }\n ]\n }\n ],\n \"PostToolUse\": [\n {\n \"matcher\": \"Bash\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"deciduous log-loop post-bash || true\"\n }\n ]\n }\n ],\n \"Stop\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"deciduous log-loop stop || true\"\n }\n ]\n }\n ]\n },\n \"model\": \"x\"\n}\n";
#[test]
fn retired_hooks_leave_settings_and_the_users_entries_stay_in_order() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".claude/hooks")).unwrap();
let p = tmp.path().join(".claude/settings.json");
fs::write(&p, SETTINGS_1_0_2).unwrap();
strip_retired_hook_settings(&p).unwrap();
let after = fs::read_to_string(&p).unwrap();
assert!(!after.contains("log-loop"), "{after}");
assert!(!after.contains("require-action-node"), "{after}");
assert!(
!after.contains("\"Stop\"") && !after.contains("\"PostToolUse\""),
"empty events dropped:\n{after}"
);
let (perm, hooks, model) = (
after.find("\"permissions\"").unwrap(),
after.find("\"hooks\"").unwrap(),
after.find("\"model\"").unwrap(),
);
assert!(
perm < hooks && hooks < model,
"top-level order kept:\n{after}"
);
let (m, h) = (
after.find("\"matcher\": \"Edit\"").unwrap(),
after.find("\"command\": \"mine.sh\"").unwrap(),
);
assert!(m < h, "matcher still before hooks in the user's entry");
// Idempotent: a second run finds nothing to do.
strip_retired_hook_settings(&p).unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), after);
}
#[test]
fn a_retired_script_the_user_wrote_keeps_its_entry() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".claude/hooks")).unwrap();
fs::write(
tmp.path().join(".claude/hooks/require-action-node.sh"),
"#!/bin/sh\n# mine\nexit 0\n",
)
.unwrap();
let p = tmp.path().join(".claude/settings.json");
fs::write(&p, SETTINGS_1_0_2).unwrap();
strip_retired_hook_settings(&p).unwrap();
let after = fs::read_to_string(&p).unwrap();
assert!(after.contains("require-action-node.sh"), "{after}");
assert!(!after.contains("log-loop"), "{after}");
}
#[test]
fn test_write_file_overwrite() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".deciduous")).unwrap();
let file_path = tmp.path().join("test.txt");
// Written by deciduous, then updated: replaced.
write_file_overwrite(&file_path, "original", "test.txt").unwrap();
write_file_overwrite(&file_path, "updated", "test.txt").unwrap();
assert_eq!(fs::read_to_string(&file_path).unwrap(), "updated");
// Written by someone else: kept.
fs::write(&file_path, "someone else's").unwrap();
write_file_overwrite(&file_path, "updated again", "test.txt").unwrap();
assert_eq!(fs::read_to_string(&file_path).unwrap(), "someone else's");
}
#[test]
fn test_ensure_gitignore_new_file() {
let tmp = TempDir::new().unwrap();
ensure_gitignore(tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(content.contains(".deciduous/*"));
assert!(content.contains("!.deciduous/graph.json"));
assert!(content.contains("!.deciduous/config.toml"));
}
#[test]
fn test_ensure_gitignore_keeps_other_rules() {
let tmp = TempDir::new().unwrap();
let gitignore = tmp.path().join(".gitignore");
fs::write(&gitignore, "node_modules/\n").unwrap();
ensure_gitignore(tmp.path()).unwrap();
let content = fs::read_to_string(&gitignore).unwrap();
assert!(content.starts_with("node_modules/\n"));
assert!(content.contains("!.deciduous/graph.json"));
}
#[test]
fn test_ensure_gitignore_replaces_blanket_rule_and_is_idempotent() {
let tmp = TempDir::new().unwrap();
let gitignore = tmp.path().join(".gitignore");
fs::write(
&gitignore,
"target/\n\n# Deciduous database (local)\n.deciduous/\n\n.deciduous/*\n!.deciduous/patches/\n",
)
.unwrap();
ensure_gitignore(tmp.path()).unwrap();
let content = fs::read_to_string(&gitignore).unwrap();
assert!(
!content.lines().any(|l| l.trim() == ".deciduous/"),
"{content}"
);
assert!(!content.contains("patches"), "{content}");
assert!(content.contains("target/"));
assert_eq!(content.matches(".deciduous/*").count(), 1);
let before = content.clone();
ensure_gitignore(tmp.path()).unwrap();
assert_eq!(fs::read_to_string(&gitignore).unwrap(), before);
}
#[test]
fn test_ensure_gitattributes_and_graph_file() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".gitattributes"),
"*.png binary\n.deciduous/sync/** linguist-generated=true\n",
)
.unwrap();
ensure_gitattributes(tmp.path()).unwrap();
ensure_gitattributes(tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join(".gitattributes")).unwrap();
assert_eq!(content.matches("linguist-generated").count(), 1);
assert!(content.contains("merge=deciduous"), "{content}");
assert!(content.starts_with("*.png binary\n"));
fs::create_dir_all(tmp.path().join(".deciduous")).unwrap();
ensure_graph_file(tmp.path()).unwrap();
assert!(tmp.path().join(".deciduous/graph.json").is_file());
}
/// Upgrading from 0.17: the old rules must be replaced, not stacked on.
#[test]
fn test_upgrade_replaces_the_0_17_sync_rules() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".gitignore"),
"target/
# Deciduous: local database stays private, shared graph records are tracked
.deciduous/*
!.deciduous/config.toml
!.deciduous/sync/
",
)
.unwrap();
fs::write(
tmp.path().join(".gitattributes"),
".deciduous/sync/** merge=deciduous linguist-generated=true
",
)
.unwrap();
ensure_gitignore(tmp.path()).unwrap();
ensure_gitattributes(tmp.path()).unwrap();
let ignore = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(!ignore.contains("!.deciduous/sync/"), "{ignore}");
assert!(ignore.contains("!.deciduous/graph.json"), "{ignore}");
assert!(ignore.starts_with("target/\n"));
let attrs = fs::read_to_string(tmp.path().join(".gitattributes")).unwrap();
assert_eq!(attrs.lines().count(), 1, "{attrs}");
assert!(
attrs.starts_with(".deciduous/graph.json merge=deciduous"),
"{attrs}"
);
}
/// A section deciduous shipped, and the same text without its markers,
/// as installs from before the markers have it.
fn shipped_section() -> (&'static str, String) {
let marked = templates::CLAUDE_MD_SECTION.trim();
let legacy = marked
.lines()
.filter(|l| !l.starts_with("<!-- deciduous:"))
.collect::<Vec<_>>()
.join("\n");
(marked, legacy)
}
#[test]
fn replacing_the_marked_section_leaves_everything_after_it_byte_for_byte() {
let tmp = TempDir::new().unwrap();
let md = tmp.path().join("CLAUDE.md");
let (old, _) = shipped_section();
let user_before = "# Mine\n\nRules.\n\n";
let user_after = "\n\n## My Section\n\nText.\n";
fs::write(&md, format!("{user_before}{old}{user_after}")).unwrap();
let section = remote_templates::CLAUDE_MD_SECTION_REMOTE.trim();
replace_config_md_section(&md, section, "CLAUDE.md").unwrap();
let once = fs::read_to_string(&md).unwrap();
assert_eq!(once, format!("{user_before}{section}{user_after}"));
replace_config_md_section(&md, section, "CLAUDE.md").unwrap();
assert_eq!(fs::read_to_string(&md).unwrap(), once);
}
#[test]
fn an_edited_marked_section_is_kept_byte_for_byte() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".deciduous")).unwrap();
let md = tmp.path().join("CLAUDE.md");
let text = "# My Project\n\n<!-- deciduous:start -->\n## Decision Graph Workflow\n\nUse `deciduous remote pull`.\n<!-- deciduous:end -->\n\n## Mine\n";
fs::write(&md, text).unwrap();
replace_config_md_section(&md, CLAUDE_MD_SECTION, "CLAUDE.md").unwrap();
assert_eq!(fs::read_to_string(&md).unwrap(), text);
assert_eq!(
fs::read_to_string(tmp.path().join(".deciduous/update-templates/CLAUDE.md")).unwrap(),
CLAUDE_MD_SECTION.trim()
);
}
#[test]
fn a_legacy_section_gets_markers_and_the_next_heading_stays() {
let tmp = TempDir::new().unwrap();
let md = tmp.path().join("CLAUDE.md");
let (_, legacy) = shipped_section();
fs::write(
&md,
format!("# My Project\n\nCustom rules.\n\n{legacy}\n\n## My Custom Rules\n\nDo not delete this!\n"),
)
.unwrap();
replace_config_md_section(&md, CLAUDE_MD_SECTION, "CLAUDE.md").unwrap();
let result = fs::read_to_string(&md).unwrap();
assert_eq!(
result,
format!(
"# My Project\n\nCustom rules.\n\n{}\n\n## My Custom Rules\n\nDo not delete this!\n",
CLAUDE_MD_SECTION.trim()
)
);
}
#[test]
fn a_start_marker_without_an_end_is_replaced_from_the_marker_once() {
// 1.0.10 searched for the heading, which sits after the marker, so
// the old marker survived beside the new one.
let tmp = TempDir::new().unwrap();
let md = tmp.path().join("CLAUDE.md");
let (_, legacy) = shipped_section();
fs::write(
&md,
format!("# P\n\n<!-- deciduous:start -->\n{legacy}\n\n## Build\n\nmake\n"),
)
.unwrap();
replace_config_md_section(&md, CLAUDE_MD_SECTION, "CLAUDE.md").unwrap();
let once = fs::read_to_string(&md).unwrap();
assert_eq!(once.matches(guard::BLOCK_START).count(), 1, "{once}");
assert_eq!(once.matches(guard::BLOCK_END).count(), 1);
assert!(once.ends_with("<!-- deciduous:end -->\n\n## Build\n\nmake\n"));
replace_config_md_section(&md, CLAUDE_MD_SECTION, "CLAUDE.md").unwrap();
assert_eq!(fs::read_to_string(&md).unwrap(), once);
}
#[test]
fn a_file_with_the_doubled_marker_is_repaired() {
// What 1.0.10's update left: the user's start marker, then a whole
// shipped section with its own markers.
let tmp = TempDir::new().unwrap();
let md = tmp.path().join("CLAUDE.md");
let (old, _) = shipped_section();
fs::write(
&md,
format!("# P\n\n<!-- deciduous:start -->\n{old}\n## Build\n"),
)
.unwrap();
replace_config_md_section(&md, CLAUDE_MD_SECTION, "CLAUDE.md").unwrap();
let body = fs::read_to_string(&md).unwrap();
assert_eq!(body, format!("# P\n\n{old}\n## Build\n"));
}
#[test]
fn user_text_after_a_legacy_section_without_a_heading_is_kept() {
// The span of an unmarked section runs to the next `## `; with user
// notes under `###` that span holds them too, so it is not a shipped
// text and nothing is replaced. (It used to be eaten to the end.)
let tmp = TempDir::new().unwrap();
let md = tmp.path().join("CLAUDE.md");
let (_, legacy) = shipped_section();
let text = format!("# My Project\n\n{legacy}\n\n### My Notes\n\nImportant.\n");
fs::write(&md, &text).unwrap();
replace_config_md_section(&md, CLAUDE_MD_SECTION, "CLAUDE.md").unwrap();
assert_eq!(fs::read_to_string(&md).unwrap(), text);
}
#[test]
fn test_replace_section_no_existing_section_appends() {
let tmp = TempDir::new().unwrap();
let md = tmp.path().join("CLAUDE.md");
fs::write(&md, "# My Project\n\nMy custom instructions.\n").unwrap();
let new_section = "<!-- deciduous:start -->\n## Decision Graph Workflow\n\nNew content.\n<!-- deciduous:end -->";
replace_config_md_section(&md, new_section, "CLAUDE.md").unwrap();
let result = fs::read_to_string(&md).unwrap();
assert!(
result.contains("My custom instructions."),
"Existing content preserved"
);
assert!(result.contains("New content."), "Section appended");
assert!(
result.contains("<!-- deciduous:start -->"),
"Start marker present"
);
}
#[test]
fn test_replace_section_file_does_not_exist_creates() {
let tmp = TempDir::new().unwrap();
let md = tmp.path().join("CLAUDE.md");
let new_section = "<!-- deciduous:start -->\n## Decision Graph Workflow\n\nContent.\n<!-- deciduous:end -->";
replace_config_md_section(&md, new_section, "CLAUDE.md").unwrap();
let result = fs::read_to_string(&md).unwrap();
assert!(result.contains("Content."));
assert!(result.contains("<!-- deciduous:start -->"));
assert!(result.contains("<!-- deciduous:end -->"));
}
#[test]
fn every_agent_facing_template_teaches_the_board() {
use crate::opencode as o;
use templates as t;
// (name, text, also has to name unanswered and forbid scratch files)
let all: &[(&str, &str, bool)] = &[
("CLAUDE_MD_SECTION", t::CLAUDE_MD_SECTION, true),
("DECISION_MD", t::DECISION_MD, true),
("RECOVER_MD", t::RECOVER_MD, true),
("WORK_MD", t::WORK_MD, true),
("CLAUDE_AGENTS_TOML", t::CLAUDE_AGENTS_TOML, true),
(
"WINDSURF_RULES_DECIDUOUS",
t::WINDSURF_RULES_DECIDUOUS,
true,
),
("SKILL_PULSE", t::SKILL_PULSE, true),
("SKILL_NARRATIVES", t::SKILL_NARRATIVES, true),
("SKILL_ARCHAEOLOGY", t::SKILL_ARCHAEOLOGY, true),
("DECISION_GRAPH_MD", t::DECISION_GRAPH_MD, true),
("SYNC_MD", t::SYNC_MD, false),
("DEMO_SWARM_MD", t::DEMO_SWARM_MD, false),
("HOOK_BOARD_MENTIONS", t::HOOK_BOARD_MENTIONS, false),
("opencode COMMAND_WORK", o::COMMAND_WORK, true),
("opencode COMMAND_RECOVER", o::COMMAND_RECOVER, true),
("opencode COMMAND_DECISION", o::COMMAND_DECISION, true),
("opencode COMMAND_SYNC", o::COMMAND_SYNC, false),
(
"opencode COMMAND_DECISION_GRAPH",
o::COMMAND_DECISION_GRAPH,
true,
),
("opencode SKILL_PULSE", o::SKILL_PULSE, true),
("opencode SKILL_NARRATIVES", o::SKILL_NARRATIVES, true),
("opencode SKILL_ARCHAEOLOGY", o::SKILL_ARCHAEOLOGY, true),
(
"opencode SKILL_PULSE_OPENCODE",
o::SKILL_PULSE_OPENCODE,
true,
),
(
"opencode SKILL_NARRATIVES_OPENCODE",
o::SKILL_NARRATIVES_OPENCODE,
true,
),
(
"opencode SKILL_ARCHAEOLOGY_OPENCODE",
o::SKILL_ARCHAEOLOGY_OPENCODE,
true,
),
("opencode AGENT_DECIDUOUS", o::AGENT_DECIDUOUS, true),
("opencode TOOL_DECIDUOUS", o::TOOL_DECIDUOUS, true),
("demo-swarm.zsh", crate::demo_swarm::SCRIPT, true),
];
let mut lost = Vec::new();
for (name, text, full) in all {
let board = text.contains("deciduous board")
|| text.contains("board post")
|| text.contains("post_message");
let full_ok = !full || (text.contains("unanswered") && text.contains("scratch"));
if !board || !full_ok {
lost.push(*name);
}
}
assert!(
lost.is_empty(),
"templates that no longer teach the board: {lost:?}"
);
// The swarm's record is the board; SendMessage is for interrupts.
assert!(crate::demo_swarm::SCRIPT.contains("for\n interrupts only"));
// The session checklist in CLAUDE.md reads the board.
assert!(t::CLAUDE_MD_SECTION.contains("deciduous board read --unanswered <label>"));
}
#[test]
fn board_hook_is_added_to_settings_once_and_the_users_hooks_stay() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".claude")).unwrap();
let p = tmp.path().join(".claude/settings.json");
let mine = r#"{
"permissions": {"allow": ["Bash(ls:*)"]},
"hooks": {
"UserPromptSubmit": [
{"hooks": [{"type": "command", "command": "my-own.sh"}]}
]
}
}
"#;
fs::write(&p, mine).unwrap();
ensure_board_hook_settings(&p).unwrap();
let once = fs::read_to_string(&p).unwrap();
let v: serde_json::Value = serde_json::from_str(&once).unwrap();
assert_eq!(v["permissions"]["allow"][0], "Bash(ls:*)");
let ups = v["hooks"]["UserPromptSubmit"].as_array().unwrap();
assert_eq!(ups[0]["hooks"][0]["command"], "my-own.sh");
assert!(ups[1]["hooks"][0]["command"]
.as_str()
.unwrap()
.ends_with("board-mentions.sh\" prompt"));
assert!(v["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.unwrap()
.ends_with("board-mentions.sh\" start"));
// The user's key order survives: permissions still comes first.
assert!(once.find("permissions").unwrap() < once.find("hooks").unwrap());
ensure_board_hook_settings(&p).unwrap();
assert_eq!(
fs::read_to_string(&p).unwrap(),
once,
"second run changed the file"
);
// "{}" (what earlier updates left once they stripped the logging
// hooks) gets both entries.
fs::write(&p, "{}\n").unwrap();
ensure_board_hook_settings(&p).unwrap();
let v: serde_json::Value = serde_json::from_str(&fs::read_to_string(&p).unwrap()).unwrap();
assert_eq!(v["hooks"].as_object().unwrap().len(), 2);
// Not JSON: left alone.
fs::write(&p, "{ nope").unwrap();
ensure_board_hook_settings(&p).unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "{ nope");
}
#[test]
fn the_settings_template_runs_the_board_hook() {
let v: serde_json::Value = serde_json::from_str(CLAUDE_SETTINGS_JSON).unwrap();
for (event, arg) in BOARD_HOOK_EVENTS {
let cmd = v["hooks"][event][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(
cmd.ends_with(&format!("board-mentions.sh\" {arg}")),
"{event}: {cmd}"
);
}
}
}