Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/auth/device_flow.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -35,11 +35,12 @@ struct ErrorResponse {

pub async fn login() -> Result<Credentials, AuthError> {
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?;

Expand Down Expand Up @@ -86,7 +87,7 @@ pub async fn login() -> Result<Credentials, AuthError> {
"urn:ietf:params:oauth:grant-type:device_code",
),
("device_code", &device.device_code),
("client_id", CLIENT_ID),
("client_id", cid.as_str()),
])
.send()
.await?;
Expand Down
9 changes: 7 additions & 2 deletions src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, AuthError> {
Expand All @@ -54,11 +58,12 @@ pub async fn get_token() -> Result<String, AuthError> {

async fn refresh(refresh_token: &str) -> Result<String, AuthError> {
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()
Expand Down
111 changes: 0 additions & 111 deletions src/cli/app_bundle.rs

This file was deleted.

8 changes: 8 additions & 0 deletions src/cli/debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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(())
}
}
}

Expand Down
8 changes: 0 additions & 8 deletions src/cli/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
32 changes: 21 additions & 11 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -55,24 +53,25 @@ enum Command {
/// Show current authenticated user
Whoami,

/// Handle a threader:// deep link URL
HandleUrl {
/// The full URL (e.g. threader://resume/<sessionId>?cwd=<path>)
url: String,
},

/// Resume a Claude Code session (downloads from cloud if needed)
Resume {
/// The session ID to resume
session_id: String,
},

/// 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<String>,
},

/// Check for and install updates
Update,

/// Print the current access token (for scripting)
Token,

/// Debug transcript sync issues
Debug {
#[command(subcommand)]
Expand Down Expand Up @@ -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,
}
Expand Down
79 changes: 0 additions & 79 deletions src/cli/resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sessionId>?cwd=<path>` 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<()> {
Expand Down Expand Up @@ -98,68 +84,3 @@ async fn resolve_session_cwd(session_id: &str) -> Result<String> {
.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/<sessionId>
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();
}
Loading