use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::session::mcp_server_key;
#[derive(Debug, Clone)]
pub struct Roots {
pub account: PathBuf,
pub claude_dir: PathBuf,
}
impl Roots {
pub fn here() -> Roots {
let claude_dir = crate::config::CLAUDE_CONFIG_DIR.clone();
let account = match crate::config::env_dir("CLAUDE_CONFIG_DIR") {
Some(dir) => dir.join(".claude.json"),
None => crate::config::HOME.join(".claude.json"),
};
Roots {
account,
claude_dir,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Source {
McpUser(PathBuf),
McpLocal { account: PathBuf, project: String },
McpProject(PathBuf),
Connector,
Plugin(String),
Skills(PathBuf),
Agents(PathBuf),
SyncedSkills,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolved {
At(Source),
Disabled,
Unknown,
}
#[derive(Debug, Default)]
struct Project {
local: HashSet<String>,
mcp_json: HashMap<String, String>,
disabled: HashSet<String>,
disabled_json: HashSet<String>,
skills: HashSet<String>,
agents: HashSet<String>,
plugins: HashMap<String, bool>,
}
#[derive(Debug, Default)]
pub struct Inventory {
account: PathBuf,
claude_dir: PathBuf,
user_mcp: HashSet<String>,
user_skills: HashSet<String>,
user_agents: HashSet<String>,
synced_skills: HashSet<String>,
plugins: HashMap<String, Option<String>>,
user_plugins: HashMap<String, bool>,
projects: HashMap<String, Project>,
}
fn read_json(path: &Path) -> Option<Value> {
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
fn keys_of(v: Option<&Value>) -> impl Iterator<Item = &str> {
v.and_then(Value::as_object).into_iter().flat_map(|m| {
m.iter()
.filter(|(_, cfg)| cfg.is_object())
.map(|(k, _)| k.as_str())
})
}
fn strings_of(v: Option<&Value>) -> impl Iterator<Item = &str> {
v.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
}
fn plugin_switches(settings: &Value) -> HashMap<String, bool> {
let mut out = HashMap::new();
if let Some(map) = settings.get("enabledPlugins").and_then(Value::as_object) {
for (key, on) in map {
if let Some(on) = on.as_bool() {
let name = key.split('@').next().unwrap_or(key);
out.insert(name.to_string(), on);
}
}
}
out
}
fn skill_names(dir: &Path) -> HashSet<String> {
crate::access::skills(dir)
.into_iter()
.map(|s| s.name)
.collect()
}
fn agent_names(dir: &Path) -> HashSet<String> {
let mut out = HashSet::new();
for entry in crate::config::list_dir(dir) {
let Some(stem) = entry.strip_suffix(".md") else {
continue;
};
let named = crate::util::read_head(&dir.join(&entry), 4096).and_then(|text| {
text.lines()
.take(20)
.find_map(|l| l.strip_prefix("name:").map(|v| v.trim().to_string()))
});
out.insert(named.unwrap_or_else(|| stem.to_string()));
}
out
}
impl Inventory {
pub fn load<'a>(roots: &Roots, cwds: impl IntoIterator<Item = &'a str>) -> Inventory {
let account = read_json(&roots.account).unwrap_or(Value::Null);
let user_settings =
read_json(&roots.claude_dir.join("settings.json")).unwrap_or(Value::Null);
let skills_dir = roots.claude_dir.join("skills");
let mut inv = Inventory {
account: roots.account.clone(),
claude_dir: roots.claude_dir.clone(),
user_mcp: keys_of(account.get("mcpServers"))
.map(mcp_server_key)
.collect(),
user_skills: skill_names(&skills_dir),
user_agents: agent_names(&roots.claude_dir.join("agents")),
user_plugins: plugin_switches(&user_settings),
..Inventory::default()
};
let synced = skills_dir.join("synced");
for org in crate::config::list_dir(&synced) {
inv.synced_skills.extend(skill_names(&synced.join(org)));
}
let plugins_dir = roots.claude_dir.join("plugins");
if let Some(installed) = read_json(&plugins_dir.join("installed_plugins.json"))
&& let Some(map) = installed.get("plugins").and_then(Value::as_object)
{
for key in map.keys() {
let name = key.split('@').next().unwrap_or(key);
inv.plugins.insert(name.to_string(), Some(key.clone()));
}
}
let synced = plugins_dir.join("synced");
for org in crate::config::list_dir(&synced) {
for entry in crate::config::list_dir(&synced.join(&org)) {
if synced.join(&org).join(&entry).is_dir() {
let name = entry.split('~').next().unwrap_or(&entry);
inv.plugins.entry(name.to_string()).or_insert(None);
}
}
}
let user_disabled_json: Vec<&str> =
strings_of(user_settings.get("disabledMcpjsonServers")).collect();
for cwd in cwds {
if cwd.is_empty() || inv.projects.contains_key(cwd) {
continue;
}
let dir = Path::new(cwd);
let entry = account.get("projects").and_then(|p| p.get(cwd));
let settings: Vec<Value> = ["settings.json", "settings.local.json"]
.iter()
.filter_map(|f| read_json(&dir.join(".claude").join(f)))
.collect();
let mcp_json = read_json(&dir.join(".mcp.json"));
let mut project = Project {
local: keys_of(entry.and_then(|e| e.get("mcpServers")))
.map(mcp_server_key)
.collect(),
mcp_json: mcp_json
.as_ref()
.map(|v| keys_of(Some(v.get("mcpServers").unwrap_or(v))))
.into_iter()
.flatten()
.map(|k| (mcp_server_key(k), k.to_string()))
.collect(),
disabled: strings_of(entry.and_then(|e| e.get("disabledMcpServers")))
.map(mcp_server_key)
.collect(),
disabled_json: strings_of(entry.and_then(|e| e.get("disabledMcpjsonServers")))
.chain(user_disabled_json.iter().copied())
.map(str::to_string)
.collect(),
skills: skill_names(&dir.join(".claude").join("skills")),
agents: agent_names(&dir.join(".claude").join("agents")),
plugins: HashMap::new(),
};
for s in &settings {
project
.disabled_json
.extend(strings_of(s.get("disabledMcpjsonServers")).map(str::to_string));
project.plugins.extend(plugin_switches(s));
}
inv.projects.insert(cwd.to_string(), project);
}
inv
}
fn project(&self, cwd: &str) -> Option<&Project> {
self.projects.get(cwd)
}
fn plugin(&self, cwd: &str, name: &str) -> Resolved {
if !self.plugins.contains_key(name) {
return Resolved::Unknown;
}
let on = self
.project(cwd)
.and_then(|p| p.plugins.get(name))
.or_else(|| self.user_plugins.get(name))
.copied();
match on {
Some(false) => Resolved::Disabled,
_ => Resolved::At(Source::Plugin(name.to_string())),
}
}
pub fn mcp(&self, cwd: &str, server: &str) -> Resolved {
let project = self.project(cwd);
if project.is_some_and(|p| p.disabled.contains(server)) {
return Resolved::Disabled;
}
if project.is_some_and(|p| p.local.contains(server)) {
return Resolved::At(Source::McpLocal {
account: self.account.clone(),
project: cwd.to_string(),
});
}
if let Some(p) = project
&& let Some(key) = p.mcp_json.get(server)
{
if p.disabled_json.contains(key) {
return Resolved::Disabled;
}
let file = Path::new(cwd).join(".mcp.json");
return Resolved::At(Source::McpProject(PathBuf::from(super::checkout_path(
&file.to_string_lossy(),
))));
}
if self.user_mcp.contains(server) {
return Resolved::At(Source::McpUser(self.account.clone()));
}
if server.starts_with("claude_ai_") {
return Resolved::At(Source::Connector);
}
if let Some(rest) = server.strip_prefix("plugin_") {
let mut names: Vec<&String> = self
.plugins
.keys()
.filter(|p| rest.starts_with(&format!("{}_", mcp_server_key(p))))
.collect();
names.sort_by_key(|p| std::cmp::Reverse(p.len()));
if let Some(name) = names.first() {
return self.plugin(cwd, name);
}
}
Resolved::Unknown
}
pub fn skill(&self, cwd: &str, name: &str) -> Resolved {
if let Some((prefix, bare)) = name.split_once(':') {
return match self.plugin(cwd, prefix) {
Resolved::Unknown if self.synced_skills.contains(bare) => {
Resolved::At(Source::SyncedSkills)
}
other => other,
};
}
if self.project(cwd).is_some_and(|p| p.skills.contains(name)) {
let dir = Path::new(cwd).join(".claude").join("skills");
return Resolved::At(Source::Skills(PathBuf::from(super::checkout_path(
&dir.to_string_lossy(),
))));
}
if self.user_skills.contains(name) {
return Resolved::At(Source::Skills(self.claude_dir.join("skills")));
}
Resolved::Unknown
}
pub fn agent(&self, cwd: &str, name: &str) -> Resolved {
if let Some((prefix, _)) = name.split_once(':') {
return self.plugin(cwd, prefix);
}
if self.project(cwd).is_some_and(|p| p.agents.contains(name)) {
let dir = Path::new(cwd).join(".claude").join("agents");
return Resolved::At(Source::Agents(PathBuf::from(super::checkout_path(
&dir.to_string_lossy(),
))));
}
if self.user_agents.contains(name) {
return Resolved::At(Source::Agents(self.claude_dir.join("agents")));
}
Resolved::Unknown
}
pub fn plugin_key(&self, name: &str) -> Option<&str> {
self.plugins.get(name).and_then(|k| k.as_deref())
}
pub fn user_settings(&self) -> PathBuf {
self.claude_dir.join("settings.json")
}
}