use std::fmt;
use std::io::{BufRead, IsTerminal, Write};
use std::path::Path;
use crate::claude_settings;
use crate::commands::hook_install::{self, HookAction};
use crate::core::config::Config;
use crate::core::monorepo::{self, MonorepoSignal};
use crate::core::severity::SeverityCounts;
use crate::core::HealPaths;
use crate::skill_assets::{self, skills_dest, ExtractMode, ExtractStats};
use anyhow::{Context, Result};
use serde::Serialize;
use crate::observers::{build_calibration, classify, run_all};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(tag = "action", rename_all = "snake_case")]
enum ConfigAction {
Wrote,
Overwrote,
KeptExisting,
}
impl fmt::Display for ConfigAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Wrote => "wrote",
Self::Overwrote => "overwrote",
Self::KeptExisting => "kept existing",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "action", rename_all = "snake_case")]
enum SkillsAction {
Installed {
added: usize,
updated: usize,
unchanged: usize,
},
Declined,
SuppressedByFlag,
SkippedNoClaude,
SkippedNonInteractive,
}
#[allow(clippy::fn_params_excessive_bools)] pub fn run(project: &Path, force: bool, yes: bool, no_skills: bool, as_json: bool) -> Result<()> {
let paths = HealPaths::new(project);
paths
.ensure()
.with_context(|| format!("creating {}", paths.root().display()))?;
write_gitignore(&paths)?;
let config_action = write_config(&paths, force)?;
let (hook_action, hook_path) = hook_install::install(project, force)?;
let InitialScan {
cfg,
primary_language,
severity_counts,
} = run_initial_scan(project, &paths)?;
let skills_dest = skills_dest(project);
let skills_action =
handle_skills_install(project, &paths, &skills_dest, force, yes, no_skills)?;
let monorepo_signals = if cfg.project.workspaces.is_empty() {
let mut sigs = monorepo::detect(project);
monorepo::enrich_with_languages(project, &cfg, &mut sigs);
sigs
} else {
Vec::new()
};
if as_json {
super::emit_json(&InitReport::new(
project,
&paths,
primary_language.as_deref(),
&config_action,
&hook_action,
hook_path.as_deref(),
&skills_dest,
&skills_action,
severity_counts.as_ref(),
&monorepo_signals,
));
return Ok(());
}
print_summary(
&paths,
primary_language.as_deref(),
config_action,
hook_action,
hook_path.as_deref(),
&skills_dest,
&skills_action,
severity_counts.as_ref(),
&monorepo_signals,
);
Ok(())
}
#[derive(Debug, Serialize)]
struct InitReport<'a> {
project: String,
heal_dir: String,
primary_language: Option<&'a str>,
config: PathAction<'a, ConfigAction>,
calibration_path: String,
post_commit_hook: PathAction<'a, HookAction>,
skills: SkillsReport<'a>,
severity_counts: Option<&'a SeverityCounts>,
#[serde(skip_serializing_if = "<[_]>::is_empty")]
monorepo_signals: &'a [MonorepoSignal],
}
#[derive(Debug, Serialize)]
struct PathAction<'a, A: Serialize> {
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(flatten)]
action: &'a A,
}
#[derive(Debug, Serialize)]
struct SkillsReport<'a> {
dest: String,
#[serde(flatten)]
action: &'a SkillsAction,
}
impl<'a> InitReport<'a> {
#[allow(clippy::too_many_arguments)]
fn new(
project: &Path,
paths: &HealPaths,
primary_language: Option<&'a str>,
config_action: &'a ConfigAction,
hook_action: &'a HookAction,
hook_path: Option<&Path>,
skills_dest: &Path,
skills_action: &'a SkillsAction,
severity_counts: Option<&'a SeverityCounts>,
monorepo_signals: &'a [MonorepoSignal],
) -> Self {
Self {
project: project.display().to_string(),
heal_dir: paths.root().display().to_string(),
primary_language,
config: PathAction {
path: Some(paths.config().display().to_string()),
action: config_action,
},
calibration_path: paths.calibration().display().to_string(),
post_commit_hook: PathAction {
path: hook_path.map(|p| p.display().to_string()),
action: hook_action,
},
skills: SkillsReport {
dest: skills_dest.display().to_string(),
action: skills_action,
},
severity_counts,
monorepo_signals,
}
}
}
#[allow(clippy::too_many_arguments)]
fn print_summary(
paths: &HealPaths,
primary_language: Option<&str>,
config_action: ConfigAction,
hook_action: HookAction,
hook_path: Option<&Path>,
skills_dest: &Path,
skills_action: &SkillsAction,
severity_counts: Option<&SeverityCounts>,
monorepo_signals: &[MonorepoSignal],
) {
println!("HEAL initialized at {}", paths.root().display());
println!(
" primary language: {}",
primary_language.unwrap_or("(not detected)"),
);
println!();
println!("Installed:");
println!(
" config {} ({config_action})",
paths.config().display(),
);
println!(" calibration {}", paths.calibration().display());
match hook_path {
Some(p) => println!(" post-commit hook {} ({hook_action})", p.display()),
None => println!(" post-commit hook {hook_action}"),
}
println!(
" Claude skills {}",
render_skills_line(skills_dest, skills_action),
);
if let Some(counts) = severity_counts {
let colorize = std::io::stdout().is_terminal();
println!();
println!("Findings: {}", counts.render_inline(colorize));
}
if !monorepo_signals.is_empty() {
println!();
println!("Workspace detected:");
for s in monorepo_signals {
println!(" - via {} ({})", s.manifest, s.kind);
for m in &s.members {
let lang = m
.primary_language
.as_deref()
.unwrap_or("primary language not detected");
println!(" {} ({lang})", m.path);
}
}
println!(
" → declare workspaces in `[[project.workspaces]]` so calibration\n \
scopes per package — run `claude /heal-config` to set this up.",
);
}
println!();
println!("Next steps:");
println!(" heal status # render the Severity-grouped TODO list");
println!(" heal metrics # see metric trends");
println!(" heal diff # progress vs. the calibration baseline");
match skills_action {
SkillsAction::Installed { .. } => {
println!();
println!("Claude skills (from this project):");
println!(" claude /heal-config # tune thresholds / declare workspaces");
println!(" claude /heal-code-review # architectural reading + refactor TODO");
println!(" claude /heal-code-patch # drain the cache, one fix per commit");
}
SkillsAction::SkippedNoClaude => {
}
_ => {
println!(" heal skills install # extract the Claude skills when ready");
}
}
}
fn render_skills_line(dest: &Path, action: &SkillsAction) -> String {
match action {
SkillsAction::Installed {
added,
updated,
unchanged,
} => {
let mut parts = vec![format!("{added} new")];
if *updated > 0 {
parts.push(format!("{updated} updated"));
}
parts.push(format!("{unchanged} unchanged"));
format!("{}/ (extracted: {})", dest.display(), parts.join(", "))
}
SkillsAction::Declined => "skipped (declined)".to_string(),
SkillsAction::SuppressedByFlag => "skipped (--no-skills)".to_string(),
SkillsAction::SkippedNoClaude => "skipped (no `claude` command on PATH)".to_string(),
SkillsAction::SkippedNonInteractive => {
"skipped (non-interactive shell; pass `--yes` or run `heal skills install` later)"
.to_string()
}
}
}
const GITIGNORE_BODY: &str = "\
# Managed by `heal init` — re-run to refresh.
";
fn write_gitignore(paths: &HealPaths) -> Result<()> {
let path = paths.gitignore();
if std::fs::read(&path).is_ok_and(|prior| prior == GITIGNORE_BODY.as_bytes()) {
return Ok(());
}
crate::core::fs::atomic_write(&path, GITIGNORE_BODY.as_bytes())?;
Ok(())
}
fn write_config(paths: &HealPaths, force: bool) -> Result<ConfigAction> {
let cfg_path = paths.config();
let already_present = cfg_path.exists();
if already_present && !force {
return Ok(ConfigAction::KeptExisting);
}
Config::default().save(&cfg_path)?;
Ok(if already_present {
ConfigAction::Overwrote
} else {
ConfigAction::Wrote
})
}
struct InitialScan {
cfg: Config,
primary_language: Option<String>,
severity_counts: Option<SeverityCounts>,
}
fn run_initial_scan(project: &Path, paths: &HealPaths) -> Result<InitialScan> {
let cfg = match crate::core::config::load_from_project(project) {
Ok(c) => c,
Err(crate::core::Error::ConfigMissing(_)) => Config::default(),
Err(e) => return Err(e.into()),
};
let reports = run_all(project, &cfg, None, None);
let primary_language = reports.loc.primary.clone();
let calibration = build_calibration(project, &reports, &cfg);
calibration.save(&paths.calibration())?;
let cal_with_overrides = calibration.with_overrides(&cfg);
let findings = classify(&reports, &cal_with_overrides, &cfg);
Ok(InitialScan {
cfg,
primary_language,
severity_counts: Some(SeverityCounts::from_findings(&findings)),
})
}
fn handle_skills_install(
project: &Path,
paths: &HealPaths,
dest: &Path,
force: bool,
yes: bool,
no_skills: bool,
) -> Result<SkillsAction> {
if no_skills {
return Ok(SkillsAction::SuppressedByFlag);
}
if !claude_on_path() {
return Ok(SkillsAction::SkippedNoClaude);
}
if yes {
return install_skills(project, paths, dest, force);
}
if std::io::stdin().is_terminal() {
if confirm_skills_install()? {
install_skills(project, paths, dest, force)
} else {
Ok(SkillsAction::Declined)
}
} else {
Ok(SkillsAction::SkippedNonInteractive)
}
}
fn install_skills(
project: &Path,
_paths: &HealPaths,
dest: &Path,
force: bool,
) -> Result<SkillsAction> {
let mode = if force {
ExtractMode::Update { force: true }
} else {
ExtractMode::InstallSafe
};
let stats = skill_assets::extract(dest, mode)?;
claude_settings::wire(project)?;
Ok(extract_counts(&stats))
}
fn extract_counts(stats: &ExtractStats) -> SkillsAction {
let s = stats.summary();
SkillsAction::Installed {
added: s.added,
updated: s.updated,
unchanged: s.unchanged + s.skipped,
}
}
fn claude_on_path() -> bool {
let Some(path_var) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path_var).any(|dir| dir.join("claude").is_file())
}
fn confirm_skills_install() -> Result<bool> {
print!(
"Install the bundled Claude skills (/heal-cli, /heal-config, /heal-code-review, /heal-code-patch)? [Y/n] ",
);
std::io::stdout()
.flush()
.context("flushing skills-install prompt")?;
let stdin = std::io::stdin();
let mut line = String::new();
stdin
.lock()
.read_line(&mut line)
.context("reading skills-install prompt response")?;
let answer = line.trim().to_ascii_lowercase();
Ok(matches!(answer.as_str(), "" | "y" | "yes"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{commit, init_repo};
use tempfile::TempDir;
fn commit_default(cwd: &Path, file: &str, body: &str, email: &str) {
commit(cwd, file, body, email, "snap");
}
fn run_no_skills(project: &Path, force: bool) -> Result<()> {
run(project, force, false, true, false)
}
#[test]
fn write_config_writes_default_when_absent() {
let dir = TempDir::new().unwrap();
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
let action = write_config(&paths, false).unwrap();
assert_eq!(action, ConfigAction::Wrote);
let cfg = Config::load(&paths.config()).unwrap();
assert_eq!(cfg, Config::default());
}
#[test]
fn write_config_keeps_existing_without_force() {
let dir = TempDir::new().unwrap();
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
std::fs::write(paths.config(), "# user-edited\n").unwrap();
let action = write_config(&paths, false).unwrap();
assert_eq!(action, ConfigAction::KeptExisting);
let body = std::fs::read_to_string(paths.config()).unwrap();
assert_eq!(body, "# user-edited\n");
}
#[test]
fn write_config_overwrites_with_force() {
let dir = TempDir::new().unwrap();
let paths = HealPaths::new(dir.path());
paths.ensure().unwrap();
std::fs::write(paths.config(), "# user-edited\n").unwrap();
let action = write_config(&paths, true).unwrap();
assert_eq!(action, ConfigAction::Overwrote);
let cfg = Config::load(&paths.config()).unwrap();
assert_eq!(cfg, Config::default());
}
#[test]
fn run_end_to_end_creates_layout_config_and_calibration() {
let dir = TempDir::new().unwrap();
init_repo(dir.path());
commit_default(dir.path(), "main.rs", "fn main() {}\n", "solo@example.com");
run_no_skills(dir.path(), false).unwrap();
let paths = HealPaths::new(dir.path());
assert!(paths.config().exists(), "config.toml must exist");
assert!(paths.calibration().exists(), "calibration.toml must exist");
assert!(
hook_install::hook_path_for(dir.path()).exists(),
"post-commit hook must be installed",
);
let calibration =
crate::core::calibration::Calibration::load(&paths.calibration()).unwrap();
assert!(
calibration.meta.calibrated_at_sha.is_some(),
"calibrated_at_sha must be captured from HEAD",
);
assert!(
calibration.meta.codebase_files >= 1,
"calibration must record codebase_files",
);
}
#[test]
fn no_skills_flag_leaves_skills_dir_unwritten() {
let dir = TempDir::new().unwrap();
init_repo(dir.path());
commit_default(dir.path(), "main.rs", "fn main() {}\n", "solo@example.com");
run_no_skills(dir.path(), false).unwrap();
assert!(
!skills_dest(dir.path()).exists(),
"--no-skills must not extract the skill set"
);
}
#[test]
fn handle_skills_install_respects_no_skills_flag() {
let dir = TempDir::new().unwrap();
let project = dir.path();
let paths = HealPaths::new(project);
paths.ensure().unwrap();
let dest = skills_dest(project);
let action = handle_skills_install(project, &paths, &dest, false, false, true).unwrap();
assert_eq!(action, SkillsAction::SuppressedByFlag);
assert!(!dest.exists());
}
#[test]
fn handle_skills_install_with_yes_extracts_skills_when_claude_available() {
let bin_dir = TempDir::new().unwrap();
let claude_bin = bin_dir.path().join("claude");
std::fs::write(&claude_bin, b"#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&claude_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let original_path = std::env::var_os("PATH").unwrap_or_default();
let mut new_path = std::ffi::OsString::from(bin_dir.path());
new_path.push(":");
new_path.push(&original_path);
let _guard = PathGuard::set(new_path);
let dir = TempDir::new().unwrap();
let project = dir.path();
let paths = HealPaths::new(project);
paths.ensure().unwrap();
let dest = skills_dest(project);
let action = handle_skills_install(project, &paths, &dest, false, true, false).unwrap();
assert!(matches!(action, SkillsAction::Installed { .. }));
assert!(dest.exists(), "yes path must extract the skill set");
assert!(dest.join("heal-cli/SKILL.md").exists());
}
#[test]
fn handle_skills_install_skips_when_no_claude() {
let _guard = PathGuard::set(std::ffi::OsString::new());
let dir = TempDir::new().unwrap();
let project = dir.path();
let paths = HealPaths::new(project);
paths.ensure().unwrap();
let dest = skills_dest(project);
let action = handle_skills_install(project, &paths, &dest, false, true, false).unwrap();
assert_eq!(action, SkillsAction::SkippedNoClaude);
assert!(!dest.exists());
}
#[test]
fn install_skills_force_overwrites_drifted_files() {
let dir = TempDir::new().unwrap();
let project = dir.path();
let paths = HealPaths::new(project);
paths.ensure().unwrap();
let dest = project.join("skills");
let initial = install_skills(project, &paths, &dest, false).unwrap();
let SkillsAction::Installed {
added: initial_added,
updated: initial_updated,
..
} = initial
else {
panic!("expected Installed, got {initial:?}");
};
assert!(initial_added > 0);
assert_eq!(initial_updated, 0, "no drift on first install");
let skill = dest.join("heal-code-patch/SKILL.md");
assert!(skill.exists(), "fixture should have shipped this skill");
std::fs::write(&skill, "tampered\n").unwrap();
let refreshed = install_skills(project, &paths, &dest, true).unwrap();
let SkillsAction::Installed {
updated: refreshed_updated,
..
} = refreshed
else {
panic!("expected Installed, got {refreshed:?}");
};
assert!(
refreshed_updated > 0,
"force refresh must report updated files"
);
assert_ne!(
std::fs::read_to_string(&skill).unwrap(),
"tampered\n",
"force refresh must overwrite drifted skill content"
);
}
#[test]
fn install_skills_no_force_preserves_existing_files() {
let dir = TempDir::new().unwrap();
let project = dir.path();
let paths = HealPaths::new(project);
paths.ensure().unwrap();
let dest = project.join("skills");
install_skills(project, &paths, &dest, false).unwrap();
let skill = dest.join("heal-code-patch/SKILL.md");
std::fs::write(&skill, "tampered\n").unwrap();
let action = install_skills(project, &paths, &dest, false).unwrap();
let SkillsAction::Installed { updated, .. } = action else {
panic!("expected Installed, got {action:?}");
};
assert_eq!(updated, 0, "InstallSafe must not overwrite anything");
assert_eq!(
std::fs::read_to_string(&skill).unwrap(),
"tampered\n",
"non-force install must leave the user-edited file alone"
);
}
struct PathGuard {
original: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
static PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
impl PathGuard {
fn set(value: std::ffi::OsString) -> Self {
let lock = PATH_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let original = std::env::var_os("PATH");
std::env::set_var("PATH", value);
Self {
original,
_lock: lock,
}
}
}
impl Drop for PathGuard {
fn drop(&mut self) {
match self.original.take() {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
}
}