From 9cb71a6da107a7970ecd19ebf845dc8f08d355eb Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 11 Jun 2026 21:34:19 +0800 Subject: [PATCH] feat(share): publish sessions to Cloudflare Pages Signed-off-by: samzong --- docs/share.md | 42 +++++ src/config.rs | 9 + src/lib.rs | 1 + src/main.rs | 120 ++++++++++++- src/share.rs | 451 +++++++++++++++++++++++++++++++++++++++++++++++++ src/tui/app.rs | 95 ++++++++++- src/tui/ui.rs | 127 +++++++++++++- 7 files changed, 841 insertions(+), 4 deletions(-) create mode 100644 docs/share.md create mode 100644 src/share.rs diff --git a/docs/share.md b/docs/share.md new file mode 100644 index 0000000..9154e80 --- /dev/null +++ b/docs/share.md @@ -0,0 +1,42 @@ +# Share Spec + +## Goal + +Let a user publish one Recall session to a browser-viewable URL. The current supported provider is Cloudflare Pages. + +## Flow + +1. The user runs `recall share init` once. +2. Recall checks that `wrangler` exists, is logged in, and can access Cloudflare Pages. +3. Recall asks for a Pages project name and a local publish directory. +4. In the TUI session view, the user presses `s`. +5. Recall writes one static HTML file to `/.html`. +6. Recall deploys the publish directory with Wrangler. +7. The TUI shows `https://.pages.dev/` for the user to copy. + +## Scope + +- Supported provider: Cloudflare Pages on `pages.dev`. +- Published unit: one session, one static HTML page. +- Re-publishing the same session UUID overwrites the same route. +- Wrangler work during TUI publish is hidden unless it fails. + +## Page + +- Show readable user and assistant messages. +- Collapse tool calls and tool results by default. +- Do not show local filesystem paths. + +## Privacy + +- The published page is public to anyone with the URL. +- Recall sets no-index headers and robots rules, but this is not access control. +- Auth is not supported now; if needed later, it may use another Cloudflare tool such as Workers. +- The user is responsible for choosing sessions that are safe to share. + +## Non-Goals + +- No share picker command. +- No list, revoke, or update command. +- No authentication or private access control. +- No provider abstraction beyond what is needed for the current Cloudflare Pages path. diff --git a/src/config.rs b/src/config.rs index e4abaf3..e2a71f4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -72,6 +72,15 @@ pub struct AppConfig { /// `**/.claude-mem/**`, `**/scratch-*`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub excluded_paths: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub share: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShareConfig { + pub provider: String, + pub project_name: String, + pub publish_dir: String, } impl AppConfig { diff --git a/src/lib.rs b/src/lib.rs index 1f42c30..a7c4f00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod embedding; pub mod export; pub mod import; pub mod semantic; +pub mod share; pub mod skill_audit; pub mod tui; pub mod types; diff --git a/src/main.rs b/src/main.rs index 67a1a19..9efe3b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; +use std::io::Write as _; +use std::path::PathBuf; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -97,6 +99,22 @@ enum Commands { #[arg(long, help = "Parse and report without writing")] dry_run: bool, }, + #[command(about = "Share session pages")] + Share { + #[command(subcommand)] + command: ShareCommands, + }, +} + +#[derive(Subcommand)] +enum ShareCommands { + #[command(about = "Initialize Cloudflare Pages sharing")] + Init { + #[arg(long, help = "Cloudflare Pages project name")] + project_name: Option, + #[arg(long, help = "Local directory used for generated share pages")] + publish_dir: Option, + }, } fn main() -> Result<()> { @@ -133,6 +151,11 @@ fn main() -> Result<()> { cmd_export(source.as_deref(), time.as_deref(), project.as_deref(), limit)? } Some(Commands::Import { file, dry_run }) => cmd_import(&file, dry_run)?, + Some(Commands::Share { command }) => match command { + ShareCommands::Init { project_name, publish_dir } => { + cmd_share_init(project_name, publish_dir)? + } + }, None => cmd_tui(None)?, } @@ -1001,6 +1024,73 @@ fn cmd_import(file: &str, dry_run: bool) -> Result<()> { Ok(()) } +fn cmd_share_init(project_name: Option, publish_dir: Option) -> Result<()> { + let mut config = AppConfig::load_or_default(); + let existing = config.share.clone(); + if let Some(ref share) = existing { + println!("Share already initialized"); + println!(" Provider {}", share.provider); + println!(" Project {}", share.project_name); + println!(" Publish dir {}", share.publish_dir); + println!(" URL base https://{}.pages.dev", share.project_name); + if !prompt_yes_no_default_yes("Reinitialize?")? { + return Ok(()); + } + } + + println!("Checking Wrangler and Cloudflare Pages..."); + recall::share::preflight_cloudflare_pages()?; + + let default_project = existing + .as_ref() + .map(|share| share.project_name.clone()) + .unwrap_or_else(|| recall::share::default_project_name().to_string()); + let project_name = match project_name { + Some(name) => name, + None => prompt_with_default("Cloudflare Pages project", &default_project)?, + }; + + let default_dir = + existing.as_ref().map(|share| share.publish_dir.clone()).unwrap_or_else(|| { + recall::share::default_publish_dir() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|_| "share-pages".to_string()) + }); + let publish_dir = match publish_dir { + Some(path) => recall::share::expand_path(&path.to_string_lossy()), + None => { + let input = prompt_with_default("Local share directory", &default_dir)?; + recall::share::expand_path(&input) + } + }; + + println!("Configuring Cloudflare Pages share target..."); + recall::share::init_cloudflare_pages(&mut config, project_name.clone(), publish_dir.clone())?; + println!("Share initialized"); + println!(" Project {project_name}"); + println!(" Publish dir {}", publish_dir.display()); + println!(" URL base https://{project_name}.pages.dev"); + Ok(()) +} + +fn prompt_with_default(label: &str, default: &str) -> Result { + print!("{label} [{default}]: "); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let trimmed = input.trim(); + if trimmed.is_empty() { Ok(default.to_string()) } else { Ok(trimmed.to_string()) } +} + +fn prompt_yes_no_default_yes(label: &str) -> Result { + print!("{label} [Y/n]: "); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let trimmed = input.trim().to_lowercase(); + Ok(trimmed.is_empty() || trimmed == "y" || trimmed == "yes") +} + fn format_usage_report_text(report: &usage::UsageReport) -> String { let mut out = String::new(); writeln!(out, "Usage").unwrap(); @@ -1169,6 +1259,7 @@ fn cmd_tui(usage_start: Option<(Option>, Option)>) -> Res let tick_rate = Duration::from_millis(50); loop { + app.poll_share_publish(); terminal.draw(|f| ui::render(f, &app))?; match poll_event(tick_rate)? { @@ -1290,8 +1381,9 @@ mod tests { use std::collections::HashSet; use super::{ - BackfillPlan, Cli, Commands, ExistingSessionAction, decide_existing_session_action, - delete_excluded_sessions_for_source, raw_session_metadata_changed, + BackfillPlan, Cli, Commands, ExistingSessionAction, ShareCommands, + decide_existing_session_action, delete_excluded_sessions_for_source, + raw_session_metadata_changed, }; use clap::{CommandFactory, Parser}; use recall::adapters::{ @@ -1341,6 +1433,29 @@ mod tests { assert!(Cli::try_parse_from(["recall", "export", "--jsonl"]).is_err()); } + #[test] + fn share_init_accepts_project_and_publish_dir() { + let cli = Cli::try_parse_from([ + "recall", + "share", + "init", + "--project-name", + "recall-share", + "--publish-dir", + "/tmp/recall-share", + ]) + .unwrap(); + match cli.command { + Some(Commands::Share { + command: ShareCommands::Init { project_name, publish_dir }, + }) => { + assert_eq!(project_name.as_deref(), Some("recall-share")); + assert_eq!(publish_dir.unwrap().to_string_lossy(), "/tmp/recall-share"); + } + _ => panic!("expected share init command"), + } + } + #[test] fn top_level_help_describes_public_commands() { let mut command = Cli::command(); @@ -1352,6 +1467,7 @@ mod tests { assert!(help.contains("usage Show token usage reports")); assert!(help.contains("export Export session records as JSON Lines")); assert!(help.contains("import Import session records from JSON Lines")); + assert!(help.contains("share Share session pages")); } #[test] diff --git a/src/share.rs b/src/share.rs new file mode 100644 index 0000000..e116e01 --- /dev/null +++ b/src/share.rs @@ -0,0 +1,451 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, anyhow, bail}; + +use crate::config::{AppConfig, ShareConfig}; +use crate::types::{Message, Role, Session}; +use crate::utils; + +const PROVIDER_CLOUDFLARE_PAGES: &str = "cloudflare-pages"; +const MAX_PAGES_ASSET_BYTES: usize = 25 * 1024 * 1024; +const HEADERS: &str = "/*\n X-Robots-Tag: noindex, nofollow\n X-Frame-Options: DENY\n X-Content-Type-Options: nosniff\n Referrer-Policy: no-referrer\n"; +const ROBOTS: &str = "User-agent: *\nDisallow: /\n"; + +pub fn default_project_name() -> &'static str { + "recall-share" +} + +pub fn default_publish_dir() -> Result { + if let Some(dir) = dirs::data_local_dir().or_else(dirs::data_dir) { + return Ok(dir.join("recall").join("share-pages")); + } + let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; + Ok(home.join(".local").join("share").join("recall").join("share-pages")) +} + +pub fn expand_path(path: &str) -> PathBuf { + if path == "~" { + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)); + } + if let Some(rest) = path.strip_prefix("~/") + && let Some(home) = dirs::home_dir() + { + return home.join(rest); + } + PathBuf::from(path) +} + +pub fn preflight_cloudflare_pages() -> Result<()> { + ensure_wrangler_available()?; + ensure_wrangler_login()?; + list_pages_projects()?; + Ok(()) +} + +pub fn init_cloudflare_pages( + config: &mut AppConfig, + project_name: String, + publish_dir: PathBuf, +) -> Result<()> { + validate_project_name(&project_name)?; + ensure_wrangler_available()?; + ensure_wrangler_login()?; + ensure_pages_project(&project_name)?; + init_publish_dir(&publish_dir)?; + config.share = Some(ShareConfig { + provider: PROVIDER_CLOUDFLARE_PAGES.to_string(), + project_name, + publish_dir: publish_dir.to_string_lossy().to_string(), + }); + config.save() +} + +pub fn publish_session( + config: &AppConfig, + session: &Session, + messages: &[Message], +) -> Result { + let share = config + .share + .as_ref() + .ok_or_else(|| anyhow!("sharing is not initialized; run `recall share init` first"))?; + if share.provider != PROVIDER_CLOUDFLARE_PAGES { + bail!("unsupported share provider '{}'", share.provider); + } + validate_project_name(&share.project_name)?; + + let publish_dir = expand_path(&share.publish_dir); + init_publish_dir(&publish_dir)?; + + let share_id = share_id_for_session(session); + let html = render_session_html(session, messages); + if html.len() > MAX_PAGES_ASSET_BYTES { + bail!("session page is larger than Cloudflare Pages' 25 MiB asset limit"); + } + + let file_path = publish_dir.join(format!("{share_id}.html")); + fs::write(&file_path, html) + .with_context(|| format!("failed to write {}", file_path.display()))?; + + deploy_pages(&publish_dir, &share.project_name)?; + Ok(format!("https://{}.pages.dev/{share_id}", share.project_name)) +} + +pub fn share_id_for_session(session: &Session) -> String { + let candidate = + if session.source_id.trim().is_empty() { &session.id } else { &session.source_id }; + let mut out = String::with_capacity(candidate.len()); + for c in candidate.chars() { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + out.push(c); + } else if !out.ends_with('-') { + out.push('-'); + } + } + let trimmed = out.trim_matches('-').to_string(); + if trimmed.is_empty() { session.id.clone() } else { trimmed } +} + +pub fn render_session_html(session: &Session, messages: &[Message]) -> String { + let title = session.custom_title.as_deref().unwrap_or(&session.title); + let mut out = String::new(); + out.push_str(""); + out.push_str(""); + out.push_str(""); + out.push_str(""); + out.push_str(&escape_html(title)); + out.push_str("
"); + out.push_str("

