use crate::adapter::Capability;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub struct Paths {
pub root: PathBuf,
pub repo: PathBuf,
}
impl Paths {
pub fn discover(cwd: &Path) -> Result<Self> {
let home = dirs::home_dir().context("no home directory")?;
Ok(Self {
root: home.join(".omh"),
repo: repo_root(cwd)?,
})
}
pub fn adapters(&self) -> PathBuf {
self.root.join("adapters")
}
pub fn editors(&self) -> PathBuf {
self.root.join("editors")
}
pub fn stacks(&self) -> PathBuf {
self.root.join("stacks")
}
pub fn repo_stacks(&self) -> PathBuf {
self.repo.join(".omh").join("stacks")
}
pub fn markers(&self) -> PathBuf {
self.root.join("markers")
}
pub fn hooks(&self) -> PathBuf {
self.root.join(Capability::Hooks.source())
}
pub fn base(&self) -> PathBuf {
self.root.join("base")
}
pub fn creds(&self, harness: &str) -> PathBuf {
self.root.join("creds").join(harness)
}
pub fn worktrees(&self) -> PathBuf {
self.root.join("worktrees").join(self.repo_id())
}
pub fn staging(&self, session: &str, harness: &str) -> PathBuf {
self.runs().join(session).join(harness)
}
pub fn runs(&self) -> PathBuf {
self.root.join("run").join(self.repo_id())
}
pub fn scratch(&self, name: &str) -> PathBuf {
self.root.join("scratch").join(self.repo_id()).join(name)
}
pub fn keys(&self) -> PathBuf {
self.root.join("keys").join(self.repo_id())
}
pub fn shadows(&self) -> PathBuf {
self.root.join("shadow").join(self.repo_id())
}
pub fn notes(&self) -> PathBuf {
self.root.join("notes").join(self.repo_id())
}
pub fn cache_volume(&self) -> String {
format!("omh-cache-{}", self.repo_id())
}
pub fn network(&self) -> String {
format!("omh-{}", self.repo_id())
}
pub fn container(&self, session: &str) -> String {
format!("omh-{}-{session}", self.repo_id())
}
pub fn repo_name(&self) -> String {
self.repo_id()
}
fn repo_id(&self) -> String {
self.repo
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "repo".into())
}
}
pub struct Profile {
root: PathBuf,
repo: PathBuf,
}
impl Profile {
pub fn resolve(paths: &Paths) -> Self {
Self {
root: paths.root.clone(),
repo: paths.repo.clone(),
}
}
pub fn sources(&self, cap: Capability) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
for path in self.candidates(cap) {
if path
.try_exists()
.with_context(|| format!("reading {}", path.display()))?
{
out.push(path);
}
}
Ok(out)
}
fn candidates(&self, cap: Capability) -> Vec<PathBuf> {
let mut out = vec![self.root.join(cap.source())];
if cap == Capability::Hooks {
out.push(self.repo.join(".omh").join(cap.source()));
}
out
}
pub fn entries(&self, cap: Capability) -> Result<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for source in self.sources(cap)? {
if cap == Capability::Mcp {
out.extend(crate::render::parse_layers(&[source])?.into_keys());
continue;
}
let entries = std::fs::read_dir(&source)
.with_context(|| format!("reading {}", source.display()))?;
for entry in entries {
let entry = entry.with_context(|| format!("reading {}", source.display()))?;
let name = entry_name(&entry.file_name());
if crate::selection::validate_entry_name(&name, cap, &source).is_err() {
continue;
}
out.push(name);
}
}
out.sort();
out.dedup();
Ok(out)
}
#[allow(dead_code)]
pub fn declared(&self) -> Result<Vec<Capability>> {
let mut out = Vec::new();
for cap in Capability::ALL {
if !self.sources(cap)?.is_empty() {
out.push(cap);
}
}
Ok(out)
}
}
pub fn entry_name(file_name: &std::ffi::OsStr) -> String {
let path = Path::new(file_name);
path.file_stem()
.unwrap_or(file_name)
.to_string_lossy()
.into_owned()
}
pub fn repo_root(start: &Path) -> Result<PathBuf> {
let mut cur = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
loop {
if cur.join(".git").exists() {
return Ok(cur);
}
if !cur.pop() {
anyhow::bail!(
"{} is not inside a git repository\n\
omh isolates the agent on a worktree branch, which needs one.\n\
run `git init` first.",
start.display()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixture {
_dir: tempfile::TempDir,
paths: Paths,
}
fn fixture(layers: &[(&str, &str, &str)]) -> Fixture {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
for (layer, name, body) in layers {
let base = match *layer {
"catalogue" => paths.root.clone(),
"project" => paths.repo.join(".omh"),
"personal" => paths.root.join("profile"),
"shared" => paths.repo.join(".omh/profile"),
"local" => paths.repo.join(".omh/local"),
other => panic!("unknown layer {other}"),
};
let p = base.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
Fixture { _dir: dir, paths }
}
#[cfg(unix)]
#[test]
fn an_unreadable_catalogue_is_an_error_not_an_empty_one() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(&[("catalogue", "skills/mine/SKILL.md", "s")]);
std::fs::set_permissions(&f.paths.root, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = Profile::resolve(&f.paths).sources(Capability::Skills);
std::fs::set_permissions(&f.paths.root, std::fs::Permissions::from_mode(0o755)).unwrap();
let err = result.expect_err("unreadable must not read as undeclared");
assert!(
format!("{err:#}").contains("skills"),
"must name the path: {err:#}"
);
}
#[test]
fn absent_capabilities_are_skipped_not_faked() {
let f = fixture(&[("catalogue", "rules/tdd.md", "only")]);
let profile = Profile::resolve(&f.paths);
assert_eq!(profile.sources(Capability::Rules).unwrap().len(), 1);
assert!(profile.sources(Capability::Skills).unwrap().is_empty());
}
#[test]
fn a_name_omh_cannot_write_is_not_an_entry() {
let f = fixture(&[
("catalogue", "skills/review-diff/SKILL.md", "s"),
("catalogue", "skills/.DS_Store", "junk"),
]);
assert_eq!(
Profile::resolve(&f.paths)
.entries(Capability::Skills)
.unwrap(),
vec!["review-diff"],
"a dotfile is not a skill, and naming it would poison the settings file"
);
}
#[test]
fn declared_reports_only_present_capabilities() {
let f = fixture(&[
("catalogue", "rules/tdd.md", "r"),
("catalogue", "mcp.json", "{}"),
("catalogue", "skills/x/SKILL.md", "s"),
]);
let declared = Profile::resolve(&f.paths).declared().unwrap();
assert_eq!(
declared,
vec![Capability::Rules, Capability::Skills, Capability::Mcp]
);
}
#[test]
fn a_capability_resolves_to_one_catalogue_path() {
let f = fixture(&[("catalogue", "skills/mine/SKILL.md", "yours")]);
assert_eq!(
Profile::resolve(&f.paths)
.sources(Capability::Skills)
.unwrap(),
vec![f.paths.root.join("skills")]
);
}
#[test]
fn a_repo_cannot_declare_content_of_its_own() {
let f = fixture(&[
("shared", "skills/theirs/SKILL.md", "the repo's"),
("local", "skills/secret/SKILL.md", "yours, here"),
("shared", "mcp.json", "{}"),
]);
let profile = Profile::resolve(&f.paths);
assert!(profile.sources(Capability::Skills).unwrap().is_empty());
assert!(profile.sources(Capability::Mcp).unwrap().is_empty());
}
#[test]
fn hooks_resolve_to_the_catalogue_then_the_repo() {
let f = fixture(&[
("catalogue", "hooks/format.json", "yours"),
("project", "hooks/format.json", "this repo's"),
]);
assert_eq!(
Profile::resolve(&f.paths)
.sources(Capability::Hooks)
.unwrap(),
vec![f.paths.root.join("hooks"), f.paths.repo.join(".omh/hooks")],
"project last, so project wins"
);
}
#[test]
fn worktrees_live_outside_the_repo() {
let f = fixture(&[]);
assert!(!f.paths.worktrees().starts_with(&f.paths.repo));
assert!(f.paths.worktrees().starts_with(&f.paths.root));
}
#[test]
fn cache_volume_is_harness_independent() {
let f = fixture(&[]);
assert_eq!(f.paths.cache_volume(), "omh-cache-repo");
}
#[test]
fn missing_git_repo_is_a_hard_error() {
let dir = tempfile::tempdir().unwrap();
let err = repo_root(dir.path()).unwrap_err();
assert!(err.to_string().contains("git init"), "got: {err}");
}
#[test]
fn staging_is_keyed_by_repo() {
let dir = tempfile::tempdir().unwrap();
let a = Paths {
root: dir.path().into(),
repo: dir.path().join("alpha"),
};
let b = Paths {
root: dir.path().into(),
repo: dir.path().join("beta"),
};
assert_ne!(a.staging("s01", "claude"), b.staging("s01", "claude"));
}
#[test]
fn a_sandbox_repository_never_lives_where_sessions_are_counted() {
let f = fixture(&[]);
let p = &f.paths;
assert!(
!p.shadows().starts_with(p.worktrees()),
"shadows must not sit under worktrees: {} inside {}",
p.shadows().display(),
p.worktrees().display()
);
}
#[test]
fn sandbox_repositories_are_keyed_by_repo() {
let dir = tempfile::tempdir().unwrap();
let a = Paths {
root: dir.path().into(),
repo: dir.path().join("alpha"),
};
let b = Paths {
root: dir.path().into(),
repo: dir.path().join("beta"),
};
assert_ne!(a.shadows(), b.shadows());
}
#[test]
fn staging_still_separates_sessions_and_harnesses() {
let f = fixture(&[]);
let p = &f.paths;
assert_ne!(p.staging("s01", "claude"), p.staging("s02", "claude"));
assert_ne!(p.staging("s01", "claude"), p.staging("s01", "opencode"));
}
}