ソースを参照

Merge remote-tracking branch 'origin/rcc/api' into dev/rust

# Conflicts:
#	rust/crates/claw-cli/src/main.rs
Yeachan-Heo 2 ヶ月 前
コミット
0f8dc4b5c2

+ 1 - 0
.claude/sessions/session-1774998936453.json

@@ -0,0 +1 @@
+{"messages":[],"version":1}

+ 1 - 0
.claude/sessions/session-1774998994373.json

@@ -0,0 +1 @@
+{"messages":[{"blocks":[{"text":"Say hello in one sentence","type":"text"}],"role":"user"},{"blocks":[{"text":"Hello! I'm Claude, an AI assistant ready to help you with software engineering tasks, code analysis, debugging, or any other programming challenges you might have.","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":11,"output_tokens":32}}],"version":1}

+ 1 - 0
rust/Cargo.lock

@@ -22,6 +22,7 @@ name = "api"
 version = "0.1.0"
 dependencies = [
  "reqwest",
+ "runtime",
  "serde",
  "serde_json",
  "tokio",

+ 23 - 2
rust/README.md

@@ -64,6 +64,26 @@ cd rust
 cargo run -p rusty-claude-cli -- --version
 ```
 
+### Login with OAuth
+
+Configure `settings.json` with an `oauth` block containing `clientId`, `authorizeUrl`, `tokenUrl`, optional `callbackPort`, and optional `scopes`, then run:
+
+```bash
+cd rust
+cargo run -p rusty-claude-cli -- login
+```
+
+This opens the browser, listens on the configured localhost callback, exchanges the auth code for tokens, and stores OAuth credentials in `~/.claude/credentials.json` (or `$CLAUDE_CONFIG_HOME/credentials.json`).
+
+### Logout
+
+```bash
+cd rust
+cargo run -p rusty-claude-cli -- logout
+```
+
+This removes only the stored OAuth credentials and preserves unrelated JSON fields in `credentials.json`.
+
 ## Usage examples
 
 ### 1) Prompt mode
@@ -155,8 +175,9 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
 
 ### Anthropic/API
 
-- `ANTHROPIC_AUTH_TOKEN` — preferred bearer token for API auth
-- `ANTHROPIC_API_KEY` — legacy API key fallback if auth token is unset
+- `ANTHROPIC_API_KEY` — highest-precedence API credential
+- `ANTHROPIC_AUTH_TOKEN` — bearer-token override used when no API key is set
+- Persisted OAuth credentials in `~/.claude/credentials.json` — used when neither env var is set
 - `ANTHROPIC_BASE_URL` — override the Anthropic API base URL
 - `ANTHROPIC_MODEL` — default model used by selected live integration tests
 

+ 1 - 0
rust/crates/api/Cargo.toml

@@ -7,6 +7,7 @@ publish.workspace = true
 
 [dependencies]
 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+runtime = { path = "../runtime" }
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }

+ 267 - 7
rust/crates/api/src/client.rs

@@ -1,6 +1,10 @@
 use std::collections::VecDeque;
-use std::time::Duration;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
 
+use runtime::{
+    load_oauth_credentials, save_oauth_credentials, OAuthConfig, OAuthRefreshRequest,
+    OAuthTokenExchangeRequest,
+};
 use serde::Deserialize;
 
 use crate::error::ApiError;
@@ -81,11 +85,12 @@ impl AuthSource {
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
 pub struct OAuthTokenSet {
     pub access_token: String,
     pub refresh_token: Option<String>,
     pub expires_at: Option<u64>,
+    #[serde(default)]
     pub scopes: Vec<String>,
 }
 
@@ -131,7 +136,7 @@ impl AnthropicClient {
     }
 
     pub fn from_env() -> Result<Self, ApiError> {
-        Ok(Self::from_auth(AuthSource::from_env()?).with_base_url(read_base_url()))
+        Ok(Self::from_auth(AuthSource::from_env_or_saved()?).with_base_url(read_base_url()))
     }
 
     #[must_use]
@@ -225,6 +230,46 @@ impl AnthropicClient {
         })
     }
 
+    pub async fn exchange_oauth_code(
+        &self,
+        config: &OAuthConfig,
+        request: &OAuthTokenExchangeRequest,
+    ) -> Result<OAuthTokenSet, ApiError> {
+        let response = self
+            .http
+            .post(&config.token_url)
+            .header("content-type", "application/x-www-form-urlencoded")
+            .form(&request.form_params())
+            .send()
+            .await
+            .map_err(ApiError::from)?;
+        let response = expect_success(response).await?;
+        response
+            .json::<OAuthTokenSet>()
+            .await
+            .map_err(ApiError::from)
+    }
+
+    pub async fn refresh_oauth_token(
+        &self,
+        config: &OAuthConfig,
+        request: &OAuthRefreshRequest,
+    ) -> Result<OAuthTokenSet, ApiError> {
+        let response = self
+            .http
+            .post(&config.token_url)
+            .header("content-type", "application/x-www-form-urlencoded")
+            .form(&request.form_params())
+            .send()
+            .await
+            .map_err(ApiError::from)?;
+        let response = expect_success(response).await?;
+        response
+            .json::<OAuthTokenSet>()
+            .await
+            .map_err(ApiError::from)
+    }
+
     async fn send_with_retry(
         &self,
         request: &MessageRequest,
@@ -304,6 +349,99 @@ impl AnthropicClient {
     }
 }
 
+impl AuthSource {
+    pub fn from_env_or_saved() -> Result<Self, ApiError> {
+        if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
+            return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
+                Some(bearer_token) => Ok(Self::ApiKeyAndBearer {
+                    api_key,
+                    bearer_token,
+                }),
+                None => Ok(Self::ApiKey(api_key)),
+            };
+        }
+        if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
+            return Ok(Self::BearerToken(bearer_token));
+        }
+        match load_saved_oauth_token() {
+            Ok(Some(token_set)) if oauth_token_is_expired(&token_set) => {
+                if token_set.refresh_token.is_some() {
+                    Err(ApiError::Auth(
+                        "saved OAuth token is expired; load runtime OAuth config to refresh it"
+                            .to_string(),
+                    ))
+                } else {
+                    Err(ApiError::ExpiredOAuthToken)
+                }
+            }
+            Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)),
+            Ok(None) => Err(ApiError::MissingApiKey),
+            Err(error) => Err(error),
+        }
+    }
+}
+
+#[must_use]
+pub fn oauth_token_is_expired(token_set: &OAuthTokenSet) -> bool {
+    token_set
+        .expires_at
+        .is_some_and(|expires_at| expires_at <= now_unix_timestamp())
+}
+
+pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTokenSet>, ApiError> {
+    let Some(token_set) = load_saved_oauth_token()? else {
+        return Ok(None);
+    };
+    if !oauth_token_is_expired(&token_set) {
+        return Ok(Some(token_set));
+    }
+    let Some(refresh_token) = token_set.refresh_token.clone() else {
+        return Err(ApiError::ExpiredOAuthToken);
+    };
+    let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(read_base_url());
+    let refreshed = client_runtime_block_on(async {
+        client
+            .refresh_oauth_token(
+                config,
+                &OAuthRefreshRequest::from_config(config, refresh_token, Some(token_set.scopes)),
+            )
+            .await
+    })?;
+    save_oauth_credentials(&runtime::OAuthTokenSet {
+        access_token: refreshed.access_token.clone(),
+        refresh_token: refreshed.refresh_token.clone(),
+        expires_at: refreshed.expires_at,
+        scopes: refreshed.scopes.clone(),
+    })
+    .map_err(ApiError::from)?;
+    Ok(Some(refreshed))
+}
+
+fn client_runtime_block_on<F, T>(future: F) -> Result<T, ApiError>
+where
+    F: std::future::Future<Output = Result<T, ApiError>>,
+{
+    tokio::runtime::Runtime::new()
+        .map_err(ApiError::from)?
+        .block_on(future)
+}
+
+fn load_saved_oauth_token() -> Result<Option<OAuthTokenSet>, ApiError> {
+    let token_set = load_oauth_credentials().map_err(ApiError::from)?;
+    Ok(token_set.map(|token_set| OAuthTokenSet {
+        access_token: token_set.access_token,
+        refresh_token: token_set.refresh_token,
+        expires_at: token_set.expires_at,
+        scopes: token_set.scopes,
+    }))
+}
+
+fn now_unix_timestamp() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_or(0, |duration| duration.as_secs())
+}
+
 fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
     match std::env::var(key) {
         Ok(value) if !value.is_empty() => Ok(Some(value)),
@@ -314,7 +452,7 @@ fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
 
 #[cfg(test)]
 fn read_api_key() -> Result<String, ApiError> {
-    let auth = AuthSource::from_env()?;
+    let auth = AuthSource::from_env_or_saved()?;
     auth.api_key()
         .or_else(|| auth.bearer_token())
         .map(ToOwned::to_owned)
@@ -424,10 +562,18 @@ struct AnthropicErrorBody {
 #[cfg(test)]
 mod tests {
     use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
+    use std::io::{Read, Write};
+    use std::net::TcpListener;
     use std::sync::{Mutex, OnceLock};
-    use std::time::Duration;
+    use std::thread;
+    use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+    use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig};
 
-    use crate::client::{AuthSource, OAuthTokenSet};
+    use crate::client::{
+        now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient,
+        AuthSource, OAuthTokenSet,
+    };
     use crate::types::{ContentBlockDelta, MessageRequest};
 
     fn env_lock() -> std::sync::MutexGuard<'static, ()> {
@@ -437,11 +583,53 @@ mod tests {
             .expect("env lock")
     }
 
+    fn temp_config_home() -> std::path::PathBuf {
+        std::env::temp_dir().join(format!(
+            "api-oauth-test-{}-{}",
+            std::process::id(),
+            SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .expect("time")
+                .as_nanos()
+        ))
+    }
+
+    fn sample_oauth_config(token_url: String) -> OAuthConfig {
+        OAuthConfig {
+            client_id: "runtime-client".to_string(),
+            authorize_url: "https://console.test/oauth/authorize".to_string(),
+            token_url,
+            callback_port: Some(4545),
+            manual_redirect_url: Some("https://console.test/oauth/callback".to_string()),
+            scopes: vec!["org:read".to_string(), "user:write".to_string()],
+        }
+    }
+
+    fn spawn_token_server(response_body: &'static str) -> String {
+        let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
+        let address = listener.local_addr().expect("local addr");
+        thread::spawn(move || {
+            let (mut stream, _) = listener.accept().expect("accept connection");
+            let mut buffer = [0_u8; 4096];
+            let _ = stream.read(&mut buffer).expect("read request");
+            let response = format!(
+                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
+                response_body.len(),
+                response_body
+            );
+            stream
+                .write_all(response.as_bytes())
+                .expect("write response");
+        });
+        format!("http://{address}/oauth/token")
+    }
+
     #[test]
     fn read_api_key_requires_presence() {
         let _guard = env_lock();
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
         std::env::remove_var("ANTHROPIC_API_KEY");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
         let error = super::read_api_key().expect_err("missing key should error");
         assert!(matches!(error, crate::error::ApiError::MissingApiKey));
     }
@@ -453,6 +641,7 @@ mod tests {
         std::env::remove_var("ANTHROPIC_API_KEY");
         let error = super::read_api_key().expect_err("empty key should error");
         assert!(matches!(error, crate::error::ApiError::MissingApiKey));
+        std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
     }
 
     #[test]
@@ -500,6 +689,77 @@ mod tests {
         std::env::remove_var("ANTHROPIC_API_KEY");
     }
 
+    #[test]
+    fn auth_source_from_saved_oauth_when_env_absent() {
+        let _guard = env_lock();
+        let config_home = temp_config_home();
+        std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
+        std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+        std::env::remove_var("ANTHROPIC_API_KEY");
+        save_oauth_credentials(&runtime::OAuthTokenSet {
+            access_token: "saved-access-token".to_string(),
+            refresh_token: Some("refresh".to_string()),
+            expires_at: Some(now_unix_timestamp() + 300),
+            scopes: vec!["scope:a".to_string()],
+        })
+        .expect("save oauth credentials");
+
+        let auth = AuthSource::from_env_or_saved().expect("saved auth");
+        assert_eq!(auth.bearer_token(), Some("saved-access-token"));
+
+        clear_oauth_credentials().expect("clear credentials");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
+        std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
+    }
+
+    #[test]
+    fn oauth_token_expiry_uses_expires_at_timestamp() {
+        assert!(oauth_token_is_expired(&OAuthTokenSet {
+            access_token: "access-token".to_string(),
+            refresh_token: None,
+            expires_at: Some(1),
+            scopes: Vec::new(),
+        }));
+        assert!(!oauth_token_is_expired(&OAuthTokenSet {
+            access_token: "access-token".to_string(),
+            refresh_token: None,
+            expires_at: Some(now_unix_timestamp() + 60),
+            scopes: Vec::new(),
+        }));
+    }
+
+    #[test]
+    fn resolve_saved_oauth_token_refreshes_expired_credentials() {
+        let _guard = env_lock();
+        let config_home = temp_config_home();
+        std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
+        std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
+        std::env::remove_var("ANTHROPIC_API_KEY");
+        save_oauth_credentials(&runtime::OAuthTokenSet {
+            access_token: "expired-access-token".to_string(),
+            refresh_token: Some("refresh-token".to_string()),
+            expires_at: Some(1),
+            scopes: vec!["scope:a".to_string()],
+        })
+        .expect("save expired oauth credentials");
+
+        let token_url = spawn_token_server(
+            "{\"access_token\":\"refreshed-token\",\"refresh_token\":\"fresh-refresh\",\"expires_at\":9999999999,\"scopes\":[\"scope:a\"]}",
+        );
+        let resolved = resolve_saved_oauth_token(&sample_oauth_config(token_url))
+            .expect("resolve refreshed token")
+            .expect("token set present");
+        assert_eq!(resolved.access_token, "refreshed-token");
+        let stored = runtime::load_oauth_credentials()
+            .expect("load stored credentials")
+            .expect("stored token set");
+        assert_eq!(stored.access_token, "refreshed-token");
+
+        clear_oauth_credentials().expect("clear credentials");
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
+        std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
+    }
+
     #[test]
     fn message_request_stream_helper_sets_stream_true() {
         let request = MessageRequest {
@@ -517,7 +777,7 @@ mod tests {
 
     #[test]
     fn backoff_doubles_until_maximum() {
-        let client = super::AnthropicClient::new("test-key").with_retry_policy(
+        let client = AnthropicClient::new("test-key").with_retry_policy(
             3,
             Duration::from_millis(10),
             Duration::from_millis(25),

+ 11 - 0
rust/crates/api/src/error.rs

@@ -5,6 +5,8 @@ use std::time::Duration;
 #[derive(Debug)]
 pub enum ApiError {
     MissingApiKey,
+    ExpiredOAuthToken,
+    Auth(String),
     InvalidApiKeyEnv(VarError),
     Http(reqwest::Error),
     Io(std::io::Error),
@@ -35,6 +37,8 @@ impl ApiError {
             Self::Api { retryable, .. } => *retryable,
             Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
             Self::MissingApiKey
+            | Self::ExpiredOAuthToken
+            | Self::Auth(_)
             | Self::InvalidApiKeyEnv(_)
             | Self::Io(_)
             | Self::Json(_)
@@ -53,6 +57,13 @@ impl Display for ApiError {
                     "ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is not set; export one before calling the Anthropic API"
                 )
             }
+            Self::ExpiredOAuthToken => {
+                write!(
+                    f,
+                    "saved OAuth token is expired and no refresh token is available"
+                )
+            }
+            Self::Auth(message) => write!(f, "auth error: {message}"),
             Self::InvalidApiKeyEnv(error) => {
                 write!(
                     f,

+ 4 - 1
rust/crates/api/src/lib.rs

@@ -3,7 +3,10 @@ mod error;
 mod sse;
 mod types;
 
-pub use client::{AnthropicClient, AuthSource, MessageStream, OAuthTokenSet};
+pub use client::{
+    oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient, AuthSource, MessageStream,
+    OAuthTokenSet,
+};
 pub use error::ApiError;
 pub use sse::{parse_frame, SseParser};
 pub use types::{

+ 4 - 2
rust/crates/runtime/src/lib.rs

@@ -53,8 +53,10 @@ pub use mcp_stdio::{
     McpToolCallContent, McpToolCallParams, McpToolCallResult, UnsupportedMcpServer,
 };
 pub use oauth::{
-    code_challenge_s256, generate_pkce_pair, generate_state, loopback_redirect_uri,
-    OAuthAuthorizationRequest, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
+    clear_oauth_credentials, code_challenge_s256, credentials_path, generate_pkce_pair,
+    generate_state, load_oauth_credentials, loopback_redirect_uri, parse_oauth_callback_query,
+    parse_oauth_callback_request_target, save_oauth_credentials, OAuthAuthorizationRequest,
+    OAuthCallbackParams, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
     PkceChallengeMethod, PkceCodePair,
 };
 pub use permissions::{

+ 260 - 5
rust/crates/runtime/src/oauth.rs

@@ -1,12 +1,15 @@
 use std::collections::BTreeMap;
-use std::fs::File;
+use std::fs::{self, File};
 use std::io::{self, Read};
+use std::path::PathBuf;
 
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
 use sha2::{Digest, Sha256};
 
 use crate::config::OAuthConfig;
 
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct OAuthTokenSet {
     pub access_token: String,
     pub refresh_token: Option<String>,
@@ -65,6 +68,48 @@ pub struct OAuthRefreshRequest {
     pub scopes: Vec<String>,
 }
 
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct OAuthCallbackParams {
+    pub code: Option<String>,
+    pub state: Option<String>,
+    pub error: Option<String>,
+    pub error_description: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct StoredOAuthCredentials {
+    access_token: String,
+    #[serde(default)]
+    refresh_token: Option<String>,
+    #[serde(default)]
+    expires_at: Option<u64>,
+    #[serde(default)]
+    scopes: Vec<String>,
+}
+
+impl From<OAuthTokenSet> for StoredOAuthCredentials {
+    fn from(value: OAuthTokenSet) -> Self {
+        Self {
+            access_token: value.access_token,
+            refresh_token: value.refresh_token,
+            expires_at: value.expires_at,
+            scopes: value.scopes,
+        }
+    }
+}
+
+impl From<StoredOAuthCredentials> for OAuthTokenSet {
+    fn from(value: StoredOAuthCredentials) -> Self {
+        Self {
+            access_token: value.access_token,
+            refresh_token: value.refresh_token,
+            expires_at: value.expires_at,
+            scopes: value.scopes,
+        }
+    }
+}
+
 impl OAuthAuthorizationRequest {
     #[must_use]
     pub fn from_config(
@@ -137,7 +182,6 @@ impl OAuthTokenExchangeRequest {
         verifier: impl Into<String>,
         redirect_uri: impl Into<String>,
     ) -> Self {
-        let _ = config;
         Self {
             grant_type: "authorization_code",
             code: code.into(),
@@ -211,12 +255,116 @@ pub fn loopback_redirect_uri(port: u16) -> String {
     format!("http://localhost:{port}/callback")
 }
 
+pub fn credentials_path() -> io::Result<PathBuf> {
+    Ok(credentials_home_dir()?.join("credentials.json"))
+}
+
+pub fn load_oauth_credentials() -> io::Result<Option<OAuthTokenSet>> {
+    let path = credentials_path()?;
+    let root = read_credentials_root(&path)?;
+    let Some(oauth) = root.get("oauth") else {
+        return Ok(None);
+    };
+    if oauth.is_null() {
+        return Ok(None);
+    }
+    let stored = serde_json::from_value::<StoredOAuthCredentials>(oauth.clone())
+        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
+    Ok(Some(stored.into()))
+}
+
+pub fn save_oauth_credentials(token_set: &OAuthTokenSet) -> io::Result<()> {
+    let path = credentials_path()?;
+    let mut root = read_credentials_root(&path)?;
+    root.insert(
+        "oauth".to_string(),
+        serde_json::to_value(StoredOAuthCredentials::from(token_set.clone()))
+            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
+    );
+    write_credentials_root(&path, &root)
+}
+
+pub fn clear_oauth_credentials() -> io::Result<()> {
+    let path = credentials_path()?;
+    let mut root = read_credentials_root(&path)?;
+    root.remove("oauth");
+    write_credentials_root(&path, &root)
+}
+
+pub fn parse_oauth_callback_request_target(target: &str) -> Result<OAuthCallbackParams, String> {
+    let (path, query) = target
+        .split_once('?')
+        .map_or((target, ""), |(path, query)| (path, query));
+    if path != "/callback" {
+        return Err(format!("unexpected callback path: {path}"));
+    }
+    parse_oauth_callback_query(query)
+}
+
+pub fn parse_oauth_callback_query(query: &str) -> Result<OAuthCallbackParams, String> {
+    let mut params = BTreeMap::new();
+    for pair in query.split('&').filter(|pair| !pair.is_empty()) {
+        let (key, value) = pair
+            .split_once('=')
+            .map_or((pair, ""), |(key, value)| (key, value));
+        params.insert(percent_decode(key)?, percent_decode(value)?);
+    }
+    Ok(OAuthCallbackParams {
+        code: params.get("code").cloned(),
+        state: params.get("state").cloned(),
+        error: params.get("error").cloned(),
+        error_description: params.get("error_description").cloned(),
+    })
+}
+
 fn generate_random_token(bytes: usize) -> io::Result<String> {
     let mut buffer = vec![0_u8; bytes];
     File::open("/dev/urandom")?.read_exact(&mut buffer)?;
     Ok(base64url_encode(&buffer))
 }
 
+fn credentials_home_dir() -> io::Result<PathBuf> {
+    if let Some(path) = std::env::var_os("CLAUDE_CONFIG_HOME") {
+        return Ok(PathBuf::from(path));
+    }
+    let home = std::env::var_os("HOME")
+        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
+    Ok(PathBuf::from(home).join(".claude"))
+}
+
+fn read_credentials_root(path: &PathBuf) -> io::Result<Map<String, Value>> {
+    match fs::read_to_string(path) {
+        Ok(contents) => {
+            if contents.trim().is_empty() {
+                return Ok(Map::new());
+            }
+            serde_json::from_str::<Value>(&contents)
+                .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
+                .as_object()
+                .cloned()
+                .ok_or_else(|| {
+                    io::Error::new(
+                        io::ErrorKind::InvalidData,
+                        "credentials file must contain a JSON object",
+                    )
+                })
+        }
+        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Map::new()),
+        Err(error) => Err(error),
+    }
+}
+
+fn write_credentials_root(path: &PathBuf, root: &Map<String, Value>) -> io::Result<()> {
+    if let Some(parent) = path.parent() {
+        fs::create_dir_all(parent)?;
+    }
+    let rendered = serde_json::to_string_pretty(&Value::Object(root.clone()))
+        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
+    let temp_path = path.with_extension("json.tmp");
+    fs::write(&temp_path, format!("{rendered}\n"))?;
+    fs::rename(temp_path, path)
+}
+
 fn base64url_encode(bytes: &[u8]) -> String {
     const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
     let mut output = String::new();
@@ -264,11 +412,50 @@ fn percent_encode(value: &str) -> String {
     encoded
 }
 
+fn percent_decode(value: &str) -> Result<String, String> {
+    let mut decoded = Vec::with_capacity(value.len());
+    let bytes = value.as_bytes();
+    let mut index = 0;
+    while index < bytes.len() {
+        match bytes[index] {
+            b'%' if index + 2 < bytes.len() => {
+                let hi = decode_hex(bytes[index + 1])?;
+                let lo = decode_hex(bytes[index + 2])?;
+                decoded.push((hi << 4) | lo);
+                index += 3;
+            }
+            b'+' => {
+                decoded.push(b' ');
+                index += 1;
+            }
+            byte => {
+                decoded.push(byte);
+                index += 1;
+            }
+        }
+    }
+    String::from_utf8(decoded).map_err(|error| error.to_string())
+}
+
+fn decode_hex(byte: u8) -> Result<u8, String> {
+    match byte {
+        b'0'..=b'9' => Ok(byte - b'0'),
+        b'a'..=b'f' => Ok(byte - b'a' + 10),
+        b'A'..=b'F' => Ok(byte - b'A' + 10),
+        _ => Err(format!("invalid percent-encoding byte: {byte}")),
+    }
+}
+
 #[cfg(test)]
 mod tests {
+    use std::sync::{Mutex, OnceLock};
+    use std::time::{SystemTime, UNIX_EPOCH};
+
     use super::{
-        code_challenge_s256, generate_pkce_pair, generate_state, loopback_redirect_uri,
-        OAuthAuthorizationRequest, OAuthConfig, OAuthRefreshRequest, OAuthTokenExchangeRequest,
+        clear_oauth_credentials, code_challenge_s256, credentials_path, generate_pkce_pair,
+        generate_state, load_oauth_credentials, loopback_redirect_uri, parse_oauth_callback_query,
+        parse_oauth_callback_request_target, save_oauth_credentials, OAuthAuthorizationRequest,
+        OAuthConfig, OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenSet,
     };
 
     fn sample_config() -> OAuthConfig {
@@ -282,6 +469,24 @@ mod tests {
         }
     }
 
+    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
+        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+        LOCK.get_or_init(|| Mutex::new(()))
+            .lock()
+            .expect("env lock")
+    }
+
+    fn temp_config_home() -> std::path::PathBuf {
+        std::env::temp_dir().join(format!(
+            "runtime-oauth-test-{}-{}",
+            std::process::id(),
+            SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .expect("time")
+                .as_nanos()
+        ))
+    }
+
     #[test]
     fn s256_challenge_matches_expected_vector() {
         assert_eq!(
@@ -335,4 +540,54 @@ mod tests {
             Some("org:read user:write")
         );
     }
+
+    #[test]
+    fn oauth_credentials_round_trip_and_clear_preserves_other_fields() {
+        let _guard = env_lock();
+        let config_home = temp_config_home();
+        std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
+        let path = credentials_path().expect("credentials path");
+        std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
+        std::fs::write(&path, "{\"other\":\"value\"}\n").expect("seed credentials");
+
+        let token_set = OAuthTokenSet {
+            access_token: "access-token".to_string(),
+            refresh_token: Some("refresh-token".to_string()),
+            expires_at: Some(123),
+            scopes: vec!["scope:a".to_string()],
+        };
+        save_oauth_credentials(&token_set).expect("save credentials");
+        assert_eq!(
+            load_oauth_credentials().expect("load credentials"),
+            Some(token_set)
+        );
+        let saved = std::fs::read_to_string(&path).expect("read saved file");
+        assert!(saved.contains("\"other\": \"value\""));
+        assert!(saved.contains("\"oauth\""));
+
+        clear_oauth_credentials().expect("clear credentials");
+        assert_eq!(load_oauth_credentials().expect("load cleared"), None);
+        let cleared = std::fs::read_to_string(&path).expect("read cleared file");
+        assert!(cleared.contains("\"other\": \"value\""));
+        assert!(!cleared.contains("\"oauth\""));
+
+        std::env::remove_var("CLAUDE_CONFIG_HOME");
+        std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
+    }
+
+    #[test]
+    fn parses_callback_query_and_target() {
+        let params =
+            parse_oauth_callback_query("code=abc123&state=state-1&error_description=needs%20login")
+                .expect("parse query");
+        assert_eq!(params.code.as_deref(), Some("abc123"));
+        assert_eq!(params.state.as_deref(), Some("state-1"));
+        assert_eq!(params.error_description.as_deref(), Some("needs login"));
+
+        let params = parse_oauth_callback_request_target("/callback?code=abc&state=xyz")
+            .expect("parse callback target");
+        assert_eq!(params.code.as_deref(), Some("abc"));
+        assert_eq!(params.state.as_deref(), Some("xyz"));
+        assert!(parse_oauth_callback_request_target("/wrong?code=abc").is_err());
+    }
 }

+ 13 - 0
rust/crates/rusty-claude-cli/src/args.rs

@@ -31,6 +31,10 @@ pub enum Command {
     DumpManifests,
     /// Print the current bootstrap phase skeleton
     BootstrapPlan,
+    /// Start the OAuth login flow
+    Login,
+    /// Clear saved OAuth credentials
+    Logout,
     /// Run a non-interactive prompt and exit
     Prompt { prompt: Vec<String> },
 }
@@ -86,4 +90,13 @@ mod tests {
             })
         );
     }
+
+    #[test]
+    fn parses_login_and_logout_commands() {
+        let login = Cli::parse_from(["rusty-claude-cli", "login"]);
+        assert_eq!(login.command, Some(Command::Login));
+
+        let logout = Cli::parse_from(["rusty-claude-cli", "logout"]);
+        assert_eq!(logout.command, Some(Command::Logout));
+    }
 }

+ 169 - 10
rust/crates/rusty-claude-cli/src/main.rs

@@ -4,24 +4,28 @@ mod render;
 use std::collections::{BTreeMap, BTreeSet};
 use std::env;
 use std::fs;
-use std::io::{self, Write};
+use std::io::{self, Read, Write};
+use std::net::TcpListener;
 use std::path::{Path, PathBuf};
+use std::process::Command;
 use std::time::{SystemTime, UNIX_EPOCH};
 
 use api::{
-    AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
-    MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
-    ToolResultContentBlock,
+    resolve_saved_oauth_token, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
+    InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
+    StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
 };
 
 use commands::{render_slash_command_help, resume_supported_slash_commands, SlashCommand};
 use compat_harness::{extract_manifest, UpstreamPaths};
 use render::{Spinner, TerminalRenderer};
 use runtime::{
-    load_system_prompt, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader,
-    ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole,
-    PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError,
-    ToolExecutor, UsageTracker,
+    clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
+    parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
+    AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
+    ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
+    OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
+    Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
 };
 use serde_json::json;
 use tools::{execute_tool, mvp_tool_specs};
@@ -29,6 +33,7 @@ use tools::{execute_tool, mvp_tool_specs};
 const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
 const DEFAULT_MAX_TOKENS: u32 = 32;
 const DEFAULT_DATE: &str = "2026-03-31";
+const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
 const VERSION: &str = env!("CARGO_PKG_VERSION");
 const BUILD_TARGET: Option<&str> = option_env!("TARGET");
 const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
@@ -64,6 +69,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
             allowed_tools,
         } => LiveCli::new(model, false, allowed_tools)?
             .run_turn_with_output(&prompt, output_format)?,
+        CliAction::Login => run_login()?,
+        CliAction::Logout => run_logout()?,
         CliAction::Repl {
             model,
             allowed_tools,
@@ -92,6 +99,8 @@ enum CliAction {
         output_format: CliOutputFormat,
         allowed_tools: Option<AllowedToolSet>,
     },
+    Login,
+    Logout,
     Repl {
         model: String,
         allowed_tools: Option<AllowedToolSet>,
@@ -199,6 +208,8 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
         "dump-manifests" => Ok(CliAction::DumpManifests),
         "bootstrap-plan" => Ok(CliAction::BootstrapPlan),
         "system-prompt" => parse_system_prompt_args(&rest[1..]),
+        "login" => Ok(CliAction::Login),
+        "logout" => Ok(CliAction::Logout),
         "prompt" => {
             let prompt = rest[1..].join(" ");
             if prompt.trim().is_empty() {
@@ -344,6 +355,122 @@ fn print_bootstrap_plan() {
     }
 }
 
+fn run_login() -> Result<(), Box<dyn std::error::Error>> {
+    let cwd = env::current_dir()?;
+    let config = ConfigLoader::default_for(&cwd).load()?;
+    let oauth = config.oauth().ok_or_else(|| {
+        io::Error::new(
+            io::ErrorKind::NotFound,
+            "OAuth config is missing. Add settings.oauth.clientId/authorizeUrl/tokenUrl first.",
+        )
+    })?;
+    let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT);
+    let redirect_uri = runtime::loopback_redirect_uri(callback_port);
+    let pkce = generate_pkce_pair()?;
+    let state = generate_state()?;
+    let authorize_url =
+        OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
+            .build_url();
+
+    println!("Starting Claude OAuth login...");
+    println!("Listening for callback on {redirect_uri}");
+    if let Err(error) = open_browser(&authorize_url) {
+        eprintln!("warning: failed to open browser automatically: {error}");
+        println!("Open this URL manually:\n{authorize_url}");
+    }
+
+    let callback = wait_for_oauth_callback(callback_port)?;
+    if let Some(error) = callback.error {
+        let description = callback
+            .error_description
+            .unwrap_or_else(|| "authorization failed".to_string());
+        return Err(io::Error::other(format!("{error}: {description}")).into());
+    }
+    let code = callback.code.ok_or_else(|| {
+        io::Error::new(io::ErrorKind::InvalidData, "callback did not include code")
+    })?;
+    let returned_state = callback.state.ok_or_else(|| {
+        io::Error::new(io::ErrorKind::InvalidData, "callback did not include state")
+    })?;
+    if returned_state != state {
+        return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into());
+    }
+
+    let client = AnthropicClient::from_auth(AuthSource::None);
+    let exchange_request =
+        OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
+    let runtime = tokio::runtime::Runtime::new()?;
+    let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
+    save_oauth_credentials(&runtime::OAuthTokenSet {
+        access_token: token_set.access_token,
+        refresh_token: token_set.refresh_token,
+        expires_at: token_set.expires_at,
+        scopes: token_set.scopes,
+    })?;
+    println!("Claude OAuth login complete.");
+    Ok(())
+}
+
+fn run_logout() -> Result<(), Box<dyn std::error::Error>> {
+    clear_oauth_credentials()?;
+    println!("Claude OAuth credentials cleared.");
+    Ok(())
+}
+
+fn open_browser(url: &str) -> io::Result<()> {
+    let commands = if cfg!(target_os = "macos") {
+        vec![("open", vec![url])]
+    } else if cfg!(target_os = "windows") {
+        vec![("cmd", vec!["/C", "start", "", url])]
+    } else {
+        vec![("xdg-open", vec![url])]
+    };
+    for (program, args) in commands {
+        match Command::new(program).args(args).spawn() {
+            Ok(_) => return Ok(()),
+            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
+            Err(error) => return Err(error),
+        }
+    }
+    Err(io::Error::new(
+        io::ErrorKind::NotFound,
+        "no supported browser opener command found",
+    ))
+}
+
+fn wait_for_oauth_callback(
+    port: u16,
+) -> Result<runtime::OAuthCallbackParams, Box<dyn std::error::Error>> {
+    let listener = TcpListener::bind(("127.0.0.1", port))?;
+    let (mut stream, _) = listener.accept()?;
+    let mut buffer = [0_u8; 4096];
+    let bytes_read = stream.read(&mut buffer)?;
+    let request = String::from_utf8_lossy(&buffer[..bytes_read]);
+    let request_line = request.lines().next().ok_or_else(|| {
+        io::Error::new(io::ErrorKind::InvalidData, "missing callback request line")
+    })?;
+    let target = request_line.split_whitespace().nth(1).ok_or_else(|| {
+        io::Error::new(
+            io::ErrorKind::InvalidData,
+            "missing callback request target",
+        )
+    })?;
+    let callback = parse_oauth_callback_request_target(target)
+        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
+    let body = if callback.error.is_some() {
+        "Claude OAuth login failed. You can close this window."
+    } else {
+        "Claude OAuth login succeeded. You can close this window."
+    };
+    let response = format!(
+        "HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
+        body.len(),
+        body
+    );
+    stream.write_all(response.as_bytes())?;
+    Ok(callback)
+}
+
 fn print_system_prompt(cwd: PathBuf, date: String) {
     match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
         Ok(sections) => println!("{}", sections.join("\n\n")),
@@ -840,7 +967,7 @@ impl LiveCli {
     }
 
     fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
-        let client = AnthropicClient::from_env()?;
+        let client = AnthropicClient::from_auth(resolve_cli_auth_source()?);
         let request = MessageRequest {
             model: self.model.clone(),
             max_tokens: DEFAULT_MAX_TOKENS,
@@ -1742,7 +1869,7 @@ impl AnthropicRuntimeClient {
     ) -> Result<Self, Box<dyn std::error::Error>> {
         Ok(Self {
             runtime: tokio::runtime::Runtime::new()?,
-            client: AnthropicClient::from_env()?,
+            client: AnthropicClient::from_auth(resolve_cli_auth_source()?),
             model,
             enable_tools,
             allowed_tools,
@@ -1750,6 +1877,23 @@ impl AnthropicRuntimeClient {
     }
 }
 
+fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
+    match AuthSource::from_env() {
+        Ok(auth) => Ok(auth),
+        Err(api::ApiError::MissingApiKey) => {
+            let cwd = env::current_dir()?;
+            let config = ConfigLoader::default_for(&cwd).load()?;
+            if let Some(oauth) = config.oauth() {
+                if let Some(token_set) = resolve_saved_oauth_token(oauth)? {
+                    return Ok(AuthSource::from(token_set));
+                }
+            }
+            Ok(AuthSource::from_env_or_saved()?)
+        }
+        Err(error) => Err(Box::new(error)),
+    }
+}
+
 impl ApiClient for AnthropicRuntimeClient {
     #[allow(clippy::too_many_lines)]
     fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
@@ -2019,6 +2163,8 @@ fn print_help() {
     println!("  rusty-claude-cli dump-manifests");
     println!("  rusty-claude-cli bootstrap-plan");
     println!("  rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
+    println!("  rusty-claude-cli login");
+    println!("  rusty-claude-cli logout");
     println!();
     println!("Flags:");
     println!("  --model MODEL              Override the active model");
@@ -2043,6 +2189,7 @@ fn print_help() {
     println!("  rusty-claude-cli --output-format json prompt \"explain src/main.rs\"");
     println!("  rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\"");
     println!("  rusty-claude-cli --resume session.json /status /diff /export notes.txt");
+    println!("  rusty-claude-cli login");
 }
 
 #[cfg(test)]
@@ -2166,6 +2313,18 @@ mod tests {
         );
     }
 
+    #[test]
+    fn parses_login_and_logout_subcommands() {
+        assert_eq!(
+            parse_args(&["login".to_string()]).expect("login should parse"),
+            CliAction::Login
+        );
+        assert_eq!(
+            parse_args(&["logout".to_string()]).expect("logout should parse"),
+            CliAction::Logout
+        );
+    }
+
     #[test]
     fn parses_resume_flag_with_slash_command() {
         let args = vec![