klaude-code 1.2.6__py3-none-any.whl → 1.8.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/__init__.py +24 -0
- klaude_code/auth/codex/__init__.py +20 -0
- klaude_code/auth/codex/exceptions.py +17 -0
- klaude_code/auth/codex/jwt_utils.py +45 -0
- klaude_code/auth/codex/oauth.py +229 -0
- klaude_code/auth/codex/token_manager.py +84 -0
- klaude_code/cli/auth_cmd.py +73 -0
- klaude_code/cli/config_cmd.py +91 -0
- klaude_code/cli/cost_cmd.py +338 -0
- klaude_code/cli/debug.py +78 -0
- klaude_code/cli/list_model.py +307 -0
- klaude_code/cli/main.py +233 -134
- klaude_code/cli/runtime.py +309 -117
- klaude_code/{version.py → cli/self_update.py} +114 -5
- klaude_code/cli/session_cmd.py +37 -21
- klaude_code/command/__init__.py +88 -27
- klaude_code/command/clear_cmd.py +8 -7
- klaude_code/command/command_abc.py +31 -31
- klaude_code/command/debug_cmd.py +79 -0
- klaude_code/command/export_cmd.py +19 -53
- klaude_code/command/export_online_cmd.py +154 -0
- klaude_code/command/fork_session_cmd.py +267 -0
- klaude_code/command/help_cmd.py +7 -8
- klaude_code/command/model_cmd.py +60 -10
- klaude_code/command/model_select.py +84 -0
- klaude_code/command/prompt-jj-describe.md +32 -0
- klaude_code/command/prompt_command.py +19 -11
- klaude_code/command/refresh_cmd.py +8 -10
- klaude_code/command/registry.py +139 -40
- klaude_code/command/release_notes_cmd.py +84 -0
- klaude_code/command/resume_cmd.py +111 -0
- klaude_code/command/status_cmd.py +104 -60
- klaude_code/command/terminal_setup_cmd.py +7 -9
- klaude_code/command/thinking_cmd.py +98 -0
- klaude_code/config/__init__.py +14 -6
- klaude_code/config/assets/__init__.py +1 -0
- klaude_code/config/assets/builtin_config.yaml +303 -0
- klaude_code/config/builtin_config.py +38 -0
- klaude_code/config/config.py +378 -109
- klaude_code/config/select_model.py +117 -53
- klaude_code/config/thinking.py +269 -0
- klaude_code/{const/__init__.py → const.py} +50 -19
- klaude_code/core/agent.py +20 -28
- klaude_code/core/executor.py +327 -112
- klaude_code/core/manager/__init__.py +2 -4
- klaude_code/core/manager/llm_clients.py +1 -15
- klaude_code/core/manager/llm_clients_builder.py +10 -11
- klaude_code/core/manager/sub_agent_manager.py +37 -6
- klaude_code/core/prompt.py +63 -44
- klaude_code/core/prompts/prompt-claude-code.md +2 -13
- klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
- klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
- klaude_code/core/prompts/prompt-codex.md +9 -42
- klaude_code/core/prompts/prompt-minimal.md +12 -0
- klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
- klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
- klaude_code/core/reminders.py +283 -95
- klaude_code/core/task.py +113 -75
- klaude_code/core/tool/__init__.py +24 -31
- klaude_code/core/tool/file/_utils.py +36 -0
- klaude_code/core/tool/file/apply_patch.py +17 -25
- klaude_code/core/tool/file/apply_patch_tool.py +57 -77
- klaude_code/core/tool/file/diff_builder.py +151 -0
- klaude_code/core/tool/file/edit_tool.py +50 -63
- klaude_code/core/tool/file/move_tool.md +41 -0
- klaude_code/core/tool/file/move_tool.py +435 -0
- klaude_code/core/tool/file/read_tool.md +1 -1
- klaude_code/core/tool/file/read_tool.py +86 -86
- klaude_code/core/tool/file/write_tool.py +59 -69
- klaude_code/core/tool/report_back_tool.py +84 -0
- klaude_code/core/tool/shell/bash_tool.py +265 -22
- klaude_code/core/tool/shell/command_safety.py +3 -6
- klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
- klaude_code/core/tool/sub_agent_tool.py +13 -2
- klaude_code/core/tool/todo/todo_write_tool.md +0 -157
- klaude_code/core/tool/todo/todo_write_tool.py +1 -1
- klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
- klaude_code/core/tool/todo/update_plan_tool.py +1 -1
- klaude_code/core/tool/tool_abc.py +18 -0
- klaude_code/core/tool/tool_context.py +27 -12
- klaude_code/core/tool/tool_registry.py +7 -7
- klaude_code/core/tool/tool_runner.py +44 -36
- klaude_code/core/tool/truncation.py +29 -14
- klaude_code/core/tool/web/mermaid_tool.md +43 -0
- klaude_code/core/tool/web/mermaid_tool.py +2 -5
- klaude_code/core/tool/web/web_fetch_tool.md +1 -1
- klaude_code/core/tool/web/web_fetch_tool.py +112 -22
- klaude_code/core/tool/web/web_search_tool.md +23 -0
- klaude_code/core/tool/web/web_search_tool.py +130 -0
- klaude_code/core/turn.py +168 -66
- klaude_code/llm/__init__.py +2 -10
- klaude_code/llm/anthropic/client.py +190 -178
- klaude_code/llm/anthropic/input.py +39 -15
- klaude_code/llm/bedrock/__init__.py +3 -0
- klaude_code/llm/bedrock/client.py +60 -0
- klaude_code/llm/client.py +7 -21
- klaude_code/llm/codex/__init__.py +5 -0
- klaude_code/llm/codex/client.py +149 -0
- klaude_code/llm/google/__init__.py +3 -0
- klaude_code/llm/google/client.py +309 -0
- klaude_code/llm/google/input.py +215 -0
- klaude_code/llm/input_common.py +3 -9
- klaude_code/llm/openai_compatible/client.py +72 -164
- klaude_code/llm/openai_compatible/input.py +6 -4
- klaude_code/llm/openai_compatible/stream.py +273 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
- klaude_code/llm/openrouter/client.py +89 -160
- klaude_code/llm/openrouter/input.py +18 -30
- klaude_code/llm/openrouter/reasoning.py +118 -0
- klaude_code/llm/registry.py +39 -7
- klaude_code/llm/responses/client.py +184 -171
- klaude_code/llm/responses/input.py +20 -1
- klaude_code/llm/usage.py +17 -12
- klaude_code/protocol/commands.py +17 -1
- klaude_code/protocol/events.py +31 -4
- klaude_code/protocol/llm_param.py +13 -10
- klaude_code/protocol/model.py +232 -29
- klaude_code/protocol/op.py +90 -1
- klaude_code/protocol/op_handler.py +35 -1
- klaude_code/protocol/sub_agent/__init__.py +117 -0
- klaude_code/protocol/sub_agent/explore.py +63 -0
- klaude_code/protocol/sub_agent/oracle.py +91 -0
- klaude_code/protocol/sub_agent/task.py +61 -0
- klaude_code/protocol/sub_agent/web.py +79 -0
- klaude_code/protocol/tools.py +4 -2
- klaude_code/session/__init__.py +2 -2
- klaude_code/session/codec.py +71 -0
- klaude_code/session/export.py +293 -86
- klaude_code/session/selector.py +89 -67
- klaude_code/session/session.py +320 -309
- klaude_code/session/store.py +220 -0
- klaude_code/session/templates/export_session.html +595 -83
- klaude_code/session/templates/mermaid_viewer.html +926 -0
- klaude_code/skill/__init__.py +27 -0
- klaude_code/skill/assets/deslop/SKILL.md +17 -0
- klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
- klaude_code/skill/assets/handoff/SKILL.md +39 -0
- klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
- klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
- klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
- klaude_code/skill/manager.py +70 -0
- klaude_code/skill/system_skills.py +192 -0
- klaude_code/trace/__init__.py +20 -2
- klaude_code/trace/log.py +150 -5
- klaude_code/ui/__init__.py +4 -9
- klaude_code/ui/core/input.py +1 -1
- klaude_code/ui/core/stage_manager.py +7 -7
- klaude_code/ui/modes/debug/display.py +2 -1
- klaude_code/ui/modes/repl/__init__.py +3 -48
- klaude_code/ui/modes/repl/clipboard.py +5 -5
- klaude_code/ui/modes/repl/completers.py +487 -123
- klaude_code/ui/modes/repl/display.py +5 -4
- klaude_code/ui/modes/repl/event_handler.py +370 -117
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
- klaude_code/ui/modes/repl/key_bindings.py +146 -23
- klaude_code/ui/modes/repl/renderer.py +189 -99
- klaude_code/ui/renderers/assistant.py +9 -2
- klaude_code/ui/renderers/bash_syntax.py +178 -0
- klaude_code/ui/renderers/common.py +78 -0
- klaude_code/ui/renderers/developer.py +104 -48
- klaude_code/ui/renderers/diffs.py +87 -6
- klaude_code/ui/renderers/errors.py +11 -6
- klaude_code/ui/renderers/mermaid_viewer.py +57 -0
- klaude_code/ui/renderers/metadata.py +112 -76
- klaude_code/ui/renderers/sub_agent.py +92 -7
- klaude_code/ui/renderers/thinking.py +40 -18
- klaude_code/ui/renderers/tools.py +405 -227
- klaude_code/ui/renderers/user_input.py +73 -13
- klaude_code/ui/rich/__init__.py +10 -1
- klaude_code/ui/rich/cjk_wrap.py +228 -0
- klaude_code/ui/rich/code_panel.py +131 -0
- klaude_code/ui/rich/live.py +17 -0
- klaude_code/ui/rich/markdown.py +305 -170
- klaude_code/ui/rich/searchable_text.py +10 -13
- klaude_code/ui/rich/status.py +190 -49
- klaude_code/ui/rich/theme.py +135 -39
- klaude_code/ui/terminal/__init__.py +55 -0
- klaude_code/ui/terminal/color.py +1 -1
- klaude_code/ui/terminal/control.py +13 -22
- klaude_code/ui/terminal/notifier.py +44 -4
- klaude_code/ui/terminal/selector.py +658 -0
- klaude_code/ui/utils/common.py +0 -18
- klaude_code-1.8.0.dist-info/METADATA +377 -0
- klaude_code-1.8.0.dist-info/RECORD +219 -0
- {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
- klaude_code/command/diff_cmd.py +0 -138
- klaude_code/command/prompt-dev-docs-update.md +0 -56
- klaude_code/command/prompt-dev-docs.md +0 -46
- klaude_code/config/list_model.py +0 -162
- klaude_code/core/manager/agent_manager.py +0 -127
- klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
- klaude_code/core/tool/file/multi_edit_tool.md +0 -42
- klaude_code/core/tool/file/multi_edit_tool.py +0 -199
- klaude_code/core/tool/memory/memory_tool.md +0 -16
- klaude_code/core/tool/memory/memory_tool.py +0 -462
- klaude_code/llm/openrouter/reasoning_handler.py +0 -209
- klaude_code/protocol/sub_agent.py +0 -348
- klaude_code/ui/utils/debouncer.py +0 -42
- klaude_code-1.2.6.dist-info/METADATA +0 -178
- klaude_code-1.2.6.dist-info/RECORD +0 -167
- /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
- /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
- /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
- {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Authentication module.
|
|
2
|
+
|
|
3
|
+
Currently includes Codex OAuth helpers in ``klaude_code.auth.codex``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from klaude_code.auth.codex import (
|
|
7
|
+
CodexAuthError,
|
|
8
|
+
CodexAuthState,
|
|
9
|
+
CodexNotLoggedInError,
|
|
10
|
+
CodexOAuth,
|
|
11
|
+
CodexOAuthError,
|
|
12
|
+
CodexTokenExpiredError,
|
|
13
|
+
CodexTokenManager,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CodexAuthError",
|
|
18
|
+
"CodexAuthState",
|
|
19
|
+
"CodexNotLoggedInError",
|
|
20
|
+
"CodexOAuth",
|
|
21
|
+
"CodexOAuthError",
|
|
22
|
+
"CodexTokenExpiredError",
|
|
23
|
+
"CodexTokenManager",
|
|
24
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Codex authentication helpers."""
|
|
2
|
+
|
|
3
|
+
from klaude_code.auth.codex.exceptions import (
|
|
4
|
+
CodexAuthError,
|
|
5
|
+
CodexNotLoggedInError,
|
|
6
|
+
CodexOAuthError,
|
|
7
|
+
CodexTokenExpiredError,
|
|
8
|
+
)
|
|
9
|
+
from klaude_code.auth.codex.oauth import CodexOAuth
|
|
10
|
+
from klaude_code.auth.codex.token_manager import CodexAuthState, CodexTokenManager
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CodexAuthError",
|
|
14
|
+
"CodexAuthState",
|
|
15
|
+
"CodexNotLoggedInError",
|
|
16
|
+
"CodexOAuth",
|
|
17
|
+
"CodexOAuthError",
|
|
18
|
+
"CodexTokenExpiredError",
|
|
19
|
+
"CodexTokenManager",
|
|
20
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exceptions for Codex authentication."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CodexAuthError(Exception):
|
|
5
|
+
"""Base exception for Codex authentication errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CodexNotLoggedInError(CodexAuthError):
|
|
9
|
+
"""User has not logged in to Codex."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CodexTokenExpiredError(CodexAuthError):
|
|
13
|
+
"""Token expired and refresh failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CodexOAuthError(CodexAuthError):
|
|
17
|
+
"""OAuth flow failed."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""JWT parsing utilities for Codex authentication."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def decode_jwt_payload(token: str) -> dict[str, Any]:
|
|
9
|
+
"""Decode JWT payload without verification.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
token: JWT token string
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Decoded payload as a dictionary
|
|
16
|
+
"""
|
|
17
|
+
parts = token.split(".")
|
|
18
|
+
if len(parts) != 3:
|
|
19
|
+
raise ValueError("Invalid JWT format")
|
|
20
|
+
|
|
21
|
+
payload = parts[1]
|
|
22
|
+
# Add padding if needed
|
|
23
|
+
padding = 4 - len(payload) % 4
|
|
24
|
+
if padding != 4:
|
|
25
|
+
payload += "=" * padding
|
|
26
|
+
|
|
27
|
+
decoded = base64.urlsafe_b64decode(payload)
|
|
28
|
+
return json.loads(decoded)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_account_id(token: str) -> str:
|
|
32
|
+
"""Extract ChatGPT account ID from JWT token.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
token: JWT access token from OpenAI OAuth
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The chatgpt_account_id from the token claims
|
|
39
|
+
"""
|
|
40
|
+
payload = decode_jwt_payload(token)
|
|
41
|
+
auth_claim = payload.get("https://api.openai.com/auth", {})
|
|
42
|
+
account_id = auth_claim.get("chatgpt_account_id")
|
|
43
|
+
if not account_id:
|
|
44
|
+
raise ValueError("chatgpt_account_id not found in token")
|
|
45
|
+
return account_id
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""OAuth PKCE flow for Codex authentication."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
from threading import Thread
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from klaude_code.auth.codex.exceptions import CodexOAuthError
|
|
16
|
+
from klaude_code.auth.codex.jwt_utils import extract_account_id
|
|
17
|
+
from klaude_code.auth.codex.token_manager import CodexAuthState, CodexTokenManager
|
|
18
|
+
|
|
19
|
+
# OAuth configuration
|
|
20
|
+
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
21
|
+
AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
|
|
22
|
+
TOKEN_URL = "https://auth.openai.com/oauth/token"
|
|
23
|
+
REDIRECT_URI = "http://localhost:1455/auth/callback"
|
|
24
|
+
REDIRECT_PORT = 1455
|
|
25
|
+
SCOPE = "openid profile email offline_access"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def generate_code_verifier() -> str:
|
|
29
|
+
"""Generate a random code verifier for PKCE."""
|
|
30
|
+
return secrets.token_urlsafe(64)[:128]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate_code_challenge(verifier: str) -> str:
|
|
34
|
+
"""Generate code challenge from verifier using S256 method."""
|
|
35
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
36
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_authorize_url(code_challenge: str, state: str) -> str:
|
|
40
|
+
"""Build the authorization URL with all required parameters."""
|
|
41
|
+
params = {
|
|
42
|
+
"response_type": "code",
|
|
43
|
+
"client_id": CLIENT_ID,
|
|
44
|
+
"redirect_uri": REDIRECT_URI,
|
|
45
|
+
"scope": SCOPE,
|
|
46
|
+
"code_challenge": code_challenge,
|
|
47
|
+
"code_challenge_method": "S256",
|
|
48
|
+
"state": state,
|
|
49
|
+
"id_token_add_organizations": "true",
|
|
50
|
+
"codex_cli_simplified_flow": "true",
|
|
51
|
+
"originator": "codex_cli_rs",
|
|
52
|
+
}
|
|
53
|
+
return f"{AUTHORIZE_URL}?{urlencode(params)}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
57
|
+
"""HTTP request handler for OAuth callback."""
|
|
58
|
+
|
|
59
|
+
code: str | None = None
|
|
60
|
+
state: str | None = None
|
|
61
|
+
error: str | None = None
|
|
62
|
+
|
|
63
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
64
|
+
"""Suppress HTTP server logs."""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def do_GET(self) -> None:
|
|
68
|
+
"""Handle GET request from OAuth callback."""
|
|
69
|
+
parsed = urlparse(self.path)
|
|
70
|
+
params = parse_qs(parsed.query)
|
|
71
|
+
|
|
72
|
+
OAuthCallbackHandler.code = params.get("code", [None])[0]
|
|
73
|
+
OAuthCallbackHandler.state = params.get("state", [None])[0]
|
|
74
|
+
OAuthCallbackHandler.error = params.get("error", [None])[0]
|
|
75
|
+
|
|
76
|
+
self.send_response(200)
|
|
77
|
+
self.send_header("Content-Type", "text/html")
|
|
78
|
+
self.end_headers()
|
|
79
|
+
|
|
80
|
+
if OAuthCallbackHandler.error:
|
|
81
|
+
html = f"""
|
|
82
|
+
<html><body style="font-family: sans-serif; text-align: center; padding: 50px;">
|
|
83
|
+
<h1>Authentication Failed</h1>
|
|
84
|
+
<p>Error: {OAuthCallbackHandler.error}</p>
|
|
85
|
+
<p>Please close this window and try again.</p>
|
|
86
|
+
</body></html>
|
|
87
|
+
"""
|
|
88
|
+
else:
|
|
89
|
+
html = """
|
|
90
|
+
<html><body style="font-family: sans-serif; text-align: center; padding: 50px;">
|
|
91
|
+
<h1>Authentication Successful!</h1>
|
|
92
|
+
<p>You can close this window now.</p>
|
|
93
|
+
<script>setTimeout(function() { window.close(); }, 2000);</script>
|
|
94
|
+
</body></html>
|
|
95
|
+
"""
|
|
96
|
+
self.wfile.write(html.encode())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CodexOAuth:
|
|
100
|
+
"""Handle OAuth PKCE flow for Codex authentication."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, token_manager: CodexTokenManager | None = None):
|
|
103
|
+
self.token_manager = token_manager or CodexTokenManager()
|
|
104
|
+
|
|
105
|
+
def login(self) -> CodexAuthState:
|
|
106
|
+
"""Run the complete OAuth login flow."""
|
|
107
|
+
# Generate PKCE parameters
|
|
108
|
+
verifier = generate_code_verifier()
|
|
109
|
+
challenge = generate_code_challenge(verifier)
|
|
110
|
+
state = secrets.token_urlsafe(32)
|
|
111
|
+
|
|
112
|
+
# Build authorization URL
|
|
113
|
+
auth_url = build_authorize_url(challenge, state)
|
|
114
|
+
|
|
115
|
+
# Start callback server
|
|
116
|
+
OAuthCallbackHandler.code = None
|
|
117
|
+
OAuthCallbackHandler.state = None
|
|
118
|
+
OAuthCallbackHandler.error = None
|
|
119
|
+
|
|
120
|
+
server = HTTPServer(("localhost", REDIRECT_PORT), OAuthCallbackHandler)
|
|
121
|
+
server_thread = Thread(target=server.handle_request)
|
|
122
|
+
server_thread.start()
|
|
123
|
+
|
|
124
|
+
# Open browser for user to authenticate
|
|
125
|
+
webbrowser.open(auth_url)
|
|
126
|
+
|
|
127
|
+
# Wait for callback
|
|
128
|
+
server_thread.join(timeout=300) # 5 minute timeout
|
|
129
|
+
server.server_close()
|
|
130
|
+
|
|
131
|
+
# Check for errors
|
|
132
|
+
if OAuthCallbackHandler.error:
|
|
133
|
+
raise CodexOAuthError(f"OAuth error: {OAuthCallbackHandler.error}")
|
|
134
|
+
|
|
135
|
+
if not OAuthCallbackHandler.code:
|
|
136
|
+
raise CodexOAuthError("No authorization code received")
|
|
137
|
+
|
|
138
|
+
if OAuthCallbackHandler.state is None or OAuthCallbackHandler.state != state:
|
|
139
|
+
raise CodexOAuthError("OAuth state mismatch")
|
|
140
|
+
|
|
141
|
+
# Exchange code for tokens
|
|
142
|
+
auth_state = self._exchange_code(OAuthCallbackHandler.code, verifier)
|
|
143
|
+
|
|
144
|
+
# Save tokens
|
|
145
|
+
self.token_manager.save(auth_state)
|
|
146
|
+
|
|
147
|
+
return auth_state
|
|
148
|
+
|
|
149
|
+
def _exchange_code(self, code: str, verifier: str) -> CodexAuthState:
|
|
150
|
+
"""Exchange authorization code for tokens."""
|
|
151
|
+
data = {
|
|
152
|
+
"grant_type": "authorization_code",
|
|
153
|
+
"client_id": CLIENT_ID,
|
|
154
|
+
"code": code,
|
|
155
|
+
"redirect_uri": REDIRECT_URI,
|
|
156
|
+
"code_verifier": verifier,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
with httpx.Client() as client:
|
|
160
|
+
response = client.post(TOKEN_URL, data=data)
|
|
161
|
+
|
|
162
|
+
if response.status_code != 200:
|
|
163
|
+
raise CodexOAuthError(f"Token exchange failed: {response.text}")
|
|
164
|
+
|
|
165
|
+
tokens = response.json()
|
|
166
|
+
access_token = tokens["access_token"]
|
|
167
|
+
refresh_token = tokens["refresh_token"]
|
|
168
|
+
expires_in = tokens.get("expires_in", 3600)
|
|
169
|
+
|
|
170
|
+
account_id = extract_account_id(access_token)
|
|
171
|
+
|
|
172
|
+
return CodexAuthState(
|
|
173
|
+
access_token=access_token,
|
|
174
|
+
refresh_token=refresh_token,
|
|
175
|
+
expires_at=int(time.time()) + expires_in,
|
|
176
|
+
account_id=account_id,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def refresh(self) -> CodexAuthState:
|
|
180
|
+
"""Refresh the access token using refresh token."""
|
|
181
|
+
state = self.token_manager.get_state()
|
|
182
|
+
if state is None:
|
|
183
|
+
from klaude_code.auth.codex.exceptions import CodexNotLoggedInError
|
|
184
|
+
|
|
185
|
+
raise CodexNotLoggedInError("Not logged in to Codex. Run 'klaude login codex' first.")
|
|
186
|
+
|
|
187
|
+
data = {
|
|
188
|
+
"grant_type": "refresh_token",
|
|
189
|
+
"client_id": CLIENT_ID,
|
|
190
|
+
"refresh_token": state.refresh_token,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
with httpx.Client() as client:
|
|
194
|
+
response = client.post(TOKEN_URL, data=data)
|
|
195
|
+
|
|
196
|
+
if response.status_code != 200:
|
|
197
|
+
from klaude_code.auth.codex.exceptions import CodexTokenExpiredError
|
|
198
|
+
|
|
199
|
+
raise CodexTokenExpiredError(f"Token refresh failed: {response.text}")
|
|
200
|
+
|
|
201
|
+
tokens = response.json()
|
|
202
|
+
access_token = tokens["access_token"]
|
|
203
|
+
refresh_token = tokens.get("refresh_token", state.refresh_token)
|
|
204
|
+
expires_in = tokens.get("expires_in", 3600)
|
|
205
|
+
|
|
206
|
+
account_id = extract_account_id(access_token)
|
|
207
|
+
|
|
208
|
+
new_state = CodexAuthState(
|
|
209
|
+
access_token=access_token,
|
|
210
|
+
refresh_token=refresh_token,
|
|
211
|
+
expires_at=int(time.time()) + expires_in,
|
|
212
|
+
account_id=account_id,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self.token_manager.save(new_state)
|
|
216
|
+
return new_state
|
|
217
|
+
|
|
218
|
+
def ensure_valid_token(self) -> str:
|
|
219
|
+
"""Ensure we have a valid access token, refreshing if needed."""
|
|
220
|
+
state = self.token_manager.get_state()
|
|
221
|
+
if state is None:
|
|
222
|
+
from klaude_code.auth.codex.exceptions import CodexNotLoggedInError
|
|
223
|
+
|
|
224
|
+
raise CodexNotLoggedInError("Not logged in to Codex. Run 'klaude login codex' first.")
|
|
225
|
+
|
|
226
|
+
if state.is_expired():
|
|
227
|
+
state = self.refresh()
|
|
228
|
+
|
|
229
|
+
return state.access_token
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Token storage and management for Codex authentication."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CodexAuthState(BaseModel):
|
|
11
|
+
"""Stored authentication state for Codex."""
|
|
12
|
+
|
|
13
|
+
access_token: str
|
|
14
|
+
refresh_token: str
|
|
15
|
+
expires_at: int # Unix timestamp
|
|
16
|
+
account_id: str
|
|
17
|
+
|
|
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
|
+
|
|
22
|
+
|
|
23
|
+
CODEX_AUTH_FILE = Path.home() / ".klaude" / "codex-auth.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CodexTokenManager:
|
|
27
|
+
"""Manage Codex OAuth tokens."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, auth_file: Path | None = None):
|
|
30
|
+
self.auth_file = auth_file or CODEX_AUTH_FILE
|
|
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
|
|
56
|
+
|
|
57
|
+
def is_logged_in(self) -> bool:
|
|
58
|
+
"""Check if user is logged in."""
|
|
59
|
+
state = self._state or self.load()
|
|
60
|
+
return state is not None
|
|
61
|
+
|
|
62
|
+
def get_state(self) -> CodexAuthState | None:
|
|
63
|
+
"""Get current authentication state."""
|
|
64
|
+
if self._state is None:
|
|
65
|
+
self._state = self.load()
|
|
66
|
+
return self._state
|
|
67
|
+
|
|
68
|
+
def get_access_token(self) -> str:
|
|
69
|
+
"""Get access token, raising if not logged in."""
|
|
70
|
+
state = self.get_state()
|
|
71
|
+
if state is None:
|
|
72
|
+
from klaude_code.auth.codex.exceptions import CodexNotLoggedInError
|
|
73
|
+
|
|
74
|
+
raise CodexNotLoggedInError("Not logged in to Codex. Run 'klaude login codex' first.")
|
|
75
|
+
return state.access_token
|
|
76
|
+
|
|
77
|
+
def get_account_id(self) -> str:
|
|
78
|
+
"""Get account ID, raising if not logged in."""
|
|
79
|
+
state = self.get_state()
|
|
80
|
+
if state is None:
|
|
81
|
+
from klaude_code.auth.codex.exceptions import CodexNotLoggedInError
|
|
82
|
+
|
|
83
|
+
raise CodexNotLoggedInError("Not logged in to Codex. Run 'klaude login codex' first.")
|
|
84
|
+
return state.account_id
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Authentication commands for CLI."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from klaude_code.trace import log
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def login_command(
|
|
11
|
+
provider: str = typer.Argument("codex", help="Provider to login (codex)"),
|
|
12
|
+
) -> None:
|
|
13
|
+
"""Login to a provider using OAuth."""
|
|
14
|
+
if provider.lower() != "codex":
|
|
15
|
+
log((f"Error: Unknown provider '{provider}'. Currently only 'codex' is supported.", "red"))
|
|
16
|
+
raise typer.Exit(1)
|
|
17
|
+
|
|
18
|
+
from klaude_code.auth.codex.oauth import CodexOAuth
|
|
19
|
+
from klaude_code.auth.codex.token_manager import CodexTokenManager
|
|
20
|
+
|
|
21
|
+
token_manager = CodexTokenManager()
|
|
22
|
+
|
|
23
|
+
# Check if already logged in
|
|
24
|
+
if token_manager.is_logged_in():
|
|
25
|
+
state = token_manager.get_state()
|
|
26
|
+
if state and not state.is_expired():
|
|
27
|
+
log(("You are already logged in to Codex.", "green"))
|
|
28
|
+
log(f" Account ID: {state.account_id[:8]}...")
|
|
29
|
+
expires_dt = datetime.datetime.fromtimestamp(state.expires_at, tz=datetime.UTC)
|
|
30
|
+
log(f" Expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
31
|
+
if not typer.confirm("Do you want to re-login?"):
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
log("Starting Codex OAuth login flow...")
|
|
35
|
+
log("A browser window will open for authentication.")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
oauth = CodexOAuth(token_manager)
|
|
39
|
+
state = oauth.login()
|
|
40
|
+
log(("Login successful!", "green"))
|
|
41
|
+
log(f" Account ID: {state.account_id[:8]}...")
|
|
42
|
+
expires_dt = datetime.datetime.fromtimestamp(state.expires_at, tz=datetime.UTC)
|
|
43
|
+
log(f" Expires: {expires_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
44
|
+
except Exception as e:
|
|
45
|
+
log((f"Login failed: {e}", "red"))
|
|
46
|
+
raise typer.Exit(1) from None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def logout_command(
|
|
50
|
+
provider: str = typer.Argument("codex", help="Provider to logout (codex)"),
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Logout from a provider."""
|
|
53
|
+
if provider.lower() != "codex":
|
|
54
|
+
log((f"Error: Unknown provider '{provider}'. Currently only 'codex' is supported.", "red"))
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
|
|
57
|
+
from klaude_code.auth.codex.token_manager import CodexTokenManager
|
|
58
|
+
|
|
59
|
+
token_manager = CodexTokenManager()
|
|
60
|
+
|
|
61
|
+
if not token_manager.is_logged_in():
|
|
62
|
+
log("You are not logged in to Codex.")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if typer.confirm("Are you sure you want to logout from Codex?"):
|
|
66
|
+
token_manager.delete()
|
|
67
|
+
log(("Logged out from Codex.", "green"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def register_auth_commands(app: typer.Typer) -> None:
|
|
71
|
+
"""Register auth commands to the given Typer app."""
|
|
72
|
+
app.command("login")(login_command)
|
|
73
|
+
app.command("logout")(logout_command)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Configuration commands for CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from klaude_code.config import config_path, create_example_config, example_config_path, load_config
|
|
10
|
+
from klaude_code.trace import log
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def list_models() -> None:
|
|
14
|
+
"""List all models and providers configuration"""
|
|
15
|
+
from klaude_code.cli.list_model import display_models_and_providers
|
|
16
|
+
from klaude_code.ui.terminal.color import is_light_terminal_background
|
|
17
|
+
|
|
18
|
+
config = load_config()
|
|
19
|
+
|
|
20
|
+
# Auto-detect theme when not explicitly set in config, to match other CLI entrypoints.
|
|
21
|
+
if config.theme is None:
|
|
22
|
+
detected = is_light_terminal_background()
|
|
23
|
+
if detected is True:
|
|
24
|
+
config.theme = "light"
|
|
25
|
+
elif detected is False:
|
|
26
|
+
config.theme = "dark"
|
|
27
|
+
|
|
28
|
+
display_models_and_providers(config)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def edit_config() -> None:
|
|
32
|
+
"""Open the configuration file in $EDITOR or default system editor"""
|
|
33
|
+
editor = os.environ.get("EDITOR")
|
|
34
|
+
|
|
35
|
+
# If no EDITOR is set, prioritize TextEdit on macOS
|
|
36
|
+
if not editor:
|
|
37
|
+
# Try common editors in order of preference on other platforms
|
|
38
|
+
for cmd in [
|
|
39
|
+
"code",
|
|
40
|
+
"nvim",
|
|
41
|
+
"vim",
|
|
42
|
+
"nano",
|
|
43
|
+
]:
|
|
44
|
+
try:
|
|
45
|
+
subprocess.run(["which", cmd], check=True, capture_output=True)
|
|
46
|
+
editor = cmd
|
|
47
|
+
break
|
|
48
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
# If no editor found, try platform-specific defaults
|
|
52
|
+
if not editor:
|
|
53
|
+
if sys.platform == "darwin": # macOS
|
|
54
|
+
editor = "open"
|
|
55
|
+
elif sys.platform == "win32": # Windows
|
|
56
|
+
editor = "notepad"
|
|
57
|
+
else: # Linux and other Unix systems
|
|
58
|
+
editor = "xdg-open"
|
|
59
|
+
|
|
60
|
+
# Ensure config directory exists and create example config if needed
|
|
61
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
if create_example_config():
|
|
63
|
+
log(f"Created example config: {example_config_path}", style="dim")
|
|
64
|
+
|
|
65
|
+
# Decide which file to open
|
|
66
|
+
target_path = config_path if config_path.exists() else example_config_path
|
|
67
|
+
if target_path == example_config_path:
|
|
68
|
+
log(f"Opening example config (copy to {config_path.name} to use)", style="yellow")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if editor == "open -a TextEdit":
|
|
72
|
+
subprocess.run(["open", "-a", "TextEdit", str(target_path)], check=True)
|
|
73
|
+
elif editor in ["open", "xdg-open"]:
|
|
74
|
+
# For open/xdg-open, we need to pass the file directly
|
|
75
|
+
subprocess.run([editor, str(target_path)], check=True)
|
|
76
|
+
else:
|
|
77
|
+
subprocess.run([editor, str(target_path)], check=True)
|
|
78
|
+
except subprocess.CalledProcessError as e:
|
|
79
|
+
log((f"Error: Failed to open editor: {e}", "red"))
|
|
80
|
+
raise typer.Exit(1) from None
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
log((f"Error: Editor '{editor}' not found", "red"))
|
|
83
|
+
log("Please install a text editor or set your $EDITOR environment variable")
|
|
84
|
+
raise typer.Exit(1) from None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def register_config_commands(app: typer.Typer) -> None:
|
|
88
|
+
"""Register config commands to the given Typer app."""
|
|
89
|
+
app.command("list")(list_models)
|
|
90
|
+
app.command("config")(edit_config)
|
|
91
|
+
app.command("conf", hidden=True)(edit_config)
|