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")
}
}
#[cfg(test)]
pub(crate) struct Machine {
dir: tempfile::TempDir,
}
#[cfg(test)]
impl Machine {
pub(crate) fn new() -> Machine {
Machine {
dir: tempfile::tempdir().unwrap(),
}
}
pub(crate) fn path(&self, rel: &str) -> PathBuf {
self.dir.path().join(rel)
}
pub(crate) fn write(&self, rel: &str, body: &str) {
let p = self.path(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
pub(crate) fn roots(&self) -> Roots {
Roots {
account: self.path("home/.claude.json"),
claude_dir: self.path("home/.claude"),
}
}
pub(crate) fn project(&self) -> String {
self.path("work/app").to_string_lossy().into_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_server_resolves_to_the_most_local_definition_that_defines_it() {
let m = Machine::new();
let project = m.project();
m.write(
"home/.claude.json",
&format!(
r#"{{"mcpServers": {{"everywhere": {{"command": "x"}}, "shared": {{"command": "x"}}}},
"projects": {{"{project}": {{"mcpServers": {{"shared": {{"command": "x"}}}}}}}}}}"#
),
);
m.write(
"work/app/.mcp.json",
r#"{"mcpServers": {"shared": {"command": "x"}, "checked-in": {"command": "x"}}}"#,
);
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(
inv.mcp(&project, "shared"),
Resolved::At(Source::McpLocal {
account: m.path("home/.claude.json"),
project: project.clone(),
})
);
assert_eq!(
inv.mcp(&project, "checked-in"),
Resolved::At(Source::McpProject(PathBuf::from(format!(
"{project}/.mcp.json"
))))
);
assert_eq!(
inv.mcp(&project, "everywhere"),
Resolved::At(Source::McpUser(m.path("home/.claude.json")))
);
assert_eq!(inv.mcp(&project, "never-configured"), Resolved::Unknown);
}
#[test]
fn a_server_switched_off_for_a_project_is_not_defined_at_all() {
let m = Machine::new();
let project = m.project();
m.write(
"home/.claude.json",
&format!(
r#"{{"mcpServers": {{"tracker": {{"command": "x"}}}},
"projects": {{"{project}": {{"disabledMcpServers": ["tracker"]}}}}}}"#
),
);
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(inv.mcp(&project, "tracker"), Resolved::Disabled);
assert!(matches!(
inv.mcp("/somewhere-else", "tracker"),
Resolved::At(Source::McpUser(_))
));
}
#[test]
fn a_project_server_switched_off_by_its_written_name_is_disabled() {
let m = Machine::new();
let project = m.project();
m.write(
"work/app/.mcp.json",
r#"{"mcpServers": {"my.server": {"command": "x"}}}"#,
);
m.write(
"work/app/.claude/settings.json",
r#"{"disabledMcpjsonServers": ["my.server"]}"#,
);
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(inv.mcp(&project, "my_server"), Resolved::Disabled);
}
#[test]
fn a_plugin_server_resolves_to_the_longest_plugin_name() {
let m = Machine::new();
let project = m.project();
m.write(
"home/.claude/plugins/installed_plugins.json",
r#"{"plugins": {"data@marketplace": {}, "data-tools@marketplace": {}}}"#,
);
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(
inv.mcp(&project, "plugin_data-tools_fetch_x"),
Resolved::At(Source::Plugin("data-tools".into()))
);
assert_eq!(
inv.mcp(&project, "plugin_data_fetch_x"),
Resolved::At(Source::Plugin("data".into()))
);
assert_eq!(inv.mcp(&project, "plugin_absent_fetch"), Resolved::Unknown);
}
#[test]
fn a_projects_plugin_switch_wins_over_the_users() {
let m = Machine::new();
let project = m.project();
m.write(
"home/.claude/plugins/installed_plugins.json",
r#"{"plugins": {"web@marketplace": {}}}"#,
);
m.write(
"home/.claude/settings.json",
r#"{"enabledPlugins": {"web@marketplace": false}}"#,
);
m.write(
"work/app/.claude/settings.local.json",
r#"{"enabledPlugins": {"web@marketplace": true}}"#,
);
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(
inv.mcp(&project, "plugin_web_fetch"),
Resolved::At(Source::Plugin("web".into()))
);
m.write("work/app/.claude/settings.local.json", "{}");
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(inv.mcp(&project, "plugin_web_fetch"), Resolved::Disabled);
}
#[test]
fn a_synced_skill_is_reached_through_a_prefix_that_is_not_a_plugin() {
let m = Machine::new();
let project = m.project();
m.write(
"home/.claude/skills/synced/org/guidance/SKILL.md",
"---\nname: guidance\ndescription: how to\n---\n",
);
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(
inv.skill(&project, "claude.ai:guidance"),
Resolved::At(Source::SyncedSkills)
);
m.write(
"home/.claude/plugins/installed_plugins.json",
r#"{"plugins": {"org@marketplace": {}}}"#,
);
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(
inv.skill(&project, "org:guidance"),
Resolved::At(Source::Plugin("org".into()))
);
assert_eq!(inv.skill(&project, "nothing:absent"), Resolved::Unknown);
}
#[test]
fn a_skills_directory_is_the_project_ones_or_the_users() {
let m = Machine::new();
let project = m.project();
m.write(
"home/.claude/skills/everywhere/SKILL.md",
"---\nname: everywhere\n---\n",
);
m.write(
"work/app/.claude/skills/here/SKILL.md",
"---\nname: here\n---\n",
);
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(
inv.skill(&project, "here"),
Resolved::At(Source::Skills(PathBuf::from(format!(
"{project}/.claude/skills"
))))
);
assert_eq!(
inv.skill(&project, "everywhere"),
Resolved::At(Source::Skills(m.path("home/.claude/skills")))
);
assert_eq!(inv.skill(&project, "neither"), Resolved::Unknown);
}
#[test]
fn an_agent_is_named_by_its_front_matter_or_its_file() {
let m = Machine::new();
let project = m.project();
m.write(
"home/.claude/agents/reviewer.md",
"---\nname: review\n---\n",
);
m.write("home/.claude/agents/planner.md", "no front matter here\n");
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(
inv.agent(&project, "review"),
Resolved::At(Source::Agents(m.path("home/.claude/agents")))
);
assert_eq!(
inv.agent(&project, "planner"),
Resolved::At(Source::Agents(m.path("home/.claude/agents")))
);
assert_eq!(inv.agent(&project, "nobody"), Resolved::Unknown);
}
#[test]
fn no_resolved_source_carries_a_secret() {
let m = Machine::new();
let project = m.project();
m.write(
"home/.claude.json",
r#"{"mcpServers": {"tracker": {"command": "npx", "env": {"TOKEN": "sk-live-SECRET"}}}}"#,
);
let inv = Inventory::load(&m.roots(), [project.as_str()]);
assert_eq!(
inv.mcp(&project, "tracker"),
Resolved::At(Source::McpUser(m.path("home/.claude.json")))
);
}
}