Skip to main content

agentry_openclaw/
discovery.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5
6/// Parsed OpenClaw configuration (from ~/.openclaw/openclaw.json).
7/// Supports JSON5 format (comments, trailing commas).
8#[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/// A discovered OpenClaw workspace with its docs.
55#[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/// A document file in a workspace.
73#[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/// A .lobster workflow file.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct LobsterWorkflow {
115    pub name: String,
116    pub path: PathBuf,
117}
118
119/// Discover all OpenClaw workspaces from the config.
120pub 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    // Try to parse as JSON (strip comments for JSON5 support)
131    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        // Expand ~ to home dir
145        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        // Scan workspace for docs and workflows
165        if workspace_path.is_dir() {
166            scan_workspace(&mut ws);
167        }
168
169        workspaces.push(ws);
170    }
171
172    // If no agents list but default workspace exists
173    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
200/// Check if OpenClaw CLI is installed.
201pub 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
209/// Expand ~ in paths.
210fn 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
220/// Scan a workspace directory for docs and .lobster workflows.
221fn 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    // Also scan for .lobster files
257    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    // Also check memory/ directory
274    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}