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),
Gists,
Delete(DeleteArgs),
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, value_name = "URL")]
preview_base: Option<String>,
#[arg(long)]
yes: bool,
#[arg(long)]
open: bool,
}
#[derive(Args)]
struct DeleteArgs {
gist: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
yes: 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::Gists => gists(),
Command::Delete(args) => delete(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, base_override);
let highlighter = highlighter();
let scribe = Scribe::new(&highlighter, TimeZone::system());
let html = scribe.folio(&folio, &colophon()).into_string();
let session_id = folio.session_id();
let filename = format!("{session_id}.html");
let description = gist::describe(session_id, Folio::peek(&session).title.as_deref());
let published = gist::publish(&html, session_id, &description, args.public)?;
if published.updated {
println!("updated the gist already published for this session");
}
let gist_url = published.url;
println!("{gist_url}");
let preview = viewer_base.map(|base| gist::preview_url(&base, &gist_url, &filename));
if let Some(preview) = &preview {
println!();
println!("preview (renders the folio in a browser through a viewer page):");
println!(" {preview}");
println!(
" the reader's browser fetches the gist straight from GitHub, so the viewer's host never sees the transcript."
);
}
println!();
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, base_override: Option<String>) -> Option<String> {
match base_override {
Some(base) => Some(base),
None if identity.host == "github.com" => Some(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")
);
None
}
}
}
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 require_confirmation(question: &str, assume_yes: bool) -> Result<()> {
if assume_yes {
return Ok(());
}
if !std::io::stdin().is_terminal() {
bail!("refusing without confirmation: pass --yes");
}
if !ask(question)? {
bail!("aborted");
}
Ok(())
}
fn gists() -> Result<()> {
let identity = gist::resolve_identity()?;
let ours = gist::list_ours()?;
if ours.is_empty() {
println!(
"no gists published by {} as {identity}",
env!("CARGO_PKG_NAME")
);
return Ok(());
}
for gist in &ours {
print_gist(gist);
}
Ok(())
}
fn delete(args: DeleteArgs) -> Result<()> {
let identity = gist::resolve_identity()?;
if args.all {
if args.gist.is_some() {
bail!("pass a gist id/URL or --all, not both");
}
return delete_all(&identity, args.yes);
}
let gist = args
.gist
.context("give a gist id or URL to delete, or --all to delete every published gist")?;
let found = gist::lookup(&gist)?;
if !found.is_ours() {
bail!(
"{} was not published by {} and will not be deleted",
found.id,
env!("CARGO_PKG_NAME")
);
}
println!("Deleting this gist, published as {identity}:");
print_gist(&found);
require_confirmation("Delete it?", args.yes)?;
gist::delete(&found.id)?;
println!("deleted {}", found.id);
Ok(())
}
fn delete_all(identity: &gist::Identity, assume_yes: bool) -> Result<()> {
let ours = gist::list_ours()?;
if ours.is_empty() {
println!(
"no gists published by {} as {identity}",
env!("CARGO_PKG_NAME")
);
return Ok(());
}
println!(
"Deleting these {} gists, published as {identity}:",
ours.len()
);
for gist in &ours {
print_gist(gist);
}
require_confirmation("Delete all of them?", assume_yes)?;
for gist in &ours {
gist::delete(&gist.id)?;
println!("deleted {}", gist.id);
}
Ok(())
}
fn print_gist(gist: &gist::PublishedGist) {
let visibility = if gist.public { "public" } else { "secret" };
println!(" {} ({visibility})", gist.url);
if let Some(description) = &gist.description {
println!(" {description}");
}
}
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-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 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"),
home: env!("CARGO_PKG_REPOSITORY"),
}
}