gac 1.13.0__py3-none-any.whl → 3.8.1__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.
- gac/__version__.py +1 -1
- gac/ai.py +33 -47
- gac/ai_utils.py +113 -41
- gac/auth_cli.py +214 -0
- gac/cli.py +72 -2
- gac/config.py +63 -6
- gac/config_cli.py +26 -5
- gac/constants.py +178 -2
- gac/git.py +158 -12
- gac/init_cli.py +40 -125
- gac/language_cli.py +378 -0
- gac/main.py +868 -158
- gac/model_cli.py +429 -0
- gac/oauth/__init__.py +27 -0
- gac/oauth/claude_code.py +464 -0
- gac/oauth/qwen_oauth.py +323 -0
- gac/oauth/token_store.py +81 -0
- gac/preprocess.py +3 -3
- gac/prompt.py +573 -226
- gac/providers/__init__.py +49 -0
- gac/providers/anthropic.py +11 -1
- gac/providers/azure_openai.py +101 -0
- gac/providers/cerebras.py +11 -1
- gac/providers/chutes.py +11 -1
- gac/providers/claude_code.py +112 -0
- gac/providers/custom_anthropic.py +6 -2
- gac/providers/custom_openai.py +6 -3
- gac/providers/deepseek.py +11 -1
- gac/providers/fireworks.py +11 -1
- gac/providers/gemini.py +11 -1
- gac/providers/groq.py +5 -1
- gac/providers/kimi_coding.py +67 -0
- gac/providers/lmstudio.py +12 -1
- gac/providers/minimax.py +11 -1
- gac/providers/mistral.py +48 -0
- gac/providers/moonshot.py +48 -0
- gac/providers/ollama.py +11 -1
- gac/providers/openai.py +11 -1
- gac/providers/openrouter.py +11 -1
- gac/providers/qwen.py +76 -0
- gac/providers/replicate.py +110 -0
- gac/providers/streamlake.py +11 -1
- gac/providers/synthetic.py +11 -1
- gac/providers/together.py +11 -1
- gac/providers/zai.py +11 -1
- gac/security.py +1 -1
- gac/utils.py +272 -4
- gac/workflow_utils.py +217 -0
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/METADATA +90 -27
- gac-3.8.1.dist-info/RECORD +56 -0
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/WHEEL +1 -1
- gac-1.13.0.dist-info/RECORD +0 -41
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/entry_points.txt +0 -0
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/licenses/LICENSE +0 -0
gac/__version__.py
CHANGED
gac/ai.py
CHANGED
|
@@ -9,27 +9,7 @@ import logging
|
|
|
9
9
|
from gac.ai_utils import generate_with_retries
|
|
10
10
|
from gac.constants import EnvDefaults
|
|
11
11
|
from gac.errors import AIError
|
|
12
|
-
from gac.providers import
|
|
13
|
-
call_anthropic_api,
|
|
14
|
-
call_cerebras_api,
|
|
15
|
-
call_chutes_api,
|
|
16
|
-
call_custom_anthropic_api,
|
|
17
|
-
call_custom_openai_api,
|
|
18
|
-
call_deepseek_api,
|
|
19
|
-
call_fireworks_api,
|
|
20
|
-
call_gemini_api,
|
|
21
|
-
call_groq_api,
|
|
22
|
-
call_lmstudio_api,
|
|
23
|
-
call_minimax_api,
|
|
24
|
-
call_ollama_api,
|
|
25
|
-
call_openai_api,
|
|
26
|
-
call_openrouter_api,
|
|
27
|
-
call_streamlake_api,
|
|
28
|
-
call_synthetic_api,
|
|
29
|
-
call_together_api,
|
|
30
|
-
call_zai_api,
|
|
31
|
-
call_zai_coding_api,
|
|
32
|
-
)
|
|
12
|
+
from gac.providers import PROVIDER_REGISTRY
|
|
33
13
|
|
|
34
14
|
logger = logging.getLogger(__name__)
|
|
35
15
|
|
|
@@ -41,11 +21,14 @@ def generate_commit_message(
|
|
|
41
21
|
max_tokens: int = EnvDefaults.MAX_OUTPUT_TOKENS,
|
|
42
22
|
max_retries: int = EnvDefaults.MAX_RETRIES,
|
|
43
23
|
quiet: bool = False,
|
|
24
|
+
is_group: bool = False,
|
|
25
|
+
skip_success_message: bool = False,
|
|
26
|
+
task_description: str = "commit message",
|
|
44
27
|
) -> str:
|
|
45
28
|
"""Generate a commit message using direct API calls to AI providers.
|
|
46
29
|
|
|
47
30
|
Args:
|
|
48
|
-
model: The model to use in provider:model_name format (e.g., 'anthropic:claude-
|
|
31
|
+
model: The model to use in provider:model_name format (e.g., 'anthropic:claude-haiku-4-5')
|
|
49
32
|
prompt: Either a string prompt (for backward compatibility) or tuple of (system_prompt, user_prompt)
|
|
50
33
|
temperature: Controls randomness (0.0-1.0), lower values are more deterministic
|
|
51
34
|
max_tokens: Maximum tokens in the response
|
|
@@ -59,7 +42,7 @@ def generate_commit_message(
|
|
|
59
42
|
AIError: If generation fails after max_retries attempts
|
|
60
43
|
|
|
61
44
|
Example:
|
|
62
|
-
>>> model = "anthropic:claude-
|
|
45
|
+
>>> model = "anthropic:claude-haiku-4-5"
|
|
63
46
|
>>> system_prompt, user_prompt = build_prompt("On branch main", "diff --git a/README.md b/README.md")
|
|
64
47
|
>>> generate_commit_message(model, (system_prompt, user_prompt))
|
|
65
48
|
'docs: Update README with installation instructions'
|
|
@@ -81,39 +64,19 @@ def generate_commit_message(
|
|
|
81
64
|
{"role": "user", "content": user_prompt},
|
|
82
65
|
]
|
|
83
66
|
|
|
84
|
-
# Provider functions mapping
|
|
85
|
-
provider_funcs = {
|
|
86
|
-
"anthropic": call_anthropic_api,
|
|
87
|
-
"cerebras": call_cerebras_api,
|
|
88
|
-
"chutes": call_chutes_api,
|
|
89
|
-
"custom-anthropic": call_custom_anthropic_api,
|
|
90
|
-
"custom-openai": call_custom_openai_api,
|
|
91
|
-
"deepseek": call_deepseek_api,
|
|
92
|
-
"fireworks": call_fireworks_api,
|
|
93
|
-
"gemini": call_gemini_api,
|
|
94
|
-
"groq": call_groq_api,
|
|
95
|
-
"lm-studio": call_lmstudio_api,
|
|
96
|
-
"minimax": call_minimax_api,
|
|
97
|
-
"ollama": call_ollama_api,
|
|
98
|
-
"openai": call_openai_api,
|
|
99
|
-
"openrouter": call_openrouter_api,
|
|
100
|
-
"streamlake": call_streamlake_api,
|
|
101
|
-
"synthetic": call_synthetic_api,
|
|
102
|
-
"together": call_together_api,
|
|
103
|
-
"zai": call_zai_api,
|
|
104
|
-
"zai-coding": call_zai_coding_api,
|
|
105
|
-
}
|
|
106
|
-
|
|
107
67
|
# Generate the commit message using centralized retry logic
|
|
108
68
|
try:
|
|
109
69
|
return generate_with_retries(
|
|
110
|
-
provider_funcs=
|
|
70
|
+
provider_funcs=PROVIDER_REGISTRY,
|
|
111
71
|
model=model,
|
|
112
72
|
messages=messages,
|
|
113
73
|
temperature=temperature,
|
|
114
74
|
max_tokens=max_tokens,
|
|
115
75
|
max_retries=max_retries,
|
|
116
76
|
quiet=quiet,
|
|
77
|
+
is_group=is_group,
|
|
78
|
+
skip_success_message=skip_success_message,
|
|
79
|
+
task_description=task_description,
|
|
117
80
|
)
|
|
118
81
|
except AIError:
|
|
119
82
|
# Re-raise AIError exceptions as-is to preserve error classification
|
|
@@ -121,3 +84,26 @@ def generate_commit_message(
|
|
|
121
84
|
except Exception as e:
|
|
122
85
|
logger.error(f"Failed to generate commit message: {e}")
|
|
123
86
|
raise AIError.model_error(f"Failed to generate commit message: {e}") from e
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def generate_grouped_commits(
|
|
90
|
+
model: str,
|
|
91
|
+
prompt: list[dict[str, str]],
|
|
92
|
+
temperature: float,
|
|
93
|
+
max_tokens: int,
|
|
94
|
+
max_retries: int,
|
|
95
|
+
quiet: bool = False,
|
|
96
|
+
skip_success_message: bool = False,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Generate grouped commits JSON response."""
|
|
99
|
+
return generate_commit_message(
|
|
100
|
+
model=model,
|
|
101
|
+
prompt=prompt,
|
|
102
|
+
temperature=temperature,
|
|
103
|
+
max_tokens=max_tokens,
|
|
104
|
+
max_retries=max_retries,
|
|
105
|
+
quiet=quiet,
|
|
106
|
+
is_group=True,
|
|
107
|
+
skip_success_message=skip_success_message,
|
|
108
|
+
task_description="commit message",
|
|
109
|
+
)
|
gac/ai_utils.py
CHANGED
|
@@ -4,17 +4,28 @@ This module provides utility functions that support the AI provider implementati
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
import time
|
|
8
9
|
from functools import lru_cache
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
12
|
import tiktoken
|
|
12
|
-
from
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.status import Status
|
|
13
15
|
|
|
14
|
-
from gac.constants import Utility
|
|
16
|
+
from gac.constants import EnvDefaults, Utility
|
|
15
17
|
from gac.errors import AIError
|
|
18
|
+
from gac.providers import SUPPORTED_PROVIDERS
|
|
16
19
|
|
|
17
20
|
logger = logging.getLogger(__name__)
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=1)
|
|
25
|
+
def _should_skip_tiktoken_counting() -> bool:
|
|
26
|
+
"""Return True when token counting should avoid tiktoken calls entirely."""
|
|
27
|
+
value = os.getenv("GAC_NO_TIKTOKEN", str(EnvDefaults.NO_TIKTOKEN))
|
|
28
|
+
return value.lower() in ("true", "1", "yes", "on")
|
|
18
29
|
|
|
19
30
|
|
|
20
31
|
def count_tokens(content: str | list[dict[str, str]] | dict[str, Any], model: str) -> int:
|
|
@@ -23,11 +34,15 @@ def count_tokens(content: str | list[dict[str, str]] | dict[str, Any], model: st
|
|
|
23
34
|
if not text:
|
|
24
35
|
return 0
|
|
25
36
|
|
|
37
|
+
if _should_skip_tiktoken_counting():
|
|
38
|
+
return len(text) // 4
|
|
39
|
+
|
|
26
40
|
try:
|
|
27
41
|
encoding = get_encoding(model)
|
|
28
42
|
return len(encoding.encode(text))
|
|
29
43
|
except Exception as e:
|
|
30
44
|
logger.error(f"Error counting tokens: {e}")
|
|
45
|
+
# Fallback to rough estimation (4 chars per token on average)
|
|
31
46
|
return len(text) // 4
|
|
32
47
|
|
|
33
48
|
|
|
@@ -45,10 +60,18 @@ def extract_text_content(content: str | list[dict[str, str]] | dict[str, Any]) -
|
|
|
45
60
|
@lru_cache(maxsize=1)
|
|
46
61
|
def get_encoding(model: str) -> tiktoken.Encoding:
|
|
47
62
|
"""Get the appropriate encoding for a given model."""
|
|
48
|
-
model_name = model.split(":")
|
|
63
|
+
provider, model_name = model.split(":", 1) if ":" in model else (None, model)
|
|
64
|
+
|
|
65
|
+
if provider != "openai":
|
|
66
|
+
return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
|
|
67
|
+
|
|
49
68
|
try:
|
|
50
69
|
return tiktoken.encoding_for_model(model_name)
|
|
51
70
|
except KeyError:
|
|
71
|
+
# Fall back to default encoding if model not found
|
|
72
|
+
return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
|
|
73
|
+
except Exception:
|
|
74
|
+
# If there are any network/SSL issues, fall back to default encoding
|
|
52
75
|
return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
|
|
53
76
|
|
|
54
77
|
|
|
@@ -83,6 +106,9 @@ def generate_with_retries(
|
|
|
83
106
|
max_tokens: int,
|
|
84
107
|
max_retries: int,
|
|
85
108
|
quiet: bool = False,
|
|
109
|
+
is_group: bool = False,
|
|
110
|
+
skip_success_message: bool = False,
|
|
111
|
+
task_description: str = "commit message",
|
|
86
112
|
) -> str:
|
|
87
113
|
"""Generate content with retry logic using direct API calls."""
|
|
88
114
|
# Parse model string to determine provider and actual model
|
|
@@ -92,38 +118,69 @@ def generate_with_retries(
|
|
|
92
118
|
provider, model_name = model.split(":", 1)
|
|
93
119
|
|
|
94
120
|
# Validate provider
|
|
95
|
-
|
|
96
|
-
"
|
|
97
|
-
"cerebras",
|
|
98
|
-
"chutes",
|
|
99
|
-
"deepseek",
|
|
100
|
-
"fireworks",
|
|
101
|
-
"gemini",
|
|
102
|
-
"groq",
|
|
103
|
-
"lm-studio",
|
|
104
|
-
"minimax",
|
|
105
|
-
"ollama",
|
|
106
|
-
"openai",
|
|
107
|
-
"openrouter",
|
|
108
|
-
"streamlake",
|
|
109
|
-
"synthetic",
|
|
110
|
-
"together",
|
|
111
|
-
"zai",
|
|
112
|
-
"zai-coding",
|
|
113
|
-
"custom-anthropic",
|
|
114
|
-
"custom-openai",
|
|
115
|
-
]
|
|
116
|
-
if provider not in supported_providers:
|
|
117
|
-
raise AIError.model_error(f"Unsupported provider: {provider}. Supported providers: {supported_providers}")
|
|
121
|
+
if provider not in SUPPORTED_PROVIDERS:
|
|
122
|
+
raise AIError.model_error(f"Unsupported provider: {provider}. Supported providers: {SUPPORTED_PROVIDERS}")
|
|
118
123
|
|
|
119
124
|
if not messages:
|
|
120
125
|
raise AIError.model_error("No messages provided for AI generation")
|
|
121
126
|
|
|
127
|
+
# Load Claude Code token from TokenStore if needed
|
|
128
|
+
if provider == "claude-code":
|
|
129
|
+
from gac.oauth import refresh_token_if_expired
|
|
130
|
+
from gac.oauth.token_store import TokenStore
|
|
131
|
+
|
|
132
|
+
# Check token expiry and refresh if needed
|
|
133
|
+
if not refresh_token_if_expired(quiet=True):
|
|
134
|
+
raise AIError.authentication_error(
|
|
135
|
+
"Claude Code token not found or expired. Please authenticate with 'gac auth claude-code login'."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Load the (possibly refreshed) token
|
|
139
|
+
token_store = TokenStore()
|
|
140
|
+
token_data = token_store.get_token("claude-code")
|
|
141
|
+
if token_data and "access_token" in token_data:
|
|
142
|
+
os.environ["CLAUDE_CODE_ACCESS_TOKEN"] = token_data["access_token"]
|
|
143
|
+
else:
|
|
144
|
+
raise AIError.authentication_error(
|
|
145
|
+
"Claude Code token not found. Please authenticate with 'gac auth claude-code login'."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Check Qwen OAuth token expiry and refresh if needed
|
|
149
|
+
if provider == "qwen":
|
|
150
|
+
from gac.oauth import QwenOAuthProvider, TokenStore
|
|
151
|
+
|
|
152
|
+
oauth_provider = QwenOAuthProvider(TokenStore())
|
|
153
|
+
token = oauth_provider.get_token()
|
|
154
|
+
if not token:
|
|
155
|
+
if not quiet:
|
|
156
|
+
console.print("[yellow]⚠ Qwen authentication not found or expired[/yellow]")
|
|
157
|
+
console.print("[cyan]🔐 Starting automatic authentication...[/cyan]")
|
|
158
|
+
try:
|
|
159
|
+
oauth_provider.initiate_auth(open_browser=True)
|
|
160
|
+
token = oauth_provider.get_token()
|
|
161
|
+
if not token:
|
|
162
|
+
raise AIError.authentication_error(
|
|
163
|
+
"Qwen authentication failed. Run 'gac auth qwen login' to authenticate manually."
|
|
164
|
+
)
|
|
165
|
+
if not quiet:
|
|
166
|
+
console.print("[green]✓ Authentication successful![/green]\n")
|
|
167
|
+
except AIError:
|
|
168
|
+
raise
|
|
169
|
+
except Exception as e:
|
|
170
|
+
raise AIError.authentication_error(
|
|
171
|
+
f"Qwen authentication failed: {e}. Run 'gac auth qwen login' to authenticate manually."
|
|
172
|
+
) from e
|
|
173
|
+
|
|
122
174
|
# Set up spinner
|
|
175
|
+
if is_group:
|
|
176
|
+
message_type = f"grouped {task_description}s"
|
|
177
|
+
else:
|
|
178
|
+
message_type = task_description
|
|
179
|
+
|
|
123
180
|
if quiet:
|
|
124
181
|
spinner = None
|
|
125
182
|
else:
|
|
126
|
-
spinner =
|
|
183
|
+
spinner = Status(f"Generating {message_type} with {provider} {model_name}...")
|
|
127
184
|
spinner.start()
|
|
128
185
|
|
|
129
186
|
last_exception = None
|
|
@@ -131,9 +188,9 @@ def generate_with_retries(
|
|
|
131
188
|
|
|
132
189
|
for attempt in range(max_retries):
|
|
133
190
|
try:
|
|
134
|
-
if not quiet and attempt > 0:
|
|
191
|
+
if not quiet and not skip_success_message and attempt > 0:
|
|
135
192
|
if spinner:
|
|
136
|
-
spinner.
|
|
193
|
+
spinner.update(f"Retry {attempt + 1}/{max_retries} with {provider} {model_name}...")
|
|
137
194
|
logger.info(f"Retry attempt {attempt + 1}/{max_retries}")
|
|
138
195
|
|
|
139
196
|
# Call the appropriate provider function
|
|
@@ -144,7 +201,11 @@ def generate_with_retries(
|
|
|
144
201
|
content = provider_func(model=model_name, messages=messages, temperature=temperature, max_tokens=max_tokens)
|
|
145
202
|
|
|
146
203
|
if spinner:
|
|
147
|
-
|
|
204
|
+
if skip_success_message:
|
|
205
|
+
spinner.stop() # Stop spinner without showing success/failure
|
|
206
|
+
else:
|
|
207
|
+
spinner.stop()
|
|
208
|
+
console.print(f"✓ Generated {message_type} with {provider} {model_name}")
|
|
148
209
|
|
|
149
210
|
if content is not None and content.strip():
|
|
150
211
|
return content.strip() # type: ignore[no-any-return]
|
|
@@ -159,8 +220,9 @@ def generate_with_retries(
|
|
|
159
220
|
|
|
160
221
|
# For authentication and model errors, don't retry
|
|
161
222
|
if error_type in ["authentication", "model"]:
|
|
162
|
-
if spinner:
|
|
163
|
-
spinner.
|
|
223
|
+
if spinner and not skip_success_message:
|
|
224
|
+
spinner.stop()
|
|
225
|
+
console.print(f"✗ Failed to generate {message_type} with {provider} {model_name}")
|
|
164
226
|
|
|
165
227
|
# Create the appropriate error type based on classification
|
|
166
228
|
if error_type == "authentication":
|
|
@@ -171,23 +233,33 @@ def generate_with_retries(
|
|
|
171
233
|
if attempt < max_retries - 1:
|
|
172
234
|
# Exponential backoff
|
|
173
235
|
wait_time = 2**attempt
|
|
174
|
-
if not quiet:
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
236
|
+
if not quiet and not skip_success_message:
|
|
237
|
+
if attempt == 0:
|
|
238
|
+
logger.warning(f"AI generation failed, retrying in {wait_time}s: {str(e)}")
|
|
239
|
+
else:
|
|
240
|
+
logger.warning(
|
|
241
|
+
f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {str(e)}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if spinner and not skip_success_message:
|
|
178
245
|
for i in range(wait_time, 0, -1):
|
|
179
|
-
spinner.
|
|
246
|
+
spinner.update(f"Retry {attempt + 1}/{max_retries} in {i}s...")
|
|
180
247
|
time.sleep(1)
|
|
181
248
|
else:
|
|
182
249
|
time.sleep(wait_time)
|
|
183
250
|
else:
|
|
184
|
-
|
|
251
|
+
num_retries = max_retries
|
|
252
|
+
retry_word = "retry" if num_retries == 1 else "retries"
|
|
253
|
+
logger.error(f"AI generation failed after {num_retries} {retry_word}: {str(e)}")
|
|
185
254
|
|
|
186
|
-
if spinner:
|
|
187
|
-
spinner.
|
|
255
|
+
if spinner and not skip_success_message:
|
|
256
|
+
spinner.stop()
|
|
257
|
+
console.print(f"✗ Failed to generate {message_type} with {provider} {model_name}")
|
|
188
258
|
|
|
189
259
|
# If we get here, all retries failed - use the last classified error type
|
|
190
|
-
|
|
260
|
+
num_retries = max_retries
|
|
261
|
+
retry_word = "retry" if num_retries == 1 else "retries"
|
|
262
|
+
error_message = f"Failed to generate {message_type} after {num_retries} {retry_word}"
|
|
191
263
|
if last_error_type == "authentication":
|
|
192
264
|
raise AIError.authentication_error(error_message) from last_exception
|
|
193
265
|
elif last_error_type == "rate_limit":
|
gac/auth_cli.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""CLI for OAuth authentication with various providers.
|
|
2
|
+
|
|
3
|
+
Provides commands to authenticate and manage OAuth tokens for supported providers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from gac.oauth import (
|
|
11
|
+
QwenOAuthProvider,
|
|
12
|
+
TokenStore,
|
|
13
|
+
authenticate_and_save,
|
|
14
|
+
remove_token,
|
|
15
|
+
)
|
|
16
|
+
from gac.utils import setup_logging
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group(invoke_without_command=True)
|
|
22
|
+
@click.pass_context
|
|
23
|
+
def auth(ctx: click.Context) -> None:
|
|
24
|
+
"""Manage OAuth authentication for AI providers.
|
|
25
|
+
|
|
26
|
+
Supports authentication for:
|
|
27
|
+
- claude-code: Claude Code subscription OAuth
|
|
28
|
+
- qwen: Qwen AI OAuth (device flow)
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
gac auth # Show authentication status
|
|
32
|
+
gac auth claude-code login # Login to Claude Code
|
|
33
|
+
gac auth claude-code logout # Logout from Claude Code
|
|
34
|
+
gac auth claude-code status # Check Claude Code auth status
|
|
35
|
+
gac auth qwen login # Login to Qwen
|
|
36
|
+
gac auth qwen logout # Logout from Qwen
|
|
37
|
+
gac auth qwen status # Check Qwen auth status
|
|
38
|
+
"""
|
|
39
|
+
if ctx.invoked_subcommand is None:
|
|
40
|
+
_show_auth_status()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _show_auth_status() -> None:
|
|
44
|
+
"""Show authentication status for all providers."""
|
|
45
|
+
click.echo("OAuth Authentication Status")
|
|
46
|
+
click.echo("-" * 40)
|
|
47
|
+
|
|
48
|
+
token_store = TokenStore()
|
|
49
|
+
|
|
50
|
+
claude_token = token_store.get_token("claude-code")
|
|
51
|
+
if claude_token:
|
|
52
|
+
click.echo("Claude Code: ✓ Authenticated")
|
|
53
|
+
else:
|
|
54
|
+
click.echo("Claude Code: ✗ Not authenticated")
|
|
55
|
+
click.echo(" Run 'gac auth claude-code login' to login")
|
|
56
|
+
|
|
57
|
+
qwen_token = token_store.get_token("qwen")
|
|
58
|
+
if qwen_token:
|
|
59
|
+
click.echo("Qwen: ✓ Authenticated")
|
|
60
|
+
else:
|
|
61
|
+
click.echo("Qwen: ✗ Not authenticated")
|
|
62
|
+
click.echo(" Run 'gac auth qwen login' to login")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Claude Code commands
|
|
66
|
+
@auth.group("claude-code")
|
|
67
|
+
def claude_code() -> None:
|
|
68
|
+
"""Manage Claude Code OAuth authentication.
|
|
69
|
+
|
|
70
|
+
Use browser-based authentication to log in to Claude Code.
|
|
71
|
+
"""
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@claude_code.command("login")
|
|
76
|
+
@click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
|
|
77
|
+
def claude_code_login(quiet: bool = False) -> None:
|
|
78
|
+
"""Login to Claude Code using OAuth.
|
|
79
|
+
|
|
80
|
+
Opens a browser to authenticate with Claude Code. The token is stored
|
|
81
|
+
securely in ~/.gac/oauth/claude-code.json.
|
|
82
|
+
"""
|
|
83
|
+
if not quiet:
|
|
84
|
+
setup_logging("INFO")
|
|
85
|
+
|
|
86
|
+
token_store = TokenStore()
|
|
87
|
+
existing_token = token_store.get_token("claude-code")
|
|
88
|
+
if existing_token:
|
|
89
|
+
if not quiet:
|
|
90
|
+
click.echo("✓ Already authenticated with Claude Code.")
|
|
91
|
+
if not click.confirm("Re-authenticate?"):
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if not quiet:
|
|
95
|
+
click.echo("🔐 Starting Claude Code OAuth authentication...")
|
|
96
|
+
click.echo(" Your browser will open automatically")
|
|
97
|
+
click.echo(" (Waiting up to 3 minutes for callback)")
|
|
98
|
+
click.echo()
|
|
99
|
+
|
|
100
|
+
success = authenticate_and_save(quiet=quiet)
|
|
101
|
+
|
|
102
|
+
if success:
|
|
103
|
+
if not quiet:
|
|
104
|
+
click.echo()
|
|
105
|
+
click.echo("✅ Claude Code authentication completed successfully!")
|
|
106
|
+
else:
|
|
107
|
+
click.echo("❌ Claude Code authentication failed.")
|
|
108
|
+
click.echo(" Please try again or check your network connection.")
|
|
109
|
+
raise click.ClickException("Claude Code authentication failed")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@claude_code.command("logout")
|
|
113
|
+
@click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
|
|
114
|
+
def claude_code_logout(quiet: bool = False) -> None:
|
|
115
|
+
"""Logout from Claude Code and remove stored tokens."""
|
|
116
|
+
token_store = TokenStore()
|
|
117
|
+
existing_token = token_store.get_token("claude-code")
|
|
118
|
+
|
|
119
|
+
if not existing_token:
|
|
120
|
+
if not quiet:
|
|
121
|
+
click.echo("Not currently authenticated with Claude Code.")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
remove_token()
|
|
126
|
+
if not quiet:
|
|
127
|
+
click.echo("✅ Successfully logged out from Claude Code.")
|
|
128
|
+
except Exception as e:
|
|
129
|
+
click.echo("❌ Failed to remove Claude Code token.")
|
|
130
|
+
raise click.ClickException("Claude Code logout failed") from e
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@claude_code.command("status")
|
|
134
|
+
def claude_code_status() -> None:
|
|
135
|
+
"""Check Claude Code authentication status."""
|
|
136
|
+
token_store = TokenStore()
|
|
137
|
+
token = token_store.get_token("claude-code")
|
|
138
|
+
|
|
139
|
+
if token:
|
|
140
|
+
click.echo("Claude Code Authentication Status: ✓ Authenticated")
|
|
141
|
+
else:
|
|
142
|
+
click.echo("Claude Code Authentication Status: ✗ Not authenticated")
|
|
143
|
+
click.echo("Run 'gac auth claude-code login' to authenticate.")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Qwen commands
|
|
147
|
+
@auth.group()
|
|
148
|
+
def qwen() -> None:
|
|
149
|
+
"""Manage Qwen OAuth authentication.
|
|
150
|
+
|
|
151
|
+
Use device flow authentication to log in to Qwen AI.
|
|
152
|
+
"""
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@qwen.command("login")
|
|
157
|
+
@click.option("--no-browser", is_flag=True, help="Don't automatically open browser")
|
|
158
|
+
@click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
|
|
159
|
+
def qwen_login(no_browser: bool = False, quiet: bool = False) -> None:
|
|
160
|
+
"""Login to Qwen using OAuth device flow.
|
|
161
|
+
|
|
162
|
+
Opens a browser to authenticate with Qwen. The token is stored
|
|
163
|
+
securely in ~/.gac/oauth/qwen.json.
|
|
164
|
+
"""
|
|
165
|
+
if not quiet:
|
|
166
|
+
setup_logging("INFO")
|
|
167
|
+
|
|
168
|
+
provider = QwenOAuthProvider()
|
|
169
|
+
|
|
170
|
+
if provider.is_authenticated():
|
|
171
|
+
if not quiet:
|
|
172
|
+
click.echo("✓ Already authenticated with Qwen.")
|
|
173
|
+
if not click.confirm("Re-authenticate?"):
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
provider.initiate_auth(open_browser=not no_browser)
|
|
178
|
+
if not quiet:
|
|
179
|
+
click.echo()
|
|
180
|
+
click.echo("✅ Qwen authentication completed successfully!")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
click.echo(f"❌ Qwen authentication failed: {e}")
|
|
183
|
+
raise click.ClickException("Qwen authentication failed") from None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@qwen.command("logout")
|
|
187
|
+
@click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
|
|
188
|
+
def qwen_logout(quiet: bool = False) -> None:
|
|
189
|
+
"""Logout from Qwen and remove stored tokens."""
|
|
190
|
+
provider = QwenOAuthProvider()
|
|
191
|
+
|
|
192
|
+
if not provider.is_authenticated():
|
|
193
|
+
if not quiet:
|
|
194
|
+
click.echo("Not currently authenticated with Qwen.")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
provider.logout()
|
|
198
|
+
if not quiet:
|
|
199
|
+
click.echo("✅ Successfully logged out from Qwen.")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@qwen.command("status")
|
|
203
|
+
def qwen_status() -> None:
|
|
204
|
+
"""Check Qwen authentication status."""
|
|
205
|
+
provider = QwenOAuthProvider()
|
|
206
|
+
token = provider.get_token()
|
|
207
|
+
|
|
208
|
+
if token:
|
|
209
|
+
click.echo("Qwen Authentication Status: ✓ Authenticated")
|
|
210
|
+
if token.get("resource_url"):
|
|
211
|
+
click.echo(f"API Endpoint: {token['resource_url']}")
|
|
212
|
+
else:
|
|
213
|
+
click.echo("Qwen Authentication Status: ✗ Not authenticated")
|
|
214
|
+
click.echo("Run 'gac auth qwen login' to authenticate.")
|