1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct OpenClawConfig {
10 #[serde(default)]
11 pub agents: AgentsConfig,
12}
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct AgentsConfig {
16 #[serde(default)]
17 pub defaults: AgentDefaults,
18 #[serde(default)]
19 pub list: Vec<AgentEntry>,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct AgentDefaults {
24 #[serde(default)]
25 pub workspace: Option<String>,
26 #[serde(default)]
27 pub model: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct AgentEntry {
32 pub id: String,
33 #[serde(default)]
34 pub default: Option<bool>,
35 #[serde(default)]
36 pub name: Option<String>,
37 #[serde(default)]
38 pub workspace: Option<String>,
39 #[serde(default)]
40 #[serde(rename = "agentDir")]
41 pub agent_dir: Option<String>,
42 #[serde(default)]
43 pub model: Option<String>,
44 #[serde(default)]
45 pub identity: Option<serde_json::Value>,
46 #[serde(default)]
47 pub group_chat: Option<serde_json::Value>,
48 #[serde(default)]
49 pub sandbox: Option<serde_json::Value>,
50 #[serde(default)]
51 pub tools: Option<serde_json::Value>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct OpenClawWorkspace {
57 pub id: String,
58 pub name: String,
59 pub workspace_path: PathBuf,
60 pub model: Option<String>,
61 pub is_default: bool,
62 pub docs: Vec<WorkspaceDoc>,
63 pub lobster_workflows: Vec<LobsterWorkflow>,
64 pub has_agents_md: bool,
65 pub has_soul_md: bool,
66 pub has_tools_md: bool,
67 pub has_identity_md: bool,
68 pub has_memory_md: bool,
69 pub has_user_md: bool,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct WorkspaceDoc {
75 pub name: String,
76 pub path: PathBuf,
77 pub doc_type: DocType,
78 pub size_bytes: u64,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82pub enum DocType {
83 Agents,
84 Soul,
85 Tools,
86 Identity,
87 Memory,
88 User,
89 Heartbeat,
90 Boot,
91 Bootstrap,
92 Other,
93}
94
95impl std::fmt::Display for DocType {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 DocType::Agents => write!(f, "AGENTS.md"),
99 DocType::Soul => write!(f, "SOUL.md"),
100 DocType::Tools => write!(f, "TOOLS.md"),
101 DocType::Identity => write!(f, "IDENTITY.md"),
102 DocType::Memory => write!(f, "MEMORY.md"),
103 DocType::User => write!(f, "USER.md"),
104 DocType::Heartbeat => write!(f, "HEARTBEAT.md"),
105 DocType::Boot => write!(f, "BOOT.md"),
106 DocType::Bootstrap => write!(f, "BOOTSTRAP.md"),
107 DocType::Other => write!(f, "Other"),
108 }
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct LobsterWorkflow {
115 pub name: String,
116 pub path: PathBuf,
117}
118
119pub fn discover_workspaces(home_dir: &Path) -> Result<Vec<OpenClawWorkspace>> {
121 let config_path = home_dir.join(".openclaw").join("openclaw.json");
122
123 if !config_path.exists() {
124 return Ok(Vec::new());
125 }
126
127 let content = std::fs::read_to_string(&config_path)
128 .with_context(|| format!("Failed to read {}", config_path.display()))?;
129
130 let config: OpenClawConfig = serde_json::from_str(&content)
132 .with_context(|| format!("Failed to parse {}", config_path.display()))?;
133
134 let mut workspaces = Vec::new();
135 let default_workspace = config.agents.defaults.workspace.as_deref();
136
137 for entry in &config.agents.list {
138 let workspace_path = entry
139 .workspace
140 .as_deref()
141 .or(default_workspace)
142 .unwrap_or("~/.openclaw/workspace");
143
144 let workspace_path = expand_tilde(workspace_path, home_dir);
146 let workspace_name = entry.name.as_deref().unwrap_or(&entry.id);
147
148 let mut ws = OpenClawWorkspace {
149 id: entry.id.clone(),
150 name: workspace_name.to_string(),
151 workspace_path: workspace_path.clone(),
152 model: entry.model.clone().or(config.agents.defaults.model.clone()),
153 is_default: entry.default.unwrap_or(false),
154 docs: Vec::new(),
155 lobster_workflows: Vec::new(),
156 has_agents_md: false,
157 has_soul_md: false,
158 has_tools_md: false,
159 has_identity_md: false,
160 has_memory_md: false,
161 has_user_md: false,
162 };
163
164 if workspace_path.is_dir() {
166 scan_workspace(&mut ws);
167 }
168
169 workspaces.push(ws);
170 }
171
172 if config.agents.list.is_empty() {
174 let default_ws = default_workspace.unwrap_or("~/.openclaw/workspace");
175 let ws_path = expand_tilde(default_ws, home_dir);
176 if ws_path.is_dir() {
177 let mut ws = OpenClawWorkspace {
178 id: "default".to_string(),
179 name: "Default".to_string(),
180 workspace_path: ws_path.clone(),
181 model: config.agents.defaults.model.clone(),
182 is_default: true,
183 docs: Vec::new(),
184 lobster_workflows: Vec::new(),
185 has_agents_md: false,
186 has_soul_md: false,
187 has_tools_md: false,
188 has_identity_md: false,
189 has_memory_md: false,
190 has_user_md: false,
191 };
192 scan_workspace(&mut ws);
193 workspaces.push(ws);
194 }
195 }
196
197 Ok(workspaces)
198}
199
200pub fn is_openclaw_installed() -> bool {
202 std::process::Command::new("openclaw")
203 .arg("--version")
204 .output()
205 .map(|o| o.status.success())
206 .unwrap_or(false)
207}
208
209fn expand_tilde(path: &str, home_dir: &Path) -> PathBuf {
211 if let Some(rest) = path.strip_prefix("~/") {
212 home_dir.join(rest)
213 } else if let Some(rest) = path.strip_prefix('~') {
214 home_dir.join(rest)
215 } else {
216 PathBuf::from(path)
217 }
218}
219
220fn scan_workspace(ws: &mut OpenClawWorkspace) {
222 let known_docs = [
223 ("AGENTS.md", DocType::Agents),
224 ("SOUL.md", DocType::Soul),
225 ("TOOLS.md", DocType::Tools),
226 ("IDENTITY.md", DocType::Identity),
227 ("MEMORY.md", DocType::Memory),
228 ("USER.md", DocType::User),
229 ("HEARTBEAT.md", DocType::Heartbeat),
230 ("BOOT.md", DocType::Boot),
231 ("BOOTSTRAP.md", DocType::Bootstrap),
232 ];
233
234 for (filename, doc_type) in &known_docs {
235 let path = ws.workspace_path.join(filename);
236 if path.exists() {
237 let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
238 ws.docs.push(WorkspaceDoc {
239 name: filename.to_string(),
240 path: path.clone(),
241 doc_type: *doc_type,
242 size_bytes: size,
243 });
244 match doc_type {
245 DocType::Agents => ws.has_agents_md = true,
246 DocType::Soul => ws.has_soul_md = true,
247 DocType::Tools => ws.has_tools_md = true,
248 DocType::Identity => ws.has_identity_md = true,
249 DocType::Memory => ws.has_memory_md = true,
250 DocType::User => ws.has_user_md = true,
251 _ => {}
252 }
253 }
254 }
255
256 if let Ok(entries) = std::fs::read_dir(&ws.workspace_path) {
258 for entry in entries.flatten() {
259 let path = entry.path();
260 if let Some(ext) = path.extension() {
261 if ext == "lobster" {
262 let name = path
263 .file_stem()
264 .and_then(|n| n.to_str())
265 .unwrap_or("unknown")
266 .to_string();
267 ws.lobster_workflows.push(LobsterWorkflow { name, path });
268 }
269 }
270 }
271 }
272
273 let memory_dir = ws.workspace_path.join("memory");
275 if memory_dir.is_dir() {
276 if let Ok(entries) = std::fs::read_dir(&memory_dir) {
277 for entry in entries.flatten() {
278 let path = entry.path();
279 if path.extension().and_then(|e| e.to_str()) == Some("md") {
280 let name = path
281 .file_name()
282 .and_then(|n| n.to_str())
283 .unwrap_or("")
284 .to_string();
285 let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
286 ws.docs.push(WorkspaceDoc {
287 name: format!("memory/{}", name),
288 path,
289 doc_type: DocType::Other,
290 size_bytes: size,
291 });
292 }
293 }
294 }
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_expand_tilde() {
304 let home = PathBuf::from("/home/user");
305 assert_eq!(
306 expand_tilde("~/.openclaw/workspace", &home),
307 PathBuf::from("/home/user/.openclaw/workspace")
308 );
309 assert_eq!(
310 expand_tilde("/absolute/path", &home),
311 PathBuf::from("/absolute/path")
312 );
313 }
314
315 #[test]
316 fn test_discover_workspaces_no_config() {
317 let tmp = std::env::temp_dir().join("agentry_test_oc_noconfig");
318 let _ = std::fs::remove_dir_all(&tmp);
319 std::fs::create_dir_all(&tmp).unwrap();
320
321 let result = discover_workspaces(&tmp).unwrap();
322 assert!(result.is_empty());
323
324 let _ = std::fs::remove_dir_all(&tmp);
325 }
326
327 #[test]
328 fn test_doc_type_display() {
329 assert_eq!(format!("{}", DocType::Agents), "AGENTS.md");
330 assert_eq!(format!("{}", DocType::Soul), "SOUL.md");
331 assert_eq!(format!("{}", DocType::Tools), "TOOLS.md");
332 }
333}