use std::{
collections::HashMap,
fmt,
io::Write,
process::{Command, Stdio},
};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, de::IgnoredAny};
pub const DEFAULT_VIEWER_BASE: &str = "https://joshkarpel.github.io/claude-scriptorium/";
const VIEWER_TEMPLATE: &str = include_str!("../docs/index.html");
const DEFAULT_API_BASE: &str = "https://api.github.com";
pub const GIST_MARKER: &str = env!("CARGO_PKG_NAME");
pub fn describe(session_id: &str, title: Option<&str>) -> String {
match title {
Some(title) => {
let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
format!("{GIST_MARKER} {session_id}: {title}")
}
None => format!("{GIST_MARKER} {session_id}"),
}
}
fn is_ours(description: Option<&str>) -> bool {
description.is_some_and(|description| {
description
.strip_prefix(GIST_MARKER)
.is_some_and(|rest| rest.starts_with(' '))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub login: String,
pub host: String,
pub token_source: String,
}
impl fmt::Display for Identity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} on {} (auth: {})",
self.login, self.host, self.token_source
)
}
}
pub fn resolve_identity() -> Result<Identity> {
let host = resolve_host(std::env::var("GH_HOST").ok(), &authenticated_hosts()?);
let status = gh(&[
"auth",
"status",
"--active",
"--hostname",
&host,
"--json",
"hosts",
])?;
parse_identity(&status, &host)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Published {
pub url: String,
pub updated: bool,
}
pub fn publish(html: &str, session_id: &str, description: &str, public: bool) -> Result<Published> {
let filename = format!("{session_id}.html");
if let Some(existing) = find_ours(session_id)? {
if existing.public != public {
bail!(
"session {session_id} is already published as a {} gist ({}); delete it first to change its visibility",
visibility(existing.public),
existing.url
);
}
gh_stdin(
&[
"gist",
"edit",
&existing.id,
"--filename",
&filename,
"--desc",
description,
"-",
],
html,
)
.context("editing the existing gist")?;
return Ok(Published {
url: existing.url,
updated: true,
});
}
let mut args = vec![
"gist",
"create",
"-",
"--filename",
&filename,
"--desc",
description,
];
if public {
args.push("--public");
}
let url = gh_stdin(&args, html)
.context("running gh gist create (is the GitHub CLI installed?)")?
.trim()
.to_owned();
Ok(Published {
url,
updated: false,
})
}
fn find_ours(session_id: &str) -> Result<Option<PublishedGist>> {
let filename = format!("{session_id}.html");
Ok(list_ours()?
.into_iter()
.find(|gist| gist.files.iter().any(|file| file == &filename)))
}
pub fn list_ours() -> Result<Vec<PublishedGist>> {
let json = gh(&["api", "gists", "--paginate"])?;
let gists: Vec<ApiGist> = serde_json::from_str(&json).context("parsing gh api gists")?;
Ok(gists
.into_iter()
.map(PublishedGist::from)
.filter(PublishedGist::is_ours)
.collect())
}
pub fn lookup(gist: &str) -> Result<PublishedGist> {
let json = gh(&["api", &format!("gists/{}", gist_id(gist))])?;
let gist: ApiGist = serde_json::from_str(&json).context("parsing gh api gist")?;
Ok(gist.into())
}
pub fn delete(id: &str) -> Result<()> {
gh(&["gist", "delete", id, "--yes"]).map(drop)
}
fn visibility(public: bool) -> &'static str {
if public { "public" } else { "secret" }
}
fn gh_stdin(args: &[&str], input: &str) -> Result<String> {
let mut child = Command::new("gh")
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.context("running gh (is the GitHub CLI installed?)")?;
child
.stdin
.take()
.context("gh stdin was not captured")?
.write_all(input.as_bytes())
.context("piping input to gh")?;
let output = child.wait_with_output().context("waiting for gh")?;
if !output.status.success() {
bail!("gh {} failed", args.join(" "));
}
String::from_utf8(output.stdout).context("gh produced non-UTF-8 output")
}
pub fn fetch(gist: &str) -> Result<Vec<(String, String)>> {
let id = gist_id(gist);
let files = gh(&["gist", "view", id, "--files"])?;
let mut fetched = Vec::new();
for name in files.lines().map(str::trim).filter(|line| !line.is_empty()) {
let contents = gh(&["gist", "view", id, "--raw", "--filename", name])?;
fetched.push((name.to_owned(), contents));
}
Ok(fetched)
}
fn gist_id(gist: &str) -> &str {
gist.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(gist)
}
fn resolve_host(gh_host: Option<String>, authenticated: &[String]) -> String {
if let Some(host) = gh_host.filter(|host| !host.is_empty()) {
return host;
}
if let [only] = authenticated {
return only.clone();
}
"github.com".to_owned()
}
fn parse_identity(status: &str, host: &str) -> Result<Identity> {
let status: AuthStatus = serde_json::from_str(status).context("parsing gh auth status")?;
let account = status
.hosts
.get(host)
.into_iter()
.flatten()
.find(|account| account.active)
.with_context(|| format!("gh has no active account for {host}"))?;
Ok(Identity {
login: account.login.clone(),
host: account.host.clone(),
token_source: account.token_source.clone(),
})
}
pub fn preview_url(viewer_base: &str, gist_url: &str, filename: &str) -> String {
let id = gist_id(gist_url);
let base = viewer_base.trim_end_matches('/');
format!("{base}/?{id}/{filename}")
}
pub fn scaffold_viewer(ghes_host: Option<&str>) -> Result<String> {
let Some(host) = ghes_host else {
return Ok(VIEWER_TEMPLATE.to_owned());
};
let api_base = format!("https://{host}/api/v3");
let rewritten =
VIEWER_TEMPLATE.replace(&format!("'{DEFAULT_API_BASE}'"), &format!("'{api_base}'"));
if rewritten == VIEWER_TEMPLATE {
bail!("viewer template no longer contains the API base to rewrite for a GHES host");
}
Ok(rewritten)
}
fn authenticated_hosts() -> Result<Vec<String>> {
let output = gh(&[
"auth",
"status",
"--json",
"hosts",
"--jq",
".hosts | keys[]",
])?;
Ok(output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
fn gh(args: &[&str]) -> Result<String> {
let output = Command::new("gh")
.args(args)
.stderr(Stdio::inherit())
.output()
.context("running gh (is the GitHub CLI installed?)")?;
if !output.status.success() {
bail!("gh {} failed", args.join(" "));
}
String::from_utf8(output.stdout).context("gh produced non-UTF-8 output")
}
#[derive(Deserialize)]
struct ApiGist {
id: String,
description: Option<String>,
public: bool,
html_url: String,
files: HashMap<String, IgnoredAny>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublishedGist {
pub id: String,
pub description: Option<String>,
pub public: bool,
pub url: String,
pub files: Vec<String>,
}
impl PublishedGist {
pub fn is_ours(&self) -> bool {
is_ours(self.description.as_deref())
}
}
impl From<ApiGist> for PublishedGist {
fn from(gist: ApiGist) -> Self {
let mut files: Vec<String> = gist.files.into_keys().collect();
files.sort();
PublishedGist {
id: gist.id,
description: gist.description,
public: gist.public,
url: gist.html_url,
files,
}
}
}
#[derive(Deserialize)]
struct AuthStatus {
hosts: HashMap<String, Vec<Account>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Account {
login: String,
host: String,
token_source: String,
active: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn describe_stamps_the_marker_session_id_and_title() {
let description = describe("abc123", Some("Investigate the missing panels"));
assert_eq!(
description,
"claude-scriptorium abc123: Investigate the missing panels"
);
}
#[test]
fn describe_collapses_whitespace_in_a_multiline_title() {
let description = describe("abc123", Some("first line\n\n second line"));
assert_eq!(
description,
"claude-scriptorium abc123: first line second line"
);
}
#[test]
fn describe_omits_the_title_when_the_session_has_none() {
assert_eq!(describe("abc123", None), "claude-scriptorium abc123");
}
#[test]
fn is_ours_accepts_a_description_stamped_by_this_tool() {
assert!(is_ours(Some("claude-scriptorium abc123: a title")));
assert!(is_ours(Some("claude-scriptorium abc123")));
}
#[test]
fn is_ours_rejects_foreign_and_missing_descriptions() {
assert!(!is_ours(Some("Claude Code session: a title")));
assert!(!is_ours(Some("claude-scriptorium-fork abc123")));
assert!(!is_ours(Some("claude-scriptorium")));
assert!(!is_ours(None));
}
#[test]
fn published_gist_recovers_id_visibility_url_and_file_names() {
let json = r#"{
"id": "7f15",
"description": "claude-scriptorium abc123: a title",
"public": true,
"html_url": "https://gist.github.com/scribe/7f15",
"files": {"abc123.html": {"filename": "abc123.html"}}
}"#;
let gist: PublishedGist = serde_json::from_str::<ApiGist>(json).unwrap().into();
assert_eq!(gist.id, "7f15");
assert!(gist.public);
assert_eq!(gist.url, "https://gist.github.com/scribe/7f15");
assert_eq!(gist.files, vec!["abc123.html".to_owned()]);
assert!(gist.is_ours());
}
#[test]
fn published_gist_is_not_ours_without_the_marker() {
let json = r#"{
"id": "7f15",
"description": "some unrelated gist",
"public": false,
"html_url": "https://gist.github.com/scribe/7f15",
"files": {"notes.txt": {"filename": "notes.txt"}}
}"#;
let gist: PublishedGist = serde_json::from_str::<ApiGist>(json).unwrap().into();
assert!(!gist.is_ours());
}
#[test]
fn gh_host_env_wins_over_authenticated_hosts() {
let host = resolve_host(
Some("ghe.example.com".to_owned()),
&["github.com".to_owned()],
);
assert_eq!(host, "ghe.example.com");
}
#[test]
fn empty_gh_host_falls_through_to_the_sole_authenticated_host() {
let host = resolve_host(Some(String::new()), &["ghe.example.com".to_owned()]);
assert_eq!(host, "ghe.example.com");
}
#[test]
fn several_authenticated_hosts_default_to_github_com() {
let host = resolve_host(
None,
&["ghe.example.com".to_owned(), "github.com".to_owned()],
);
assert_eq!(host, "github.com");
}
#[test]
fn no_authenticated_hosts_default_to_github_com() {
assert_eq!(resolve_host(None, &[]), "github.com");
}
#[test]
fn parse_identity_picks_the_active_account_for_the_host() {
let status = r#"{"hosts":{"github.com":[
{"login":"other","host":"github.com","tokenSource":"keyring","active":false},
{"login":"scribe","host":"github.com","tokenSource":"/etc/gh/hosts.yml","active":true}
]}}"#;
let identity = parse_identity(status, "github.com").unwrap();
assert_eq!(identity.login, "scribe");
assert_eq!(identity.host, "github.com");
assert_eq!(identity.token_source, "/etc/gh/hosts.yml");
}
#[test]
fn parse_identity_fails_when_the_host_has_no_active_account() {
let status = r#"{"hosts":{"github.com":[
{"login":"scribe","host":"github.com","tokenSource":"keyring","active":false}
]}}"#;
assert!(parse_identity(status, "github.com").is_err());
}
#[test]
fn preview_url_addresses_the_gist_through_the_viewer_base() {
let preview = preview_url(
"https://joshkarpel.github.io/claude-scriptorium/",
"https://gist.github.com/scribe/abc123",
"session-7.html",
);
assert_eq!(
preview,
"https://joshkarpel.github.io/claude-scriptorium/?abc123/session-7.html"
);
}
#[test]
fn preview_url_tolerates_a_viewer_base_without_a_trailing_slash() {
let preview = preview_url(
"https://viewer.example.com",
"https://gist.github.com/scribe/abc123",
"session-7.html",
);
assert_eq!(preview, "https://viewer.example.com/?abc123/session-7.html");
}
#[test]
fn gist_id_reduces_a_page_url_to_its_trailing_id() {
assert_eq!(gist_id("https://gist.github.com/scribe/abc123"), "abc123");
assert_eq!(gist_id("https://gist.github.com/scribe/abc123/"), "abc123");
assert_eq!(gist_id("abc123"), "abc123");
}
#[test]
fn scaffold_viewer_is_the_template_verbatim_for_github_com() {
assert_eq!(scaffold_viewer(None).unwrap(), VIEWER_TEMPLATE);
}
#[test]
fn scaffold_viewer_rewrites_the_api_base_for_a_ghes_host() {
let viewer = scaffold_viewer(Some("ghe.example.com")).unwrap();
assert!(viewer.contains("'https://ghe.example.com/api/v3'"));
assert!(!viewer.contains("'https://api.github.com'"));
}
}