use anyhow::{Context, Result};
use std::path::Path;
use wcore::paths::{AGENTS_DIR, CONFIG_FILE, LOCAL_DIR, PLUGINS_DIR, SKILLS_DIR};
pub const DEFAULT_CONFIG: &str = include_str!("../../config.toml");
pub fn scaffold_config_dir(config_dir: &Path) -> Result<()> {
migrate_layout(config_dir);
std::fs::create_dir_all(config_dir.join(AGENTS_DIR))
.context("failed to create agents directory")?;
std::fs::create_dir_all(config_dir.join(SKILLS_DIR))
.context("failed to create skills directory")?;
std::fs::create_dir_all(config_dir.join(PLUGINS_DIR))
.context("failed to create plugins directory")?;
let config_toml = config_dir.join(CONFIG_FILE);
if !config_toml.exists() {
std::fs::write(&config_toml, DEFAULT_CONFIG)
.with_context(|| format!("failed to write {}", config_toml.display()))?;
}
Ok(())
}
fn migrate_layout(config_dir: &Path) {
let old_config = config_dir.join("crab.toml");
let new_config = config_dir.join(CONFIG_FILE);
if old_config.exists() && !new_config.exists() {
if let Err(e) = std::fs::rename(&old_config, &new_config) {
tracing::warn!("failed to rename crab.toml → config.toml: {e}");
} else {
tracing::info!("migrated crab.toml → config.toml");
}
}
let local_dir = config_dir.join(LOCAL_DIR);
let _ = std::fs::create_dir_all(&local_dir);
let old_skills = config_dir.join("skills");
let new_skills = config_dir.join(SKILLS_DIR);
if old_skills.exists() && old_skills.is_dir() && !new_skills.exists() {
if let Err(e) = std::fs::rename(&old_skills, &new_skills) {
tracing::warn!("failed to move skills/ → local/skills/: {e}");
} else {
tracing::info!("migrated skills/ → local/skills/");
}
}
let old_agents = config_dir.join("agents");
let new_agents = config_dir.join(AGENTS_DIR);
if old_agents.exists() && old_agents.is_dir() && !new_agents.exists() {
if let Err(e) = std::fs::rename(&old_agents, &new_agents) {
tracing::warn!("failed to move agents/ → local/agents/: {e}");
} else {
tracing::info!("migrated agents/ → local/agents/");
}
}
let config_path = config_dir.join(CONFIG_FILE);
if config_path.exists() {
migrate_mcps(&config_path, &local_dir.join("CrabTalk.toml"));
}
let manifest_path = local_dir.join("CrabTalk.toml");
if manifest_path.exists() && config_path.exists() {
migrate_disabled(&manifest_path, &config_path);
}
let old_packages = config_dir.join("packages");
let new_plugins = config_dir.join(PLUGINS_DIR);
if old_packages.exists() && old_packages.is_dir() && !new_plugins.exists() {
let _ = std::fs::create_dir_all(&new_plugins);
if let Ok(scopes) = std::fs::read_dir(&old_packages) {
for scope_entry in scopes.flatten() {
let scope_path = scope_entry.path();
if scope_path.is_dir() {
if let Ok(manifests) = std::fs::read_dir(&scope_path) {
for manifest in manifests.flatten() {
let src = manifest.path();
if src.extension().is_some_and(|e| e == "toml") {
let dst = new_plugins.join(manifest.file_name());
let _ = std::fs::rename(&src, &dst);
}
}
}
} else if scope_path.extension().is_some_and(|e| e == "toml") {
let dst = new_plugins.join(scope_entry.file_name());
let _ = std::fs::rename(&scope_path, &dst);
}
}
}
let _ = std::fs::remove_dir_all(&old_packages);
tracing::info!("migrated packages/ → plugins/");
}
let old_hub = config_dir.join("hub");
let new_registry = config_dir.join("registry");
if old_hub.exists() && old_hub.is_dir() && !new_registry.exists() {
if let Err(e) = std::fs::rename(&old_hub, &new_registry) {
tracing::warn!("failed to rename hub/ → registry/: {e}");
} else {
tracing::info!("migrated hub/ → registry/");
}
}
}
fn migrate_mcps(config_path: &Path, manifest_path: &Path) {
use toml_edit::DocumentMut;
let Ok(content) = std::fs::read_to_string(config_path) else {
return;
};
let Ok(mut doc) = content.parse::<DocumentMut>() else {
return;
};
let has_mcps = doc
.get("mcps")
.and_then(|v| v.as_table())
.is_some_and(|t| !t.is_empty());
if !has_mcps {
return;
}
let mut manifest_doc = if manifest_path.exists() {
std::fs::read_to_string(manifest_path)
.ok()
.and_then(|s| s.parse::<DocumentMut>().ok())
.unwrap_or_default()
} else {
DocumentMut::default()
};
if manifest_doc
.get("mcps")
.and_then(|v| v.as_table())
.is_none_or(|t| t.is_empty())
&& let Some(mcps) = doc.remove("mcps")
{
manifest_doc.insert("mcps", mcps);
tracing::info!("migrated [mcps] from config.toml → local/CrabTalk.toml");
}
if let Err(e) = std::fs::write(manifest_path, manifest_doc.to_string()) {
tracing::warn!("failed to write local/CrabTalk.toml: {e}");
return;
}
if let Err(e) = std::fs::write(config_path, doc.to_string()) {
tracing::warn!("failed to update config.toml after migration: {e}");
}
}
fn migrate_disabled(manifest_path: &Path, config_path: &Path) {
use toml_edit::DocumentMut;
let Ok(manifest_content) = std::fs::read_to_string(manifest_path) else {
return;
};
let Ok(mut manifest_doc) = manifest_content.parse::<DocumentMut>() else {
return;
};
let has_disabled = manifest_doc
.get("disabled")
.and_then(|v| v.as_table())
.is_some_and(|t| !t.is_empty());
if !has_disabled {
return;
}
let Ok(config_content) = std::fs::read_to_string(config_path) else {
return;
};
let Ok(mut config_doc) = config_content.parse::<DocumentMut>() else {
return;
};
if config_doc
.get("disabled")
.and_then(|v| v.as_table())
.is_some_and(|t| !t.is_empty())
{
return;
}
if let Some(disabled) = manifest_doc.remove("disabled") {
config_doc.insert("disabled", disabled);
if let Err(e) = std::fs::write(config_path, config_doc.to_string()) {
tracing::warn!("failed to write config.toml during disabled migration: {e}");
return;
}
if let Err(e) = std::fs::write(manifest_path, manifest_doc.to_string()) {
tracing::warn!("failed to update local/CrabTalk.toml after disabled migration: {e}");
return;
}
tracing::info!("migrated [disabled] from local/CrabTalk.toml → config.toml");
}
}