use std::path::{Path, PathBuf};
use anyhow::{bail, Context};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameworkHint {
ClaudeCode,
Openclaw,
Unknown,
}
impl std::fmt::Display for FrameworkHint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FrameworkHint::ClaudeCode => write!(f, "Claude Code"),
FrameworkHint::Openclaw => write!(f, "OpenClaw"),
FrameworkHint::Unknown => write!(f, "agentic framework"),
}
}
}
#[derive(Debug, Clone)]
pub struct InstallRoot {
pub path: PathBuf,
pub framework: FrameworkHint,
}
impl InstallRoot {
pub fn from_explicit(path: PathBuf) -> anyhow::Result<Self> {
let canonical = path
.canonicalize()
.with_context(|| format!("cannot access path: {}", path.display()))?;
if !canonical.is_dir() {
bail!("path is not a directory: {}", canonical.display());
}
let framework = detect_framework(&canonical);
Ok(InstallRoot {
path: canonical,
framework,
})
}
}
pub fn resolve(explicit: Vec<PathBuf>) -> anyhow::Result<Vec<InstallRoot>> {
if !explicit.is_empty() {
return explicit
.into_iter()
.map(InstallRoot::from_explicit)
.collect();
}
if let Ok(val) = std::env::var("OPENCLAW_HOME") {
let p = PathBuf::from(&val);
if p.is_dir() {
let canonical = p.canonicalize().unwrap_or(p);
let framework = detect_framework(&canonical);
return Ok(vec![InstallRoot {
path: canonical,
framework,
}]);
}
}
let candidates = candidate_paths();
for (path, framework) in candidates {
if path.is_dir() && looks_like_install(&path) {
return Ok(vec![InstallRoot { path, framework }]);
}
}
bail!(
"No agentic framework installation found.\n\
Tried: ~/.claude, ~/.openclaw, ~/.config/openclaw, $OPENCLAW_HOME\n\
Use `ocls --path <dir>` to specify the installation directory."
)
}
fn candidate_paths() -> Vec<(PathBuf, FrameworkHint)> {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
vec![
(home.join(".claude"), FrameworkHint::ClaudeCode),
(home.join(".openclaw"), FrameworkHint::Openclaw),
(
home.join(".config").join("openclaw"),
FrameworkHint::Openclaw,
),
(PathBuf::from(".claude"), FrameworkHint::ClaudeCode),
(PathBuf::from(".openclaw"), FrameworkHint::Openclaw),
]
}
fn detect_framework(path: &Path) -> FrameworkHint {
match path.file_name().and_then(|n| n.to_str()).unwrap_or("") {
".claude" => FrameworkHint::ClaudeCode,
".openclaw" => FrameworkHint::Openclaw,
_ => FrameworkHint::Unknown,
}
}
fn looks_like_install(path: &Path) -> bool {
let markers = [
"settings.json",
"history.jsonl",
".credentials.json",
"credentials.json",
"installed_plugins.json",
"mcp-needs-auth-cache.json",
];
markers.iter().any(|m| path.join(m).exists())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_install_dir() -> TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("settings.json"), b"{}").unwrap();
dir
}
#[test]
fn explicit_path_must_be_directory() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let result = InstallRoot::from_explicit(tmp.path().to_path_buf());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not a directory"));
}
#[test]
fn explicit_path_nonexistent() {
let result = InstallRoot::from_explicit(PathBuf::from("/nonexistent/path/xyz"));
assert!(result.is_err());
}
#[test]
fn explicit_path_valid_dir() {
let dir = make_install_dir();
let result = InstallRoot::from_explicit(dir.path().to_path_buf());
assert!(result.is_ok());
}
#[test]
fn looks_like_install_with_marker() {
let dir = make_install_dir();
assert!(looks_like_install(dir.path()));
}
#[test]
fn looks_like_install_empty_dir() {
let dir = tempfile::tempdir().unwrap();
assert!(!looks_like_install(dir.path()));
}
#[test]
fn detect_framework_claude() {
let path = PathBuf::from("/home/user/.claude");
assert_eq!(detect_framework(&path), FrameworkHint::ClaudeCode);
}
#[test]
fn detect_framework_openclaw() {
let path = PathBuf::from("/home/user/.openclaw");
assert_eq!(detect_framework(&path), FrameworkHint::Openclaw);
}
#[test]
fn resolve_returns_error_when_nothing_found() {
let result = resolve(vec![PathBuf::from("/tmp/__nonexistent_ocls_test__")]);
assert!(result.is_err());
}
#[test]
fn framework_hint_display() {
assert_eq!(FrameworkHint::ClaudeCode.to_string(), "Claude Code");
assert_eq!(FrameworkHint::Openclaw.to_string(), "OpenClaw");
assert_eq!(FrameworkHint::Unknown.to_string(), "agentic framework");
}
}