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        // Content and description in one call, and the trailing `-` is what
127        // makes it safe: `gh gist edit --desc` does not return once it has set
128        // the description, it falls through to the file-edit loop, and with no
129        // source file that loop opens $EDITOR. A second, description-only call
130        // would therefore launch an editor against a piped stdin rather than
131        // update anything. One call sets both in a single request.
132        gh_stdin(
133            &[
134                "gist",
135                "edit",
136                &existing.id,
137                "--filename",
138                &filename,
139                "--desc",
140                description,
141                "-",
142            ],
143            html,
144        )
145        .context("editing the existing gist")?;
146        return Ok(Published {
147            url: existing.url,
148            updated: true,
149        });
150    }
151
152    let mut args = vec![
153        "gist",
154        "create",
155        "-",
156        "--filename",
157        &filename,
158        "--desc",
159        description,
160    ];
161    if public {
162        args.push("--public");
163    }
164    let url = gh_stdin(&args, html)
165        .context("running gh gist create (is the GitHub CLI installed?)")?
166        .trim()
167        .to_owned();
168    Ok(Published {
169        url,
170        updated: false,
171    })
172}
173
174/// The gist this tool published for `session_id`, if any: the one among our
175/// gists whose files include `<session_id>.html`.
176fn find_ours(session_id: &str) -> Result<Option<PublishedGist>> {
177    let filename = format!("{session_id}.html");
178    Ok(list_ours()?
179        .into_iter()
180        .find(|gist| gist.files.iter().any(|file| file == &filename)))
181}
182
183/// Lists the gists this tool published as the active `gh` account, recognised by
184/// the [marker](GIST_MARKER) their descriptions carry. `--paginate` walks every
185/// page so a republish or a bulk delete sees all of them, not just the first.
186pub fn list_ours() -> Result<Vec<PublishedGist>> {
187    let json = gh(&["api", "gists", "--paginate"])?;
188    let gists: Vec<ApiGist> = serde_json::from_str(&json).context("parsing gh api gists")?;
189    Ok(gists
190        .into_iter()
191        .map(PublishedGist::from)
192        .filter(PublishedGist::is_ours)
193        .collect())
194}
195
196/// Looks up a single gist by id or URL through `gh api`, so a delete can confirm
197/// it is one this tool published before removing it.
198pub fn lookup(gist: &str) -> Result<PublishedGist> {
199    let json = gh(&["api", &format!("gists/{}", gist_id(gist))])?;
200    let gist: ApiGist = serde_json::from_str(&json).context("parsing gh api gist")?;
201    Ok(gist.into())
202}
203
204/// Deletes a gist by id via `gh gist delete`. Ownership is the caller's to check
205/// (see [`PublishedGist::is_ours`]); this is the mechanical removal.
206pub fn delete(id: &str) -> Result<()> {
207    gh(&["gist", "delete", id, "--yes"]).map(drop)
208}
209
210fn visibility(public: bool) -> &'static str {
211    if public { "public" } else { "secret" }
212}
213
214/// Runs `gh` with the given arguments, piping `input` to its stdin (so the HTML
215/// never lands in a temp file), and returns its stdout.
216fn gh_stdin(args: &[&str], input: &str) -> Result<String> {
217    let mut child = Command::new("gh")
218        .args(args)
219        .stdin(Stdio::piped())
220        .stdout(Stdio::piped())
221        .stderr(Stdio::inherit())
222        .spawn()
223        .context("running gh (is the GitHub CLI installed?)")?;
224
225    child
226        .stdin
227        .take()
228        .context("gh stdin was not captured")?
229        .write_all(input.as_bytes())
230        .context("piping input to gh")?;
231
232    let output = child.wait_with_output().context("waiting for gh")?;
233    if !output.status.success() {
234        bail!("gh {} failed", args.join(" "));
235    }
236    String::from_utf8(output.stdout).context("gh produced non-UTF-8 output")
237}
238
239/// Downloads a published gist's raw files by id or URL via `gh gist view`,
240/// returning each file's name and contents so a folio can be viewed offline
241/// without any rendering proxy. `gh` resolves the id against its default host,
242/// the same one [`publish`] targets.
243pub fn fetch(gist: &str) -> Result<Vec<(String, String)>> {
244    let id = gist_id(gist);
245    let files = gh(&["gist", "view", id, "--files"])?;
246
247    let mut fetched = Vec::new();
248    for name in files.lines().map(str::trim).filter(|line| !line.is_empty()) {
249        let contents = gh(&["gist", "view", id, "--raw", "--filename", name])?;
250        fetched.push((name.to_owned(), contents));
251    }
252    Ok(fetched)
253}
254
255/// The gist id from a page URL or a bare id: `gh gist view` accepts either, but
256/// reducing a URL to its trailing id keeps a copied browser URL working too.
257fn gist_id(gist: &str) -> &str {
258    gist.trim_end_matches('/')
259        .rsplit('/')
260        .next()
261        .unwrap_or(gist)
262}
263
264/// Which host `gh gist create` targets: `GH_HOST` when set, otherwise the sole
265/// authenticated host, otherwise github.com. It has no `--hostname` flag, so
266/// this mirrors gh's own precedence.
267fn resolve_host(gh_host: Option<String>, authenticated: &[String]) -> String {
268    if let Some(host) = gh_host.filter(|host| !host.is_empty()) {
269        return host;
270    }
271    if let [only] = authenticated {
272        return only.clone();
273    }
274    "github.com".to_owned()
275}
276
277/// The active account for `host` from `gh auth status --json hosts`.
278fn parse_identity(status: &str, host: &str) -> Result<Identity> {
279    let status: AuthStatus = serde_json::from_str(status).context("parsing gh auth status")?;
280    let account = status
281        .hosts
282        .get(host)
283        .into_iter()
284        .flatten()
285        .find(|account| account.active)
286        .with_context(|| format!("gh has no active account for {host}"))?;
287    Ok(Identity {
288        login: account.login.clone(),
289        host: account.host.clone(),
290        token_source: account.token_source.clone(),
291    })
292}
293
294/// The URL that renders a published gist through a viewer page. `viewer_base` is
295/// the Pages site hosting the viewer (this project's by default, or a
296/// self-hosted one). The viewer only needs the gist id and file name, since its
297/// own script fetches the content from the GitHub API; a folio over GitHub's
298/// ~1 MB API truncation limit is fetched from its raw URL by the same script.
299/// Unlike a re-serving proxy, the viewer's host never receives the transcript.
300pub fn preview_url(viewer_base: &str, gist_url: &str, filename: &str) -> String {
301    let id = gist_id(gist_url);
302    let base = viewer_base.trim_end_matches('/');
303    format!("{base}/?{id}/{filename}")
304}
305
306/// A self-hostable copy of the viewer page. With no `ghes_host`, it is the
307/// github.com viewer verbatim; with one, its API base is rewritten to the
308/// enterprise instance (`https://<host>/api/v3`) so a viewer served from that
309/// instance's Pages can read its gists.
310pub fn scaffold_viewer(ghes_host: Option<&str>) -> Result<String> {
311    let Some(host) = ghes_host else {
312        return Ok(VIEWER_TEMPLATE.to_owned());
313    };
314
315    let api_base = format!("https://{host}/api/v3");
316    let rewritten =
317        VIEWER_TEMPLATE.replace(&format!("'{DEFAULT_API_BASE}'"), &format!("'{api_base}'"));
318    if rewritten == VIEWER_TEMPLATE {
319        bail!("viewer template no longer contains the API base to rewrite for a GHES host");
320    }
321    Ok(rewritten)
322}
323
324/// The hosts gh has an authenticated account for, in gh's own order.
325fn authenticated_hosts() -> Result<Vec<String>> {
326    let output = gh(&[
327        "auth",
328        "status",
329        "--json",
330        "hosts",
331        "--jq",
332        ".hosts | keys[]",
333    ])?;
334    Ok(output
335        .lines()
336        .map(str::trim)
337        .filter(|line| !line.is_empty())
338        .map(str::to_owned)
339        .collect())
340}
341
342/// Runs `gh` with the given arguments and returns its stdout, letting gh's own
343/// diagnostics through on stderr.
344fn gh(args: &[&str]) -> Result<String> {
345    let output = Command::new("gh")
346        .args(args)
347        .stderr(Stdio::inherit())
348        .output()
349        .context("running gh (is the GitHub CLI installed?)")?;
350    if !output.status.success() {
351        bail!("gh {} failed", args.join(" "));
352    }
353    String::from_utf8(output.stdout).context("gh produced non-UTF-8 output")
354}
355
356/// A gist as `gh api gists` reports it, kept minimal to what the management
357/// commands need.
358#[derive(Deserialize)]
359struct ApiGist {
360    id: String,
361    description: Option<String>,
362    public: bool,
363    html_url: String,
364    files: HashMap<String, IgnoredAny>,
365}
366
367/// A gist recovered from `gh api`, refined to what the shell shows and acts on.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct PublishedGist {
370    pub id: String,
371    pub description: Option<String>,
372    pub public: bool,
373    pub url: String,
374    pub files: Vec<String>,
375}
376
377impl PublishedGist {
378    /// Whether this gist was published by this tool (its description carries the
379    /// [marker](GIST_MARKER)), so a delete can refuse to touch anything else.
380    pub fn is_ours(&self) -> bool {
381        is_ours(self.description.as_deref())
382    }
383}
384
385impl From<ApiGist> for PublishedGist {
386    fn from(gist: ApiGist) -> Self {
387        let mut files: Vec<String> = gist.files.into_keys().collect();
388        files.sort();
389        PublishedGist {
390            id: gist.id,
391            description: gist.description,
392            public: gist.public,
393            url: gist.html_url,
394            files,
395        }
396    }
397}
398
399#[derive(Deserialize)]
400struct AuthStatus {
401    hosts: HashMap<String, Vec<Account>>,
402}
403
404#[derive(Deserialize)]
405#[serde(rename_all = "camelCase")]
406struct Account {
407    login: String,
408    host: String,
409    token_source: String,
410    active: bool,
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn describe_stamps_the_marker_session_id_and_title() {
419        let description = describe("abc123", Some("Investigate the missing panels"));
420        assert_eq!(
421            description,
422            "claude-scriptorium abc123: Investigate the missing panels"
423        );
424    }
425
426    #[test]
427    fn describe_collapses_whitespace_in_a_multiline_title() {
428        let description = describe("abc123", Some("first line\n\n  second   line"));
429        assert_eq!(
430            description,
431            "claude-scriptorium abc123: first line second line"
432        );
433    }
434
435    #[test]
436    fn describe_omits_the_title_when_the_session_has_none() {
437        assert_eq!(describe("abc123", None), "claude-scriptorium abc123");
438    }
439
440    #[test]
441    fn is_ours_accepts_a_description_stamped_by_this_tool() {
442        assert!(is_ours(Some("claude-scriptorium abc123: a title")));
443        assert!(is_ours(Some("claude-scriptorium abc123")));
444    }
445
446    #[test]
447    fn is_ours_rejects_foreign_and_missing_descriptions() {
448        assert!(!is_ours(Some("Claude Code session: a title")));
449        assert!(!is_ours(Some("claude-scriptorium-fork abc123")));
450        assert!(!is_ours(Some("claude-scriptorium")));
451        assert!(!is_ours(None));
452    }
453
454    #[test]
455    fn published_gist_recovers_id_visibility_url_and_file_names() {
456        let json = r#"{
457            "id": "7f15",
458            "description": "claude-scriptorium abc123: a title",
459            "public": true,
460            "html_url": "https://gist.github.com/scribe/7f15",
461            "files": {"abc123.html": {"filename": "abc123.html"}}
462        }"#;
463        let gist: PublishedGist = serde_json::from_str::<ApiGist>(json).unwrap().into();
464
465        assert_eq!(gist.id, "7f15");
466        assert!(gist.public);
467        assert_eq!(gist.url, "https://gist.github.com/scribe/7f15");
468        assert_eq!(gist.files, vec!["abc123.html".to_owned()]);
469        assert!(gist.is_ours());
470    }
471
472    #[test]
473    fn published_gist_is_not_ours_without_the_marker() {
474        let json = r#"{
475            "id": "7f15",
476            "description": "some unrelated gist",
477            "public": false,
478            "html_url": "https://gist.github.com/scribe/7f15",
479            "files": {"notes.txt": {"filename": "notes.txt"}}
480        }"#;
481        let gist: PublishedGist = serde_json::from_str::<ApiGist>(json).unwrap().into();
482
483        assert!(!gist.is_ours());
484    }
485
486    #[test]
487    fn gh_host_env_wins_over_authenticated_hosts() {
488        let host = resolve_host(
489            Some("ghe.example.com".to_owned()),
490            &["github.com".to_owned()],
491        );
492        assert_eq!(host, "ghe.example.com");
493    }
494
495    #[test]
496    fn empty_gh_host_falls_through_to_the_sole_authenticated_host() {
497        let host = resolve_host(Some(String::new()), &["ghe.example.com".to_owned()]);
498        assert_eq!(host, "ghe.example.com");
499    }
500
501    #[test]
502    fn several_authenticated_hosts_default_to_github_com() {
503        let host = resolve_host(
504            None,
505            &["ghe.example.com".to_owned(), "github.com".to_owned()],
506        );
507        assert_eq!(host, "github.com");
508    }
509
510    #[test]
511    fn no_authenticated_hosts_default_to_github_com() {
512        assert_eq!(resolve_host(None, &[]), "github.com");
513    }
514
515    #[test]
516    fn parse_identity_picks_the_active_account_for_the_host() {
517        let status = r#"{"hosts":{"github.com":[
518            {"login":"other","host":"github.com","tokenSource":"keyring","active":false},
519            {"login":"scribe","host":"github.com","tokenSource":"/etc/gh/hosts.yml","active":true}
520        ]}}"#;
521
522        let identity = parse_identity(status, "github.com").unwrap();
523
524        assert_eq!(identity.login, "scribe");
525        assert_eq!(identity.host, "github.com");
526        assert_eq!(identity.token_source, "/etc/gh/hosts.yml");
527    }
528
529    #[test]
530    fn parse_identity_fails_when_the_host_has_no_active_account() {
531        let status = r#"{"hosts":{"github.com":[
532            {"login":"scribe","host":"github.com","tokenSource":"keyring","active":false}
533        ]}}"#;
534
535        assert!(parse_identity(status, "github.com").is_err());
536    }
537
538    #[test]
539    fn preview_url_addresses_the_gist_through_the_viewer_base() {
540        let preview = preview_url(
541            "https://joshkarpel.github.io/claude-scriptorium/",
542            "https://gist.github.com/scribe/abc123",
543            "session-7.html",
544        );
545        assert_eq!(
546            preview,
547            "https://joshkarpel.github.io/claude-scriptorium/?abc123/session-7.html"
548        );
549    }
550
551    #[test]
552    fn preview_url_tolerates_a_viewer_base_without_a_trailing_slash() {
553        let preview = preview_url(
554            "https://viewer.example.com",
555            "https://gist.github.com/scribe/abc123",
556            "session-7.html",
557        );
558        assert_eq!(preview, "https://viewer.example.com/?abc123/session-7.html");
559    }
560
561    #[test]
562    fn gist_id_reduces_a_page_url_to_its_trailing_id() {
563        assert_eq!(gist_id("https://gist.github.com/scribe/abc123"), "abc123");
564        assert_eq!(gist_id("https://gist.github.com/scribe/abc123/"), "abc123");
565        assert_eq!(gist_id("abc123"), "abc123");
566    }
567
568    #[test]
569    fn scaffold_viewer_is_the_template_verbatim_for_github_com() {
570        assert_eq!(scaffold_viewer(None).unwrap(), VIEWER_TEMPLATE);
571    }
572
573    #[test]
574    fn scaffold_viewer_rewrites_the_api_base_for_a_ghes_host() {
575        let viewer = scaffold_viewer(Some("ghe.example.com")).unwrap();
576        assert!(viewer.contains("'https://ghe.example.com/api/v3'"));
577        assert!(!viewer.contains("'https://api.github.com'"));
578    }
579}