Skip to content

Commit 221537f

Browse files
committed
auth: expose in-process login API
1 parent 08ee461 commit 221537f

8 files changed

Lines changed: 517 additions & 117 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cctty"
3-
version = "0.2.6"
3+
version = "0.2.7"
44
edition = "2024"
55
description = "Claude Agent SDK compatibility through the interactive Claude Code TTY"
66
license = "MIT"

src/auth.rs

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
use std::collections::HashSet;
2+
use std::path::PathBuf;
3+
use std::sync::{Arc, Mutex};
4+
use std::time::{Duration, Instant};
5+
6+
use serde::{Deserialize, Serialize};
7+
use serde_json::Value;
8+
use tokio::sync::mpsc;
9+
use tokio::task::JoinHandle;
10+
11+
use crate::error::{CcttyError, Result};
12+
use crate::logging;
13+
use crate::pty::{PtyProcess, PtySpawnSpec};
14+
use crate::runner::{
15+
claude_path::resolve_claude_path_with_options, interactive_claude_env,
16+
interactive_claude_unset_env, plain_tty_output,
17+
};
18+
19+
pub const DEFAULT_AUTH_LOGIN_TIMEOUT: Duration = Duration::from_secs(3600);
20+
21+
const AUTH_LOGIN_POLL: Duration = Duration::from_millis(100);
22+
const PTY_TERMINATE_TIMEOUT: Duration = Duration::from_secs(5);
23+
24+
#[derive(Debug, Clone)]
25+
pub struct AuthLoginOptions {
26+
pub passthrough_args: Vec<String>,
27+
pub cwd: Option<PathBuf>,
28+
pub claude_path: Option<PathBuf>,
29+
pub timeout: Duration,
30+
}
31+
32+
impl Default for AuthLoginOptions {
33+
fn default() -> Self {
34+
Self {
35+
passthrough_args: vec!["auth".to_owned(), "login".to_owned()],
36+
cwd: None,
37+
claude_path: None,
38+
timeout: DEFAULT_AUTH_LOGIN_TIMEOUT,
39+
}
40+
}
41+
}
42+
43+
#[derive(Debug, Clone, Default)]
44+
pub struct AuthStatusOptions {
45+
pub cwd: Option<PathBuf>,
46+
pub claude_path: Option<PathBuf>,
47+
}
48+
49+
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
50+
#[serde(tag = "type", rename_all = "snake_case")]
51+
pub enum AuthLoginEvent {
52+
Started { command: String, args: Vec<String> },
53+
AuthorizationUrl { url: String },
54+
InputRequested { input: String, prompt: String },
55+
Success { message: String },
56+
Error { message: String },
57+
Exit { exit_code: i32 },
58+
}
59+
60+
pub struct AuthLoginSession {
61+
input: AuthLoginInput,
62+
events: Option<mpsc::Receiver<AuthLoginEvent>>,
63+
join: Option<JoinHandle<Result<i32>>>,
64+
}
65+
66+
impl AuthLoginSession {
67+
pub fn start(options: AuthLoginOptions) -> Result<Self> {
68+
let cwd = match options.cwd {
69+
Some(cwd) => cwd,
70+
None => std::env::current_dir()?,
71+
};
72+
let cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
73+
let claude = resolve_claude_path_with_options(Some(&cwd), options.claude_path.as_deref())?;
74+
let env = interactive_claude_env();
75+
logging::event(format!(
76+
"auth_login_session_spawn claude={} args={}",
77+
claude,
78+
options.passthrough_args.len()
79+
));
80+
81+
let process = PtyProcess::spawn(&PtySpawnSpec {
82+
command: claude.clone(),
83+
args: options.passthrough_args.clone(),
84+
cwd,
85+
env,
86+
unset_env: interactive_claude_unset_env(&options.passthrough_args),
87+
})?;
88+
let (event_tx, event_rx) = mpsc::channel(64);
89+
let (input_tx, input_rx) = mpsc::channel(8);
90+
let input = AuthLoginInput::new(input_tx);
91+
let join = tokio::spawn(run_auth_login_session(
92+
process,
93+
input_rx,
94+
event_tx,
95+
claude,
96+
options.passthrough_args,
97+
options.timeout,
98+
));
99+
100+
Ok(Self {
101+
input,
102+
events: Some(event_rx),
103+
join: Some(join),
104+
})
105+
}
106+
107+
pub fn input(&self) -> AuthLoginInput {
108+
self.input.clone()
109+
}
110+
111+
pub fn take_events(&mut self) -> mpsc::Receiver<AuthLoginEvent> {
112+
self.events
113+
.take()
114+
.expect("auth login events receiver already taken")
115+
}
116+
117+
pub async fn wait(mut self) -> Result<i32> {
118+
let join = self
119+
.join
120+
.take()
121+
.expect("auth login session completion already taken");
122+
match join.await {
123+
Ok(result) => result,
124+
Err(error) => Err(CcttyError::Tty(format!(
125+
"Claude auth login task failed: {error}"
126+
))),
127+
}
128+
}
129+
}
130+
131+
impl Drop for AuthLoginSession {
132+
fn drop(&mut self) {
133+
self.input.close();
134+
if let Some(join) = self.join.take() {
135+
join.abort();
136+
}
137+
}
138+
}
139+
140+
#[derive(Clone)]
141+
pub struct AuthLoginInput {
142+
sender: Arc<Mutex<Option<mpsc::Sender<String>>>>,
143+
}
144+
145+
impl AuthLoginInput {
146+
fn new(sender: mpsc::Sender<String>) -> Self {
147+
Self {
148+
sender: Arc::new(Mutex::new(Some(sender))),
149+
}
150+
}
151+
152+
pub async fn submit_code(&self, code: impl Into<String>) -> Result<()> {
153+
let sender = self
154+
.sender
155+
.lock()
156+
.map_err(|_| CcttyError::Tty("Claude auth login input lock failed".to_owned()))?
157+
.clone()
158+
.ok_or_else(|| CcttyError::Tty("Claude auth login session is closed".to_owned()))?;
159+
sender
160+
.send(code.into())
161+
.await
162+
.map_err(|_| CcttyError::Tty("Claude auth login session is closed".to_owned()))
163+
}
164+
165+
pub fn close(&self) {
166+
if let Ok(mut sender) = self.sender.lock() {
167+
sender.take();
168+
}
169+
}
170+
}
171+
172+
pub async fn auth_status_json(options: AuthStatusOptions) -> Result<Value> {
173+
let cwd = match options.cwd {
174+
Some(cwd) => cwd,
175+
None => std::env::current_dir()?,
176+
};
177+
let cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
178+
let claude = resolve_claude_path_with_options(Some(&cwd), options.claude_path.as_deref())?;
179+
let output = tokio::process::Command::new(claude)
180+
.args(["auth", "status", "--json"])
181+
.current_dir(cwd)
182+
.output()
183+
.await?;
184+
if !output.status.success() {
185+
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
186+
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
187+
let detail = if stderr.is_empty() { stdout } else { stderr };
188+
return Err(CcttyError::Tty(if detail.is_empty() {
189+
format!("Claude Code auth status exited with {}.", output.status)
190+
} else {
191+
detail
192+
}));
193+
}
194+
Ok(serde_json::from_slice(&output.stdout)?)
195+
}
196+
197+
async fn run_auth_login_session(
198+
mut process: PtyProcess,
199+
mut input_rx: mpsc::Receiver<String>,
200+
event_tx: mpsc::Sender<AuthLoginEvent>,
201+
claude: String,
202+
args: Vec<String>,
203+
timeout: Duration,
204+
) -> Result<i32> {
205+
send_auth_event(
206+
&event_tx,
207+
AuthLoginEvent::Started {
208+
command: claude,
209+
args,
210+
},
211+
)
212+
.await?;
213+
214+
let started = Instant::now();
215+
let mut state = AuthLoginEventState::default();
216+
loop {
217+
while let Ok(line) = input_rx.try_recv() {
218+
process.write_all(line.as_bytes())?;
219+
process.write_all(b"\n")?;
220+
}
221+
222+
process_auth_login_output(&process.recent_output(), &mut state, &event_tx).await?;
223+
224+
if let Some(code) = process.try_wait()? {
225+
process_auth_login_output(&process.recent_output(), &mut state, &event_tx).await?;
226+
if code != 0 {
227+
send_auth_event(
228+
&event_tx,
229+
AuthLoginEvent::Error {
230+
message: format!("Claude auth login exited with code {code}"),
231+
},
232+
)
233+
.await?;
234+
}
235+
send_auth_event(&event_tx, AuthLoginEvent::Exit { exit_code: code }).await?;
236+
logging::event(format!("auth_login_session_exit exit_code={code}"));
237+
return Ok(code);
238+
}
239+
240+
if started.elapsed() >= timeout {
241+
process.terminate(PTY_TERMINATE_TIMEOUT);
242+
send_auth_event(
243+
&event_tx,
244+
AuthLoginEvent::Error {
245+
message: "Claude auth login timed out".to_owned(),
246+
},
247+
)
248+
.await?;
249+
send_auth_event(&event_tx, AuthLoginEvent::Exit { exit_code: 124 }).await?;
250+
logging::event("auth_login_session_timeout");
251+
return Ok(124);
252+
}
253+
254+
tokio::time::sleep(AUTH_LOGIN_POLL).await;
255+
}
256+
}
257+
258+
async fn send_auth_event(
259+
event_tx: &mpsc::Sender<AuthLoginEvent>,
260+
event: AuthLoginEvent,
261+
) -> Result<()> {
262+
event_tx
263+
.send(event)
264+
.await
265+
.map_err(|_| CcttyError::Tty("Claude auth login event receiver closed".to_owned()))
266+
}
267+
268+
#[derive(Default)]
269+
struct AuthLoginEventState {
270+
seen_urls: HashSet<String>,
271+
input_requested: bool,
272+
success: bool,
273+
}
274+
275+
async fn process_auth_login_output(
276+
output: &str,
277+
state: &mut AuthLoginEventState,
278+
event_tx: &mpsc::Sender<AuthLoginEvent>,
279+
) -> Result<()> {
280+
let plain = plain_tty_output(output);
281+
for url in auth_login_urls(&plain) {
282+
if state.seen_urls.insert(url.clone()) {
283+
send_auth_event(event_tx, AuthLoginEvent::AuthorizationUrl { url }).await?;
284+
}
285+
}
286+
if !state.input_requested && plain.contains("Paste code here if prompted") {
287+
state.input_requested = true;
288+
send_auth_event(
289+
event_tx,
290+
AuthLoginEvent::InputRequested {
291+
input: "authorization_code".to_owned(),
292+
prompt: "Paste code here if prompted >".to_owned(),
293+
},
294+
)
295+
.await?;
296+
}
297+
if !state.success && plain.contains("Login successful") {
298+
state.success = true;
299+
send_auth_event(
300+
event_tx,
301+
AuthLoginEvent::Success {
302+
message: "Login successful.".to_owned(),
303+
},
304+
)
305+
.await?;
306+
}
307+
Ok(())
308+
}
309+
310+
fn auth_login_urls(text: &str) -> Vec<String> {
311+
let mut urls = Vec::new();
312+
let mut rest = text;
313+
while let Some(offset) = rest.find("https://") {
314+
let candidate = &rest[offset..];
315+
let end = candidate
316+
.find(|ch: char| ch.is_whitespace() || matches!(ch, '"' | '\'' | '<' | '>' | ')' | ']'))
317+
.unwrap_or(candidate.len());
318+
let url = candidate[..end]
319+
.trim_end_matches(|ch| matches!(ch, '.' | ',' | ';' | ':'))
320+
.to_owned();
321+
if !url.is_empty() {
322+
urls.push(url);
323+
}
324+
rest = &candidate[end..];
325+
}
326+
urls
327+
}
328+
329+
pub(crate) fn auth_event_to_json(event: &AuthLoginEvent) -> Result<Value> {
330+
Ok(serde_json::to_value(event)?)
331+
}

src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
mod args;
2+
pub mod auth;
23
mod error;
34
mod logging;
45
mod pty;
5-
mod runner;
6+
pub(crate) mod runner;
67
mod transcript;
78

89
pub use error::{CcttyError, Result};

0 commit comments

Comments
 (0)