mod manifest;
mod merge;
mod python;
mod signing;
mod walkthrough;
use anyhow::{Context, Result};
use crossterm::style::Stylize;
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::Path;
use crate::db::Database;
use merge::{write_mcp_json_merged, write_root_gitignore, write_settings_json_merged};
pub use python::detect_python_prefix;
use python::{install_cpitd, CpitdResult};
use signing::setup_driver_signing;
use walkthrough::{apply_tui_choices, run_tui_walkthrough, setup_shell_alias};
const PYTHON_PREFIX_PLACEHOLDER: &str = "__PYTHON_PREFIX__";
const SETTINGS_JSON: &str = include_str!("../../../resources/claude/settings.json");
pub(crate) const PROMPT_GUARD_PY: &str =
include_str!("../../../resources/claude/hooks/prompt-guard.py");
pub(crate) const POST_EDIT_CHECK_PY: &str =
include_str!("../../../resources/claude/hooks/post-edit-check.py");
pub(crate) const SESSION_START_PY: &str =
include_str!("../../../resources/claude/hooks/session-start.py");
pub(crate) const PRE_WEB_CHECK_PY: &str =
include_str!("../../../resources/claude/hooks/pre-web-check.py");
pub(crate) const WORK_CHECK_PY: &str =
include_str!("../../../resources/claude/hooks/work-check.py");
pub(crate) const CROSSLINK_CONFIG_PY: &str =
include_str!("../../../resources/claude/hooks/crosslink_config.py");
pub(crate) const HEARTBEAT_PY: &str = include_str!("../../../resources/claude/hooks/heartbeat.py");
const SAFE_FETCH_SERVER_PY: &str =
include_str!("../../../resources/claude/mcp/safe-fetch-server.py");
const KNOWLEDGE_SERVER_PY: &str = include_str!("../../../resources/claude/mcp/knowledge-server.py");
const AGENT_PROMPT_SERVER_PY: &str =
include_str!("../../../resources/claude/mcp/agent-prompt-server.py");
const MCP_JSON: &str = include_str!("../../../resources/mcp.json");
include!(concat!(env!("OUT_DIR"), "/commands_gen.rs"));
pub(crate) use crate::commands::config_registry::HOOK_CONFIG_JSON;
include!(concat!(env!("OUT_DIR"), "/rules_gen.rs"));
use crate::commands::config_registry::{ConfigType, REGISTRY};
use std::collections::HashMap;
struct TuiChoices {
values: HashMap<String, serde_json::Value>,
install_alias: bool,
}
impl Default for TuiChoices {
fn default() -> Self {
let mut values = HashMap::new();
let defaults: serde_json::Value =
serde_json::from_str(HOOK_CONFIG_JSON).unwrap_or_default();
for entry in REGISTRY {
if matches!(
entry.config_type,
ConfigType::StringArray | ConfigType::Map | ConfigType::Integer
) {
continue;
}
if let Some(v) = defaults.get(entry.key) {
values.insert(entry.key.to_string(), v.clone());
}
}
Self {
values,
install_alias: false,
}
}
}
struct InitUI {
is_tty: bool,
}
impl InitUI {
fn new() -> Self {
Self {
is_tty: io::stdout().is_terminal(),
}
}
fn banner(&self) {
if self.is_tty {
println!();
println!(" {} {}", "crosslink".bold().cyan(), "init".dark_grey());
println!(" {}", "─".repeat(40).dark_grey());
println!();
}
}
fn step_start(&self, label: &str) {
if self.is_tty {
print!(" {} {}... ", "●".cyan(), label);
io::stdout().flush().ok();
} else {
print!("{label}... ");
}
}
fn step_ok(&self, detail: Option<&str>) {
if self.is_tty {
match detail {
Some(d) => println!("{} {}", "✓".green(), d.dark_grey()),
None => println!("{}", "✓".green()),
}
} else {
match detail {
Some(d) => println!("done ({d})"),
None => println!("done"),
}
}
}
fn step_created(&self, what: &str) {
if self.is_tty {
println!(
" {} {} {}",
"✓".green(),
"created".green(),
what.dark_grey()
);
} else {
println!("Created {what}");
}
}
fn step_skip(&self, msg: &str) {
if self.is_tty {
println!(" {} {}", "–".dark_grey(), msg.dark_grey());
} else {
println!("{msg}");
}
}
fn warn(&self, msg: &str) {
if self.is_tty {
println!(" {} {}", "⚠".yellow(), msg.yellow());
} else {
println!("Warning: {msg}");
}
}
fn detail(&self, msg: &str) {
if self.is_tty {
println!(" {}", msg.dark_grey());
} else {
println!(" {msg}");
}
}
fn success(&self) {
if self.is_tty {
println!();
println!(
" {} {}",
"✓".green().bold(),
"Crosslink initialized successfully!".bold()
);
println!();
println!(
" {} {} {}",
"next".dark_grey(),
"→".cyan(),
"crosslink session start".white()
);
println!(
" {} {}",
"→".cyan(),
"crosslink create \"Task\"".white()
);
println!();
} else {
println!("Crosslink initialized successfully!");
println!();
println!("Crosslink tracks issues, comments, and sessions in .crosslink/issues.db.");
println!("AI agents use it to coordinate work across sessions and worktrees.");
println!();
println!("Quick start:");
println!(" crosslink create \"Task\" # Create an issue");
println!(" crosslink list # See all issues");
println!(" crosslink session start # Start a work session");
println!();
println!("Multi-agent features (agents, signing, locks, containers) are optional");
println!("and only needed when multiple AI agents collaborate on the same repo.");
}
}
}
pub struct InitOpts<'a> {
pub force: bool,
pub update: bool,
pub dry_run: bool,
pub no_prompt: bool,
pub python_prefix: Option<&'a str>,
pub skip_cpitd: bool,
pub skip_signing: bool,
pub signing_key: Option<&'a str>,
pub reconfigure: bool,
pub defaults: bool,
}
fn ensure_repo_compact_id(crosslink_dir: &Path) -> Result<()> {
let id_path = crosslink_dir.join("repo-id");
if id_path.exists() {
return Ok(());
}
let id = crate::utils::generate_compact_id();
fs::write(&id_path, &id).context("Failed to write repo-id")?;
Ok(())
}
pub fn read_repo_compact_id(crosslink_dir: &Path) -> String {
let id_path = crosslink_dir.join("repo-id");
if let Ok(id) = fs::read_to_string(&id_path) {
let trimmed = id.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
crosslink_dir.hash(&mut hasher);
crate::utils::base62_encode_4(hasher.finish())
}
fn init_agent_identity(crosslink_dir: &Path, agent_id: &str) -> Result<()> {
let mut config = crate::identity::AgentConfig::init(crosslink_dir, agent_id, None)?;
let keys_dir = crosslink_dir.join("keys");
match crate::signing::generate_agent_key(&keys_dir, agent_id, &config.machine_id) {
Ok(keypair) => {
config.ssh_key_path = Some(format!("keys/{agent_id}_ed25519"));
config.ssh_fingerprint = Some(keypair.fingerprint);
config.ssh_public_key = Some(keypair.public_key);
let path = crosslink_dir.join("agent.json");
let json = serde_json::to_string_pretty(&config)?;
fs::write(&path, json)?;
}
Err(e) => {
tracing::warn!("Could not generate agent SSH key: {e}");
}
}
Ok(())
}
fn populate_agent_tool_commands(config_path: &Path, project_root: &Path) -> Result<()> {
if !config_path.exists() {
return Ok(());
}
let raw = fs::read_to_string(config_path)?;
let mut config: serde_json::Value = serde_json::from_str(&raw)?;
let Some(serde_json::Value::Object(overrides)) = config.get_mut("agent_overrides") else {
return Ok(());
};
let lint_empty = overrides
.get("agent_lint_commands")
.and_then(|v| v.as_array())
.is_none_or(Vec::is_empty);
let test_empty = overrides
.get("agent_test_commands")
.and_then(|v| v.as_array())
.is_none_or(Vec::is_empty);
if !lint_empty && !test_empty {
return Ok(()); }
let conv = super::kickoff::detect_conventions(project_root);
let changed = if lint_empty && !conv.lint_commands.is_empty() {
overrides.insert(
"agent_lint_commands".to_string(),
serde_json::json!(conv.lint_commands),
);
true
} else {
false
};
let changed = if test_empty {
conv.test_command.as_ref().map_or(changed, |test_cmd| {
overrides.insert(
"agent_test_commands".to_string(),
serde_json::json!([test_cmd]),
);
true
})
} else {
changed
};
if changed {
let output = serde_json::to_string_pretty(&config)?;
fs::write(config_path, format!("{output}\n"))?;
}
Ok(())
}
fn managed_files(python_prefix: &str) -> Vec<(String, String)> {
let mut files: Vec<(String, String)> = vec![
(
".claude/hooks/prompt-guard.py".into(),
PROMPT_GUARD_PY.into(),
),
(
".claude/hooks/post-edit-check.py".into(),
POST_EDIT_CHECK_PY.into(),
),
(
".claude/hooks/session-start.py".into(),
SESSION_START_PY.into(),
),
(
".claude/hooks/pre-web-check.py".into(),
PRE_WEB_CHECK_PY.into(),
),
(".claude/hooks/work-check.py".into(), WORK_CHECK_PY.into()),
(
".claude/hooks/crosslink_config.py".into(),
CROSSLINK_CONFIG_PY.into(),
),
(".claude/hooks/heartbeat.py".into(), HEARTBEAT_PY.into()),
(
".claude/mcp/safe-fetch-server.py".into(),
SAFE_FETCH_SERVER_PY.into(),
),
(
".claude/mcp/knowledge-server.py".into(),
KNOWLEDGE_SERVER_PY.into(),
),
(
".claude/mcp/agent-prompt-server.py".into(),
AGENT_PROMPT_SERVER_PY.into(),
),
];
for (filename, content) in COMMAND_FILES {
files.push((format!(".claude/commands/{filename}"), content.to_string()));
}
for (filename, content) in RULE_FILES {
files.push((format!(".crosslink/rules/{filename}"), content.to_string()));
}
let settings_template = SETTINGS_JSON.replace(PYTHON_PREFIX_PLACEHOLDER, python_prefix);
files.push((".claude/settings.json".into(), settings_template));
files
}
fn run_update(path: &Path, opts: &InitOpts<'_>) -> Result<()> {
use manifest::{
build_manifest, classify_update, read_manifest, sha256_file, sha256_hex, write_manifest,
UpdateAction,
};
let crosslink_dir = path.join(".crosslink");
let ui = InitUI::new();
if !crosslink_dir.exists() || !path.join(".claude").exists() {
anyhow::bail!(
"Project not initialized. Run `crosslink init` first, then use `--update` for upgrades."
);
}
let prefix = opts.python_prefix.map_or_else(
|| detect_python_prefix(path),
std::string::ToString::to_string,
);
let template_files = managed_files(&prefix);
let old_manifest = read_manifest(&crosslink_dir);
let manifest_missing = old_manifest.is_none();
if manifest_missing {
ui.warn(
"No init-manifest.json found — treating all managed files as potentially modified.",
);
ui.detail("This is expected on first upgrade from a pre-manifest crosslink version.");
ui.detail("Use `crosslink init --force` instead to overwrite all managed files.");
println!();
}
ui.banner();
let old_files = old_manifest
.as_ref()
.map(|m| &m.files)
.cloned()
.unwrap_or_default();
let mut auto_updated: Vec<String> = Vec::new();
let mut up_to_date: Vec<String> = Vec::new();
let mut template_unchanged: Vec<String> = Vec::new();
let mut conflicts: Vec<String> = Vec::new();
let mut deleted: Vec<String> = Vec::new();
let mut new_files: Vec<String> = Vec::new();
for (rel_path, template_content) in &template_files {
let abs_path = path.join(rel_path);
let new_template_hash = sha256_hex(template_content);
match old_files.get(rel_path) {
Some(entry) => {
let current_hash = sha256_file(&abs_path)?;
let action =
classify_update(&entry.sha256, current_hash.as_deref(), &new_template_hash);
match action {
UpdateAction::UpToDate => up_to_date.push(rel_path.clone()),
UpdateAction::AutoUpdate => auto_updated.push(rel_path.clone()),
UpdateAction::TemplateUnchanged => {
template_unchanged.push(rel_path.clone());
}
UpdateAction::Conflict => conflicts.push(rel_path.clone()),
UpdateAction::Deleted => deleted.push(rel_path.clone()),
UpdateAction::NewFile => unreachable!(),
}
}
None => {
new_files.push(rel_path.clone());
}
}
}
let total_changes = auto_updated.len() + new_files.len();
let has_issues = !conflicts.is_empty() || !deleted.is_empty();
if total_changes == 0 && !has_issues {
ui.step_skip("All managed files are up to date.");
return Ok(());
}
if !auto_updated.is_empty() {
ui.step_start(&format!(
"{} file{} to auto-update",
auto_updated.len(),
if auto_updated.len() == 1 { "" } else { "s" }
));
println!();
for f in &auto_updated {
ui.detail(f);
}
}
if !new_files.is_empty() {
ui.step_start(&format!(
"{} new file{} to create",
new_files.len(),
if new_files.len() == 1 { "" } else { "s" }
));
println!();
for f in &new_files {
ui.detail(f);
}
}
if !conflicts.is_empty() {
ui.warn(&format!(
"{} file{} modified by both user and template — {}",
conflicts.len(),
if conflicts.len() == 1 { "" } else { "s" },
if opts.no_prompt {
"skipping (--no-prompt)"
} else {
"will prompt"
}
));
for f in &conflicts {
ui.detail(f);
}
}
for f in &deleted {
ui.detail(&format!(
"{f} — deleted by user, skipping (will not recreate)"
));
}
if !template_unchanged.is_empty() || !up_to_date.is_empty() {
let skip_count = template_unchanged.len() + up_to_date.len();
ui.step_skip(&format!(
"{skip_count} file{} already up to date",
if skip_count == 1 { "" } else { "s" }
));
}
if opts.dry_run {
println!();
ui.detail("Dry run — no files were modified.");
return Ok(());
}
let template_map: std::collections::HashMap<&str, &str> = template_files
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
for rel_path in &auto_updated {
let abs_path = path.join(rel_path);
let content = template_map[rel_path.as_str()];
if rel_path == ".claude/settings.json" {
write_settings_json_merged(&abs_path, &prefix)?;
} else {
if let Some(parent) = abs_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&abs_path, content).with_context(|| format!("Failed to write {rel_path}"))?;
}
}
for rel_path in &new_files {
let abs_path = path.join(rel_path);
let content = template_map[rel_path.as_str()];
if rel_path == ".claude/settings.json" {
write_settings_json_merged(&abs_path, &prefix)?;
} else {
if let Some(parent) = abs_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&abs_path, content).with_context(|| format!("Failed to write {rel_path}"))?;
}
}
let mut conflict_accepted: Vec<String> = Vec::new();
if !opts.no_prompt {
let is_tty = io::stdin().is_terminal();
for rel_path in &conflicts {
if !is_tty {
ui.detail(&format!("Skipping {rel_path} (non-interactive terminal)"));
continue;
}
print!(" Overwrite {rel_path} with new template? (user changes will be lost) [y/N] ");
io::stdout().flush().ok();
let mut answer = String::new();
io::stdin().read_line(&mut answer).ok();
if answer.trim().eq_ignore_ascii_case("y") {
let abs_path = path.join(rel_path);
let content = template_map[rel_path.as_str()];
if rel_path == ".claude/settings.json" {
write_settings_json_merged(&abs_path, &prefix)?;
} else {
fs::write(&abs_path, content)
.with_context(|| format!("Failed to write {rel_path}"))?;
}
conflict_accepted.push(rel_path.clone());
} else {
ui.detail(&format!("Keeping user version of {rel_path}"));
}
}
}
let new_full_manifest = build_manifest(&template_files);
let mut final_manifest = new_full_manifest;
for rel_path in &conflicts {
if !conflict_accepted.contains(rel_path) {
if let Some(old_entry) = old_files.get(rel_path) {
final_manifest
.files
.insert(rel_path.clone(), old_entry.clone());
}
}
}
for rel_path in &deleted {
final_manifest.files.remove(rel_path);
}
for rel_path in &template_unchanged {
if let Some(old_entry) = old_files.get(rel_path) {
final_manifest
.files
.insert(rel_path.clone(), old_entry.clone());
}
}
write_manifest(&crosslink_dir, &final_manifest)?;
let total_written = auto_updated.len() + new_files.len() + conflict_accepted.len();
if total_written > 0 {
ui.step_ok(Some(&format!(
"{total_written} file{} updated",
if total_written == 1 { "" } else { "s" }
)));
}
Ok(())
}
pub fn run(path: &Path, opts: &InitOpts<'_>) -> Result<()> {
if opts.update {
return run_update(path, opts);
}
let force = opts.force;
let python_prefix = opts.python_prefix;
let skip_cpitd = opts.skip_cpitd;
let skip_signing = opts.skip_signing;
let signing_key = opts.signing_key;
let reconfigure = opts.reconfigure;
let defaults = opts.defaults;
let crosslink_dir = path.join(".crosslink");
let claude_dir = path.join(".claude");
let hooks_dir = claude_dir.join("hooks");
let ui = InitUI::new();
let git_dir = path.join(".git");
if !git_dir.exists() {
anyhow::bail!(
"No git repository found at {}.\n\
Run `git init` and create an initial commit before running `crosslink init`.",
path.display()
);
}
let has_commits = std::process::Command::new("git")
.current_dir(path)
.args(["rev-parse", "HEAD"])
.output()
.is_ok_and(|o| o.status.success());
if !has_commits {
anyhow::bail!(
"Git repository has no commits.\n\
Create an initial commit before running `crosslink init`:\n\
\n git add .\n git commit -m \"Initial commit\""
);
}
let crosslink_exists = crosslink_dir.exists();
let claude_exists = claude_dir.exists();
if crosslink_exists && claude_exists && !force && !reconfigure {
ui.step_skip("Already initialized");
ui.detail("Use --force to update hooks to latest version.");
ui.detail("Use --reconfigure to re-run the setup walkthrough.");
return Ok(());
}
let config_path = crosslink_dir.join("hook-config.json");
let config_exists = config_path.exists();
let should_run_tui = !defaults && (!config_exists || force || reconfigure);
let tui_result = if should_run_tui {
let base_config: serde_json::Value = if config_exists && reconfigure {
let raw = fs::read_to_string(&config_path)
.context("Failed to read existing hook-config.json")?;
serde_json::from_str(&raw).context("hook-config.json contains invalid JSON")?
} else {
serde_json::from_str(HOOK_CONFIG_JSON)
.context("Embedded hook-config.json is invalid")?
};
let existing_ref = if config_exists {
Some(&base_config as &serde_json::Value)
} else {
None
};
let choices = run_tui_walkthrough(existing_ref)?;
Some((base_config, choices))
} else {
None
};
if opts.dry_run {
let ui = InitUI::new();
ui.banner();
let prefix = python_prefix.map_or_else(
|| detect_python_prefix(path),
std::string::ToString::to_string,
);
let files = managed_files(&prefix);
let mut would_write: Vec<&str> = Vec::new();
let mut would_create: Vec<&str> = Vec::new();
for (rel_path, _) in &files {
if path.join(rel_path).exists() {
would_write.push(rel_path);
} else {
would_create.push(rel_path);
}
}
let extra = [".mcp.json", ".crosslink/hook-config.json", ".gitignore"];
for f in &extra {
if path.join(f).exists() {
would_write.push(f);
} else {
would_create.push(f);
}
}
if !would_write.is_empty() {
ui.step_start(&format!(
"{} file{} to overwrite",
would_write.len(),
if would_write.len() == 1 { "" } else { "s" }
));
println!();
for f in &would_write {
ui.detail(f);
}
}
if !would_create.is_empty() {
ui.step_start(&format!(
"{} new file{} to create",
would_create.len(),
if would_create.len() == 1 { "" } else { "s" }
));
println!();
for f in &would_create {
ui.detail(f);
}
}
println!();
ui.detail("Dry run — no files were modified.");
return Ok(());
}
ui.banner();
let rules_dir = crosslink_dir.join("rules");
if !crosslink_exists {
ui.step_start("Initializing database");
fs::create_dir_all(&crosslink_dir).context("Failed to create .crosslink directory")?;
let db_path = crosslink_dir.join("issues.db");
Database::open(&db_path)?;
ui.step_ok(None);
}
let tui_choices = match tui_result {
Some((mut config, choices)) => {
apply_tui_choices(&mut config, &choices)?;
let output = serde_json::to_string_pretty(&config)
.context("Failed to serialize hook-config.json")?;
fs::write(&config_path, format!("{output}\n"))
.context("Failed to write hook-config.json")?;
ui.step_created("hook-config.json");
Some(choices)
}
None if !config_exists || force => {
fs::write(&config_path, HOOK_CONFIG_JSON)
.context("Failed to write hook-config.json")?;
ui.step_created("hook-config.json");
None
}
None => None,
};
ensure_repo_compact_id(&crosslink_dir)?;
populate_agent_tool_commands(&config_path, path)?;
let crosslink_gitignore = crosslink_dir.join(".gitignore");
if !crosslink_gitignore.exists() || force {
fs::write(
&crosslink_gitignore,
"# Multi-agent collaboration (machine-local)\n\
agent.json\n\
repo-id\n\
.hub-cache/\n\
.knowledge-cache/\n\
keys/\n\
integrations/\n\
\n\
# Machine-local overrides\n\
hook-config.local.json\n\
rules.local/\n",
)
.context("Failed to write .crosslink/.gitignore")?;
}
ui.step_start("Configuring .gitignore");
write_root_gitignore(path).context("Failed to update root .gitignore")?;
ui.step_ok(None);
let rules_exist = rules_dir.exists();
if !rules_exist || force {
ui.step_start("Deploying rules");
fs::create_dir_all(&rules_dir).context("Failed to create .crosslink/rules directory")?;
for (filename, content) in RULE_FILES {
fs::write(rules_dir.join(filename), content)
.with_context(|| format!("Failed to write {filename}"))?;
}
if force && rules_exist {
ui.step_ok(Some("updated"));
} else {
ui.step_ok(Some(&format!("{} files", RULE_FILES.len())));
}
}
let rules_local_dir = crosslink_dir.join("rules.local");
if !rules_local_dir.exists() {
fs::create_dir_all(&rules_local_dir)
.context("Failed to create .crosslink/rules.local directory")?;
}
let prefix = python_prefix.map_or_else(
|| detect_python_prefix(path),
std::string::ToString::to_string,
);
if !claude_exists || force {
ui.step_start("Setting up Claude Code hooks");
fs::create_dir_all(&hooks_dir).context("Failed to create .claude/hooks directory")?;
write_settings_json_merged(&claude_dir.join("settings.json"), &prefix)
.context("Failed to write settings.json")?;
fs::write(hooks_dir.join("prompt-guard.py"), PROMPT_GUARD_PY)
.context("Failed to write prompt-guard.py")?;
fs::write(hooks_dir.join("post-edit-check.py"), POST_EDIT_CHECK_PY)
.context("Failed to write post-edit-check.py")?;
fs::write(hooks_dir.join("session-start.py"), SESSION_START_PY)
.context("Failed to write session-start.py")?;
fs::write(hooks_dir.join("pre-web-check.py"), PRE_WEB_CHECK_PY)
.context("Failed to write pre-web-check.py")?;
fs::write(hooks_dir.join("work-check.py"), WORK_CHECK_PY)
.context("Failed to write work-check.py")?;
fs::write(hooks_dir.join("crosslink_config.py"), CROSSLINK_CONFIG_PY)
.context("Failed to write crosslink_config.py")?;
fs::write(hooks_dir.join("heartbeat.py"), HEARTBEAT_PY)
.context("Failed to write heartbeat.py")?;
let mcp_dir = claude_dir.join("mcp");
fs::create_dir_all(&mcp_dir).context("Failed to create .claude/mcp directory")?;
fs::write(mcp_dir.join("safe-fetch-server.py"), SAFE_FETCH_SERVER_PY)
.context("Failed to write safe-fetch-server.py")?;
fs::write(mcp_dir.join("knowledge-server.py"), KNOWLEDGE_SERVER_PY)
.context("Failed to write knowledge-server.py")?;
fs::write(
mcp_dir.join("agent-prompt-server.py"),
AGENT_PROMPT_SERVER_PY,
)
.context("Failed to write agent-prompt-server.py")?;
let commands_dir = claude_dir.join("commands");
fs::create_dir_all(&commands_dir).context("Failed to create .claude/commands directory")?;
for (filename, content) in COMMAND_FILES {
fs::write(commands_dir.join(filename), content)
.with_context(|| format!("Failed to write {filename}"))?;
}
let warnings =
write_mcp_json_merged(&path.join(".mcp.json")).context("Failed to write .mcp.json")?;
for warning in &warnings {
ui.warn(warning);
}
if force && claude_exists {
ui.step_ok(Some("updated"));
} else {
ui.step_ok(None);
}
}
{
let manifest_files = managed_files(&prefix);
let m = manifest::build_manifest(&manifest_files);
manifest::write_manifest(&crosslink_dir, &m)
.context("Failed to write init-manifest.json")?;
}
if !skip_cpitd {
ui.step_start("Checking cpitd");
match install_cpitd(&prefix) {
Ok(CpitdResult::InstalledFromPypi) => ui.step_ok(Some("installed")),
Ok(CpitdResult::InstalledFromSource) => ui.step_ok(Some("installed from source")),
Ok(CpitdResult::AlreadyInstalled) => ui.step_ok(Some("already installed")),
Err(e) => {
println!();
ui.warn(&format!("Could not auto-install cpitd: {e}"));
ui.detail("You can install it manually: pip install cpitd");
}
}
}
if !skip_signing {
setup_driver_signing(path, signing_key, &ui)?;
}
if crate::identity::AgentConfig::load(&crosslink_dir)?.is_none() {
let agent_id = crate::utils::generate_compact_id();
ui.step_start("Initializing agent identity");
match init_agent_identity(&crosslink_dir, &agent_id) {
Ok(()) => ui.step_ok(Some(&agent_id)),
Err(e) => {
println!();
ui.warn(&format!("Could not auto-initialize agent: {e}"));
ui.detail("Run `crosslink agent init <id>` manually to enable signing.");
}
}
}
if let Some(ref choices) = tui_choices {
setup_shell_alias(&ui, choices);
}
ui.success();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use merge::{GITIGNORE_SECTION_END, GITIGNORE_SECTION_START};
use tempfile::tempdir;
fn test_dir() -> tempfile::TempDir {
let dir = tempdir().unwrap();
let init = std::process::Command::new("git")
.current_dir(dir.path())
.args(["init"])
.output()
.expect("git init failed");
assert!(init.status.success(), "git init failed");
let commit = std::process::Command::new("git")
.current_dir(dir.path())
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.output()
.expect("git commit failed");
assert!(commit.status.success(), "git commit --allow-empty failed");
dir
}
fn test_opts(force: bool) -> InitOpts<'static> {
InitOpts {
force,
update: false,
dry_run: false,
no_prompt: false,
python_prefix: None,
skip_cpitd: true,
skip_signing: true,
signing_key: None,
reconfigure: false,
defaults: true,
}
}
#[test]
fn test_run_fresh_init() {
let dir = test_dir();
let result = run(dir.path(), &test_opts(false));
assert!(result.is_ok());
assert!(dir.path().join(".crosslink").exists());
assert!(dir.path().join(".crosslink/rules").exists());
assert!(dir.path().join(".crosslink/issues.db").exists());
assert!(dir.path().join(".claude").exists());
assert!(dir.path().join(".claude/hooks").exists());
assert!(dir.path().join(".claude/mcp").exists());
assert!(dir.path().join(".crosslink/hook-config.json").exists());
}
#[test]
fn test_run_creates_hook_files() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
assert!(dir.path().join(".claude/settings.json").exists());
assert!(dir.path().join(".claude/hooks/prompt-guard.py").exists());
assert!(dir.path().join(".claude/hooks/post-edit-check.py").exists());
assert!(dir.path().join(".claude/hooks/session-start.py").exists());
assert!(dir.path().join(".claude/hooks/pre-web-check.py").exists());
assert!(dir.path().join(".claude/hooks/work-check.py").exists());
assert!(dir
.path()
.join(".claude/hooks/crosslink_config.py")
.exists());
assert!(dir.path().join(".claude/mcp/safe-fetch-server.py").exists());
assert!(dir.path().join(".claude/mcp/knowledge-server.py").exists());
assert!(dir
.path()
.join(".claude/mcp/agent-prompt-server.py")
.exists());
assert!(dir.path().join(".mcp.json").exists());
}
#[test]
fn test_run_creates_rule_files() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let rules_dir = dir.path().join(".crosslink/rules");
assert!(rules_dir.join("global.md").exists());
assert!(rules_dir.join("project.md").exists());
assert!(rules_dir.join("rust.md").exists());
assert!(rules_dir.join("python.md").exists());
assert!(rules_dir.join("javascript.md").exists());
assert!(rules_dir.join("typescript.md").exists());
assert!(rules_dir.join("tracking-strict.md").exists());
assert!(rules_dir.join("tracking-normal.md").exists());
assert!(rules_dir.join("tracking-relaxed.md").exists());
}
#[test]
fn test_run_already_initialized_no_force() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let result = run(dir.path(), &test_opts(false));
assert!(result.is_ok());
}
#[test]
fn test_run_force_update() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let hook_path = dir.path().join(".claude/hooks/prompt-guard.py");
fs::write(&hook_path, "# modified").unwrap();
run(dir.path(), &test_opts(true)).unwrap();
let content = fs::read_to_string(&hook_path).unwrap();
assert_ne!(content, "# modified");
assert!(content.contains("python") || content.contains("def") || content.len() > 20);
}
fn embedded_mcp_keys() -> Vec<String> {
let embedded: serde_json::Value = serde_json::from_str(MCP_JSON).unwrap();
embedded["mcpServers"]
.as_object()
.unwrap()
.keys()
.cloned()
.collect()
}
#[test]
fn test_force_init_preserves_existing_mcp_servers() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let mcp_path = dir.path().join(".mcp.json");
let mut content: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&mcp_path).unwrap()).unwrap();
content["mcpServers"]["my-custom-server"] = serde_json::json!({
"command": "node",
"args": ["my-server.js"]
});
fs::write(&mcp_path, serde_json::to_string_pretty(&content).unwrap()).unwrap();
run(dir.path(), &test_opts(true)).unwrap();
let result: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&mcp_path).unwrap()).unwrap();
let servers = result["mcpServers"].as_object().unwrap();
for key in embedded_mcp_keys() {
assert!(
servers.contains_key(&key),
"embedded key \"{key}\" should exist"
);
}
assert!(
servers.contains_key("my-custom-server"),
"custom server should be preserved"
);
assert_eq!(
servers["my-custom-server"]["command"].as_str().unwrap(),
"node"
);
}
#[test]
fn test_force_init_returns_warnings_for_overwritten_keys() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let mcp_path = dir.path().join(".mcp.json");
let warnings = write_mcp_json_merged(&mcp_path).unwrap();
let expected_keys = embedded_mcp_keys();
assert_eq!(
warnings.len(),
expected_keys.len(),
"should warn once per embedded key"
);
for key in &expected_keys {
assert!(
warnings.iter().any(|w| w.contains(key)),
"should warn about overwriting \"{key}\""
);
}
}
#[test]
fn test_write_mcp_json_merged_creates_fresh_file() {
let dir = test_dir();
let mcp_path = dir.path().join(".mcp.json");
assert!(!mcp_path.exists());
let warnings = write_mcp_json_merged(&mcp_path).unwrap();
assert!(
warnings.is_empty(),
"fresh creation should produce no warnings"
);
let content: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&mcp_path).unwrap()).unwrap();
let servers = content["mcpServers"].as_object().unwrap();
let expected_keys = embedded_mcp_keys();
assert_eq!(servers.len(), expected_keys.len());
for key in &expected_keys {
assert!(
servers.contains_key(key),
"fresh file should contain \"{key}\""
);
}
}
#[test]
fn test_force_init_fails_on_malformed_mcp_json() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let mcp_path = dir.path().join(".mcp.json");
fs::write(&mcp_path, "not json {{{").unwrap();
let result = run(dir.path(), &test_opts(true));
assert!(result.is_err());
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("invalid JSON"),
"Error should mention invalid JSON, got: {err}"
);
let content = fs::read_to_string(&mcp_path).unwrap();
assert_eq!(content, "not json {{{");
}
#[test]
fn test_force_init_fails_on_non_object_mcp_json() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let mcp_path = dir.path().join(".mcp.json");
fs::write(&mcp_path, "[1, 2, 3]").unwrap();
let result = run(dir.path(), &test_opts(true));
assert!(result.is_err());
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("not a JSON object"),
"Error should mention not a JSON object, got: {err}"
);
let content = fs::read_to_string(&mcp_path).unwrap();
assert_eq!(content, "[1, 2, 3]");
}
#[test]
fn test_force_init_handles_empty_mcp_json_file() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let mcp_path = dir.path().join(".mcp.json");
fs::write(&mcp_path, "").unwrap();
let result = run(dir.path(), &test_opts(true));
assert!(result.is_err());
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("invalid JSON"),
"Error should mention invalid JSON, got: {err}"
);
}
#[test]
fn test_force_init_fails_on_non_object_mcp_servers_value() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let mcp_path = dir.path().join(".mcp.json");
fs::write(&mcp_path, r#"{"mcpServers": "banana"}"#).unwrap();
let result = run(dir.path(), &test_opts(true));
assert!(result.is_err());
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("non-object mcpServers"),
"Error should mention non-object mcpServers, got: {err}"
);
let content = fs::read_to_string(&mcp_path).unwrap();
assert_eq!(content, r#"{"mcpServers": "banana"}"#);
}
#[test]
fn test_init_merges_into_mcp_json_without_mcp_servers_key() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let mcp_path = dir.path().join(".mcp.json");
fs::write(&mcp_path, r#"{"someOtherKey": true}"#).unwrap();
run(dir.path(), &test_opts(true)).unwrap();
let content = fs::read_to_string(&mcp_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(parsed["someOtherKey"], true);
assert!(parsed["mcpServers"]["crosslink-safe-fetch"].is_object());
assert!(parsed["mcpServers"]["crosslink-knowledge"].is_object());
}
#[test]
fn test_run_partial_init_crosslink_only() {
let dir = test_dir();
fs::create_dir_all(dir.path().join(".crosslink")).unwrap();
let result = run(dir.path(), &test_opts(false));
assert!(result.is_ok());
assert!(dir.path().join(".claude").exists());
}
#[test]
fn test_run_partial_init_claude_only() {
let dir = test_dir();
fs::create_dir_all(dir.path().join(".claude")).unwrap();
let result = run(dir.path(), &test_opts(false));
assert!(result.is_ok());
assert!(dir.path().join(".crosslink").exists());
}
#[test]
fn test_run_database_usable() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let db_path = dir.path().join(".crosslink/issues.db");
let db = Database::open(&db_path).unwrap();
let id = db.create_issue("Test issue", None, "medium").unwrap();
assert!(id > 0);
}
#[test]
fn test_run_rule_files_not_empty() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let rules_dir = dir.path().join(".crosslink/rules");
let global = fs::read_to_string(rules_dir.join("global.md")).unwrap();
assert!(!global.is_empty());
let rust = fs::read_to_string(rules_dir.join("rust.md")).unwrap();
assert!(!rust.is_empty());
}
#[test]
fn test_run_force_updates_rules() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let rule_path = dir.path().join(".crosslink/rules/global.md");
fs::write(&rule_path, "# modified rule").unwrap();
run(dir.path(), &test_opts(true)).unwrap();
let content = fs::read_to_string(&rule_path).unwrap();
assert_ne!(content, "# modified rule");
}
#[test]
fn test_run_idempotent_with_force() {
let dir = test_dir();
for _ in 0..3 {
let result = run(dir.path(), &test_opts(true));
assert!(result.is_ok());
}
assert!(dir.path().join(".crosslink/issues.db").exists());
assert!(dir.path().join(".claude/settings.json").exists());
}
#[test]
#[allow(clippy::const_is_empty)]
fn test_embedded_constants_not_empty() {
assert!(!SETTINGS_JSON.is_empty());
assert!(!PROMPT_GUARD_PY.is_empty());
assert!(!POST_EDIT_CHECK_PY.is_empty());
assert!(!SESSION_START_PY.is_empty());
assert!(!PRE_WEB_CHECK_PY.is_empty());
assert!(!WORK_CHECK_PY.is_empty());
assert!(!CROSSLINK_CONFIG_PY.is_empty());
assert!(!HEARTBEAT_PY.is_empty());
assert!(!SAFE_FETCH_SERVER_PY.is_empty());
assert!(!KNOWLEDGE_SERVER_PY.is_empty());
assert!(!AGENT_PROMPT_SERVER_PY.is_empty());
assert!(!MCP_JSON.is_empty());
assert!(
COMMAND_FILES.len() >= 11,
"Expected at least 11 command files, found {}",
COMMAND_FILES.len()
);
for (filename, content) in COMMAND_FILES {
assert!(!content.is_empty(), "Command file {filename} is empty");
}
assert!(!RULE_SANITIZE_PATTERNS.is_empty());
assert!(!HOOK_CONFIG_JSON.is_empty());
assert!(!RULE_TRACKING_STRICT.is_empty());
assert!(!RULE_TRACKING_NORMAL.is_empty());
assert!(!RULE_TRACKING_RELAXED.is_empty());
assert!(!RULE_GLOBAL.is_empty());
assert!(!RULE_RUST.is_empty());
}
#[test]
fn test_rule_files_count() {
assert!(RULE_FILES.len() >= 20);
for (name, content) in RULE_FILES {
assert!(!name.is_empty(), "Rule file name should not be empty");
assert!(!content.is_empty(), "Rule file {name} should not be empty");
}
}
#[test]
fn test_gitignore_includes_local_config() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".crosslink/.gitignore")).unwrap();
assert!(content.contains("agent.json"));
assert!(content.contains(".hub-cache/"));
assert!(content.contains("hook-config.local.json"));
}
#[test]
fn test_detect_python_prefix_default() {
let dir = test_dir();
assert_eq!(detect_python_prefix(dir.path()), "python3");
}
#[test]
fn test_detect_python_prefix_uv_lock() {
let dir = test_dir();
fs::write(dir.path().join("uv.lock"), "").unwrap();
assert_eq!(detect_python_prefix(dir.path()), "uv run python3");
}
#[test]
fn test_detect_python_prefix_uv_pyproject() {
let dir = test_dir();
fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"foo\"\n\n[tool.uv]\ndev-dependencies = []\n",
)
.unwrap();
assert_eq!(detect_python_prefix(dir.path()), "uv run python3");
}
#[test]
fn test_detect_python_prefix_poetry_lock() {
let dir = test_dir();
fs::write(dir.path().join("poetry.lock"), "").unwrap();
assert_eq!(detect_python_prefix(dir.path()), "poetry run python3");
}
#[test]
fn test_detect_python_prefix_poetry_pyproject() {
let dir = test_dir();
fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"foo\"\n\n[tool.poetry]\nname = \"foo\"\n",
)
.unwrap();
assert_eq!(detect_python_prefix(dir.path()), "poetry run python3");
}
#[test]
fn test_detect_python_prefix_venv() {
let dir = test_dir();
fs::create_dir(dir.path().join(".venv")).unwrap();
assert_eq!(detect_python_prefix(dir.path()), ".venv/bin/python3");
}
#[test]
fn test_detect_python_prefix_pipenv() {
let dir = test_dir();
fs::write(dir.path().join("Pipfile"), "").unwrap();
assert_eq!(detect_python_prefix(dir.path()), "pipenv run python3");
}
#[test]
fn test_detect_python_prefix_pipenv_lock() {
let dir = test_dir();
fs::write(dir.path().join("Pipfile.lock"), "{}").unwrap();
assert_eq!(detect_python_prefix(dir.path()), "pipenv run python3");
}
#[test]
fn test_detect_python_prefix_uv_beats_poetry() {
let dir = test_dir();
fs::write(dir.path().join("uv.lock"), "").unwrap();
fs::write(dir.path().join("poetry.lock"), "").unwrap();
assert_eq!(detect_python_prefix(dir.path()), "uv run python3");
}
#[test]
fn test_detect_python_prefix_poetry_beats_venv() {
let dir = test_dir();
fs::write(dir.path().join("poetry.lock"), "").unwrap();
fs::create_dir(dir.path().join(".venv")).unwrap();
assert_eq!(detect_python_prefix(dir.path()), "poetry run python3");
}
#[test]
fn test_detect_python_prefix_venv_beats_pipenv() {
let dir = test_dir();
fs::create_dir(dir.path().join(".venv")).unwrap();
fs::write(dir.path().join("Pipfile"), "").unwrap();
assert_eq!(detect_python_prefix(dir.path()), ".venv/bin/python3");
}
#[test]
fn test_detect_python_prefix_pyproject_without_tools_is_default() {
let dir = test_dir();
fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"foo\"\nversion = \"1.0\"\n",
)
.unwrap();
assert_eq!(detect_python_prefix(dir.path()), "python3");
}
#[test]
fn test_settings_json_default_uses_python3() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
assert!(
content.contains("python3"),
"Default init should use python3 in settings.json"
);
assert!(
!content.contains(PYTHON_PREFIX_PLACEHOLDER),
"Placeholder should be replaced"
);
}
#[test]
fn test_settings_json_uv_project() {
let dir = test_dir();
fs::write(dir.path().join("uv.lock"), "").unwrap();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
assert!(
content.contains("uv run python3"),
"uv project should use 'uv run python3' in settings.json"
);
}
#[test]
fn test_settings_json_cli_override() {
let dir = test_dir();
fs::write(dir.path().join("uv.lock"), "").unwrap();
run(
dir.path(),
&InitOpts {
python_prefix: Some("custom-python"),
..test_opts(false)
},
)
.unwrap();
let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
assert!(
content.contains("custom-python"),
"CLI override should be used in settings.json"
);
assert!(
!content.contains("uv run python3"),
"Auto-detected prefix should not appear when overridden"
);
}
#[test]
fn test_settings_json_produces_valid_json() {
let dir = test_dir();
fs::write(dir.path().join("uv.lock"), "").unwrap();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&content);
assert!(
parsed.is_ok(),
"Settings JSON should be valid after templating"
);
}
#[test]
fn test_force_re_detects_toolchain() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
assert!(content.contains("python3 \\\"$HOOK\\\""));
fs::write(dir.path().join("uv.lock"), "").unwrap();
run(dir.path(), &test_opts(true)).unwrap();
let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
assert!(
content.contains("uv run python3"),
"Force re-init should re-detect toolchain"
);
}
fn embedded_allowed_tools() -> Vec<String> {
let template: serde_json::Value = serde_json::from_str(SETTINGS_JSON).unwrap();
template
.get("allowedTools")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
#[test]
fn test_settings_json_includes_allowed_tools() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
let tools = parsed["allowedTools"]
.as_array()
.expect("allowedTools should be an array");
for expected in embedded_allowed_tools() {
assert!(
tools.iter().any(|v| v.as_str() == Some(&expected)),
"allowedTools should contain \"{expected}\""
);
}
}
#[test]
fn test_settings_json_includes_tmux_and_worktree_permissions() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
let tools: Vec<&str> = parsed["allowedTools"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(
tools.contains(&"Bash(tmux *)"),
"allowedTools should include tmux permission"
);
assert!(
tools.contains(&"Bash(git worktree *)"),
"allowedTools should include git worktree permission"
);
}
#[test]
fn test_force_init_preserves_user_allowed_tools() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let settings_path = dir.path().join(".claude/settings.json");
let mut content: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
content["allowedTools"]
.as_array_mut()
.unwrap()
.push(serde_json::Value::String("Bash(my-custom-tool *)".into()));
fs::write(
&settings_path,
serde_json::to_string_pretty(&content).unwrap(),
)
.unwrap();
run(dir.path(), &test_opts(true)).unwrap();
let result: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
let tools: Vec<&str> = result["allowedTools"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
for expected in embedded_allowed_tools() {
assert!(
tools.contains(&expected.as_str()),
"embedded tool \"{expected}\" should be preserved after force re-init"
);
}
assert!(
tools.contains(&"Bash(my-custom-tool *)"),
"custom allowedTools entry should be preserved after force re-init"
);
}
#[test]
fn test_force_init_no_duplicate_allowed_tools() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
run(dir.path(), &test_opts(true)).unwrap();
run(dir.path(), &test_opts(true)).unwrap();
let settings_path = dir.path().join(".claude/settings.json");
let content: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
let tools: Vec<&str> = content["allowedTools"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
for expected in embedded_allowed_tools() {
let count = tools.iter().filter(|&&t| t == expected.as_str()).count();
assert_eq!(
count, 1,
"\"{expected}\" should appear exactly once, found {count}"
);
}
}
#[test]
fn test_settings_json_merge_fails_on_malformed_json() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let settings_path = dir.path().join(".claude/settings.json");
fs::write(&settings_path, "not json {{{").unwrap();
let result = run(dir.path(), &test_opts(true));
assert!(result.is_err());
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("invalid JSON"),
"Error should mention invalid JSON, got: {err}"
);
let content = fs::read_to_string(&settings_path).unwrap();
assert_eq!(content, "not json {{{");
}
#[test]
fn test_settings_json_merge_fails_on_non_object() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let settings_path = dir.path().join(".claude/settings.json");
fs::write(&settings_path, "[1, 2, 3]").unwrap();
let result = run(dir.path(), &test_opts(true));
assert!(result.is_err());
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("not a JSON object"),
"Error should mention not a JSON object, got: {err}"
);
}
#[test]
fn test_settings_json_merge_creates_fresh_file() {
let dir = test_dir();
let settings_path = dir.path().join(".claude/settings.json");
fs::create_dir_all(dir.path().join(".claude")).unwrap();
assert!(!settings_path.exists());
write_settings_json_merged(&settings_path, "python3").unwrap();
let content: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
let tools: Vec<&str> = content["allowedTools"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
for expected in embedded_allowed_tools() {
assert!(
tools.contains(&expected.as_str()),
"fresh file should contain \"{expected}\""
);
}
}
#[test]
fn test_init_creates_root_gitignore() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(content.contains(GITIGNORE_SECTION_START));
assert!(content.contains(GITIGNORE_SECTION_END));
assert!(content.contains(".crosslink/issues.db"));
assert!(content.contains(".crosslink/agent.json"));
assert!(content.contains(".crosslink/session.json"));
assert!(content.contains(".crosslink/daemon.pid"));
assert!(content.contains(".crosslink/keys/"));
assert!(content.contains(".crosslink/.hub-cache/"));
assert!(content.contains(".crosslink/hook-config.local.json"));
assert!(content.contains(".claude/hooks/"));
assert!(content.contains(".claude/commands/"));
assert!(content.contains(".claude/mcp/"));
}
#[test]
fn test_root_gitignore_idempotent() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let first = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
run(dir.path(), &test_opts(true)).unwrap();
let second = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert_eq!(
first, second,
"Re-init should not duplicate gitignore entries"
);
}
#[test]
fn test_root_gitignore_preserves_user_entries() {
let dir = test_dir();
fs::write(dir.path().join(".gitignore"), "/target/\n*.log\n").unwrap();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
content.contains("/target/"),
"User entries before managed section should be preserved"
);
assert!(
content.contains("*.log"),
"User entries before managed section should be preserved"
);
assert!(content.contains(GITIGNORE_SECTION_START));
assert!(content.contains(".crosslink/issues.db"));
}
#[test]
fn test_root_gitignore_preserves_entries_around_managed_section() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
let new_content =
format!("# My custom rules\n/build/\n\n{content}\n# Trailing rules\n*.tmp\n");
fs::write(dir.path().join(".gitignore"), new_content).unwrap();
run(dir.path(), &test_opts(true)).unwrap();
let result = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
result.contains("/build/"),
"Pre-section user entries preserved"
);
assert!(
result.contains("*.tmp"),
"Post-section user entries preserved"
);
assert!(
result.contains(".crosslink/issues.db"),
"Managed entries present"
);
assert_eq!(
result.matches(GITIGNORE_SECTION_START).count(),
1,
"Should have exactly one managed section start marker"
);
assert_eq!(
result.matches(GITIGNORE_SECTION_END).count(),
1,
"Should have exactly one managed section end marker"
);
}
#[test]
fn test_root_gitignore_has_do_track_comments() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
content.contains("DO track"),
"Should include DO track comments for documentation"
);
assert!(
content.contains("hook-config.json"),
"Should mention hook-config.json as tracked"
);
}
#[test]
fn test_write_root_gitignore_fresh() {
let dir = test_dir();
write_root_gitignore(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(content.starts_with(GITIGNORE_SECTION_START));
assert!(content.contains(GITIGNORE_SECTION_END));
assert!(content.contains(".crosslink/issues.db"));
}
#[test]
fn test_write_root_gitignore_replaces_section() {
let dir = test_dir();
write_root_gitignore(dir.path()).unwrap();
write_root_gitignore(dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert_eq!(
content.matches(GITIGNORE_SECTION_START).count(),
1,
"Should have exactly one start marker after double write"
);
}
#[test]
fn test_crosslink_inner_gitignore_includes_integrations() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".crosslink/.gitignore")).unwrap();
assert!(content.contains("integrations/"));
}
#[test]
fn test_init_deploys_skill_files() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let commands_dir = dir.path().join(".claude/commands");
assert!(
commands_dir.join("maintain.md").exists(),
"maintain.md skill not deployed"
);
assert!(
commands_dir.join("design.md").exists(),
"design.md skill not deployed"
);
let maintain = fs::read_to_string(commands_dir.join("maintain.md")).unwrap();
assert!(!maintain.is_empty(), "maintain.md is empty");
let design = fs::read_to_string(commands_dir.join("design.md")).unwrap();
assert!(!design.is_empty(), "design.md is empty");
}
#[test]
fn test_init_deploys_mcp_servers() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let mcp_dir = dir.path().join(".claude/mcp");
assert!(
mcp_dir.join("safe-fetch-server.py").exists(),
"safe-fetch-server.py not deployed"
);
assert!(
mcp_dir.join("knowledge-server.py").exists(),
"knowledge-server.py not deployed"
);
assert!(
mcp_dir.join("agent-prompt-server.py").exists(),
"agent-prompt-server.py not deployed"
);
let mcp_content = fs::read_to_string(dir.path().join(".mcp.json")).unwrap();
let mcp: serde_json::Value = serde_json::from_str(&mcp_content).unwrap();
let servers = mcp["mcpServers"].as_object().unwrap();
assert!(
servers.contains_key("crosslink-safe-fetch"),
".mcp.json missing crosslink-safe-fetch"
);
assert!(
servers.contains_key("crosslink-knowledge"),
".mcp.json missing crosslink-knowledge"
);
assert!(
servers.contains_key("crosslink-agent-prompt"),
".mcp.json missing crosslink-agent-prompt"
);
}
#[test]
fn test_force_init_deploys_skill_files() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let commands_dir = dir.path().join(".claude/commands");
fs::remove_file(commands_dir.join("maintain.md")).unwrap();
fs::remove_file(commands_dir.join("design.md")).unwrap();
assert!(!commands_dir.join("maintain.md").exists());
assert!(!commands_dir.join("design.md").exists());
run(dir.path(), &test_opts(true)).unwrap();
assert!(commands_dir.join("maintain.md").exists());
assert!(commands_dir.join("design.md").exists());
}
fn update_opts() -> InitOpts<'static> {
InitOpts {
force: false,
update: true,
dry_run: false,
no_prompt: true, python_prefix: None,
skip_cpitd: true,
skip_signing: true,
signing_key: None,
reconfigure: false,
defaults: true,
}
}
fn dry_run_opts() -> InitOpts<'static> {
InitOpts {
dry_run: true,
..update_opts()
}
}
#[test]
fn test_init_writes_manifest() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let manifest_path = dir.path().join(".crosslink/init-manifest.json");
assert!(manifest_path.exists(), "Manifest should be created on init");
let content = fs::read_to_string(&manifest_path).unwrap();
let manifest: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(manifest.get("crosslink_version").is_some());
assert!(manifest.get("initialized_at").is_some());
assert!(manifest.get("files").is_some());
let files = manifest["files"].as_object().unwrap();
assert!(
files.contains_key(".claude/hooks/prompt-guard.py"),
"Manifest should track hook files"
);
assert!(
files.contains_key(".claude/settings.json"),
"Manifest should track settings.json"
);
assert!(
files.contains_key(".claude/mcp/safe-fetch-server.py"),
"Manifest should track MCP servers"
);
assert!(
files.contains_key(".claude/mcp/knowledge-server.py"),
"Manifest should track knowledge MCP server"
);
assert!(
files.contains_key(".claude/mcp/agent-prompt-server.py"),
"Manifest should track agent-prompt MCP server"
);
}
#[test]
fn test_force_init_updates_manifest() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let manifest_path = dir.path().join(".crosslink/init-manifest.json");
let first = fs::read_to_string(&manifest_path).unwrap();
run(dir.path(), &test_opts(true)).unwrap();
let second = fs::read_to_string(&manifest_path).unwrap();
let m1: serde_json::Value = serde_json::from_str(&first).unwrap();
let m2: serde_json::Value = serde_json::from_str(&second).unwrap();
assert_eq!(
m1["files"], m2["files"],
"File hashes should be identical across force re-inits"
);
}
#[test]
fn test_update_no_changes_needed() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let result = run(dir.path(), &update_opts());
assert!(result.is_ok());
let content = fs::read_to_string(dir.path().join(".claude/hooks/prompt-guard.py")).unwrap();
assert!(content.contains("python") || content.contains("def") || content.len() > 20);
}
#[test]
fn test_update_fails_without_init() {
let dir = test_dir();
let result = run(dir.path(), &update_opts());
assert!(result.is_err());
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("not initialized"),
"Should mention not initialized, got: {err}"
);
}
#[test]
fn test_update_preserves_user_modified_hook() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let hook_path = dir.path().join(".claude/hooks/prompt-guard.py");
fs::write(&hook_path, "# user customization\nprint('hello')").unwrap();
run(dir.path(), &update_opts()).unwrap();
let content = fs::read_to_string(&hook_path).unwrap();
assert_eq!(
content, "# user customization\nprint('hello')",
"User-modified hook should not be overwritten"
);
}
#[test]
fn test_update_skips_deleted_files() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let hook_path = dir.path().join(".claude/hooks/heartbeat.py");
fs::remove_file(&hook_path).unwrap();
run(dir.path(), &update_opts()).unwrap();
assert!(
!hook_path.exists(),
"Deleted file should not be recreated by --update"
);
}
#[test]
fn test_update_dry_run_makes_no_changes() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let hook_path = dir.path().join(".claude/hooks/prompt-guard.py");
let original = fs::read_to_string(&hook_path).unwrap();
fs::write(&hook_path, "# modified").unwrap();
let manifest_before =
fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();
run(dir.path(), &dry_run_opts()).unwrap();
let content = fs::read_to_string(&hook_path).unwrap();
assert_eq!(content, "# modified", "Dry run should not modify files");
let manifest_after =
fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();
assert_eq!(
manifest_before, manifest_after,
"Dry run should not update manifest"
);
fs::write(&hook_path, original).unwrap();
}
#[test]
fn test_force_dry_run_makes_no_changes() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let hook_path = dir.path().join(".claude/hooks/prompt-guard.py");
fs::write(&hook_path, "# user-modified").unwrap();
let manifest_before =
fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();
let dry_force = InitOpts {
force: true,
dry_run: true,
..test_opts(true)
};
run(dir.path(), &dry_force).unwrap();
let content = fs::read_to_string(&hook_path).unwrap();
assert_eq!(
content, "# user-modified",
"Force dry-run should not overwrite files"
);
let manifest_after =
fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();
assert_eq!(
manifest_before, manifest_after,
"Force dry-run should not update manifest"
);
}
#[test]
fn test_update_preserves_user_allowed_tools() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let settings_path = dir.path().join(".claude/settings.json");
let mut content: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
content["allowedTools"]
.as_array_mut()
.unwrap()
.push(serde_json::Value::String("Bash(my-tool *)".into()));
fs::write(
&settings_path,
serde_json::to_string_pretty(&content).unwrap(),
)
.unwrap();
run(dir.path(), &update_opts()).unwrap();
let result: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
let has_custom_tool = result["allowedTools"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.any(|t| t == "Bash(my-tool *)");
assert!(
has_custom_tool,
"Custom allowedTools entry should survive --update"
);
}
#[test]
fn test_update_without_manifest_warns() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
fs::remove_file(dir.path().join(".crosslink/init-manifest.json")).unwrap();
let result = run(dir.path(), &update_opts());
assert!(result.is_ok());
}
#[test]
fn test_gitignore_includes_manifest() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
content.contains("init-manifest.json"),
"Gitignore should include init-manifest.json"
);
}
#[test]
fn test_manifest_tracks_all_managed_files() {
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let manifest_content =
fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();
let manifest: serde_json::Value = serde_json::from_str(&manifest_content).unwrap();
let files = manifest["files"].as_object().unwrap();
let hook_files = [
"prompt-guard.py",
"post-edit-check.py",
"session-start.py",
"pre-web-check.py",
"work-check.py",
"crosslink_config.py",
"heartbeat.py",
];
for hook in &hook_files {
let key = format!(".claude/hooks/{hook}");
assert!(files.contains_key(&key), "Manifest should track {key}");
}
assert!(files.contains_key(".claude/mcp/safe-fetch-server.py"));
assert!(files.contains_key(".claude/mcp/knowledge-server.py"));
assert!(files.contains_key(".claude/mcp/agent-prompt-server.py"));
assert!(files.contains_key(".claude/settings.json"));
assert!(files.contains_key(".crosslink/rules/global.md"));
assert!(files.contains_key(".crosslink/rules/rust.md"));
}
#[test]
fn test_manifest_hashes_match_file_content() {
use manifest::sha256_hex;
let dir = test_dir();
run(dir.path(), &test_opts(false)).unwrap();
let manifest_content =
fs::read_to_string(dir.path().join(".crosslink/init-manifest.json")).unwrap();
let manifest: manifest::InitManifest = serde_json::from_str(&manifest_content).unwrap();
let hook_path = dir.path().join(".claude/hooks/prompt-guard.py");
let on_disk = fs::read_to_string(&hook_path).unwrap();
let expected_hash = sha256_hex(&on_disk);
assert_eq!(
manifest.files[".claude/hooks/prompt-guard.py"].sha256, expected_hash,
"Manifest hash should match on-disk file for non-merged files"
);
}
}