use std::path::Path;
use serde_json::Value;
use crate::core::claude_config::{ClaudeConfig, ClaudeConfigPaths, ConfigRecommendation, Severity};
use crate::core::{Error, Result};
use super::checkpointer::ConfigCheckpointer;
pub struct ClaudeConfigAnalyzer;
impl ClaudeConfigAnalyzer {
pub fn read_config(paths: &ClaudeConfigPaths) -> ClaudeConfig {
let mut config = ClaudeConfig::default();
for settings_path in [
&paths.user_settings,
&paths.user_local_settings,
&paths.project_settings,
&paths.project_local_settings,
] {
if let Some(json) = read_json(settings_path) {
merge_settings(&mut config, &json);
}
}
config.has_agents = dir_has_agent_files(&paths.user_agents_dir)
|| dir_has_agent_files(&paths.project_agents_dir);
config
}
pub fn analyze(config: &ClaudeConfig) -> Vec<ConfigRecommendation> {
let mut recs = Vec::new();
if !config.has_hooks {
recs.push(ConfigRecommendation {
id: "add-trusty-hooks".into(),
severity: Severity::Warning,
title: "No hooks configured".into(),
description: "Claude Code has no hooks. Add pre/post tool-use \
hooks so trusty-mpm can observe and oversee tool calls."
.into(),
auto_applicable: true,
});
}
if config.allow_list_has_wildcard {
recs.push(ConfigRecommendation {
id: "scope-permissions".into(),
severity: Severity::Critical,
title: "Permission allow list contains a wildcard".into(),
description: "The `permissions.allow` list contains `*`, which \
grants every tool unconditionally. Scope it to the specific tools the project \
needs."
.into(),
auto_applicable: false,
});
}
if !config.has_agents {
recs.push(ConfigRecommendation {
id: "deploy-agents".into(),
severity: Severity::Info,
title: "No agents deployed".into(),
description: "No agent files were found. Deploy the trusty-mpm \
agents so delegated work runs under managed agents."
.into(),
auto_applicable: false,
});
}
if !config.has_openrouter_key {
recs.push(ConfigRecommendation {
id: "add-openrouter-key".into(),
severity: Severity::Warning,
title: "OPENROUTER_API_KEY not in env hooks".into(),
description: "The LLM overseer needs `OPENROUTER_API_KEY`. Add \
it to the Claude Code `env` block (or to `.env.local`)."
.into(),
auto_applicable: false,
});
}
recs.sort_by_key(|r| std::cmp::Reverse(severity_rank(r.severity)));
recs
}
pub fn apply_recommendation(
rec: &ConfigRecommendation,
paths: &ClaudeConfigPaths,
project: &Path,
) -> Result<String> {
let label = format!("before-{}", rec.id);
let checkpoint_id = ConfigCheckpointer::create(paths, project, Some(&label))?;
match rec.id.as_str() {
"add-trusty-hooks" => {
apply_add_hooks(&paths.project_settings)?;
Ok(checkpoint_id)
}
other => Err(Error::Protocol(format!(
"recommendation `{other}` is not auto-applicable; apply it manually"
))),
}
}
}
pub(super) fn severity_rank(severity: Severity) -> u8 {
match severity {
Severity::Info => 0,
Severity::Warning => 1,
Severity::Critical => 2,
}
}
pub(super) fn read_json(path: &Path) -> Option<Value> {
let raw = std::fs::read_to_string(path).ok()?;
match serde_json::from_str(&raw) {
Ok(json) => Some(json),
Err(e) => {
tracing::warn!("malformed Claude config {}: {e}; skipping", path.display());
None
}
}
}
fn merge_settings(config: &mut ClaudeConfig, json: &Value) {
if let Some(hooks) = json.get("hooks").and_then(Value::as_object)
&& !hooks.is_empty()
{
config.has_hooks = true;
}
if let Some(allow) = json
.get("permissions")
.and_then(|p| p.get("allow"))
.and_then(Value::as_array)
{
config.allow_list_entries += allow.len();
if allow.iter().any(|v| v.as_str() == Some("*")) {
config.allow_list_has_wildcard = true;
}
}
if let Some(env) = json.get("env").and_then(Value::as_object)
&& env.contains_key("OPENROUTER_API_KEY")
{
config.has_openrouter_key = true;
}
}
fn dir_has_agent_files(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x.eq_ignore_ascii_case("md"))
})
}
fn apply_add_hooks(settings_path: &Path) -> Result<()> {
let mut json: Value = read_json(settings_path).unwrap_or_else(|| serde_json::json!({}));
let hooks = serde_json::json!({
"PreToolUse": [{ "matcher": "*", "hooks": [
{ "type": "command", "command": "trusty-mpm hook" }
] }],
"PostToolUse": [{ "matcher": "*", "hooks": [
{ "type": "command", "command": "trusty-mpm hook" }
] }],
"Stop": [{ "matcher": "*", "hooks": [
{ "type": "command", "command": "trusty-mpm hook" }
] }],
});
if let Some(obj) = json.as_object_mut() {
obj.insert("hooks".to_string(), hooks);
} else {
json = serde_json::json!({ "hooks": hooks });
}
if let Some(parent) = settings_path.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
let pretty = serde_json::to_string_pretty(&json)
.map_err(|e| Error::Protocol(format!("serialize settings.json: {e}")))?;
std::fs::write(settings_path, pretty).map_err(Error::Io)?;
Ok(())
}