use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct Features {
#[serde(default)]
pub memory: Option<Memory>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct Config {
#[serde(default)]
pub cache: Cache,
#[serde(default)]
pub scope: Scopes,
#[serde(default)]
pub capabilities: Capabilities,
#[serde(default)]
pub native: std::collections::BTreeMap<String, serde_yaml::Value>,
#[serde(default)]
pub bundle: Vec<Bundle>,
#[serde(default)]
pub mcp: Vec<McpServer>,
#[serde(default)]
pub features: Option<Features>,
#[serde(default)]
pub marketplace: Vec<Marketplace>,
#[serde(default, rename = "plugin-collection")]
pub plugin_collection: Vec<PluginCollection>,
#[serde(default)]
pub state: StateConfig,
#[serde(default)]
pub host: std::collections::BTreeMap<String, HostEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default)]
pub struct Cache {
pub cache_dir: String,
pub sync_interval_minutes: u64,
pub cache_retention_hours: Option<u64>,
pub hashing: HashingMode,
}
impl Default for Cache {
fn default() -> Self {
Self {
cache_dir: "~/.cache/llmenv".into(),
sync_interval_minutes: 15,
cache_retention_hours: Some(168), hashing: HashingMode::default(),
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum HashingMode {
Loose,
#[default]
Normal,
Strict,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct Capabilities {
#[serde(default)]
pub permissions: Permissions,
#[serde(default)]
pub hooks: Vec<Hook>,
#[serde(default)]
pub plugins: Vec<String>,
#[serde(default)]
pub mcp: Vec<McpServer>,
#[serde(default)]
pub env: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_memory_enabled: Option<bool>,
#[serde(default)]
pub native_permissions: std::collections::BTreeMap<String, NativePermissionRules>,
#[serde(default)]
pub native_hooks: std::collections::BTreeMap<String, serde_yaml::Value>,
#[serde(default)]
pub native_plugins: std::collections::BTreeMap<String, serde_yaml::Value>,
#[serde(default)]
pub native_mcp: std::collections::BTreeMap<String, serde_yaml::Value>,
#[serde(default)]
pub native: std::collections::BTreeMap<String, serde_yaml::Value>,
}
impl Capabilities {
pub fn is_empty(&self) -> bool {
self.permissions.is_empty()
&& self.hooks.is_empty()
&& self.plugins.is_empty()
&& self.mcp.is_empty()
&& self.env.is_empty()
&& self.auto_memory_enabled.is_none()
&& self.native_permissions.is_empty()
&& self.native_hooks.is_empty()
&& self.native_plugins.is_empty()
&& self.native_mcp.is_empty()
&& self.native.is_empty()
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct Permissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_mode: Option<PermissionMode>,
#[serde(default)]
pub allow: Vec<PermissionRule>,
#[serde(default)]
pub ask: Vec<PermissionRule>,
#[serde(default)]
pub deny: Vec<PermissionRule>,
}
impl Permissions {
pub fn is_empty(&self) -> bool {
self.default_mode.is_none()
&& self.allow.is_empty()
&& self.ask.is_empty()
&& self.deny.is_empty()
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct NativePermissionRules {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub ask: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum PermissionMode {
AcceptEdits,
Plan,
Default,
BypassPermissions,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct PermissionRule {
pub tool: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub paths: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Eq)]
pub struct Hook {
pub event: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matcher: Option<String>,
pub handler: HookHandler,
#[serde(skip)]
pub bundle_origin: Option<std::path::PathBuf>,
}
impl PartialEq for Hook {
fn eq(&self, other: &Self) -> bool {
self.event == other.event && self.matcher == other.matcher && self.handler == other.handler
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct HookHandler {
#[serde(rename = "type")]
pub kind: HookHandlerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HookHandlerKind {
Command,
McpTool,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct Scopes {
#[serde(default)]
pub network: Vec<NetworkScope>,
#[serde(default)]
pub host: Vec<HostScope>,
#[serde(default)]
pub user: Vec<UserScope>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct NetworkScope {
pub id: String,
pub r#match: NetworkMatch,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct NetworkMatch {
pub gateway_mac: Option<String>,
pub ssid: Option<String>,
pub cidr: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct HostScope {
pub id: String,
pub r#match: HostMatch,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct HostMatch {
pub hostname: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct UserScope {
pub id: String,
pub r#match: UserMatch,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct UserMatch {
pub user: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Bundle {
pub name: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub vars: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct HostEntry {
pub addr: String,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum McpTransport {
#[default]
Stdio,
Http,
Sse,
}
fn default_listen_host() -> String {
"127.0.0.1".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Memory {
pub server_host: String,
pub port: u16,
#[serde(default = "default_listen_host")]
pub listen_host: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub default_topics: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct McpServer {
pub name: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, rename = "type")]
pub transport: McpTransport,
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: std::collections::BTreeMap<String, String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Marketplace {
pub name: String,
pub source: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarketplaceSource {
Git,
Path,
}
impl Marketplace {
#[must_use]
pub fn classify_source(&self) -> MarketplaceSource {
classify_source(&self.source)
}
}
#[must_use]
pub fn classify_source(source: &str) -> MarketplaceSource {
const GIT_SCHEMES: &[&str] = &["https://", "http://", "ssh://", "git://", "git+ssh://"];
if GIT_SCHEMES.iter().any(|s| source.starts_with(s)) {
return MarketplaceSource::Git;
}
if source.starts_with('/')
|| source.starts_with("~")
|| source.starts_with("./")
|| source.starts_with("../")
{
return MarketplaceSource::Path;
}
if let Some(colon) = source.find(':') {
let before = &source[..colon];
let after = &source[colon + 1..];
if !before.is_empty() && !after.is_empty() && !before.contains('/') {
return MarketplaceSource::Git;
}
}
MarketplaceSource::Path
}
pub const RESERVED_OFFICIAL_MARKETPLACES: &[&str] = &[
"claude-plugins-official",
"claude-code-plugins",
"claude-code-marketplace",
"anthropic-marketplace",
"anthropic-plugins",
];
pub const OFFICIAL_MARKETPLACE_OWNER: &str = "anthropics";
#[must_use]
pub fn is_reserved_official_marketplace(name: &str) -> bool {
RESERVED_OFFICIAL_MARKETPLACES.contains(&name)
}
#[must_use]
pub fn github_owner_repo(source: &str) -> Option<(&str, &str)> {
let rest = source
.strip_prefix("https://github.com/")
.or_else(|| source.strip_prefix("http://github.com/"))
.or_else(|| source.strip_prefix("ssh://git@github.com/"))
.or_else(|| source.strip_prefix("git://github.com/"))
.or_else(|| source.strip_prefix("git@github.com:"))?;
let mut segments = rest.trim_end_matches('/').splitn(3, '/');
let owner = segments.next().filter(|s| !s.is_empty())?;
let repo = segments.next().filter(|s| !s.is_empty())?;
let repo = repo.strip_suffix(".git").unwrap_or(repo);
if repo.is_empty() {
return None;
}
Some((owner, repo))
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct StateConfig {
#[serde(default)]
pub tools: Vec<StateTool>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct StateTool {
pub env: String,
pub subdir: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct PluginCollection {
pub name: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub plugins: Vec<String>,
}
#[must_use]
pub fn split_plugin_ref(s: &str) -> Option<(&str, &str)> {
let (marketplace, plugin) = s.split_once(':')?;
if marketplace.is_empty() || plugin.is_empty() || plugin.contains(':') {
return None;
}
Some((marketplace, plugin))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn cache_defaults_to_normal_when_hashing_absent() {
let cache: Cache =
serde_yaml::from_str("cache_dir: ~/.cache/llmenv\nsync_interval_minutes: 60\n")
.expect("parse minimal cache");
assert_eq!(cache.hashing, HashingMode::Normal);
assert_eq!(Cache::default().hashing, HashingMode::Normal);
}
#[test]
fn cache_parses_each_strictness_position() {
for (text, expected) in [
("loose", HashingMode::Loose),
("normal", HashingMode::Normal),
("strict", HashingMode::Strict),
] {
let cache: Cache = serde_yaml::from_str(&format!(
"cache_dir: ~/.cache/llmenv\nsync_interval_minutes: 60\nhashing: {text}\n"
))
.expect("parse explicit cache");
assert_eq!(cache.hashing, expected, "hashing: {text}");
}
}
#[test]
fn classify_scp_style_is_git() {
assert_eq!(
classify_source("git@github.com:owner/repo"),
MarketplaceSource::Git
);
}
#[test]
fn reserved_official_marketplace_names_detected() {
for name in [
"claude-plugins-official",
"claude-code-plugins",
"claude-code-marketplace",
"anthropic-marketplace",
"anthropic-plugins",
] {
assert!(is_reserved_official_marketplace(name), "{name} reserved");
}
for name in ["superpowers", "dev-commons", "claude", "my-claude-plugins"] {
assert!(!is_reserved_official_marketplace(name), "{name} free");
}
}
#[test]
fn github_owner_repo_parses_common_forms() {
let want = Some(("anthropics", "claude-code"));
assert_eq!(
github_owner_repo("https://github.com/anthropics/claude-code"),
want
);
assert_eq!(
github_owner_repo("https://github.com/anthropics/claude-code.git"),
want
);
assert_eq!(
github_owner_repo("https://github.com/anthropics/claude-code/"),
want
);
assert_eq!(
github_owner_repo("git@github.com:anthropics/claude-code.git"),
want
);
assert_eq!(
github_owner_repo("ssh://git@github.com/anthropics/claude-code"),
want
);
}
#[test]
fn github_owner_repo_rejects_non_github_and_malformed() {
assert_eq!(
github_owner_repo("https://gitlab.com/anthropics/claude-code"),
None
);
assert_eq!(github_owner_repo("https://github.com/anthropics"), None);
assert_eq!(github_owner_repo("/local/path"), None);
assert_eq!(github_owner_repo("not a url"), None);
}
#[test]
fn split_plugin_ref_roundtrips() {
assert_eq!(
split_plugin_ref("superpowers:caveman"),
Some(("superpowers", "caveman"))
);
}
#[test]
fn split_plugin_ref_rejects_malformed() {
assert_eq!(split_plugin_ref("nocolon"), None);
assert_eq!(split_plugin_ref(":plugin"), None);
assert_eq!(split_plugin_ref("market:"), None);
assert_eq!(split_plugin_ref("a:b:c"), None);
}
proptest! {
#[test]
fn prop_git_scheme_sources_classified_git(
scheme in prop_oneof![
Just("https://"),
Just("http://"),
Just("ssh://"),
Just("git://"),
Just("git+ssh://"),
],
rest in "[a-z0-9./_-]{1,30}",
) {
let source = format!("{scheme}{rest}");
prop_assert_eq!(classify_source(&source), MarketplaceSource::Git);
}
#[test]
fn prop_absolute_and_tilde_paths_classified_path(
prefix in prop_oneof![Just("/"), Just("~/"), Just("./"), Just("../")],
rest in "[a-z0-9._-]{0,30}",
) {
let source = format!("{prefix}{rest}");
prop_assert_eq!(classify_source(&source), MarketplaceSource::Path);
}
#[test]
fn prop_classify_source_never_panics(source in ".{0,60}") {
let _ = classify_source(&source);
}
#[test]
fn prop_split_plugin_ref_roundtrip(
market in "[a-z0-9_-]{1,15}",
plugin in "[a-z0-9_-]{1,15}",
) {
let s = format!("{market}:{plugin}");
prop_assert_eq!(split_plugin_ref(&s), Some((market.as_str(), plugin.as_str())));
}
#[test]
fn prop_split_plugin_ref_no_colon_is_none(s in "[a-z0-9_-]{0,30}") {
prop_assert_eq!(split_plugin_ref(&s), None);
}
#[test]
fn prop_split_plugin_ref_never_panics(s in ".{0,60}") {
let _ = split_plugin_ref(&s);
}
#[test]
fn prop_github_owner_repo_roundtrip(
owner in "[a-z0-9][a-z0-9-]{0,20}",
repo in "[a-z0-9][a-z0-9._-]{0,20}",
) {
prop_assume!(!repo.ends_with(".git"));
let source = format!("https://github.com/{owner}/{repo}");
prop_assert_eq!(github_owner_repo(&source), Some((owner.as_str(), repo.as_str())));
}
#[test]
fn prop_github_owner_repo_never_panics(source in ".{0,80}") {
let _ = github_owner_repo(&source);
}
#[test]
fn prop_state_config_yaml_roundtrip(
tools in proptest::collection::vec(
("[A-Z][A-Z0-9_]{0,10}", "[a-z0-9][a-z0-9._-]{0,12}"),
0..5,
),
) {
prop_assume!({
let names: std::collections::HashSet<_> = tools.iter().map(|(e, _)| e).collect();
names.len() == tools.len()
});
let cfg = StateConfig {
tools: tools
.into_iter()
.map(|(env, subdir)| StateTool { env, subdir })
.collect(),
};
let yaml = serde_yaml::to_string(&cfg).expect("serialize StateConfig");
let back: StateConfig = serde_yaml::from_str(&yaml).expect("deserialize StateConfig");
prop_assert_eq!(cfg, back);
}
}
}