use std::path::PathBuf;
use crate::config;
use crate::error::LorumError;
pub mod backup_cmds;
pub mod doctor;
pub use doctor::{
ConsistencyReport, DoctorResult, print_consistency_reports, print_doctor_results, run_doctor,
run_doctor_consistency,
};
pub mod hook;
#[cfg(test)]
mod hook_tests;
pub mod init;
#[cfg(test)]
mod init_tests;
pub mod mcp;
#[cfg(test)]
mod mcp_tests;
pub mod rule;
#[cfg(test)]
mod rule_tests;
pub mod skill;
#[cfg(test)]
mod skill_tests;
#[cfg(test)]
mod tests;
fn resolve_path(config_path: Option<&str>) -> Result<PathBuf, LorumError> {
match config_path {
Some(p) => Ok(PathBuf::from(p)),
None => config::global_config_path(),
}
}
fn load_config_or_default(path: &std::path::Path) -> Result<config::LorumConfig, LorumError> {
match config::load_config(path) {
Ok(cfg) => Ok(cfg),
Err(LorumError::ConfigNotFound { .. }) => Ok(config::LorumConfig::default()),
Err(e) => Err(e),
}
}
pub fn run_init(config_path: Option<&str>, local: bool, yes: bool) -> Result<(), LorumError> {
if local {
init::run_interactive_init(true, yes)
} else if let Some(path) = config_path {
let path = std::path::PathBuf::from(path);
if path.exists() {
return Err(LorumError::Other {
message: format!("config already exists: {}", path.display()),
});
}
let cfg = config::LorumConfig::default();
config::save_config(&path, &cfg)?;
println!("created config at: {}", path.display());
Ok(())
} else {
init::run_interactive_init(false, yes)
}
}
fn detect_installed_tools() -> Vec<String> {
let mut tools = Vec::new();
if let Some(home) = dirs::home_dir() {
if home.join(".claude").exists() {
tools.push("claude-code".into());
}
if home.join(".codex").exists() {
tools.push("codex".into());
}
if home.join(".proma").exists() {
tools.push("proma".into());
}
if home.join(".kimi").exists() {
tools.push("kimi".into());
}
if home.join(".codeium").exists() {
tools.push("windsurf".into());
}
if home.join(".config").join("opencode").exists() {
tools.push("opencode".into());
}
if home.join(".continue").exists() {
tools.push("continue".into());
}
}
if std::env::current_dir()
.map(|d| d.join(".trae").exists())
.unwrap_or(false)
{
tools.push("trae".into());
}
if std::env::current_dir()
.map(|d| d.join(".cursor").exists())
.unwrap_or(false)
{
tools.push("cursor".into());
}
if command_exists("windsurf") {
tools.push("windsurf".into());
}
if command_exists("opencode") {
tools.push("opencode".into());
}
if command_exists("continue") {
tools.push("continue".into());
}
tools.sort();
tools.dedup();
tools
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ImportSummary {
tool: String,
mcp_servers: usize,
hook_handlers: usize,
rules_sections: usize,
errors: Vec<String>,
}
pub fn run_import(from: &str, dry_run: bool, config_path: Option<&str>) -> Result<(), LorumError> {
let path = resolve_path(config_path)?;
let mut lorum_config = load_config_or_default(&path)?;
let tool_names: Vec<String> = if from == "all" {
crate::adapters::all_adapter_tool_names()
} else {
let known = crate::adapters::all_adapter_tool_names();
if !known.iter().any(|n| n == from) {
return Err(LorumError::AdapterNotFound { name: from.into() });
}
vec![from.to_string()]
};
let mut summaries: Vec<ImportSummary> = Vec::new();
for tool_name in &tool_names {
let mut summary = ImportSummary {
tool: tool_name.clone(),
mcp_servers: 0,
hook_handlers: 0,
rules_sections: 0,
errors: Vec::new(),
};
import_mcp(tool_name, dry_run, &mut summary, &mut lorum_config);
import_hooks(tool_name, dry_run, &mut summary, &mut lorum_config);
import_rules(tool_name, dry_run, &mut summary)?;
if summary.mcp_servers > 0
|| summary.hook_handlers > 0
|| summary.rules_sections > 0
|| !summary.errors.is_empty()
{
summaries.push(summary);
}
}
if dry_run {
print_import_preview(&summaries);
} else {
config::save_config(&path, &lorum_config)?;
print_import_results(&summaries);
}
Ok(())
}
fn import_mcp(
tool_name: &str,
dry_run: bool,
summary: &mut ImportSummary,
lorum_config: &mut config::LorumConfig,
) {
if let Some(adapter) = crate::adapters::find_adapter(tool_name) {
match adapter.read_mcp() {
Ok(mcp) => {
summary.mcp_servers = mcp.servers.len();
if !dry_run {
for (name, server) in &mcp.servers {
lorum_config
.mcp
.servers
.insert(name.clone(), server.clone());
}
}
}
Err(e) => summary.errors.push(format!("mcp: {e}")),
}
}
}
fn import_hooks(
tool_name: &str,
dry_run: bool,
summary: &mut ImportSummary,
lorum_config: &mut config::LorumConfig,
) {
if let Some(adapter) = crate::adapters::find_hooks_adapter(tool_name) {
match adapter.read_hooks() {
Ok(hooks) => {
summary.hook_handlers = hooks.events.values().map(|v| v.len()).sum();
if !dry_run {
for (event, handlers) in &hooks.events {
lorum_config
.hooks
.events
.insert(event.clone(), handlers.clone());
}
}
}
Err(e) => summary.errors.push(format!("hooks: {e}")),
}
}
}
fn import_rules(
tool_name: &str,
dry_run: bool,
summary: &mut ImportSummary,
) -> Result<(), LorumError> {
if let Some(adapter) = crate::adapters::find_rules_adapter(tool_name) {
let project_root = crate::rules::find_project_root(
&std::env::current_dir().map_err(|e| LorumError::Io { source: e })?,
)
.unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
});
match adapter.read_rules(&project_root) {
Ok(Some(content)) => {
let imported = crate::rules::parse_rules(&content);
summary.rules_sections = imported.sections.len();
if !dry_run {
let mut existing = match crate::rules::load_rules(&project_root) {
Ok(rules) => rules,
Err(LorumError::ConfigNotFound { .. }) => crate::rules::RulesFile {
preamble: crate::rules::DEFAULT_PREAMBLE.to_string(),
sections: Vec::new(),
},
Err(e) => return Err(e),
};
let existing_names: std::collections::HashSet<String> =
existing.sections.iter().map(|s| s.name.clone()).collect();
for section in imported.sections {
if !existing_names.contains(§ion.name) {
existing.sections.push(section);
}
}
crate::rules::save_rules(&project_root, &existing)?;
}
}
Ok(None) => {}
Err(e) => summary.errors.push(format!("rules: {e}")),
}
}
Ok(())
}
fn print_import_preview(summaries: &[ImportSummary]) {
println!(
"{:<15} {:>6} {:>6} {:>6} {:<30}",
"TOOL", "MCP", "HOOKS", "RULES", "ERRORS"
);
for s in summaries {
let errors = if s.errors.is_empty() {
"".to_string()
} else {
s.errors.join(", ")
};
let errors_truncated = if errors.len() > 30 {
format!("{}...", &errors[..27])
} else {
errors
};
println!(
"{:<15} {:>6} {:>6} {:>6} {:<30}",
s.tool, s.mcp_servers, s.hook_handlers, s.rules_sections, errors_truncated
);
}
}
fn print_import_results(summaries: &[ImportSummary]) {
let total_mcp: usize = summaries.iter().map(|s| s.mcp_servers).sum();
let total_hooks: usize = summaries.iter().map(|s| s.hook_handlers).sum();
let total_rules: usize = summaries.iter().map(|s| s.rules_sections).sum();
for s in summaries {
println!(
"imported {} mcp servers, {} hook handlers, {} rules sections from {}",
s.mcp_servers, s.hook_handlers, s.rules_sections, s.tool
);
}
println!(
"imported {total_mcp} servers, {total_hooks} hook handlers, {total_rules} rules sections total"
);
}
pub fn run_sync(
dry_run: bool,
tools: &[String],
expand_env: bool,
config_path: Option<&str>,
) -> Result<(), LorumError> {
let config =
config::resolve_effective_config_from_cwd(config_path.map(PathBuf::from).as_deref())?;
let mcp = crate::env_interpolate::interpolate_mcp_config(&config.mcp, expand_env);
if dry_run {
let results = if tools.is_empty() {
crate::sync::dry_run_all(&mcp)
} else {
crate::sync::dry_run_tools(&mcp, tools)
};
print_dry_run_results(&results);
} else {
let results = if tools.is_empty() {
crate::sync::sync_all(&mcp)
} else {
crate::sync::sync_tools(&mcp, tools)
};
let failed = print_sync_results(&results);
if failed > 0 {
eprintln!("{failed} tool(s) failed to sync");
}
}
Ok(())
}
fn print_dry_run_results(results: &[crate::sync::DryRunResult]) {
for r in results {
let status = if r.success { "OK" } else { "FAIL" };
if let Some(diff) = &r.diff {
let summary = format!(
"+{}/-{}/~{}/={}",
diff.added.len(),
diff.removed.len(),
diff.modified.len(),
diff.unchanged.len()
);
println!("{:<15} {:<6} {summary}", r.tool, status);
} else {
println!("{:<15} {:<6}", r.tool, status);
}
if let Some(err) = &r.error {
println!(" error: {err}");
}
}
}
fn print_sync_results(results: &[crate::sync::SyncResult]) -> usize {
for r in results {
let status = if r.success { "OK" } else { "FAIL" };
println!("{:<15} {:<6} {} servers", r.tool, status, r.servers_synced);
if let Some(err) = &r.error {
println!(" error: {err}");
}
}
results.iter().filter(|r| !r.success).count()
}
#[derive(Debug, Clone)]
pub struct SelfCheckIssue {
pub category: String,
pub message: String,
}
pub fn perform_self_check(config_path: Option<&str>) -> Result<Vec<SelfCheckIssue>, LorumError> {
let config =
config::resolve_effective_config_from_cwd(config_path.map(PathBuf::from).as_deref())?;
let mut issues = Vec::new();
check_mcp_servers_structured(&config, &mut issues);
check_hooks_structured(&config, &mut issues);
check_skills_structured(&mut issues);
Ok(issues)
}
pub fn run_check(config_path: Option<&str>) -> Result<(), LorumError> {
let config =
config::resolve_effective_config_from_cwd(config_path.map(PathBuf::from).as_deref())?;
let issues = perform_self_check(config_path)?;
if issues.is_empty() {
let hook_events = config.hooks.events.len();
let hook_handlers: usize = config.hooks.events.values().map(|v| v.len()).sum();
println!(
"config is valid ({} servers, {} hook events, {} handlers)",
config.mcp.servers.len(),
hook_events,
hook_handlers,
);
} else {
for issue in &issues {
eprintln!("issue: {}", issue.message);
}
return Err(LorumError::Other {
message: format!("{} issue(s) found", issues.len()),
});
}
Ok(())
}
fn check_mcp_servers_structured(config: &config::LorumConfig, issues: &mut Vec<SelfCheckIssue>) {
for (name, server) in &config.mcp.servers {
if server.command.is_empty() {
issues.push(SelfCheckIssue {
category: "mcp".into(),
message: format!("server '{name}' has empty command"),
});
continue;
}
if !command_exists(&server.command) {
issues.push(SelfCheckIssue {
category: "mcp".into(),
message: format!(
"server '{name}' command '{}' not found on PATH",
server.command
),
});
}
let mut refs = find_unset_env_refs(&server.command);
for arg in &server.args {
refs.extend(find_unset_env_refs(arg));
}
for val in server.env.values() {
refs.extend(find_unset_env_refs(val));
}
for var in refs {
issues.push(SelfCheckIssue {
category: "mcp".into(),
message: format!(
"server '{name}' references unset environment variable '${{{var}}}'"
),
});
}
}
}
fn check_hooks_structured(config: &config::LorumConfig, issues: &mut Vec<SelfCheckIssue>) {
for (event, handlers) in &config.hooks.events {
if event.is_empty() {
issues.push(SelfCheckIssue {
category: "hooks".into(),
message: "hooks: empty event name".into(),
});
continue;
}
if !is_valid_kebab_case(event) {
issues.push(SelfCheckIssue {
category: "hooks".into(),
message: format!("hooks: event '{event}' is not valid kebab-case"),
});
}
for (i, h) in handlers.iter().enumerate() {
if h.matcher.is_empty() {
issues.push(SelfCheckIssue {
category: "hooks".into(),
message: format!("hooks: event '{event}' handler {i} has empty matcher"),
});
}
if h.command.is_empty() {
issues.push(SelfCheckIssue {
category: "hooks".into(),
message: format!("hooks: event '{event}' handler {i} has empty command"),
});
}
}
}
}
fn check_skills_structured(issues: &mut Vec<SelfCheckIssue>) {
match crate::skills::global_skills_dir() {
Ok(dir) if dir.exists() => match crate::skills::scan_skills_dir(&dir) {
Ok(entries) => {
for entry in &entries {
if entry.manifest.name.is_empty() {
issues.push(SelfCheckIssue {
category: "skills".into(),
message: format!(
"skill '{}' has empty manifest name",
entry.dir_path.display()
),
});
}
}
}
Err(e) => {
issues.push(SelfCheckIssue {
category: "skills".into(),
message: format!("failed to scan skills directory '{}': {e}", dir.display()),
});
}
},
_ => {} }
}
fn command_exists(cmd: &str) -> bool {
if cmd.contains('/') || cmd.contains('\\') {
return std::path::Path::new(cmd).is_file();
}
if let Ok(path_env) = std::env::var("PATH") {
for dir in path_env.split(if cfg!(windows) { ';' } else { ':' }) {
let full = std::path::Path::new(dir).join(cmd);
if full.is_file() {
return true;
}
#[cfg(windows)]
if std::path::Path::new(dir)
.join(format!("{cmd}.exe"))
.is_file()
{
return true;
}
}
}
false
}
fn find_unset_env_refs(value: &str) -> Vec<String> {
let mut unset = Vec::new();
let chars: Vec<char> = value.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '$' && i + 1 < chars.len() && chars[i + 1] == '{' {
i += 2;
let mut var_name = String::new();
let mut found_close = false;
while i < chars.len() {
if chars[i] == '}' {
found_close = true;
i += 1;
break;
}
var_name.push(chars[i]);
i += 1;
}
if found_close && std::env::var(&var_name).is_err() {
unset.push(var_name);
}
} else {
i += 1;
}
}
unset
}
pub(crate) fn is_valid_kebab_case(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !s.starts_with('-')
&& !s.ends_with('-')
&& !s.contains("--")
}
pub fn run_status(_config_path: Option<&str>) -> Result<(), LorumError> {
let mut tool_names = std::collections::BTreeSet::new();
for a in crate::adapters::all_adapters() {
tool_names.insert(a.name().to_string());
}
for a in crate::adapters::all_rules_adapters() {
tool_names.insert(a.name().to_string());
}
for a in crate::adapters::all_hooks_adapters() {
tool_names.insert(a.name().to_string());
}
for a in crate::adapters::all_skills_adapters() {
tool_names.insert(a.name().to_string());
}
let cwd = std::env::current_dir().ok();
println!(
"{:<15} {:>6} {:>8} {:>8} {:>8}",
"TOOL", "MCP", "RULES", "HOOKS", "SKILLS"
);
for name in tool_names {
let mcp = mcp_status(&name);
let rules = rules_status(&name, cwd.as_deref());
let hooks = hooks_status(&name);
let skills = skills_status(&name);
println!(
"{:<15} {:>6} {:>8} {:>8} {:>8}",
name,
fmt_count(mcp),
fmt_count(rules),
fmt_count(hooks),
fmt_count(skills),
);
}
Ok(())
}
fn fmt_count(count: Option<usize>) -> String {
match count {
None => "-".to_string(),
Some(0) => "·".to_string(),
Some(n) => n.to_string(),
}
}
fn mcp_status(name: &str) -> Option<usize> {
let adapter = crate::adapters::find_adapter(name)?;
let paths = adapter.config_paths();
if !paths.iter().any(|p| p.exists()) {
return Some(0);
}
adapter.read_mcp().map(|m| m.servers.len()).ok()
}
fn rules_status(name: &str, project_root: Option<&std::path::Path>) -> Option<usize> {
let adapter = crate::adapters::find_rules_adapter(name)?;
let root = project_root?;
let content = adapter.read_rules(root).ok()?;
Some(
content
.map(|c| crate::rules::parse_rules(&c).sections.len())
.unwrap_or(0),
)
}
fn hooks_status(name: &str) -> Option<usize> {
let adapter = crate::adapters::find_hooks_adapter(name)?;
let paths = adapter.config_paths();
if !paths.iter().any(|p| p.exists()) {
return Some(0);
}
adapter
.read_hooks()
.map(|h| h.events.values().map(|v| v.len()).sum())
.ok()
}
fn skills_status(name: &str) -> Option<usize> {
let adapter = crate::adapters::find_skills_adapter(name)?;
adapter.read_skills().map(|s| s.len()).ok()
}
pub fn run_config(
resolve_env: bool,
local: bool,
global: bool,
format: config::OutputFormat,
config_path: Option<&str>,
) -> Result<(), LorumError> {
let (config, source) = if let Some(p) = config_path {
(
config::load_config(std::path::Path::new(p))?,
format!("file: {p}"),
)
} else if global {
let path = config::global_config_path()?;
(
load_config_or_default(&path)?,
format!("global: {}", path.display()),
)
} else if local {
let cwd = std::env::current_dir()?;
match config::find_project_config(&cwd) {
Some(p) => {
let proj = config::load_project_config(&p)?
.ok_or_else(|| LorumError::ConfigNotFound { path: p.clone() })?;
(
config::LorumConfig {
mcp: proj.mcp,
hooks: proj.hooks,
},
format!("local: {}", p.display()),
)
}
None => {
return Err(LorumError::ConfigNotFound {
path: cwd.join(".lorum").join("config.yaml"),
});
}
}
} else {
let path = config::global_config_path()?;
let cfg = config::resolve_effective_config_from_cwd(None)?;
let cwd = std::env::current_dir()?;
let source = if config::find_project_config(&cwd).is_some() {
format!("merged: global ({}) + local project", path.display())
} else {
format!("global: {}", path.display())
};
(cfg, source)
};
let output = if resolve_env {
let mcp = crate::env_interpolate::interpolate_mcp_config(&config.mcp, true);
let hooks = crate::env_interpolate::interpolate_hooks_config(&config.hooks, true);
config::LorumConfig { mcp, hooks }
} else {
config
};
match format {
config::OutputFormat::Yaml => {
let yaml = serde_yaml::to_string(&output).map_err(|e| LorumError::Other {
message: format!("failed to serialize config: {e}"),
})?;
println!("# Source: {source}");
print!("{yaml}");
}
config::OutputFormat::Json => {
let json = serde_json::to_string_pretty(&output).map_err(|e| LorumError::Other {
message: format!("failed to serialize config: {e}"),
})?;
println!("{json}");
}
}
Ok(())
}
pub fn run_backup_list(config_path: Option<&str>) -> Result<(), LorumError> {
backup_cmds::run_backup_list(config_path)
}
pub fn run_backup_create(
tools: &[String],
all: bool,
config_path: Option<&str>,
) -> Result<(), LorumError> {
backup_cmds::run_backup_create(tools, all, config_path)
}
pub fn run_backup_restore(
tool: &str,
backup: Option<&str>,
config_path: Option<&str>,
) -> Result<(), LorumError> {
backup_cmds::run_backup_restore(tool, backup, config_path)
}