"); + out.push_str(&escape_html(title)); + out.push_str("

"); + out.push_str(&escape_html(&session.source)); + out.push_str(""); + out.push_str(&escape_html(&format_started_at(session.started_at))); + out.push_str(""); + out.push_str(&messages.len().to_string()); + out.push_str(" messages"); + out.push_str("
"); + if messages.is_empty() { + out.push_str("
No messages in this session.
"); + } else { + for message in messages { + render_message_html(&mut out, message); + } + } + out.push_str("
"); + out +} + +fn render_message_html(out: &mut String, message: &Message) { + let role = match message.role { + Role::User => "User", + Role::Assistant => "Assistant", + }; + let class = match message.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + out.push_str("
"); + out.push_str(role); + out.push_str("
"); + + let mut text = String::new(); + let mut rendered = false; + for line in message.content.lines() { + let sanitized = utils::sanitize_line(line); + if is_tool_line(&sanitized) { + if !text.trim().is_empty() { + render_text_segment(out, &text); + } + text.clear(); + render_tool_segment(out, &sanitized); + rendered = true; + } else { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(&sanitized); + } + } + if !text.trim().is_empty() { + render_text_segment(out, &text); + rendered = true; + } + if !rendered && !message.content.is_empty() { + let sanitized = + message.content.lines().map(utils::sanitize_line).collect::>().join("\n"); + render_text_segment(out, &sanitized); + } + out.push_str("
"); +} + +fn render_text_segment(out: &mut String, text: &str) { + out.push_str("
"); + out.push_str(&escape_html(text.trim())); + out.push_str("
"); +} + +fn render_tool_segment(out: &mut String, text: &str) { + out.push_str("
"); + out.push_str(&escape_html(&tool_summary(text))); + out.push_str("
");
+    out.push_str(&escape_html(text));
+    out.push_str("
"); +} + +fn is_tool_line(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("[tool:") + || trimmed.starts_with("[tool_result:") + || trimmed.starts_with("[tool_use:") +} + +fn tool_summary(line: &str) -> String { + let trimmed = line.trim_start(); + for (prefix, label) in + [("[tool:", "Tool call"), ("[tool_result:", "Tool result"), ("[tool_use:", "Tool use")] + { + if let Some(name) = trimmed + .strip_prefix(prefix) + .and_then(|rest| rest.split(']').next()) + .filter(|name| !name.trim().is_empty()) + { + return format!("{label}: {name}"); + } + } + "Tool event".to_string() +} + +fn init_publish_dir(publish_dir: &Path) -> Result<()> { + fs::create_dir_all(publish_dir) + .with_context(|| format!("failed to create {}", publish_dir.display()))?; + fs::write(publish_dir.join("_headers"), HEADERS)?; + fs::write(publish_dir.join("robots.txt"), ROBOTS)?; + Ok(()) +} + +fn ensure_wrangler_available() -> Result<()> { + let output = wrangler_command()? + .arg("--version") + .output() + .map_err(|e| anyhow!("wrangler is not available on PATH: {e}"))?; + if output.status.success() { + Ok(()) + } else { + bail!("wrangler --version failed: {}", command_output_text(&output)); + } +} + +fn ensure_wrangler_login() -> Result<()> { + let output = wrangler_command()? + .arg("whoami") + .output() + .map_err(|e| anyhow!("failed to run wrangler whoami: {e}"))?; + if output.status.success() { + Ok(()) + } else { + bail!("wrangler is not logged in: {}", command_output_text(&output)); + } +} + +fn ensure_pages_project(project_name: &str) -> Result<()> { + if json_has_project_name(&list_pages_projects()?, project_name) { + return Ok(()); + } + let status = wrangler_command()? + .args(["pages", "project", "create", project_name, "--production-branch", "main"]) + .status() + .map_err(|e| anyhow!("failed to run wrangler pages project create: {e}"))?; + if status.success() { + Ok(()) + } else { + bail!("failed to create Cloudflare Pages project '{project_name}'"); + } +} + +fn list_pages_projects() -> Result { + let output = wrangler_command()? + .args(["pages", "project", "list", "--json"]) + .output() + .map_err(|e| anyhow!("failed to run wrangler pages project list: {e}"))?; + if !output.status.success() { + bail!("failed to list Cloudflare Pages projects: {}", command_output_text(&output)); + } + serde_json::from_slice(&output.stdout) + .map_err(|e| anyhow!("failed to parse wrangler project list JSON: {e}")) +} + +fn deploy_pages(publish_dir: &Path, project_name: &str) -> Result<()> { + let output = wrangler_command()? + .args(["pages", "deploy"]) + .arg(publish_dir) + .args(["--project-name", project_name]) + .output() + .map_err(|e| anyhow!("failed to run wrangler pages deploy: {e}"))?; + if output.status.success() { + Ok(()) + } else { + bail!("wrangler pages deploy failed: {}", command_output_text(&output)); + } +} + +fn wrangler_command() -> Result { + let mut command = Command::new("wrangler"); + command.current_dir(wrangler_work_dir()?); + Ok(command) +} + +fn wrangler_work_dir() -> Result { + let root = dirs::cache_dir() + .or_else(dirs::data_local_dir) + .or_else(dirs::data_dir) + .ok_or_else(|| anyhow!("cannot determine cache directory"))?; + let dir = root.join("recall").join("wrangler"); + fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; + Ok(dir) +} + +fn command_output_text(output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if !stderr.is_empty() { + return stderr; + } + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { "no output".to_string() } else { stdout } +} + +fn json_has_project_name(value: &serde_json::Value, project_name: &str) -> bool { + match value { + serde_json::Value::Object(map) => { + if map.get("name").and_then(|v| v.as_str()) == Some(project_name) { + return true; + } + map.values().any(|v| json_has_project_name(v, project_name)) + } + serde_json::Value::Array(values) => { + values.iter().any(|v| json_has_project_name(v, project_name)) + } + _ => false, + } +} + +fn validate_project_name(project_name: &str) -> Result<()> { + let valid = !project_name.is_empty() + && project_name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && project_name + .chars() + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + && project_name + .chars() + .last() + .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()); + if valid { + Ok(()) + } else { + bail!("Cloudflare Pages project name must use lowercase letters, digits, and hyphens"); + } +} + +fn format_started_at(started_at: i64) -> String { + chrono::DateTime::from_timestamp_millis(started_at) + .map(|dt| dt.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "unknown time".to_string()) +} + +fn escape_html(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for c in input.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session(source_id: &str) -> Session { + Session { + id: "local-id".to_string(), + source: "codex".to_string(), + source_id: source_id.to_string(), + title: "Fix ".to_string(), + directory: Some("/tmp/project".to_string()), + started_at: 0, + updated_at: None, + message_count: 1, + entrypoint: None, + custom_title: None, + summary: None, + duration_minutes: None, + source_file_path: None, + is_import: false, + } + } + + #[test] + fn share_id_prefers_source_id() { + assert_eq!( + share_id_for_session(&session("019e6d8d-588b-7fd2-a326-c525469ed120")), + "019e6d8d-588b-7fd2-a326-c525469ed120" + ); + } + + #[test] + fn share_id_sanitizes_path_chars() { + assert_eq!(share_id_for_session(&session("foo/bar baz")), "foo-bar-baz"); + } + + #[test] + fn html_renderer_escapes_content() { + let html = render_session_html( + &session("s1"), + &[Message { + session_id: "local-id".to_string(), + role: Role::User, + content: "".to_string(), + timestamp: None, + seq: 0, + }], + ); + assert!(html.contains("<script>alert('x')</script>")); + assert!(!html.contains("