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