From 75fa6a1550b4cbb75835dea401209d9e7686b399 Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 7 Feb 2026 18:44:58 -0800 Subject: [PATCH 1/2] Add workspace sharing, token command, and dev tooling support - Add `share -w ` flag to share sessions with a workspace by slug - Add `token` command for scripting (prints current access token) - Add `debug resolve` command that prints the session ID `share` would use - Make WorkOS client ID configurable via THREADER_WORKOS_CLIENT_ID env var - Remove unused deep-link URL handling (app_bundle, terminal, handle-url) Co-Authored-By: Claude Opus 4.6 --- src/auth/device_flow.rs | 7 +- src/auth/mod.rs | 9 ++- src/cli/app_bundle.rs | 111 --------------------------- src/cli/debug.rs | 8 ++ src/cli/init.rs | 8 -- src/cli/mod.rs | 32 +++++--- src/cli/resume.rs | 79 ------------------- src/cli/share.rs | 12 ++- src/cli/terminal.rs | 163 ---------------------------------------- 9 files changed, 49 insertions(+), 380 deletions(-) delete mode 100644 src/cli/app_bundle.rs delete mode 100644 src/cli/terminal.rs diff --git a/src/auth/device_flow.rs b/src/auth/device_flow.rs index 5242cef..910f3d8 100644 --- a/src/auth/device_flow.rs +++ b/src/auth/device_flow.rs @@ -1,7 +1,7 @@ use serde::Deserialize; use tracing::{info, warn}; -use super::{AuthError, Credentials, CLIENT_ID}; +use super::{AuthError, Credentials, client_id}; #[derive(Debug, Deserialize)] struct DeviceAuthResponse { @@ -35,11 +35,12 @@ struct ErrorResponse { pub async fn login() -> Result { let client = reqwest::Client::new(); + let cid = client_id(); // Step 1: Initiate device flow let resp = client .post("https://api.workos.com/user_management/authorize/device") - .form(&[("client_id", CLIENT_ID), ("screen_hint", "sign-up")]) + .form(&[("client_id", cid.as_str()), ("screen_hint", "sign-up")]) .send() .await?; @@ -86,7 +87,7 @@ pub async fn login() -> Result { "urn:ietf:params:oauth:grant-type:device_code", ), ("device_code", &device.device_code), - ("client_id", CLIENT_ID), + ("client_id", cid.as_str()), ]) .send() .await?; diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 95da5c1..3982a96 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -29,7 +29,11 @@ pub enum AuthError { Http(#[from] reqwest::Error), } -const CLIENT_ID: &str = "client_01KFE40Z1FZ1NJQKHTNNPPWZ3C"; +const DEFAULT_CLIENT_ID: &str = "client_01KFE40Z1FZ1NJQKHTNNPPWZ3C"; + +pub fn client_id() -> String { + std::env::var("THREADER_WORKOS_CLIENT_ID").unwrap_or_else(|_| DEFAULT_CLIENT_ID.to_string()) +} /// Get a valid access token, refreshing if needed. pub async fn get_token() -> Result { @@ -54,11 +58,12 @@ pub async fn get_token() -> Result { async fn refresh(refresh_token: &str) -> Result { let client = reqwest::Client::new(); + let cid = client_id(); let resp = client .post("https://api.workos.com/user_management/authenticate") .form(&[ ("grant_type", "refresh_token"), - ("client_id", CLIENT_ID), + ("client_id", cid.as_str()), ("refresh_token", refresh_token), ]) .send() diff --git a/src/cli/app_bundle.rs b/src/cli/app_bundle.rs deleted file mode 100644 index c40c269..0000000 --- a/src/cli/app_bundle.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::fs; -use std::process::Command; - -use anyhow::{Context, Result}; -use tracing::info; - -/// Create a macOS .app bundle at `~/.threader/Threader.app` that registers -/// the `threader://` URL scheme. When macOS opens a `threader://` URL, it -/// launches this app which delegates to `threader handle-url `. -pub fn create_app_bundle() -> Result<()> { - let home = dirs::home_dir().context("Could not determine home directory")?; - let threader_dir = home.join(".threader"); - let app_path = threader_dir.join("Threader.app"); - - // Remove existing bundle for idempotency - if app_path.exists() { - fs::remove_dir_all(&app_path) - .with_context(|| format!("Failed to remove existing app bundle: {}", app_path.display()))?; - } - - fs::create_dir_all(&threader_dir)?; - - // Resolve threader binary path - let threader_bin = home.join(".local").join("bin").join("threader"); - let threader_cmd = if threader_bin.exists() { - threader_bin - .to_str() - .context("Non-UTF8 path to threader binary")? - .to_string() - } else { - "threader".to_string() - }; - - // Write AppleScript source that handles URL open events - let applescript = format!( - r#"on open location this_URL - do shell script "{threader_cmd} handle-url " & quoted form of this_URL & " &> /dev/null &" -end open location"# - ); - - let script_path = threader_dir.join("url_handler.applescript"); - fs::write(&script_path, &applescript)?; - - // Compile into .app bundle - let output = Command::new("osacompile") - .args(["-o"]) - .arg(&app_path) - .arg(&script_path) - .output() - .context("Failed to run osacompile")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("osacompile failed: {}", stderr.trim()); - } - - // Clean up source file - let _ = fs::remove_file(&script_path); - - // Patch Info.plist to register URL scheme - let plist_path = app_path.join("Contents").join("Info.plist"); - patch_info_plist(&plist_path)?; - - // Register with Launch Services - let output = Command::new("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister") - .args(["-R", "-f"]) - .arg(&app_path) - .output() - .context("Failed to run lsregister")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("lsregister failed: {}", stderr.trim()); - } - - info!("Created URL handler app at {}", app_path.display()); - Ok(()) -} - -/// Patch the Info.plist to add URL scheme registration and hide from dock. -fn patch_info_plist(plist_path: &std::path::Path) -> Result<()> { - // Read existing plist (osacompile generates an XML plist) - let content = fs::read_to_string(plist_path) - .with_context(|| format!("Failed to read {}", plist_path.display()))?; - - // Insert our keys before the closing - let additions = r#" - CFBundleIdentifier - sh.threader.URLHandler - CFBundleURLTypes - - - CFBundleURLName - Threader URL - CFBundleURLSchemes - - threader - - - - LSUIElement - -"#; - - let patched = content.replace("\n", &format!("{additions}\n")); - - fs::write(plist_path, &patched) - .with_context(|| format!("Failed to write patched {}", plist_path.display()))?; - - Ok(()) -} diff --git a/src/cli/debug.rs b/src/cli/debug.rs index 038eab0..7629a6c 100644 --- a/src/cli/debug.rs +++ b/src/cli/debug.rs @@ -36,6 +36,9 @@ pub enum DebugCommand { #[arg(long, default_value = "20")] max_diffs: usize, }, + + /// Print the session ID that `share` would resolve (for scripting) + Resolve, } /// Convex HTTP endpoint base URL (same as uploader.rs). @@ -61,6 +64,11 @@ pub async fn run(command: DebugCommand) -> Result<()> { session_id, max_diffs, } => cmd_diff(&session_id, max_diffs).await, + DebugCommand::Resolve => { + let session_id = crate::cli::share::resolve_current_session()?; + print!("{session_id}"); + Ok(()) + } } } diff --git a/src/cli/init.rs b/src/cli/init.rs index 6699528..29854d7 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -20,14 +20,6 @@ pub fn init_core() -> Result<()> { install_hooks()?; - #[cfg(target_os = "macos")] - { - match super::app_bundle::create_app_bundle() { - Ok(()) => println!("URL scheme registered (threader://)."), - Err(e) => eprintln!("Warning: failed to register URL scheme: {e}"), - } - } - Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2aa2e3e..72e60fa 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,11 +1,9 @@ -pub mod app_bundle; pub mod debug; pub mod hook; pub mod hydrate; pub mod init; pub mod resume; pub mod share; -pub mod terminal; use anyhow::{Context, Result}; use chrono::Utc; @@ -55,12 +53,6 @@ enum Command { /// Show current authenticated user Whoami, - /// Handle a threader:// deep link URL - HandleUrl { - /// The full URL (e.g. threader://resume/?cwd=) - url: String, - }, - /// Resume a Claude Code session (downloads from cloud if needed) Resume { /// The session ID to resume @@ -68,11 +60,18 @@ enum Command { }, /// Share the current session and print the URL - Share, + Share { + /// Share with a specific workspace (by slug) instead of making public + #[arg(short, long)] + workspace: Option, + }, /// Check for and install updates Update, + /// Print the current access token (for scripting) + Token, + /// Debug transcript sync issues Debug { #[command(subcommand)] @@ -143,9 +142,20 @@ impl Cli { show_status(base_dir) } Command::Whoami => show_whoami(), - Command::HandleUrl { url } => resume::handle_url(&url).await, Command::Resume { session_id } => resume::resume_session(&session_id).await, - Command::Share => share::run().await, + Command::Share { workspace } => share::run(workspace).await, + Command::Token => { + match crate::auth::get_token().await { + Ok(token) => { + print!("{token}"); + Ok(()) + } + Err(e) => { + eprintln!("Not authenticated: {e}"); + std::process::exit(1); + } + } + } Command::Update => crate::sync::updater::run_manual_update().await, Command::Debug { command } => debug::run(command).await, } diff --git a/src/cli/resume.rs b/src/cli/resume.rs index 038600a..ec24481 100644 --- a/src/cli/resume.rs +++ b/src/cli/resume.rs @@ -3,24 +3,10 @@ use std::process::Command; use anyhow::{Context, Result}; use tracing::info; -use url::Url; use super::hydrate; -use super::terminal; use crate::storage::local::LocalStorage; -/// Handle a `threader://resume/?cwd=` deep link URL. -/// -/// Since this runs from a URL handler (no terminal attached), all errors are -/// shown as native macOS dialogs via osascript. -pub async fn handle_url(raw_url: &str) -> Result<()> { - if let Err(e) = handle_url_inner(raw_url).await { - show_error_dialog(&format!("Threader Resume Error\n\n{e}")); - anyhow::bail!("{e}"); - } - Ok(()) -} - /// Resume a session from the CLI. Looks up the session locally first, then /// falls back to downloading from cloud. Runs `claude --resume` in the current terminal. pub async fn resume_session(session_id: &str) -> Result<()> { @@ -98,68 +84,3 @@ async fn resolve_session_cwd(session_id: &str) -> Result { .map(|s| s.to_string()) .context("Session has no working directory (cwd) set") } - -async fn handle_url_inner(raw_url: &str) -> Result<()> { - let url = Url::parse(raw_url).context("Invalid URL")?; - - // Extract session ID from path: threader://resume/ - let path_segments: Vec<&str> = url - .path_segments() - .map(|s| s.collect()) - .unwrap_or_default(); - - let session_id = path_segments - .first() - .filter(|s| !s.is_empty()) - .context("Missing session ID in URL")?; - - // Extract cwd from query params - let cwd = url - .query_pairs() - .find(|(k, _)| k == "cwd") - .map(|(_, v)| v.into_owned()) - .context("Missing 'cwd' query parameter in URL")?; - - // Validate cwd exists on disk - if !Path::new(&cwd).is_dir() { - anyhow::bail!( - "The working directory does not exist on this machine:\n{cwd}\n\n\ - This session was started in a directory that doesn't exist locally." - ); - } - - // Check claude binary is available - let claude_exists = Command::new("which") - .arg("claude") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - - if !claude_exists { - anyhow::bail!( - "Claude Code CLI not found.\n\n\ - Install it from https://docs.anthropic.com/en/docs/claude-code" - ); - } - - // Hydrate session transcript (downloads from cloud if not present locally) - info!("Hydrating session {session_id} for cwd {cwd}"); - hydrate::hydrate_session(session_id, &cwd).await?; - - // Detect terminal and launch - let term = terminal::detect_terminal(); - let command = format!("claude --resume {session_id}"); - info!("Opening {:?} with: {command}", term); - terminal::open_in_terminal(term, &cwd, &command)?; - - Ok(()) -} - -/// Show a native macOS error dialog (since there's no terminal for stderr). -fn show_error_dialog(message: &str) { - let escaped = message.replace('\\', "\\\\").replace('"', "\\\""); - let script = format!( - r#"display dialog "{escaped}" with title "Threader" buttons {{"OK"}} default button "OK" with icon stop"# - ); - let _ = Command::new("osascript").args(["-e", &script]).output(); -} diff --git a/src/cli/share.rs b/src/cli/share.rs index c385421..7c59e7d 100644 --- a/src/cli/share.rs +++ b/src/cli/share.rs @@ -2,7 +2,7 @@ use anyhow::{bail, Context, Result}; use crate::storage::local::LocalStorage; -pub async fn run() -> Result<()> { +pub async fn run(workspace: Option) -> Result<()> { let session_id = resolve_current_session()?; let token = crate::auth::get_token() @@ -12,11 +12,17 @@ pub async fn run() -> Result<()> { let site_url = std::env::var("THREADER_CONVEX_SITE_URL") .unwrap_or_else(|_| "https://ceaseless-shepherd-756.convex.site".to_string()); + let body = if let Some(ref ws) = workspace { + serde_json::json!({ "workspace": ws }) + } else { + serde_json::json!({ "visibility": "public" }) + }; + let client = reqwest::Client::new(); let resp = client .post(format!("{site_url}/api/sessions/{session_id}/share")) .bearer_auth(&token) - .json(&serde_json::json!({ "visibility": "public" })) + .json(&body) .send() .await .context("Failed to reach Threader cloud")?; @@ -36,7 +42,7 @@ pub async fn run() -> Result<()> { Ok(()) } -fn resolve_current_session() -> Result { +pub fn resolve_current_session() -> Result { // Try PID-based lookup first if let Some(claude_pid) = crate::process::find_claude_ancestor_pid() { let base = LocalStorage::default_base_dir()?; diff --git a/src/cli/terminal.rs b/src/cli/terminal.rs deleted file mode 100644 index 482ea71..0000000 --- a/src/cli/terminal.rs +++ /dev/null @@ -1,163 +0,0 @@ -use std::process::Command; - -use anyhow::{Context, Result}; -use tracing::{debug, warn}; - -/// Supported macOS terminal emulators. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Terminal { - ITerm2, - Ghostty, - Alacritty, - Kitty, - WezTerm, - TerminalApp, -} - -impl Terminal { - /// Process name as reported by macOS System Events. - fn process_name(self) -> &'static str { - match self { - Terminal::ITerm2 => "iTerm2", - Terminal::Ghostty => "Ghostty", - Terminal::Alacritty => "Alacritty", - Terminal::Kitty => "kitty", - Terminal::WezTerm => "WezTerm", - Terminal::TerminalApp => "Terminal", - } - } -} - -/// Detection priority order (most preferred first). -const DETECTION_ORDER: &[Terminal] = &[ - Terminal::ITerm2, - Terminal::Ghostty, - Terminal::Alacritty, - Terminal::Kitty, - Terminal::WezTerm, - Terminal::TerminalApp, -]; - -/// Detect the user's running terminal by querying macOS System Events. -pub fn detect_terminal() -> Terminal { - let script = r#"tell application "System Events" to get name of every application process"#; - let output = Command::new("osascript").args(["-e", script]).output(); - - let processes = match output { - Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), - _ => { - debug!("Failed to query running applications, falling back to Terminal.app"); - return Terminal::TerminalApp; - } - }; - - for terminal in DETECTION_ORDER { - if processes.contains(terminal.process_name()) { - debug!("Detected terminal: {:?}", terminal); - return *terminal; - } - } - - debug!("No known terminal detected, falling back to Terminal.app"); - Terminal::TerminalApp -} - -/// Shell-escape a string for use in AppleScript (double backslashes and escape quotes). -fn applescript_escape(s: &str) -> String { - s.replace('\\', "\\\\").replace('"', "\\\"") -} - -/// Open a terminal window, cd to `cwd`, and run `command`. -pub fn open_in_terminal(terminal: Terminal, cwd: &str, command: &str) -> Result<()> { - let result = launch(terminal, cwd, command); - if result.is_err() && terminal != Terminal::TerminalApp { - warn!( - "Failed to launch {:?}, falling back to Terminal.app: {:?}", - terminal, - result.as_ref().err() - ); - return launch(Terminal::TerminalApp, cwd, command); - } - result -} - -fn launch(terminal: Terminal, cwd: &str, command: &str) -> Result<()> { - match terminal { - Terminal::ITerm2 => launch_iterm2(cwd, command), - Terminal::TerminalApp => launch_terminal_app(cwd, command), - Terminal::Ghostty => launch_cli("ghostty", &["-e", "bash", "-c"], cwd, command), - Terminal::Alacritty => { - launch_cli("alacritty", &["--working-directory"], cwd, command) - } - Terminal::Kitty => launch_cli("kitty", &["--directory"], cwd, command), - Terminal::WezTerm => launch_cli("wezterm", &["start", "--cwd"], cwd, command), - } -} - -fn launch_iterm2(cwd: &str, command: &str) -> Result<()> { - let escaped_cwd = applescript_escape(cwd); - let escaped_cmd = applescript_escape(command); - let script = format!( - r#" - tell application "iTerm" - activate - set newWindow to (create window with default profile) - tell current session of newWindow - write text "cd \"{escaped_cwd}\" && {escaped_cmd}" - end tell - end tell - "# - ); - run_applescript(&script) -} - -fn launch_terminal_app(cwd: &str, command: &str) -> Result<()> { - let escaped_cwd = applescript_escape(cwd); - let escaped_cmd = applescript_escape(command); - let script = format!( - r#" - tell application "Terminal" - activate - do script "cd \"{escaped_cwd}\" && {escaped_cmd}" - end tell - "# - ); - run_applescript(&script) -} - -fn launch_cli(bin: &str, args: &[&str], cwd: &str, command: &str) -> Result<()> { - let full_cmd = format!("cd '{}' && {}", cwd.replace('\'', "'\\''"), command); - - let mut cmd = Command::new(bin); - // For alacritty/kitty/wezterm, the working directory flag takes the path, - // then we pass -e bash -c to run the command. - // For ghostty, -e bash -c is already in args. - if bin == "ghostty" { - cmd.args(args); - cmd.arg(&full_cmd); - } else { - cmd.args(args); - cmd.arg(cwd); - cmd.args(["-e", "bash", "-c", &full_cmd]); - } - - let status = cmd - .spawn() - .with_context(|| format!("Failed to spawn {bin}"))?; - - debug!("Launched {bin} with pid {:?}", status.id()); - Ok(()) -} - -fn run_applescript(script: &str) -> Result<()> { - let output = Command::new("osascript") - .args(["-e", script]) - .output() - .context("Failed to run osascript")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("AppleScript failed: {}", stderr.trim()); - } - Ok(()) -} From 9d0865c75689df82840a0d1c724e300a5c8a86af Mon Sep 17 00:00:00 2001 From: "continue-staging[bot]" <230944351+continue-staging[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:03:53 +0000 Subject: [PATCH 2/2] Remove unnecessary intermediate variable in share command The body variable was used exactly once on the next line, which is a classic AI slop pattern. Inline the conditional JSON construction directly into the .json() call for cleaner code. Co-authored-by: nate Generated with [Continue](https://continue.dev) Co-Authored-By: Continue --- src/cli/share.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/cli/share.rs b/src/cli/share.rs index 7c59e7d..920920b 100644 --- a/src/cli/share.rs +++ b/src/cli/share.rs @@ -12,17 +12,15 @@ pub async fn run(workspace: Option) -> Result<()> { let site_url = std::env::var("THREADER_CONVEX_SITE_URL") .unwrap_or_else(|_| "https://ceaseless-shepherd-756.convex.site".to_string()); - let body = if let Some(ref ws) = workspace { - serde_json::json!({ "workspace": ws }) - } else { - serde_json::json!({ "visibility": "public" }) - }; - let client = reqwest::Client::new(); let resp = client .post(format!("{site_url}/api/sessions/{session_id}/share")) .bearer_auth(&token) - .json(&body) + .json(&if let Some(ref ws) = workspace { + serde_json::json!({ "workspace": ws }) + } else { + serde_json::json!({ "visibility": "public" }) + }) .send() .await .context("Failed to reach Threader cloud")?;