use crate::config::{ENV_CONFIG_DIR, resolve_config_dir};
use crate::config_schema::{ConfigSchemaKind, validate_yaml_value};
use crate::path_utils::validate_path_str;
use crate::skills::catalog::types::{ConfigEntry, SkillsConfig, SkillsPaths, TargetMode};
use crate::skills::shared::git::find_git_root;
use anyhow::{Result, anyhow};
use regex::Regex;
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const ENV_SKILLS_DIR: &str = "LLMAN_SKILLS_DIR";
const LLMAN_CONFIG_FILE: &str = "config.yaml";
const SKILLS_DIR: &str = "skills";
const CONFIG_FILE: &str = "config.toml";
#[derive(Deserialize, Debug)]
struct TomlConfig {
version: Option<u32>,
#[serde(default)]
target: Vec<TomlEntry>,
#[serde(default)]
source: Vec<TomlEntry>,
}
#[derive(Deserialize, Debug)]
struct TomlEntry {
id: String,
agent: String,
scope: String,
path: String,
mode: Option<String>,
#[serde(default = "default_true")]
enabled: bool,
}
#[derive(Deserialize, Debug)]
struct LlmanConfig {
skills: Option<LlmanSkillsConfig>,
}
#[derive(Deserialize, Debug)]
struct LlmanSkillsConfig {
dir: Option<String>,
}
fn default_true() -> bool {
true
}
impl SkillsPaths {
pub fn resolve() -> Result<Self> {
Self::resolve_with_override(None)
}
pub fn resolve_with_override(cli_override: Option<&Path>) -> Result<Self> {
let root = resolve_skills_root(cli_override)?;
Ok(Self {
root: root.clone(),
config_path: root.join(CONFIG_FILE),
})
}
pub fn ensure_dirs(&self) -> Result<()> {
fs::create_dir_all(&self.root)?;
Ok(())
}
}
fn resolve_skills_root(cli_override: Option<&Path>) -> Result<PathBuf> {
if let Some(path) = cli_override {
return resolve_skills_root_from_cli(path);
}
if let Ok(env_skills_dir) = env::var(ENV_SKILLS_DIR) {
return resolve_skills_root_from_env(&env_skills_dir);
}
let config_dir = resolve_config_dir(None)?;
resolve_skills_root_with(None, None, &config_dir)
}
fn resolve_skills_root_with(
cli_override: Option<&Path>,
env_skills_dir: Option<&str>,
config_dir: &Path,
) -> Result<PathBuf> {
if let Some(path) = cli_override {
return resolve_skills_root_from_cli(path);
}
if let Some(env_skills_dir) = env_skills_dir {
return resolve_skills_root_from_env(env_skills_dir);
}
let global_config = config_dir.join(LLMAN_CONFIG_FILE);
if let Some(skills_root) = load_skills_root_from_config_path(&global_config, config_dir)? {
return Ok(skills_root);
}
Ok(config_dir.join(SKILLS_DIR))
}
fn resolve_skills_root_from_cli(path: &Path) -> Result<PathBuf> {
let raw = path.to_string_lossy();
validate_path_str(&raw)
.map_err(|e| anyhow!(t!("skills.config.skills_dir_invalid_cli", error = e)))?;
expand_path(&raw)
}
fn resolve_skills_root_from_env(raw: &str) -> Result<PathBuf> {
validate_path_str(raw)
.map_err(|e| anyhow!(t!("skills.config.skills_dir_invalid_env", error = e)))?;
expand_path(raw)
}
fn resolve_skills_root_from_config(raw: &str, config_dir: &Path) -> Result<PathBuf> {
validate_path_str(raw)
.map_err(|e| anyhow!(t!("skills.config.skills_dir_invalid_config", error = e)))?;
expand_path_with(raw, None, |key| {
if key == ENV_CONFIG_DIR {
Some(config_dir.to_string_lossy().to_string())
} else {
env::var(key).ok()
}
})
}
fn load_skills_root_from_config_path(path: &Path, config_dir: &Path) -> Result<Option<PathBuf>> {
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(path)
.map_err(|e| anyhow!(t!("skills.config.llman_read_failed", error = e)))?;
let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
.map_err(|e| anyhow!(t!("skills.config.llman_parse_failed", error = e)))?;
if let Err(error) = validate_yaml_value(ConfigSchemaKind::Global, &yaml_value) {
return Err(anyhow!(t!(
"skills.config.llman_schema_invalid",
path = path.display(),
error = error
)));
}
let parsed: LlmanConfig = serde_yaml::from_value(yaml_value)
.map_err(|e| anyhow!(t!("skills.config.llman_parse_failed", error = e)))?;
if let Some(skills) = parsed.skills
&& let Some(dir) = skills.dir
{
return Ok(Some(resolve_skills_root_from_config(&dir, config_dir)?));
}
Ok(None)
}
pub fn load_config(paths: &SkillsPaths) -> Result<SkillsConfig> {
if paths.config_path.exists() {
let content = fs::read_to_string(&paths.config_path)
.map_err(|e| anyhow!(t!("skills.config.read_failed", error = e)))?;
let parsed: TomlConfig = toml::from_str(&content)
.map_err(|e| anyhow!(t!("skills.config.parse_failed", error = e)))?;
let version = parsed.version.unwrap_or(2);
if version != 2 {
return Err(anyhow!(t!(
"skills.config.unsupported_version",
version = version
)));
}
if !parsed.source.is_empty() {
return Err(anyhow!(t!("skills.config.sources_removed")));
}
let targets = resolve_target_entries(parsed.target)?;
Ok(SkillsConfig { targets })
} else {
Ok(SkillsConfig {
targets: default_targets()?,
})
}
}
fn resolve_target_entries(entries: Vec<TomlEntry>) -> Result<Vec<ConfigEntry>> {
let mut resolved = Vec::new();
for entry in entries {
let mode = parse_target_mode(entry.mode.as_deref())?;
let path = expand_path(&entry.path)?;
resolved.push(ConfigEntry {
id: entry.id,
agent: entry.agent,
scope: entry.scope,
path,
enabled: entry.enabled,
mode,
});
}
Ok(resolved)
}
fn parse_target_mode(raw: Option<&str>) -> Result<TargetMode> {
match raw.unwrap_or("link") {
"link" => Ok(TargetMode::Link),
"copy" => Ok(TargetMode::Copy),
"skip" => Ok(TargetMode::Skip),
other => Err(anyhow!(t!(
"skills.config.invalid_target_mode",
mode = other
))),
}
}
fn default_targets() -> Result<Vec<ConfigEntry>> {
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let home_dir = crate::config::try_home_dir();
let claude_home = env::var("CLAUDE_HOME").ok().map(PathBuf::from);
default_targets_with(&cwd, home_dir.as_deref(), claude_home.as_deref())
}
fn default_targets_with(
cwd: &Path,
home_dir: Option<&Path>,
claude_home: Option<&Path>,
) -> Result<Vec<ConfigEntry>> {
let (claude_project_path, claude_project_mode) = default_repo_scope_dir(cwd, ".claude/skills");
let (agents_project_path, agents_project_mode) = default_repo_scope_dir(cwd, ".agents/skills");
Ok(vec![
ConfigEntry {
id: "claude_user".to_string(),
agent: "claude".to_string(),
scope: "user".to_string(),
path: default_claude_user_dir_with(claude_home, home_dir)?,
enabled: true,
mode: TargetMode::Link,
},
ConfigEntry {
id: "claude_project".to_string(),
agent: "claude".to_string(),
scope: "project".to_string(),
path: claude_project_path,
enabled: true,
mode: claude_project_mode,
},
ConfigEntry {
id: "agents_project".to_string(),
agent: "agents".to_string(),
scope: "project".to_string(),
path: agents_project_path,
enabled: true,
mode: agents_project_mode,
},
])
}
fn default_claude_user_dir_with(
claude_home: Option<&Path>,
home_dir: Option<&Path>,
) -> Result<PathBuf> {
if let Some(home) = claude_home {
return Ok(home.join("skills"));
}
expand_path_with("~/.claude/skills", home_dir, |_key| None)
}
fn default_repo_scope_dir(cwd: &Path, relative: &str) -> (PathBuf, TargetMode) {
if let Some(repo_root) = find_git_root(cwd) {
return (repo_root.join(relative), TargetMode::Copy);
}
(cwd.join(relative), TargetMode::Skip)
}
fn expand_path(raw: &str) -> Result<PathBuf> {
expand_path_with(raw, None, |key| env::var(key).ok())
}
fn expand_path_with<F>(raw: &str, home_dir: Option<&Path>, env_lookup: F) -> Result<PathBuf>
where
F: Fn(&str) -> Option<String>,
{
let expanded = expand_env_vars_with(raw, env_lookup);
let path = expand_tilde_with(&expanded, home_dir)?;
Ok(PathBuf::from(path))
}
fn expand_tilde_with(path: &str, home_dir: Option<&Path>) -> Result<String> {
if path == "~" || path.starts_with("~/") {
let home = home_dir
.map(Path::to_path_buf)
.or_else(crate::config::try_home_dir)
.ok_or_else(|| anyhow!(t!("skills.config.home_missing")))?;
if path == "~" {
return Ok(home.to_string_lossy().to_string());
}
let trimmed = path.trim_start_matches("~/");
return Ok(home.join(trimmed).to_string_lossy().to_string());
}
Ok(path.to_string())
}
fn expand_env_vars_with<F>(input: &str, lookup: F) -> String
where
F: Fn(&str) -> Option<String>,
{
let re = Regex::new(r"\$([A-Za-z0-9_]+)|\$\{([^}]+)\}").unwrap();
re.replace_all(input, |caps: ®ex::Captures| {
let key = caps.get(1).or_else(|| caps.get(2)).unwrap().as_str();
lookup(key).unwrap_or_else(|| caps.get(0).unwrap().as_str().to_string())
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
#[test]
fn test_expand_env_vars() {
let vars: HashMap<&str, &str> = HashMap::from([("SKILLS_TEST_PATH", "/tmp/skills-test")]);
let expanded = expand_env_vars_with("$SKILLS_TEST_PATH/dir", |key| {
vars.get(key).map(|value| (*value).to_string())
});
assert_eq!(expanded, "/tmp/skills-test/dir");
}
#[test]
fn test_load_default_config() {
let temp = TempDir::new().expect("temp dir");
let config_dir = temp.path().join("config");
fs::create_dir_all(&config_dir).expect("create config dir");
let root = resolve_skills_root_with(None, None, &config_dir).expect("paths");
let paths = SkillsPaths {
root: root.clone(),
config_path: root.join(CONFIG_FILE),
};
assert_eq!(paths.root, config_dir.join("skills"));
let config = load_config(&paths).expect("config");
assert!(!config.targets.is_empty());
}
#[test]
fn test_resolve_skills_root_cli_overrides_env_and_config() {
let temp = TempDir::new().expect("temp dir");
let cli_root = temp.path().join("cli-root");
let env_root = temp.path().join("env-root");
let config_dir = temp.path().join("config");
let resolved = resolve_skills_root_with(
Some(cli_root.as_path()),
Some(env_root.to_str().unwrap()),
&config_dir,
)
.expect("paths");
assert_eq!(resolved, cli_root);
}
#[test]
fn test_resolve_skills_root_env_overrides_config() {
let temp = TempDir::new().expect("temp dir");
let env_root = temp.path().join("env-root");
let global_root = temp.path().join("global-root");
let config_dir = temp.path().join("config");
fs::create_dir_all(&config_dir).expect("create config dir");
fs::write(
config_dir.join("config.yaml"),
format!(
"version: \"0.1\"\ntools: {{}}\nskills:\n dir: {}\n",
global_root.display()
),
)
.expect("write global config");
let resolved =
resolve_skills_root_with(None, Some(env_root.to_str().unwrap()), &config_dir)
.expect("paths");
assert_eq!(resolved, env_root);
}
#[test]
fn test_resolve_skills_root_local_config_ignored() {
let temp = TempDir::new().expect("temp dir");
let global_root = temp.path().join("global-root");
let config_dir = temp.path().join("config");
fs::create_dir_all(&config_dir).expect("create config dir");
fs::write(
config_dir.join("config.yaml"),
format!(
"version: \"0.1\"\ntools: {{}}\nskills:\n dir: {}\n",
global_root.display()
),
)
.expect("write global config");
let resolved = resolve_skills_root_with(None, None, &config_dir).expect("paths");
assert_eq!(resolved, global_root);
}
#[test]
fn test_resolve_skills_root_config_supports_llman_config_dir_placeholder() {
let temp = TempDir::new().expect("temp dir");
let config_dir = temp.path().join("config");
fs::create_dir_all(&config_dir).expect("create config dir");
fs::write(
config_dir.join("config.yaml"),
"version: \"0.1\"\ntools: {}\nskills:\n dir: \"$LLMAN_CONFIG_DIR/skills\"\n",
)
.expect("write global config");
let resolved = resolve_skills_root_with(None, None, &config_dir).expect("paths");
assert_eq!(resolved, config_dir.join("skills"));
}
#[test]
fn test_resolve_skills_root_global_config_fallback() {
let temp = TempDir::new().expect("temp dir");
let global_root = temp.path().join("global-root");
let config_dir = temp.path().join("config");
fs::create_dir_all(&config_dir).expect("create config dir");
fs::write(
config_dir.join("config.yaml"),
format!(
"version: \"0.1\"\ntools: {{}}\nskills:\n dir: {}\n",
global_root.display()
),
)
.expect("write global config");
let resolved = resolve_skills_root_with(None, None, &config_dir).expect("paths");
assert_eq!(resolved, global_root);
}
#[test]
fn test_rejects_v1_config() {
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
fs::create_dir_all(&skills_root).expect("create skills root");
fs::write(
skills_root.join("config.toml"),
"version = 1\n\n[[target]]\nid = \"claude\"\nagent = \"claude\"\nscope = \"user\"\npath = \"~/.claude/skills\"\n",
)
.expect("write config");
let paths = SkillsPaths {
root: skills_root.clone(),
config_path: skills_root.join("config.toml"),
};
let err = load_config(&paths).expect_err("should reject v1");
assert!(
err.to_string()
.contains("Unsupported skills config version")
);
}
#[test]
fn test_rejects_sources_in_v2_config() {
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
fs::create_dir_all(&skills_root).expect("create skills root");
fs::write(
skills_root.join("config.toml"),
"version = 2\n\n[[source]]\nid = \"claude_user\"\nagent = \"claude\"\nscope = \"user\"\npath = \"~/.claude/skills\"\n\n[[target]]\nid = \"claude_user\"\nagent = \"claude\"\nscope = \"user\"\npath = \"~/.claude/skills\"\n",
)
.expect("write config");
let paths = SkillsPaths {
root: skills_root.clone(),
config_path: skills_root.join("config.toml"),
};
let err = load_config(&paths).expect_err("should reject sources");
assert!(err.to_string().contains("[[source]]"));
}
#[test]
fn test_accepts_copy_target_mode() {
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
fs::create_dir_all(&skills_root).expect("create skills root");
fs::write(
skills_root.join("config.toml"),
"version = 2\n\n[[target]]\nid = \"claude_user\"\nagent = \"claude\"\nscope = \"user\"\npath = \"~/.claude/skills\"\nmode = \"copy\"\n",
)
.expect("write config");
let paths = SkillsPaths {
root: skills_root.clone(),
config_path: skills_root.join("config.toml"),
};
let config = load_config(&paths).expect("config");
assert_eq!(config.targets.len(), 1);
assert_eq!(config.targets[0].mode, TargetMode::Copy);
}
#[test]
fn test_default_targets_include_repo_scopes_inside_git_repo() {
let temp = TempDir::new().expect("temp dir");
let repo_root = temp.path().join("repo");
let nested = repo_root.join("nested");
fs::create_dir_all(repo_root.join(".git")).expect("create .git");
fs::create_dir_all(&nested).expect("create nested");
let home_root = temp.path().join("home");
fs::create_dir_all(&home_root).expect("create home");
let targets =
default_targets_with(&nested, Some(&home_root), None).expect("default targets");
let claude_project = targets
.iter()
.find(|target| target.id == "claude_project")
.expect("claude_project target");
assert_eq!(claude_project.mode, TargetMode::Copy);
assert_eq!(claude_project.path, repo_root.join(".claude/skills"));
let agents_project = targets
.iter()
.find(|target| target.id == "agents_project")
.expect("agents_project target");
assert_eq!(agents_project.mode, TargetMode::Copy);
assert_eq!(agents_project.path, repo_root.join(".agents/skills"));
}
#[test]
fn test_default_targets_mark_repo_scopes_read_only_outside_git_repo() {
let temp = TempDir::new().expect("temp dir");
let cwd = temp.path().join("work");
fs::create_dir_all(&cwd).expect("create cwd");
let home_root = temp.path().join("home");
fs::create_dir_all(&home_root).expect("create home");
let targets = default_targets_with(&cwd, Some(&home_root), None).expect("default");
let claude_project = targets
.iter()
.find(|target| target.id == "claude_project")
.expect("claude_project target");
assert_eq!(claude_project.mode, TargetMode::Skip);
let agents_project = targets
.iter()
.find(|target| target.id == "agents_project")
.expect("agents_project target");
assert_eq!(agents_project.mode, TargetMode::Skip);
}
}