Skip to main content

claude_scriptorium/
gist.rs

1//! Publishing a rendered folio to a GitHub gist through `gh`, resolving and
2//! confirming the publishing account first.
3//!
4//! `gh gist create` has no `--hostname` flag: it publishes as whichever account
5//! gh resolves for its default host, so a machine with several authenticated
6//! accounts can silently push to the wrong identity. [`resolve_identity`]
7//! recovers that account up front, using gh's own host precedence, so the shell
8//! can confirm it before anything is published.
9
10use std::{
11    collections::HashMap,
12    fmt,
13    io::Write,
14    process::{Command, Stdio},
15};
16
17use anyhow::{Context, Result, bail};
18use serde::{Deserialize, de::IgnoredAny};
19
20/// This project's own viewer, served from its GitHub Pages site, used to render
21/// a published github.com gist. The viewer's host never receives the transcript:
22/// the reader's browser fetches the gist from the GitHub API and writes it into
23/// the page (see `docs/index.html`).
24pub const DEFAULT_VIEWER_BASE: &str = "https://joshkarpel.github.io/claude-scriptorium/";
25
26/// The viewer page, embedded so `scaffold_viewer` can write a self-hostable copy.
27/// It is the very file this project serves from its own Pages site, so the two
28/// never drift.
29const VIEWER_TEMPLATE: &str = include_str!("../docs/index.html");
30
31/// The GitHub API the embedded viewer reads gists from by default. A scaffolded
32/// enterprise viewer rewrites this to the GHES instance's API.
33const DEFAULT_API_BASE: &str = "https://api.github.com";
34
35/// The marker every published gist's description begins with: this tool's own
36/// package name. It identifies a gist as one this tool created, so the
37/// management commands never touch an unrelated gist, and the session id that
38/// follows lets a republish find and edit the existing gist in place rather than
39/// piling up duplicates.
40pub const GIST_MARKER: &str = env!("CARGO_PKG_NAME");
41
42/// The gist description this tool stamps: the [marker](GIST_MARKER) and session
43/// id, then the session's title when it has one. The marker prefix is how the
44/// management commands recognise a gist as ours; the session id is how a
45/// republish matches a gist back to its session.
46pub fn describe(session_id: &str, title: Option<&str>) -> String {
47    match title {
48        Some(title) => {
49            let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
50            format!("{GIST_MARKER} {session_id}: {title}")
51        }
52        None => format!("{GIST_MARKER} {session_id}"),
53    }
54}
55
56/// Whether a gist's description marks it as one this tool published: it begins
57/// with the marker followed by a space (the session id). A `None` description,
58/// or one that merely happens to contain the marker, is not ours.
59fn is_ours(description: Option<&str>) -> bool {
60    description.is_some_and(|description| {
61        description
62            .strip_prefix(GIST_MARKER)
63            .is_some_and(|rest| rest.starts_with(' '))
64    })
65}
66
67/// The GitHub account `gh gist create` will publish as.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Identity {
70    pub login: String,
71    pub host: String,
72    pub token_source: String,
73}
74
75impl fmt::Display for Identity {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(
78            f,
79            "{} on {} (auth: {})",
80            self.login, self.host, self.token_source
81        )
82    }
83}
84
85/// Resolves the account `gh gist create` will publish as, following gh's own
86/// host precedence so the account this reports is the one that publishes.
87pub fn resolve_identity() -> Result<Identity> {
88    let host = resolve_host(std::env::var("GH_HOST").ok(), &authenticated_hosts()?);
89    let status = gh(&[
90        "auth",
91        "status",
92        "--active",
93        "--hostname",
94        &host,
95        "--json",
96        "hosts",
97    ])?;
98    parse_identity(&status, &host)
99}
100
101/// The outcome of a [`publish`]: the gist's page URL, and whether an existing
102/// gist for the session was edited in place rather than a new one created.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Published {
105    pub url: String,
106    pub updated: bool,
107}
108
109/// Publishes `html` for `session_id`, idempotently: if this tool already has a
110/// gist for the session (matched by its `<session_id>.html` file), that gist is
111/// edited in place so its URL stays stable and re-publishing doesn't pile up
112/// duplicates; otherwise a new gist is created. Secret by default; `public`
113/// lists it. Visibility is fixed at creation, so a republish that would change
114/// it fails rather than silently ignoring the request.
115pub fn publish(html: &str, session_id: &str, description: &str, public: bool) -> Result<Published> {
116    let filename = format!("{session_id}.html");
117
118    if let Some(existing) = find_ours(session_id)? {
119        if existing.public != public {
120            bail!(
121                "session {session_id} is already published as a {} gist ({}); delete it first to change its visibility",
122                visibility(existing.public),
123                existing.url
124            );
125        }
126        gh_stdin(
127            &["gist", "edit", &existing.id, "--filename", &filename, "-"],
128            html,
129        )
130        .context("editing the existing gist")?;
131        gh(&["gist", "edit", &existing.id, "--desc", description])
132            .context("updating the gist description")?;
133        return Ok(Published {
134            url: existing.url,
135            updated: true,
136        });
137    }
138
139    let mut args = vec![
140        "gist",
141        "create",
142        "-",
143        "--filename",
144        &filename,
145        "--desc",
146        description,
147    ];
148    if public {
149        args.push("--public");
150    }
151    let url = gh_stdin(&args, html)
152        .context("running gh gist create (is the GitHub CLI installed?)")?
153        .trim()
154        .to_owned();
155    Ok(Published {
156        url,
157        updated: false,
158    })
159}
160
161/// The gist this tool published for `session_id`, if any: the one among our
162/// gists whose files include `<session_id>.html`.
163fn find_ours(session_id: &str) -> Result<Option<PublishedGist>> {
164    let filename = format!("{session_id}.html");
165    Ok(list_ours()?
166        .into_iter()
167        .find(|gist| gist.files.iter().any(|file| file == &filename)))
168}
169
170/// Lists the gists this tool published as the active `gh` account, recognised by
171/// the [marker](GIST_MARKER) their descriptions carry. `--paginate` walks every
172/// page so a republish or a bulk delete sees all of them, not just the first.
173pub fn list_ours() -> Result<Vec<PublishedGist>> {
174    let json = gh(&["api", "gists", "--paginate"])?;
175    let gists: Vec<ApiGist> = serde_json::from_str(&json).context("parsing gh api gists")?;
176    Ok(gists
177        .into_iter()
178        .map(PublishedGist::from)
179        .filter(PublishedGist::is_ours)
180        .collect())
181}
182
183/// Looks up a single gist by id or URL through `gh api`, so a delete can confirm
184/// it is one this tool published before removing it.
185pub fn lookup(gist: &str) -> Result<PublishedGist> {
186    let json = gh(&["api", &format!("gists/{}", gist_id(gist))])?;
187    let gist: ApiGist = serde_json::from_str(&json).context("parsing gh api gist")?;
188    Ok(gist.into())
189}
190
191/// Deletes a gist by id via `gh gist delete`. Ownership is the caller's to check
192/// (see [`PublishedGist::is_ours`]); this is the mechanical removal.
193pub fn delete(id: &str) -> Result<()> {
194    gh(&["gist", "delete", id, "--yes"]).map(drop)
195}
196
197fn visibility(public: bool) -> &'static str {
198    if public { "public" } else { "secret" }
199}
200
201/// Runs `gh` with the given arguments, piping `input` to its stdin (so the HTML
202/// never lands in a temp file), and returns its stdout.
203fn gh_stdin(args: &[&str], input: &str) -> Result<String> {
204    let mut child = Command::new("gh")
205        .args(args)
206        .stdin(Stdio::piped())
207        .stdout(Stdio::piped())
208        .stderr(Stdio::inherit())
209        .spawn()
210        .context("running gh (is the GitHub CLI installed?)")?;
211
212    child
213        .stdin
214        .take()
215        .context("gh stdin was not captured")?
216        .write_all(input.as_bytes())
217        .context("piping input to gh")?;
218
219    let output = child.wait_with_output().context("waiting for gh")?;
220    if !output.status.success() {
221        bail!("gh {} failed", args.join(" "));
222    }
223    String::from_utf8(output.stdout).context("gh produced non-UTF-8 output")
224}
225
226/// Downloads a published gist's raw files by id or URL via `gh gist view`,
227/// returning each file's name and contents so a folio can be viewed offline
228/// without any rendering proxy. `gh` resolves the id against its default host,
229/// the same one [`publish`] targets.
230pub fn fetch(gist: &str) -> Result<Vec<(String, String)>> {
231    let id = gist_id(gist);
232    let files = gh(&["gist", "view", id, "--files"])?;
233
234    let mut fetched = Vec::new();
235    for name in files.lines().map(str::trim).filter(|line| !line.is_empty()) {
236        let contents = gh(&["gist", "view", id, "--raw", "--filename", name])?;
237        fetched.push((name.to_owned(), contents));
238    }
239    Ok(fetched)
240}
241
242/// The gist id from a page URL or a bare id: `gh gist view` accepts either, but
243/// reducing a URL to its trailing id keeps a copied browser URL working too.
244fn gist_id(gist: &str) -> &str {
245    gist.trim_end_matches('/')
246        .rsplit('/')
247        .next()
248        .unwrap_or(gist)
249}
250
251/// Which host `gh gist create` targets: `GH_HOST` when set, otherwise the sole
252/// authenticated host, otherwise github.com. It has no `--hostname` flag, so
253/// this mirrors gh's own precedence.
254fn resolve_host(gh_host: Option<String>, authenticated: &[String]) -> String {
255    if let Some(host) = gh_host.filter(|host| !host.is_empty()) {
256        return host;
257    }
258    if let [only] = authenticated {
259        return only.clone();
260    }
261    "github.com".to_owned()
262}
263
264/// The active account for `host` from `gh auth status --json hosts`.
265fn parse_identity(status: &str, host: &str) -> Result<Identity> {
266    let status: AuthStatus = serde_json::from_str(status).context("parsing gh auth status")?;
267    let account = status
268        .hosts
269        .get(host)
270        .into_iter()
271        .flatten()
272        .find(|account| account.active)
273        .with_context(|| format!("gh has no active account for {host}"))?;
274    Ok(Identity {
275        login: account.login.clone(),
276        host: account.host.clone(),
277        token_source: account.token_source.clone(),
278    })
279}
280
281/// The URL that renders a published gist through a viewer page. `viewer_base` is
282/// the Pages site hosting the viewer (this project's by default, or a
283/// self-hosted one). The viewer only needs the gist id and file name, since its
284/// own script fetches the content from the GitHub API; a folio over GitHub's
285/// ~1 MB API truncation limit is fetched from its raw URL by the same script.
286/// Unlike a re-serving proxy, the viewer's host never receives the transcript.
287pub fn preview_url(viewer_base: &str, gist_url: &str, filename: &str) -> String {
288    let id = gist_id(gist_url);
289    let base = viewer_base.trim_end_matches('/');
290    format!("{base}/?{id}/{filename}")
291}
292
293/// A self-hostable copy of the viewer page. With no `ghes_host`, it is the
294/// github.com viewer verbatim; with one, its API base is rewritten to the
295/// enterprise instance (`https://<host>/api/v3`) so a viewer served from that
296/// instance's Pages can read its gists.
297pub fn scaffold_viewer(ghes_host: Option<&str>) -> Result<String> {
298    let Some(host) = ghes_host else {
299        return Ok(VIEWER_TEMPLATE.to_owned());
300    };
301
302    let api_base = format!("https://{host}/api/v3");
303    let rewritten =
304        VIEWER_TEMPLATE.replace(&format!("'{DEFAULT_API_BASE}'"), &format!("'{api_base}'"));
305    if rewritten == VIEWER_TEMPLATE {
306        bail!("viewer template no longer contains the API base to rewrite for a GHES host");
307    }
308    Ok(rewritten)
309}
310
311/// The hosts gh has an authenticated account for, in gh's own order.
312fn authenticated_hosts() -> Result<Vec<String>> {
313    let output = gh(&[
314        "auth",
315        "status",
316        "--json",
317        "hosts",
318        "--jq",
319        ".hosts | keys[]",
320    ])?;
321    Ok(output
322        .lines()
323        .map(str::trim)
324        .filter(|line| !line.is_empty())
325        .map(str::to_owned)
326        .collect())
327}
328
329/// Runs `gh` with the given arguments and returns its stdout, letting gh's own
330/// diagnostics through on stderr.
331fn gh(args: &[&str]) -> Result<String> {
332    let output = Command::new("gh")
333        .args(args)
334        .stderr(Stdio::inherit())
335        .output()
336        .context("running gh (is the GitHub CLI installed?)")?;
337    if !output.status.success() {
338        bail!("gh {} failed", args.join(" "));
339    }
340    String::from_utf8(output.stdout).context("gh produced non-UTF-8 output")
341}
342
343/// A gist as `gh api gists` reports it, kept minimal to what the management
344/// commands need.
345#[derive(Deserialize)]
346struct ApiGist {
347    id: String,
348    description: Option<String>,
349    public: bool,
350    html_url: String,
351    files: HashMap<String, IgnoredAny>,
352}
353
354/// A gist recovered from `gh api`, refined to what the shell shows and acts on.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct PublishedGist {
357    pub id: String,
358    pub description: Option<String>,
359    pub public: bool,
360    pub url: String,
361    pub files: Vec<String>,
362}
363
364impl PublishedGist {
365    /// Whether this gist was published by this tool (its description carries the
366    /// [marker](GIST_MARKER)), so a delete can refuse to touch anything else.
367    pub fn is_ours(&self) -> bool {
368        is_ours(self.description.as_deref())
369    }
370}
371
372impl From<ApiGist> for PublishedGist {
373    fn from(gist: ApiGist) -> Self {
374        let mut files: Vec<String> = gist.files.into_keys().collect();
375        files.sort();
376        PublishedGist {
377            id: gist.id,
378            description: gist.description,
379            public: gist.public,
380            url: gist.html_url,
381            files,
382        }
383    }
384}
385
386#[derive(Deserialize)]
387struct AuthStatus {
388    hosts: HashMap<String, Vec<Account>>,
389}
390
391#[derive(Deserialize)]
392#[serde(rename_all = "camelCase")]
393struct Account {
394    login: String,
395    host: String,
396    token_source: String,
397    active: bool,
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn describe_stamps_the_marker_session_id_and_title() {
406        let description = describe("abc123", Some("Investigate the missing panels"));
407        assert_eq!(
408            description,
409            "claude-scriptorium abc123: Investigate the missing panels"
410        );
411    }
412
413    #[test]
414    fn describe_collapses_whitespace_in_a_multiline_title() {
415        let description = describe("abc123", Some("first line\n\n  second   line"));
416        assert_eq!(
417            description,
418            "claude-scriptorium abc123: first line second line"
419        );
420    }
421
422    #[test]
423    fn describe_omits_the_title_when_the_session_has_none() {
424        assert_eq!(describe("abc123", None), "claude-scriptorium abc123");
425    }
426
427    #[test]
428    fn is_ours_accepts_a_description_stamped_by_this_tool() {
429        assert!(is_ours(Some("claude-scriptorium abc123: a title")));
430        assert!(is_ours(Some("claude-scriptorium abc123")));
431    }
432
433    #[test]
434    fn is_ours_rejects_foreign_and_missing_descriptions() {
435        assert!(!is_ours(Some("Claude Code session: a title")));
436        assert!(!is_ours(Some("claude-scriptorium-fork abc123")));
437        assert!(!is_ours(Some("claude-scriptorium")));
438        assert!(!is_ours(None));
439    }
440
441    #[test]
442    fn published_gist_recovers_id_visibility_url_and_file_names() {
443        let json = r#"{
444            "id": "7f15",
445            "description": "claude-scriptorium abc123: a title",
446            "public": true,
447            "html_url": "https://gist.github.com/scribe/7f15",
448            "files": {"abc123.html": {"filename": "abc123.html"}}
449        }"#;
450        let gist: PublishedGist = serde_json::from_str::<ApiGist>(json).unwrap().into();
451
452        assert_eq!(gist.id, "7f15");
453        assert!(gist.public);
454        assert_eq!(gist.url, "https://gist.github.com/scribe/7f15");
455        assert_eq!(gist.files, vec!["abc123.html".to_owned()]);
456        assert!(gist.is_ours());
457    }
458
459    #[test]
460    fn published_gist_is_not_ours_without_the_marker() {
461        let json = r#"{
462            "id": "7f15",
463            "description": "some unrelated gist",
464            "public": false,
465            "html_url": "https://gist.github.com/scribe/7f15",
466            "files": {"notes.txt": {"filename": "notes.txt"}}
467        }"#;
468        let gist: PublishedGist = serde_json::from_str::<ApiGist>(json).unwrap().into();
469
470        assert!(!gist.is_ours());
471    }
472
473    #[test]
474    fn gh_host_env_wins_over_authenticated_hosts() {
475        let host = resolve_host(
476            Some("ghe.example.com".to_owned()),
477            &["github.com".to_owned()],
478        );
479        assert_eq!(host, "ghe.example.com");
480    }
481
482    #[test]
483    fn empty_gh_host_falls_through_to_the_sole_authenticated_host() {
484        let host = resolve_host(Some(String::new()), &["ghe.example.com".to_owned()]);
485        assert_eq!(host, "ghe.example.com");
486    }
487
488    #[test]
489    fn several_authenticated_hosts_default_to_github_com() {
490        let host = resolve_host(
491            None,
492            &["ghe.example.com".to_owned(), "github.com".to_owned()],
493        );
494        assert_eq!(host, "github.com");
495    }
496
497    #[test]
498    fn no_authenticated_hosts_default_to_github_com() {
499        assert_eq!(resolve_host(None, &[]), "github.com");
500    }
501
502    #[test]
503    fn parse_identity_picks_the_active_account_for_the_host() {
504        let status = r#"{"hosts":{"github.com":[
505            {"login":"other","host":"github.com","tokenSource":"keyring","active":false},
506            {"login":"scribe","host":"github.com","tokenSource":"/etc/gh/hosts.yml","active":true}
507        ]}}"#;
508
509        let identity = parse_identity(status, "github.com").unwrap();
510
511        assert_eq!(identity.login, "scribe");
512        assert_eq!(identity.host, "github.com");
513        assert_eq!(identity.token_source, "/etc/gh/hosts.yml");
514    }
515
516    #[test]
517    fn parse_identity_fails_when_the_host_has_no_active_account() {
518        let status = r#"{"hosts":{"github.com":[
519            {"login":"scribe","host":"github.com","tokenSource":"keyring","active":false}
520        ]}}"#;
521
522        assert!(parse_identity(status, "github.com").is_err());
523    }
524
525    #[test]
526    fn preview_url_addresses_the_gist_through_the_viewer_base() {
527        let preview = preview_url(
528            "https://joshkarpel.github.io/claude-scriptorium/",
529            "https://gist.github.com/scribe/abc123",
530            "session-7.html",
531        );
532        assert_eq!(
533            preview,
534            "https://joshkarpel.github.io/claude-scriptorium/?abc123/session-7.html"
535        );
536    }
537
538    #[test]
539    fn preview_url_tolerates_a_viewer_base_without_a_trailing_slash() {
540        let preview = preview_url(
541            "https://viewer.example.com",
542            "https://gist.github.com/scribe/abc123",
543            "session-7.html",
544        );
545        assert_eq!(preview, "https://viewer.example.com/?abc123/session-7.html");
546    }
547
548    #[test]
549    fn gist_id_reduces_a_page_url_to_its_trailing_id() {
550        assert_eq!(gist_id("https://gist.github.com/scribe/abc123"), "abc123");
551        assert_eq!(gist_id("https://gist.github.com/scribe/abc123/"), "abc123");
552        assert_eq!(gist_id("abc123"), "abc123");
553    }
554
555    #[test]
556    fn scaffold_viewer_is_the_template_verbatim_for_github_com() {
557        assert_eq!(scaffold_viewer(None).unwrap(), VIEWER_TEMPLATE);
558    }
559
560    #[test]
561    fn scaffold_viewer_rewrites_the_api_base_for_a_ghes_host() {
562        let viewer = scaffold_viewer(Some("ghe.example.com")).unwrap();
563        assert!(viewer.contains("'https://ghe.example.com/api/v3'"));
564        assert!(!viewer.contains("'https://api.github.com'"));
565    }
566}