Skip to main content

sparrow/
share.rs

1//! `sparrow share` β€” Share the latest session transcript as a GitHub Gist.
2//!
3//! Reads the latest session transcript from `state/transcripts/`, formats it
4//! as a clean Markdown gist, and uploads it to GitHub Gist using either the
5//! `gh` CLI or a direct API call. Returns the shareable URL.
6
7use std::path::PathBuf;
8use std::process::Command;
9
10/// Run the share command.
11///
12/// Reads the latest transcript, formats it as a Markdown gist, and uploads it.
13/// If `include_demo_gif` is true, adds a placeholder for a demo GIF at the top.
14pub async fn run_share(
15    state_dir: &std::path::Path,
16    include_demo_gif: bool,
17) -> anyhow::Result<()> {
18    println!("πŸ”— Sparrow Share β€” partage ta session\n");
19
20    // 1. Find the latest transcript
21    let transcripts_dir = state_dir.join("transcripts");
22    let latest = find_latest_transcript(&transcripts_dir)?;
23
24    println!("πŸ“„ Transcript : {}", latest.display());
25
26    // 2. Read and format the transcript
27    let raw = std::fs::read_to_string(&latest)?;
28    let formatted = format_transcript(&raw, &latest, include_demo_gif)?;
29
30    // 3. Upload to GitHub Gist
31    let filename = latest
32        .file_name()
33        .map(|n| n.to_string_lossy().to_string())
34        .unwrap_or_else(|| "sparrow-session.md".into());
35
36    let url = upload_gist(&filename, &formatted).await?;
37
38    // 4. Print result
39    println!();
40    println!("══════════════════════════════════════════════════");
41    println!("  βœ… Gist créé avec succΓ¨s !");
42    println!("══════════════════════════════════════════════════");
43    println!();
44    println!("  πŸ”— {url}");
45    println!();
46    println!("  PartagΓ© depuis Sparrow β€” https://github.com/ucav/Sparrow");
47    println!();
48
49    Ok(())
50}
51
52// ─── Find latest transcript ──────────────────────────────────────────────────
53
54fn find_latest_transcript(transcripts_dir: &std::path::Path) -> anyhow::Result<PathBuf> {
55    if !transcripts_dir.exists() {
56        anyhow::bail!(
57            "Aucun transcript trouvΓ©. Le dossier {} n'existe pas.\n\
58             β†’ Lance une tΓ’che Sparrow d'abord : sparrow run \"explique ce projet\"",
59            transcripts_dir.display()
60        );
61    }
62
63    let mut entries: Vec<_> = std::fs::read_dir(transcripts_dir)?
64        .filter_map(|e| e.ok())
65        .filter(|e| {
66            e.path()
67                .extension()
68                .map(|ext| ext == "json" || ext == "jsonl")
69                .unwrap_or(false)
70        })
71        .collect();
72
73    if entries.is_empty() {
74        anyhow::bail!(
75            "Aucun transcript trouvΓ© dans {}.\n\
76             β†’ Lance une tΓ’che Sparrow d'abord !",
77            transcripts_dir.display()
78        );
79    }
80
81    // Sort by modification time, newest first
82    entries.sort_by(|a, b| {
83        b.metadata()
84            .and_then(|m| m.modified())
85            .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
86            .cmp(
87                &a.metadata()
88                    .and_then(|m| m.modified())
89                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
90            )
91    });
92
93    Ok(entries[0].path())
94}
95
96// ─── Format transcript ───────────────────────────────────────────────────────
97
98fn format_transcript(
99    raw: &str,
100    path: &std::path::Path,
101    include_demo_gif: bool,
102) -> anyhow::Result<String> {
103    let timestamp = path
104        .metadata()
105        .ok()
106        .and_then(|m| m.modified().ok())
107        .map(|t| {
108            let dt: chrono::DateTime<chrono::Local> = t.into();
109            dt.format("%Y-%m-%d %H:%M:%S").to_string()
110        })
111        .unwrap_or_else(|| "date inconnue".into());
112
113    let mut md = String::new();
114
115    // Header
116    md.push_str(&format!(
117        "# 🐦 Sparrow Session β€” {timestamp}\n\n"
118    ));
119
120    if include_demo_gif {
121        md.push_str("> 🎬 *DΓ©mo GIF Γ  venir β€” regarde Sparrow en action !*\n\n");
122    }
123
124    md.push_str(&format!(
125        "Session gΓ©nΓ©rΓ©e par [Sparrow](https://github.com/ucav/Sparrow) v{version}.\n\n",
126        version = env!("CARGO_PKG_VERSION"),
127    ));
128    md.push_str("---\n\n");
129
130    // Try to parse the transcript as NDJSON or JSON
131    let parsed = if raw.trim().starts_with('{') {
132        // Single JSON object (old format)
133        format_single_json_transcript(raw)
134    } else if raw.trim().starts_with('[') {
135        // JSON array
136        format_json_array_transcript(raw)
137    } else {
138        // NDJSON (one JSON object per line)
139        format_ndjson_transcript(raw)
140    };
141
142    match parsed {
143        Ok(formatted) => md.push_str(&formatted),
144        Err(_) => {
145            // Fall back to raw transcript in a code block
146            md.push_str("## Transcript brut\n\n");
147            md.push_str("```json\n");
148            // Truncate if too long (gists have limits)
149            if raw.len() > 500_000 {
150                md.push_str(&raw[..500_000]);
151                md.push_str("\n\n// ... (transcript tronquΓ© β€” trop volumineux)\n");
152            } else {
153                md.push_str(raw);
154            }
155            md.push_str("\n```\n");
156        }
157    }
158
159    // Footer
160    md.push_str("\n---\n");
161    md.push_str("*PartagΓ© avec Sparrow β€” l'agent CLI qui code avec toi.*\n");
162    md.push_str(
163        "*[Installer Sparrow](https://github.com/ucav/Sparrow) | cargo install sparrow*\n",
164    );
165
166    Ok(md)
167}
168
169/// Format a single JSON transcript object.
170fn format_single_json_transcript(raw: &str) -> anyhow::Result<String> {
171    let v: serde_json::Value = serde_json::from_str(raw)?;
172    let mut md = String::new();
173
174    if let Some(task) = v.get("task").and_then(|t| t.as_str()) {
175        md.push_str(&format!("## πŸ“‹ TΓ’che\n\n{task}\n\n"));
176    }
177
178    if let Some(events) = v.get("events").and_then(|e| e.as_array()) {
179        md.push_str("## πŸ“ Γ‰vΓ©nements\n\n");
180        for event in events {
181            render_event_to_markdown(&mut md, event);
182        }
183    }
184
185    if let Some(usage) = v.get("usage") {
186        md.push_str("## πŸ“Š Statistiques\n\n");
187        if let Some(input) = usage.get("input_tokens").and_then(|t| t.as_u64()) {
188            md.push_str(&format!("- **Tokens d'entrΓ©e** : {input}\n"));
189        }
190        if let Some(output) = usage.get("output_tokens").and_then(|t| t.as_u64()) {
191            md.push_str(&format!("- **Tokens de sortie** : {output}\n"));
192        }
193        if let Some(cost) = usage.get("cost_usd").and_then(|c| c.as_f64()) {
194            md.push_str(&format!("- **CoΓ»t estimΓ©** : ${cost:.4}\n"));
195        }
196        md.push('\n');
197    }
198
199    Ok(md)
200}
201
202/// Format a JSON array of event objects.
203fn format_json_array_transcript(raw: &str) -> anyhow::Result<String> {
204    let events: Vec<serde_json::Value> = serde_json::from_str(raw)?;
205    let mut md = String::from("## πŸ“ Γ‰vΓ©nements\n\n");
206
207    for event in &events {
208        render_event_to_markdown(&mut md, event);
209    }
210
211    Ok(md)
212}
213
214/// Format NDJSON (one JSON object per line).
215fn format_ndjson_transcript(raw: &str) -> anyhow::Result<String> {
216    let mut md = String::from("## πŸ“ Γ‰vΓ©nements\n\n");
217    let mut task_found = false;
218
219    for line in raw.lines() {
220        let trimmed = line.trim();
221        if trimmed.is_empty() {
222            continue;
223        }
224
225        if let Ok(event) = serde_json::from_str::<serde_json::Value>(trimmed) {
226            // Extract task from first relevant event
227            if !task_found {
228                if let Some(task) = event
229                    .get("task")
230                    .or_else(|| event.get("description"))
231                    .and_then(|t| t.as_str())
232                {
233                    md.push_str(&format!("## πŸ“‹ TΓ’che\n\n{task}\n\n"));
234                    task_found = true;
235                }
236            }
237            render_event_to_markdown(&mut md, &event);
238        }
239    }
240
241    if md == "## πŸ“ Γ‰vΓ©nements\n\n" {
242        md.push_str("*Aucun Γ©vΓ©nement parsable trouvΓ©.*\n\n");
243    }
244
245    Ok(md)
246}
247
248/// Render a single event value to markdown.
249fn render_event_to_markdown(md: &mut String, event: &serde_json::Value) {
250    let event_type = event
251        .get("type")
252        .or_else(|| event.get("event"))
253        .and_then(|t| t.as_str())
254        .unwrap_or("event");
255
256    match event_type {
257        "RunStarted" | "run_started" => {
258            let task = event
259                .get("task")
260                .and_then(|t| t.as_str())
261                .unwrap_or("(tΓ’che)");
262            md.push_str(&format!("### πŸš€ DΓ©marrage : {task}\n\n"));
263        }
264        "TextDelta" | "text_delta" | "assistant" => {
265            if let Some(text) = event.get("text").or_else(|| event.get("content")).and_then(|t| t.as_str()) {
266                if text.len() > 200 {
267                    md.push_str(&format!(
268                        "<details>\n<summary>πŸ’¬ {}</summary>\n\n{}\n\n</details>\n\n",
269                        &text[..text.len().min(80)],
270                        text,
271                    ));
272                } else {
273                    md.push_str(&format!("πŸ’¬ {text}\n\n"));
274                }
275            }
276        }
277        "ToolUseProposed" | "tool_use" => {
278            let name = event
279                .get("name")
280                .or_else(|| event.get("tool"))
281                .and_then(|n| n.as_str())
282                .unwrap_or("?");
283            let input = event
284                .get("input")
285                .or_else(|| event.get("args"))
286                .map(|i| format!("{i}"))
287                .unwrap_or_default();
288            md.push_str(&format!(
289                "<details>\n<summary>πŸ”§ Outil : `{name}`</summary>\n\n```json\n{input}\n```\n\n</details>\n\n"
290            ));
291        }
292        "ToolOutput" | "tool_output" | "tool_result" => {
293            let output = event
294                .get("output")
295                .or_else(|| event.get("content"))
296                .or_else(|| event.get("result"))
297                .map(|o| format!("{o}"))
298                .unwrap_or_default();
299            let truncated = if output.len() > 500 {
300                format!("{}...", &output[..500])
301            } else {
302                output
303            };
304            md.push_str(&format!("βœ… RΓ©sultat :\n```\n{truncated}\n```\n\n"));
305        }
306        "Error" | "error" => {
307            let msg = event
308                .get("message")
309                .or_else(|| event.get("error"))
310                .and_then(|m| m.as_str())
311                .unwrap_or("erreur inconnue");
312            md.push_str(&format!("❌ **Erreur** : {msg}\n\n"));
313        }
314        "RunFinished" | "run_finished" | "Done" => {
315            let reason = event
316                .get("reason")
317                .or_else(|| event.get("stop_reason"))
318                .and_then(|r| r.as_str())
319                .unwrap_or("terminΓ©");
320            md.push_str(&format!("### 🏁 Terminé ({reason})\n\n"));
321        }
322        _ => {
323            // Generic event: show as JSON
324            let pretty = serde_json::to_string_pretty(event).unwrap_or_default();
325            if pretty.len() < 200 {
326                md.push_str(&format!("πŸ“Œ `{event_type}` :\n```json\n{pretty}\n```\n\n"));
327            }
328        }
329    }
330}
331
332// ─── GitHub Gist upload ──────────────────────────────────────────────────────
333
334/// Upload content as a GitHub Gist.
335///
336/// Tries the `gh` CLI first, then falls back to a direct GitHub API call if
337/// `GITHUB_TOKEN` is set.
338async fn upload_gist(filename: &str, content: &str) -> anyhow::Result<String> {
339    // 1. Try `gh` CLI
340    if gh_available() && gh_authenticated() {
341        match upload_via_gh_cli(filename, content) {
342            Ok(url) => return Ok(url),
343            Err(e) => {
344                eprintln!("⚠️  Γ‰chec gh CLI : {e}");
345                eprintln!("   β†’ Tentative via API directe...");
346            }
347        }
348    }
349
350    // 2. Try direct API call with GITHUB_TOKEN
351    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
352        if !token.is_empty() {
353            return upload_via_api(filename, content, &token).await;
354        }
355    }
356
357    // 3. No way to upload
358    anyhow::bail!(
359        "Impossible de crΓ©er un Gist.\n\
360         β†’ Installe gh CLI : https://cli.github.com\n\
361         β†’ Puis : gh auth login\n\
362         β†’ Ou dΓ©finis GITHUB_TOKEN : export GITHUB_TOKEN=\"ghp_...\""
363    )
364}
365
366fn gh_available() -> bool {
367    Command::new("gh")
368        .arg("--version")
369        .stdout(std::process::Stdio::null())
370        .stderr(std::process::Stdio::null())
371        .status()
372        .map(|s| s.success())
373        .unwrap_or(false)
374}
375
376fn gh_authenticated() -> bool {
377    Command::new("gh")
378        .args(["auth", "status"])
379        .stdout(std::process::Stdio::null())
380        .stderr(std::process::Stdio::null())
381        .status()
382        .map(|s| s.success())
383        .unwrap_or(false)
384}
385
386fn upload_via_gh_cli(filename: &str, content: &str) -> anyhow::Result<String> {
387    // Write content to a temp file for gh CLI
388    let tmp = std::env::temp_dir().join(format!("sparrow-gist-{}.md", uuid::Uuid::new_v4()));
389    std::fs::write(&tmp, content)?;
390
391    let output = Command::new("gh")
392        .args([
393            "gist",
394            "create",
395            "--public",
396            "--filename",
397            filename,
398            "--desc",
399            &format!(
400                "🐦 Sparrow Session β€” shared via sparrow share (v{})",
401                env!("CARGO_PKG_VERSION")
402            ),
403        ])
404        .arg(&tmp)
405        .output()?;
406
407    // Clean up temp file
408    let _ = std::fs::remove_file(&tmp);
409
410    if !output.status.success() {
411        let stderr = String::from_utf8_lossy(&output.stderr);
412        anyhow::bail!("gh gist create a Γ©chouΓ© : {stderr}");
413    }
414
415    let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
416    if url.is_empty() {
417        anyhow::bail!("gh gist create n'a pas retournΓ© d'URL.");
418    }
419
420    Ok(url)
421}
422
423async fn upload_via_api(
424    filename: &str,
425    content: &str,
426    token: &str,
427) -> anyhow::Result<String> {
428    let client = reqwest::Client::builder()
429        .user_agent("sparrow-share")
430        .timeout(std::time::Duration::from_secs(30))
431        .build()?;
432
433    let body = serde_json::json!({
434        "description": format!("🐦 Sparrow Session β€” shared via sparrow share (v{})", env!("CARGO_PKG_VERSION")),
435        "public": true,
436        "files": {
437            filename: {
438                "content": content
439            }
440        }
441    });
442
443    let resp = client
444        .post("https://api.github.com/gists")
445        .header("Authorization", format!("Bearer {token}"))
446        .header("Accept", "application/vnd.github+json")
447        .header("X-GitHub-Api-Version", "2022-11-28")
448        .json(&body)
449        .send()
450        .await?;
451
452    let status = resp.status();
453    let resp_body = resp.text().await?;
454
455    if !status.is_success() {
456        anyhow::bail!(
457            "GitHub API a retournΓ© HTTP {} : {}",
458            status.as_u16(),
459            resp_body,
460        );
461    }
462
463    let parsed: serde_json::Value = serde_json::from_str(&resp_body)?;
464    let html_url = parsed
465        .get("html_url")
466        .and_then(|u| u.as_str())
467        .ok_or_else(|| anyhow::anyhow!("RΓ©ponse GitHub inattendue : pas de html_url"))?;
468
469    Ok(html_url.to_string())
470}