1use std::path::{Path, PathBuf};
10
11use anyhow::{bail, Context};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum FrameworkHint {
19 ClaudeCode,
21 Openclaw,
23 Unknown,
25}
26
27impl std::fmt::Display for FrameworkHint {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 FrameworkHint::ClaudeCode => write!(f, "Claude Code"),
31 FrameworkHint::Openclaw => write!(f, "OpenClaw"),
32 FrameworkHint::Unknown => write!(f, "agentic framework"),
33 }
34 }
35}
36
37#[derive(Debug, Clone)]
41pub struct InstallRoot {
42 pub path: PathBuf,
44 pub framework: FrameworkHint,
46}
47
48impl InstallRoot {
49 pub fn from_explicit(path: PathBuf) -> anyhow::Result<Self> {
51 let canonical = path
52 .canonicalize()
53 .with_context(|| format!("cannot access path: {}", path.display()))?;
54
55 if !canonical.is_dir() {
56 bail!("path is not a directory: {}", canonical.display());
57 }
58
59 let framework = detect_framework(&canonical);
60 Ok(InstallRoot {
61 path: canonical,
62 framework,
63 })
64 }
65}
66
67pub fn resolve(explicit: Vec<PathBuf>) -> anyhow::Result<Vec<InstallRoot>> {
75 if !explicit.is_empty() {
76 return explicit
77 .into_iter()
78 .map(InstallRoot::from_explicit)
79 .collect();
80 }
81
82 if let Ok(val) = std::env::var("OPENCLAW_HOME") {
84 let p = PathBuf::from(&val);
85 if p.is_dir() {
86 let canonical = p.canonicalize().unwrap_or(p);
88 let framework = detect_framework(&canonical);
89 return Ok(vec![InstallRoot {
90 path: canonical,
91 framework,
92 }]);
93 }
94 }
95
96 let candidates = candidate_paths();
98 for (path, framework) in candidates {
99 if path.is_dir() && looks_like_install(&path) {
100 return Ok(vec![InstallRoot { path, framework }]);
101 }
102 }
103
104 bail!(
105 "No agentic framework installation found.\n\
106 Tried: ~/.claude, ~/.openclaw, ~/.config/openclaw, $OPENCLAW_HOME\n\
107 Use `ocls --path <dir>` to specify the installation directory."
108 )
109}
110
111fn candidate_paths() -> Vec<(PathBuf, FrameworkHint)> {
115 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
116 vec![
117 (home.join(".claude"), FrameworkHint::ClaudeCode),
118 (home.join(".openclaw"), FrameworkHint::Openclaw),
119 (
120 home.join(".config").join("openclaw"),
121 FrameworkHint::Openclaw,
122 ),
123 (PathBuf::from(".claude"), FrameworkHint::ClaudeCode),
125 (PathBuf::from(".openclaw"), FrameworkHint::Openclaw),
126 ]
127}
128
129fn detect_framework(path: &Path) -> FrameworkHint {
131 match path.file_name().and_then(|n| n.to_str()).unwrap_or("") {
133 ".claude" => FrameworkHint::ClaudeCode,
134 ".openclaw" => FrameworkHint::Openclaw,
135 _ => FrameworkHint::Unknown,
136 }
137}
138
139fn looks_like_install(path: &Path) -> bool {
142 let markers = [
143 "settings.json",
144 "history.jsonl",
145 ".credentials.json",
146 "credentials.json",
147 "installed_plugins.json",
148 "mcp-needs-auth-cache.json",
149 ];
150 markers.iter().any(|m| path.join(m).exists())
151}
152
153#[cfg(test)]
156mod tests {
157 use super::*;
158 use tempfile::TempDir;
159
160 fn make_install_dir() -> TempDir {
161 let dir = tempfile::tempdir().unwrap();
162 std::fs::write(dir.path().join("settings.json"), b"{}").unwrap();
163 dir
164 }
165
166 #[test]
167 fn explicit_path_must_be_directory() {
168 let tmp = tempfile::NamedTempFile::new().unwrap();
169 let result = InstallRoot::from_explicit(tmp.path().to_path_buf());
170 assert!(result.is_err());
171 assert!(result.unwrap_err().to_string().contains("not a directory"));
172 }
173
174 #[test]
175 fn explicit_path_nonexistent() {
176 let result = InstallRoot::from_explicit(PathBuf::from("/nonexistent/path/xyz"));
177 assert!(result.is_err());
178 }
179
180 #[test]
181 fn explicit_path_valid_dir() {
182 let dir = make_install_dir();
183 let result = InstallRoot::from_explicit(dir.path().to_path_buf());
184 assert!(result.is_ok());
185 }
186
187 #[test]
188 fn looks_like_install_with_marker() {
189 let dir = make_install_dir();
190 assert!(looks_like_install(dir.path()));
191 }
192
193 #[test]
194 fn looks_like_install_empty_dir() {
195 let dir = tempfile::tempdir().unwrap();
196 assert!(!looks_like_install(dir.path()));
197 }
198
199 #[test]
200 fn detect_framework_claude() {
201 let path = PathBuf::from("/home/user/.claude");
202 assert_eq!(detect_framework(&path), FrameworkHint::ClaudeCode);
203 }
204
205 #[test]
206 fn detect_framework_openclaw() {
207 let path = PathBuf::from("/home/user/.openclaw");
208 assert_eq!(detect_framework(&path), FrameworkHint::Openclaw);
209 }
210
211 #[test]
212 fn resolve_returns_error_when_nothing_found() {
213 let result = resolve(vec![PathBuf::from("/tmp/__nonexistent_ocls_test__")]);
218 assert!(result.is_err());
219 }
220
221 #[test]
222 fn framework_hint_display() {
223 assert_eq!(FrameworkHint::ClaudeCode.to_string(), "Claude Code");
224 assert_eq!(FrameworkHint::Openclaw.to_string(), "OpenClaw");
225 assert_eq!(FrameworkHint::Unknown.to_string(), "agentic framework");
226 }
227}