use std::{
fs,
io::IsTerminal,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
use claude_scriptorium::{
discovery, gist, picker,
render::{Colophon, Scribe},
serve,
transcript::Folio,
};
use comrak::plugins::syntect::{SyntectAdapter, SyntectAdapterBuilder};
use inquire::{Confirm, InquireError};
use jiff::{Timestamp, tz::TimeZone};
const VIEWER_BASE_ENV: &str = "CLAUDE_SCRIPTORIUM_VIEWER_BASE";
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Render(RenderArgs),
Serve(ServeArgs),
Publish(PublishArgs),
Fetch(FetchArgs),
ScaffoldViewer(ScaffoldViewerArgs),
}
#[derive(Args)]
struct Selection {
session: Option<PathBuf>,
#[arg(long)]
latest: bool,
}
#[derive(Args)]
struct RenderArgs {
#[command(flatten)]
selection: Selection,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
open: bool,
}
#[derive(Args)]
struct ServeArgs {
#[command(flatten)]
selection: Selection,
#[arg(long, default_value_t = 7878)]
port: u16,
#[arg(long)]
open: bool,
}
#[derive(Args)]
struct PublishArgs {
#[command(flatten)]
selection: Selection,
#[arg(long)]
public: bool,
#[arg(long)]
preview: bool,
#[arg(long, value_name = "URL")]
preview_base: Option<String>,
#[arg(long)]
yes: bool,
#[arg(long)]
open: bool,
}
#[derive(Args)]
struct FetchArgs {
gist: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
open: bool,
}
#[derive(Args)]
struct ScaffoldViewerArgs {
output: PathBuf,
#[arg(long, value_name = "HOST")]
host: Option<String>,
}
fn main() -> Result<()> {
match Cli::parse().command {
Command::Render(args) => render(args),
Command::Serve(args) => serve(args),
Command::Publish(args) => publish(args),
Command::Fetch(args) => fetch(args),
Command::ScaffoldViewer(args) => scaffold_viewer(args),
}
}
fn render(args: RenderArgs) -> Result<()> {
let session = resolve_session(args.selection)?;
let folio = Folio::read(&session)?;
let output = output_path(args.output, &folio)?;
let highlighter = highlighter();
let scribe = Scribe::new(&highlighter, TimeZone::system());
let markup = scribe.folio(&folio, &colophon());
fs::write(&output, markup.into_string())
.with_context(|| format!("writing {}", output.display()))?;
println!("{}", output.display());
if args.open {
open::that(&output).with_context(|| format!("opening {}", output.display()))?;
}
Ok(())
}
fn serve(args: ServeArgs) -> Result<()> {
let session = resolve_session(args.selection)?;
let highlighter = highlighter();
let scribe = Scribe::new(&highlighter, TimeZone::system());
serve::run(args.port, &session, args.open, || {
let folio = Folio::read(&session)?;
Ok(scribe.folio(&folio, &colophon()).into_string())
})
}
fn publish(args: PublishArgs) -> Result<()> {
let session = resolve_session(args.selection)?;
let folio = Folio::read(&session)?;
let identity = gist::resolve_identity()?;
confirm_publish(&identity, args.public, args.yes)?;
let base_override = args.preview_base.or_else(|| {
std::env::var(VIEWER_BASE_ENV)
.ok()
.filter(|value| !value.is_empty())
});
let viewer_base = resolve_viewer(&identity, args.preview, base_override, args.yes)?;
let highlighter = highlighter();
let scribe = Scribe::new(&highlighter, TimeZone::system());
let html = scribe.folio(&folio, &colophon()).into_string();
let filename = format!("{}.html", folio.session_id());
let description = gist_description(&session, folio.session_id());
let gist_url = gist::publish(&html, &filename, &description, args.public)?;
println!("{gist_url}");
let preview = viewer_base.map(|base| gist::preview_url(&base, &gist_url, &filename));
if let Some(preview) = &preview {
println!("{preview}");
}
println!("anyone can view it locally, with no proxy, by running:");
println!(" {} fetch {gist_url} --open", env!("CARGO_PKG_NAME"));
if args.open {
let url = preview.as_deref().unwrap_or(&gist_url);
open::that(url).with_context(|| format!("opening {url}"))?;
}
Ok(())
}
fn confirm_publish(identity: &gist::Identity, public: bool, assume_yes: bool) -> Result<()> {
if assume_yes {
return Ok(());
}
if !std::io::stdin().is_terminal() {
bail!("refusing to publish as {identity} without confirmation: pass --yes");
}
let visibility = if public { "public" } else { "secret" };
println!("Publishing this session as a {visibility} gist, as {identity}.");
if public {
println!(
" A public gist is listed on {} and readable by anyone.",
identity.host
);
} else {
println!(
" A secret gist is unlisted, but anyone with access to {} and the URL can read it.",
identity.host
);
}
if !ask(&format!("Publish this {visibility} gist?"))? {
bail!("aborted");
}
Ok(())
}
fn resolve_viewer(
identity: &gist::Identity,
requested: bool,
base_override: Option<String>,
assume_yes: bool,
) -> Result<Option<String>> {
if !requested {
return Ok(None);
}
let base = match base_override {
Some(base) => base,
None if identity.host == "github.com" => gist::DEFAULT_VIEWER_BASE.to_owned(),
None => {
eprintln!(
"note: no built-in viewer for {} gists; scaffold one with `{} scaffold-viewer` and pass --preview-base. Publishing without a preview link.",
identity.host,
env!("CARGO_PKG_NAME")
);
return Ok(None);
}
};
if assume_yes {
return Ok(Some(base));
}
println!("The preview link renders the folio through a viewer page at {base}.");
println!(
"The reader's browser fetches the gist straight from GitHub; the viewer's host never sees it."
);
Ok(ask("Include the preview link?")?.then_some(base))
}
fn ask(question: &str) -> Result<bool> {
match Confirm::new(question).with_default(false).prompt() {
Ok(answer) => Ok(answer),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(false),
Err(error) => Err(error.into()),
}
}
fn fetch(args: FetchArgs) -> Result<()> {
let files = gist::fetch(&args.gist)?;
let dir = args.output.unwrap_or_else(|| PathBuf::from("."));
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let mut folio = None;
for (name, contents) in &files {
let path = dir.join(name);
fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?;
println!("{}", path.display());
if folio.is_none() && name.ends_with(".html") {
folio = Some(path);
}
}
if args.open {
let folio = folio.context("gist holds no .html file to open")?;
open::that(&folio).with_context(|| format!("opening {}", folio.display()))?;
}
Ok(())
}
fn scaffold_viewer(args: ScaffoldViewerArgs) -> Result<()> {
let viewer = gist::scaffold_viewer(args.host.as_deref())?;
fs::create_dir_all(&args.output)
.with_context(|| format!("creating {}", args.output.display()))?;
let index = args.output.join("index.html");
fs::write(&index, viewer).with_context(|| format!("writing {}", index.display()))?;
let readme = args.output.join("README.md");
fs::write(&readme, viewer_readme(args.host.as_deref()))
.with_context(|| format!("writing {}", readme.display()))?;
git_init(&args.output);
println!("{}", index.display());
println!("{}", readme.display());
println!(
"next: push {} to GitHub, enable Pages (Deploy from a branch, / root), then publish with --preview-base <your Pages URL>",
args.output.display()
);
Ok(())
}
fn git_init(dir: &Path) {
let ran = std::process::Command::new("git")
.arg("init")
.arg(dir)
.stdout(std::process::Stdio::null())
.status();
if !matches!(ran, Ok(status) if status.success()) {
eprintln!(
"note: could not run `git init` in {}; do it yourself",
dir.display()
);
}
}
fn viewer_readme(host: Option<&str>) -> String {
let reads_from = host.unwrap_or("github.com");
let ghes_note = match host {
Some(host) => format!(
"\nThis viewer reads gists from `{host}` (its `/api/v3` endpoint). It must be \
served from that instance's Pages, and the instance must allow cross-origin \
requests from the Pages origin to its API and enable GitHub Pages.\n"
),
None => String::new(),
};
format!(
"# Folio viewer\n\n\
A self-hostable page that renders a Claude Code folio published as a gist on \
`{reads_from}`. The reader's browser fetches the gist from the GitHub API and \
writes it into the page, so this site's host never receives the transcript.\n\
{ghes_note}\n\
## Deploy\n\n\
1. Push this directory to a repository.\n\
2. Enable GitHub Pages for it: Settings, Pages, Deploy from a branch, your \
branch, `/` (root).\n\
3. The viewer is then served at your Pages URL, e.g. \
`https://<owner>.github.io/<repo>/`.\n\n\
## Use\n\n\
Point `publish` at it:\n\n\
```\n\
{} publish --preview --preview-base https://<owner>.github.io/<repo>/\n\
```\n\n\
Vendored from GistHost (MIT); see the license header in `index.html`.\n",
env!("CARGO_PKG_NAME")
)
}
fn gist_description(session: &Path, session_id: &str) -> String {
match Folio::peek(session).title {
Some(title) => format!(
"Claude Code session: {}",
title.split_whitespace().collect::<Vec<_>>().join(" ")
),
None => format!("Claude Code session {session_id}"),
}
}
fn resolve_session(selection: Selection) -> Result<PathBuf> {
if let Some(session) = selection.session {
return Ok(session);
}
let cwd = std::env::current_dir().context("resolving current directory")?;
let root = discovery::projects_root()?;
if selection.latest {
return Ok(discovery::quire_for(&root, &cwd)?.latest()?.to_path_buf());
}
if !std::io::stdin().is_terminal() {
bail!("no session given: pass a file path or --latest (no terminal to pick from)");
}
picker::pick_session(&root, &cwd)
}
fn output_path(output: Option<PathBuf>, folio: &Folio) -> Result<PathBuf> {
let filename = format!("{}.html", folio.session_id());
let path = match output {
None => PathBuf::from(filename),
Some(target) if is_directory_target(&target) => target.join(filename),
Some(target) => target,
};
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
Ok(path)
}
fn is_directory_target(target: &Path) -> bool {
target.is_dir()
|| target
.as_os_str()
.to_string_lossy()
.ends_with(std::path::MAIN_SEPARATOR)
}
fn highlighter() -> SyntectAdapter {
SyntectAdapterBuilder::new()
.css_with_class_prefix("ink-")
.build()
}
fn colophon() -> Colophon {
Colophon {
generated: Timestamp::now(),
tool: env!("CARGO_PKG_NAME"),
version: env!("CARGO_PKG_VERSION"),
}
}