1use crate::{PrismerClient, types::*};
2use serde_json::json;
3
4fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let cloud_res = self.install_skill(slug_or_id, None).await?;
393
394 let mut local_paths = Vec::new();
395
396 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let result = safe_slug("../../../");
739 assert_eq!(result, "");
740 }
741}