klaude-code 1.8.0__py3-none-any.whl → 1.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/auth/base.py +101 -0
- klaude_code/auth/claude/__init__.py +6 -0
- klaude_code/auth/claude/exceptions.py +9 -0
- klaude_code/auth/claude/oauth.py +172 -0
- klaude_code/auth/claude/token_manager.py +26 -0
- klaude_code/auth/codex/token_manager.py +10 -50
- klaude_code/cli/auth_cmd.py +127 -46
- klaude_code/cli/config_cmd.py +4 -2
- klaude_code/cli/cost_cmd.py +14 -9
- klaude_code/cli/list_model.py +248 -200
- klaude_code/command/prompt-commit.md +73 -0
- klaude_code/config/assets/builtin_config.yaml +36 -3
- klaude_code/config/config.py +24 -5
- klaude_code/config/thinking.py +4 -4
- klaude_code/core/prompt.py +1 -1
- klaude_code/llm/anthropic/client.py +28 -3
- klaude_code/llm/claude/__init__.py +3 -0
- klaude_code/llm/claude/client.py +95 -0
- klaude_code/llm/codex/client.py +1 -1
- klaude_code/llm/registry.py +3 -1
- klaude_code/protocol/llm_param.py +2 -1
- klaude_code/protocol/sub_agent/__init__.py +1 -2
- klaude_code/session/session.py +4 -4
- klaude_code/ui/renderers/metadata.py +6 -26
- klaude_code/ui/rich/theme.py +6 -5
- klaude_code/ui/utils/common.py +46 -0
- {klaude_code-1.8.0.dist-info → klaude_code-1.9.0.dist-info}/METADATA +25 -5
- {klaude_code-1.8.0.dist-info → klaude_code-1.9.0.dist-info}/RECORD +30 -25
- klaude_code/command/prompt-jj-describe.md +0 -32
- klaude_code/core/prompts/prompt-sub-agent-oracle.md +0 -22
- klaude_code/protocol/sub_agent/oracle.py +0 -91
- {klaude_code-1.8.0.dist-info → klaude_code-1.9.0.dist-info}/WHEEL +0 -0
- {klaude_code-1.8.0.dist-info → klaude_code-1.9.0.dist-info}/entry_points.txt +0 -0
klaude_code/auth/base.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Base classes for authentication token management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Generic, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
KLAUDE_AUTH_FILE = Path.home() / ".klaude" / "klaude-auth.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseAuthState(BaseModel):
|
|
16
|
+
"""Base authentication state with common OAuth fields."""
|
|
17
|
+
|
|
18
|
+
access_token: str
|
|
19
|
+
refresh_token: str
|
|
20
|
+
expires_at: int # Unix timestamp
|
|
21
|
+
|
|
22
|
+
def is_expired(self, buffer_seconds: int = 300) -> bool:
|
|
23
|
+
"""Check if token is expired or will expire soon."""
|
|
24
|
+
return time.time() + buffer_seconds >= self.expires_at
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T", bound=BaseAuthState)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BaseTokenManager(ABC, Generic[T]):
|
|
31
|
+
"""Base class for OAuth token management."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, auth_file: Path | None = None):
|
|
34
|
+
self.auth_file = auth_file or KLAUDE_AUTH_FILE
|
|
35
|
+
self._state: T | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def storage_key(self) -> str:
|
|
40
|
+
"""Key used to store this auth state in the JSON file."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def _create_state(self, data: dict[str, Any]) -> T:
|
|
45
|
+
"""Create state instance from dict data."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def _load_store(self) -> dict[str, Any]:
|
|
49
|
+
if not self.auth_file.exists():
|
|
50
|
+
return {}
|
|
51
|
+
try:
|
|
52
|
+
data: Any = json.loads(self.auth_file.read_text())
|
|
53
|
+
if isinstance(data, dict):
|
|
54
|
+
return cast(dict[str, Any], data)
|
|
55
|
+
return {}
|
|
56
|
+
except (json.JSONDecodeError, ValueError):
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
def _save_store(self, data: dict[str, Any]) -> None:
|
|
60
|
+
self.auth_file.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
self.auth_file.write_text(json.dumps(data, indent=2))
|
|
62
|
+
|
|
63
|
+
def load(self) -> T | None:
|
|
64
|
+
"""Load authentication state from file."""
|
|
65
|
+
data: Any = self._load_store().get(self.storage_key)
|
|
66
|
+
if not isinstance(data, dict):
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
self._state = self._create_state(cast(dict[str, Any], data))
|
|
70
|
+
return self._state
|
|
71
|
+
except ValueError:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def save(self, state: T) -> None:
|
|
75
|
+
"""Save authentication state to file."""
|
|
76
|
+
store = self._load_store()
|
|
77
|
+
store[self.storage_key] = state.model_dump(mode="json")
|
|
78
|
+
self._save_store(store)
|
|
79
|
+
self._state = state
|
|
80
|
+
|
|
81
|
+
def delete(self) -> None:
|
|
82
|
+
"""Delete stored tokens."""
|
|
83
|
+
store = self._load_store()
|
|
84
|
+
store.pop(self.storage_key, None)
|
|
85
|
+
if len(store) == 0:
|
|
86
|
+
if self.auth_file.exists():
|
|
87
|
+
self.auth_file.unlink()
|
|
88
|
+
else:
|
|
89
|
+
self._save_store(store)
|
|
90
|
+
self._state = None
|
|
91
|
+
|
|
92
|
+
def is_logged_in(self) -> bool:
|
|
93
|
+
"""Check if user is logged in."""
|
|
94
|
+
state = self._state or self.load()
|
|
95
|
+
return state is not None
|
|
96
|
+
|
|
97
|
+
def get_state(self) -> T | None:
|
|
98
|
+
"""Get current authentication state."""
|
|
99
|
+
if self._state is None:
|
|
100
|
+
self._state = self.load()
|
|
101
|
+
return self._state
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""OAuth PKCE flow for Claude (Anthropic OAuth) authentication."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from klaude_code.auth.claude.exceptions import ClaudeAuthError, ClaudeNotLoggedInError
|
|
12
|
+
from klaude_code.auth.claude.token_manager import ClaudeAuthState, ClaudeTokenManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _decode_base64(value: str) -> str:
|
|
16
|
+
return base64.b64decode(value).decode()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# OAuth configuration (Claude Pro/Max)
|
|
20
|
+
CLIENT_ID = _decode_base64("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl")
|
|
21
|
+
AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
|
22
|
+
TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
|
23
|
+
REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
|
24
|
+
SCOPE = "org:create_api_key user:profile user:inference"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate_code_verifier() -> str:
|
|
28
|
+
"""Generate a random code verifier for PKCE."""
|
|
29
|
+
return secrets.token_urlsafe(64)[:128]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def generate_code_challenge(verifier: str) -> str:
|
|
33
|
+
"""Generate code challenge from verifier using S256 method."""
|
|
34
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
35
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_authorize_url(code_challenge: str, state: str) -> str:
|
|
39
|
+
"""Build the authorization URL with all required parameters."""
|
|
40
|
+
# Note: the `code=true` parameter is required for the console callback flow.
|
|
41
|
+
params = {
|
|
42
|
+
"code": "true",
|
|
43
|
+
"client_id": CLIENT_ID,
|
|
44
|
+
"response_type": "code",
|
|
45
|
+
"redirect_uri": REDIRECT_URI,
|
|
46
|
+
"scope": SCOPE,
|
|
47
|
+
"code_challenge": code_challenge,
|
|
48
|
+
"code_challenge_method": "S256",
|
|
49
|
+
"state": state,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
encoded = httpx.QueryParams(params)
|
|
53
|
+
return f"{AUTHORIZE_URL}?{encoded}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_user_code(value: str) -> tuple[str, str | None]:
|
|
57
|
+
raw = value.strip()
|
|
58
|
+
if "#" in raw:
|
|
59
|
+
code, state = raw.split("#", 1)
|
|
60
|
+
return code.strip(), state.strip()
|
|
61
|
+
return raw, None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ClaudeOAuth:
|
|
65
|
+
"""Handle OAuth PKCE flow for Claude (Anthropic OAuth) authentication."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, token_manager: ClaudeTokenManager | None = None):
|
|
68
|
+
self.token_manager = token_manager or ClaudeTokenManager()
|
|
69
|
+
|
|
70
|
+
def login(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
on_auth_url: Callable[[str], None],
|
|
74
|
+
on_prompt_code: Callable[[], str],
|
|
75
|
+
) -> ClaudeAuthState:
|
|
76
|
+
"""Run the complete OAuth login flow."""
|
|
77
|
+
verifier = generate_code_verifier()
|
|
78
|
+
challenge = generate_code_challenge(verifier)
|
|
79
|
+
|
|
80
|
+
# Some flows require `state` to be echoed back for token exchange.
|
|
81
|
+
state = verifier
|
|
82
|
+
|
|
83
|
+
auth_url = build_authorize_url(challenge, state)
|
|
84
|
+
on_auth_url(auth_url)
|
|
85
|
+
|
|
86
|
+
raw_user_code = on_prompt_code()
|
|
87
|
+
code, returned_state = _parse_user_code(raw_user_code)
|
|
88
|
+
if not code:
|
|
89
|
+
raise ClaudeAuthError("No authorization code provided")
|
|
90
|
+
|
|
91
|
+
exchange_state = returned_state or state
|
|
92
|
+
auth_state = self._exchange_code(code=code, state=exchange_state, verifier=verifier)
|
|
93
|
+
self.token_manager.save(auth_state)
|
|
94
|
+
return auth_state
|
|
95
|
+
|
|
96
|
+
def _exchange_code(self, *, code: str, state: str, verifier: str) -> ClaudeAuthState:
|
|
97
|
+
"""Exchange authorization code for tokens."""
|
|
98
|
+
payload = {
|
|
99
|
+
"grant_type": "authorization_code",
|
|
100
|
+
"client_id": CLIENT_ID,
|
|
101
|
+
"code": code,
|
|
102
|
+
"state": state,
|
|
103
|
+
"redirect_uri": REDIRECT_URI,
|
|
104
|
+
"code_verifier": verifier,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
with httpx.Client() as client:
|
|
108
|
+
response = client.post(
|
|
109
|
+
TOKEN_URL,
|
|
110
|
+
json=payload,
|
|
111
|
+
headers={"Content-Type": "application/json"},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if response.status_code != 200:
|
|
115
|
+
raise ClaudeAuthError(f"Token exchange failed: {response.text}")
|
|
116
|
+
|
|
117
|
+
tokens = response.json()
|
|
118
|
+
access_token = tokens["access_token"]
|
|
119
|
+
refresh_token = tokens["refresh_token"]
|
|
120
|
+
expires_in = tokens.get("expires_in", 3600)
|
|
121
|
+
|
|
122
|
+
return ClaudeAuthState(
|
|
123
|
+
access_token=access_token,
|
|
124
|
+
refresh_token=refresh_token,
|
|
125
|
+
expires_at=int(time.time()) + int(expires_in),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def refresh(self) -> ClaudeAuthState:
|
|
129
|
+
"""Refresh the access token using refresh token."""
|
|
130
|
+
state = self.token_manager.get_state()
|
|
131
|
+
if state is None:
|
|
132
|
+
raise ClaudeNotLoggedInError("Not logged in to Claude. Run 'klaude login claude' first.")
|
|
133
|
+
|
|
134
|
+
payload = {
|
|
135
|
+
"grant_type": "refresh_token",
|
|
136
|
+
"client_id": CLIENT_ID,
|
|
137
|
+
"refresh_token": state.refresh_token,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
with httpx.Client() as client:
|
|
141
|
+
response = client.post(
|
|
142
|
+
TOKEN_URL,
|
|
143
|
+
json=payload,
|
|
144
|
+
headers={"Content-Type": "application/json"},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if response.status_code != 200:
|
|
148
|
+
raise ClaudeAuthError(f"Token refresh failed: {response.text}")
|
|
149
|
+
|
|
150
|
+
tokens = response.json()
|
|
151
|
+
access_token = tokens["access_token"]
|
|
152
|
+
refresh_token = tokens.get("refresh_token", state.refresh_token)
|
|
153
|
+
expires_in = tokens.get("expires_in", 3600)
|
|
154
|
+
|
|
155
|
+
new_state = ClaudeAuthState(
|
|
156
|
+
access_token=access_token,
|
|
157
|
+
refresh_token=refresh_token,
|
|
158
|
+
expires_at=int(time.time()) + int(expires_in),
|
|
159
|
+
)
|
|
160
|
+
self.token_manager.save(new_state)
|
|
161
|
+
return new_state
|
|
162
|
+
|
|
163
|
+
def ensure_valid_token(self) -> str:
|
|
164
|
+
"""Ensure we have a valid access token, refreshing if needed."""
|
|
165
|
+
state = self.token_manager.get_state()
|
|
166
|
+
if state is None:
|
|
167
|
+
raise ClaudeNotLoggedInError("Not logged in to Claude. Run 'klaude login claude' first.")
|
|
168
|
+
|
|
169
|
+
if state.is_expired():
|
|
170
|
+
state = self.refresh()
|
|
171
|
+
|
|
172
|
+
return state.access_token
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Token storage and management for Claude (Anthropic OAuth) authentication."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from klaude_code.auth.base import BaseAuthState, BaseTokenManager
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClaudeAuthState(BaseAuthState):
|
|
10
|
+
"""Stored authentication state for Claude OAuth."""
|
|
11
|
+
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ClaudeTokenManager(BaseTokenManager[ClaudeAuthState]):
|
|
16
|
+
"""Manage Claude OAuth tokens."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, auth_file: Path | None = None):
|
|
19
|
+
super().__init__(auth_file)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def storage_key(self) -> str:
|
|
23
|
+
return "claude"
|
|
24
|
+
|
|
25
|
+
def _create_state(self, data: dict[str, Any]) -> ClaudeAuthState:
|
|
26
|
+
return ClaudeAuthState.model_validate(data)
|
|
@@ -1,69 +1,29 @@
|
|
|
1
1
|
"""Token storage and management for Codex authentication."""
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
|
-
import time
|
|
5
3
|
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
6
5
|
|
|
7
|
-
from
|
|
6
|
+
from klaude_code.auth.base import BaseAuthState, BaseTokenManager
|
|
8
7
|
|
|
9
8
|
|
|
10
|
-
class CodexAuthState(
|
|
9
|
+
class CodexAuthState(BaseAuthState):
|
|
11
10
|
"""Stored authentication state for Codex."""
|
|
12
11
|
|
|
13
|
-
access_token: str
|
|
14
|
-
refresh_token: str
|
|
15
|
-
expires_at: int # Unix timestamp
|
|
16
12
|
account_id: str
|
|
17
13
|
|
|
18
|
-
def is_expired(self, buffer_seconds: int = 300) -> bool:
|
|
19
|
-
"""Check if token is expired or will expire soon."""
|
|
20
|
-
return time.time() + buffer_seconds >= self.expires_at
|
|
21
14
|
|
|
22
|
-
|
|
23
|
-
CODEX_AUTH_FILE = Path.home() / ".klaude" / "codex-auth.json"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class CodexTokenManager:
|
|
15
|
+
class CodexTokenManager(BaseTokenManager[CodexAuthState]):
|
|
27
16
|
"""Manage Codex OAuth tokens."""
|
|
28
17
|
|
|
29
18
|
def __init__(self, auth_file: Path | None = None):
|
|
30
|
-
|
|
31
|
-
self._state: CodexAuthState | None = None
|
|
32
|
-
|
|
33
|
-
def load(self) -> CodexAuthState | None:
|
|
34
|
-
"""Load authentication state from file."""
|
|
35
|
-
if not self.auth_file.exists():
|
|
36
|
-
return None
|
|
37
|
-
|
|
38
|
-
try:
|
|
39
|
-
data = json.loads(self.auth_file.read_text())
|
|
40
|
-
self._state = CodexAuthState.model_validate(data)
|
|
41
|
-
return self._state
|
|
42
|
-
except (json.JSONDecodeError, ValueError):
|
|
43
|
-
return None
|
|
44
|
-
|
|
45
|
-
def save(self, state: CodexAuthState) -> None:
|
|
46
|
-
"""Save authentication state to file."""
|
|
47
|
-
self.auth_file.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
-
self.auth_file.write_text(state.model_dump_json(indent=2))
|
|
49
|
-
self._state = state
|
|
50
|
-
|
|
51
|
-
def delete(self) -> None:
|
|
52
|
-
"""Delete stored tokens."""
|
|
53
|
-
if self.auth_file.exists():
|
|
54
|
-
self.auth_file.unlink()
|
|
55
|
-
self._state = None
|
|
19
|
+
super().__init__(auth_file)
|
|
56
20
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return state is not None
|
|
21
|
+
@property
|
|
22
|
+
def storage_key(self) -> str:
|
|
23
|
+
return "codex"
|
|
61
24
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
if self._state is None:
|
|
65
|
-
self._state = self.load()
|
|
66
|
-
return self._state
|
|
25
|
+
def _create_state(self, data: dict[str, Any]) -> CodexAuthState:
|
|
26
|
+
return CodexAuthState.model_validate(data)
|
|
67
27
|
|
|
68
28
|
def get_access_token(self) -> str:
|
|
69
29
|
"""Get access token, raising if not logged in."""
|
klaude_code/cli/auth_cmd.py
CHANGED
|
@@ -1,70 +1,151 @@
|
|
|
1
1
|
"""Authentication commands for CLI."""
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
|
+
import webbrowser
|
|
4
5
|
|
|
5
6
|
import typer
|
|
7
|
+
from prompt_toolkit.styles import Style
|
|
6
8
|
|
|
7
9
|
from klaude_code.trace import log
|
|
10
|
+
from klaude_code.ui.terminal.selector import SelectItem, select_one
|
|
11
|
+
|
|
12
|
+
_SELECT_STYLE = Style(
|
|
13
|
+
[
|
|
14
|
+
("instruction", "ansibrightblack"),
|
|
15
|
+
("pointer", "ansigreen"),
|
|
16
|
+
("highlighted", "ansigreen"),
|
|
17
|
+
("text", "ansibrightblack"),
|
|
18
|
+
("question", "bold"),
|
|
19
|
+
]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _select_provider() -> str | None:
|
|
24
|
+
"""Display provider selection menu and return selected provider."""
|
|
25
|
+
items: list[SelectItem[str]] = [
|
|
26
|
+
SelectItem(title=[("class:text", "Claude Max/Pro Subscription\n")], value="claude", search_text="claude"),
|
|
27
|
+
SelectItem(title=[("class:text", "ChatGPT Codex Subscription\n")], value="codex", search_text="codex"),
|
|
28
|
+
]
|
|
29
|
+
return select_one(
|
|
30
|
+
message="Select provider to login:",
|
|
31
|
+
items=items,
|
|
32
|
+
pointer="→",
|
|
33
|
+
style=_SELECT_STYLE,
|
|
34
|
+
use_search_filter=False,
|
|
35
|
+
)
|
|
8
36
|
|
|
9
37
|
|
|
10
38
|
def login_command(
|
|
11
|
-
provider: str = typer.Argument(
|
|
39
|
+
provider: str | None = typer.Argument(None, help="Provider to login (codex|claude)"),
|
|
12
40
|
) -> None:
|
|
13
41
|
"""Login to a provider using OAuth."""
|
|
14
|
-
if provider
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
if provider is None:
|
|
43
|
+
provider = _select_provider()
|
|
44
|
+
if provider is None:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
match provider.lower():
|
|
48
|
+
case "codex":
|
|
49
|
+
from klaude_code.auth.codex.oauth import CodexOAuth
|
|
50
|
+
from klaude_code.auth.codex.token_manager import CodexTokenManager
|
|
51
|
+
|
|
52
|
+
token_manager = CodexTokenManager()
|
|
53
|
+
|
|
54
|
+
# Check if already logged in
|
|
55
|
+
if token_manager.is_logged_in():
|
|
56
|
+
state = token_manager.get_state()
|
|
57
|
+
if state and not state.is_expired():
|
|
58
|
+
log(("You are already logged in to Codex.", "green"))
|
|
59
|
+
log(f" Account ID: {state.account_id[:8]}...")
|
|
60
|
+
expires_dt = datetime.datetime.fromtimestamp(state.expires_at, tz=datetime.UTC)
|
|
61
|
+
log(f" Expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
62
|
+
if not typer.confirm("Do you want to re-login?"):
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
log("Starting Codex OAuth login flow...")
|
|
66
|
+
log("A browser window will open for authentication.")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
oauth = CodexOAuth(token_manager)
|
|
70
|
+
state = oauth.login()
|
|
71
|
+
log(("Login successful!", "green"))
|
|
72
|
+
log(f" Account ID: {state.account_id[:8]}...")
|
|
73
|
+
expires_dt = datetime.datetime.fromtimestamp(state.expires_at, tz=datetime.UTC)
|
|
74
|
+
log(f" Expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
log((f"Login failed: {e}", "red"))
|
|
77
|
+
raise typer.Exit(1) from None
|
|
78
|
+
case "claude":
|
|
79
|
+
from klaude_code.auth.claude.oauth import ClaudeOAuth
|
|
80
|
+
from klaude_code.auth.claude.token_manager import ClaudeTokenManager
|
|
81
|
+
|
|
82
|
+
token_manager = ClaudeTokenManager()
|
|
83
|
+
|
|
84
|
+
if token_manager.is_logged_in():
|
|
85
|
+
state = token_manager.get_state()
|
|
86
|
+
if state and not state.is_expired():
|
|
87
|
+
log(("You are already logged in to Claude.", "green"))
|
|
88
|
+
expires_dt = datetime.datetime.fromtimestamp(state.expires_at, tz=datetime.UTC)
|
|
89
|
+
log(f" Expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
90
|
+
if not typer.confirm("Do you want to re-login?"):
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
log("Starting Claude OAuth login flow...")
|
|
94
|
+
log("A browser window will open for authentication.")
|
|
95
|
+
log("After login, paste the authorization code in the terminal.")
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
oauth = ClaudeOAuth(token_manager)
|
|
99
|
+
state = oauth.login(
|
|
100
|
+
on_auth_url=lambda url: (webbrowser.open(url), None)[1],
|
|
101
|
+
on_prompt_code=lambda: typer.prompt(
|
|
102
|
+
"Paste the authorization code (format: code#state)",
|
|
103
|
+
prompt_suffix=": ",
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
log(("Login successful!", "green"))
|
|
107
|
+
expires_dt = datetime.datetime.fromtimestamp(state.expires_at, tz=datetime.UTC)
|
|
108
|
+
log(f" Expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
109
|
+
except Exception as e:
|
|
110
|
+
log((f"Login failed: {e}", "red"))
|
|
111
|
+
raise typer.Exit(1) from None
|
|
112
|
+
case _:
|
|
113
|
+
log((f"Error: Unknown provider '{provider}'. Supported: codex, claude", "red"))
|
|
114
|
+
raise typer.Exit(1)
|
|
47
115
|
|
|
48
116
|
|
|
49
117
|
def logout_command(
|
|
50
|
-
provider: str = typer.Argument("codex", help="Provider to logout (codex)"),
|
|
118
|
+
provider: str = typer.Argument("codex", help="Provider to logout (codex|claude)"),
|
|
51
119
|
) -> None:
|
|
52
120
|
"""Logout from a provider."""
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
121
|
+
match provider.lower():
|
|
122
|
+
case "codex":
|
|
123
|
+
from klaude_code.auth.codex.token_manager import CodexTokenManager
|
|
56
124
|
|
|
57
|
-
|
|
125
|
+
token_manager = CodexTokenManager()
|
|
58
126
|
|
|
59
|
-
|
|
127
|
+
if not token_manager.is_logged_in():
|
|
128
|
+
log("You are not logged in to Codex.")
|
|
129
|
+
return
|
|
60
130
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
131
|
+
if typer.confirm("Are you sure you want to logout from Codex?"):
|
|
132
|
+
token_manager.delete()
|
|
133
|
+
log(("Logged out from Codex.", "green"))
|
|
134
|
+
case "claude":
|
|
135
|
+
from klaude_code.auth.claude.token_manager import ClaudeTokenManager
|
|
136
|
+
|
|
137
|
+
token_manager = ClaudeTokenManager()
|
|
138
|
+
|
|
139
|
+
if not token_manager.is_logged_in():
|
|
140
|
+
log("You are not logged in to Claude.")
|
|
141
|
+
return
|
|
64
142
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
143
|
+
if typer.confirm("Are you sure you want to logout from Claude?"):
|
|
144
|
+
token_manager.delete()
|
|
145
|
+
log(("Logged out from Claude.", "green"))
|
|
146
|
+
case _:
|
|
147
|
+
log((f"Error: Unknown provider '{provider}'. Supported: codex, claude", "red"))
|
|
148
|
+
raise typer.Exit(1)
|
|
68
149
|
|
|
69
150
|
|
|
70
151
|
def register_auth_commands(app: typer.Typer) -> None:
|
klaude_code/cli/config_cmd.py
CHANGED
|
@@ -10,7 +10,9 @@ from klaude_code.config import config_path, create_example_config, example_confi
|
|
|
10
10
|
from klaude_code.trace import log
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def list_models(
|
|
13
|
+
def list_models(
|
|
14
|
+
show_all: bool = typer.Option(False, "--all", "-a", help="Show all providers including unavailable ones"),
|
|
15
|
+
) -> None:
|
|
14
16
|
"""List all models and providers configuration"""
|
|
15
17
|
from klaude_code.cli.list_model import display_models_and_providers
|
|
16
18
|
from klaude_code.ui.terminal.color import is_light_terminal_background
|
|
@@ -25,7 +27,7 @@ def list_models() -> None:
|
|
|
25
27
|
elif detected is False:
|
|
26
28
|
config.theme = "dark"
|
|
27
29
|
|
|
28
|
-
display_models_and_providers(config)
|
|
30
|
+
display_models_and_providers(config, show_all=show_all)
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def edit_config() -> None:
|
klaude_code/cli/cost_cmd.py
CHANGED
|
@@ -5,8 +5,8 @@ from dataclasses import dataclass, field
|
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
-
from rich.box import Box
|
|
9
8
|
import typer
|
|
9
|
+
from rich.box import Box
|
|
10
10
|
from rich.console import Console
|
|
11
11
|
from rich.table import Table
|
|
12
12
|
|
|
@@ -163,13 +163,13 @@ def format_cost_dual(cost_usd: float, cost_cny: float) -> tuple[str, str]:
|
|
|
163
163
|
|
|
164
164
|
|
|
165
165
|
def format_date_display(date_str: str) -> str:
|
|
166
|
-
"""Format date string YYYY-MM-DD to 'YYYY M-D' for table display."""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
day
|
|
171
|
-
|
|
172
|
-
|
|
166
|
+
"""Format date string YYYY-MM-DD to 'YYYY M-D WEEKDAY' for table display."""
|
|
167
|
+
try:
|
|
168
|
+
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
|
169
|
+
weekday = dt.strftime("%a").upper()
|
|
170
|
+
return f"{dt.year} {dt.month}-{dt.day} {weekday}"
|
|
171
|
+
except (ValueError, TypeError):
|
|
172
|
+
return date_str
|
|
173
173
|
|
|
174
174
|
|
|
175
175
|
def render_cost_table(daily_stats: dict[str, DailyStats]) -> Table:
|
|
@@ -178,6 +178,7 @@ def render_cost_table(daily_stats: dict[str, DailyStats]) -> Table:
|
|
|
178
178
|
title="Usage Statistics",
|
|
179
179
|
show_header=True,
|
|
180
180
|
header_style="bold",
|
|
181
|
+
border_style="bright_black dim",
|
|
181
182
|
padding=(0, 1, 0, 2),
|
|
182
183
|
box=ASCII_HORIZONAL,
|
|
183
184
|
)
|
|
@@ -267,10 +268,14 @@ def render_cost_table(daily_stats: dict[str, DailyStats]) -> Table:
|
|
|
267
268
|
sorted_global_models = [s.model_name for s in sorted(global_by_model.values(), key=sort_by_cost)]
|
|
268
269
|
first_total_row = True
|
|
269
270
|
for model_name in sorted_global_models:
|
|
271
|
+
# Add empty row before first model to align with Total date range
|
|
272
|
+
if first_total_row:
|
|
273
|
+
table.add_row(total_label, "", "", "", "", "", "", "")
|
|
274
|
+
first_total_row = False
|
|
270
275
|
stats = global_by_model[model_name]
|
|
271
276
|
usd_str, cny_str = format_cost_dual(stats.cost_usd, stats.cost_cny)
|
|
272
277
|
table.add_row(
|
|
273
|
-
|
|
278
|
+
"",
|
|
274
279
|
f"- {model_name}",
|
|
275
280
|
format_tokens(stats.input_tokens),
|
|
276
281
|
format_tokens(stats.output_tokens),
|