use edda_core::event::new_note_event;
use edda_derive::rebuild_all;
use edda_ledger::lock::WorkspaceLock;
use edda_ledger::paths::EddaPaths;
use edda_ledger::{ledger, Ledger};
use std::path::Path;
const SKILLS: &[(&str, &str)] = &[
("coord-sync", include_str!("skills/coord-sync.md")),
("coord-handoff", include_str!("skills/coord-handoff.md")),
("coord-request", include_str!("skills/coord-request.md")),
("coord-review", include_str!("skills/coord-review.md")),
];
pub fn execute(repo_root: &Path, no_hooks: bool, force_skills: bool) -> anyhow::Result<()> {
let paths = EddaPaths::discover(repo_root);
if paths.is_initialized() {
ledger::init_workspace(&paths)?;
ledger::init_head(&paths, "main")?;
println!("Already initialized at {}", paths.edda_dir.display());
} else {
ledger::init_workspace(&paths)?;
ledger::init_head(&paths, "main")?;
ledger::init_branches_json(&paths, "main")?;
let policy_path = paths.edda_dir.join("policy.yaml");
if !policy_path.exists() {
let default_policy = "\
version: 2
roles:
- lead
- reviewer
rules:
- id: require
when:
labels_any: [\"risk\", \"security\", \"prod\"]
failed_cmd: true
evidence_count_gte: 15
stages:
- stage_id: lead
role: lead
min_approvals: 1
max_assignees: 2
- id: default
when:
default: true
stages: []
# permissions:
# default: deny
# grants:
# - actions: [deploy, merge]
# roles: [lead]
# - actions: [read]
# roles: [\"*\"]
";
std::fs::write(&policy_path, default_policy.as_bytes())?;
}
let actors_path = paths.edda_dir.join("actors.yaml");
if !actors_path.exists() {
let default_actors = "\
version: 2
actors: {}
# Example:
# alice:
# kind: user
# roles: [lead, reviewer]
# email: alice@example.com
# claude-agent:
# kind: agent
# roles: [operator]
# runtime: claude
";
std::fs::write(&actors_path, default_actors.as_bytes())?;
}
let tier_path = paths.edda_dir.join("tool_tiers.yaml");
if !tier_path.exists() {
let default_config = edda_core::tool_tier::default_tool_tier_config();
edda_core::tool_tier::save_tool_tiers_to_dir(&paths.edda_dir, &default_config)?;
}
let ledger = Ledger::open(repo_root)?;
let _lock = WorkspaceLock::acquire(&ledger.paths)?;
let parent_hash = ledger.last_event_hash()?;
let event = new_note_event(
"main",
parent_hash.as_deref(),
"system",
"init edda workspace",
&[],
)?;
ledger.append_event(&event)?;
rebuild_all(&ledger)?;
println!("Initialized .edda/ (HEAD=main)");
println!(" {}", event.event_id);
}
if let Err(e) = edda_store::registry::register_project(repo_root) {
eprintln!("Warning: failed to register project: {e}");
}
if !no_hooks {
auto_install_bridges(repo_root);
}
if repo_root.join(".claude").is_dir() {
scaffold_skills(repo_root, force_skills);
}
match edda_store::skill_registry::scan_and_register(repo_root) {
Ok(0) => {} Ok(n) => println!("Registered {n} skill(s) in skill registry."),
Err(e) => eprintln!("Warning: skill scan failed: {e}"),
}
Ok(())
}
fn auto_install_bridges(repo_root: &Path) {
if repo_root.join(".claude").is_dir() {
println!("Detected Claude Code project, installing hooks...");
match edda_bridge_claude::install(repo_root, false) {
Ok(()) => {} Err(e) => eprintln!("Warning: Claude hook install failed: {e}"),
}
}
if repo_root.join(".openclaw").is_dir() {
println!("Detected OpenClaw project. Run 'edda setup openclaw' to enable edda hooks.");
}
}
fn scaffold_skills(repo_root: &Path, force: bool) {
let skills_dir = repo_root.join(".claude").join("skills");
for &(name, content) in SKILLS {
let dir = skills_dir.join(name);
let path = dir.join("SKILL.md");
if path.exists() && !force {
continue;
}
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("Warning: could not create {}: {e}", dir.display());
continue;
}
let already_existed = path.exists();
if let Err(e) = std::fs::write(&path, content) {
eprintln!("Warning: could not write {}: {e}", path.display());
continue;
}
if already_existed {
println!(" Updated skill: {name}");
} else {
println!(" Scaffolded skill: {name}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir() -> std::path::PathBuf {
let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let tmp = std::env::temp_dir().join(format!("edda_init_test_{}_{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
tmp
}
#[test]
fn init_detects_claude_and_installs_hooks() {
let tmp = temp_dir();
std::fs::create_dir_all(tmp.join(".claude")).unwrap();
execute(&tmp, false, false).unwrap();
assert!(tmp.join(".edda").is_dir());
let settings = tmp.join(".claude").join("settings.local.json");
assert!(settings.exists(), "settings.local.json should exist");
let content = std::fs::read_to_string(&settings).unwrap();
assert!(
content.contains("edda hook claude"),
"should contain edda hook"
);
assert!(
content.contains("mcpServers"),
"MCP server config should exist"
);
assert!(
content.contains(r#""command": "edda""#),
"edda MCP server should be configured"
);
assert!(tmp.join(".claude").join("CLAUDE.md").exists());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn init_no_hooks_skips_bridge() {
let tmp = temp_dir();
std::fs::create_dir_all(tmp.join(".claude")).unwrap();
execute(&tmp, true, false).unwrap();
assert!(tmp.join(".edda").is_dir());
assert!(
!tmp.join(".claude").join("settings.local.json").exists(),
"settings.local.json should not exist with --no-hooks"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn init_without_claude_dir_no_error() {
let tmp = temp_dir();
execute(&tmp, false, false).unwrap();
assert!(tmp.join(".edda").is_dir());
assert!(!tmp.join(".claude").join("settings.local.json").exists());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn reinit_also_installs_hooks() {
let tmp = temp_dir();
execute(&tmp, false, false).unwrap();
assert!(tmp.join(".edda").is_dir());
assert!(!tmp.join(".claude").join("settings.local.json").exists());
std::fs::create_dir_all(tmp.join(".claude")).unwrap();
execute(&tmp, false, false).unwrap();
let settings = tmp.join(".claude").join("settings.local.json");
assert!(settings.exists(), "hooks should be installed on re-init");
let content = std::fs::read_to_string(&settings).unwrap();
assert!(content.contains("edda hook claude"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn init_scaffolds_coord_skills() {
let tmp = temp_dir();
std::fs::create_dir_all(tmp.join(".claude")).unwrap();
execute(&tmp, true, false).unwrap();
for name in [
"coord-sync",
"coord-handoff",
"coord-request",
"coord-review",
] {
let path = tmp
.join(".claude")
.join("skills")
.join(name)
.join("SKILL.md");
assert!(path.exists(), "{name}/SKILL.md should be scaffolded");
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains(&format!("name: {name}")),
"{name} should have correct frontmatter"
);
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn init_skips_existing_skills() {
let tmp = temp_dir();
let skill_dir = tmp.join(".claude").join("skills").join("coord-sync");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "custom content").unwrap();
execute(&tmp, true, false).unwrap();
let content = std::fs::read_to_string(skill_dir.join("SKILL.md")).unwrap();
assert_eq!(
content, "custom content",
"should not overwrite existing skill"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn init_force_skills_overwrites() {
let tmp = temp_dir();
let skill_dir = tmp.join(".claude").join("skills").join("coord-sync");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "custom content").unwrap();
execute(&tmp, true, true).unwrap();
let content = std::fs::read_to_string(skill_dir.join("SKILL.md")).unwrap();
assert!(
content.contains("name: coord-sync"),
"should overwrite with embedded version"
);
let _ = std::fs::remove_dir_all(&tmp);
}
}