use std::path::{Path, PathBuf};
use crate::cli::{SkillArgs, SkillTarget};
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
const MANAGED_MARKER: &str = "dbmd-managed-skill:v1";
const SKILL_BODY: &str = include_str!("../../skills/db-md/SKILL.md");
pub fn install(ctx: &Context, args: &SkillArgs) -> CliResult {
let home = home_dir()?;
let planned = plan(args.target, &home);
for (_, _, file) in &planned {
ensure_installable(file)?;
}
let mut outcomes = Vec::with_capacity(planned.len());
for (target, dir, file) in planned {
std::fs::create_dir_all(&dir).map_err(|e| io_err("creating skill directory", &dir, e))?;
std::fs::write(&file, SKILL_BODY).map_err(|e| io_err("writing skill", &file, e))?;
outcomes.push((target, file, "installed"));
}
emit(ctx, &outcomes);
Ok(())
}
pub fn uninstall(ctx: &Context, args: &SkillArgs) -> CliResult {
let home = home_dir()?;
let planned = plan(args.target, &home);
for (_, _, file) in &planned {
if file.exists() {
ensure_managed(file, "uninstall")?;
}
}
let mut outcomes = Vec::with_capacity(planned.len());
for (target, dir, file) in planned {
if !file.exists() {
outcomes.push((target, file, "noop"));
continue;
}
remove_skill(&dir, &file).map_err(|e| io_err("removing skill", &file, e))?;
outcomes.push((target, file, "uninstalled"));
}
emit(ctx, &outcomes);
Ok(())
}
fn plan(explicit: Option<SkillTarget>, home: &Path) -> Vec<(SkillTarget, PathBuf, PathBuf)> {
resolve_targets(explicit, home)
.into_iter()
.map(|t| {
let (dir, file) = paths_for(t, home);
(t, dir, file)
})
.collect()
}
fn remove_skill(dir: &Path, file: &Path) -> std::io::Result<()> {
std::fs::remove_file(file)?;
match std::fs::remove_dir(dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => Ok(()),
Err(e) => Err(e),
}
}
fn ensure_installable(file: &Path) -> CliResult {
if !file.exists() {
return Ok(());
}
ensure_managed(file, "install")
}
fn ensure_managed(file: &Path, action: &str) -> CliResult {
let body = std::fs::read_to_string(file).map_err(|e| io_err("reading skill", file, e))?;
if body.contains(MANAGED_MARKER) {
return Ok(());
}
Err(CliError::new(
ExitCode::Collision,
"SKILL_NOT_MANAGED",
format!(
"refusing to {action} unmanaged db.md skill file at {}",
file.display()
),
)
.with_hint("move the file aside, or remove it yourself if it is not managed by dbmd"))
}
fn resolve_targets(explicit: Option<SkillTarget>, home: &Path) -> Vec<SkillTarget> {
if let Some(t) = explicit {
return vec![t];
}
let mut targets = Vec::new();
if home.join(".claude").is_dir() {
targets.push(SkillTarget::ClaudeCode);
}
if home.join(".codex").is_dir() {
targets.push(SkillTarget::Codex);
}
if targets.is_empty() {
targets.push(SkillTarget::ClaudeCode);
}
targets
}
fn paths_for(target: SkillTarget, home: &Path) -> (PathBuf, PathBuf) {
let skills_root = match target {
SkillTarget::ClaudeCode => home.join(".claude").join("skills"),
SkillTarget::Codex => home.join(".codex").join("skills"),
};
let dir = skills_root.join("db-md");
let file = dir.join("SKILL.md");
(dir, file)
}
fn home_dir() -> Result<PathBuf, CliError> {
std::env::var_os("HOME")
.filter(|h| !h.is_empty())
.map(PathBuf::from)
.ok_or_else(|| {
CliError::new(
ExitCode::Runtime,
"NO_HOME",
"cannot resolve the home directory ($HOME is unset)",
)
.with_hint("set $HOME so the skill can be written under ~/.claude or ~/.codex")
})
}
fn io_err(action: &str, path: &Path, e: std::io::Error) -> CliError {
CliError::new(
ExitCode::Runtime,
"IO_ERROR",
format!("{action} {}: {e}", path.display()),
)
}
fn emit(ctx: &Context, outcomes: &[(SkillTarget, PathBuf, &str)]) {
if ctx.json {
let arr: Vec<serde_json::Value> = outcomes
.iter()
.map(|(target, file, action)| {
serde_json::json!({
"target": target.as_str(),
"path": file.display().to_string(),
"action": *action,
})
})
.collect();
println!("{}", serde_json::Value::Array(arr));
return;
}
for (target, file, action) in outcomes {
let target = target.as_str();
let path = file.display();
match *action {
"installed" => println!(
"Installed the db.md skill for {target} at {path}.\n \
Start a new {target} session and it will know how to operate a db.md store with `dbmd`."
),
"uninstalled" => println!("Removed the db.md skill for {target} ({path})."),
"noop" => println!("No db.md skill installed for {target} ({path}); nothing to remove."),
other => println!("{other}: {target} ({path})"),
}
}
}