use crate::{
AgentConfig, McpServerConfig,
paths::{AGENTS_DIR, LOCAL_DIR, PACKAGES_DIR, SKILLS_DIR},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ManifestConfig {
#[serde(default)]
pub package: Option<PackageMeta>,
#[serde(default)]
pub mcps: BTreeMap<String, McpServerConfig>,
#[serde(default)]
pub agents: BTreeMap<String, AgentConfig>,
#[serde(default)]
pub disabled: DisabledItems,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DisabledItems {
#[serde(default)]
pub providers: Vec<String>,
#[serde(default)]
pub mcps: Vec<String>,
#[serde(default)]
pub skills: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PackageMeta {
#[serde(default)]
pub name: String,
#[serde(default)]
pub repository: String,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub setup: Option<Setup>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Setup {
pub script: String,
}
impl ManifestConfig {
pub fn load(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("cannot read manifest at {}", path.display()))?;
let manifest: Self = toml::from_str(&content)
.with_context(|| format!("invalid manifest at {}", path.display()))?;
Ok(Some(manifest))
}
}
#[derive(Debug, Default)]
pub struct ResolvedManifest {
pub mcps: BTreeMap<String, McpServerConfig>,
pub agents: BTreeMap<String, AgentConfig>,
pub skill_dirs: Vec<PathBuf>,
pub agent_dirs: Vec<PathBuf>,
pub package_skill_dirs: BTreeMap<String, PathBuf>,
pub disabled: DisabledItems,
}
pub fn resolve_manifests(config_dir: &Path) -> (ResolvedManifest, Vec<String>) {
let mut resolved = ResolvedManifest::default();
let mut warnings = Vec::new();
let local_skills = config_dir.join(SKILLS_DIR);
if local_skills.exists() {
resolved.skill_dirs.push(local_skills);
}
let local_agents = config_dir.join(AGENTS_DIR);
if local_agents.exists() {
resolved.agent_dirs.push(local_agents);
}
let local_manifest_path = config_dir.join(LOCAL_DIR).join("CrabTalk.toml");
if let Ok(Some(manifest)) = ManifestConfig::load(&local_manifest_path) {
merge_manifest(&mut resolved, &manifest, "local", &mut warnings);
}
let packages_dir = config_dir.join(PACKAGES_DIR);
if let Ok(scopes) = std::fs::read_dir(&packages_dir) {
let mut scope_entries: Vec<_> = scopes.flatten().collect();
scope_entries.sort_by_key(|e| e.file_name());
for scope_entry in scope_entries {
let scope_path = scope_entry.path();
if !scope_path.is_dir() {
if scope_path.extension().is_some_and(|e| e == "toml") {
load_package_manifest(config_dir, &scope_path, &mut resolved, &mut warnings);
}
continue;
}
if let Ok(packages) = std::fs::read_dir(&scope_path) {
let mut pkg_entries: Vec<_> = packages.flatten().collect();
pkg_entries.sort_by_key(|e| e.file_name());
for pkg_entry in pkg_entries {
let pkg_path = pkg_entry.path();
if pkg_path.extension().is_some_and(|e| e == "toml") {
load_package_manifest(config_dir, &pkg_path, &mut resolved, &mut warnings);
}
}
}
}
}
if let Some(ref home) = dirs::home_dir() {
for dir in [".claude/skills", ".codex/skills", ".openclaw/skills"] {
let path = home.join(dir);
if path.exists() {
resolved.skill_dirs.push(path);
}
}
}
(resolved, warnings)
}
fn load_package_manifest(
config_dir: &Path,
path: &Path,
resolved: &mut ResolvedManifest,
warnings: &mut Vec<String>,
) {
let source = path
.strip_prefix(config_dir.join(PACKAGES_DIR))
.unwrap_or(path)
.to_string_lossy()
.into_owned();
let manifest = match ManifestConfig::load(path) {
Ok(Some(m)) => m,
Ok(None) => return,
Err(e) => {
tracing::warn!("failed to load package manifest {}: {e}", path.display());
return;
}
};
let package_id = source.strip_suffix(".toml").unwrap_or(&source).to_owned();
if let Some(ref pkg) = manifest.package
&& !pkg.repository.is_empty()
{
let slug = repo_slug(&pkg.repository);
let repo_dir = config_dir.join(".cache").join("repos").join(&slug);
if repo_dir.exists() {
resolved.skill_dirs.push(repo_dir.clone());
resolved
.package_skill_dirs
.insert(package_id, repo_dir.clone());
let agents = repo_dir.join("agents");
if agents.exists() && agents.is_dir() {
resolved.agent_dirs.push(agents);
}
}
}
merge_manifest(resolved, &manifest, &source, warnings);
}
fn merge_manifest(
resolved: &mut ResolvedManifest,
manifest: &ManifestConfig,
source: &str,
warnings: &mut Vec<String>,
) {
for (name, mcp) in &manifest.mcps {
if resolved.mcps.contains_key(name) {
let msg =
format!("MCP '{name}' from {source} conflicts with already-loaded MCP, skipping");
tracing::warn!("{msg}");
warnings.push(msg);
} else {
let mut cfg = mcp.clone();
if cfg.name.is_empty() {
cfg.name = name.clone();
}
resolved.mcps.insert(name.clone(), cfg);
}
}
for (name, agent) in &manifest.agents {
if resolved.agents.contains_key(name) {
let msg = format!(
"agent '{name}' from {source} conflicts with already-loaded agent, skipping"
);
tracing::warn!("{msg}");
warnings.push(msg);
} else {
resolved.agents.insert(name.clone(), agent.clone());
}
}
}
pub fn check_skill_conflicts(skill_dirs: &[PathBuf]) -> Vec<String> {
let mut seen = std::collections::BTreeMap::<String, &Path>::new();
let mut warnings = Vec::new();
for dir in skill_dirs {
if !dir.exists() {
continue;
}
for name in scan_skill_names(dir) {
if let Some(first_dir) = seen.get(&name) {
warnings.push(format!(
"skill '{name}' from {} conflicts with skill from {}, skipping",
dir.display(),
first_dir.display(),
));
} else {
seen.insert(name, dir);
}
}
}
warnings
}
pub fn scan_skill_names(dir: &Path) -> Vec<String> {
let mut results = Vec::new();
scan_skill_names_inner(dir, &mut results);
results
}
fn scan_skill_names_inner(dir: &Path, results: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if entry
.file_name()
.to_str()
.is_some_and(|n| n.starts_with('.'))
{
continue;
}
let skill_file = path.join("SKILL.md");
if skill_file.exists()
&& let Some(name) = extract_skill_name(&skill_file)
{
results.push(name);
}
scan_skill_names_inner(&path, results);
}
}
fn extract_skill_name(path: &Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let (frontmatter, _) = crate::utils::split_yaml_frontmatter(&content).ok()?;
for line in frontmatter.lines() {
let line = line.trim();
if let Some(value) = line.strip_prefix("name:") {
let value = value.trim().trim_matches('"').trim_matches('\'');
if !value.is_empty() {
return Some(value.to_owned());
}
}
}
None
}
pub fn repo_slug(url: &str) -> String {
url.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
pub fn load_agents_dir(path: &Path) -> Result<Vec<(String, String)>> {
if !path.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<_> = std::fs::read_dir(path)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
.collect();
entries.sort_by_key(|e| e.file_name());
let mut agents = Vec::with_capacity(entries.len());
for entry in entries {
let stem = entry
.path()
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let content = std::fs::read_to_string(entry.path())?;
agents.push((stem, content));
}
Ok(agents)
}
pub fn load_agents_dirs(dirs: &[PathBuf]) -> Result<Vec<(String, String)>> {
let mut seen = std::collections::BTreeSet::new();
let mut all = Vec::new();
for dir in dirs {
for (stem, content) in load_agents_dir(dir)? {
if seen.insert(stem.clone()) {
all.push((stem, content));
}
}
}
Ok(all)
}