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(
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
174fn 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
183pub 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
196pub 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
204pub 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
214fn 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
239pub 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
255fn gist_id(gist: &str) -> &str {
258 gist.trim_end_matches('/')
259 .rsplit('/')
260 .next()
261 .unwrap_or(gist)
262}
263
264fn 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
277fn 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
294pub 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
306pub 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
324fn 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
342fn 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#[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#[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 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}