use std::path::PathBuf;
use anyhow::{Context, Result};
use directories::BaseDirs;
fn home() -> Result<PathBuf> {
let base = BaseDirs::new().context("could not determine the home directory")?;
Ok(base.home_dir().to_path_buf())
}
pub fn home_dir() -> Option<PathBuf> {
home().ok()
}
fn env_dir(var: &str) -> Option<PathBuf> {
std::env::var_os(var)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
pub fn galdr_root() -> Result<PathBuf> {
if let Some(root) = env_dir("GALDR_ROOT") {
return Ok(root);
}
Ok(home()?.join(".galdr"))
}
pub fn active_flag() -> Result<PathBuf> {
Ok(galdr_root()?.join("active"))
}
pub fn spans_dir() -> Result<PathBuf> {
Ok(galdr_root()?.join("spans"))
}
pub fn span_file(rec_id: &str) -> Result<PathBuf> {
Ok(spans_dir()?.join(format!("{rec_id}.jsonl")))
}
pub fn recordings_dir() -> Result<PathBuf> {
Ok(galdr_root()?.join("recordings"))
}
pub fn frames_root() -> Result<PathBuf> {
Ok(galdr_root()?.join("frames"))
}
pub fn frames_dir(rec_id: &str) -> Result<PathBuf> {
Ok(frames_root()?.join(rec_id))
}
pub fn outcomes_dir() -> Result<PathBuf> {
Ok(galdr_root()?.join("outcomes"))
}
pub fn skill_usage_log() -> Result<PathBuf> {
Ok(outcomes_dir()?.join("skill_usage.jsonl"))
}
pub fn skill_outcomes_log() -> Result<PathBuf> {
Ok(outcomes_dir()?.join("skill_outcomes.jsonl"))
}
pub fn recording_file(rec_id: &str) -> Result<PathBuf> {
Ok(recordings_dir()?.join(format!("{rec_id}.json")))
}
pub fn ensure_dirs() -> Result<()> {
let root = galdr_root()?;
std::fs::create_dir_all(&root)?;
restrict_to_owner(&root);
std::fs::create_dir_all(spans_dir()?)?;
std::fs::create_dir_all(recordings_dir()?)?;
std::fs::create_dir_all(outcomes_dir()?)?;
Ok(())
}
fn restrict_to_owner(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
let _ = path;
}
pub fn socket_path() -> Result<PathBuf> {
Ok(galdr_root()?.join("galdrd.sock"))
}
pub fn pidfile() -> Result<PathBuf> {
Ok(galdr_root()?.join("galdrd.pid"))
}
pub fn catalog_db() -> Result<PathBuf> {
Ok(galdr_root()?.join("catalog.sqlite"))
}
pub fn config_file() -> Result<PathBuf> {
Ok(galdr_root()?.join("config.json"))
}
pub fn claude_settings() -> Result<PathBuf> {
Ok(home()?.join(".claude").join("settings.json"))
}
pub fn codex_hooks() -> Result<PathBuf> {
Ok(home()?.join(".codex").join("hooks.json"))
}
pub fn cursor_hooks() -> Result<PathBuf> {
Ok(home()?.join(".cursor").join("hooks.json"))
}
pub fn skills_root() -> Result<PathBuf> {
if let Some(root) = env_dir("GALDR_SKILLS_ROOT") {
return Ok(root);
}
Ok(home()?.join(".agents").join("skills"))
}
pub fn skill_dir(name: &str) -> Result<PathBuf> {
validate_skill_name(name)?;
Ok(skills_root()?.join(name))
}
pub fn ensure_not_symlinked(dir: &std::path::Path) -> Result<()> {
use anyhow::bail;
if let Ok(meta) = std::fs::symlink_metadata(dir)
&& meta.file_type().is_symlink()
{
bail!(
"skill directory {} is a symlink; refusing to write through it",
dir.display()
);
}
Ok(())
}
fn validate_skill_name(name: &str) -> Result<()> {
use anyhow::bail;
if name.is_empty() {
bail!("skill name cannot be empty");
}
if name == "." || name == ".." {
bail!("invalid skill name '{name}'");
}
if name.contains('/') || name.contains('\\') {
bail!("skill name '{name}' must not contain a path separator");
}
if name.contains('\0') || name.chars().any(|c| c.is_control()) {
bail!("skill name contains a control character");
}
Ok(())
}