1use 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
20pub const DEFAULT_VIEWER_BASE: &str = "https://joshkarpel.github.io/claude-scriptorium/";
25
26const VIEWER_TEMPLATE: &str = include_str!("../docs/index.html");
30
31const DEFAULT_API_BASE: &str = "https://api.github.com";
34
35pub const GIST_MARKER: &str = env!("CARGO_PKG_NAME");
41
42pub 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
56fn 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#[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
85pub 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#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Published {
105 pub url: String,
106 pub updated: bool,
107}
108
109pub 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
161fn 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
170pub 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
183pub 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
191pub 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
201fn 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
226pub 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
242fn gist_id(gist: &str) -> &str {
245 gist.trim_end_matches('/')
246 .rsplit('/')
247 .next()
248 .unwrap_or(gist)
249}
250
251fn 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
264fn 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
281pub 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
293pub 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
311fn 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
329fn 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#[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#[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 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}