Skip to main content

prismer_sdk/
evolution.rs

1use crate::{PrismerClient, types::*};
2use serde_json::json;
3
4/// Sanitize a slug to prevent directory traversal attacks.
5/// Strips `..`, `/`, `\`, null bytes, and takes only the basename component.
6fn safe_slug(s: &str) -> String {
7    let s = s.replace("..", "").replace('/', "").replace('\\', "").replace('\0', "");
8    std::path::Path::new(&s)
9        .file_name()
10        .map(|f| f.to_string_lossy().to_string())
11        .unwrap_or_default()
12}
13
14pub struct EvolutionClient<'a> {
15    pub(crate) client: &'a PrismerClient,
16}
17
18impl<'a> EvolutionClient<'a> {
19    // ─── Public (no auth) ────────────────────────
20
21    /// Get evolution statistics.
22    pub async fn stats(&self) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
23        self.client.request(reqwest::Method::GET, "/api/im/evolution/public/stats", None).await
24    }
25
26    /// Get hot genes.
27    pub async fn hot_genes(&self, limit: Option<u32>) -> Result<ApiResponse<Vec<Gene>>, PrismerError> {
28        let path = match limit {
29            Some(l) => format!("/api/im/evolution/public/hot?limit={}", l),
30            None => "/api/im/evolution/public/hot".to_string(),
31        };
32        self.client.request(reqwest::Method::GET, &path, None).await
33    }
34
35    /// Browse public genes.
36    pub async fn browse_genes(&self, category: Option<&str>, limit: Option<u32>) -> Result<ApiResponse<Vec<Gene>>, PrismerError> {
37        let mut params = vec![];
38        if let Some(c) = category { params.push(format!("category={}", c)); }
39        if let Some(l) = limit { params.push(format!("limit={}", l)); }
40        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
41        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/public/genes{}", qs), None).await
42    }
43
44    /// Get evolution feed.
45    pub async fn feed(&self, limit: Option<u32>) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
46        let path = match limit {
47            Some(l) => format!("/api/im/evolution/public/feed?limit={}", l),
48            None => "/api/im/evolution/public/feed".to_string(),
49        };
50        self.client.request(reqwest::Method::GET, &path, None).await
51    }
52
53    /// Get evolution stories (L1 narrative).
54    pub async fn stories(&self, limit: Option<u32>, since_minutes: Option<u32>) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
55        let mut params = vec![];
56        if let Some(l) = limit { params.push(format!("limit={}", l)); }
57        if let Some(s) = since_minutes { params.push(format!("since={}", s)); }
58        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
59        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/stories{}", qs), None).await
60    }
61
62    /// Get evolution map data.
63    pub async fn map_data(&self) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
64        self.client.request(reqwest::Method::GET, "/api/im/evolution/map", None).await
65    }
66
67    /// Get north-star A/B metrics comparison.
68    pub async fn metrics(&self) -> Result<ApiResponse<EvolutionMetrics>, PrismerError> {
69        self.client.request(reqwest::Method::GET, "/api/im/evolution/metrics", None).await
70    }
71
72    // ─── Leaderboard V2 (public, no auth) ────────
73
74    /// Get hero section global stats (total agents, genes, capsules, savings).
75    pub async fn leaderboard_hero(&self) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
76        self.client.request(reqwest::Method::GET, "/api/im/evolution/leaderboard/hero", None).await
77    }
78
79    /// Get rising stars leaderboard.
80    pub async fn leaderboard_rising(&self, period: Option<&str>, limit: Option<u32>) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
81        let mut params = vec![];
82        if let Some(p) = period { params.push(format!("period={}", p)); }
83        if let Some(l) = limit { params.push(format!("limit={}", l)); }
84        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
85        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/leaderboard/rising{}", qs), None).await
86    }
87
88    /// Get leaderboard summary stats.
89    pub async fn leaderboard_stats(&self) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
90        self.client.request(reqwest::Method::GET, "/api/im/evolution/leaderboard/stats", None).await
91    }
92
93    /// Get agent improvement board.
94    pub async fn leaderboard_agents(&self, period: Option<&str>, domain: Option<&str>) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
95        let mut params = vec![];
96        if let Some(p) = period { params.push(format!("period={}", p)); }
97        if let Some(d) = domain { params.push(format!("domain={}", d)); }
98        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
99        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/leaderboard/agents{}", qs), None).await
100    }
101
102    /// Get gene impact board.
103    pub async fn leaderboard_genes(&self, period: Option<&str>, sort: Option<&str>) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
104        let mut params = vec![];
105        if let Some(p) = period { params.push(format!("period={}", p)); }
106        if let Some(s) = sort { params.push(format!("sort={}", s)); }
107        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
108        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/leaderboard/genes{}", qs), None).await
109    }
110
111    /// Get contributor board.
112    pub async fn leaderboard_contributors(&self, period: Option<&str>) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
113        let mut params = vec![];
114        if let Some(p) = period { params.push(format!("period={}", p)); }
115        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
116        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/leaderboard/contributors{}", qs), None).await
117    }
118
119    /// Get cross-environment comparison data.
120    pub async fn leaderboard_comparison(&self) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
121        self.client.request(reqwest::Method::GET, "/api/im/evolution/leaderboard/comparison", None).await
122    }
123
124    /// Get public profile page data for an agent or owner.
125    pub async fn public_profile(&self, entity_id: &str) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
126        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/profile/{}", entity_id), None).await
127    }
128
129    /// Render agent/creator card as PNG.
130    pub async fn render_card(&self, input: serde_json::Value) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
131        self.client.request(reqwest::Method::POST, "/api/im/evolution/card/render", Some(input)).await
132    }
133
134    /// Get benchmark data for profile FOMO section.
135    pub async fn benchmark(&self) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
136        self.client.request(reqwest::Method::GET, "/api/im/evolution/benchmark", None).await
137    }
138
139    /// Get gene highlight capsules for profile page.
140    pub async fn highlights(&self, gene_id: &str) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
141        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/highlights/{}", gene_id), None).await
142    }
143
144    // ─── Auth required ───────────────────────────
145
146    /// Analyze signals and get gene recommendation.
147    pub async fn analyze(&self, signals: Vec<serde_json::Value>, scope: Option<&str>) -> Result<ApiResponse<EvolutionAdvice>, PrismerError> {
148        let path = match scope {
149            Some(s) => format!("/api/im/evolution/analyze?scope={}", s),
150            None => "/api/im/evolution/analyze".to_string(),
151        };
152        self.client.request(
153            reqwest::Method::POST,
154            &path,
155            Some(json!({ "signals": signals })),
156        ).await
157    }
158
159    /// Record gene execution outcome.
160    pub async fn record(
161        &self,
162        gene_id: &str,
163        signals: Vec<serde_json::Value>,
164        outcome: &str,
165        summary: &str,
166        score: Option<f64>,
167        scope: Option<&str>,
168    ) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
169        let mut body = json!({
170            "gene_id": gene_id,
171            "signals": signals,
172            "outcome": outcome,
173            "summary": summary,
174        });
175        if let Some(s) = score {
176            body["score"] = json!(s);
177        }
178        let path = match scope {
179            Some(s) => format!("/api/im/evolution/record?scope={}", s),
180            None => "/api/im/evolution/record".to_string(),
181        };
182        self.client.request(reqwest::Method::POST, &path, Some(body)).await
183    }
184
185    /// One-step evolution: analyze context → get gene → auto-record outcome.
186    pub async fn evolve(
187        &self,
188        signals: Vec<serde_json::Value>,
189        outcome: &str,
190        summary: &str,
191        score: Option<f64>,
192        scope: Option<&str>,
193    ) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
194        let analysis: ApiResponse<serde_json::Value> = self.client.request(
195            reqwest::Method::POST,
196            &match scope {
197                Some(s) if !s.is_empty() => format!("/api/im/evolution/analyze?scope={}", s),
198                _ => "/api/im/evolution/analyze".to_string(),
199            },
200            Some(json!({ "signals": signals })),
201        ).await?;
202
203        let data = match &analysis.data {
204            Some(d) => d,
205            None => return Ok(ApiResponse {
206                success: Some(true),
207                ok: Some(true),
208                data: Some(json!({ "recorded": false })),
209                error: None,
210            }),
211        };
212
213        let gene_id = data.get("gene_id")
214            .or_else(|| data.get("gene").and_then(|g| g.get("id")))
215            .and_then(|v| v.as_str())
216            .unwrap_or("");
217        let action = data.get("action").and_then(|v| v.as_str()).unwrap_or("");
218
219        if gene_id.is_empty() || (action != "apply_gene" && action != "explore") {
220            return Ok(ApiResponse {
221                success: Some(true),
222                ok: Some(true),
223                data: Some(json!({ "analysis": data, "recorded": false })),
224                error: None,
225            });
226        }
227
228        let rec_signals = data.get("signals")
229            .and_then(|v| v.as_array())
230            .cloned()
231            .map(|arr| arr.into_iter().collect())
232            .unwrap_or(signals);
233
234        let _ = self.record(gene_id, rec_signals, outcome, summary, score, scope).await?;
235        Ok(ApiResponse {
236            success: Some(true),
237            ok: Some(true),
238            data: Some(json!({ "analysis": data, "recorded": true })),
239            error: None,
240        })
241    }
242
243    /// Create a new gene.
244    pub async fn create_gene(
245        &self,
246        category: &str,
247        signals_match: Vec<serde_json::Value>,
248        strategy: Vec<String>,
249        title: Option<&str>,
250        scope: Option<&str>,
251    ) -> Result<ApiResponse<Gene>, PrismerError> {
252        let mut body = json!({
253            "category": category,
254            "signals_match": signals_match,
255            "strategy": strategy,
256        });
257        if let Some(t) = title {
258            body["title"] = json!(t);
259        }
260        let path = match scope {
261            Some(s) => format!("/api/im/evolution/genes?scope={}", s),
262            None => "/api/im/evolution/genes".to_string(),
263        };
264        self.client.request(reqwest::Method::POST, &path, Some(body)).await
265    }
266
267    /// List own genes.
268    pub async fn list_genes(&self, scope: Option<&str>) -> Result<ApiResponse<Vec<Gene>>, PrismerError> {
269        let path = match scope {
270            Some(s) => format!("/api/im/evolution/genes?scope={}", s),
271            None => "/api/im/evolution/genes".to_string(),
272        };
273        self.client.request(reqwest::Method::GET, &path, None).await
274    }
275
276    /// Delete a gene.
277    pub async fn delete_gene(&self, gene_id: &str) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
278        self.client.request(reqwest::Method::DELETE, &format!("/api/im/evolution/genes/{}", gene_id), None).await
279    }
280
281    /// Publish gene as canary.
282    pub async fn publish_gene(&self, gene_id: &str) -> Result<ApiResponse<Gene>, PrismerError> {
283        self.client.request(reqwest::Method::POST, &format!("/api/im/evolution/publish/{}", gene_id), None).await
284    }
285
286    /// Get edges.
287    pub async fn edges(&self, signal_key: Option<&str>, gene_id: Option<&str>, scope: Option<&str>) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
288        let mut params = vec![];
289        if let Some(s) = signal_key { params.push(format!("signal_key={}", s)); }
290        if let Some(g) = gene_id { params.push(format!("gene_id={}", g)); }
291        if let Some(sc) = scope { params.push(format!("scope={}", sc)); }
292        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
293        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/edges{}", qs), None).await
294    }
295
296    /// Get personality.
297    pub async fn personality(&self, agent_id: &str) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
298        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/personality/{}", agent_id), None).await
299    }
300
301    /// List available evolution scopes.
302    pub async fn list_scopes(&self) -> Result<ApiResponse<Vec<String>>, PrismerError> {
303        self.client.request(reqwest::Method::GET, "/api/im/evolution/scopes", None).await
304    }
305
306    /// Trigger metrics collection.
307    pub async fn collect_metrics(&self, window_hours: u32) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
308        self.client.request(
309            reqwest::Method::POST,
310            "/api/im/evolution/metrics/collect",
311            Some(json!({ "window_hours": window_hours })),
312        ).await
313    }
314
315    // ─── Skills ──────────────────────────────────
316
317    /// Search skills catalog.
318    pub async fn search_skills(&self, query: Option<&str>, category: Option<&str>, limit: Option<u32>) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
319        let mut params = vec![];
320        if let Some(q) = query { params.push(format!("query={}", q)); }
321        if let Some(c) = category { params.push(format!("category={}", c)); }
322        if let Some(l) = limit { params.push(format!("limit={}", l)); }
323        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
324        self.client.request(reqwest::Method::GET, &format!("/api/im/skills/search{}", qs), None).await
325    }
326
327    /// Install a skill — creates cloud record + Gene, returns content for local install.
328    /// Pass `scope` to associate the install with a specific evolution scope.
329    pub async fn install_skill(&self, slug_or_id: &str, scope: Option<&str>) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
330        let body = scope.map(|s| json!({ "scope": s }));
331        self.client.request(
332            reqwest::Method::POST,
333            &format!("/api/im/skills/{}/install", urlencoding::encode(slug_or_id)),
334            body,
335        ).await
336    }
337
338    /// Get workspace view — active genes/skills/memory visible to this agent.
339    /// `scope` filters by evolution scope, `slots` restricts returned slot types,
340    /// `include_content` embeds full SKILL.md content in the response.
341    pub async fn get_workspace(
342        &self,
343        scope: Option<&str>,
344        slots: Option<&[&str]>,
345        include_content: bool,
346    ) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
347        let mut params = vec![];
348        if let Some(s) = scope { params.push(format!("scope={}", s)); }
349        if let Some(sl) = slots {
350            for slot in sl { params.push(format!("slots[]={}", slot)); }
351        }
352        if include_content { params.push("include_content=true".to_string()); }
353        let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) };
354        self.client.request(reqwest::Method::GET, &format!("/api/im/workspace/view{}", qs), None).await
355    }
356
357    /// Uninstall a skill.
358    pub async fn uninstall_skill(&self, slug_or_id: &str) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
359        self.client.request(
360            reqwest::Method::DELETE,
361            &format!("/api/im/skills/{}/install", urlencoding::encode(slug_or_id)),
362            None,
363        ).await
364    }
365
366    /// List installed skills for this agent.
367    pub async fn installed_skills(&self) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
368        self.client.request(reqwest::Method::GET, "/api/im/skills/installed", None).await
369    }
370
371    /// Get full skill content (SKILL.md + package info).
372    pub async fn get_skill_content(&self, slug_or_id: &str) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
373        self.client.request(
374            reqwest::Method::GET,
375            &format!("/api/im/skills/{}/content", urlencoding::encode(slug_or_id)),
376            None,
377        ).await
378    }
379
380    // ─── Local file sync ────────────────────────────
381
382    /// Install a skill and write SKILL.md to local filesystem.
383    /// Combines cloud install + local file sync for Claude Code / OpenClaw / OpenCode.
384    pub async fn install_skill_local(
385        &self,
386        slug_or_id: &str,
387        platforms: Option<&[&str]>,
388        project: bool,
389        project_root: Option<&str>,
390    ) -> Result<(ApiResponse<serde_json::Value>, Vec<String>), PrismerError> {
391        // 1. Cloud install
392        let cloud_res = self.install_skill(slug_or_id, None).await?;
393
394        let mut local_paths = Vec::new();
395
396        // 2. Extract content and slug from response
397        let (content, slug) = if let Some(ref data) = cloud_res.data {
398            let skill = data.get("skill").and_then(|s| s.as_object());
399            let content = skill
400                .and_then(|s| s.get("content"))
401                .and_then(|c| c.as_str())
402                .unwrap_or("")
403                .to_string();
404            let raw_slug = skill
405                .and_then(|s| s.get("slug"))
406                .and_then(|s| s.as_str())
407                .unwrap_or(slug_or_id);
408            let slug = safe_slug(raw_slug);
409            if slug.is_empty() {
410                return Ok((cloud_res, local_paths));
411            }
412            (content, slug)
413        } else {
414            return Ok((cloud_res, local_paths));
415        };
416
417        // 3. If no content, fetch it
418        let content = if content.is_empty() {
419            match self.get_skill_content(slug_or_id).await {
420                Ok(res) => res
421                    .data
422                    .as_ref()
423                    .and_then(|d| d.get("content"))
424                    .and_then(|c| c.as_str())
425                    .unwrap_or("")
426                    .to_string(),
427                Err(_) => String::new(),
428            }
429        } else {
430            content
431        };
432
433        if content.is_empty() {
434            return Ok((cloud_res, local_paths));
435        }
436
437        // 4. Determine target paths
438        let home = dirs::home_dir().unwrap_or_default();
439        let root = project_root
440            .map(std::path::PathBuf::from)
441            .unwrap_or_else(|| std::path::PathBuf::from("."));
442
443        let plugin_dir = std::env::var("PRISMER_PLUGIN_DIR")
444            .unwrap_or_else(|_| home.join(".claude").join("plugins").join("prismer").to_string_lossy().to_string());
445        let plugin_base = std::path::PathBuf::from(&plugin_dir);
446
447        let all_platforms: Vec<(&str, std::path::PathBuf)> = if project {
448            vec![
449                ("claude-code", root.join(".claude").join("skills").join(&slug)),
450                ("openclaw", root.join("skills").join(&slug)),
451                ("opencode", root.join(".opencode").join("skills").join(&slug)),
452                ("plugin", root.join(".claude").join("plugins").join("prismer").join("skills").join(&slug)),
453            ]
454        } else {
455            vec![
456                ("claude-code", home.join(".claude").join("skills").join(&slug)),
457                ("openclaw", home.join(".openclaw").join("skills").join(&slug)),
458                ("opencode", home.join(".config").join("opencode").join("skills").join(&slug)),
459                ("plugin", plugin_base.join("skills").join(&slug)),
460            ]
461        };
462
463        // Filter by requested platforms
464        let targets: Vec<_> = match platforms {
465            Some(ps) => all_platforms
466                .into_iter()
467                .filter(|(name, _)| ps.contains(name))
468                .collect(),
469            None => all_platforms,
470        };
471
472        // 5. Write SKILL.md
473        for (_, dir) in &targets {
474            if let Err(_) = std::fs::create_dir_all(dir) {
475                continue;
476            }
477            let file_path = dir.join("SKILL.md");
478            if std::fs::write(&file_path, &content).is_ok() {
479                local_paths.push(file_path.to_string_lossy().to_string());
480            }
481        }
482
483        Ok((cloud_res, local_paths))
484    }
485
486    /// Uninstall a skill and remove local SKILL.md files.
487    pub async fn uninstall_skill_local(
488        &self,
489        slug_or_id: &str,
490    ) -> Result<(ApiResponse<serde_json::Value>, Vec<String>), PrismerError> {
491        let cloud_res = self.uninstall_skill(slug_or_id).await?;
492        let mut removed = Vec::new();
493
494        let safe = safe_slug(slug_or_id);
495        if safe.is_empty() {
496            return Ok((cloud_res, removed));
497        }
498
499        if let Some(home) = dirs::home_dir() {
500            let plugin_dir = std::env::var("PRISMER_PLUGIN_DIR")
501                .unwrap_or_else(|_| home.join(".claude").join("plugins").join("prismer").to_string_lossy().to_string());
502            let plugin_base = std::path::PathBuf::from(&plugin_dir);
503
504            let dirs = [
505                home.join(".claude").join("skills").join(&safe),
506                home.join(".openclaw").join("skills").join(&safe),
507                home.join(".config").join("opencode").join("skills").join(&safe),
508                plugin_base.join("skills").join(&safe),
509            ];
510
511            for dir in &dirs {
512                if dir.exists() {
513                    if std::fs::remove_dir_all(dir).is_ok() {
514                        removed.push(dir.to_string_lossy().to_string());
515                    }
516                }
517            }
518        }
519
520        Ok((cloud_res, removed))
521    }
522
523    /// Sync all installed skills to local filesystem.
524    pub async fn sync_skills_local(
525        &self,
526        platforms: Option<&[&str]>,
527    ) -> Result<(usize, usize, Vec<String>), PrismerError> {
528        let installed = self.installed_skills().await?;
529        let mut synced = 0usize;
530        let mut failed = 0usize;
531        let mut paths = Vec::new();
532
533        let records = match &installed.data {
534            Some(data) => data.clone(),
535            None => return Ok((0, 0, paths)),
536        };
537
538        let home = dirs::home_dir().unwrap_or_default();
539
540        for record in records {
541            let slug = record
542                .get("skill")
543                .and_then(|s| s.get("slug"))
544                .and_then(|s| s.as_str());
545
546            let slug = match slug {
547                Some(s) => {
548                    let safe = safe_slug(s);
549                    if safe.is_empty() {
550                        failed += 1;
551                        continue;
552                    }
553                    safe
554                }
555                None => {
556                    failed += 1;
557                    continue;
558                }
559            };
560
561            let content = match self.get_skill_content(&slug).await {
562                Ok(res) => res
563                    .data
564                    .as_ref()
565                    .and_then(|d| d.get("content"))
566                    .and_then(|c| c.as_str())
567                    .unwrap_or("")
568                    .to_string(),
569                Err(_) => {
570                    failed += 1;
571                    continue;
572                }
573            };
574
575            if content.is_empty() {
576                failed += 1;
577                continue;
578            }
579
580            let plugin_dir = std::env::var("PRISMER_PLUGIN_DIR")
581                .unwrap_or_else(|_| home.join(".claude").join("plugins").join("prismer").to_string_lossy().to_string());
582            let plugin_base = std::path::PathBuf::from(&plugin_dir);
583
584            let all_paths: Vec<(&str, std::path::PathBuf)> = vec![
585                ("claude-code", home.join(".claude").join("skills").join(&slug)),
586                ("openclaw", home.join(".openclaw").join("skills").join(&slug)),
587                ("opencode", home.join(".config").join("opencode").join("skills").join(&slug)),
588                ("plugin", plugin_base.join("skills").join(&slug)),
589            ];
590
591            let targets: Vec<_> = match platforms {
592                Some(ps) => all_paths
593                    .into_iter()
594                    .filter(|(name, _)| ps.contains(name))
595                    .collect(),
596                None => all_paths,
597            };
598
599            for (_, dir) in &targets {
600                let _ = std::fs::create_dir_all(dir);
601                let fp = dir.join("SKILL.md");
602                if std::fs::write(&fp, &content).is_ok() {
603                    paths.push(fp.to_string_lossy().to_string());
604                }
605            }
606            synced += 1;
607        }
608
609        Ok((synced, failed, paths))
610    }
611
612    // ─── P0: Report, Achievements, Sync ──────────────
613
614    /// Submit a raw-context evolution report (auto-creates signals + gene match).
615    pub async fn submit_report(
616        &self,
617        raw_context: &str,
618        outcome: &str,
619        task_context: Option<&str>,
620        task_error: Option<&str>,
621        task_id: Option<&str>,
622        metadata: Option<serde_json::Value>,
623    ) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
624        let mut body = json!({
625            "raw_context": raw_context,
626            "outcome": outcome,
627        });
628        if let Some(tc) = task_context { body["task_context"] = json!(tc); }
629        if let Some(te) = task_error { body["task_error"] = json!(te); }
630        if let Some(ti) = task_id { body["task_id"] = json!(ti); }
631        if let Some(m) = metadata { body["metadata"] = m; }
632        self.client.request(reqwest::Method::POST, "/api/im/evolution/report", Some(body)).await
633    }
634
635    /// Get status of a submitted report by traceId.
636    pub async fn get_report_status(&self, trace_id: &str) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
637        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/report/{}", trace_id), None).await
638    }
639
640    /// Get evolution achievements for the current agent.
641    pub async fn get_achievements(&self) -> Result<ApiResponse<Vec<serde_json::Value>>, PrismerError> {
642        self.client.request(reqwest::Method::GET, "/api/im/evolution/achievements", None).await
643    }
644
645    /// Get a sync snapshot (global gene/edge state since a sequence number).
646    pub async fn get_sync_snapshot(&self, since: Option<u64>) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
647        let mut params = vec!["scope=global".to_string()];
648        if let Some(s) = since { params.push(format!("since={}", s)); }
649        let qs = format!("?{}", params.join("&"));
650        self.client.request(reqwest::Method::GET, &format!("/api/im/evolution/sync/snapshot{}", qs), None).await
651    }
652
653    /// Bidirectional sync: push local outcomes and pull remote updates.
654    pub async fn sync(
655        &self,
656        push_outcomes: Option<Vec<serde_json::Value>>,
657        pull_since: Option<u64>,
658    ) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
659        let mut body = json!({});
660        if let Some(outcomes) = push_outcomes {
661            body["push"] = json!({ "outcomes": outcomes });
662        }
663        if let Some(since) = pull_since {
664            body["pull"] = json!({ "since": since });
665        }
666        self.client.request(reqwest::Method::POST, "/api/im/evolution/sync", Some(body)).await
667    }
668
669    /// Export a Gene as a Skill (export_gene_as_skill).
670    pub async fn export_gene_as_skill(
671        &self,
672        gene_id: &str,
673        slug: Option<&str>,
674        display_name: Option<&str>,
675        changelog: Option<&str>,
676    ) -> Result<ApiResponse<serde_json::Value>, PrismerError> {
677        let mut body = json!({});
678        if let Some(s) = slug { body["slug"] = json!(s); }
679        if let Some(dn) = display_name { body["displayName"] = json!(dn); }
680        if let Some(cl) = changelog { body["changelog"] = json!(cl); }
681        self.client.request(
682            reqwest::Method::POST,
683            &format!("/api/im/evolution/genes/{}/export-skill", gene_id),
684            Some(body),
685        ).await
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn safe_slug_simple_name() {
695        assert_eq!(safe_slug("my-skill"), "my-skill");
696    }
697
698    #[test]
699    fn safe_slug_strips_directory_traversal() {
700        assert_eq!(safe_slug("../../etc/passwd"), "etcpasswd");
701    }
702
703    #[test]
704    fn safe_slug_strips_forward_slashes() {
705        assert_eq!(safe_slug("path/to/skill"), "pathtoskill");
706    }
707
708    #[test]
709    fn safe_slug_strips_backslashes() {
710        assert_eq!(safe_slug("path\\to\\skill"), "pathtoskill");
711    }
712
713    #[test]
714    fn safe_slug_strips_null_bytes() {
715        assert_eq!(safe_slug("skill\0name"), "skillname");
716    }
717
718    #[test]
719    fn safe_slug_empty_string() {
720        assert_eq!(safe_slug(""), "");
721    }
722
723    #[test]
724    fn safe_slug_only_dots() {
725        // ".." gets stripped, "." should be handled by file_name()
726        let result = safe_slug("..");
727        assert_eq!(result, "");
728    }
729
730    #[test]
731    fn safe_slug_preserves_normal_chars() {
732        assert_eq!(safe_slug("hello-world_v2"), "hello-world_v2");
733    }
734
735    #[test]
736    fn safe_slug_complex_traversal() {
737        // "../../" stripped -> "" -> empty
738        let result = safe_slug("../../../");
739        assert_eq!(result, "");
740    }
741}